migrate_crm_payment_invoice.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. #!/usr/bin/env python3
  2. """将 sx 库 CRM 回款/开票相关表迁入 shanxia2.0。
  3. 门户侧当前仅有 crm_invoice_info 表;源库回款/发票主表及明细均为空,跳过。
  4. 实际迁移:crm_invoice_info(客户开票信息子表)。
  5. 用法:
  6. cd backend
  7. venv/bin/python scripts/migrate_crm_payment_invoice.py --dry-run --env-file .env.production
  8. venv/bin/python scripts/migrate_crm_payment_invoice.py --env-file .env.production
  9. """
  10. from __future__ import annotations
  11. import argparse
  12. import csv
  13. import os
  14. import sys
  15. from dataclasses import dataclass, field
  16. from datetime import datetime
  17. from pathlib import Path
  18. from typing import Any, Dict, List, Optional, Tuple
  19. from urllib.parse import urlparse, urlunparse
  20. from sqlalchemy import create_engine, text
  21. from sqlalchemy.engine import Engine
  22. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  23. if str(BACKEND_ROOT) not in sys.path:
  24. sys.path.insert(0, str(BACKEND_ROOT))
  25. DEFAULT_TENANT_NO = "shanxia2.0"
  26. CRM_ADMIN_SOURCE_USER_ID = 1
  27. # 源库存在但门户暂无对应业务表,或源库无数据
  28. SKIPPED_TABLES = (
  29. "crm_payment",
  30. "crm_payment_plan",
  31. "crm_payment_collection",
  32. "crm_invoice",
  33. "crm_invoice_item",
  34. )
  35. @dataclass
  36. class StepStats:
  37. source_count: int = 0
  38. inserted: int = 0
  39. skipped: int = 0
  40. @dataclass
  41. class MigrationStats:
  42. steps: Dict[str, StepStats] = field(default_factory=dict)
  43. warnings: List[str] = field(default_factory=list)
  44. def step(self, name: str) -> StepStats:
  45. if name not in self.steps:
  46. self.steps[name] = StepStats()
  47. return self.steps[name]
  48. def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
  49. if legacy_id is None or str(legacy_id).strip() == "":
  50. return None
  51. try:
  52. return str(int(legacy_id))
  53. except (TypeError, ValueError):
  54. return str(legacy_id).strip()
  55. def derive_source_url(target_url: str, source_db: str = "sx") -> str:
  56. return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
  57. def load_settings_env(env_file: Optional[str]) -> None:
  58. if env_file:
  59. os.environ["ENV_FILE"] = env_file
  60. from dotenv import load_dotenv
  61. candidates: List[Path] = []
  62. if env_file:
  63. p = Path(env_file)
  64. candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
  65. candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
  66. for path in candidates:
  67. if path.is_file():
  68. load_dotenv(path, override=True)
  69. break
  70. def load_runtime_settings():
  71. from app.core.config import Settings
  72. return Settings()
  73. def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
  74. crm_map: Dict[int, str] = {}
  75. admin_id: Optional[str] = None
  76. with path.open(encoding="utf-8-sig", newline="") as f:
  77. for row in csv.DictReader(f):
  78. new_id = (row.get("new_user_id") or "").strip()
  79. user_no = (row.get("user_no") or "").strip()
  80. crm_raw = (row.get("crm_user_id") or "").strip()
  81. if user_no == "admin" and new_id:
  82. admin_id = new_id
  83. if crm_raw and new_id:
  84. try:
  85. crm_map[int(crm_raw)] = new_id
  86. except ValueError:
  87. pass
  88. return crm_map, admin_id
  89. def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
  90. with target_engine.connect() as conn:
  91. row = conn.execute(
  92. text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
  93. ).first()
  94. return str(row[0]) if row and row[0] else None
  95. def resolve_portal_user_id(
  96. source_user_id: Any,
  97. crm_user_map: Dict[int, str],
  98. admin_id: Optional[str],
  99. stats: MigrationStats,
  100. label: str,
  101. ) -> Optional[str]:
  102. if source_user_id is None or str(source_user_id).strip() == "":
  103. return None
  104. try:
  105. uid = int(source_user_id)
  106. except (TypeError, ValueError):
  107. stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
  108. return None
  109. if uid == CRM_ADMIN_SOURCE_USER_ID:
  110. return admin_id
  111. mapped = crm_user_map.get(uid)
  112. if not mapped:
  113. stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
  114. return mapped
  115. def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
  116. if value is None:
  117. return None
  118. s = str(value).strip()
  119. if not s:
  120. return None
  121. return s[:max_len] if max_len else s
  122. def fetch_source_rows(engine: Engine, table: str, include_deleted: bool) -> List[dict]:
  123. where = "" if include_deleted else "WHERE del_flag = 0"
  124. sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
  125. with engine.connect() as conn:
  126. return [dict(r._mapping) for r in conn.execute(text(sql))]
  127. def count_source_rows(engine: Engine, table: str) -> int:
  128. with engine.connect() as conn:
  129. try:
  130. return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE del_flag=0")).scalar_one())
  131. except Exception:
  132. return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}`")).scalar_one())
  133. def target_table_exists(engine: Engine, table: str) -> bool:
  134. with engine.connect() as conn:
  135. row = conn.execute(text("SHOW TABLES LIKE :t"), {"t": table}).first()
  136. return row is not None
  137. def target_row_exists(conn, table: str, row_id: str) -> bool:
  138. return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
  139. def transform_invoice_info(
  140. row: dict,
  141. tenant_no: str,
  142. crm_user_map: Dict[int, str],
  143. admin_id: Optional[str],
  144. stats: MigrationStats,
  145. ) -> Optional[dict]:
  146. rid = legacy_id_to_str(row["id"])
  147. label = f"invoice_info#{rid}"
  148. customer_id = legacy_id_to_str(row.get("customer_id"))
  149. if not customer_id:
  150. stats.warnings.append(f"{label}: 缺少 customer_id,已跳过")
  151. return None
  152. company_name = _str_or_none(row.get("company_name"), 200)
  153. if not company_name:
  154. stats.warnings.append(f"{label}: company_name 为空,已跳过")
  155. return None
  156. def map_user(field_val: Any) -> Optional[str]:
  157. return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
  158. is_default = (str(row.get("is_default") or "0").strip() or "0")[:1]
  159. if is_default not in ("0", "1"):
  160. is_default = "1" if is_default in ("true", "True", "Y", "y") else "0"
  161. return {
  162. "id": rid,
  163. "customer_id": customer_id,
  164. "company_name": company_name,
  165. "tax_no": None,
  166. "company_address": _str_or_none(row.get("company_address"), 500),
  167. "legal_representative": _str_or_none(row.get("legal_representative"), 100),
  168. "agent": _str_or_none(row.get("agent"), 100),
  169. "phone_fax": _str_or_none(row.get("phone_fax"), 100),
  170. "bank_name": _str_or_none(row.get("bank_name"), 200),
  171. "bank_account": _str_or_none(row.get("bank_account"), 100),
  172. "is_default": is_default,
  173. "tenant_no": tenant_no,
  174. "order_num": 0,
  175. "remark": _str_or_none(row.get("remark"), 512),
  176. "create_by": map_user(row.get("create_by")),
  177. "create_dept_id": "",
  178. "create_time": row.get("create_time") or datetime.now(),
  179. "update_by": map_user(row.get("update_by")),
  180. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  181. "del_flag": int(row.get("del_flag") or 0),
  182. }
  183. INSERT_INVOICE_INFO_SQL = text(
  184. """
  185. INSERT INTO crm_invoice_info (
  186. id, customer_id, company_name, tax_no, company_address, legal_representative,
  187. agent, phone_fax, bank_name, bank_account, is_default,
  188. tenant_no, order_num, remark, create_by, create_dept_id,
  189. create_time, update_by, update_time, del_flag
  190. ) VALUES (
  191. :id, :customer_id, :company_name, :tax_no, :company_address, :legal_representative,
  192. :agent, :phone_fax, :bank_name, :bank_account, :is_default,
  193. :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
  194. :create_time, :update_by, :update_time, :del_flag
  195. )
  196. """
  197. )
  198. def write_mapping_csv(rows: List[dict], path: Path) -> None:
  199. path.parent.mkdir(parents=True, exist_ok=True)
  200. migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  201. with path.open("w", encoding="utf-8", newline="") as f:
  202. w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
  203. w.writeheader()
  204. for row in rows:
  205. sid = legacy_id_to_str(row["id"])
  206. w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
  207. def report_skipped_tables(source_engine: Engine, target_engine: Engine, stats: MigrationStats) -> None:
  208. print("\n>> 跳过表(门户无表或源库无数据):")
  209. for table in SKIPPED_TABLES:
  210. source_count = count_source_rows(source_engine, table)
  211. target_exists = target_table_exists(target_engine, table)
  212. step = stats.step(table)
  213. step.source_count = source_count
  214. if source_count == 0:
  215. print(f" {table}: 源库 0 行,跳过")
  216. elif not target_exists:
  217. print(f" {table}: 源库 {source_count} 行,门户无对应表,跳过")
  218. stats.warnings.append(f"{table}: 源库有 {source_count} 行但门户无表,未迁移")
  219. else:
  220. print(f" {table}: 源={source_count} 目标表存在(本脚本未实现,需单独处理)")
  221. def migrate_invoice_info(
  222. source_engine: Engine,
  223. target_engine: Engine,
  224. *,
  225. tenant_no: str,
  226. crm_user_map: Dict[int, str],
  227. admin_id: Optional[str],
  228. stats: MigrationStats,
  229. dry_run: bool,
  230. mapping_dir: Path,
  231. ) -> None:
  232. table = "crm_invoice_info"
  233. step = stats.step(table)
  234. source_rows = fetch_source_rows(source_engine, table, include_deleted=False)
  235. step.source_count = len(source_rows)
  236. payloads: List[dict] = []
  237. for row in source_rows:
  238. payload = transform_invoice_info(row, tenant_no, crm_user_map, admin_id, stats)
  239. if payload:
  240. payloads.append(payload)
  241. else:
  242. step.skipped += 1
  243. if dry_run:
  244. step.inserted = len(payloads)
  245. print(f"{table}: 源={step.source_count} 可插入={step.inserted} 跳过={step.skipped}")
  246. return
  247. with target_engine.begin() as conn:
  248. for payload in payloads:
  249. rid = payload["id"]
  250. if target_row_exists(conn, table, rid):
  251. step.skipped += 1
  252. else:
  253. conn.execute(INSERT_INVOICE_INFO_SQL, payload)
  254. step.inserted += 1
  255. write_mapping_csv(source_rows, mapping_dir / "crm_invoice_info_id_mapping.csv")
  256. print(f"{table}: 源={step.source_count} 插入={step.inserted} 跳过={step.skipped}")
  257. def print_report(stats: MigrationStats, dry_run: bool) -> None:
  258. mode = "DRY-RUN" if dry_run else "COMMITTED"
  259. print(f"\n=== CRM 回款/开票迁移 [{mode}] ===")
  260. for name in (*SKIPPED_TABLES, "crm_invoice_info"):
  261. s = stats.step(name)
  262. if s.source_count or s.inserted or s.skipped:
  263. print(f"{name}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
  264. if stats.warnings:
  265. print(f"警告 ({len(stats.warnings)}):")
  266. for w in stats.warnings[:20]:
  267. print(f" - {w}")
  268. if len(stats.warnings) > 20:
  269. print(f" ... 另有 {len(stats.warnings) - 20} 条")
  270. def run_migration(
  271. source_engine: Engine,
  272. target_engine: Engine,
  273. *,
  274. tenant_no: str,
  275. user_mapping_path: Path,
  276. mapping_dir: Path,
  277. dry_run: bool,
  278. ) -> MigrationStats:
  279. stats = MigrationStats()
  280. crm_user_map, admin_id = load_user_mapping(user_mapping_path)
  281. if not admin_id:
  282. admin_id = fetch_portal_admin_id(target_engine)
  283. report_skipped_tables(source_engine, target_engine, stats)
  284. print("\n>> 迁移 crm_invoice_info ...")
  285. migrate_invoice_info(
  286. source_engine,
  287. target_engine,
  288. tenant_no=tenant_no,
  289. crm_user_map=crm_user_map,
  290. admin_id=admin_id,
  291. stats=stats,
  292. dry_run=dry_run,
  293. mapping_dir=mapping_dir,
  294. )
  295. print_report(stats, dry_run)
  296. return stats
  297. def main() -> int:
  298. parser = argparse.ArgumentParser(description="Migrate CRM payment/invoice bundle from sx to portal")
  299. parser.add_argument("--source-url", default="")
  300. parser.add_argument("--target-url", default="")
  301. parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
  302. parser.add_argument(
  303. "--user-mapping",
  304. default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
  305. )
  306. parser.add_argument("--dry-run", action="store_true")
  307. parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
  308. args = parser.parse_args()
  309. load_settings_env(args.env_file or None)
  310. settings = load_runtime_settings()
  311. target_url = args.target_url or settings.DATABASE_URL
  312. source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
  313. print(f"源库: {urlparse(source_url).path}")
  314. print(f"目标库: {urlparse(target_url).path}")
  315. run_migration(
  316. create_engine(source_url, pool_pre_ping=True),
  317. create_engine(target_url, pool_pre_ping=True),
  318. tenant_no=args.tenant_no,
  319. user_mapping_path=Path(args.user_mapping),
  320. mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
  321. dry_run=args.dry_run,
  322. )
  323. return 0
  324. if __name__ == "__main__":
  325. raise SystemExit(main())