backfill_crm_customer_license_urls.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. #!/usr/bin/env python3
  2. """用目标 sys_oss 将 sx 客户证照 OSS ID 回填为门户相对 URL。
  3. 场景:客户表已迁、当时 sys_oss 未齐,导致 business_license_img 等为空。
  4. 用法:
  5. cd backend
  6. venv/bin/python scripts/backfill_crm_customer_license_urls.py --dry-run --env-file .env.production
  7. venv/bin/python scripts/backfill_crm_customer_license_urls.py --env-file .env.production
  8. """
  9. from __future__ import annotations
  10. import argparse
  11. import csv
  12. import os
  13. import sys
  14. from datetime import datetime
  15. from pathlib import Path
  16. from typing import Any, Dict, List, Optional, Tuple
  17. from urllib.parse import urlparse, urlunparse
  18. from sqlalchemy import create_engine, text
  19. from sqlalchemy.engine import Engine
  20. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  21. MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
  22. if str(BACKEND_ROOT) not in sys.path:
  23. sys.path.insert(0, str(BACKEND_ROOT))
  24. if str(MIGRATION_DIR) not in sys.path:
  25. sys.path.insert(0, str(MIGRATION_DIR))
  26. from minio_url import resolve_oss_id_csv # noqa: E402
  27. DEFAULT_TENANT_NO = "shanxia2.0"
  28. FIELD_MAP = (
  29. # (sx_col, portal_col)
  30. ("business_license_images", "business_license_img"),
  31. ("business_permit_images", "business_permit_images"),
  32. ("other_cert_images", "other_cert_images"),
  33. )
  34. def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
  35. if legacy_id is None or str(legacy_id).strip() == "":
  36. return None
  37. try:
  38. return str(int(legacy_id))
  39. except (TypeError, ValueError):
  40. return str(legacy_id).strip()
  41. def derive_source_url(target_url: str, source_db: str = "sx") -> str:
  42. return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
  43. def load_settings_env(env_file: Optional[str]) -> None:
  44. if env_file:
  45. os.environ["ENV_FILE"] = env_file
  46. from dotenv import load_dotenv
  47. candidates: List[Path] = []
  48. if env_file:
  49. p = Path(env_file)
  50. candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
  51. candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
  52. for path in candidates:
  53. if path.is_file():
  54. load_dotenv(path, override=True)
  55. break
  56. def load_runtime_settings():
  57. from app.core.config import Settings
  58. return Settings()
  59. def load_oss_map(target_engine: Engine, tenant_no: str) -> Dict[str, str]:
  60. with target_engine.connect() as conn:
  61. rows = conn.execute(
  62. text("SELECT id, url FROM sys_oss WHERE tenant_no=:t AND del_flag=0"),
  63. {"t": tenant_no},
  64. ).mappings().all()
  65. return {str(r["id"]): (r["url"] or "").strip() for r in rows if r["id"]}
  66. def fetch_source_customers(source_engine: Engine) -> List[dict]:
  67. sql = """
  68. SELECT id, business_license_images, business_permit_images, other_cert_images
  69. FROM crm_customer
  70. WHERE del_flag = 0
  71. ORDER BY id ASC
  72. """
  73. with source_engine.connect() as conn:
  74. return [dict(r._mapping) for r in conn.execute(text(sql))]
  75. def build_updates(
  76. source_rows: List[dict],
  77. oss_map: Dict[str, str],
  78. ) -> Tuple[List[dict], List[str]]:
  79. updates: List[dict] = []
  80. warnings: List[str] = []
  81. for row in source_rows:
  82. cid = legacy_id_to_str(row["id"])
  83. if not cid:
  84. continue
  85. if not any(str(row.get(s) or "").strip() for s, _ in FIELD_MAP):
  86. continue
  87. payload = {"id": cid}
  88. for src_col, tgt_col in FIELD_MAP:
  89. raw = row.get(src_col)
  90. if raw is None or str(raw).strip() == "":
  91. payload[tgt_col] = None
  92. continue
  93. resolved = resolve_oss_id_csv(raw, oss_map) or None
  94. if not resolved:
  95. warnings.append(f"customer#{cid} {src_col}={raw!r} 未能解析为 URL")
  96. payload[tgt_col] = resolved
  97. updates.append(payload)
  98. return updates, warnings
  99. UPDATE_SQL = text(
  100. """
  101. UPDATE crm_customer SET
  102. business_license_img = :business_license_img,
  103. business_permit_images = :business_permit_images,
  104. other_cert_images = :other_cert_images,
  105. update_time = :update_time
  106. WHERE id = :id
  107. """
  108. )
  109. def write_report_csv(rows: List[dict], path: Path) -> None:
  110. path.parent.mkdir(parents=True, exist_ok=True)
  111. with path.open("w", encoding="utf-8", newline="") as f:
  112. w = csv.DictWriter(
  113. f,
  114. fieldnames=[
  115. "customer_id",
  116. "business_license_img",
  117. "business_permit_images",
  118. "other_cert_images",
  119. ],
  120. )
  121. w.writeheader()
  122. for r in rows:
  123. w.writerow(
  124. {
  125. "customer_id": r["id"],
  126. "business_license_img": r.get("business_license_img") or "",
  127. "business_permit_images": r.get("business_permit_images") or "",
  128. "other_cert_images": r.get("other_cert_images") or "",
  129. }
  130. )
  131. def main() -> int:
  132. parser = argparse.ArgumentParser(description="Backfill CRM customer license URLs from sys_oss")
  133. parser.add_argument("--source-url", default="")
  134. parser.add_argument("--target-url", default="")
  135. parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
  136. parser.add_argument("--env-file", default="")
  137. parser.add_argument("--dry-run", action="store_true")
  138. parser.add_argument(
  139. "--report-out",
  140. default=str(
  141. BACKEND_ROOT.parent / "docs/data-migration/mappings/crm_customer_license_url_backfill.csv"
  142. ),
  143. )
  144. args = parser.parse_args()
  145. load_settings_env(args.env_file or None)
  146. settings = load_runtime_settings()
  147. target_url = args.target_url or settings.DATABASE_URL
  148. source_url = args.source_url or derive_source_url(target_url, "sx")
  149. source_engine = create_engine(source_url, pool_pre_ping=True)
  150. target_engine = create_engine(target_url, pool_pre_ping=True)
  151. print(f"源库: {urlparse(source_url).path}")
  152. print(f"目标库: {urlparse(target_url).path}")
  153. oss_map = load_oss_map(target_engine, args.tenant_no)
  154. print(f"sys_oss 映射条数: {len(oss_map)}")
  155. source_rows = fetch_source_customers(source_engine)
  156. updates, warnings = build_updates(source_rows, oss_map)
  157. with_url = sum(
  158. 1
  159. for u in updates
  160. if any(u.get(t) for _, t in FIELD_MAP)
  161. )
  162. print(f"源客户: {len(source_rows)} 待回填行: {len(updates)} 至少一证有 URL: {with_url}")
  163. if args.dry_run:
  164. print("\n预览(前 5 条):")
  165. for u in updates[:5]:
  166. print(
  167. f" id={u['id']} license={u.get('business_license_img')!r} "
  168. f"permit={u.get('business_permit_images')!r} other={u.get('other_cert_images')!r}"
  169. )
  170. else:
  171. now = datetime.now()
  172. applied = 0
  173. missing = 0
  174. with target_engine.begin() as conn:
  175. for u in updates:
  176. payload = {**u, "update_time": now}
  177. result = conn.execute(UPDATE_SQL, payload)
  178. if result.rowcount:
  179. applied += 1
  180. else:
  181. missing += 1
  182. print(f"已更新: {applied} 目标无此客户: {missing}")
  183. report_path = Path(args.report_out)
  184. write_report_csv(updates, report_path)
  185. print(f"回填明细: {report_path}")
  186. if warnings:
  187. print(f"警告 ({len(warnings)}):")
  188. for w in warnings[:20]:
  189. print(f" - {w}")
  190. if len(warnings) > 20:
  191. print(f" ... 另有 {len(warnings) - 20} 条")
  192. mode = "DRY-RUN" if args.dry_run else "COMMITTED"
  193. print(f"\n=== 客户证照 URL 回填 [{mode}] ===")
  194. return 0
  195. if __name__ == "__main__":
  196. raise SystemExit(main())