arc/hesabixAPI/scripts/migrate_products_complete.py
2025-12-09 02:51:12 +00:00

552 lines
18 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
این اسکریپت:
- دسته‌بندی‌ها را ایجاد/نگاشت می‌کند
- واحدها را نگاشت می‌کند
- کالا/خدمات را منتقل می‌کند
"""
import sys
import os
import argparse
from typing import List, Dict, Any, Optional, Tuple
from datetime import datetime
from decimal import Decimal, InvalidOperation
import json
# اضافه کردن مسیر پروژه به 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_price(price_str: str | None) -> Decimal | None:
"""تبدیل قیمت از varchar به decimal"""
if not price_str or not price_str.strip():
return None
try:
# حذف فاصله و کاما
cleaned = price_str.strip().replace(',', '').replace(' ', '').replace('،', '')
if not cleaned or cleaned == '0' or cleaned == '':
return None
return Decimal(cleaned)
except (ValueError, InvalidOperation, TypeError):
return None
def convert_khadamat_to_item_type(khadamat: int | None) -> str:
"""تبدیل khadamat به item_type"""
if khadamat == 1:
return "خدمت"
return "کالا" # پیش‌فرض برای 0 و NULL
def convert_order_point(order_point: str | None) -> int | None:
"""تبدیل order_point به integer (محدود به محدوده integer)"""
if not order_point or not order_point.strip():
return None
try:
value = int(float(order_point.strip()))
# محدود کردن به محدوده integer (MAX_INT = 2147483647)
if value > 2147483647:
return None
if value < -2147483648:
return None
return value
except (ValueError, TypeError, OverflowError):
return None
def convert_track_inventory(check: int | None) -> bool:
"""تبدیل commodity_count_check به track_inventory"""
return bool(check) if check is not None else False
def convert_taxable(without_tax: int | None) -> Tuple[bool, bool]:
"""تبدیل without_tax به is_sales_taxable و is_purchase_taxable"""
if without_tax == 1:
return (False, False) # بدون مالیات
return (True, True) # با مالیات (پیش‌فرض)
class ProductMigration:
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)
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)
self.new_db = sessionmaker(bind=new_engine)()
# آمار
self.stats = {
"products_processed": 0,
"products_migrated": 0,
"products_skipped": 0,
"categories_created": 0,
"categories_mapped": 0,
"errors": 0,
"error_details": []
}
def create_business_id_mapping(self) -> Dict[int, int]:
"""ایجاد mapping بین business_id قدیمی و جدید"""
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 BINARY old_user.email = BINARY new_user.email) OR
(old_user.mobile IS NOT NULL AND new_user.mobile IS NOT NULL AND BINARY old_user.mobile = BINARY 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_unit_mapping(self) -> Dict[int, str]:
"""ایجاد mapping بین unit_id و unit name"""
query = text("SELECT id, name FROM hesabixOld.commodity_unit")
results = self.old_db.execute(query).fetchall()
mapping = {}
for row in results:
mapping[row.id] = row.name
print(f"✅ Unit mapping ایجاد شد: {len(mapping)} واحد")
return mapping
def get_or_create_category(self, old_cat_id: int | None, old_cat_name: str | None,
new_business_id: int, category_mapping: Dict[Tuple[int, int], int]) -> int | None:
"""
دریافت یا ایجاد دسته‌بندی
Args:
old_cat_id: شناسه دسته‌بندی قدیمی
old_cat_name: نام دسته‌بندی قدیمی
new_business_id: شناسه کسب و کار جدید
category_mapping: mapping {(old_business_id, old_cat_id): new_cat_id}
Returns:
شناسه دسته‌بندی جدید یا None
"""
if not old_cat_id or not old_cat_name:
return None
# بررسی mapping
key = (new_business_id, old_cat_id)
if key in category_mapping:
self.stats["categories_mapped"] += 1
return category_mapping[key]
# جستجو در دیتابیس جدید
query = text("""
SELECT id FROM categories
WHERE business_id = :business_id
AND JSON_EXTRACT(title_translations, '$.fa') = :name
LIMIT 1
""")
result = self.new_db.execute(query, {
"business_id": new_business_id,
"name": old_cat_name
}).fetchone()
if result:
category_mapping[key] = result[0]
self.stats["categories_mapped"] += 1
return result[0]
# ایجاد دسته‌بندی جدید
title_translations = json.dumps({"fa": old_cat_name, "en": old_cat_name})
query = text("""
INSERT INTO categories (
business_id, title_translations,
sort_order, is_active,
created_at, updated_at
) VALUES (
:business_id, :title_translations,
0, 1,
:created_at, :updated_at
)
""")
self.new_db.execute(query, {
"business_id": new_business_id,
"title_translations": title_translations,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
})
self.new_db.commit()
# دریافت شناسه جدید
query = text("""
SELECT id FROM categories
WHERE business_id = :business_id
AND JSON_EXTRACT(title_translations, '$.fa') = :name
LIMIT 1
""")
result = self.new_db.execute(query, {
"business_id": new_business_id,
"name": old_cat_name
}).fetchone()
if result:
category_mapping[key] = result[0]
self.stats["categories_created"] += 1
return result[0]
return None
def migrate_product(self, old_product: Dict[str, Any], business_id_mapping: Dict[int, int],
unit_mapping: Dict[int, str], category_mapping: Dict[Tuple[int, int], int]) -> Optional[int]:
"""انتقال یک کالا/خدمت"""
try:
# نگاشت business_id
old_business_id = old_product.get('bid_id')
new_business_id = business_id_mapping.get(old_business_id)
if not new_business_id:
self.stats["products_skipped"] += 1
return None
# بررسی وجود در دیتابیس جدید
query = text("""
SELECT COUNT(*) FROM products
WHERE business_id = :business_id AND code = :code
""")
result = self.new_db.execute(query, {
"business_id": new_business_id,
"code": str(old_product.get('code', ''))
}).scalar()
if result > 0:
self.stats["products_skipped"] += 1
return None
# تبدیل داده‌ها
item_type = convert_khadamat_to_item_type(old_product.get('khadamat'))
# تبدیل قیمت‌ها
base_purchase_price = convert_price(old_product.get('price_buy'))
base_sales_price = convert_price(old_product.get('price_sell'))
# نگاشت category_id
old_cat_id = old_product.get('cat_id')
old_cat_name = old_product.get('cat_name')
category_id = self.get_or_create_category(
old_cat_id, old_cat_name, new_business_id, category_mapping
)
# نگاشت unit_id به main_unit
old_unit_id = old_product.get('unit_id')
main_unit = unit_mapping.get(old_unit_id) if old_unit_id else None
# تبدیل سایر فیلدها
reorder_point = convert_order_point(old_product.get('order_point'))
track_inventory = convert_track_inventory(old_product.get('commodity_count_check'))
min_order_qty = convert_order_point(old_product.get('min_order_count'))
lead_time_days = convert_order_point(old_product.get('day_loading'))
# تبدیل مالیات
is_sales_taxable, is_purchase_taxable = convert_taxable(old_product.get('without_tax'))
# درج کالا/خدمت
query = text("""
INSERT INTO products (
business_id, item_type, code, name, description,
category_id, main_unit,
base_sales_price, base_purchase_price,
track_inventory, reorder_point, min_order_qty, lead_time_days,
is_sales_taxable, is_purchase_taxable,
tax_code, inventory_mode,
track_serial, track_barcode,
created_at, updated_at
) VALUES (
:business_id, :item_type, :code, :name, :description,
:category_id, :main_unit,
:base_sales_price, :base_purchase_price,
:track_inventory, :reorder_point, :min_order_qty, :lead_time_days,
:is_sales_taxable, :is_purchase_taxable,
:tax_code, :inventory_mode,
:track_serial, :track_barcode,
:created_at, :updated_at
)
""")
# بررسی وجود بارکد
barcodes = old_product.get('barcodes')
track_barcode = bool(barcodes and barcodes.strip())
self.new_db.execute(query, {
"business_id": new_business_id,
"item_type": item_type,
"code": str(old_product.get('code', '')),
"name": old_product.get('name', ''),
"description": old_product.get('des'),
"category_id": category_id,
"main_unit": main_unit,
"base_sales_price": base_sales_price,
"base_purchase_price": base_purchase_price,
"track_inventory": track_inventory,
"reorder_point": reorder_point,
"min_order_qty": min_order_qty,
"lead_time_days": lead_time_days,
"is_sales_taxable": is_sales_taxable,
"is_purchase_taxable": is_purchase_taxable,
"tax_code": old_product.get('tax_code'),
"inventory_mode": "bulk", # پیش‌فرض
"track_serial": False, # پیش‌فرض
"track_barcode": track_barcode,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
})
self.new_db.commit()
# دریافت new_product_id
query = text("""
SELECT id FROM products
WHERE business_id = :business_id AND code = :code
LIMIT 1
""")
result = self.new_db.execute(query, {
"business_id": new_business_id,
"code": str(old_product.get('code', ''))
}).fetchone()
if result:
self.stats["products_migrated"] += 1
return result[0]
else:
raise Exception("Failed to get new product ID after insert")
except Exception as e:
self.new_db.rollback()
self.stats["errors"] += 1
self.stats["error_details"].append({
"old_product_id": old_product.get('id'),
"old_business_id": old_product.get('bid_id'),
"code": old_product.get('code'),
"error": str(e)
})
return None
def get_old_products(self, start_id: Optional[int] = None, limit: Optional[int] = None,
business_ids: Optional[List[int]] = None) -> List[Dict[str, Any]]:
"""دریافت کالا/خدمات از دیتابیس قدیمی"""
query = f"""
SELECT
c.id, c.bid_id, c.name, c.code, c.des,
c.price_buy, c.price_sell, c.khadamat,
c.cat_id, cat.name as cat_name,
c.unit_id, c.order_point, c.commodity_count_check,
c.min_order_count, c.day_loading, c.without_tax,
c.tax_code, c.barcodes
FROM {self.old_db_name}.commodity c
LEFT JOIN {self.old_db_name}.commodity_cat cat ON c.cat_id = cat.id AND c.bid_id = cat.bid_id
WHERE 1=1
"""
params = {}
if business_ids:
placeholders = ','.join([f':bid_{i}' for i in range(len(business_ids))])
query += f" AND c.bid_id IN ({placeholders})"
for i, bid in enumerate(business_ids):
params[f'bid_{i}'] = bid
elif start_id:
query += " AND c.id >= :start_id"
params["start_id"] = start_id
query += " ORDER BY c.bid_id, c.id ASC"
if limit:
query += " LIMIT :limit"
params["limit"] = limit
results = self.old_db.execute(text(query), params).fetchall()
products = []
for row in results:
products.append({
"id": row[0],
"bid_id": row[1],
"name": row[2],
"code": row[3],
"des": row[4],
"price_buy": row[5],
"price_sell": row[6],
"khadamat": row[7],
"cat_id": row[8],
"cat_name": row[9],
"unit_id": row[10],
"order_point": row[11],
"commodity_count_check": row[12],
"min_order_count": row[13],
"day_loading": row[14],
"without_tax": row[15],
"tax_code": row[16],
"barcodes": row[17]
})
return products
def run_migration(self, dry_run: bool = False, batch_size: int = 500,
start_id: Optional[int] = None, limit: Optional[int] = None):
"""اجرای انتقال"""
print(f"{'='*60}")
print(f"شروع انتقال کالا و خدمات")
print(f"{'='*60}")
print(f"دیتابیس قدیمی: {self.old_db_name}")
print(f"دیتابیس جدید: {self.new_db_name}")
print(f"حالت تست (dry-run): {dry_run}")
print(f"اندازه batch: {batch_size}")
if start_id:
print(f"شروع از ID: {start_id}")
if limit:
print(f"محدودیت تعداد: {limit}")
print(f"{'='*60}\n")
# ایجاد mapping ها
business_id_mapping = self.create_business_id_mapping()
unit_mapping = self.create_unit_mapping()
# دریافت کسب و کارهای منتقل شده
old_business_ids = list(business_id_mapping.keys())
if not old_business_ids:
print("هیچ کسب و کاری برای انتقال یافت نشد.")
return
# دریافت کالا/خدمات
old_products = self.get_old_products(
start_id=start_id,
limit=limit,
business_ids=old_business_ids
)
total_products = len(old_products)
print(f"تعداد کالا/خدمات برای انتقال: {total_products}\n")
if total_products == 0:
print("هیچ کالا/خدماتی برای انتقال یافت نشد.")
return
# نگاشت category برای هر کسب و کار
category_mapping: Dict[Tuple[int, int], int] = {} # {(new_business_id, old_cat_id): new_cat_id}
# پردازش batch به batch
for i in range(0, total_products, batch_size):
batch = old_products[i:i+batch_size]
batch_num = (i // batch_size) + 1
total_batches = (total_products + batch_size - 1) // batch_size
print(f"\nپردازش batch {batch_num}/{total_batches} ({len(batch)} کالا/خدمت)...")
for old_product in batch:
self.stats["products_processed"] += 1
if not dry_run:
self.migrate_product(
old_product, business_id_mapping, unit_mapping, category_mapping
)
else:
# در حالت dry-run فقط بررسی می‌کنیم
old_business_id = old_product.get('bid_id')
if old_business_id in business_id_mapping:
self.stats["products_migrated"] += 1
else:
self.stats["products_skipped"] += 1
if self.stats["products_processed"] % 50 == 0:
print(f" پردازش شده: {self.stats['products_processed']}/{total_products}", end='\r')
print(f"\n Batch {batch_num} تکمیل شد")
# نمایش آمار نهایی
print(f"\n{'='*60}")
print("آمار نهایی:")
print(f"{'='*60}")
print(f"کالا/خدمات:")
print(f" پردازش شده: {self.stats['products_processed']}")
print(f" منتقل شده: {self.stats['products_migrated']}")
print(f" رد شده: {self.stats['products_skipped']}")
print(f"\nدسته‌بندی‌ها:")
print(f" ایجاد شده: {self.stats['categories_created']}")
print(f" نگاشت شده: {self.stats['categories_mapped']}")
print(f"\nخطاها: {self.stats['errors']}")
if self.stats['error_details']:
print(f"\nجزئیات خطاها:")
for error in self.stats['error_details'][:10]:
print(f" - Product ID {error.get('old_product_id')} (Code: {error.get('code')}): {error.get('error')}")
if len(self.stats['error_details']) > 10:
print(f" ... و {len(self.stats['error_details']) - 10} خطای دیگر")
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=500, help="تعداد کالا/خدمات در هر batch")
parser.add_argument("--start-id", type=int, help="شروع از شناسه خاص")
parser.add_argument("--limit", type=int, 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 = ProductMigration(
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(
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()