renumber_crm_product_no.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. #!/usr/bin/env python3
  2. """将门户 crm_product.product_no 统一为 SXWL + 6 位流水,并写出编号映射 CSV。
  3. 设计:docs/superpowers/specs/2026-07-12-crm-product-no-renumber-design.md
  4. 用法:
  5. cd backend
  6. venv/bin/python scripts/renumber_crm_product_no.py --dry-run --env-file .env.production
  7. venv/bin/python scripts/renumber_crm_product_no.py --apply --env-file .env.production \\
  8. --mapping-out ../docs/data-migration/mappings/crm_product_no_mapping.csv
  9. """
  10. from __future__ import annotations
  11. import argparse
  12. import csv
  13. import os
  14. import re
  15. import sys
  16. from dataclasses import dataclass, field
  17. from datetime import datetime
  18. from pathlib import Path
  19. from typing import Any, Dict, List, Optional
  20. from sqlalchemy import create_engine, text
  21. from sqlalchemy.engine import Engine
  22. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  23. if str(BACKEND_ROOT) not in sys.path:
  24. sys.path.insert(0, str(BACKEND_ROOT))
  25. DEFAULT_TENANT_NO = "shanxia2.0"
  26. PRODUCT_NO_PREFIX = "SXWL"
  27. PRODUCT_NO_SEQ_LEN = 6
  28. NEW_NO_PATTERN = re.compile(rf"^{PRODUCT_NO_PREFIX}[0-9]{{{PRODUCT_NO_SEQ_LEN}}}$")
  29. TEMP_PREFIX = "__REN_"
  30. @dataclass
  31. class RenumberStats:
  32. total: int = 0
  33. to_update: int = 0
  34. skipped: int = 0
  35. updated: int = 0
  36. warnings: List[str] = field(default_factory=list)
  37. def load_settings_env(env_file: Optional[str]) -> None:
  38. if env_file:
  39. os.environ["ENV_FILE"] = env_file
  40. from dotenv import load_dotenv
  41. candidates: List[Path] = []
  42. if env_file:
  43. p = Path(env_file)
  44. candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
  45. candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
  46. for path in candidates:
  47. if path.is_file():
  48. load_dotenv(path, override=True)
  49. break
  50. def product_id_sort_key(product_id: str) -> int:
  51. try:
  52. return int(product_id)
  53. except (TypeError, ValueError):
  54. return 0
  55. def format_new_product_no(seq: int) -> str:
  56. if seq < 1 or seq > 10**PRODUCT_NO_SEQ_LEN - 1:
  57. raise ValueError(f"序号超出范围: {seq}")
  58. return f"{PRODUCT_NO_PREFIX}{seq:0{PRODUCT_NO_SEQ_LEN}d}"
  59. def fetch_products(engine: Engine, tenant_no: str) -> List[dict]:
  60. sql = text(
  61. """
  62. SELECT id, product_no, product_name, category_id
  63. FROM crm_product
  64. WHERE tenant_no = :tenant_no AND del_flag = 0
  65. """
  66. )
  67. with engine.connect() as conn:
  68. rows = [dict(r._mapping) for r in conn.execute(sql, {"tenant_no": tenant_no})]
  69. rows.sort(key=lambda r: product_id_sort_key(str(r["id"])))
  70. return rows
  71. def build_mapping(rows: List[dict], renumbered_at: Optional[str] = None) -> List[dict]:
  72. mapping: List[dict] = []
  73. for seq, row in enumerate(rows, start=1):
  74. pid = str(row["id"])
  75. old_no = (row.get("product_no") or "").strip()
  76. new_no = format_new_product_no(seq)
  77. mapping.append(
  78. {
  79. "product_id": pid,
  80. "old_product_no": old_no,
  81. "new_product_no": new_no,
  82. "product_name": (row.get("product_name") or "")[:200],
  83. "category_id": str(row.get("category_id") or ""),
  84. "seq": seq,
  85. "renumbered_at": renumbered_at or "",
  86. }
  87. )
  88. return mapping
  89. def validate_mapping(mapping: List[dict], stats: RenumberStats) -> None:
  90. new_nos = [m["new_product_no"] for m in mapping]
  91. if len(new_nos) != len(set(new_nos)):
  92. stats.warnings.append("新编号存在重复")
  93. old_nos = [m["old_product_no"] for m in mapping if m["old_product_no"]]
  94. if len(old_nos) != len(set(old_nos)):
  95. stats.warnings.append("旧编号存在重复(仍将按 product_id 映射)")
  96. for m in mapping:
  97. if not NEW_NO_PATTERN.match(m["new_product_no"]):
  98. stats.warnings.append(f"非法新编号: {m['new_product_no']} (id={m['product_id']})")
  99. def write_mapping_csv(path: Path, mapping: List[dict], renumbered_at: str) -> None:
  100. path.parent.mkdir(parents=True, exist_ok=True)
  101. for row in mapping:
  102. if not row.get("renumbered_at"):
  103. row["renumbered_at"] = renumbered_at
  104. fieldnames = [
  105. "product_id",
  106. "old_product_no",
  107. "new_product_no",
  108. "product_name",
  109. "category_id",
  110. "seq",
  111. "renumbered_at",
  112. ]
  113. with path.open("w", encoding="utf-8", newline="") as f:
  114. writer = csv.DictWriter(f, fieldnames=fieldnames)
  115. writer.writeheader()
  116. writer.writerows(mapping)
  117. def apply_renumber(engine: Engine, mapping: List[dict], tenant_no: str, stats: RenumberStats) -> None:
  118. """两阶段 UPDATE,避免 uk_product_no 中间态冲突。"""
  119. phase1_sql = text(
  120. """
  121. UPDATE crm_product
  122. SET product_no = :temp_no, update_time = NOW()
  123. WHERE id = :id AND tenant_no = :tenant_no AND del_flag = 0
  124. """
  125. )
  126. phase2_sql = text(
  127. """
  128. UPDATE crm_product
  129. SET product_no = :new_no, update_time = NOW()
  130. WHERE id = :id AND tenant_no = :tenant_no AND del_flag = 0
  131. """
  132. )
  133. with engine.begin() as conn:
  134. for m in mapping:
  135. old_no = m["old_product_no"]
  136. new_no = m["new_product_no"]
  137. if old_no == new_no:
  138. stats.skipped += 1
  139. continue
  140. stats.to_update += 1
  141. temp_no = f"{TEMP_PREFIX}{m['product_id']}"[:50]
  142. conn.execute(
  143. phase1_sql,
  144. {"temp_no": temp_no, "id": m["product_id"], "tenant_no": tenant_no},
  145. )
  146. for m in mapping:
  147. old_no = m["old_product_no"]
  148. new_no = m["new_product_no"]
  149. if old_no == new_no:
  150. continue
  151. result = conn.execute(
  152. phase2_sql,
  153. {"new_no": new_no, "id": m["product_id"], "tenant_no": tenant_no},
  154. )
  155. stats.updated += int(result.rowcount or 0)
  156. def main() -> int:
  157. parser = argparse.ArgumentParser(description="CRM 产品编号统一为 SXWL + 6 位流水")
  158. parser.add_argument("--dry-run", action="store_true", help="仅预览,不写库")
  159. parser.add_argument("--apply", action="store_true", help="执行 UPDATE 并写映射")
  160. parser.add_argument("--env-file", default=None, help="如 .env.production")
  161. parser.add_argument(
  162. "--mapping-out",
  163. default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/crm_product_no_mapping.csv"),
  164. help="编号映射 CSV 输出路径",
  165. )
  166. parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
  167. args = parser.parse_args()
  168. if args.apply and args.dry_run:
  169. print("请只指定 --dry-run 或 --apply 之一")
  170. return 1
  171. if not args.apply and not args.dry_run:
  172. args.dry_run = True
  173. load_settings_env(args.env_file)
  174. from app.core.config import Settings
  175. settings = Settings()
  176. engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
  177. stats = RenumberStats()
  178. rows = fetch_products(engine, args.tenant_no)
  179. stats.total = len(rows)
  180. if stats.total == 0:
  181. print("目标库无有效产品,退出")
  182. return 1
  183. now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  184. mapping = build_mapping(rows, renumbered_at=now_str if args.apply else "")
  185. validate_mapping(mapping, stats)
  186. for m in mapping:
  187. if m["old_product_no"] != m["new_product_no"]:
  188. stats.to_update += 1
  189. else:
  190. stats.skipped += 1
  191. mode = "DRY-RUN" if args.dry_run else "APPLY"
  192. print(f"租户: {args.tenant_no}")
  193. print(f"有效产品: {stats.total}")
  194. print(f"需更新编号: {stats.to_update} 已符合跳过: {stats.skipped}")
  195. print("\n抽样映射(前 5 / 后 3):")
  196. for m in mapping[:5]:
  197. print(f" id={m['product_id']} {m['old_product_no']!r} -> {m['new_product_no']}")
  198. if len(mapping) > 8:
  199. print(" ...")
  200. for m in mapping[-3:]:
  201. print(f" id={m['product_id']} {m['old_product_no']!r} -> {m['new_product_no']}")
  202. next_seq = stats.total + 1
  203. print(f"\n下一自动编号应为: {format_new_product_no(next_seq)}")
  204. if stats.warnings:
  205. print("\n警告:")
  206. for w in stats.warnings:
  207. print(f" - {w}")
  208. if args.dry_run:
  209. print("\n=== 产品编号统一 [DRY-RUN] ===")
  210. print("未写库。确认后加 --apply --mapping-out ...")
  211. return 0
  212. mapping_path = Path(args.mapping_out)
  213. if not mapping_path.is_absolute():
  214. mapping_path = BACKEND_ROOT / mapping_path
  215. apply_stats = RenumberStats(total=stats.total)
  216. apply_renumber(engine, mapping, args.tenant_no, apply_stats)
  217. write_mapping_csv(mapping_path, mapping, now_str)
  218. print(f"\n映射已写入: {mapping_path}")
  219. print("\n=== 产品编号统一 [COMMITTED] ===")
  220. print(f"更新行数: {apply_stats.updated}")
  221. print(f"跳过(已符合): {apply_stats.skipped}")
  222. return 0
  223. if __name__ == "__main__":
  224. raise SystemExit(main())