migrate_crm_contract.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842
  1. #!/usr/bin/env python3
  2. """将 sx 库 CRM 合同相关表迁入 shanxia2.0。
  3. 顺序:crm_contract_template → crm_contract → crm_contract_product →
  4. crm_contract_product_price_tier → crm_contract_approval → crm_contract_change
  5. 用户外键经 user_mapping;附件 OSS ID / URL 规范化;attachment_files JSON 保留 ossId。
  6. 用法:
  7. cd backend
  8. venv/bin/python scripts/migrate_crm_contract.py --dry-run --env-file .env.production
  9. venv/bin/python scripts/migrate_crm_contract.py --env-file .env.production
  10. """
  11. from __future__ import annotations
  12. import argparse
  13. import csv
  14. import json
  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. MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
  27. if str(BACKEND_ROOT) not in sys.path:
  28. sys.path.insert(0, str(BACKEND_ROOT))
  29. if str(MIGRATION_DIR) not in sys.path:
  30. sys.path.insert(0, str(MIGRATION_DIR))
  31. from minio_url import extract_minio_object_key, join_relative_urls, resolve_oss_id_csv # noqa: E402
  32. DEFAULT_TENANT_NO = "shanxia2.0"
  33. CRM_ADMIN_SOURCE_USER_ID = 1
  34. MIGRATION_STEPS = (
  35. ("crm_contract_template", "contract_template"),
  36. ("crm_contract", "contract"),
  37. ("crm_contract_product", "contract_product"),
  38. ("crm_contract_product_price_tier", "contract_product_price_tier"),
  39. ("crm_contract_approval", "contract_approval"),
  40. ("crm_contract_change", "contract_change"),
  41. )
  42. @dataclass
  43. class StepStats:
  44. source_count: int = 0
  45. inserted: int = 0
  46. updated: int = 0
  47. skipped: int = 0
  48. @dataclass
  49. class MigrationStats:
  50. steps: Dict[str, StepStats] = field(default_factory=dict)
  51. warnings: List[str] = field(default_factory=list)
  52. def step(self, name: str) -> StepStats:
  53. if name not in self.steps:
  54. self.steps[name] = StepStats()
  55. return self.steps[name]
  56. def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
  57. if legacy_id is None or str(legacy_id).strip() == "":
  58. return None
  59. try:
  60. return str(int(legacy_id))
  61. except (TypeError, ValueError):
  62. return str(legacy_id).strip()
  63. def derive_source_url(target_url: str, source_db: str = "sx") -> str:
  64. return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
  65. def load_settings_env(env_file: Optional[str]) -> None:
  66. if env_file:
  67. os.environ["ENV_FILE"] = env_file
  68. from dotenv import load_dotenv
  69. candidates: List[Path] = []
  70. if env_file:
  71. p = Path(env_file)
  72. candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
  73. candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
  74. for path in candidates:
  75. if path.is_file():
  76. load_dotenv(path, override=True)
  77. break
  78. def load_runtime_settings():
  79. from app.core.config import Settings
  80. return Settings()
  81. def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
  82. crm_map: Dict[int, str] = {}
  83. admin_id: Optional[str] = None
  84. with path.open(encoding="utf-8-sig", newline="") as f:
  85. for row in csv.DictReader(f):
  86. new_id = (row.get("new_user_id") or "").strip()
  87. user_no = (row.get("user_no") or "").strip()
  88. crm_raw = (row.get("crm_user_id") or "").strip()
  89. if user_no == "admin" and new_id:
  90. admin_id = new_id
  91. if crm_raw and new_id:
  92. try:
  93. crm_map[int(crm_raw)] = new_id
  94. except ValueError:
  95. pass
  96. return crm_map, admin_id
  97. def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
  98. with target_engine.connect() as conn:
  99. row = conn.execute(
  100. text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
  101. ).first()
  102. return str(row[0]) if row and row[0] else None
  103. def resolve_portal_user_id(
  104. source_user_id: Any,
  105. crm_user_map: Dict[int, str],
  106. admin_id: Optional[str],
  107. stats: MigrationStats,
  108. label: str,
  109. *,
  110. required: bool = False,
  111. ) -> Optional[str]:
  112. if source_user_id is None or str(source_user_id).strip() == "":
  113. return admin_id if required else None
  114. try:
  115. uid = int(source_user_id)
  116. except (TypeError, ValueError):
  117. stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
  118. return admin_id if required else None
  119. if uid == CRM_ADMIN_SOURCE_USER_ID:
  120. return admin_id
  121. mapped = crm_user_map.get(uid)
  122. if not mapped:
  123. stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
  124. return admin_id if required else None
  125. return mapped
  126. def load_oss_id_to_url(target_engine: Engine, tenant_no: str) -> Dict[str, str]:
  127. with target_engine.connect() as conn:
  128. rows = conn.execute(
  129. text("SELECT id, url FROM sys_oss WHERE tenant_no=:t AND del_flag=0"),
  130. {"t": tenant_no},
  131. ).mappings().all()
  132. return {str(r["id"]): (r["url"] or "").strip() for r in rows if r["id"]}
  133. def normalize_attachment_csv(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
  134. if raw is None or str(raw).strip() == "":
  135. return None
  136. oss_resolved = resolve_oss_id_csv(raw, oss_map)
  137. if oss_resolved:
  138. return oss_resolved
  139. parts: List[str] = []
  140. for token in str(raw).split(","):
  141. token = token.strip()
  142. if not token:
  143. continue
  144. if token.isdigit() and token in oss_map:
  145. parts.append(oss_map[token])
  146. else:
  147. key = extract_minio_object_key(token)
  148. if key:
  149. parts.append(key)
  150. joined = join_relative_urls(parts)
  151. return joined or None
  152. def normalize_contract_attachment_files(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
  153. if raw is None or str(raw).strip() == "":
  154. return None
  155. value = str(raw).strip()
  156. if value.startswith("["):
  157. try:
  158. parsed = json.loads(value)
  159. if isinstance(parsed, list):
  160. out: List[dict] = []
  161. for item in parsed:
  162. if not isinstance(item, dict):
  163. continue
  164. oss_id = legacy_id_to_str(item.get("ossId") or item.get("oss_id"))
  165. if not oss_id:
  166. continue
  167. # 门户约定 ossId 字段存 MinIO 相对路径;迁入时优先展开为 url
  168. file_key = oss_map.get(oss_id) or oss_id
  169. out.append({"ossId": file_key, "category": item.get("category") or "other"})
  170. if out:
  171. return json.dumps(out, ensure_ascii=False)
  172. except json.JSONDecodeError:
  173. pass
  174. return normalize_attachment_csv(raw, oss_map)
  175. def normalize_file_path(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
  176. if raw is None or str(raw).strip() == "":
  177. return None
  178. value = str(raw).strip()
  179. if value.isdigit() and value in oss_map:
  180. return oss_map[value]
  181. key = extract_minio_object_key(value)
  182. return key or None
  183. def _dec(value: Any, default: Optional[Decimal] = None) -> Optional[Decimal]:
  184. if value is None or value == "":
  185. return default
  186. return Decimal(str(value))
  187. def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
  188. if value is None:
  189. return None
  190. s = str(value).strip()
  191. if not s:
  192. return None
  193. return s[:max_len] if max_len else s
  194. def fetch_source_rows(engine: Engine, table: str, include_deleted: bool) -> List[dict]:
  195. where = "" if include_deleted else "WHERE del_flag = 0"
  196. sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
  197. with engine.connect() as conn:
  198. return [dict(r._mapping) for r in conn.execute(text(sql))]
  199. def target_row_exists(conn, table: str, row_id: str) -> bool:
  200. return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
  201. def transform_contract_template(
  202. row: dict,
  203. tenant_no: str,
  204. crm_user_map: Dict[int, str],
  205. admin_id: Optional[str],
  206. stats: MigrationStats,
  207. ) -> dict:
  208. rid = legacy_id_to_str(row["id"])
  209. label = f"contract_template#{rid}"
  210. def map_user(field_val: Any) -> Optional[str]:
  211. return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
  212. return {
  213. "id": rid,
  214. "template_no": (row.get("template_no") or "")[:50],
  215. "template_name": (row.get("template_name") or "")[:200],
  216. "contract_type": _str_or_none(row.get("contract_type"), 20),
  217. "template_content": row.get("template_content"),
  218. "payment_terms": row.get("payment_terms"),
  219. "delivery_terms": row.get("delivery_terms"),
  220. "contract_terms": row.get("contract_terms"),
  221. "party_b": _str_or_none(row.get("party_b"), 200),
  222. "party_b_address": _str_or_none(row.get("party_b_address"), 255),
  223. "party_b_contact": _str_or_none(row.get("party_b_contact"), 50),
  224. "party_b_mobile": _str_or_none(row.get("party_b_mobile"), 20),
  225. "default_currency": (row.get("default_currency") or "CNY")[:10],
  226. "default_tax_rate": _dec(row.get("default_tax_rate"), Decimal("6.00")),
  227. "default_period": int(row.get("default_period") or 12),
  228. "is_active": int(row.get("is_active") if row.get("is_active") is not None else 1),
  229. "usage_count": int(row.get("usage_count") or 0),
  230. "sort": int(row.get("sort") or 0),
  231. "tenant_no": tenant_no,
  232. "order_num": 0,
  233. "remark": _str_or_none(row.get("remark"), 500),
  234. "create_by": map_user(row.get("create_by")),
  235. "create_dept_id": "",
  236. "create_time": row.get("create_time") or datetime.now(),
  237. "update_by": map_user(row.get("update_by")),
  238. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  239. "del_flag": int(row.get("del_flag") or 0),
  240. }
  241. def transform_contract(
  242. row: dict,
  243. tenant_no: str,
  244. crm_user_map: Dict[int, str],
  245. admin_id: Optional[str],
  246. oss_map: Dict[str, str],
  247. stats: MigrationStats,
  248. ) -> dict:
  249. rid = legacy_id_to_str(row["id"])
  250. label = f"contract#{rid}"
  251. def map_user(field_val: Any, *, required: bool = False) -> Optional[str]:
  252. return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label, required=required)
  253. owner_id = map_user(row.get("owner_user_id"), required=True)
  254. return {
  255. "id": rid,
  256. "contract_no": (row.get("contract_no") or "")[:50],
  257. "contract_name": (row.get("contract_name") or "")[:200],
  258. "customer_id": legacy_id_to_str(row.get("customer_id")) or "",
  259. "customer_name": _str_or_none(row.get("customer_name"), 200),
  260. "opportunity_id": legacy_id_to_str(row.get("opportunity_id")),
  261. "quote_id": legacy_id_to_str(row.get("quote_id")),
  262. "parent_contract_id": legacy_id_to_str(row.get("parent_contract_id")),
  263. "renewal_count": int(row.get("renewal_count") or 0),
  264. "template_id": legacy_id_to_str(row.get("template_id")),
  265. "contract_template": (row.get("contract_template") or "shanxia")[:50],
  266. "contact_id": legacy_id_to_str(row.get("contact_id")),
  267. "contact_name": _str_or_none(row.get("contact_name"), 50),
  268. "contact_mobile": _str_or_none(row.get("contact_mobile"), 20),
  269. "contract_type": (row.get("contract_type") or "sale")[:20],
  270. "contract_nature": (row.get("contract_nature") or "new")[:20],
  271. "amount": _dec(row.get("amount"), Decimal("0.00")),
  272. "currency": (row.get("currency") or "CNY")[:10],
  273. "tax_rate": _dec(row.get("tax_rate"), Decimal("6.00")),
  274. "amount_with_tax": _dec(row.get("amount_with_tax"), Decimal("0.00")),
  275. "status": (row.get("status") or "draft")[:20],
  276. "performance_status": (row.get("performance_status") or "normal")[:20],
  277. "performance_rate": _dec(row.get("performance_rate"), Decimal("0.00")),
  278. "customer_satisfaction": int(row["customer_satisfaction"]) if row.get("customer_satisfaction") is not None else None,
  279. "approval_status": _str_or_none(row.get("approval_status"), 20),
  280. "approval_flow_id": legacy_id_to_str(row.get("approval_flow_id")),
  281. "contract_file": normalize_file_path(row.get("contract_file"), oss_map),
  282. "attachment_files": normalize_contract_attachment_files(row.get("attachment_files"), oss_map),
  283. "sign_date": row.get("sign_date"),
  284. "actual_sign_date": row.get("actual_sign_date"),
  285. "start_date": row.get("start_date"),
  286. "end_date": row.get("end_date"),
  287. "contract_period": int(row["contract_period"]) if row.get("contract_period") is not None else None,
  288. "owner_user_id": owner_id or "",
  289. "owner_user_name": _str_or_none(row.get("owner_user_name"), 50),
  290. "received_amount": _dec(row.get("received_amount"), Decimal("0.00")),
  291. "unreceived_amount": _dec(row.get("unreceived_amount"), Decimal("0.00")),
  292. "invoice_amount": _dec(row.get("invoice_amount"), Decimal("0.00")),
  293. "uninvoice_amount": _dec(row.get("uninvoice_amount"), Decimal("0.00")),
  294. "party_a": _str_or_none(row.get("party_a"), 200),
  295. "party_a_address": _str_or_none(row.get("party_a_address"), 255),
  296. "party_a_contact": _str_or_none(row.get("party_a_contact"), 50),
  297. "party_a_mobile": _str_or_none(row.get("party_a_mobile"), 20),
  298. "party_a_legal_person": _str_or_none(row.get("party_a_legal_person"), 50),
  299. "party_a_agent": _str_or_none(row.get("party_a_agent"), 50),
  300. "party_a_phone_fax": _str_or_none(row.get("party_a_phone_fax"), 50),
  301. "party_a_bank": _str_or_none(row.get("party_a_bank"), 100),
  302. "party_a_account": _str_or_none(row.get("party_a_account"), 50),
  303. "party_b": _str_or_none(row.get("party_b"), 200),
  304. "party_b_address": _str_or_none(row.get("party_b_address"), 255),
  305. "party_b_contact": _str_or_none(row.get("party_b_contact"), 50),
  306. "party_b_mobile": _str_or_none(row.get("party_b_mobile"), 20),
  307. "party_b_legal_person": _str_or_none(row.get("party_b_legal_person"), 100),
  308. "party_b_agent": _str_or_none(row.get("party_b_agent"), 100),
  309. "party_b_phone_fax": _str_or_none(row.get("party_b_phone_fax"), 100),
  310. "party_b_bank": _str_or_none(row.get("party_b_bank"), 200),
  311. "party_b_account": _str_or_none(row.get("party_b_account"), 100),
  312. "payment_terms": row.get("payment_terms"),
  313. "delivery_terms": row.get("delivery_terms"),
  314. "contract_terms": row.get("contract_terms"),
  315. "settlement_method": _str_or_none(row.get("settlement_method"), 50),
  316. "template_content": row.get("template_content"),
  317. "settlement_clause_drafts": row.get("settlement_clause_drafts"),
  318. "invalid_reason": _str_or_none(row.get("invalid_reason"), 500),
  319. "invalid_time": row.get("invalid_time"),
  320. "version_no": int(row.get("version_no") if row.get("version_no") is not None else row.get("version") or 0),
  321. "supplier_id": legacy_id_to_str(row.get("supplier_id")),
  322. "tenant_no": tenant_no,
  323. "order_num": 0,
  324. "remark": _str_or_none(row.get("remark"), 512),
  325. "create_by": map_user(row.get("create_by")),
  326. "create_dept_id": "",
  327. "create_time": row.get("create_time") or datetime.now(),
  328. "update_by": map_user(row.get("update_by")),
  329. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  330. "del_flag": int(row.get("del_flag") or 0),
  331. }
  332. def transform_contract_product(
  333. row: dict,
  334. tenant_no: str,
  335. crm_user_map: Dict[int, str],
  336. admin_id: Optional[str],
  337. stats: MigrationStats,
  338. ) -> dict:
  339. rid = legacy_id_to_str(row["id"])
  340. label = f"contract_product#{rid}"
  341. def map_user(field_val: Any) -> Optional[str]:
  342. return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
  343. return {
  344. "id": rid,
  345. "contract_id": legacy_id_to_str(row.get("contract_id")) or "",
  346. "product_id": legacy_id_to_str(row.get("product_id")) or "",
  347. "product_name": (row.get("product_name") or "")[:200],
  348. "product_no": _str_or_none(row.get("product_no"), 50),
  349. "category_id": legacy_id_to_str(row.get("category_id")),
  350. "category_name": _str_or_none(row.get("category_name"), 100),
  351. "model": _str_or_none(row.get("model"), 100),
  352. "unit": (row.get("unit") or "件")[:20],
  353. "quantity": _dec(row.get("quantity"), Decimal("0")),
  354. "price": _dec(row.get("price"), Decimal("0.00")),
  355. "discount": _dec(row.get("discount"), Decimal("0.00")),
  356. "tax_rate": _dec(row.get("tax_rate"), Decimal("6.00")),
  357. "tax_amount": _dec(row.get("tax_amount"), Decimal("0.00")),
  358. "subtotal": _dec(row.get("subtotal"), Decimal("0.00")),
  359. "delivery_date": row.get("delivery_date"),
  360. "delivery_status": (row.get("delivery_status") or "pending")[:20],
  361. "delivered_quantity": _dec(row.get("delivered_quantity"), Decimal("0")),
  362. "description": _str_or_none(row.get("description"), 1000),
  363. "package_spec": _str_or_none(row.get("package_spec"), 200),
  364. "delivery_method": _str_or_none(row.get("delivery_method"), 100),
  365. "sort": int(row.get("sort") or 0),
  366. "tenant_no": tenant_no,
  367. "order_num": 0,
  368. "remark": _str_or_none(row.get("remark"), 500),
  369. "create_by": map_user(row.get("create_by")),
  370. "create_dept_id": "",
  371. "create_time": row.get("create_time") or datetime.now(),
  372. "update_by": map_user(row.get("update_by")),
  373. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  374. "del_flag": int(row.get("del_flag") or 0),
  375. }
  376. def transform_contract_price_tier(
  377. row: dict,
  378. tenant_no: str,
  379. crm_user_map: Dict[int, str],
  380. admin_id: Optional[str],
  381. stats: MigrationStats,
  382. ) -> dict:
  383. rid = legacy_id_to_str(row["id"])
  384. label = f"contract_price_tier#{rid}"
  385. def map_user(field_val: Any) -> Optional[str]:
  386. return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
  387. letter_oss_ids = row.get("letter_oss_ids")
  388. letter_oss_id = legacy_id_to_str(row.get("letter_oss_id"))
  389. return {
  390. "id": rid,
  391. "contract_id": legacy_id_to_str(row.get("contract_id")) or "",
  392. "contract_product_id": legacy_id_to_str(row.get("contract_product_id")) or "",
  393. "start_date": row.get("start_date"),
  394. "end_date": row.get("end_date"),
  395. "price": _dec(row.get("price"), Decimal("0")),
  396. "letter_oss_ids": letter_oss_ids if letter_oss_ids else None,
  397. "letter_oss_id": letter_oss_id,
  398. "sort": int(row.get("sort") or 0),
  399. "tenant_no": tenant_no,
  400. "order_num": 0,
  401. "remark": _str_or_none(row.get("remark"), 500),
  402. "create_by": map_user(row.get("create_by")),
  403. "create_dept_id": "",
  404. "create_time": row.get("create_time") or datetime.now(),
  405. "update_by": map_user(row.get("update_by")),
  406. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  407. "del_flag": int(row.get("del_flag") or 0),
  408. }
  409. def transform_contract_approval(
  410. row: dict,
  411. tenant_no: str,
  412. crm_user_map: Dict[int, str],
  413. admin_id: Optional[str],
  414. oss_map: Dict[str, str],
  415. stats: MigrationStats,
  416. ) -> dict:
  417. rid = legacy_id_to_str(row["id"])
  418. label = f"contract_approval#{rid}"
  419. def map_user(field_val: Any, *, required: bool = False) -> Optional[str]:
  420. return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label, required=required)
  421. return {
  422. "id": rid,
  423. "contract_id": legacy_id_to_str(row.get("contract_id")) or "",
  424. "flow_id": legacy_id_to_str(row.get("flow_id")),
  425. "step": int(row.get("step") or 1),
  426. "step_name": _str_or_none(row.get("step_name"), 100),
  427. "approver_id": map_user(row.get("approver_id"), required=True) or "",
  428. "approver_name": _str_or_none(row.get("approver_name"), 50),
  429. "approver_dept": _str_or_none(row.get("approver_dept"), 100),
  430. "approval_result": (row.get("approval_result") or "pending")[:20],
  431. "approval_opinion": row.get("approval_opinion"),
  432. "approval_time": row.get("approval_time"),
  433. "approval_duration": int(row["approval_duration"]) if row.get("approval_duration") is not None else None,
  434. "attachments": normalize_attachment_csv(row.get("attachments"), oss_map),
  435. "is_current": int(row.get("is_current") if row.get("is_current") is not None else 1),
  436. "sort": int(row.get("sort") or 0),
  437. "tenant_no": tenant_no,
  438. "order_num": 0,
  439. "remark": _str_or_none(row.get("remark"), 500),
  440. "create_by": map_user(row.get("create_by")),
  441. "create_dept_id": "",
  442. "create_time": row.get("create_time") or datetime.now(),
  443. "update_by": map_user(row.get("update_by")),
  444. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  445. "del_flag": int(row.get("del_flag") or 0),
  446. }
  447. def transform_contract_change(
  448. row: dict,
  449. tenant_no: str,
  450. crm_user_map: Dict[int, str],
  451. admin_id: Optional[str],
  452. oss_map: Dict[str, str],
  453. stats: MigrationStats,
  454. ) -> dict:
  455. rid = legacy_id_to_str(row["id"])
  456. label = f"contract_change#{rid}"
  457. def map_user(field_val: Any) -> Optional[str]:
  458. return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
  459. return {
  460. "id": rid,
  461. "contract_id": legacy_id_to_str(row.get("contract_id")) or "",
  462. "contract_no": _str_or_none(row.get("contract_no"), 50),
  463. "change_type": (row.get("change_type") or "")[:50],
  464. "change_reason": (row.get("change_reason") or "")[:500],
  465. "change_content": row.get("change_content"),
  466. "old_value": row.get("old_value"),
  467. "new_value": row.get("new_value"),
  468. "change_amount": _dec(row.get("change_amount")),
  469. "approval_status": (row.get("approval_status") or "pending")[:20],
  470. "approver_id": map_user(row.get("approver_id")),
  471. "approver_name": _str_or_none(row.get("approver_name"), 50),
  472. "approval_time": row.get("approval_time"),
  473. "approval_opinion": _str_or_none(row.get("approval_opinion"), 500),
  474. "attachment_files": normalize_contract_attachment_files(row.get("attachment_files"), oss_map),
  475. "tenant_no": tenant_no,
  476. "order_num": 0,
  477. "remark": _str_or_none(row.get("remark"), 500),
  478. "create_by": map_user(row.get("create_by")),
  479. "create_dept_id": "",
  480. "create_time": row.get("create_time") or datetime.now(),
  481. "update_by": map_user(row.get("update_by")),
  482. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  483. "del_flag": int(row.get("del_flag") or 0),
  484. }
  485. INSERT_CONTRACT_TEMPLATE_SQL = text(
  486. """
  487. INSERT INTO crm_contract_template (
  488. id, template_no, template_name, contract_type, template_content, payment_terms,
  489. delivery_terms, contract_terms, party_b, party_b_address, party_b_contact, party_b_mobile,
  490. default_currency, default_tax_rate, default_period, is_active, usage_count, sort,
  491. tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
  492. ) VALUES (
  493. :id, :template_no, :template_name, :contract_type, :template_content, :payment_terms,
  494. :delivery_terms, :contract_terms, :party_b, :party_b_address, :party_b_contact, :party_b_mobile,
  495. :default_currency, :default_tax_rate, :default_period, :is_active, :usage_count, :sort,
  496. :tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  497. )
  498. """
  499. )
  500. INSERT_CONTRACT_SQL = text(
  501. """
  502. INSERT INTO crm_contract (
  503. id, contract_no, contract_name, customer_id, customer_name, opportunity_id, quote_id,
  504. parent_contract_id, renewal_count, template_id, contract_template, contact_id, contact_name,
  505. contact_mobile, contract_type, contract_nature, amount, currency, tax_rate, amount_with_tax,
  506. status, performance_status, performance_rate, customer_satisfaction, approval_status,
  507. approval_flow_id, contract_file, attachment_files, sign_date, actual_sign_date, start_date,
  508. end_date, contract_period, owner_user_id, owner_user_name, received_amount, unreceived_amount,
  509. invoice_amount, uninvoice_amount, party_a, party_a_address, party_a_contact, party_a_mobile,
  510. party_a_legal_person, party_a_agent, party_a_phone_fax, party_a_bank, party_a_account,
  511. party_b, party_b_address, party_b_contact, party_b_mobile, party_b_legal_person, party_b_agent,
  512. party_b_phone_fax, party_b_bank, party_b_account, payment_terms, delivery_terms, contract_terms,
  513. settlement_method, template_content, settlement_clause_drafts, invalid_reason, invalid_time,
  514. version_no, supplier_id, tenant_no, order_num, remark, create_by, create_dept_id, create_time,
  515. update_by, update_time, del_flag
  516. ) VALUES (
  517. :id, :contract_no, :contract_name, :customer_id, :customer_name, :opportunity_id, :quote_id,
  518. :parent_contract_id, :renewal_count, :template_id, :contract_template, :contact_id, :contact_name,
  519. :contact_mobile, :contract_type, :contract_nature, :amount, :currency, :tax_rate, :amount_with_tax,
  520. :status, :performance_status, :performance_rate, :customer_satisfaction, :approval_status,
  521. :approval_flow_id, :contract_file, :attachment_files, :sign_date, :actual_sign_date, :start_date,
  522. :end_date, :contract_period, :owner_user_id, :owner_user_name, :received_amount, :unreceived_amount,
  523. :invoice_amount, :uninvoice_amount, :party_a, :party_a_address, :party_a_contact, :party_a_mobile,
  524. :party_a_legal_person, :party_a_agent, :party_a_phone_fax, :party_a_bank, :party_a_account,
  525. :party_b, :party_b_address, :party_b_contact, :party_b_mobile, :party_b_legal_person, :party_b_agent,
  526. :party_b_phone_fax, :party_b_bank, :party_b_account, :payment_terms, :delivery_terms, :contract_terms,
  527. :settlement_method, :template_content, :settlement_clause_drafts, :invalid_reason, :invalid_time,
  528. :version_no, :supplier_id, :tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time,
  529. :update_by, :update_time, :del_flag
  530. )
  531. """
  532. )
  533. INSERT_CONTRACT_PRODUCT_SQL = text(
  534. """
  535. INSERT INTO crm_contract_product (
  536. id, contract_id, product_id, product_name, product_no, category_id, category_name,
  537. model, unit, quantity, price, discount, tax_rate, tax_amount, subtotal,
  538. delivery_date, delivery_status, delivered_quantity, description, package_spec,
  539. delivery_method, sort, tenant_no, order_num, remark, create_by, create_dept_id,
  540. create_time, update_by, update_time, del_flag
  541. ) VALUES (
  542. :id, :contract_id, :product_id, :product_name, :product_no, :category_id, :category_name,
  543. :model, :unit, :quantity, :price, :discount, :tax_rate, :tax_amount, :subtotal,
  544. :delivery_date, :delivery_status, :delivered_quantity, :description, :package_spec,
  545. :delivery_method, :sort, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
  546. :create_time, :update_by, :update_time, :del_flag
  547. )
  548. """
  549. )
  550. INSERT_CONTRACT_PRICE_TIER_SQL = text(
  551. """
  552. INSERT INTO crm_contract_product_price_tier (
  553. id, contract_id, contract_product_id, start_date, end_date, price,
  554. letter_oss_ids, letter_oss_id, sort, tenant_no, order_num, remark,
  555. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  556. ) VALUES (
  557. :id, :contract_id, :contract_product_id, :start_date, :end_date, :price,
  558. :letter_oss_ids, :letter_oss_id, :sort, :tenant_no, :order_num, :remark,
  559. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  560. )
  561. """
  562. )
  563. INSERT_CONTRACT_APPROVAL_SQL = text(
  564. """
  565. INSERT INTO crm_contract_approval (
  566. id, contract_id, flow_id, step, step_name, approver_id, approver_name, approver_dept,
  567. approval_result, approval_opinion, approval_time, approval_duration, attachments,
  568. is_current, sort, tenant_no, order_num, remark, create_by, create_dept_id,
  569. create_time, update_by, update_time, del_flag
  570. ) VALUES (
  571. :id, :contract_id, :flow_id, :step, :step_name, :approver_id, :approver_name, :approver_dept,
  572. :approval_result, :approval_opinion, :approval_time, :approval_duration, :attachments,
  573. :is_current, :sort, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
  574. :create_time, :update_by, :update_time, :del_flag
  575. )
  576. """
  577. )
  578. INSERT_CONTRACT_CHANGE_SQL = text(
  579. """
  580. INSERT INTO crm_contract_change (
  581. id, contract_id, contract_no, change_type, change_reason, change_content, old_value,
  582. new_value, change_amount, approval_status, approver_id, approver_name, approval_time,
  583. approval_opinion, attachment_files, tenant_no, order_num, remark, create_by, create_dept_id,
  584. create_time, update_by, update_time, del_flag
  585. ) VALUES (
  586. :id, :contract_id, :contract_no, :change_type, :change_reason, :change_content, :old_value,
  587. :new_value, :change_amount, :approval_status, :approver_id, :approver_name, :approval_time,
  588. :approval_opinion, :attachment_files, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
  589. :create_time, :update_by, :update_time, :del_flag
  590. )
  591. """
  592. )
  593. STEP_INSERT_SQL = {
  594. "crm_contract_template": INSERT_CONTRACT_TEMPLATE_SQL,
  595. "crm_contract": INSERT_CONTRACT_SQL,
  596. "crm_contract_product": INSERT_CONTRACT_PRODUCT_SQL,
  597. "crm_contract_product_price_tier": INSERT_CONTRACT_PRICE_TIER_SQL,
  598. "crm_contract_approval": INSERT_CONTRACT_APPROVAL_SQL,
  599. "crm_contract_change": INSERT_CONTRACT_CHANGE_SQL,
  600. }
  601. def write_mapping_csv(source_rows: List[dict], path: Path) -> None:
  602. path.parent.mkdir(parents=True, exist_ok=True)
  603. migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  604. with path.open("w", encoding="utf-8", newline="") as f:
  605. w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
  606. w.writeheader()
  607. for row in source_rows:
  608. sid = legacy_id_to_str(row["id"])
  609. w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
  610. def migrate_table(
  611. source_engine: Engine,
  612. target_engine: Engine,
  613. table: str,
  614. *,
  615. tenant_no: str,
  616. crm_user_map: Dict[int, str],
  617. admin_id: Optional[str],
  618. oss_map: Dict[str, str],
  619. stats: MigrationStats,
  620. dry_run: bool,
  621. update_existing: bool,
  622. include_deleted: bool,
  623. ) -> List[dict]:
  624. step = stats.step(table)
  625. source_rows = fetch_source_rows(source_engine, table, include_deleted)
  626. step.source_count = len(source_rows)
  627. transformers = {
  628. "crm_contract_template": lambda r: transform_contract_template(r, tenant_no, crm_user_map, admin_id, stats),
  629. "crm_contract": lambda r: transform_contract(r, tenant_no, crm_user_map, admin_id, oss_map, stats),
  630. "crm_contract_product": lambda r: transform_contract_product(r, tenant_no, crm_user_map, admin_id, stats),
  631. "crm_contract_product_price_tier": lambda r: transform_contract_price_tier(
  632. r, tenant_no, crm_user_map, admin_id, stats
  633. ),
  634. "crm_contract_approval": lambda r: transform_contract_approval(
  635. r, tenant_no, crm_user_map, admin_id, oss_map, stats
  636. ),
  637. "crm_contract_change": lambda r: transform_contract_change(
  638. r, tenant_no, crm_user_map, admin_id, oss_map, stats
  639. ),
  640. }
  641. payloads = [transformers[table](r) for r in source_rows]
  642. if dry_run:
  643. step.inserted = len(payloads)
  644. return source_rows
  645. insert_sql = STEP_INSERT_SQL[table]
  646. with target_engine.begin() as conn:
  647. for payload in payloads:
  648. rid = payload["id"]
  649. if target_row_exists(conn, table, rid):
  650. if update_existing:
  651. step.skipped += 1
  652. else:
  653. step.skipped += 1
  654. else:
  655. conn.execute(insert_sql, payload)
  656. step.inserted += 1
  657. return source_rows
  658. def print_report(stats: MigrationStats, dry_run: bool) -> None:
  659. mode = "DRY-RUN" if dry_run else "COMMITTED"
  660. print(f"\n=== CRM 合同迁移 [{mode}] ===")
  661. for table, _ in MIGRATION_STEPS:
  662. s = stats.step(table)
  663. print(f"{table}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
  664. if stats.warnings:
  665. print(f"警告 ({len(stats.warnings)}):")
  666. for w in stats.warnings[:20]:
  667. print(f" - {w}")
  668. if len(stats.warnings) > 20:
  669. print(f" ... 另有 {len(stats.warnings) - 20} 条")
  670. def run_migration(
  671. source_engine: Engine,
  672. target_engine: Engine,
  673. *,
  674. tenant_no: str,
  675. user_mapping_path: Path,
  676. mapping_dir: Path,
  677. dry_run: bool,
  678. update_existing: bool,
  679. include_deleted: bool,
  680. only_tables: Optional[List[str]],
  681. ) -> MigrationStats:
  682. stats = MigrationStats()
  683. crm_user_map, admin_id = load_user_mapping(user_mapping_path)
  684. if not admin_id:
  685. admin_id = fetch_portal_admin_id(target_engine)
  686. oss_map = load_oss_id_to_url(target_engine, tenant_no)
  687. print(f"sys_oss 映射条数: {len(oss_map)}")
  688. selected = [t for t, _ in MIGRATION_STEPS if not only_tables or t in only_tables]
  689. for table in selected:
  690. print(f"\n>> 迁移 {table} ...")
  691. source_rows = migrate_table(
  692. source_engine,
  693. target_engine,
  694. table,
  695. tenant_no=tenant_no,
  696. crm_user_map=crm_user_map,
  697. admin_id=admin_id,
  698. oss_map=oss_map,
  699. stats=stats,
  700. dry_run=dry_run,
  701. update_existing=update_existing,
  702. include_deleted=include_deleted,
  703. )
  704. if table == "crm_contract" and not dry_run:
  705. write_mapping_csv(source_rows, mapping_dir / "crm_contract_id_mapping.csv")
  706. print_report(stats, dry_run)
  707. return stats
  708. def main() -> int:
  709. parser = argparse.ArgumentParser(description="Migrate CRM contract tables from sx to portal")
  710. parser.add_argument("--source-url", default="")
  711. parser.add_argument("--target-url", default="")
  712. parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
  713. parser.add_argument(
  714. "--user-mapping",
  715. default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
  716. )
  717. parser.add_argument("--dry-run", action="store_true")
  718. parser.add_argument("--update", action="store_true")
  719. parser.add_argument("--include-deleted", action="store_true")
  720. parser.add_argument("--tables", default="", help="逗号分隔,默认全部六表")
  721. parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
  722. args = parser.parse_args()
  723. load_settings_env(args.env_file or None)
  724. settings = load_runtime_settings()
  725. target_url = args.target_url or settings.DATABASE_URL
  726. source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
  727. only_tables = [t.strip() for t in args.tables.split(",") if t.strip()] or None
  728. print(f"源库: {urlparse(source_url).path}")
  729. print(f"目标库: {urlparse(target_url).path}")
  730. run_migration(
  731. create_engine(source_url, pool_pre_ping=True),
  732. create_engine(target_url, pool_pre_ping=True),
  733. tenant_no=args.tenant_no,
  734. user_mapping_path=Path(args.user_mapping),
  735. mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
  736. dry_run=args.dry_run,
  737. update_existing=args.update,
  738. include_deleted=args.include_deleted,
  739. only_tables=only_tables,
  740. )
  741. return 0
  742. if __name__ == "__main__":
  743. raise SystemExit(main())