migrate_bx_data.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827
  1. #!/usr/bin/env python3
  2. """将 sx 库报销(bx_*)数据迁入 shanxia2.0。
  3. 阶段:
  4. base — 费用项目 + 工作流定义/节点(替换门户预置工作流)
  5. advance — 备用金申请 + 审批
  6. reimburse — 报销单 + 明细 + 审批 + 冲抵
  7. attachment— 附件(依赖业务单 biz_id)
  8. all — 按依赖顺序全部执行
  9. 用户外键:docs/data-migration/mappings/user_mapping_result.csv(bx_user_id);
  10. bx_user_id=1(原系统管理员)→ 门户 admin。
  11. 用法:
  12. cd backend
  13. venv/bin/python scripts/migrate_bx_data.py --dry-run --phase base --env-file .env.production
  14. venv/bin/python scripts/migrate_bx_data.py --phase all --env-file .env.production
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import csv
  19. import os
  20. import re
  21. import sys
  22. from dataclasses import dataclass, field
  23. from datetime import date, datetime
  24. from decimal import Decimal
  25. from pathlib import Path
  26. from typing import Any, Callable, Dict, List, Optional, Tuple
  27. from urllib.parse import urlparse, urlunparse
  28. from sqlalchemy import create_engine, text
  29. from sqlalchemy.engine import Engine
  30. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  31. if str(BACKEND_ROOT) not in sys.path:
  32. sys.path.insert(0, str(BACKEND_ROOT))
  33. DEFAULT_TENANT_NO = "shanxia2.0"
  34. BX_ADMIN_SOURCE_USER_ID = 1
  35. PHASE_STEPS: Dict[str, Tuple[str, ...]] = {
  36. "base": ("bx_expense_item", "bx_workflow_def", "bx_workflow_node"),
  37. "advance": ("bx_advance_apply", "bx_advance_approval"),
  38. "reimburse": (
  39. "bx_reimburse_apply",
  40. "bx_reimburse_detail",
  41. "bx_reimburse_approval",
  42. "bx_reimburse_offset",
  43. ),
  44. "attachment": ("bx_attachment",),
  45. }
  46. ALL_STEPS = PHASE_STEPS["base"] + PHASE_STEPS["advance"] + PHASE_STEPS["reimburse"] + PHASE_STEPS["attachment"]
  47. APPLY_CODE_RE = re.compile(r"^BX(\d{8})(\d{3})$")
  48. @dataclass
  49. class StepStats:
  50. source_count: int = 0
  51. inserted: int = 0
  52. skipped: int = 0
  53. @dataclass
  54. class MigrationStats:
  55. steps: Dict[str, StepStats] = field(default_factory=dict)
  56. warnings: List[str] = field(default_factory=list)
  57. def step(self, name: str) -> StepStats:
  58. if name not in self.steps:
  59. self.steps[name] = StepStats()
  60. return self.steps[name]
  61. def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
  62. if legacy_id is None or str(legacy_id).strip() == "":
  63. return None
  64. try:
  65. return str(int(legacy_id))
  66. except (TypeError, ValueError):
  67. return str(legacy_id).strip()
  68. def derive_source_url(target_url: str, source_db: str = "sx") -> str:
  69. return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
  70. def load_settings_env(env_file: Optional[str]) -> None:
  71. if env_file:
  72. os.environ["ENV_FILE"] = env_file
  73. from dotenv import load_dotenv
  74. candidates: List[Path] = []
  75. if env_file:
  76. p = Path(env_file)
  77. candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
  78. candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
  79. for path in candidates:
  80. if path.is_file():
  81. load_dotenv(path, override=True)
  82. break
  83. def load_runtime_settings():
  84. from app.core.config import Settings
  85. return Settings()
  86. def load_bx_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
  87. bx_map: Dict[int, str] = {}
  88. admin_id: Optional[str] = None
  89. with path.open(encoding="utf-8-sig", newline="") as f:
  90. for row in csv.DictReader(f):
  91. new_id = (row.get("new_user_id") or "").strip()
  92. user_no = (row.get("user_no") or "").strip()
  93. bx_raw = (row.get("bx_user_id") or "").strip()
  94. if user_no == "admin" and new_id:
  95. admin_id = new_id
  96. if bx_raw and new_id:
  97. try:
  98. bx_map[int(bx_raw)] = new_id
  99. except ValueError:
  100. pass
  101. return bx_map, admin_id
  102. def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
  103. with target_engine.connect() as conn:
  104. row = conn.execute(
  105. text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
  106. ).first()
  107. return str(row[0]) if row and row[0] else None
  108. def map_bx_user_id(
  109. raw: Any,
  110. bx_user_map: Dict[int, str],
  111. admin_id: Optional[str],
  112. stats: MigrationStats,
  113. label: str,
  114. *,
  115. required: bool = False,
  116. ) -> Optional[str]:
  117. if raw is None or str(raw).strip() == "":
  118. return None
  119. text_val = str(raw).strip()
  120. if len(text_val) >= 32 and not text_val.isdigit():
  121. return text_val
  122. try:
  123. uid = int(text_val)
  124. except (TypeError, ValueError):
  125. stats.warnings.append(f"{label}: 非法用户引用 {raw!r}")
  126. return admin_id if required else None
  127. if uid == BX_ADMIN_SOURCE_USER_ID:
  128. return admin_id
  129. mapped = bx_user_map.get(uid)
  130. if not mapped:
  131. stats.warnings.append(f"{label}: bx_user_id={uid} 无映射")
  132. return admin_id if required else None
  133. return mapped
  134. def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
  135. if value is None:
  136. return None
  137. s = str(value).strip()
  138. if not s:
  139. return None
  140. return s[:max_len] if max_len else s
  141. def _dec(value: Any) -> Optional[Decimal]:
  142. if value is None or value == "":
  143. return None
  144. return Decimal(str(value))
  145. def parse_reimburse_meta(apply_code: str) -> Tuple[date, int]:
  146. m = APPLY_CODE_RE.match(apply_code or "")
  147. if not m:
  148. raise ValueError(f"无法解析报销单号: {apply_code!r}")
  149. ymd, seq = m.group(1), int(m.group(2))
  150. d = date(int(ymd[0:4]), int(ymd[4:6]), int(ymd[6:8]))
  151. return d, seq
  152. def fetch_source_rows(
  153. engine: Engine, table: str, *, include_deleted: bool, no_del_flag: bool = False
  154. ) -> List[dict]:
  155. if no_del_flag:
  156. where = ""
  157. elif include_deleted:
  158. where = ""
  159. else:
  160. where = "WHERE del_flag = 0"
  161. sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
  162. with engine.connect() as conn:
  163. return [dict(r._mapping) for r in conn.execute(text(sql))]
  164. def target_row_exists(conn, table: str, row_id: str) -> bool:
  165. return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
  166. def clear_portal_workflow(conn) -> None:
  167. conn.execute(text("DELETE FROM bx_workflow_node"))
  168. conn.execute(text("DELETE FROM bx_workflow_def"))
  169. def transform_expense_item(
  170. row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
  171. ) -> dict:
  172. rid = legacy_id_to_str(row["id"])
  173. label = f"expense#{rid}"
  174. return {
  175. "id": rid,
  176. "parent_id": legacy_id_to_str(row.get("parent_id")) or "0",
  177. "item_name": (row.get("item_name") or "")[:128],
  178. "item_code": _str_or_none(row.get("item_code"), 64),
  179. "level": int(row.get("level") or 1),
  180. "sort_order": int(row.get("sort_order") or 0),
  181. "status": int(row.get("status") or 1),
  182. "tenant_no": tenant_no,
  183. "order_num": 0,
  184. "remark": _str_or_none(row.get("remark"), 512),
  185. "create_by": map_bx_user_id(row.get("create_by"), bx_user_map, admin_id, stats, label),
  186. "create_dept_id": "",
  187. "create_time": row.get("create_time") or datetime.now(),
  188. "update_by": map_bx_user_id(row.get("update_by"), bx_user_map, admin_id, stats, label),
  189. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  190. "del_flag": int(row.get("del_flag") or 0),
  191. }
  192. def transform_workflow_def(row: dict, tenant_no: str, stats: MigrationStats) -> dict:
  193. rid = legacy_id_to_str(row["id"])
  194. return {
  195. "id": rid,
  196. "biz_type": (row.get("biz_type") or "")[:32],
  197. "wf_name": (row.get("wf_name") or "")[:128],
  198. "status": int(row.get("status") or 1),
  199. "tenant_no": tenant_no,
  200. "order_num": 0,
  201. "remark": _str_or_none(row.get("remark"), 512),
  202. "create_by": None,
  203. "create_dept_id": "",
  204. "create_time": row.get("create_time") or datetime.now(),
  205. "update_by": None,
  206. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  207. "del_flag": int(row.get("del_flag") or 0),
  208. }
  209. def transform_workflow_node(
  210. row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
  211. ) -> dict:
  212. rid = legacy_id_to_str(row["id"])
  213. label = f"wf_node#{rid}"
  214. return {
  215. "id": rid,
  216. "wf_id": legacy_id_to_str(row.get("wf_id")),
  217. "node_name": (row.get("node_name") or "")[:128],
  218. "node_order": int(row.get("node_order") or 0),
  219. "approver_id": map_bx_user_id(
  220. row.get("approver_id"), bx_user_map, admin_id, stats, label, required=True
  221. ),
  222. "approver_name": (row.get("approver_name") or "")[:64],
  223. "tenant_no": tenant_no,
  224. "order_num": 0,
  225. "remark": None,
  226. "create_by": None,
  227. "create_dept_id": "",
  228. "create_time": row.get("create_time") or datetime.now(),
  229. "update_by": None,
  230. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  231. "del_flag": 0,
  232. }
  233. def transform_advance_apply(
  234. row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
  235. ) -> dict:
  236. rid = legacy_id_to_str(row["id"])
  237. label = f"advance#{rid}"
  238. expense_item_id = legacy_id_to_str(row.get("expense_item_id"))
  239. return {
  240. "id": rid,
  241. "apply_code": (row.get("apply_code") or "")[:32],
  242. "applicant_id": map_bx_user_id(
  243. row.get("applicant_id"), bx_user_map, admin_id, stats, label, required=True
  244. ),
  245. "applicant_name": (row.get("applicant_name") or "")[:64],
  246. "reason": (row.get("reason") or "")[:1024],
  247. "purpose": _str_or_none(row.get("purpose"), 1024),
  248. "expense_item_id": expense_item_id,
  249. "amount": _dec(row.get("amount")) or Decimal("0"),
  250. "status": (row.get("status") or "draft")[:32],
  251. "current_node": _str_or_none(row.get("current_node"), 128),
  252. "wf_id": legacy_id_to_str(row.get("wf_id")),
  253. "issue_status": _str_or_none(row.get("issue_status"), 32),
  254. "issue_time": row.get("issue_time"),
  255. "issue_user_id": map_bx_user_id(row.get("issue_user_id"), bx_user_map, admin_id, stats, label),
  256. "issue_user_name": _str_or_none(row.get("issue_user_name"), 64),
  257. "issue_remark": _str_or_none(row.get("issue_remark"), 512),
  258. "actual_amount": _dec(row.get("actual_amount")),
  259. "tenant_no": tenant_no,
  260. "order_num": 0,
  261. "remark": _str_or_none(row.get("remark"), 512),
  262. "create_by": map_bx_user_id(row.get("create_by"), bx_user_map, admin_id, stats, label),
  263. "create_dept_id": "",
  264. "create_time": row.get("create_time") or datetime.now(),
  265. "update_by": map_bx_user_id(row.get("update_by"), bx_user_map, admin_id, stats, label),
  266. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  267. "del_flag": int(row.get("del_flag") or 0),
  268. }
  269. def transform_advance_approval(
  270. row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
  271. ) -> dict:
  272. rid = legacy_id_to_str(row["id"])
  273. label = f"advance_approval#{rid}"
  274. now = datetime.now()
  275. return {
  276. "id": rid,
  277. "apply_id": legacy_id_to_str(row.get("apply_id")),
  278. "node_order": int(row.get("node_order") or 0),
  279. "node_name": (row.get("node_name") or "")[:128],
  280. "approver_id": map_bx_user_id(
  281. row.get("approver_id"), bx_user_map, admin_id, stats, label, required=True
  282. ),
  283. "approver_name": (row.get("approver_name") or "")[:64],
  284. "approval_status": (row.get("approval_status") or "")[:32],
  285. "approval_opinion": _str_or_none(row.get("approval_opinion"), 1024),
  286. "approval_time": row.get("approval_time") or now,
  287. "tenant_no": tenant_no,
  288. "order_num": 0,
  289. "remark": None,
  290. "create_by": None,
  291. "create_dept_id": "",
  292. "create_time": row.get("approval_time") or now,
  293. "update_by": None,
  294. "update_time": row.get("approval_time") or now,
  295. "del_flag": 0,
  296. }
  297. def transform_reimburse_apply(
  298. row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
  299. ) -> dict:
  300. rid = legacy_id_to_str(row["id"])
  301. label = f"reimburse#{rid}"
  302. apply_code = (row.get("apply_code") or "")[:32]
  303. try:
  304. reimburse_date, title_seq = parse_reimburse_meta(apply_code)
  305. except ValueError as exc:
  306. stats.warnings.append(f"{label}: {exc}")
  307. reimburse_date = (row.get("create_time") or datetime.now()).date()
  308. title_seq = int(rid or 0) % 1000 or 1
  309. return {
  310. "id": rid,
  311. "apply_code": apply_code,
  312. "title": (row.get("title") or "")[:256],
  313. "reimburse_date": reimburse_date,
  314. "title_seq": title_seq,
  315. "applicant_id": map_bx_user_id(
  316. row.get("applicant_id"), bx_user_map, admin_id, stats, label, required=True
  317. ),
  318. "applicant_name": (row.get("applicant_name") or "")[:64],
  319. "expense_id": legacy_id_to_str(row.get("expense_id")),
  320. "expense_name": (row.get("expense_name") or "")[:128],
  321. "amount": _dec(row.get("amount")) or Decimal("0"),
  322. "invoice_count": int(row.get("invoice_count") or 0),
  323. "attach_count": int(row.get("attach_count") or 0),
  324. "status": (row.get("status") or "draft")[:32],
  325. "current_node": _str_or_none(row.get("current_node"), 128),
  326. "wf_id": legacy_id_to_str(row.get("wf_id")),
  327. "pay_status": _str_or_none(row.get("pay_status"), 32),
  328. "pay_time": row.get("pay_time"),
  329. "pay_user_id": map_bx_user_id(row.get("pay_user_id"), bx_user_map, admin_id, stats, label),
  330. "pay_user_name": _str_or_none(row.get("pay_user_name"), 64),
  331. "actual_amount": _dec(row.get("actual_amount")),
  332. "offset_advance_id": legacy_id_to_str(row.get("offset_advance_id")),
  333. "offset_amount": _dec(row.get("offset_amount")),
  334. "tenant_no": tenant_no,
  335. "order_num": 0,
  336. "remark": _str_or_none(row.get("remark"), 512),
  337. "create_by": map_bx_user_id(row.get("create_by"), bx_user_map, admin_id, stats, label),
  338. "create_dept_id": "",
  339. "create_time": row.get("create_time") or datetime.now(),
  340. "update_by": map_bx_user_id(row.get("update_by"), bx_user_map, admin_id, stats, label),
  341. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  342. "del_flag": int(row.get("del_flag") or 0),
  343. }
  344. def transform_reimburse_detail(row: dict, tenant_no: str, stats: MigrationStats) -> dict:
  345. rid = legacy_id_to_str(row["id"])
  346. now = row.get("create_time") or datetime.now()
  347. return {
  348. "id": rid,
  349. "reimburse_id": legacy_id_to_str(row.get("reimburse_id")),
  350. "expense_id": legacy_id_to_str(row.get("expense_id")),
  351. "expense_name": (row.get("expense_name") or "")[:128],
  352. "amount": _dec(row.get("amount")) or Decimal("0"),
  353. "tenant_no": tenant_no,
  354. "order_num": 0,
  355. "remark": _str_or_none(row.get("remark"), 512),
  356. "create_by": None,
  357. "create_dept_id": "",
  358. "create_time": now,
  359. "update_by": None,
  360. "update_time": row.get("update_time") or now,
  361. "del_flag": int(row.get("del_flag") or 0),
  362. }
  363. def transform_reimburse_approval(
  364. row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
  365. ) -> dict:
  366. rid = legacy_id_to_str(row["id"])
  367. label = f"reimburse_approval#{rid}"
  368. now = datetime.now()
  369. return {
  370. "id": rid,
  371. "apply_id": legacy_id_to_str(row.get("apply_id")),
  372. "node_order": int(row.get("node_order") or 0),
  373. "node_name": (row.get("node_name") or "")[:128],
  374. "approver_id": map_bx_user_id(
  375. row.get("approver_id"), bx_user_map, admin_id, stats, label, required=True
  376. ),
  377. "approver_name": (row.get("approver_name") or "")[:64],
  378. "approval_status": (row.get("approval_status") or "")[:32],
  379. "approval_opinion": _str_or_none(row.get("approval_opinion"), 1024),
  380. "approval_time": row.get("approval_time") or now,
  381. "tenant_no": tenant_no,
  382. "order_num": 0,
  383. "remark": None,
  384. "create_by": None,
  385. "create_dept_id": "",
  386. "create_time": row.get("approval_time") or now,
  387. "update_by": None,
  388. "update_time": row.get("approval_time") or now,
  389. "del_flag": 0,
  390. }
  391. def transform_reimburse_offset(row: dict, tenant_no: str, stats: MigrationStats) -> dict:
  392. rid = legacy_id_to_str(row["id"])
  393. now = row.get("create_time") or datetime.now()
  394. return {
  395. "id": rid,
  396. "reimburse_id": legacy_id_to_str(row.get("reimburse_id")),
  397. "advance_id": legacy_id_to_str(row.get("advance_id")),
  398. "offset_amount": _dec(row.get("offset_amount")) or Decimal("0"),
  399. "tenant_no": tenant_no,
  400. "order_num": 0,
  401. "remark": _str_or_none(row.get("remark"), 512),
  402. "create_by": None,
  403. "create_dept_id": "",
  404. "create_time": now,
  405. "update_by": None,
  406. "update_time": now,
  407. "del_flag": 0,
  408. }
  409. def transform_attachment(
  410. row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
  411. ) -> dict:
  412. rid = legacy_id_to_str(row["id"])
  413. label = f"attachment#{rid}"
  414. biz_id = legacy_id_to_str(row.get("biz_id")) or "0"
  415. return {
  416. "id": rid,
  417. "biz_type": (row.get("biz_type") or "")[:32],
  418. "biz_id": biz_id,
  419. "file_name": (row.get("file_name") or "")[:256],
  420. "file_path": (row.get("file_path") or "")[:512],
  421. "file_type": _str_or_none(row.get("file_type"), 128),
  422. "file_size": row.get("file_size"),
  423. "attach_type": (row.get("attach_type") or "attachment")[:32],
  424. "sort_order": int(row.get("sort_order") or 0),
  425. "tenant_no": tenant_no,
  426. "order_num": 0,
  427. "remark": None,
  428. "create_by": map_bx_user_id(row.get("create_by"), bx_user_map, admin_id, stats, label),
  429. "create_dept_id": "",
  430. "create_time": row.get("create_time") or datetime.now(),
  431. "update_by": None,
  432. "update_time": row.get("create_time") or datetime.now(),
  433. "del_flag": int(row.get("del_flag") or 0),
  434. }
  435. INSERT_SQL = {
  436. "bx_expense_item": text(
  437. """
  438. INSERT INTO bx_expense_item (
  439. id, parent_id, item_name, item_code, level, sort_order, status,
  440. tenant_no, order_num, remark, create_by, create_dept_id,
  441. create_time, update_by, update_time, del_flag
  442. ) VALUES (
  443. :id, :parent_id, :item_name, :item_code, :level, :sort_order, :status,
  444. :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
  445. :create_time, :update_by, :update_time, :del_flag
  446. )
  447. """
  448. ),
  449. "bx_workflow_def": text(
  450. """
  451. INSERT INTO bx_workflow_def (
  452. id, biz_type, wf_name, status, tenant_no, order_num, remark,
  453. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  454. ) VALUES (
  455. :id, :biz_type, :wf_name, :status, :tenant_no, :order_num, :remark,
  456. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  457. )
  458. """
  459. ),
  460. "bx_workflow_node": text(
  461. """
  462. INSERT INTO bx_workflow_node (
  463. id, wf_id, node_name, node_order, approver_id, approver_name,
  464. tenant_no, order_num, remark, create_by, create_dept_id,
  465. create_time, update_by, update_time, del_flag
  466. ) VALUES (
  467. :id, :wf_id, :node_name, :node_order, :approver_id, :approver_name,
  468. :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
  469. :create_time, :update_by, :update_time, :del_flag
  470. )
  471. """
  472. ),
  473. "bx_advance_apply": text(
  474. """
  475. INSERT INTO bx_advance_apply (
  476. id, apply_code, applicant_id, applicant_name, reason, purpose, expense_item_id,
  477. amount, status, current_node, wf_id, issue_status, issue_time, issue_user_id,
  478. issue_user_name, issue_remark, actual_amount, tenant_no, order_num, remark,
  479. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  480. ) VALUES (
  481. :id, :apply_code, :applicant_id, :applicant_name, :reason, :purpose, :expense_item_id,
  482. :amount, :status, :current_node, :wf_id, :issue_status, :issue_time, :issue_user_id,
  483. :issue_user_name, :issue_remark, :actual_amount, :tenant_no, :order_num, :remark,
  484. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  485. )
  486. """
  487. ),
  488. "bx_advance_approval": text(
  489. """
  490. INSERT INTO bx_advance_approval (
  491. id, apply_id, node_order, node_name, approver_id, approver_name,
  492. approval_status, approval_opinion, approval_time, tenant_no, order_num, remark,
  493. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  494. ) VALUES (
  495. :id, :apply_id, :node_order, :node_name, :approver_id, :approver_name,
  496. :approval_status, :approval_opinion, :approval_time, :tenant_no, :order_num, :remark,
  497. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  498. )
  499. """
  500. ),
  501. "bx_reimburse_apply": text(
  502. """
  503. INSERT INTO bx_reimburse_apply (
  504. id, apply_code, title, reimburse_date, title_seq, applicant_id, applicant_name,
  505. expense_id, expense_name, amount, invoice_count, attach_count, status, current_node,
  506. wf_id, pay_status, pay_time, pay_user_id, pay_user_name, actual_amount,
  507. offset_advance_id, offset_amount, tenant_no, order_num, remark,
  508. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  509. ) VALUES (
  510. :id, :apply_code, :title, :reimburse_date, :title_seq, :applicant_id, :applicant_name,
  511. :expense_id, :expense_name, :amount, :invoice_count, :attach_count, :status, :current_node,
  512. :wf_id, :pay_status, :pay_time, :pay_user_id, :pay_user_name, :actual_amount,
  513. :offset_advance_id, :offset_amount, :tenant_no, :order_num, :remark,
  514. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  515. )
  516. """
  517. ),
  518. "bx_reimburse_detail": text(
  519. """
  520. INSERT INTO bx_reimburse_detail (
  521. id, reimburse_id, expense_id, expense_name, amount, tenant_no, order_num, remark,
  522. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  523. ) VALUES (
  524. :id, :reimburse_id, :expense_id, :expense_name, :amount, :tenant_no, :order_num, :remark,
  525. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  526. )
  527. """
  528. ),
  529. "bx_reimburse_approval": text(
  530. """
  531. INSERT INTO bx_reimburse_approval (
  532. id, apply_id, node_order, node_name, approver_id, approver_name,
  533. approval_status, approval_opinion, approval_time, tenant_no, order_num, remark,
  534. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  535. ) VALUES (
  536. :id, :apply_id, :node_order, :node_name, :approver_id, :approver_name,
  537. :approval_status, :approval_opinion, :approval_time, :tenant_no, :order_num, :remark,
  538. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  539. )
  540. """
  541. ),
  542. "bx_reimburse_offset": text(
  543. """
  544. INSERT INTO bx_reimburse_offset (
  545. id, reimburse_id, advance_id, offset_amount, tenant_no, order_num, remark,
  546. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  547. ) VALUES (
  548. :id, :reimburse_id, :advance_id, :offset_amount, :tenant_no, :order_num, :remark,
  549. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  550. )
  551. """
  552. ),
  553. "bx_attachment": text(
  554. """
  555. INSERT INTO bx_attachment (
  556. id, biz_type, biz_id, file_name, file_path, file_type, file_size, attach_type,
  557. sort_order, tenant_no, order_num, remark, create_by, create_dept_id,
  558. create_time, update_by, update_time, del_flag
  559. ) VALUES (
  560. :id, :biz_type, :biz_id, :file_name, :file_path, :file_type, :file_size, :attach_type,
  561. :sort_order, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
  562. :create_time, :update_by, :update_time, :del_flag
  563. )
  564. """
  565. ),
  566. }
  567. TABLE_OPTIONS: Dict[str, Dict[str, Any]] = {
  568. "bx_expense_item": {"include_deleted": True},
  569. "bx_workflow_def": {"include_deleted": False},
  570. "bx_workflow_node": {"include_deleted": False, "no_del_flag": True},
  571. "bx_advance_apply": {"include_deleted": False},
  572. "bx_advance_approval": {"include_deleted": False, "no_del_flag": True},
  573. "bx_reimburse_apply": {"include_deleted": False},
  574. "bx_reimburse_detail": {"include_deleted": False},
  575. "bx_reimburse_approval": {"include_deleted": False, "no_del_flag": True},
  576. "bx_reimburse_offset": {"include_deleted": False, "no_del_flag": True},
  577. "bx_attachment": {"include_deleted": False},
  578. }
  579. def write_mapping_csv(table: str, rows: List[dict], path: Path) -> None:
  580. path.parent.mkdir(parents=True, exist_ok=True)
  581. migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  582. with path.open("w", encoding="utf-8", newline="") as f:
  583. w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
  584. w.writeheader()
  585. for row in rows:
  586. sid = legacy_id_to_str(row["id"])
  587. w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
  588. def migrate_table(
  589. source_engine: Engine,
  590. target_engine: Engine,
  591. table: str,
  592. *,
  593. tenant_no: str,
  594. bx_user_map: Dict[int, str],
  595. admin_id: Optional[str],
  596. stats: MigrationStats,
  597. dry_run: bool,
  598. replace_workflow: bool,
  599. ) -> List[dict]:
  600. step = stats.step(table)
  601. opts = TABLE_OPTIONS.get(table, {})
  602. source_rows = fetch_source_rows(
  603. source_engine,
  604. table,
  605. include_deleted=bool(opts.get("include_deleted")),
  606. no_del_flag=bool(opts.get("no_del_flag")),
  607. )
  608. step.source_count = len(source_rows)
  609. transformers: Dict[str, Callable[..., dict]] = {
  610. "bx_expense_item": lambda r: transform_expense_item(r, tenant_no, bx_user_map, admin_id, stats),
  611. "bx_workflow_def": lambda r: transform_workflow_def(r, tenant_no, stats),
  612. "bx_workflow_node": lambda r: transform_workflow_node(r, tenant_no, bx_user_map, admin_id, stats),
  613. "bx_advance_apply": lambda r: transform_advance_apply(r, tenant_no, bx_user_map, admin_id, stats),
  614. "bx_advance_approval": lambda r: transform_advance_approval(r, tenant_no, bx_user_map, admin_id, stats),
  615. "bx_reimburse_apply": lambda r: transform_reimburse_apply(r, tenant_no, bx_user_map, admin_id, stats),
  616. "bx_reimburse_detail": lambda r: transform_reimburse_detail(r, tenant_no, stats),
  617. "bx_reimburse_approval": lambda r: transform_reimburse_approval(r, tenant_no, bx_user_map, admin_id, stats),
  618. "bx_reimburse_offset": lambda r: transform_reimburse_offset(r, tenant_no, stats),
  619. "bx_attachment": lambda r: transform_attachment(r, tenant_no, bx_user_map, admin_id, stats),
  620. }
  621. payloads = [transformers[table](r) for r in source_rows]
  622. if dry_run:
  623. step.inserted = len(payloads)
  624. return source_rows
  625. with target_engine.begin() as conn:
  626. if replace_workflow and table == "bx_workflow_def":
  627. clear_portal_workflow(conn)
  628. for payload in payloads:
  629. rid = payload["id"]
  630. if target_row_exists(conn, table, rid):
  631. step.skipped += 1
  632. else:
  633. conn.execute(INSERT_SQL[table], payload)
  634. step.inserted += 1
  635. return source_rows
  636. def print_report(stats: MigrationStats, dry_run: bool, phase: str) -> None:
  637. mode = "DRY-RUN" if dry_run else "COMMITTED"
  638. print(f"\n=== 报销数据迁移 [{phase}] [{mode}] ===")
  639. for table in ALL_STEPS:
  640. s = stats.step(table)
  641. if s.source_count or s.inserted or s.skipped:
  642. print(f"{table}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
  643. if stats.warnings:
  644. print(f"警告 ({len(stats.warnings)}):")
  645. for w in stats.warnings[:25]:
  646. print(f" - {w}")
  647. if len(stats.warnings) > 25:
  648. print(f" ... 另有 {len(stats.warnings) - 25} 条")
  649. def resolve_steps(phase: str, only_tables: Optional[List[str]]) -> List[str]:
  650. if phase == "all":
  651. steps = list(ALL_STEPS)
  652. else:
  653. steps = list(PHASE_STEPS.get(phase, ()))
  654. if not steps:
  655. raise SystemExit(f"未知 phase: {phase}")
  656. if only_tables:
  657. unknown = [t for t in only_tables if t not in ALL_STEPS]
  658. if unknown:
  659. raise SystemExit(f"未知表: {unknown}")
  660. steps = [t for t in steps if t in only_tables]
  661. return steps
  662. def run_migration(
  663. source_engine: Engine,
  664. target_engine: Engine,
  665. *,
  666. tenant_no: str,
  667. user_mapping_path: Path,
  668. mapping_dir: Path,
  669. dry_run: bool,
  670. phase: str,
  671. only_tables: Optional[List[str]],
  672. replace_workflow: bool,
  673. ) -> MigrationStats:
  674. stats = MigrationStats()
  675. bx_user_map, admin_id = load_bx_user_mapping(user_mapping_path)
  676. if not admin_id:
  677. admin_id = fetch_portal_admin_id(target_engine)
  678. print(f"bx 用户映射: {len(bx_user_map)} 条; admin={admin_id or '未找到'}")
  679. steps = resolve_steps(phase, only_tables)
  680. for table in steps:
  681. print(f"\n>> 迁移 {table} ...")
  682. source_rows = migrate_table(
  683. source_engine,
  684. target_engine,
  685. table,
  686. tenant_no=tenant_no,
  687. bx_user_map=bx_user_map,
  688. admin_id=admin_id,
  689. stats=stats,
  690. dry_run=dry_run,
  691. replace_workflow=replace_workflow,
  692. )
  693. if not dry_run and source_rows:
  694. write_mapping_csv(table, source_rows, mapping_dir / f"bx_{table.replace('bx_', '')}_id_mapping.csv")
  695. print_report(stats, dry_run, phase)
  696. return stats
  697. def main() -> int:
  698. parser = argparse.ArgumentParser(description="Migrate bx reimbursement data from sx to portal")
  699. parser.add_argument("--source-url", default="")
  700. parser.add_argument("--target-url", default="")
  701. parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
  702. parser.add_argument(
  703. "--user-mapping",
  704. default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
  705. )
  706. parser.add_argument("--phase", default="all", choices=["base", "advance", "reimburse", "attachment", "all"])
  707. parser.add_argument("--tables", default="", help="逗号分隔,限定当前 phase 内的表")
  708. parser.add_argument("--dry-run", action="store_true")
  709. parser.add_argument(
  710. "--no-replace-workflow",
  711. action="store_true",
  712. help="不清理门户预置工作流(默认 base/all 时会清理并写入源库 id=1/2)",
  713. )
  714. parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
  715. args = parser.parse_args()
  716. load_settings_env(args.env_file or None)
  717. settings = load_runtime_settings()
  718. target_url = args.target_url or settings.DATABASE_URL
  719. source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
  720. only_tables = [t.strip() for t in args.tables.split(",") if t.strip()] or None
  721. replace_workflow = not args.no_replace_workflow
  722. print(f"源库: {urlparse(source_url).path}")
  723. print(f"目标库: {urlparse(target_url).path}")
  724. print(f"阶段: {args.phase}")
  725. run_migration(
  726. create_engine(source_url, pool_pre_ping=True),
  727. create_engine(target_url, pool_pre_ping=True),
  728. tenant_no=args.tenant_no,
  729. user_mapping_path=Path(args.user_mapping),
  730. mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
  731. dry_run=args.dry_run,
  732. phase=args.phase,
  733. only_tables=only_tables,
  734. replace_workflow=replace_workflow,
  735. )
  736. return 0
  737. if __name__ == "__main__":
  738. raise SystemExit(main())