migrate_crm_quote.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  1. #!/usr/bin/env python3
  2. """将 sx 库 CRM 报价单相关表迁入 shanxia2.0。
  3. 顺序:crm_supplier_info(依赖)→ crm_quote → crm_quote_product → crm_quote_approval → crm_quote_share_log
  4. 用户外键经 user_mapping;附件/pdf OSS ID 或绝对 URL → 相对路径。
  5. 用法:
  6. cd backend
  7. venv/bin/python scripts/migrate_crm_quote.py --dry-run --env-file .env.production
  8. venv/bin/python scripts/migrate_crm_quote.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 decimal import Decimal
  18. from pathlib import Path
  19. from typing import Any, Dict, List, Optional, Tuple
  20. from urllib.parse import urlparse, urlunparse
  21. from sqlalchemy import create_engine, text
  22. from sqlalchemy.engine import Engine
  23. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  24. MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
  25. if str(BACKEND_ROOT) not in sys.path:
  26. sys.path.insert(0, str(BACKEND_ROOT))
  27. if str(MIGRATION_DIR) not in sys.path:
  28. sys.path.insert(0, str(MIGRATION_DIR))
  29. from minio_url import extract_minio_object_key, join_relative_urls, resolve_oss_id_csv # noqa: E402
  30. DEFAULT_TENANT_NO = "shanxia2.0"
  31. CRM_ADMIN_SOURCE_USER_ID = 1
  32. MIGRATION_STEPS = (
  33. ("crm_supplier_info", "supplier_info"),
  34. ("crm_quote", "quote"),
  35. ("crm_quote_product", "quote_product"),
  36. ("crm_quote_approval", "quote_approval"),
  37. ("crm_quote_share_log", "quote_share_log"),
  38. )
  39. TABLES_WITHOUT_DEL_FLAG = frozenset({"crm_quote_share_log"})
  40. @dataclass
  41. class StepStats:
  42. source_count: int = 0
  43. inserted: int = 0
  44. updated: int = 0
  45. skipped: int = 0
  46. @dataclass
  47. class MigrationStats:
  48. steps: Dict[str, StepStats] = field(default_factory=dict)
  49. warnings: List[str] = field(default_factory=list)
  50. def step(self, name: str) -> StepStats:
  51. if name not in self.steps:
  52. self.steps[name] = StepStats()
  53. return self.steps[name]
  54. def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
  55. if legacy_id is None or str(legacy_id).strip() == "":
  56. return None
  57. try:
  58. return str(int(legacy_id))
  59. except (TypeError, ValueError):
  60. return str(legacy_id).strip()
  61. def legacy_parent_id(raw: Any) -> str:
  62. if raw is None:
  63. return "0"
  64. try:
  65. val = int(raw)
  66. return "0" if val == 0 else str(val)
  67. except (TypeError, ValueError):
  68. s = str(raw).strip()
  69. return s if s else "0"
  70. def derive_source_url(target_url: str, source_db: str = "sx") -> str:
  71. return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
  72. def load_settings_env(env_file: Optional[str]) -> None:
  73. if env_file:
  74. os.environ["ENV_FILE"] = env_file
  75. from dotenv import load_dotenv
  76. candidates: List[Path] = []
  77. if env_file:
  78. p = Path(env_file)
  79. candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
  80. candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
  81. for path in candidates:
  82. if path.is_file():
  83. load_dotenv(path, override=True)
  84. break
  85. def load_runtime_settings():
  86. from app.core.config import Settings
  87. return Settings()
  88. def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
  89. crm_map: Dict[int, str] = {}
  90. admin_id: Optional[str] = None
  91. with path.open(encoding="utf-8-sig", newline="") as f:
  92. for row in csv.DictReader(f):
  93. new_id = (row.get("new_user_id") or "").strip()
  94. user_no = (row.get("user_no") or "").strip()
  95. crm_raw = (row.get("crm_user_id") or "").strip()
  96. if user_no == "admin" and new_id:
  97. admin_id = new_id
  98. if crm_raw and new_id:
  99. try:
  100. crm_map[int(crm_raw)] = new_id
  101. except ValueError:
  102. pass
  103. return crm_map, admin_id
  104. def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
  105. with target_engine.connect() as conn:
  106. row = conn.execute(
  107. text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
  108. ).first()
  109. return str(row[0]) if row and row[0] else None
  110. def resolve_portal_user_id(
  111. source_user_id: Any,
  112. crm_user_map: Dict[int, str],
  113. admin_id: Optional[str],
  114. stats: MigrationStats,
  115. label: str,
  116. *,
  117. required: bool = False,
  118. ) -> Optional[str]:
  119. if source_user_id is None or str(source_user_id).strip() == "":
  120. return admin_id if required else None
  121. try:
  122. uid = int(source_user_id)
  123. except (TypeError, ValueError):
  124. stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
  125. return admin_id if required else None
  126. if uid == CRM_ADMIN_SOURCE_USER_ID:
  127. return admin_id
  128. mapped = crm_user_map.get(uid)
  129. if not mapped:
  130. stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
  131. return admin_id if required else None
  132. return mapped
  133. def load_oss_id_to_url(target_engine: Engine, tenant_no: str) -> Dict[str, str]:
  134. with target_engine.connect() as conn:
  135. rows = conn.execute(
  136. text("SELECT id, url FROM sys_oss WHERE tenant_no=:t AND del_flag=0"),
  137. {"t": tenant_no},
  138. ).mappings().all()
  139. return {str(r["id"]): (r["url"] or "").strip() for r in rows if r["id"]}
  140. def normalize_attachment_csv(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
  141. if raw is None or str(raw).strip() == "":
  142. return None
  143. oss_resolved = resolve_oss_id_csv(raw, oss_map)
  144. if oss_resolved:
  145. return oss_resolved or None
  146. parts: List[str] = []
  147. for token in str(raw).split(","):
  148. token = token.strip()
  149. if not token:
  150. continue
  151. if token.isdigit() and token in oss_map:
  152. parts.append(oss_map[token])
  153. else:
  154. key = extract_minio_object_key(token)
  155. if key:
  156. parts.append(key)
  157. joined = join_relative_urls(parts)
  158. return joined or None
  159. def normalize_pdf_url(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
  160. if raw is None or str(raw).strip() == "":
  161. return None
  162. value = str(raw).strip()
  163. if value.isdigit() and value in oss_map:
  164. return oss_map[value]
  165. key = extract_minio_object_key(value)
  166. return key or None
  167. def _dec(value: Any, default: Optional[Decimal] = None) -> Optional[Decimal]:
  168. if value is None or value == "":
  169. return default
  170. return Decimal(str(value))
  171. def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
  172. if value is None:
  173. return None
  174. s = str(value).strip()
  175. if not s:
  176. return None
  177. return s[:max_len] if max_len else s
  178. def fetch_source_rows(engine: Engine, table: str, include_deleted: bool) -> List[dict]:
  179. if table in TABLES_WITHOUT_DEL_FLAG:
  180. where = ""
  181. else:
  182. where = "" if include_deleted else "WHERE del_flag = 0"
  183. sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
  184. with engine.connect() as conn:
  185. return [dict(r._mapping) for r in conn.execute(text(sql))]
  186. def target_row_exists(conn, table: str, row_id: str) -> bool:
  187. return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
  188. def transform_supplier_info(
  189. row: dict,
  190. tenant_no: str,
  191. crm_user_map: Dict[int, str],
  192. admin_id: Optional[str],
  193. stats: MigrationStats,
  194. ) -> dict:
  195. rid = legacy_id_to_str(row["id"])
  196. label = f"supplier_info#{rid}"
  197. def map_user(field_val: Any) -> Optional[str]:
  198. return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
  199. return {
  200. "id": rid,
  201. "supplier_name": (row.get("supplier_name") or "")[:200],
  202. "short_name": _str_or_none(row.get("short_name"), 100),
  203. "supplier_code": _str_or_none(row.get("supplier_code"), 50),
  204. "supplier_address": _str_or_none(row.get("supplier_address"), 500),
  205. "legal_person": _str_or_none(row.get("legal_person"), 100),
  206. "agent": _str_or_none(row.get("agent"), 100),
  207. "phone_fax": _str_or_none(row.get("phone_fax"), 100),
  208. "bank_name": _str_or_none(row.get("bank_name"), 200),
  209. "bank_account": _str_or_none(row.get("bank_account"), 100),
  210. "status": (row.get("status") or "0")[:1],
  211. "tenant_no": tenant_no,
  212. "order_num": 0,
  213. "remark": _str_or_none(row.get("remark"), 500),
  214. "create_by": map_user(row.get("create_by")),
  215. "create_dept_id": "",
  216. "create_time": row.get("create_time") or datetime.now(),
  217. "update_by": map_user(row.get("update_by")),
  218. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  219. "del_flag": int(row.get("del_flag") or 0),
  220. }
  221. def transform_quote(
  222. row: dict,
  223. tenant_no: str,
  224. crm_user_map: Dict[int, str],
  225. admin_id: Optional[str],
  226. oss_map: Dict[str, str],
  227. stats: MigrationStats,
  228. ) -> dict:
  229. rid = legacy_id_to_str(row["id"])
  230. label = f"quote#{rid}"
  231. def map_user(field_val: Any, *, required: bool = False) -> Optional[str]:
  232. return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label, required=required)
  233. owner_id = map_user(row.get("owner_user_id"), required=True)
  234. return {
  235. "id": rid,
  236. "quote_no": (row.get("quote_no") or "")[:50],
  237. "quote_title": (row.get("quote_title") or "")[:200],
  238. "quote_type": (row.get("quote_type") or "sales")[:20],
  239. "customer_id": legacy_id_to_str(row.get("customer_id")) or "",
  240. "customer_name": _str_or_none(row.get("customer_name"), 200),
  241. "opportunity_id": legacy_id_to_str(row.get("opportunity_id")),
  242. "contact_id": legacy_id_to_str(row.get("contact_id")),
  243. "contact_name": _str_or_none(row.get("contact_name"), 50),
  244. "contact_mobile": _str_or_none(row.get("contact_mobile"), 20),
  245. "contact_email": _str_or_none(row.get("contact_email"), 100),
  246. "version": (row.get("version") or "V1.0")[:20],
  247. "parent_id": legacy_parent_id(row.get("parent_id")),
  248. "is_latest": int(row.get("is_latest") if row.get("is_latest") is not None else 1),
  249. "total_amount": _dec(row.get("total_amount"), Decimal("0.00")),
  250. "discount_rate": _dec(row.get("discount_rate"), Decimal("0.00")),
  251. "discount_amount": _dec(row.get("discount_amount"), Decimal("0.00")),
  252. "final_amount": _dec(row.get("final_amount"), Decimal("0.00")),
  253. "validity_days": int(row.get("validity_days") or 30),
  254. "quote_date": row.get("quote_date"),
  255. "expire_date": row.get("expire_date"),
  256. "status": (row.get("status") or "draft")[:20],
  257. "reject_reason": _str_or_none(row.get("reject_reason"), 500),
  258. "pdf_url": normalize_pdf_url(row.get("pdf_url"), oss_map),
  259. "pdf_generated": int(row.get("pdf_generated") or 0),
  260. "share_link": _str_or_none(row.get("share_link"), 500),
  261. "share_code": _str_or_none(row.get("share_code"), 50),
  262. "share_password": _str_or_none(row.get("share_password"), 100),
  263. "share_expire_time": row.get("share_expire_time"),
  264. "view_count": int(row.get("view_count") or 0),
  265. "last_view_time": row.get("last_view_time"),
  266. "owner_user_id": owner_id or "",
  267. "owner_user_name": _str_or_none(row.get("owner_user_name"), 50),
  268. "supplier_id": legacy_id_to_str(row.get("supplier_id")),
  269. "supplier_name": _str_or_none(row.get("supplier_name"), 200),
  270. "contract_id": legacy_id_to_str(row.get("contract_id")),
  271. "payment_terms": _str_or_none(row.get("payment_terms"), 500),
  272. "delivery_terms": _str_or_none(row.get("delivery_terms"), 500),
  273. "freight": _dec(row.get("freight"), Decimal("0.00")),
  274. "attachments": normalize_attachment_csv(row.get("attachments"), oss_map),
  275. "template_content": row.get("template_content"),
  276. "version_no": int(row.get("version_no") or 0),
  277. "tenant_no": tenant_no,
  278. "order_num": 0,
  279. "remark": _str_or_none(row.get("remark"), 500),
  280. "create_by": map_user(row.get("create_by")),
  281. "create_dept_id": "",
  282. "create_time": row.get("create_time") or datetime.now(),
  283. "update_by": map_user(row.get("update_by")),
  284. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  285. "del_flag": int(row.get("del_flag") or 0),
  286. }
  287. def transform_quote_product(
  288. row: dict,
  289. tenant_no: str,
  290. crm_user_map: Dict[int, str],
  291. admin_id: Optional[str],
  292. stats: MigrationStats,
  293. ) -> dict:
  294. rid = legacy_id_to_str(row["id"])
  295. label = f"quote_product#{rid}"
  296. def map_user(field_val: Any) -> Optional[str]:
  297. return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
  298. return {
  299. "id": rid,
  300. "quote_id": legacy_id_to_str(row.get("quote_id")) or "",
  301. "product_id": legacy_id_to_str(row.get("product_id")) or "",
  302. "product_name": (row.get("product_name") or "")[:200],
  303. "product_no": _str_or_none(row.get("product_no"), 50),
  304. "category_id": legacy_id_to_str(row.get("category_id")),
  305. "category_name": _str_or_none(row.get("category_name"), 100),
  306. "model": _str_or_none(row.get("model"), 100),
  307. "unit": (row.get("unit") or "件")[:20],
  308. "quantity": _dec(row.get("quantity"), Decimal("0")),
  309. "price": _dec(row.get("price"), Decimal("0.00")),
  310. "discount": _dec(row.get("discount"), Decimal("0.00")),
  311. "subtotal": _dec(row.get("subtotal"), Decimal("0.00")),
  312. "tax_amount": _dec(row.get("tax_amount"), Decimal("0.00")),
  313. "tax_rate": _dec(row.get("tax_rate"), Decimal("13.00")),
  314. "package_spec": _str_or_none(row.get("package_spec"), 200),
  315. "delivery_method": _str_or_none(row.get("delivery_method"), 200),
  316. "description": _str_or_none(row.get("description"), 1000),
  317. "sort": int(row.get("sort") or 0),
  318. "tenant_no": tenant_no,
  319. "order_num": 0,
  320. "remark": _str_or_none(row.get("remark"), 500),
  321. "create_by": map_user(row.get("create_by")),
  322. "create_dept_id": "",
  323. "create_time": row.get("create_time") or datetime.now(),
  324. "update_by": map_user(row.get("update_by")),
  325. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  326. "del_flag": int(row.get("del_flag") or 0),
  327. }
  328. def transform_quote_approval(
  329. row: dict,
  330. tenant_no: str,
  331. crm_user_map: Dict[int, str],
  332. admin_id: Optional[str],
  333. stats: MigrationStats,
  334. ) -> dict:
  335. rid = legacy_id_to_str(row["id"])
  336. label = f"quote_approval#{rid}"
  337. def map_user(field_val: Any) -> Optional[str]:
  338. return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
  339. return {
  340. "id": rid,
  341. "quote_id": legacy_id_to_str(row.get("quote_id")) or "",
  342. "quote_no": _str_or_none(row.get("quote_no"), 50),
  343. "step": int(row.get("step") or 1),
  344. "approver_id": map_user(row.get("approver_id")) or "",
  345. "approver_name": _str_or_none(row.get("approver_name"), 50),
  346. "approval_result": (row.get("approval_result") or "pending")[:20],
  347. "approval_opinion": row.get("approval_opinion"),
  348. "approval_time": row.get("approval_time"),
  349. "tenant_no": tenant_no,
  350. "order_num": 0,
  351. "remark": _str_or_none(row.get("remark"), 500),
  352. "create_by": map_user(row.get("create_by")),
  353. "create_dept_id": "",
  354. "create_time": row.get("create_time") or datetime.now(),
  355. "update_by": map_user(row.get("update_by")),
  356. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  357. "del_flag": int(row.get("del_flag") or 0),
  358. }
  359. def transform_quote_share_log(
  360. row: dict,
  361. tenant_no: str,
  362. stats: MigrationStats,
  363. ) -> dict:
  364. rid = legacy_id_to_str(row["id"])
  365. return {
  366. "id": rid,
  367. "quote_id": legacy_id_to_str(row.get("quote_id")) or "",
  368. "share_code": (row.get("share_code") or "")[:50],
  369. "visit_time": row.get("visit_time") or row.get("create_time") or datetime.now(),
  370. "ip_address": _str_or_none(row.get("ip_address"), 50),
  371. "location": _str_or_none(row.get("location"), 200),
  372. "device_type": _str_or_none(row.get("device_type"), 50),
  373. "browser": _str_or_none(row.get("browser"), 100),
  374. "stay_duration": int(row.get("stay_duration") or 0),
  375. "page_views": int(row.get("page_views") or 1),
  376. "tenant_no": tenant_no,
  377. "order_num": 0,
  378. "remark": None,
  379. "create_by": None,
  380. "create_dept_id": "",
  381. "create_time": row.get("create_time") or row.get("visit_time") or datetime.now(),
  382. "update_by": None,
  383. "update_time": row.get("create_time") or row.get("visit_time") or datetime.now(),
  384. "del_flag": 0,
  385. }
  386. INSERT_SUPPLIER_SQL = text(
  387. """
  388. INSERT INTO crm_supplier_info (
  389. id, supplier_name, short_name, supplier_code, supplier_address, legal_person, agent,
  390. phone_fax, bank_name, bank_account, status, tenant_no, order_num, remark,
  391. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  392. ) VALUES (
  393. :id, :supplier_name, :short_name, :supplier_code, :supplier_address, :legal_person, :agent,
  394. :phone_fax, :bank_name, :bank_account, :status, :tenant_no, :order_num, :remark,
  395. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  396. )
  397. """
  398. )
  399. INSERT_QUOTE_SQL = text(
  400. """
  401. INSERT INTO crm_quote (
  402. id, quote_no, quote_title, quote_type, customer_id, customer_name, opportunity_id,
  403. contact_id, contact_name, contact_mobile, contact_email, version, parent_id, is_latest,
  404. total_amount, discount_rate, discount_amount, final_amount, validity_days, quote_date,
  405. expire_date, status, reject_reason, pdf_url, pdf_generated, share_link, share_code,
  406. share_password, share_expire_time, view_count, last_view_time, owner_user_id, owner_user_name,
  407. supplier_id, supplier_name, contract_id, payment_terms, delivery_terms, freight,
  408. attachments, template_content, version_no, tenant_no, order_num, remark,
  409. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  410. ) VALUES (
  411. :id, :quote_no, :quote_title, :quote_type, :customer_id, :customer_name, :opportunity_id,
  412. :contact_id, :contact_name, :contact_mobile, :contact_email, :version, :parent_id, :is_latest,
  413. :total_amount, :discount_rate, :discount_amount, :final_amount, :validity_days, :quote_date,
  414. :expire_date, :status, :reject_reason, :pdf_url, :pdf_generated, :share_link, :share_code,
  415. :share_password, :share_expire_time, :view_count, :last_view_time, :owner_user_id, :owner_user_name,
  416. :supplier_id, :supplier_name, :contract_id, :payment_terms, :delivery_terms, :freight,
  417. :attachments, :template_content, :version_no, :tenant_no, :order_num, :remark,
  418. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  419. )
  420. """
  421. )
  422. INSERT_QUOTE_PRODUCT_SQL = text(
  423. """
  424. INSERT INTO crm_quote_product (
  425. id, quote_id, product_id, product_name, product_no, category_id, category_name,
  426. model, unit, quantity, price, discount, subtotal, tax_amount, tax_rate,
  427. package_spec, delivery_method, description, sort,
  428. tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
  429. ) VALUES (
  430. :id, :quote_id, :product_id, :product_name, :product_no, :category_id, :category_name,
  431. :model, :unit, :quantity, :price, :discount, :subtotal, :tax_amount, :tax_rate,
  432. :package_spec, :delivery_method, :description, :sort,
  433. :tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  434. )
  435. """
  436. )
  437. INSERT_QUOTE_APPROVAL_SQL = text(
  438. """
  439. INSERT INTO crm_quote_approval (
  440. id, quote_id, quote_no, step, approver_id, approver_name, approval_result,
  441. approval_opinion, approval_time, tenant_no, order_num, remark,
  442. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  443. ) VALUES (
  444. :id, :quote_id, :quote_no, :step, :approver_id, :approver_name, :approval_result,
  445. :approval_opinion, :approval_time, :tenant_no, :order_num, :remark,
  446. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  447. )
  448. """
  449. )
  450. INSERT_QUOTE_SHARE_LOG_SQL = text(
  451. """
  452. INSERT INTO crm_quote_share_log (
  453. id, quote_id, share_code, visit_time, ip_address, location, device_type, browser,
  454. stay_duration, page_views, tenant_no, order_num, remark,
  455. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  456. ) VALUES (
  457. :id, :quote_id, :share_code, :visit_time, :ip_address, :location, :device_type, :browser,
  458. :stay_duration, :page_views, :tenant_no, :order_num, :remark,
  459. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  460. )
  461. """
  462. )
  463. STEP_INSERT_SQL = {
  464. "crm_supplier_info": INSERT_SUPPLIER_SQL,
  465. "crm_quote": INSERT_QUOTE_SQL,
  466. "crm_quote_product": INSERT_QUOTE_PRODUCT_SQL,
  467. "crm_quote_approval": INSERT_QUOTE_APPROVAL_SQL,
  468. "crm_quote_share_log": INSERT_QUOTE_SHARE_LOG_SQL,
  469. }
  470. def write_mapping_csv(source_rows: List[dict], path: Path) -> None:
  471. path.parent.mkdir(parents=True, exist_ok=True)
  472. migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  473. with path.open("w", encoding="utf-8", newline="") as f:
  474. w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
  475. w.writeheader()
  476. for row in source_rows:
  477. sid = legacy_id_to_str(row["id"])
  478. w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
  479. def migrate_table(
  480. source_engine: Engine,
  481. target_engine: Engine,
  482. table: str,
  483. *,
  484. tenant_no: str,
  485. crm_user_map: Dict[int, str],
  486. admin_id: Optional[str],
  487. oss_map: Dict[str, str],
  488. stats: MigrationStats,
  489. dry_run: bool,
  490. update_existing: bool,
  491. include_deleted: bool,
  492. ) -> List[dict]:
  493. step = stats.step(table)
  494. source_rows = fetch_source_rows(source_engine, table, include_deleted)
  495. step.source_count = len(source_rows)
  496. transformers = {
  497. "crm_supplier_info": lambda r: transform_supplier_info(r, tenant_no, crm_user_map, admin_id, stats),
  498. "crm_quote": lambda r: transform_quote(r, tenant_no, crm_user_map, admin_id, oss_map, stats),
  499. "crm_quote_product": lambda r: transform_quote_product(r, tenant_no, crm_user_map, admin_id, stats),
  500. "crm_quote_approval": lambda r: transform_quote_approval(r, tenant_no, crm_user_map, admin_id, stats),
  501. "crm_quote_share_log": lambda r: transform_quote_share_log(r, tenant_no, stats),
  502. }
  503. payloads = [transformers[table](r) for r in source_rows]
  504. if dry_run:
  505. step.inserted = len(payloads)
  506. return source_rows
  507. insert_sql = STEP_INSERT_SQL[table]
  508. with target_engine.begin() as conn:
  509. for payload in payloads:
  510. rid = payload["id"]
  511. if target_row_exists(conn, table, rid):
  512. if update_existing:
  513. step.skipped += 1
  514. else:
  515. step.skipped += 1
  516. else:
  517. conn.execute(insert_sql, payload)
  518. step.inserted += 1
  519. return source_rows
  520. def print_report(stats: MigrationStats, dry_run: bool) -> None:
  521. mode = "DRY-RUN" if dry_run else "COMMITTED"
  522. print(f"\n=== CRM 报价单迁移 [{mode}] ===")
  523. for table, _ in MIGRATION_STEPS:
  524. s = stats.step(table)
  525. print(f"{table}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
  526. if stats.warnings:
  527. print(f"警告 ({len(stats.warnings)}):")
  528. for w in stats.warnings[:20]:
  529. print(f" - {w}")
  530. if len(stats.warnings) > 20:
  531. print(f" ... 另有 {len(stats.warnings) - 20} 条")
  532. def run_migration(
  533. source_engine: Engine,
  534. target_engine: Engine,
  535. *,
  536. tenant_no: str,
  537. user_mapping_path: Path,
  538. mapping_dir: Path,
  539. dry_run: bool,
  540. update_existing: bool,
  541. include_deleted: bool,
  542. only_tables: Optional[List[str]],
  543. skip_supplier: bool,
  544. ) -> MigrationStats:
  545. stats = MigrationStats()
  546. crm_user_map, admin_id = load_user_mapping(user_mapping_path)
  547. if not admin_id:
  548. admin_id = fetch_portal_admin_id(target_engine)
  549. oss_map = load_oss_id_to_url(target_engine, tenant_no)
  550. print(f"sys_oss 映射条数: {len(oss_map)}")
  551. selected = [t for t, _ in MIGRATION_STEPS if not only_tables or t in only_tables]
  552. if skip_supplier and "crm_supplier_info" in selected:
  553. selected = [t for t in selected if t != "crm_supplier_info"]
  554. for table in selected:
  555. print(f"\n>> 迁移 {table} ...")
  556. source_rows = migrate_table(
  557. source_engine,
  558. target_engine,
  559. table,
  560. tenant_no=tenant_no,
  561. crm_user_map=crm_user_map,
  562. admin_id=admin_id,
  563. oss_map=oss_map,
  564. stats=stats,
  565. dry_run=dry_run,
  566. update_existing=update_existing,
  567. include_deleted=include_deleted,
  568. )
  569. if table == "crm_quote" and not dry_run:
  570. write_mapping_csv(source_rows, mapping_dir / "crm_quote_id_mapping.csv")
  571. print_report(stats, dry_run)
  572. return stats
  573. def main() -> int:
  574. parser = argparse.ArgumentParser(description="Migrate CRM quote tables from sx to portal")
  575. parser.add_argument("--source-url", default="")
  576. parser.add_argument("--target-url", default="")
  577. parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
  578. parser.add_argument(
  579. "--user-mapping",
  580. default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
  581. )
  582. parser.add_argument("--dry-run", action="store_true")
  583. parser.add_argument("--update", action="store_true")
  584. parser.add_argument("--include-deleted", action="store_true")
  585. parser.add_argument("--skip-supplier", action="store_true", help="跳过 crm_supplier_info 前置迁移")
  586. parser.add_argument("--tables", default="", help="逗号分隔,默认全部表")
  587. parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
  588. args = parser.parse_args()
  589. load_settings_env(args.env_file or None)
  590. settings = load_runtime_settings()
  591. target_url = args.target_url or settings.DATABASE_URL
  592. source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
  593. only_tables = [t.strip() for t in args.tables.split(",") if t.strip()] or None
  594. print(f"源库: {urlparse(source_url).path}")
  595. print(f"目标库: {urlparse(target_url).path}")
  596. run_migration(
  597. create_engine(source_url, pool_pre_ping=True),
  598. create_engine(target_url, pool_pre_ping=True),
  599. tenant_no=args.tenant_no,
  600. user_mapping_path=Path(args.user_mapping),
  601. mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
  602. dry_run=args.dry_run,
  603. update_existing=args.update,
  604. include_deleted=args.include_deleted,
  605. only_tables=only_tables,
  606. skip_supplier=args.skip_supplier,
  607. )
  608. return 0
  609. if __name__ == "__main__":
  610. raise SystemExit(main())