726 lines
23 KiB
Python
726 lines
23 KiB
Python
#!/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()
|