Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/scripts/migrate_invoices_complete.py
2026-04-14 19:34:55 +03:30

1122 lines
39 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
اسکریپت انتقال فاکتورهای فروش، خرید، برگشت از فروش و برگشت از خرید از hesabixOld به hesabixpy
این اسکریپت:
- فاکتورهای sell, buy, rfsell, rfbuy را منتقل می‌کند
- از سرویس invoice_service.create_invoice() استفاده می‌کند
- ثبت‌های حسابداری به صورت خودکار انجام می‌شود
"""
import sys
import os
import argparse
from typing import List, Dict, Any, Optional, Tuple
from datetime import datetime, date
from decimal import Decimal, InvalidOperation
import re
# اضافه کردن مسیر پروژه به sys.path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, Session
def convert_timestamp_to_datetime(timestamp_str: str | None) -> datetime | None:
"""تبدیل timestamp string به datetime"""
if not timestamp_str:
return None
try:
if timestamp_str.strip().isdigit():
return datetime.fromtimestamp(int(timestamp_str))
except (ValueError, TypeError, OSError):
pass
return None
def convert_persian_date_to_date(date_str: str | None) -> date | None:
"""تبدیل تاریخ شمسی string به date"""
if not date_str or not date_str.strip():
return None
date_str = date_str.strip()
# فرمت: 1403/10/05
match = re.match(r'^(\d{4})/(\d{1,2})/(\d{1,2})$', date_str)
if match:
year, month, day = match.groups()
try:
import jdatetime
jd = jdatetime.date(int(year), int(month), int(day))
gd = jd.togregorian()
return gd
except ImportError:
# تبدیل تقریبی
gregorian_year = int(year) + 621
try:
return date(gregorian_year, int(month), int(day))
except (ValueError, TypeError):
pass
return None
def convert_amount(amount_str: str | None) -> Decimal:
"""تبدیل amount از string به decimal"""
if not amount_str:
return Decimal(0)
try:
cleaned = str(amount_str).strip().replace(',', '').replace(' ', '').replace('،', '')
if not cleaned or cleaned == '0' or cleaned == '':
return Decimal(0)
return Decimal(cleaned)
except (ValueError, InvalidOperation, TypeError):
return Decimal(0)
class InvoiceMigration:
def __init__(self, old_db_name: str = "hesabixOld", new_db_name: str = "hesabixpy",
db_user: str = "root", db_password: str = "136431",
db_host: str = "localhost", db_port: int = 3306):
"""ایجاد اتصال به هر دو دیتابیس"""
self.old_db_name = old_db_name
self.new_db_name = new_db_name
# اتصال به دیتابیس قدیمی
old_dsn = f"mysql+pymysql://{db_user}:{db_password}@{db_host}:{db_port}/{old_db_name}"
old_engine = create_engine(
old_dsn,
echo=False,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
connect_args={
"connect_timeout": 60,
"read_timeout": 300,
"write_timeout": 300,
"charset": "utf8mb4",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO'"
}
)
self.old_db = sessionmaker(bind=old_engine)()
# اتصال به دیتابیس جدید
new_dsn = f"mysql+pymysql://{db_user}:{db_password}@{db_host}:{db_port}/{new_db_name}"
new_engine = create_engine(
new_dsn,
echo=False,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
connect_args={
"connect_timeout": 60,
"read_timeout": 300,
"write_timeout": 300,
"charset": "utf8mb4",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO'"
}
)
self.new_db = sessionmaker(bind=new_engine)()
# Cache ها برای بهینه‌سازی
self.default_currency_cache: Dict[int, int] = {} # {business_id: currency_id}
self.default_fiscal_year_cache: Dict[int, int] = {} # {business_id: fiscal_year_id}
self.migrated_old_doc_ids: set[int] = set() # Cache برای migrated documents
self.migrated_codes_cache: Dict[Tuple[int, str], bool] = {} # {(business_id, code): True}
# آمار
self.stats = {
"invoices_processed": 0,
"invoices_migrated": 0,
"invoices_skipped": 0,
"errors": 0,
"error_details": []
}
def create_business_id_mapping(self) -> Dict[int, int]:
"""ایجاد mapping بین business_id قدیمی و جدید با case-insensitive"""
query = text("""
SELECT
old_business.id as old_business_id,
new_business.id as new_business_id
FROM hesabixOld.business old_business
INNER JOIN hesabixOld.user old_user ON old_business.owner_id = old_user.id
INNER JOIN hesabixpy.users new_user ON (
(old_user.email IS NOT NULL AND new_user.email IS NOT NULL AND LOWER(TRIM(old_user.email)) = LOWER(TRIM(new_user.email))) OR
(old_user.mobile IS NOT NULL AND new_user.mobile IS NOT NULL AND REPLACE(REPLACE(REPLACE(old_user.mobile, '+', ''), ' ', ''), '-', '') = REPLACE(REPLACE(REPLACE(new_user.mobile, '+', ''), ' ', ''), '-', ''))
)
INNER JOIN hesabixpy.businesses new_business ON (
new_business.owner_id = new_user.id
AND new_business.name = old_business.name
)
WHERE old_user.active = 1
""")
results = self.old_db.execute(query).fetchall()
mapping = {}
for row in results:
mapping[row.old_business_id] = row.new_business_id
print(f"✅ Business ID mapping ایجاد شد: {len(mapping)} کسب و کار")
return mapping
def create_user_id_mapping(self) -> Dict[int, int]:
"""ایجاد mapping بین user_id قدیمی و جدید با case-insensitive"""
query = text("""
SELECT
old.id as old_user_id,
new.id as new_user_id
FROM hesabixOld.user old
INNER JOIN hesabixpy.users new ON (
(old.email IS NOT NULL AND new.email IS NOT NULL AND LOWER(TRIM(old.email)) = LOWER(TRIM(new.email))) OR
(old.mobile IS NOT NULL AND new.mobile IS NOT NULL AND REPLACE(REPLACE(REPLACE(old.mobile, '+', ''), ' ', ''), '-', '') = REPLACE(REPLACE(REPLACE(new.mobile, '+', ''), ' ', ''), '-', ''))
)
WHERE old.active = 1
""")
results = self.old_db.execute(query).fetchall()
mapping = {}
for row in results:
mapping[row.old_user_id] = row.new_user_id
print(f"✅ User ID mapping ایجاد شد: {len(mapping)} کاربر")
return mapping
def create_fiscal_year_mapping(self, business_id_mapping: Dict[int, int]) -> Dict[Tuple[int, int], int]:
"""ایجاد mapping بین fiscal_year_id قدیمی و جدید"""
# mapping: (old_business_id, old_year_id) -> new_year_id
mapping = {}
for old_bid, new_bid in business_id_mapping.items():
# در جدول قدیمی start و end به صورت timestamp (varchar) هستند
# باید آنها را به date تبدیل کنیم
query = text("""
SELECT
old.id as old_year_id,
new.id as new_year_id
FROM {}.year old
INNER JOIN {}.fiscal_years new ON (
new.business_id = :new_bid
AND new.start_date = DATE(FROM_UNIXTIME(CAST(old.start AS UNSIGNED)))
AND new.end_date = DATE(FROM_UNIXTIME(CAST(old.end AS UNSIGNED)))
)
WHERE old.bid_id = :old_bid
""".format(self.old_db_name, self.new_db_name))
try:
results = self.old_db.execute(query, {
"old_bid": old_bid,
"new_bid": new_bid
}).fetchall()
for row in results:
mapping[(old_bid, row.old_year_id)] = row.new_year_id
except Exception as e:
# اگر تبدیل تاریخ با مشکل مواجه شد، فقط بر اساس business_id و label (یا label) جستجو کنیم
query2 = text("""
SELECT
old.id as old_year_id,
new.id as new_year_id
FROM {}.year old
INNER JOIN {}.fiscal_years new ON (
new.business_id = :new_bid
AND new.title = old.label
)
WHERE old.bid_id = :old_bid
LIMIT 1
""".format(self.old_db_name, self.new_db_name))
try:
results = self.old_db.execute(query2, {
"old_bid": old_bid,
"new_bid": new_bid
}).fetchall()
for row in results:
mapping[(old_bid, row.old_year_id)] = row.new_year_id
except Exception:
pass # اگر پیدا نشد، skip می‌کنیم
print(f"✅ Fiscal Year mapping ایجاد شد: {len(mapping)} سال مالی")
return mapping
def create_currency_mapping(self) -> Dict[int, int]:
"""ایجاد mapping بین currency_id قدیمی و جدید"""
query = text("""
SELECT
old.id as old_currency_id,
new.id as new_currency_id
FROM {}.money old
INNER JOIN {}.currencies new ON (
BINARY new.code = BINARY old.name
AND BINARY new.symbol = BINARY old.symbol
)
""".format(self.old_db_name, self.new_db_name))
results = self.old_db.execute(query).fetchall()
mapping = {}
for row in results:
mapping[row.old_currency_id] = row.new_currency_id
print(f"✅ Currency mapping ایجاد شد: {len(mapping)} ارز")
return mapping
def create_person_id_mapping(self, business_id_mapping: Dict[int, int]) -> Dict[Tuple[int, int], int]:
"""ایجاد mapping بین person_id قدیمی و جدید"""
# mapping: (old_business_id, old_person_id) -> new_person_id
mapping = {}
for old_bid, new_bid in business_id_mapping.items():
query = text("""
SELECT
old.id as old_person_id,
new.id as new_person_id
FROM {}.person old
INNER JOIN {}.persons new ON (
new.business_id = :new_bid
AND BINARY new.code = BINARY old.code
)
WHERE old.bid_id = :old_bid
""".format(self.old_db_name, self.new_db_name))
results = self.old_db.execute(query, {
"old_bid": old_bid,
"new_bid": new_bid
}).fetchall()
for row in results:
mapping[(old_bid, row.old_person_id)] = row.new_person_id
print(f"✅ Person ID mapping ایجاد شد: {len(mapping)} شخص")
return mapping
def create_product_id_mapping(self, business_id_mapping: Dict[int, int]) -> Dict[Tuple[int, int], int]:
"""ایجاد mapping بین product_id قدیمی و جدید"""
# mapping: (old_business_id, old_commodity_id) -> new_product_id
mapping = {}
for old_bid, new_bid in business_id_mapping.items():
query = text("""
SELECT
old.id as old_commodity_id,
new.id as new_product_id
FROM {}.commodity old
INNER JOIN {}.products new ON (
new.business_id = :new_bid
AND BINARY new.code = BINARY old.code
)
WHERE old.bid_id = :old_bid
""".format(self.old_db_name, self.new_db_name))
results = self.old_db.execute(query, {
"old_bid": old_bid,
"new_bid": new_bid
}).fetchall()
for row in results:
mapping[(old_bid, row.old_commodity_id)] = row.new_product_id
print(f"✅ Product ID mapping ایجاد شد: {len(mapping)} کالا")
return mapping
def ensure_policy_exists(self, business_id: int):
"""اطمینان از وجود سیاست درآمدزایی برای کسب‌وکار"""
# بررسی وجود سیاست
query = text("""
SELECT COUNT(*) FROM document_usage_policies
WHERE business_id = :business_id AND is_active = 1
""")
result = self.new_db.execute(query, {"business_id": business_id}).scalar()
if result == 0:
# ایجاد سیاست free
query_insert = text("""
INSERT INTO document_usage_policies
(business_id, policy_type, title, priority, is_active, config, created_at, updated_at)
VALUES
(:business_id, 'free', 'سیاست رایگان برای انتقال', 100, 1, NULL, NOW(), NOW())
""")
self.new_db.execute(query_insert, {"business_id": business_id})
self.new_db.commit()
def get_old_invoices(self, invoice_type: str, start_id: Optional[int] = None,
limit: Optional[int] = None, offset: Optional[int] = None,
business_ids: Optional[List[int]] = None) -> List[Dict[str, Any]]:
"""دریافت فاکتورها از دیتابیس قدیمی"""
query_str = """
SELECT
d.id, d.bid_id, d.submitter_id, d.year_id, d.money_id,
d.date_submit, d.date, d.type, d.code, d.des, d.amount,
d.is_preview, d.is_approved, d.project_id, d.tax_percent,
d.discount_type, d.discount_percent
FROM {}.hesabdari_doc d
WHERE d.type = :invoice_type
""".format(self.old_db_name)
params = {"invoice_type": invoice_type}
if business_ids:
placeholders = ','.join([f':bid_{i}' for i in range(len(business_ids))])
query_str += f" AND d.bid_id IN ({placeholders})"
for i, bid in enumerate(business_ids):
params[f'bid_{i}'] = bid
elif start_id:
query_str += " AND d.id >= :start_id"
params["start_id"] = start_id
# فقط اسناد تایید شده
query_str += " AND d.is_approved = 1"
query_str += " ORDER BY d.bid_id, d.id ASC"
if limit:
query_str += " LIMIT :limit"
params["limit"] = limit
# فقط زمانی از offset استفاده می‌کنیم که start_id استفاده نشده باشد
if offset and offset > 0 and not start_id:
query_str += " OFFSET :offset"
params["offset"] = offset
results = self.old_db.execute(text(query_str), params).fetchall()
invoices = []
for row in results:
invoices.append({
"id": row[0],
"business_id": row[1],
"submitter_id": row[2],
"year_id": row[3],
"currency_id": row[4],
"date_submit": row[5],
"date": row[6],
"type": row[7],
"code": row[8],
"description": row[9],
"amount": row[10],
"is_preview": row[11],
"is_approved": row[12],
"project_id": row[13],
"tax_percent": row[14],
"discount_type": row[15],
"discount_percent": row[16]
})
return invoices
def get_invoice_rows(self, doc_id: int) -> List[Dict[str, Any]]:
"""دریافت سطرهای فاکتور از دیتابیس قدیمی"""
query = text("""
SELECT
r.id, r.ref_id, r.person_id, r.commodity_id, r.bs, r.bd, r.des,
r.commdity_count, r.discount, r.tax, r.bank_id, r.cashdesk_id,
r.salary_id, r.cheque_id, t.name as account_name, t.type as account_type
FROM {}.hesabdari_row r
LEFT JOIN {}.hesabdari_table t ON r.ref_id = t.id
WHERE r.doc_id = :doc_id
ORDER BY r.id ASC
""".format(self.old_db_name, self.old_db_name))
results = self.old_db.execute(query, {"doc_id": doc_id}).fetchall()
def get_invoice_rows_bulk(self, doc_ids: List[int]) -> Dict[int, List[Dict[str, Any]]]:
"""دریافت سطرهای چند فاکتور به صورت bulk"""
if not doc_ids:
return {}
placeholders = ','.join([f':doc_id_{i}' for i in range(len(doc_ids))])
query = text(f"""
SELECT
r.doc_id, r.id, r.ref_id, r.person_id, r.commodity_id, r.bs, r.bd, r.des,
r.commdity_count, r.discount, r.tax, r.bank_id, r.cashdesk_id,
r.salary_id, r.cheque_id, t.name as account_name, t.type as account_type
FROM {self.old_db_name}.hesabdari_row r
LEFT JOIN {self.old_db_name}.hesabdari_table t ON r.ref_id = t.id
WHERE r.doc_id IN ({placeholders})
ORDER BY r.doc_id, r.id ASC
""")
params = {f'doc_id_{i}': doc_id for i, doc_id in enumerate(doc_ids)}
results = self.old_db.execute(query, params).fetchall()
rows_dict: Dict[int, List[Dict[str, Any]]] = {}
for row in results:
doc_id = row[0]
if doc_id not in rows_dict:
rows_dict[doc_id] = []
rows_dict[doc_id].append({
"id": row[1],
"ref_id": row[2],
"person_id": row[3],
"commodity_id": row[4],
"debit": row[5],
"credit": row[6],
"description": row[7],
"quantity": row[8],
"discount": row[9],
"tax": row[10],
"bank_id": row[11],
"cashdesk_id": row[12],
"salary_id": row[13],
"cheque_id": row[14],
"account_name": row[15],
"account_type": row[16]
})
return rows_dict
def _load_migrated_documents_cache(self, business_id_mapping: Dict[int, int]):
"""بارگذاری cache اسناد منتقل شده از دیتابیس"""
if not business_id_mapping:
return
new_business_ids = list(business_id_mapping.values())
if not new_business_ids:
return
placeholders = ','.join([f':bid_{i}' for i in range(len(new_business_ids))])
# Load migrated old_document_ids
query1 = text(f"""
SELECT JSON_EXTRACT(extra_info, '$.old_document_id') as old_doc_id
FROM documents
WHERE business_id IN ({placeholders})
AND extra_info LIKE '%migration%'
AND JSON_EXTRACT(extra_info, '$.old_document_id') IS NOT NULL
""")
params1 = {f'bid_{i}': bid for i, bid in enumerate(new_business_ids)}
results1 = self.new_db.execute(query1, params1).fetchall()
for row in results1:
try:
old_doc_id = int(row[0])
self.migrated_old_doc_ids.add(old_doc_id)
except (ValueError, TypeError):
pass
# Load migrated codes
query2 = text(f"""
SELECT business_id, code
FROM documents
WHERE business_id IN ({placeholders})
AND code IS NOT NULL
""")
results2 = self.new_db.execute(query2, params1).fetchall()
for row in results2:
business_id = row[0]
code = row[1]
if business_id and code:
self.migrated_codes_cache[(business_id, code)] = True
print(f" 📋 Cache بارگذاری شد: {len(self.migrated_old_doc_ids)} سند منتقل شده، {len(self.migrated_codes_cache)} کد")
def _batch_duplicate_check(self, invoices: List[Dict[str, Any]], business_id_mapping: Dict[int, int]):
"""بررسی تکراری بودن فاکتورها به صورت batch"""
if not invoices:
return
# جمع‌آوری business_id و code ها
business_codes: Dict[int, List[str]] = {}
for invoice in invoices:
old_bid = invoice.get('business_id')
new_bid = business_id_mapping.get(old_bid)
code = invoice.get('code')
if new_bid and code:
if new_bid not in business_codes:
business_codes[new_bid] = []
business_codes[new_bid].append(code)
# Query batch برای یافتن کدهای تکراری
for new_bid, codes in business_codes.items():
if not codes:
continue
placeholders = ','.join([f':code_{i}' for i in range(len(codes))])
query = text(f"""
SELECT code
FROM documents
WHERE business_id = :business_id
AND BINARY code IN ({placeholders})
""")
params = {"business_id": new_bid}
for i, code in enumerate(codes):
params[f'code_{i}'] = code
results = self.new_db.execute(query, params).fetchall()
# اضافه کردن به cache
for row in results:
if row[0]:
self.migrated_codes_cache[(new_bid, row[0])] = True
def extract_invoice_lines(self, rows: List[Dict[str, Any]], invoice_type: str,
business_id_mapping: Dict[int, int],
product_mapping: Dict[Tuple[int, int], int]) -> List[Dict[str, Any]]:
"""استخراج و تبدیل سطرهای فاکتور به فرمت جدید"""
lines = []
# شناسایی ref_id برای کالاها (معمولاً 59 برای فروش کالا)
# و ref_id برای شخص (معمولاً 3 برای حساب‌های دریافتی)
for row in rows:
ref_id = row.get("ref_id")
commodity_id = row.get("commodity_id")
person_id = row.get("person_id")
# اگر commodity_id وجود دارد، این یک سطر کالا است
if commodity_id:
# پیدا کردن business_id قدیمی از rows (از اولین row می‌توانیم استفاده کنیم)
# یا باید از invoice استفاده کنیم - در اینجا فرض می‌کنیم که در invoice موجود است
# محاسبه unit_price
debit = convert_amount(row.get("debit"))
credit = convert_amount(row.get("credit"))
quantity = convert_amount(row.get("quantity") or 1)
if quantity == 0:
quantity = Decimal(1)
# unit_price = (بدهکار یا بستانکار) / quantity
unit_price = (debit + credit) / quantity if quantity > 0 else Decimal(0)
# تخفیف
discount_amount = convert_amount(row.get("discount") or 0)
line_discount = discount_amount
# مالیات
tax_amount = convert_amount(row.get("tax") or 0)
# محاسبه tax_percent
taxable_amount = (quantity * unit_price) - line_discount
tax_percent = Decimal(0)
if taxable_amount > 0 and tax_amount > 0:
tax_percent = (tax_amount / taxable_amount) * 100
line = {
"product_id": None, # باید بعداً set شود
"quantity": float(quantity),
"extra_info": {
"unit_price": float(unit_price),
"line_discount": float(line_discount),
"tax_amount": float(tax_amount),
"tax_rate": float(tax_percent)
},
"description": row.get("description"),
"_old_commodity_id": commodity_id, # برای mapping بعدی
"_old_business_id": None # باید set شود
}
lines.append(line)
return lines
def migrate_invoice(self, old_invoice: Dict[str, Any],
business_id_mapping: Dict[int, int],
user_id_mapping: Dict[int, int],
fiscal_year_mapping: Dict[Tuple[int, int], int],
currency_mapping: Dict[int, int],
person_mapping: Dict[Tuple[int, int], int],
product_mapping: Dict[Tuple[int, int], int],
dry_run: bool = False) -> Optional[int]:
"""انتقال یک فاکتور"""
try:
# تعریف متغیرهای اولیه
old_code = old_invoice.get('code')
old_document_id = old_invoice.get('id')
# نگاشت شناسه‌ها
old_business_id = old_invoice.get('business_id')
new_business_id = business_id_mapping.get(old_business_id)
if not new_business_id:
self.stats["invoices_skipped"] += 1
return None
# بررسی اینکه آیا این سند قبلاً منتقل شده یا نه (با cache)
if old_document_id in self.migrated_old_doc_ids:
# سند قبلاً منتقل شده، skip می‌کنیم
return None
old_user_id = old_invoice.get('submitter_id')
new_user_id = user_id_mapping.get(old_user_id)
if not new_user_id:
self.stats["invoices_skipped"] += 1
return None
old_currency_id = old_invoice.get('currency_id')
new_currency_id = currency_mapping.get(old_currency_id)
# اگر ارز پیدا نشد، از ارز پیش‌فرض کسب‌وکار استفاده می‌کنیم (با cache)
if not new_currency_id:
if new_business_id in self.default_currency_cache:
new_currency_id = self.default_currency_cache[new_business_id]
else:
query = text("""
SELECT default_currency_id FROM businesses
WHERE id = :business_id
""")
result = self.new_db.execute(query, {"business_id": new_business_id}).first()
if result and result[0]:
new_currency_id = result[0]
self.default_currency_cache[new_business_id] = new_currency_id
else:
if self.stats["invoices_skipped"] < 5:
print(f" ⚠️ فاکتور {old_invoice.get('code')} رد شد: ارز پیدا نشد و ارز پیش‌فرض کسب‌وکار هم نیست")
self.stats["invoices_skipped"] += 1
return None
old_year_id = old_invoice.get('year_id')
fiscal_year_id = fiscal_year_mapping.get((old_business_id, old_year_id))
# اگر سال مالی پیدا نشد، از سال مالی پیش‌فرض (جاری) کسب‌وکار استفاده می‌کنیم (با cache)
if not fiscal_year_id:
if new_business_id in self.default_fiscal_year_cache:
fiscal_year_id = self.default_fiscal_year_cache[new_business_id]
else:
# ابتدا سعی می‌کنیم سال مالی جاری (is_last = True) را پیدا کنیم
query = text("""
SELECT id FROM fiscal_years
WHERE business_id = :business_id AND is_last = 1
LIMIT 1
""")
result = self.new_db.execute(query, {"business_id": new_business_id}).first()
if result:
fiscal_year_id = result[0]
self.default_fiscal_year_cache[new_business_id] = fiscal_year_id
else:
# اگر سال مالی جاری پیدا نشد، آخرین سال مالی را برمی‌گردانیم
query2 = text("""
SELECT id FROM fiscal_years
WHERE business_id = :business_id
ORDER BY start_date DESC
LIMIT 1
""")
result2 = self.new_db.execute(query2, {"business_id": new_business_id}).first()
if result2:
fiscal_year_id = result2[0]
self.default_fiscal_year_cache[new_business_id] = fiscal_year_id
else:
if self.stats["invoices_skipped"] < 5:
print(f" ⚠️ فاکتور {old_invoice.get('code')} رد شد: سال مالی پیدا نشد (old_year_id: {old_year_id})")
self.stats["invoices_skipped"] += 1
return None
# تبدیل تاریخ
doc_date = convert_persian_date_to_date(old_invoice.get('date'))
if not doc_date:
date_submit = convert_timestamp_to_datetime(old_invoice.get('date_submit'))
if date_submit:
doc_date = date_submit.date()
else:
doc_date = date.today()
# تشخیص نوع فاکتور
old_type = old_invoice.get('type')
type_mapping = {
"sell": "invoice_sales",
"buy": "invoice_purchase",
"rfsell": "invoice_sales_return",
"rfbuy": "invoice_purchase_return"
}
new_invoice_type = type_mapping.get(old_type)
if not new_invoice_type:
self.stats["invoices_skipped"] += 1
return None
# دریافت سطرهای فاکتور (استفاده از cache اگر موجود باشد)
if '_cached_rows' in old_invoice:
old_rows = old_invoice['_cached_rows']
else:
old_rows = self.get_invoice_rows(old_invoice.get('id'))
if not old_rows:
self.stats["invoices_skipped"] += 1
return None
# استخراج person_id از سطرها
person_id = None
person_ids_found = []
for row in old_rows:
row_person_id = row.get('person_id')
if row_person_id is not None:
try:
row_person_id_int = int(row_person_id)
person_ids_found.append(row_person_id_int)
mapped_person_id = person_mapping.get((old_business_id, row_person_id_int))
if mapped_person_id:
person_id = mapped_person_id
break
except (ValueError, TypeError):
pass
# اگر person_id پیدا نشد، لاگ می‌کنیم
if not person_id and person_ids_found:
if self.stats["errors"] < 3:
print(f" ⚠️ فاکتور {old_code}: person_id در mapping پیدا نشد (old_person_ids: {person_ids_found[:3]}, old_business_id: {old_business_id})")
if not person_id:
# برای فاکتورهای فروش/خرید person_id الزامی است
if new_invoice_type in ["invoice_sales", "invoice_purchase",
"invoice_sales_return", "invoice_purchase_return"]:
# لاگ برای دیباگ
if self.stats["invoices_skipped"] < 5:
old_person_ids_str = ", ".join([str(pid) for pid in person_ids_found[:3]])
print(f" ⚠️ فاکتور {old_code} رد شد: person_id پیدا نشد (doc_id: {old_invoice.get('id')}, old_person_ids: {old_person_ids_str})")
self.stats["invoices_skipped"] += 1
return None
# استخراج سطرهای کالا
invoice_lines = []
skipped_commodities = []
for row in old_rows:
commodity_id = row.get('commodity_id')
if commodity_id:
product_id = product_mapping.get((old_business_id, commodity_id))
if not product_id:
skipped_commodities.append(commodity_id)
continue
# محاسبه مقادیر
debit = convert_amount(row.get('debit'))
credit = convert_amount(row.get('credit'))
quantity = convert_amount(row.get('quantity') or 1)
if quantity == 0:
quantity = Decimal(1)
# unit_price
unit_price = (debit + credit) / quantity if quantity > 0 else Decimal(0)
# تخفیف و مالیات
discount_amount = convert_amount(row.get('discount') or 0)
tax_amount = convert_amount(row.get('tax') or 0)
# محاسبه tax_percent
taxable_amount = (quantity * unit_price) - discount_amount
tax_rate = Decimal(0)
if taxable_amount > 0 and tax_amount > 0:
tax_rate = (tax_amount / taxable_amount) * 100
line = {
"product_id": product_id,
"quantity": float(quantity),
"extra_info": {
"unit_price": float(unit_price),
"line_discount": float(discount_amount),
"tax_amount": float(tax_amount),
"tax_rate": float(tax_rate)
},
"description": row.get('description')
}
invoice_lines.append(line)
if not invoice_lines:
# لاگ برای دیباگ
if self.stats["invoices_skipped"] < 5:
skipped_str = ", ".join([str(cid) for cid in skipped_commodities[:3]])
print(f" ⚠️ فاکتور {old_code} رد شد: هیچ سطر کالایی پیدا نشد (doc_id: {old_invoice.get('id')}, skipped_commodities: {skipped_str})")
self.stats["invoices_skipped"] += 1
return None
if dry_run:
self.stats["invoices_migrated"] += 1
return None
# استفاده از سرویس create_invoice
try:
# Import سرویس
from app.services.invoice_service import create_invoice
# اطمینان از وجود سیاست درآمدزایی
self.ensure_policy_exists(new_business_id)
# ایجاد فاکتور
# person_id باید در extra_info قرار بگیرد یا مستقیماً در data
extra_info_data = {
"source": "migration",
"old_document_id": old_invoice.get('id'),
"old_code": old_code,
"person_id": int(person_id) # اضافه کردن person_id به extra_info
}
result = create_invoice(
db=self.new_db,
business_id=new_business_id,
user_id=new_user_id,
data={
"invoice_type": new_invoice_type,
"document_date": doc_date.isoformat(),
"currency_id": new_currency_id,
"person_id": int(person_id), # همچنین در data هم قرار می‌دهیم
"lines": invoice_lines,
"description": old_invoice.get('description'),
"is_proforma": old_invoice.get('is_preview') == 1,
"extra_info": extra_info_data
}
)
# commit در batch انجام می‌شود (در run_migration)
# فقط ID را برمی‌گردانیم
new_invoice_id = result.get("id")
if new_invoice_id:
# اضافه کردن به cache
self.migrated_old_doc_ids.add(old_document_id)
if old_code:
cache_key = (new_business_id, old_code)
self.migrated_codes_cache[cache_key] = True
self.stats["invoices_migrated"] += 1
return new_invoice_id
except Exception as e:
# rollback و ایجاد session جدید
try:
self.new_db.rollback()
except Exception:
pass
# ایجاد session جدید برای ادامه کار
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = f"mysql+pymysql://root:136431@localhost:3306/{self.new_db_name}"
engine = create_engine(dsn, echo=False, pool_pre_ping=True)
self.new_db = sessionmaker(bind=engine)()
except Exception:
pass
self.stats["errors"] += 1
# استخراج جزئیات خطا از ApiError
error_msg = str(e)
error_code = None
if hasattr(e, 'code'):
error_code = e.code
if hasattr(e, 'message'):
error_msg = e.message
# بررسی خطای duplicate
if "Duplicate entry" in error_msg or "1062" in error_msg:
error_msg = "کد تکراری در warehouse_documents"
error_detail = {
"old_invoice_id": old_invoice.get('id'),
"old_code": old_code,
"error": error_msg,
"error_code": error_code
}
# فقط خطاهای اولیه را در لاگ نمایش می‌دهیم
if len(self.stats["error_details"]) < 10:
short_error = error_msg[:200] if len(error_msg) > 200 else error_msg
print(f" ❌ خطا در فاکتور {old_code} (ID: {old_invoice.get('id')}): {short_error}")
self.stats["error_details"].append(error_detail)
return None
except Exception as e:
self.stats["errors"] += 1
import traceback
error_trace = traceback.format_exc()
# فقط خطاهای اولیه را در لاگ نمایش می‌دهیم
if len(self.stats["error_details"]) < 10:
print(f" ❌ خطای عمومی در فاکتور {old_invoice.get('id', 'unknown')}: {str(e)}")
if "PERSON_REQUIRED" in str(e) or "person_id" in str(e).lower():
print(f" -> مشکل در person_id")
self.stats["error_details"].append({
"old_invoice_id": old_invoice.get('id'),
"error": str(e),
"traceback": error_trace[:500] if len(error_trace) > 500 else error_trace
})
return None
def run_migration(self, invoice_types: List[str] = None, dry_run: bool = False,
batch_size: int = 100, start_id: Optional[int] = None,
limit: Optional[int] = None, business_ids: Optional[List[int]] = None):
"""اجرای انتقال"""
if invoice_types is None:
invoice_types = ["sell", "buy", "rfsell", "rfbuy"]
print("=" * 100)
print("🚀 شروع انتقال فاکتورها")
print("=" * 100)
if dry_run:
print("\n⚠️ حالت DRY RUN - هیچ تغییری اعمال نمی‌شود")
# ایجاد mapping ها
print("\n📊 ایجاد mapping ها...")
business_id_mapping = self.create_business_id_mapping()
if not business_id_mapping:
print("❌ هیچ mapping کسب و کاری یافت نشد!")
return
user_id_mapping = self.create_user_id_mapping()
fiscal_year_mapping = self.create_fiscal_year_mapping(business_id_mapping)
currency_mapping = self.create_currency_mapping()
person_mapping = self.create_person_id_mapping(business_id_mapping)
product_mapping = self.create_product_id_mapping(business_id_mapping)
# انتقال برای هر نوع
for invoice_type in invoice_types:
print(f"\n{'='*100}")
print(f"📦 انتقال فاکتورهای نوع: {invoice_type}")
print(f"{'='*100}")
offset = 0
batch_commit_size = 10 # Commit هر 10 فاکتور
processed_in_batch = 0
# Load migrated documents cache در ابتدای هر نوع
self._load_migrated_documents_cache(business_id_mapping)
while True:
invoices = self.get_old_invoices(
invoice_type=invoice_type,
start_id=start_id if offset == 0 else None,
limit=batch_size,
offset=offset if offset > 0 else None,
business_ids=list(business_id_mapping.keys()) if not business_ids else business_ids
)
if not invoices:
break
print(f"\n📄 پردازش batch {offset // batch_size + 1}: {len(invoices)} فاکتور")
# Bulk load invoice rows
doc_ids = [inv.get('id') for inv in invoices]
rows_dict = self.get_invoice_rows_bulk(doc_ids) if not dry_run else {}
# Bulk duplicate check
if not dry_run:
self._batch_duplicate_check(invoices, business_id_mapping)
for invoice in invoices:
self.stats["invoices_processed"] += 1
# استفاده از rows از cache
if not dry_run and invoice.get('id') in rows_dict:
invoice['_cached_rows'] = rows_dict[invoice.get('id')]
if self.stats["invoices_processed"] % 10 == 0:
print(f" پردازش شده: {self.stats['invoices_processed']}, منتقل شده: {self.stats['invoices_migrated']}, رد شده: {self.stats['invoices_skipped']}, خطا: {self.stats['errors']}")
result = self.migrate_invoice(
old_invoice=invoice,
business_id_mapping=business_id_mapping,
user_id_mapping=user_id_mapping,
fiscal_year_mapping=fiscal_year_mapping,
currency_mapping=currency_mapping,
person_mapping=person_mapping,
product_mapping=product_mapping,
dry_run=dry_run
)
if result and not dry_run:
processed_in_batch += 1
# Batch commit
if processed_in_batch >= batch_commit_size:
try:
self.new_db.commit()
processed_in_batch = 0
except Exception as e:
self.new_db.rollback()
print(f" ⚠️ خطا در commit batch: {str(e)}")
# Commit نهایی batch
if not dry_run and processed_in_batch > 0:
try:
self.new_db.commit()
processed_in_batch = 0
except Exception as e:
self.new_db.rollback()
print(f" ⚠️ خطا در commit نهایی batch: {str(e)}")
offset += batch_size
if limit and self.stats["invoices_processed"] >= limit:
break
if len(invoices) < batch_size:
break
# نمایش آمار نهایی
print("\n" + "=" * 100)
print("📊 آمار نهایی")
print("=" * 100)
print(f"✅ فاکتورهای پردازش شده: {self.stats['invoices_processed']:,}")
print(f"✅ فاکتورهای منتقل شده: {self.stats['invoices_migrated']:,}")
print(f"⚠️ فاکتورهای رد شده: {self.stats['invoices_skipped']:,}")
print(f"❌ خطاها: {self.stats['errors']:,}")
if self.stats['error_details']:
print(f"\n⚠️ جزئیات خطاها (نمونه 10 مورد اول):")
for error in self.stats['error_details'][:10]:
print(f" - Invoice ID: {error.get('old_invoice_id')}, Code: {error.get('old_code')}")
print(f" Error: {error.get('error')}")
def close(self):
"""بستن اتصالات"""
self.old_db.close()
self.new_db.close()
def main():
parser = argparse.ArgumentParser(description="انتقال فاکتورهای فروش، خرید و برگشت")
parser.add_argument("--dry-run", action="store_true", help="اجرای تست بدون تغییر در دیتابیس")
parser.add_argument("--batch-size", type=int, default=100, help="تعداد فاکتورها در هر batch")
parser.add_argument("--start-id", type=int, help="شروع از شناسه خاص")
parser.add_argument("--limit", type=int, help="محدود کردن تعداد فاکتورها")
parser.add_argument("--invoice-types", nargs="+",
choices=["sell", "buy", "rfsell", "rfbuy"],
default=["sell", "buy", "rfsell", "rfbuy"],
help="انواع فاکتور برای انتقال")
parser.add_argument("--old-db", default="hesabixOld", help="نام دیتابیس قدیمی")
parser.add_argument("--new-db", default="hesabixpy", help="نام دیتابیس جدید")
parser.add_argument("--db-user", default="root", help="نام کاربری دیتابیس")
parser.add_argument("--db-password", default="136431", help="رمز عبور دیتابیس")
parser.add_argument("--db-host", default="localhost", help="آدرس دیتابیس")
parser.add_argument("--db-port", type=int, default=3306, help="پورت دیتابیس")
args = parser.parse_args()
migration = InvoiceMigration(
old_db_name=args.old_db,
new_db_name=args.new_db,
db_user=args.db_user,
db_password=args.db_password,
db_host=args.db_host,
db_port=args.db_port
)
try:
migration.run_migration(
invoice_types=args.invoice_types,
dry_run=args.dry_run,
batch_size=args.batch_size,
start_id=args.start_id,
limit=args.limit
)
finally:
migration.close()
if __name__ == "__main__":
main()