migrate_dibang_data.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. #!/usr/bin/env python3
  2. """Migrate legacy dibang business data into shanxia2.0 db_* tables.
  3. Reads from the original three tables (purchase_plans, inbound_outbound,
  4. weighing_slips), preserves legacy int IDs as VARCHAR primary keys (e.g. 12 → "12"),
  5. remaps foreign keys, and writes portal public fields (tenant_no, del_flag, etc.).
  6. Usage:
  7. cd backend && source venv/bin/activate
  8. python scripts/migrate_dibang_data.py --dry-run \\
  9. --source-url mysql+pymysql://user:pass@127.0.0.1:3306/dibang
  10. python scripts/migrate_dibang_data.py \\
  11. --source-url mysql+pymysql://user:pass@127.0.0.1:3306/dibang \\
  12. --mapping-out ./dibang-id-map.json
  13. Environment:
  14. DIBANG_SOURCE_DATABASE_URL default for --source-url
  15. DATABASE_URL target DB (from app settings / .env)
  16. """
  17. from __future__ import annotations
  18. import argparse
  19. import json
  20. import os
  21. import sys
  22. from collections import defaultdict
  23. from dataclasses import dataclass, field
  24. from datetime import date, datetime
  25. from pathlib import Path
  26. from typing import Any, Dict, Iterable, List, Optional, Tuple
  27. from sqlalchemy import create_engine, text
  28. from sqlalchemy.engine import Engine
  29. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  30. if str(BACKEND_ROOT) not in sys.path:
  31. sys.path.insert(0, str(BACKEND_ROOT))
  32. from app.core.config import settings
  33. SOURCE_TABLES = {
  34. "purchase_plans": "purchase_plans",
  35. "inbound_outbound": "inbound_outbound",
  36. "weighing_slips": "weighing_slips",
  37. }
  38. TARGET_TABLES = {
  39. "purchase_plans": "db_purchase_plan",
  40. "inbound_outbound": "db_inbound_outbound",
  41. "weighing_slips": "db_weighing_slip",
  42. }
  43. @dataclass
  44. class MigrationStats:
  45. source_counts: Dict[str, int] = field(default_factory=dict)
  46. inserted: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
  47. skipped: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
  48. warnings: List[str] = field(default_factory=list)
  49. orphan_fks: List[str] = field(default_factory=list)
  50. def parse_date(value: Any) -> Optional[date]:
  51. if value is None or value == "":
  52. return None
  53. if isinstance(value, date) and not isinstance(value, datetime):
  54. return value
  55. text_value = str(value).strip()
  56. if not text_value:
  57. return None
  58. for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
  59. try:
  60. return datetime.strptime(text_value[:10], fmt).date()
  61. except ValueError:
  62. continue
  63. raise ValueError(f"无法解析日期: {value!r}")
  64. def parse_datetime(value: Any) -> Optional[datetime]:
  65. if value is None or value == "":
  66. return None
  67. if isinstance(value, datetime):
  68. return value
  69. text_value = str(value).strip()
  70. if not text_value:
  71. return None
  72. formats: Iterable[Tuple[str, int]] = (
  73. ("%Y-%m-%d %H:%M:%S", 19),
  74. ("%Y-%m-%d %H:%M", 16),
  75. ("%Y-%m-%d", 10),
  76. ("%Y/%m/%d %H:%M:%S", 19),
  77. ("%Y/%m/%d", 10),
  78. )
  79. for fmt, length in formats:
  80. try:
  81. return datetime.strptime(text_value[:length], fmt)
  82. except ValueError:
  83. continue
  84. parsed = parse_date(text_value)
  85. return datetime.combine(parsed, datetime.min.time()) if parsed else None
  86. def clip_text(value: Any, max_len: int, label: str, stats: MigrationStats) -> Optional[str]:
  87. if value is None:
  88. return None
  89. text_value = str(value)
  90. if len(text_value) <= max_len:
  91. return text_value
  92. stats.warnings.append(f"{label} 截断至 {max_len} 字符")
  93. return text_value[:max_len]
  94. def fetch_rows(engine: Engine, table: str) -> List[dict]:
  95. with engine.connect() as conn:
  96. result = conn.execute(text(f"SELECT * FROM `{table}` ORDER BY `id`"))
  97. return [dict(row._mapping) for row in result]
  98. def count_rows(engine: Engine, table: str) -> int:
  99. with engine.connect() as conn:
  100. return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}`")).scalar_one())
  101. def map_id(
  102. table_key: str,
  103. legacy_id: Any,
  104. id_maps: Dict[str, Dict[int, str]],
  105. stats: MigrationStats,
  106. label: str,
  107. ) -> Optional[str]:
  108. if legacy_id is None:
  109. return None
  110. try:
  111. legacy_int = int(legacy_id)
  112. except (TypeError, ValueError):
  113. stats.orphan_fks.append(f"{label}: 非法 legacy id {legacy_id!r}")
  114. return None
  115. mapped = id_maps.get(table_key, {}).get(legacy_int)
  116. if mapped is None:
  117. stats.orphan_fks.append(f"{label}: legacy id {legacy_int} 无映射")
  118. return mapped
  119. def legacy_id_to_str(legacy_id: int) -> str:
  120. """原地磅 int 主键原样保留,写入门户 VARCHAR(64) 字段。"""
  121. return str(int(legacy_id))
  122. def build_id_maps(rows_by_table: Dict[str, List[dict]]) -> Dict[str, Dict[int, str]]:
  123. id_maps: Dict[str, Dict[int, str]] = {}
  124. for table_key, rows in rows_by_table.items():
  125. id_maps[table_key] = {
  126. int(row["id"]): legacy_id_to_str(int(row["id"])) for row in rows
  127. }
  128. return id_maps
  129. def transform_plan(
  130. row: dict,
  131. new_id: str,
  132. tenant_no: str,
  133. stats: MigrationStats,
  134. *,
  135. last_weighing_slip_id: Optional[str] = None,
  136. ) -> dict:
  137. create_time = parse_datetime(row.get("created_at")) or datetime.now()
  138. plan_date = parse_date(row.get("plan_date")) or parse_date(row.get("start_date")) or create_time.date()
  139. return {
  140. "id": new_id,
  141. "plan_no": row.get("plan_no"),
  142. "plan_date": plan_date,
  143. "start_date": parse_date(row.get("start_date")),
  144. "end_date": parse_date(row.get("end_date")),
  145. "business_type": row["business_type"],
  146. "product": row["product"],
  147. "specification": row.get("specification"),
  148. "planned_quantity": float(row["planned_quantity"]),
  149. "vehicle_no": row.get("vehicle_no") or "",
  150. "customer_name": row.get("customer_name"),
  151. "execution_status": row.get("execution_status"),
  152. "executed_at": parse_datetime(row.get("executed_at")),
  153. "last_weighing_slip_id": last_weighing_slip_id,
  154. "executed_quantity": float(row.get("executed_quantity") or 0),
  155. "auto_generate_enabled": int(row.get("auto_generate_enabled") or 0),
  156. "auto_generate_end_month": row.get("auto_generate_end_month"),
  157. "auto_generate_last_month": row.get("auto_generate_last_month"),
  158. "tenant_no": tenant_no,
  159. "order_num": None,
  160. "remark": clip_text(row.get("remarks"), 512, "plan.remark", stats),
  161. "create_by": None,
  162. "create_dept_id": "",
  163. "create_time": create_time,
  164. "update_by": None,
  165. "update_time": create_time,
  166. "del_flag": int(row.get("is_deleted") or 0),
  167. }
  168. def transform_inventory(
  169. row: dict,
  170. new_id: str,
  171. tenant_no: str,
  172. id_maps: Dict[str, Dict[int, str]],
  173. stats: MigrationStats,
  174. ) -> dict:
  175. create_time = parse_datetime(row.get("created_at")) or datetime.now()
  176. return {
  177. "id": new_id,
  178. "date": parse_date(row.get("date")) or create_time.date(),
  179. "partner_company": row["partner_company"],
  180. "business_type": row["business_type"],
  181. "product": row["product"],
  182. "quantity": float(row["quantity"]),
  183. "plan_no": row.get("plan_no"),
  184. "batch_no": row.get("batch_no"),
  185. "vehicle_no": row["vehicle_no"],
  186. "status": row.get("status"),
  187. "purchase_plan_id": map_id(
  188. "purchase_plans",
  189. row.get("purchase_plan_id"),
  190. id_maps,
  191. stats,
  192. f"inventory#{row.get('id')} purchase_plan_id",
  193. ),
  194. "tenant_no": tenant_no,
  195. "order_num": None,
  196. "remark": clip_text(row.get("remarks"), 512, "inventory.remark", stats),
  197. "create_by": None,
  198. "create_dept_id": "",
  199. "create_time": create_time,
  200. "update_by": None,
  201. "update_time": create_time,
  202. "del_flag": int(row.get("is_deleted") or 0),
  203. }
  204. def transform_weighing(
  205. row: dict,
  206. new_id: str,
  207. tenant_no: str,
  208. id_maps: Dict[str, Dict[int, str]],
  209. stats: MigrationStats,
  210. ) -> dict:
  211. create_time = parse_datetime(row.get("created_at")) or datetime.now()
  212. gross_time = parse_datetime(row.get("gross_weight_time")) or create_time
  213. tare_time = parse_datetime(row.get("tare_weight_time"))
  214. return {
  215. "id": new_id,
  216. "vehicle_no": row["vehicle_no"],
  217. "plan_no": row.get("plan_no"),
  218. "cargo_name": row["cargo_name"],
  219. "business_type": row["business_type"],
  220. "batch_no": row.get("batch_no"),
  221. "supplier": row["supplier"],
  222. "consignee": row["consignee"],
  223. "gross_weight": float(row["gross_weight"]),
  224. "gross_weight_time": gross_time,
  225. "tare_weight": float(row["tare_weight"]),
  226. "tare_weight_time": tare_time,
  227. "deduction": float(row.get("deduction") or 0),
  228. "net_weight": float(row["net_weight"]),
  229. "planned_quantity": row.get("planned_quantity"),
  230. "executed_quantity": row.get("executed_quantity"),
  231. "purchase_plan_id": map_id(
  232. "purchase_plans",
  233. row.get("purchase_plan_id"),
  234. id_maps,
  235. stats,
  236. f"weighing#{row.get('id')} purchase_plan_id",
  237. ),
  238. "inbound_outbound_id": map_id(
  239. "inbound_outbound",
  240. row.get("inbound_outbound_id"),
  241. id_maps,
  242. stats,
  243. f"weighing#{row.get('id')} inbound_outbound_id",
  244. ),
  245. "weighing_point": row.get("weighing_point") or "山峡地磅",
  246. "current_quantity": row.get("current_quantity"),
  247. "is_voided": int(row.get("is_voided") or 0),
  248. "tenant_no": tenant_no,
  249. "order_num": None,
  250. "remark": clip_text(row.get("remarks"), 512, "weighing.remark", stats),
  251. "create_by": None,
  252. "create_dept_id": "",
  253. "create_time": create_time,
  254. "update_by": None,
  255. "update_time": create_time,
  256. "del_flag": 0,
  257. }
  258. INSERT_SQL = {
  259. "purchase_plans": """
  260. INSERT INTO db_purchase_plan (
  261. id, plan_no, plan_date, start_date, end_date, business_type, product, specification,
  262. planned_quantity, vehicle_no, customer_name, execution_status, executed_at,
  263. last_weighing_slip_id, executed_quantity, auto_generate_enabled,
  264. auto_generate_end_month, auto_generate_last_month,
  265. tenant_no, order_num, remark, create_by, create_dept_id, create_time,
  266. update_by, update_time, del_flag
  267. ) VALUES (
  268. :id, :plan_no, :plan_date, :start_date, :end_date, :business_type, :product, :specification,
  269. :planned_quantity, :vehicle_no, :customer_name, :execution_status, :executed_at,
  270. :last_weighing_slip_id, :executed_quantity, :auto_generate_enabled,
  271. :auto_generate_end_month, :auto_generate_last_month,
  272. :tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time,
  273. :update_by, :update_time, :del_flag
  274. )
  275. """,
  276. "inbound_outbound": """
  277. INSERT INTO db_inbound_outbound (
  278. id, date, partner_company, business_type, product, quantity, plan_no, batch_no,
  279. vehicle_no, status, purchase_plan_id, tenant_no, order_num, remark,
  280. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  281. ) VALUES (
  282. :id, :date, :partner_company, :business_type, :product, :quantity, :plan_no, :batch_no,
  283. :vehicle_no, :status, :purchase_plan_id, :tenant_no, :order_num, :remark,
  284. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  285. )
  286. """,
  287. "weighing_slips": """
  288. INSERT INTO db_weighing_slip (
  289. id, vehicle_no, plan_no, cargo_name, business_type, batch_no, supplier, consignee,
  290. gross_weight, gross_weight_time, tare_weight, tare_weight_time, deduction, net_weight,
  291. planned_quantity, executed_quantity, purchase_plan_id, inbound_outbound_id,
  292. weighing_point, current_quantity, is_voided, tenant_no, order_num, remark,
  293. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  294. ) VALUES (
  295. :id, :vehicle_no, :plan_no, :cargo_name, :business_type, :batch_no, :supplier, :consignee,
  296. :gross_weight, :gross_weight_time, :tare_weight, :tare_weight_time, :deduction, :net_weight,
  297. :planned_quantity, :executed_quantity, :purchase_plan_id, :inbound_outbound_id,
  298. :weighing_point, :current_quantity, :is_voided, :tenant_no, :order_num, :remark,
  299. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  300. )
  301. """,
  302. }
  303. UPDATE_PLAN_LAST_WEIGHING_SQL = """
  304. UPDATE db_purchase_plan
  305. SET last_weighing_slip_id = :last_weighing_slip_id, update_time = :update_time
  306. WHERE id = :id
  307. """
  308. def truncate_target_tables(conn, tenant_no: str) -> None:
  309. for target in TARGET_TABLES.values():
  310. conn.execute(
  311. text(f"DELETE FROM `{target}` WHERE tenant_no = :tenant_no"),
  312. {"tenant_no": tenant_no},
  313. )
  314. def export_mapping(id_maps: Dict[str, Dict[int, str]], path: Path) -> None:
  315. payload = {
  316. table_key: {str(old_id): new_id for old_id, new_id in mapping.items()}
  317. for table_key, mapping in id_maps.items()
  318. }
  319. path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  320. def print_report(stats: MigrationStats, dry_run: bool) -> None:
  321. mode = "DRY-RUN" if dry_run else "EXECUTED"
  322. print(f"\n=== 地磅历史数据迁移 ({mode}) ===")
  323. print("\n源表行数:")
  324. for key, count in stats.source_counts.items():
  325. print(f" {SOURCE_TABLES[key]}: {count}")
  326. if not dry_run:
  327. print("\n已写入目标表:")
  328. for key, count in stats.inserted.items():
  329. print(f" {TARGET_TABLES[key]}: {count}")
  330. if stats.warnings:
  331. print(f"\n警告 ({len(stats.warnings)}):")
  332. for item in stats.warnings[:20]:
  333. print(f" - {item}")
  334. if len(stats.warnings) > 20:
  335. print(f" ... 另有 {len(stats.warnings) - 20} 条")
  336. if stats.orphan_fks:
  337. print(f"\n关联 ID 缺失 ({len(stats.orphan_fks)}):")
  338. for item in stats.orphan_fks[:20]:
  339. print(f" - {item}")
  340. if len(stats.orphan_fks) > 20:
  341. print(f" ... 另有 {len(stats.orphan_fks) - 20} 条")
  342. else:
  343. print("\n关联 ID 校验: 通过(无 orphan FK)")
  344. def migrate(
  345. source_engine: Engine,
  346. target_engine: Engine,
  347. *,
  348. tenant_no: str,
  349. dry_run: bool,
  350. truncate_target: bool,
  351. include_deleted: bool,
  352. mapping_out: Optional[Path],
  353. ) -> MigrationStats:
  354. stats = MigrationStats()
  355. rows_by_table: Dict[str, List[dict]] = {}
  356. for key, source_table in SOURCE_TABLES.items():
  357. rows = fetch_rows(source_engine, source_table)
  358. if not include_deleted:
  359. rows = [row for row in rows if int(row.get("is_deleted") or 0) == 0]
  360. rows_by_table[key] = rows
  361. stats.source_counts[key] = len(rows)
  362. id_maps = build_id_maps(rows_by_table)
  363. transformed: Dict[str, List[dict]] = {
  364. "purchase_plans": [
  365. transform_plan(
  366. row,
  367. id_maps["purchase_plans"][int(row["id"])],
  368. tenant_no,
  369. stats,
  370. last_weighing_slip_id=None,
  371. )
  372. for row in rows_by_table["purchase_plans"]
  373. ],
  374. "inbound_outbound": [
  375. transform_inventory(
  376. row,
  377. id_maps["inbound_outbound"][int(row["id"])],
  378. tenant_no,
  379. id_maps,
  380. stats,
  381. )
  382. for row in rows_by_table["inbound_outbound"]
  383. ],
  384. "weighing_slips": [
  385. transform_weighing(
  386. row,
  387. id_maps["weighing_slips"][int(row["id"])],
  388. tenant_no,
  389. id_maps,
  390. stats,
  391. )
  392. for row in rows_by_table["weighing_slips"]
  393. ],
  394. }
  395. plan_last_weighing_updates: List[Tuple[str, Optional[str], datetime]] = []
  396. for row in rows_by_table["purchase_plans"]:
  397. legacy_last = row.get("last_weighing_slip_id")
  398. if legacy_last is None:
  399. continue
  400. plan_new_id = id_maps["purchase_plans"][int(row["id"])]
  401. weighing_new_id = map_id(
  402. "weighing_slips",
  403. legacy_last,
  404. id_maps,
  405. stats,
  406. f"plan#{row.get('id')} last_weighing_slip_id",
  407. )
  408. update_time = parse_datetime(row.get("executed_at")) or parse_datetime(row.get("created_at")) or datetime.now()
  409. plan_last_weighing_updates.append((plan_new_id, weighing_new_id, update_time))
  410. if dry_run:
  411. for key, rows in transformed.items():
  412. stats.inserted[key] = len(rows)
  413. print_report(stats, dry_run=True)
  414. return stats
  415. with target_engine.begin() as conn:
  416. if truncate_target:
  417. truncate_target_tables(conn, tenant_no)
  418. for key in ("purchase_plans", "inbound_outbound", "weighing_slips"):
  419. for payload in transformed[key]:
  420. conn.execute(text(INSERT_SQL[key]), payload)
  421. stats.inserted[key] = len(transformed[key])
  422. for plan_id, weighing_id, update_time in plan_last_weighing_updates:
  423. conn.execute(
  424. text(UPDATE_PLAN_LAST_WEIGHING_SQL),
  425. {
  426. "id": plan_id,
  427. "last_weighing_slip_id": weighing_id,
  428. "update_time": update_time,
  429. },
  430. )
  431. if mapping_out:
  432. export_mapping(id_maps, mapping_out)
  433. print(f"\nID 映射已写入: {mapping_out}")
  434. print_report(stats, dry_run=False)
  435. return stats
  436. def main() -> int:
  437. parser = argparse.ArgumentParser(description="Migrate legacy dibang data into db_* tables")
  438. parser.add_argument(
  439. "--source-url",
  440. default="",
  441. help="Source MySQL URL (legacy dibang DB). Falls back to DIBANG_SOURCE_DATABASE_URL",
  442. )
  443. parser.add_argument(
  444. "--target-url",
  445. default="",
  446. help="Target MySQL URL (portal DB). Defaults to app DATABASE_URL",
  447. )
  448. parser.add_argument(
  449. "--tenant-no",
  450. default="shanxia2.0",
  451. help="Tenant number written to db_* rows",
  452. )
  453. parser.add_argument(
  454. "--dry-run",
  455. action="store_true",
  456. help="Validate and print counts without writing",
  457. )
  458. parser.add_argument(
  459. "--truncate-target",
  460. action="store_true",
  461. help="Delete existing db_* rows for tenant before import (use with care)",
  462. )
  463. parser.add_argument(
  464. "--include-deleted",
  465. action="store_true",
  466. help="Include source rows with is_deleted=1 (maps to del_flag=1)",
  467. )
  468. parser.add_argument(
  469. "--mapping-out",
  470. default="",
  471. help="Write legacy_id→new_id mapping JSON to this path (identity when IDs preserved)",
  472. )
  473. args = parser.parse_args()
  474. source_url = args.source_url or os.environ.get("DIBANG_SOURCE_DATABASE_URL", "")
  475. target_url = args.target_url or settings.DATABASE_URL
  476. if not source_url:
  477. print("错误: 请通过 --source-url 或环境变量 DIBANG_SOURCE_DATABASE_URL 指定源地磅库连接", file=sys.stderr)
  478. return 2
  479. if args.truncate_target and args.dry_run:
  480. print("错误: --truncate-target 不能与 --dry-run 同时使用", file=sys.stderr)
  481. return 2
  482. source_engine = create_engine(source_url, pool_pre_ping=True)
  483. target_engine = create_engine(target_url, pool_pre_ping=True)
  484. mapping_out = Path(args.mapping_out) if args.mapping_out else None
  485. try:
  486. migrate(
  487. source_engine,
  488. target_engine,
  489. tenant_no=args.tenant_no,
  490. dry_run=args.dry_run,
  491. truncate_target=args.truncate_target,
  492. include_deleted=args.include_deleted,
  493. mapping_out=mapping_out,
  494. )
  495. except Exception as exc:
  496. print(f"迁移失败: {exc}", file=sys.stderr)
  497. raise
  498. return 0
  499. if __name__ == "__main__":
  500. raise SystemExit(main())