merge_duplicate_crm_customers.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  1. #!/usr/bin/env python3
  2. """合并 CRM 重复客户(测试/生产均可)。
  3. 规则摘要:
  4. - 同名 del_flag=0 客户每组保留 create_time 最新的一条(新业务记录)
  5. - 旧客户软删,子表 customer_id 迁到保留客户
  6. - 联系人/开票:核心字段相同则去重;不同则迁移;旧联系人优先 is_primary
  7. - 开票默认以保留客户(新)侧为准;旧侧不同内容迁移保留
  8. - 修改前按 YYYYMMDD 备份涉及表
  9. 用法:
  10. cd backend
  11. venv/bin/python scripts/merge_duplicate_crm_customers.py --dry-run
  12. venv/bin/python scripts/merge_duplicate_crm_customers.py --execute
  13. """
  14. from __future__ import annotations
  15. import argparse
  16. import re
  17. import sys
  18. from datetime import date, datetime
  19. from pathlib import Path
  20. from typing import Any, Dict, List, Optional, Tuple
  21. from sqlalchemy import create_engine, text
  22. from sqlalchemy.engine import Connection, Engine
  23. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  24. if str(BACKEND_ROOT) not in sys.path:
  25. sys.path.insert(0, str(BACKEND_ROOT))
  26. TABLES_TO_TOUCH = [
  27. "crm_customer",
  28. "crm_contact",
  29. "crm_invoice_info",
  30. "crm_customer_follow",
  31. "crm_opportunity",
  32. "crm_contract",
  33. "crm_quote",
  34. "crm_price_letter",
  35. "crm_clue",
  36. "crm_customer_pool",
  37. "pl_plan",
  38. ]
  39. CUSTOMER_FILL_FIELDS = [
  40. "short_name",
  41. "customer_type",
  42. "mobile",
  43. "telephone",
  44. "email",
  45. "website",
  46. "level",
  47. "source",
  48. "industry",
  49. "park_area",
  50. "payment_terms",
  51. "scale",
  52. "province",
  53. "city",
  54. "district",
  55. "address",
  56. "owner_user_id",
  57. "owner_user_name",
  58. "business_license_img",
  59. "business_permit_images",
  60. "other_cert_images",
  61. "clue_id",
  62. "next_time",
  63. ]
  64. INVOICE_COMPARE_FIELDS = [
  65. "company_name",
  66. "tax_no",
  67. "bank_account",
  68. ]
  69. def _norm(value: Any) -> str:
  70. if value is None:
  71. return ""
  72. return str(value).strip()
  73. def _contact_key(row: Dict[str, Any]) -> str:
  74. mobile = _norm(row.get("mobile"))
  75. telephone = _norm(row.get("telephone"))
  76. phone = mobile or telephone
  77. return f"{_norm(row.get('contact_name')).lower()}|{phone}"
  78. def _invoice_key(row: Dict[str, Any]) -> str:
  79. return "|".join(_norm(row.get(f)).lower() for f in INVOICE_COMPARE_FIELDS)
  80. def _row_to_dict(row) -> Dict[str, Any]:
  81. return dict(row._mapping)
  82. def load_database_url(env_file: Optional[str]) -> str:
  83. if env_file:
  84. path = Path(env_file)
  85. if not path.is_absolute():
  86. path = BACKEND_ROOT / path
  87. for line in path.read_text(encoding="utf-8").splitlines():
  88. line = line.strip()
  89. if line.startswith("DATABASE_URL="):
  90. return line.split("=", 1)[1].strip()
  91. from app.core.config import settings
  92. return settings.DATABASE_URL
  93. def discover_merge_pairs(conn: Connection) -> List[Dict[str, Any]]:
  94. rows = conn.execute(
  95. text(
  96. """
  97. SELECT id, customer_name, customer_no, create_time
  98. FROM crm_customer
  99. WHERE del_flag = 0 AND customer_name IS NOT NULL AND TRIM(customer_name) != ''
  100. ORDER BY customer_name, create_time ASC, customer_no ASC
  101. """
  102. )
  103. ).fetchall()
  104. grouped: Dict[str, List[Dict[str, Any]]] = {}
  105. for row in rows:
  106. item = _row_to_dict(row)
  107. grouped.setdefault(item["customer_name"], []).append(item)
  108. pairs: List[Dict[str, Any]] = []
  109. for name, items in grouped.items():
  110. if len(items) != 2:
  111. continue
  112. source, keeper = items[0], items[1]
  113. pairs.append(
  114. {
  115. "customer_name": name,
  116. "from_id": source["id"],
  117. "from_no": source["customer_no"],
  118. "to_id": keeper["id"],
  119. "to_no": keeper["customer_no"],
  120. }
  121. )
  122. return pairs
  123. def backup_tables(conn: Connection, suffix: str, *, execute: bool) -> None:
  124. for table in TABLES_TO_TOUCH:
  125. backup_name = f"{table}_{suffix}"
  126. sql = f"CREATE TABLE `{backup_name}` AS SELECT * FROM `{table}`"
  127. print(f"[backup] {sql}")
  128. if execute:
  129. conn.execute(text(f"DROP TABLE IF EXISTS `{backup_name}`"))
  130. conn.execute(text(sql))
  131. def fetch_customer(conn: Connection, customer_id: str) -> Optional[Dict[str, Any]]:
  132. row = conn.execute(
  133. text("SELECT * FROM crm_customer WHERE id = :id"),
  134. {"id": customer_id},
  135. ).fetchone()
  136. return _row_to_dict(row) if row else None
  137. def merge_contacts(
  138. conn: Connection,
  139. pair: Dict[str, Any],
  140. keeper_name: str,
  141. *,
  142. execute: bool,
  143. ) -> Dict[str, int]:
  144. stats = {"migrated": 0, "deduped": 0, "kept": 0}
  145. from_rows = conn.execute(
  146. text(
  147. """
  148. SELECT * FROM crm_contact
  149. WHERE customer_id = :cid AND del_flag = 0
  150. ORDER BY is_primary DESC, create_time ASC
  151. """
  152. ),
  153. {"cid": pair["from_id"]},
  154. ).fetchall()
  155. to_rows = conn.execute(
  156. text(
  157. """
  158. SELECT * FROM crm_contact
  159. WHERE customer_id = :cid AND del_flag = 0
  160. ORDER BY is_primary DESC, create_time ASC
  161. """
  162. ),
  163. {"cid": pair["to_id"]},
  164. ).fetchall()
  165. keeper_keys = {_contact_key(_row_to_dict(r)) for r in to_rows}
  166. for row in from_rows:
  167. src = _row_to_dict(row)
  168. key = _contact_key(src)
  169. if key in keeper_keys:
  170. stats["deduped"] += 1
  171. print(
  172. f" [contact dedupe] {pair['customer_name']} duplicate key={key} "
  173. f"soft-delete {src['id']}"
  174. )
  175. if execute:
  176. conn.execute(
  177. text(
  178. """
  179. UPDATE crm_contact
  180. SET del_flag = 1, update_time = NOW(),
  181. remark = CONCAT(IFNULL(remark, ''), '[合并去重]')
  182. WHERE id = :id
  183. """
  184. ),
  185. {"id": src["id"]},
  186. )
  187. continue
  188. stats["migrated"] += 1
  189. print(
  190. f" [contact migrate] {src['id']} -> customer {pair['to_id']} "
  191. f"set primary=1 (old contact)"
  192. )
  193. if execute:
  194. conn.execute(
  195. text(
  196. """
  197. UPDATE crm_contact
  198. SET is_primary = 0, update_time = NOW()
  199. WHERE customer_id = :cid AND del_flag = 0
  200. """
  201. ),
  202. {"cid": pair["to_id"]},
  203. )
  204. conn.execute(
  205. text(
  206. """
  207. UPDATE crm_contact
  208. SET customer_id = :to_id,
  209. customer_name = :customer_name,
  210. is_primary = 1,
  211. update_time = NOW()
  212. WHERE id = :id
  213. """
  214. ),
  215. {
  216. "to_id": pair["to_id"],
  217. "customer_name": keeper_name,
  218. "id": src["id"],
  219. },
  220. )
  221. keeper_keys.add(key)
  222. stats["kept"] = len(to_rows)
  223. return stats
  224. def normalize_invoice_defaults(
  225. conn: Connection,
  226. customer_id: str,
  227. *,
  228. execute: bool,
  229. prefer_ids: Optional[List[str]] = None,
  230. ) -> None:
  231. """确保保留客户下活跃开票有且仅有一条 is_default=1;优先保留客户(新)侧原默认。"""
  232. rows = conn.execute(
  233. text(
  234. """
  235. SELECT id, is_default, create_time
  236. FROM crm_invoice_info
  237. WHERE customer_id = :cid AND del_flag = 0
  238. ORDER BY is_default DESC, create_time ASC
  239. """
  240. ),
  241. {"cid": customer_id},
  242. ).fetchall()
  243. if not rows:
  244. return
  245. active = [_row_to_dict(r) for r in rows]
  246. prefer_set = set(prefer_ids or [])
  247. default_id: Optional[str] = None
  248. if len(active) == 1:
  249. default_id = active[0]["id"]
  250. else:
  251. preferred = [item for item in active if item["id"] in prefer_set]
  252. if preferred:
  253. for item in preferred:
  254. if str(item.get("is_default")) == "1":
  255. default_id = item["id"]
  256. break
  257. if not default_id:
  258. default_id = preferred[0]["id"]
  259. if not default_id:
  260. for item in active:
  261. if str(item.get("is_default")) == "1":
  262. default_id = item["id"]
  263. break
  264. if not default_id:
  265. default_id = active[0]["id"]
  266. print(f" [invoice default] customer {customer_id} -> {default_id}")
  267. if execute:
  268. conn.execute(
  269. text(
  270. """
  271. UPDATE crm_invoice_info
  272. SET is_default = '0', update_time = NOW()
  273. WHERE customer_id = :cid AND del_flag = 0
  274. """
  275. ),
  276. {"cid": customer_id},
  277. )
  278. conn.execute(
  279. text(
  280. """
  281. UPDATE crm_invoice_info
  282. SET is_default = '1', update_time = NOW()
  283. WHERE id = :id
  284. """
  285. ),
  286. {"id": default_id},
  287. )
  288. def merge_invoices(
  289. conn: Connection,
  290. pair: Dict[str, Any],
  291. *,
  292. execute: bool,
  293. ) -> Dict[str, int]:
  294. stats = {"migrated": 0, "deduped": 0}
  295. from_rows = conn.execute(
  296. text(
  297. """
  298. SELECT * FROM crm_invoice_info
  299. WHERE customer_id = :cid AND del_flag = 0
  300. ORDER BY is_default DESC, create_time ASC
  301. """
  302. ),
  303. {"cid": pair["from_id"]},
  304. ).fetchall()
  305. to_rows = conn.execute(
  306. text(
  307. """
  308. SELECT * FROM crm_invoice_info
  309. WHERE customer_id = :cid AND del_flag = 0
  310. ORDER BY is_default DESC, create_time ASC
  311. """
  312. ),
  313. {"cid": pair["to_id"]},
  314. ).fetchall()
  315. keeper_original_ids = [_row_to_dict(r)["id"] for r in to_rows]
  316. keeper_map = {_invoice_key(_row_to_dict(r)): _row_to_dict(r) for r in to_rows}
  317. for row in from_rows:
  318. src = _row_to_dict(row)
  319. key = _invoice_key(src)
  320. if key in keeper_map:
  321. stats["deduped"] += 1
  322. print(f" [invoice dedupe] key={key} soft-delete {src['id']}")
  323. if execute:
  324. conn.execute(
  325. text(
  326. """
  327. UPDATE crm_invoice_info
  328. SET del_flag = 1, update_time = NOW(),
  329. remark = CONCAT(IFNULL(remark, ''), '[合并去重]')
  330. WHERE id = :id
  331. """
  332. ),
  333. {"id": src["id"]},
  334. )
  335. continue
  336. stats["migrated"] += 1
  337. print(f" [invoice migrate] {src['id']} -> customer {pair['to_id']} is_default=0")
  338. if execute:
  339. conn.execute(
  340. text(
  341. """
  342. UPDATE crm_invoice_info
  343. SET customer_id = :to_id,
  344. is_default = '0',
  345. update_time = NOW()
  346. WHERE id = :id
  347. """
  348. ),
  349. {"to_id": pair["to_id"], "id": src["id"]},
  350. )
  351. keeper_map[key] = src
  352. normalize_invoice_defaults(
  353. conn,
  354. pair["to_id"],
  355. execute=execute,
  356. prefer_ids=keeper_original_ids,
  357. )
  358. return stats
  359. def migrate_customer_refs(
  360. conn: Connection,
  361. pair: Dict[str, Any],
  362. keeper_name: str,
  363. *,
  364. execute: bool,
  365. ) -> None:
  366. updates = [
  367. (
  368. "crm_customer_follow",
  369. "UPDATE crm_customer_follow SET customer_id=:to_id, customer_name=:name, update_time=NOW() "
  370. "WHERE customer_id=:from_id AND del_flag=0",
  371. ),
  372. (
  373. "crm_opportunity",
  374. "UPDATE crm_opportunity SET customer_id=:to_id, customer_name=:name, update_time=NOW() "
  375. "WHERE customer_id=:from_id AND del_flag=0",
  376. ),
  377. (
  378. "crm_contract",
  379. "UPDATE crm_contract SET customer_id=:to_id, customer_name=:name, update_time=NOW() "
  380. "WHERE customer_id=:from_id AND del_flag=0",
  381. ),
  382. (
  383. "crm_quote",
  384. "UPDATE crm_quote SET customer_id=:to_id, customer_name=:name, update_time=NOW() "
  385. "WHERE customer_id=:from_id AND del_flag=0",
  386. ),
  387. (
  388. "crm_price_letter",
  389. "UPDATE crm_price_letter SET customer_id=:to_id, customer_name=:name, update_time=NOW() "
  390. "WHERE customer_id=:from_id AND del_flag=0",
  391. ),
  392. (
  393. "crm_customer_pool",
  394. "UPDATE crm_customer_pool SET customer_id=:to_id, customer_name=:name, update_time=NOW() "
  395. "WHERE customer_id=:from_id AND del_flag=0",
  396. ),
  397. (
  398. "crm_clue",
  399. "UPDATE crm_clue SET customer_id=:to_id, update_time=NOW() "
  400. "WHERE customer_id=:from_id AND del_flag=0",
  401. ),
  402. (
  403. "pl_plan",
  404. "UPDATE pl_plan SET customer_id=:to_id, customer_name=:name, party_a=:name, update_time=NOW() "
  405. "WHERE customer_id=:from_id AND del_flag=0",
  406. ),
  407. ]
  408. params = {"from_id": pair["from_id"], "to_id": pair["to_id"], "name": keeper_name}
  409. for table, sql in updates:
  410. count = conn.execute(
  411. text(f"SELECT COUNT(*) FROM `{table}` WHERE customer_id=:from_id AND del_flag=0"),
  412. {"from_id": pair["from_id"]},
  413. ).scalar()
  414. if count:
  415. print(f" [{table}] migrate {count} rows")
  416. if execute:
  417. conn.execute(text(sql), params)
  418. def merge_customer_master(
  419. conn: Connection,
  420. pair: Dict[str, Any],
  421. *,
  422. execute: bool,
  423. ) -> None:
  424. keeper = fetch_customer(conn, pair["to_id"])
  425. source = fetch_customer(conn, pair["from_id"])
  426. if not keeper or not source:
  427. return
  428. sets = []
  429. params: Dict[str, Any] = {"id": pair["to_id"]}
  430. for field in CUSTOMER_FILL_FIELDS:
  431. k_val = keeper.get(field)
  432. s_val = source.get(field)
  433. if _norm(k_val):
  434. continue
  435. if not _norm(s_val):
  436. continue
  437. sets.append(f"{field} = :{field}")
  438. params[field] = s_val
  439. deal_priority = {"dealed": 3, "lost": 2, "not_deal": 1}
  440. if deal_priority.get(_norm(source.get("deal_status")), 0) > deal_priority.get(
  441. _norm(keeper.get("deal_status")), 0
  442. ):
  443. sets.extend(
  444. [
  445. "deal_status = :deal_status",
  446. "deal_time = COALESCE(:deal_time, deal_time)",
  447. "deal_amount = GREATEST(IFNULL(deal_amount,0), IFNULL(:deal_amount,0))",
  448. ]
  449. )
  450. params["deal_status"] = source.get("deal_status")
  451. params["deal_time"] = source.get("deal_time")
  452. params["deal_amount"] = source.get("deal_amount")
  453. merge_note = (
  454. f"[{date.today().isoformat()} 合并客户 {source.get('customer_no')}({source.get('id')})]"
  455. )
  456. old_remark = _norm(keeper.get("remark"))
  457. if merge_note not in old_remark:
  458. sets.append("remark = :remark")
  459. params["remark"] = f"{old_remark}\n{merge_note}".strip()
  460. if sets and execute:
  461. sql = f"UPDATE crm_customer SET {', '.join(sets)}, update_time = NOW() WHERE id = :id"
  462. conn.execute(text(sql), params)
  463. print(f" [customer master] filled fields: {', '.join(x.split(' ')[0] for x in sets)}")
  464. def recalc_customer_stats(conn: Connection, customer_id: str, *, execute: bool) -> None:
  465. if not execute:
  466. return
  467. contact_count = conn.execute(
  468. text("SELECT COUNT(*) FROM crm_contact WHERE customer_id=:id AND del_flag=0"),
  469. {"id": customer_id},
  470. ).scalar()
  471. opportunity_count = conn.execute(
  472. text("SELECT COUNT(*) FROM crm_opportunity WHERE customer_id=:id AND del_flag=0"),
  473. {"id": customer_id},
  474. ).scalar()
  475. contract_count = conn.execute(
  476. text("SELECT COUNT(*) FROM crm_contract WHERE customer_id=:id AND del_flag=0"),
  477. {"id": customer_id},
  478. ).scalar()
  479. follow_count = conn.execute(
  480. text("SELECT COUNT(*) FROM crm_customer_follow WHERE customer_id=:id AND del_flag=0"),
  481. {"id": customer_id},
  482. ).scalar()
  483. deal_amount = conn.execute(
  484. text("SELECT COALESCE(SUM(amount),0) FROM crm_contract WHERE customer_id=:id AND del_flag=0"),
  485. {"id": customer_id},
  486. ).scalar()
  487. last_time = conn.execute(
  488. text("SELECT MAX(follow_time) FROM crm_customer_follow WHERE customer_id=:id AND del_flag=0"),
  489. {"id": customer_id},
  490. ).scalar()
  491. next_time = conn.execute(
  492. text(
  493. """
  494. SELECT MAX(next_time) FROM (
  495. SELECT next_time FROM crm_customer_follow WHERE customer_id=:id AND del_flag=0
  496. UNION ALL
  497. SELECT next_time FROM crm_customer WHERE id=:id
  498. ) t
  499. """
  500. ),
  501. {"id": customer_id},
  502. ).scalar()
  503. conn.execute(
  504. text(
  505. """
  506. UPDATE crm_customer
  507. SET contact_count=:contact_count,
  508. opportunity_count=:opportunity_count,
  509. contract_count=:contract_count,
  510. follow_count=:follow_count,
  511. deal_amount=:deal_amount,
  512. last_time=COALESCE(:last_time, last_time),
  513. next_time=COALESCE(:next_time, next_time),
  514. update_time=NOW()
  515. WHERE id=:id
  516. """
  517. ),
  518. {
  519. "id": customer_id,
  520. "contact_count": contact_count,
  521. "opportunity_count": opportunity_count,
  522. "contract_count": contract_count,
  523. "follow_count": follow_count,
  524. "deal_amount": deal_amount,
  525. "last_time": last_time,
  526. "next_time": next_time,
  527. },
  528. )
  529. def soft_delete_source_customer(
  530. conn: Connection,
  531. pair: Dict[str, Any],
  532. *,
  533. execute: bool,
  534. ) -> None:
  535. note = f"[{date.today().isoformat()} 已合并至 {pair['to_no']}({pair['to_id']})]"
  536. print(f" [customer soft-delete] {pair['from_no']} ({pair['from_id']})")
  537. if execute:
  538. conn.execute(
  539. text(
  540. """
  541. UPDATE crm_customer
  542. SET del_flag = 1,
  543. remark = CONCAT(IFNULL(remark, ''), :note),
  544. update_time = NOW()
  545. WHERE id = :id
  546. """
  547. ),
  548. {"id": pair["from_id"], "note": f"\n{note}"},
  549. )
  550. def verify(conn: Connection, merged_from_ids: List[str]) -> None:
  551. dup_groups = conn.execute(
  552. text(
  553. """
  554. SELECT COUNT(*) FROM (
  555. SELECT customer_name FROM crm_customer
  556. WHERE del_flag=0 AND customer_name IS NOT NULL AND TRIM(customer_name)!=''
  557. GROUP BY customer_name HAVING COUNT(*)>1
  558. ) t
  559. """
  560. )
  561. ).scalar()
  562. merge_orphans = 0
  563. if merged_from_ids:
  564. placeholders = ", ".join(f":id{i}" for i in range(len(merged_from_ids)))
  565. params = {f"id{i}": cid for i, cid in enumerate(merged_from_ids)}
  566. merge_orphans = conn.execute(
  567. text(
  568. f"""
  569. SELECT COUNT(*) FROM (
  570. SELECT customer_id FROM crm_contact WHERE del_flag=0
  571. UNION ALL SELECT customer_id FROM crm_invoice_info WHERE del_flag=0
  572. UNION ALL SELECT customer_id FROM crm_contract WHERE del_flag=0
  573. UNION ALL SELECT customer_id FROM crm_quote WHERE del_flag=0
  574. UNION ALL SELECT customer_id FROM pl_plan WHERE del_flag=0
  575. ) r
  576. WHERE r.customer_id IN ({placeholders})
  577. """
  578. ),
  579. params,
  580. ).scalar()
  581. global_orphans = {}
  582. for table in [
  583. "crm_contact",
  584. "crm_invoice_info",
  585. "crm_contract",
  586. "crm_quote",
  587. "pl_plan",
  588. ]:
  589. cnt = conn.execute(
  590. text(
  591. f"""
  592. SELECT COUNT(*) FROM `{table}` r
  593. LEFT JOIN crm_customer c ON c.id = r.customer_id AND c.del_flag = 0
  594. WHERE r.del_flag = 0 AND r.customer_id IS NOT NULL AND c.id IS NULL
  595. """
  596. )
  597. ).scalar()
  598. global_orphans[table] = cnt
  599. print("\n=== verification ===")
  600. print(f"duplicate active customer name groups: {dup_groups}")
  601. print(f"active refs to merged-deleted customer ids: {merge_orphans}")
  602. for table, cnt in global_orphans.items():
  603. if cnt:
  604. print(f"pre-existing orphan refs in {table}: {cnt}")
  605. if dup_groups != 0:
  606. raise RuntimeError("duplicate customer names still exist")
  607. if merge_orphans != 0:
  608. raise RuntimeError("active child rows still reference merged-deleted customers")
  609. def run(engine: Engine, *, execute: bool, suffix: str) -> None:
  610. pairs = []
  611. with engine.connect() as conn:
  612. pairs = discover_merge_pairs(conn)
  613. print(f"found {len(pairs)} merge pairs")
  614. for pair in pairs:
  615. print(
  616. f" {pair['customer_name']}: {pair['from_no']} -> {pair['to_no']}"
  617. )
  618. if not pairs:
  619. print("nothing to merge")
  620. return
  621. if execute:
  622. with engine.begin() as conn:
  623. backup_tables(conn, suffix, execute=True)
  624. with engine.begin() as conn:
  625. for pair in pairs:
  626. keeper = fetch_customer(conn, pair["to_id"])
  627. keeper_name = keeper["customer_name"] if keeper else pair["customer_name"]
  628. print(f"\n>>> merge {pair['customer_name']}")
  629. merge_contacts(conn, pair, keeper_name, execute=execute)
  630. merge_invoices(conn, pair, execute=execute)
  631. migrate_customer_refs(conn, pair, keeper_name, execute=execute)
  632. merge_customer_master(conn, pair, execute=execute)
  633. recalc_customer_stats(conn, pair["to_id"], execute=execute)
  634. soft_delete_source_customer(conn, pair, execute=execute)
  635. if execute:
  636. with engine.connect() as conn:
  637. verify(conn, [p["from_id"] for p in pairs])
  638. print("\nmerge completed successfully")
  639. else:
  640. print("\ndry-run finished (no changes applied)")
  641. def main() -> None:
  642. parser = argparse.ArgumentParser(description="Merge duplicate CRM customers")
  643. parser.add_argument("--env-file", default=".env", help="env file with DATABASE_URL")
  644. parser.add_argument("--suffix", default=date.today().strftime("%Y%m%d"))
  645. parser.add_argument("--dry-run", action="store_true", help="preview only")
  646. parser.add_argument("--execute", action="store_true", help="apply changes")
  647. args = parser.parse_args()
  648. if not args.dry_run and not args.execute:
  649. parser.error("specify --dry-run or --execute")
  650. db_url = load_database_url(args.env_file)
  651. print(f"database: {re.sub(r':[^:@/]+@', ':***@', db_url)}")
  652. print(f"backup suffix: {args.suffix}")
  653. engine = create_engine(db_url, pool_pre_ping=True)
  654. run(engine, execute=args.execute, suffix=args.suffix)
  655. if __name__ == "__main__":
  656. main()