migrate_crm_product.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. #!/usr/bin/env python3
  2. """将 sx 库 CRM 产品主表迁入 shanxia2.0 crm_product。
  3. 数据源:仅 sx 库 crm_product(原 CRM 系统),不含地磅 cargos、报销 bx_*。
  4. 策略见 docs/data-migration/05-id-preservation-policy.md:id/category_id 原样 str(bigint)。
  5. 用法:
  6. cd backend
  7. venv/bin/python scripts/migrate_crm_product.py --dry-run --env-file .env.production
  8. venv/bin/python scripts/migrate_crm_product.py --env-file .env.production \\
  9. --user-mapping ../docs/data-migration/mappings/user_mapping_result.csv \\
  10. --mapping-out ../docs/data-migration/mappings/crm_product_id_mapping.csv
  11. """
  12. from __future__ import annotations
  13. import argparse
  14. import csv
  15. import os
  16. import sys
  17. from dataclasses import dataclass, field
  18. from datetime import datetime
  19. from decimal import Decimal
  20. from pathlib import Path
  21. from typing import Any, Dict, List, Optional, Tuple
  22. from urllib.parse import urlparse, urlunparse
  23. from sqlalchemy import create_engine, text
  24. from sqlalchemy.engine import Engine
  25. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  26. if str(BACKEND_ROOT) not in sys.path:
  27. sys.path.insert(0, str(BACKEND_ROOT))
  28. DEFAULT_TENANT_NO = "shanxia2.0"
  29. SOURCE_TABLE = "crm_product"
  30. TARGET_TABLE = "crm_product"
  31. CRM_ADMIN_SOURCE_USER_ID = 1
  32. @dataclass
  33. class MigrationStats:
  34. source_count: int = 0
  35. inserted: int = 0
  36. updated: int = 0
  37. skipped: int = 0
  38. warnings: List[str] = field(default_factory=list)
  39. def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
  40. if legacy_id is None or str(legacy_id).strip() == "":
  41. return None
  42. try:
  43. return str(int(legacy_id))
  44. except (TypeError, ValueError):
  45. return str(legacy_id).strip()
  46. def derive_source_url(target_url: str, source_db: str = "sx") -> str:
  47. return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
  48. def load_settings_env(env_file: Optional[str]) -> None:
  49. if env_file:
  50. os.environ["ENV_FILE"] = env_file
  51. from dotenv import load_dotenv
  52. candidates: List[Path] = []
  53. if env_file:
  54. p = Path(env_file)
  55. candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
  56. candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
  57. for path in candidates:
  58. if path.is_file():
  59. load_dotenv(path, override=True)
  60. break
  61. def load_runtime_settings():
  62. from app.core.config import Settings
  63. return Settings()
  64. def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
  65. crm_map: Dict[int, str] = {}
  66. admin_id: Optional[str] = None
  67. with path.open(encoding="utf-8-sig", newline="") as f:
  68. for row in csv.DictReader(f):
  69. new_id = (row.get("new_user_id") or "").strip()
  70. user_no = (row.get("user_no") or "").strip()
  71. crm_raw = (row.get("crm_user_id") or "").strip()
  72. if user_no == "admin" and new_id:
  73. admin_id = new_id
  74. if crm_raw and new_id:
  75. try:
  76. crm_map[int(crm_raw)] = new_id
  77. except ValueError:
  78. pass
  79. return crm_map, admin_id
  80. def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
  81. with target_engine.connect() as conn:
  82. row = conn.execute(
  83. text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
  84. ).first()
  85. return str(row[0]) if row and row[0] else None
  86. def resolve_portal_user_id(
  87. source_user_id: Any,
  88. crm_user_map: Dict[int, str],
  89. admin_id: Optional[str],
  90. stats: MigrationStats,
  91. label: str,
  92. ) -> Optional[str]:
  93. if source_user_id is None or str(source_user_id).strip() == "":
  94. return None
  95. try:
  96. uid = int(source_user_id)
  97. except (TypeError, ValueError):
  98. stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
  99. return None
  100. if uid == CRM_ADMIN_SOURCE_USER_ID:
  101. return admin_id
  102. mapped = crm_user_map.get(uid)
  103. if not mapped:
  104. stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
  105. return mapped
  106. def fetch_source_rows(engine: Engine, include_deleted: bool) -> List[dict]:
  107. where = "" if include_deleted else "WHERE del_flag = 0"
  108. sql = f"SELECT * FROM `{SOURCE_TABLE}` {where} ORDER BY id ASC"
  109. with engine.connect() as conn:
  110. return [dict(r._mapping) for r in conn.execute(text(sql))]
  111. def count_target_rows(engine: Engine, tenant_no: str) -> int:
  112. with engine.connect() as conn:
  113. return int(
  114. conn.execute(
  115. text(
  116. f"SELECT COUNT(*) FROM `{TARGET_TABLE}` "
  117. "WHERE tenant_no=:t AND del_flag=0"
  118. ),
  119. {"t": tenant_no},
  120. ).scalar_one()
  121. )
  122. def _dec(value: Any) -> Optional[Decimal]:
  123. if value is None or value == "":
  124. return None
  125. return Decimal(str(value))
  126. def transform_row(
  127. row: dict,
  128. tenant_no: str,
  129. crm_user_map: Dict[int, str],
  130. admin_id: Optional[str],
  131. stats: MigrationStats,
  132. ) -> dict:
  133. new_id = legacy_id_to_str(row["id"])
  134. cat_id = legacy_id_to_str(row.get("category_id"))
  135. status = str(row.get("status") or "on").strip()
  136. if status not in ("on", "off"):
  137. status = "on"
  138. unit = (row.get("unit") or "").strip() or None
  139. return {
  140. "id": new_id,
  141. "product_no": (row.get("product_no") or "")[:50],
  142. "product_name": (row.get("product_name") or "")[:200],
  143. "category_id": cat_id,
  144. "category_name": (row.get("category_name") or "")[:100] or None,
  145. "model": (row.get("model") or "")[:100] or None,
  146. "unit": (unit[:20] if unit else None),
  147. "barcode": (row.get("barcode") or "")[:100] or None,
  148. "price": _dec(row.get("price")),
  149. "cost_price": _dec(row.get("cost")),
  150. "stock": int(row.get("stock") or 0),
  151. "low_stock": int(row.get("low_stock") or 10),
  152. "tax_rate": _dec(row.get("tax_rate")) or Decimal("13.00"),
  153. "is_toxic": 0,
  154. "is_explosive": 0,
  155. "status": status[:3],
  156. "description": row.get("description"),
  157. "images": (row.get("images") or "")[:1000] or None,
  158. "sale_count": int(row.get("sale_count") or 0),
  159. "tenant_no": tenant_no,
  160. "order_num": 0,
  161. "remark": (row.get("remark") or "")[:512] or None,
  162. "create_by": resolve_portal_user_id(
  163. row.get("create_by"), crm_user_map, admin_id, stats, f"product#{new_id} create_by"
  164. ),
  165. "create_dept_id": "",
  166. "create_time": row.get("create_time") or datetime.now(),
  167. "update_by": resolve_portal_user_id(
  168. row.get("update_by"), crm_user_map, admin_id, stats, f"product#{new_id} update_by"
  169. ),
  170. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  171. "del_flag": int(row.get("del_flag") or 0),
  172. }
  173. INSERT_SQL = text(
  174. f"""
  175. INSERT INTO `{TARGET_TABLE}` (
  176. id, product_no, product_name, category_id, category_name, model, unit, barcode,
  177. price, cost_price, stock, low_stock, tax_rate, is_toxic, is_explosive, status,
  178. description, images, sale_count, tenant_no, order_num, remark,
  179. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  180. ) VALUES (
  181. :id, :product_no, :product_name, :category_id, :category_name, :model, :unit, :barcode,
  182. :price, :cost_price, :stock, :low_stock, :tax_rate, :is_toxic, :is_explosive, :status,
  183. :description, :images, :sale_count, :tenant_no, :order_num, :remark,
  184. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  185. )
  186. """
  187. )
  188. UPDATE_SQL = text(
  189. f"""
  190. UPDATE `{TARGET_TABLE}` SET
  191. product_no=:product_no, product_name=:product_name, category_id=:category_id,
  192. category_name=:category_name, model=:model, unit=:unit, barcode=:barcode,
  193. price=:price, cost_price=:cost_price, stock=:stock, low_stock=:low_stock,
  194. tax_rate=:tax_rate, status=:status, description=:description, images=:images,
  195. sale_count=:sale_count, tenant_no=:tenant_no, order_num=:order_num, remark=:remark,
  196. create_by=:create_by, update_by=:update_by, update_time=:update_time, del_flag=:del_flag
  197. WHERE id=:id
  198. """
  199. )
  200. def target_row_exists(conn, row_id: str) -> bool:
  201. return int(
  202. conn.execute(text(f"SELECT COUNT(*) FROM `{TARGET_TABLE}` WHERE id=:id"), {"id": row_id}).scalar_one()
  203. ) > 0
  204. def write_mapping_csv(rows: List[dict], path: Path) -> None:
  205. path.parent.mkdir(parents=True, exist_ok=True)
  206. migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  207. with path.open("w", encoding="utf-8", newline="") as f:
  208. w = csv.DictWriter(
  209. f,
  210. fieldnames=["source_id", "new_id", "product_no", "product_name", "category_id", "migrated_at"],
  211. )
  212. w.writeheader()
  213. for row in rows:
  214. sid = legacy_id_to_str(row["id"])
  215. w.writerow(
  216. {
  217. "source_id": sid,
  218. "new_id": sid,
  219. "product_no": row.get("product_no") or "",
  220. "product_name": row.get("product_name") or "",
  221. "category_id": legacy_id_to_str(row.get("category_id")) or "",
  222. "migrated_at": migrated_at,
  223. }
  224. )
  225. def print_report(stats: MigrationStats, dry_run: bool) -> None:
  226. mode = "DRY-RUN" if dry_run else "COMMITTED"
  227. print(f"\n=== CRM 产品迁移 [{mode}] ===")
  228. print(f"源表行数: {stats.source_count}")
  229. print(f"插入: {stats.inserted} 更新: {stats.updated} 跳过: {stats.skipped}")
  230. if stats.warnings:
  231. print(f"警告 ({len(stats.warnings)}):")
  232. for w in stats.warnings[:15]:
  233. print(f" - {w}")
  234. if len(stats.warnings) > 15:
  235. print(f" ... 另有 {len(stats.warnings) - 15} 条")
  236. def run_migration(
  237. source_engine: Engine,
  238. target_engine: Engine,
  239. *,
  240. tenant_no: str,
  241. user_mapping_path: Path,
  242. mapping_out: Optional[Path],
  243. dry_run: bool,
  244. update_existing: bool,
  245. include_deleted: bool,
  246. ) -> MigrationStats:
  247. stats = MigrationStats()
  248. crm_user_map, admin_id = load_user_mapping(user_mapping_path)
  249. if not admin_id:
  250. admin_id = fetch_portal_admin_id(target_engine)
  251. source_rows = fetch_source_rows(source_engine, include_deleted)
  252. stats.source_count = len(source_rows)
  253. payloads = [transform_row(r, tenant_no, crm_user_map, admin_id, stats) for r in source_rows]
  254. if dry_run:
  255. print("\n预览(前 5 条):")
  256. for p in payloads[:5]:
  257. print(f" id={p['id']} no={p['product_no']} name={p['product_name']!r} cat={p['category_id']}")
  258. stats.inserted = len(payloads)
  259. print_report(stats, dry_run=True)
  260. return stats
  261. with target_engine.begin() as conn:
  262. for payload in payloads:
  263. rid = payload["id"]
  264. if target_row_exists(conn, rid):
  265. if update_existing:
  266. conn.execute(UPDATE_SQL, payload)
  267. stats.updated += 1
  268. else:
  269. stats.skipped += 1
  270. else:
  271. conn.execute(INSERT_SQL, payload)
  272. stats.inserted += 1
  273. if mapping_out:
  274. write_mapping_csv(source_rows, mapping_out)
  275. print(f"\nID 映射已写入: {mapping_out}")
  276. print_report(stats, dry_run=False)
  277. return stats
  278. def main() -> int:
  279. parser = argparse.ArgumentParser(description="Migrate CRM products from sx to portal")
  280. parser.add_argument("--source-url", default="")
  281. parser.add_argument("--target-url", default="")
  282. parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
  283. parser.add_argument(
  284. "--user-mapping",
  285. default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
  286. )
  287. parser.add_argument("--dry-run", action="store_true")
  288. parser.add_argument("--update", action="store_true")
  289. parser.add_argument("--include-deleted", action="store_true")
  290. parser.add_argument("--mapping-out", default="")
  291. parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
  292. args = parser.parse_args()
  293. load_settings_env(args.env_file or None)
  294. settings = load_runtime_settings()
  295. target_url = args.target_url or settings.DATABASE_URL
  296. source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
  297. print(f"源库: {urlparse(source_url).path}")
  298. print(f"目标库: {urlparse(target_url).path}")
  299. print("数据源: sx.crm_product(原 CRM,不含地磅 cargos)")
  300. print(f"目标表现有产品: {count_target_rows(create_engine(target_url), args.tenant_no)}")
  301. run_migration(
  302. create_engine(source_url, pool_pre_ping=True),
  303. create_engine(target_url, pool_pre_ping=True),
  304. tenant_no=args.tenant_no,
  305. user_mapping_path=Path(args.user_mapping),
  306. mapping_out=Path(args.mapping_out) if args.mapping_out else None,
  307. dry_run=args.dry_run,
  308. update_existing=args.update,
  309. include_deleted=args.include_deleted,
  310. )
  311. return 0
  312. if __name__ == "__main__":
  313. raise SystemExit(main())