migrate_crm_product.py 15 KB

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