#!/usr/bin/env python3 """合并 CRM 重复客户(测试/生产均可)。 规则摘要: - 同名 del_flag=0 客户每组保留 create_time 最新的一条(新业务记录) - 旧客户软删,子表 customer_id 迁到保留客户 - 联系人/开票:核心字段相同则去重;不同则迁移;旧联系人优先 is_primary - 开票默认以保留客户(新)侧为准;旧侧不同内容迁移保留 - 修改前按 YYYYMMDD 备份涉及表 用法: cd backend venv/bin/python scripts/merge_duplicate_crm_customers.py --dry-run venv/bin/python scripts/merge_duplicate_crm_customers.py --execute """ from __future__ import annotations import argparse import re import sys from datetime import date, datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import create_engine, text from sqlalchemy.engine import Connection, Engine BACKEND_ROOT = Path(__file__).resolve().parents[1] if str(BACKEND_ROOT) not in sys.path: sys.path.insert(0, str(BACKEND_ROOT)) TABLES_TO_TOUCH = [ "crm_customer", "crm_contact", "crm_invoice_info", "crm_customer_follow", "crm_opportunity", "crm_contract", "crm_quote", "crm_price_letter", "crm_clue", "crm_customer_pool", "pl_plan", ] CUSTOMER_FILL_FIELDS = [ "short_name", "customer_type", "mobile", "telephone", "email", "website", "level", "source", "industry", "park_area", "payment_terms", "scale", "province", "city", "district", "address", "owner_user_id", "owner_user_name", "business_license_img", "business_permit_images", "other_cert_images", "clue_id", "next_time", ] INVOICE_COMPARE_FIELDS = [ "company_name", "tax_no", "bank_account", ] def _norm(value: Any) -> str: if value is None: return "" return str(value).strip() def _contact_key(row: Dict[str, Any]) -> str: mobile = _norm(row.get("mobile")) telephone = _norm(row.get("telephone")) phone = mobile or telephone return f"{_norm(row.get('contact_name')).lower()}|{phone}" def _invoice_key(row: Dict[str, Any]) -> str: return "|".join(_norm(row.get(f)).lower() for f in INVOICE_COMPARE_FIELDS) def _row_to_dict(row) -> Dict[str, Any]: return dict(row._mapping) def load_database_url(env_file: Optional[str]) -> str: if env_file: path = Path(env_file) if not path.is_absolute(): path = BACKEND_ROOT / path for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if line.startswith("DATABASE_URL="): return line.split("=", 1)[1].strip() from app.core.config import settings return settings.DATABASE_URL def discover_merge_pairs(conn: Connection) -> List[Dict[str, Any]]: rows = conn.execute( text( """ SELECT id, customer_name, customer_no, create_time FROM crm_customer WHERE del_flag = 0 AND customer_name IS NOT NULL AND TRIM(customer_name) != '' ORDER BY customer_name, create_time ASC, customer_no ASC """ ) ).fetchall() grouped: Dict[str, List[Dict[str, Any]]] = {} for row in rows: item = _row_to_dict(row) grouped.setdefault(item["customer_name"], []).append(item) pairs: List[Dict[str, Any]] = [] for name, items in grouped.items(): if len(items) != 2: continue source, keeper = items[0], items[1] pairs.append( { "customer_name": name, "from_id": source["id"], "from_no": source["customer_no"], "to_id": keeper["id"], "to_no": keeper["customer_no"], } ) return pairs def backup_tables(conn: Connection, suffix: str, *, execute: bool) -> None: for table in TABLES_TO_TOUCH: backup_name = f"{table}_{suffix}" sql = f"CREATE TABLE `{backup_name}` AS SELECT * FROM `{table}`" print(f"[backup] {sql}") if execute: conn.execute(text(f"DROP TABLE IF EXISTS `{backup_name}`")) conn.execute(text(sql)) def fetch_customer(conn: Connection, customer_id: str) -> Optional[Dict[str, Any]]: row = conn.execute( text("SELECT * FROM crm_customer WHERE id = :id"), {"id": customer_id}, ).fetchone() return _row_to_dict(row) if row else None def merge_contacts( conn: Connection, pair: Dict[str, Any], keeper_name: str, *, execute: bool, ) -> Dict[str, int]: stats = {"migrated": 0, "deduped": 0, "kept": 0} from_rows = conn.execute( text( """ SELECT * FROM crm_contact WHERE customer_id = :cid AND del_flag = 0 ORDER BY is_primary DESC, create_time ASC """ ), {"cid": pair["from_id"]}, ).fetchall() to_rows = conn.execute( text( """ SELECT * FROM crm_contact WHERE customer_id = :cid AND del_flag = 0 ORDER BY is_primary DESC, create_time ASC """ ), {"cid": pair["to_id"]}, ).fetchall() keeper_keys = {_contact_key(_row_to_dict(r)) for r in to_rows} for row in from_rows: src = _row_to_dict(row) key = _contact_key(src) if key in keeper_keys: stats["deduped"] += 1 print( f" [contact dedupe] {pair['customer_name']} duplicate key={key} " f"soft-delete {src['id']}" ) if execute: conn.execute( text( """ UPDATE crm_contact SET del_flag = 1, update_time = NOW(), remark = CONCAT(IFNULL(remark, ''), '[合并去重]') WHERE id = :id """ ), {"id": src["id"]}, ) continue stats["migrated"] += 1 print( f" [contact migrate] {src['id']} -> customer {pair['to_id']} " f"set primary=1 (old contact)" ) if execute: conn.execute( text( """ UPDATE crm_contact SET is_primary = 0, update_time = NOW() WHERE customer_id = :cid AND del_flag = 0 """ ), {"cid": pair["to_id"]}, ) conn.execute( text( """ UPDATE crm_contact SET customer_id = :to_id, customer_name = :customer_name, is_primary = 1, update_time = NOW() WHERE id = :id """ ), { "to_id": pair["to_id"], "customer_name": keeper_name, "id": src["id"], }, ) keeper_keys.add(key) stats["kept"] = len(to_rows) return stats def normalize_invoice_defaults( conn: Connection, customer_id: str, *, execute: bool, prefer_ids: Optional[List[str]] = None, ) -> None: """确保保留客户下活跃开票有且仅有一条 is_default=1;优先保留客户(新)侧原默认。""" rows = conn.execute( text( """ SELECT id, is_default, create_time FROM crm_invoice_info WHERE customer_id = :cid AND del_flag = 0 ORDER BY is_default DESC, create_time ASC """ ), {"cid": customer_id}, ).fetchall() if not rows: return active = [_row_to_dict(r) for r in rows] prefer_set = set(prefer_ids or []) default_id: Optional[str] = None if len(active) == 1: default_id = active[0]["id"] else: preferred = [item for item in active if item["id"] in prefer_set] if preferred: for item in preferred: if str(item.get("is_default")) == "1": default_id = item["id"] break if not default_id: default_id = preferred[0]["id"] if not default_id: for item in active: if str(item.get("is_default")) == "1": default_id = item["id"] break if not default_id: default_id = active[0]["id"] print(f" [invoice default] customer {customer_id} -> {default_id}") if execute: conn.execute( text( """ UPDATE crm_invoice_info SET is_default = '0', update_time = NOW() WHERE customer_id = :cid AND del_flag = 0 """ ), {"cid": customer_id}, ) conn.execute( text( """ UPDATE crm_invoice_info SET is_default = '1', update_time = NOW() WHERE id = :id """ ), {"id": default_id}, ) def merge_invoices( conn: Connection, pair: Dict[str, Any], *, execute: bool, ) -> Dict[str, int]: stats = {"migrated": 0, "deduped": 0} from_rows = conn.execute( text( """ SELECT * FROM crm_invoice_info WHERE customer_id = :cid AND del_flag = 0 ORDER BY is_default DESC, create_time ASC """ ), {"cid": pair["from_id"]}, ).fetchall() to_rows = conn.execute( text( """ SELECT * FROM crm_invoice_info WHERE customer_id = :cid AND del_flag = 0 ORDER BY is_default DESC, create_time ASC """ ), {"cid": pair["to_id"]}, ).fetchall() keeper_original_ids = [_row_to_dict(r)["id"] for r in to_rows] keeper_map = {_invoice_key(_row_to_dict(r)): _row_to_dict(r) for r in to_rows} for row in from_rows: src = _row_to_dict(row) key = _invoice_key(src) if key in keeper_map: stats["deduped"] += 1 print(f" [invoice dedupe] key={key} soft-delete {src['id']}") if execute: conn.execute( text( """ UPDATE crm_invoice_info SET del_flag = 1, update_time = NOW(), remark = CONCAT(IFNULL(remark, ''), '[合并去重]') WHERE id = :id """ ), {"id": src["id"]}, ) continue stats["migrated"] += 1 print(f" [invoice migrate] {src['id']} -> customer {pair['to_id']} is_default=0") if execute: conn.execute( text( """ UPDATE crm_invoice_info SET customer_id = :to_id, is_default = '0', update_time = NOW() WHERE id = :id """ ), {"to_id": pair["to_id"], "id": src["id"]}, ) keeper_map[key] = src normalize_invoice_defaults( conn, pair["to_id"], execute=execute, prefer_ids=keeper_original_ids, ) return stats def migrate_customer_refs( conn: Connection, pair: Dict[str, Any], keeper_name: str, *, execute: bool, ) -> None: updates = [ ( "crm_customer_follow", "UPDATE crm_customer_follow SET customer_id=:to_id, customer_name=:name, update_time=NOW() " "WHERE customer_id=:from_id AND del_flag=0", ), ( "crm_opportunity", "UPDATE crm_opportunity SET customer_id=:to_id, customer_name=:name, update_time=NOW() " "WHERE customer_id=:from_id AND del_flag=0", ), ( "crm_contract", "UPDATE crm_contract SET customer_id=:to_id, customer_name=:name, update_time=NOW() " "WHERE customer_id=:from_id AND del_flag=0", ), ( "crm_quote", "UPDATE crm_quote SET customer_id=:to_id, customer_name=:name, update_time=NOW() " "WHERE customer_id=:from_id AND del_flag=0", ), ( "crm_price_letter", "UPDATE crm_price_letter SET customer_id=:to_id, customer_name=:name, update_time=NOW() " "WHERE customer_id=:from_id AND del_flag=0", ), ( "crm_customer_pool", "UPDATE crm_customer_pool SET customer_id=:to_id, customer_name=:name, update_time=NOW() " "WHERE customer_id=:from_id AND del_flag=0", ), ( "crm_clue", "UPDATE crm_clue SET customer_id=:to_id, update_time=NOW() " "WHERE customer_id=:from_id AND del_flag=0", ), ( "pl_plan", "UPDATE pl_plan SET customer_id=:to_id, customer_name=:name, party_a=:name, update_time=NOW() " "WHERE customer_id=:from_id AND del_flag=0", ), ] params = {"from_id": pair["from_id"], "to_id": pair["to_id"], "name": keeper_name} for table, sql in updates: count = conn.execute( text(f"SELECT COUNT(*) FROM `{table}` WHERE customer_id=:from_id AND del_flag=0"), {"from_id": pair["from_id"]}, ).scalar() if count: print(f" [{table}] migrate {count} rows") if execute: conn.execute(text(sql), params) def merge_customer_master( conn: Connection, pair: Dict[str, Any], *, execute: bool, ) -> None: keeper = fetch_customer(conn, pair["to_id"]) source = fetch_customer(conn, pair["from_id"]) if not keeper or not source: return sets = [] params: Dict[str, Any] = {"id": pair["to_id"]} for field in CUSTOMER_FILL_FIELDS: k_val = keeper.get(field) s_val = source.get(field) if _norm(k_val): continue if not _norm(s_val): continue sets.append(f"{field} = :{field}") params[field] = s_val deal_priority = {"dealed": 3, "lost": 2, "not_deal": 1} if deal_priority.get(_norm(source.get("deal_status")), 0) > deal_priority.get( _norm(keeper.get("deal_status")), 0 ): sets.extend( [ "deal_status = :deal_status", "deal_time = COALESCE(:deal_time, deal_time)", "deal_amount = GREATEST(IFNULL(deal_amount,0), IFNULL(:deal_amount,0))", ] ) params["deal_status"] = source.get("deal_status") params["deal_time"] = source.get("deal_time") params["deal_amount"] = source.get("deal_amount") merge_note = ( f"[{date.today().isoformat()} 合并客户 {source.get('customer_no')}({source.get('id')})]" ) old_remark = _norm(keeper.get("remark")) if merge_note not in old_remark: sets.append("remark = :remark") params["remark"] = f"{old_remark}\n{merge_note}".strip() if sets and execute: sql = f"UPDATE crm_customer SET {', '.join(sets)}, update_time = NOW() WHERE id = :id" conn.execute(text(sql), params) print(f" [customer master] filled fields: {', '.join(x.split(' ')[0] for x in sets)}") def recalc_customer_stats(conn: Connection, customer_id: str, *, execute: bool) -> None: if not execute: return contact_count = conn.execute( text("SELECT COUNT(*) FROM crm_contact WHERE customer_id=:id AND del_flag=0"), {"id": customer_id}, ).scalar() opportunity_count = conn.execute( text("SELECT COUNT(*) FROM crm_opportunity WHERE customer_id=:id AND del_flag=0"), {"id": customer_id}, ).scalar() contract_count = conn.execute( text("SELECT COUNT(*) FROM crm_contract WHERE customer_id=:id AND del_flag=0"), {"id": customer_id}, ).scalar() follow_count = conn.execute( text("SELECT COUNT(*) FROM crm_customer_follow WHERE customer_id=:id AND del_flag=0"), {"id": customer_id}, ).scalar() deal_amount = conn.execute( text("SELECT COALESCE(SUM(amount),0) FROM crm_contract WHERE customer_id=:id AND del_flag=0"), {"id": customer_id}, ).scalar() last_time = conn.execute( text("SELECT MAX(follow_time) FROM crm_customer_follow WHERE customer_id=:id AND del_flag=0"), {"id": customer_id}, ).scalar() next_time = conn.execute( text( """ SELECT MAX(next_time) FROM ( SELECT next_time FROM crm_customer_follow WHERE customer_id=:id AND del_flag=0 UNION ALL SELECT next_time FROM crm_customer WHERE id=:id ) t """ ), {"id": customer_id}, ).scalar() conn.execute( text( """ UPDATE crm_customer SET contact_count=:contact_count, opportunity_count=:opportunity_count, contract_count=:contract_count, follow_count=:follow_count, deal_amount=:deal_amount, last_time=COALESCE(:last_time, last_time), next_time=COALESCE(:next_time, next_time), update_time=NOW() WHERE id=:id """ ), { "id": customer_id, "contact_count": contact_count, "opportunity_count": opportunity_count, "contract_count": contract_count, "follow_count": follow_count, "deal_amount": deal_amount, "last_time": last_time, "next_time": next_time, }, ) def soft_delete_source_customer( conn: Connection, pair: Dict[str, Any], *, execute: bool, ) -> None: note = f"[{date.today().isoformat()} 已合并至 {pair['to_no']}({pair['to_id']})]" print(f" [customer soft-delete] {pair['from_no']} ({pair['from_id']})") if execute: conn.execute( text( """ UPDATE crm_customer SET del_flag = 1, remark = CONCAT(IFNULL(remark, ''), :note), update_time = NOW() WHERE id = :id """ ), {"id": pair["from_id"], "note": f"\n{note}"}, ) def verify(conn: Connection, merged_from_ids: List[str]) -> None: dup_groups = conn.execute( text( """ SELECT COUNT(*) FROM ( SELECT customer_name FROM crm_customer WHERE del_flag=0 AND customer_name IS NOT NULL AND TRIM(customer_name)!='' GROUP BY customer_name HAVING COUNT(*)>1 ) t """ ) ).scalar() merge_orphans = 0 if merged_from_ids: placeholders = ", ".join(f":id{i}" for i in range(len(merged_from_ids))) params = {f"id{i}": cid for i, cid in enumerate(merged_from_ids)} merge_orphans = conn.execute( text( f""" SELECT COUNT(*) FROM ( SELECT customer_id FROM crm_contact WHERE del_flag=0 UNION ALL SELECT customer_id FROM crm_invoice_info WHERE del_flag=0 UNION ALL SELECT customer_id FROM crm_contract WHERE del_flag=0 UNION ALL SELECT customer_id FROM crm_quote WHERE del_flag=0 UNION ALL SELECT customer_id FROM pl_plan WHERE del_flag=0 ) r WHERE r.customer_id IN ({placeholders}) """ ), params, ).scalar() global_orphans = {} for table in [ "crm_contact", "crm_invoice_info", "crm_contract", "crm_quote", "pl_plan", ]: cnt = conn.execute( text( f""" SELECT COUNT(*) FROM `{table}` r LEFT JOIN crm_customer c ON c.id = r.customer_id AND c.del_flag = 0 WHERE r.del_flag = 0 AND r.customer_id IS NOT NULL AND c.id IS NULL """ ) ).scalar() global_orphans[table] = cnt print("\n=== verification ===") print(f"duplicate active customer name groups: {dup_groups}") print(f"active refs to merged-deleted customer ids: {merge_orphans}") for table, cnt in global_orphans.items(): if cnt: print(f"pre-existing orphan refs in {table}: {cnt}") if dup_groups != 0: raise RuntimeError("duplicate customer names still exist") if merge_orphans != 0: raise RuntimeError("active child rows still reference merged-deleted customers") def run(engine: Engine, *, execute: bool, suffix: str) -> None: pairs = [] with engine.connect() as conn: pairs = discover_merge_pairs(conn) print(f"found {len(pairs)} merge pairs") for pair in pairs: print( f" {pair['customer_name']}: {pair['from_no']} -> {pair['to_no']}" ) if not pairs: print("nothing to merge") return if execute: with engine.begin() as conn: backup_tables(conn, suffix, execute=True) with engine.begin() as conn: for pair in pairs: keeper = fetch_customer(conn, pair["to_id"]) keeper_name = keeper["customer_name"] if keeper else pair["customer_name"] print(f"\n>>> merge {pair['customer_name']}") merge_contacts(conn, pair, keeper_name, execute=execute) merge_invoices(conn, pair, execute=execute) migrate_customer_refs(conn, pair, keeper_name, execute=execute) merge_customer_master(conn, pair, execute=execute) recalc_customer_stats(conn, pair["to_id"], execute=execute) soft_delete_source_customer(conn, pair, execute=execute) if execute: with engine.connect() as conn: verify(conn, [p["from_id"] for p in pairs]) print("\nmerge completed successfully") else: print("\ndry-run finished (no changes applied)") def main() -> None: parser = argparse.ArgumentParser(description="Merge duplicate CRM customers") parser.add_argument("--env-file", default=".env", help="env file with DATABASE_URL") parser.add_argument("--suffix", default=date.today().strftime("%Y%m%d")) parser.add_argument("--dry-run", action="store_true", help="preview only") parser.add_argument("--execute", action="store_true", help="apply changes") args = parser.parse_args() if not args.dry_run and not args.execute: parser.error("specify --dry-run or --execute") db_url = load_database_url(args.env_file) print(f"database: {re.sub(r':[^:@/]+@', ':***@', db_url)}") print(f"backup suffix: {args.suffix}") engine = create_engine(db_url, pool_pre_ping=True) run(engine, execute=args.execute, suffix=args.suffix) if __name__ == "__main__": main()