build_customer_summary.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. # -*- coding: utf-8 -*-
  2. """Build the consolidated customer summary sheet for the outreach workbook."""
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. import re
  7. from collections import Counter, defaultdict
  8. from dataclasses import dataclass, field
  9. from datetime import datetime
  10. from pathlib import Path
  11. from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
  12. from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
  13. from openpyxl import load_workbook
  14. from openpyxl.styles import Font, PatternFill
  15. from openpyxl.utils import get_column_letter
  16. try:
  17. from .artifact_manager import create_backup_once, resolve_artifact_path
  18. from .workbook_resolver import resolve_workbook_path
  19. from .customer_taxonomy import (
  20. CUSTOMER_TAXONOMY,
  21. VALID_ATTRIBUTES,
  22. VALID_CUSTOMER_TYPES,
  23. classify_attribute_type,
  24. is_valid_pair,
  25. normalize_existing_classification,
  26. )
  27. except ImportError: # pragma: no cover - supports direct CLI execution
  28. from artifact_manager import create_backup_once, resolve_artifact_path
  29. from workbook_resolver import resolve_workbook_path
  30. from customer_taxonomy import (
  31. CUSTOMER_TAXONOMY,
  32. VALID_ATTRIBUTES,
  33. VALID_CUSTOMER_TYPES,
  34. classify_attribute_type,
  35. is_valid_pair,
  36. normalize_existing_classification,
  37. )
  38. SUMMARY_SHEET = "客户信息汇总表"
  39. DEFAULT_COUNTRY = "摩洛哥"
  40. DEFAULT_STATUS = "未联系"
  41. PLATFORM_SHEETS = [
  42. "Facebook",
  43. "Google Maps",
  44. "LinkedIn",
  45. "TikTok",
  46. "协会商会",
  47. "本地汽车网站",
  48. "Sheet11",
  49. "汽车网站精选线索",
  50. ]
  51. # Keep this header exactly aligned with the user's reference workbook:
  52. # 摩洛哥重点客户50家-汽车渠道与平台行业渠道.xlsx
  53. SUMMARY_HEADERS = [
  54. "公司姓名",
  55. "国家",
  56. "城市",
  57. "客户类型",
  58. "官网链接",
  59. "联系人",
  60. "职位",
  61. "个人邮箱",
  62. "联系人电话",
  63. "Facebook主页链接",
  64. "linkined主页链接",
  65. "google map链接",
  66. "公共电话/WhatsApp",
  67. "公共邮箱",
  68. "客户属性",
  69. "建联状态",
  70. "下次跟进",
  71. "备注",
  72. ]
  73. HEADER_ALIASES = {
  74. "name": ["公司姓名", "客户姓名/公司", "公司名称", "客户名称", "Name", "Company"],
  75. "country": ["国家", "Country"],
  76. "city": ["城市", "City"],
  77. "attribute": ["客户属性", "客户大类", "大类", "Customer Attribute"],
  78. "type": ["客户类型", "细分客户类型", "类型", "Customer Type"],
  79. "link": ["主页/链接", "公司链接", "链接", "Link", "URL"],
  80. "facebook_link": ["Facebook主页链接", "Facebook链接", "facebook链接", "主页/链接"],
  81. "linkedin_link": ["linkined主页链接", "LinkedIn主页链接", "LinkedIn链接", "linkin链接", "linkin连接"],
  82. "google_maps_link": ["google map链接", "Google Maps链接", "Google Map链接", "地图链接"],
  83. "website": ["官网链接", "公司官网", "官网", "官方网站", "Website", "Company Website"],
  84. "source_site": ["来源网站", "来源", "Source"],
  85. "contact": ["联系人", "姓名", "Contact"],
  86. "position": ["职位", "职务", "Position", "Title"],
  87. "public_phone": ["公共电话/WhatsApp", "电话/WhatsApp", "公司公共电话", "电话", "WhatsApp", "Phone"],
  88. "contact_phone": ["联系人电话", "个人电话", "联系电话"],
  89. "public_email": ["公共邮箱", "邮箱", "公司公共邮箱(任一有效即可)", "公司公共邮箱", "Email"],
  90. "personal_email": ["个人邮箱", "个人邮箱(不一定有效)"],
  91. "business": ["主营业务", "公司主营业务", "业务", "Business"],
  92. "status": ["建联状态", "建联情况", "状态", "Status"],
  93. "next_followup": ["下次跟进", "下次跟进时间", "Next Follow-up"],
  94. "note": ["备注", "说明", "Notes"],
  95. }
  96. AUTO_NAME_KEYWORDS = ["auto", "autos", "automobile", "automobiles", "cars", "car center", "motors", "garage"]
  97. BRAND_CONTEXT_KEYWORDS = ["renault", "dacia", "seat", "cupra", "honda", "peugeot", "citroen", "citroën", "hyundai", "kia", "toyota", "nissan", "ford", "fiat", "volkswagen", "vw", "bmw", "mercedes", "audi", "opel", "skoda", "suzuki", "mazda", "jeep", "chery", "geely", "dfsk", "jac", "byd", "mg", "haval", "foton", "changan", "alfa romeo", "škoda", "porsche", "jaguar", "land rover", "ds", "gwm", "mini", "stellantis", "leapmotor", "sopriam"]
  98. SOURCE_MAP = {
  99. "Facebook": "Facebook",
  100. "LinkedIn": "LinkedIn",
  101. "Google Maps": "Google Maps",
  102. "TikTok": "TikTok",
  103. "协会商会": "平台与行业渠道",
  104. "本地汽车网站": "当地汽车网站",
  105. "Sheet11": "Moteur.ma",
  106. }
  107. GENERIC_NAME_WORDS = {
  108. "sarl", "sa", "sas", "ltd", "llc", "inc", "co", "company", "groupe", "group", "maroc", "morocco", "officiel", "official",
  109. }
  110. # Strict five-attribute taxonomy is imported from customer_taxonomy.py.
  111. EMAIL_RE = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.I)
  112. PHONE_RE = re.compile(r"(?:\+?\d[\d\s()./-]{6,}\d)")
  113. SPLIT_RE = re.compile(r"[;;,,\n]+")
  114. @dataclass
  115. class Record:
  116. idx: int
  117. sheet: str
  118. row: int
  119. values: Dict[str, str]
  120. sources: List[str] = field(default_factory=list)
  121. source_links: List[str] = field(default_factory=list)
  122. @dataclass
  123. class DSU:
  124. parent: Dict[int, int] = field(default_factory=dict)
  125. def find(self, value: int) -> int:
  126. self.parent.setdefault(value, value)
  127. if self.parent[value] != value:
  128. self.parent[value] = self.find(self.parent[value])
  129. return self.parent[value]
  130. def union(self, left: int, right: int) -> None:
  131. root_left = self.find(left)
  132. root_right = self.find(right)
  133. if root_left != root_right:
  134. self.parent[root_right] = root_left
  135. def clean(value: Any) -> str:
  136. if value is None:
  137. return ""
  138. return re.sub(r"\s+", " ", str(value).strip())
  139. def split_values(text: str) -> List[str]:
  140. output: List[str] = []
  141. for part in SPLIT_RE.split(clean(text)):
  142. item = part.strip()
  143. if item and item not in output:
  144. output.append(item)
  145. return output
  146. def first_non_empty(*values: str, default: str = "") -> str:
  147. for value in values:
  148. if clean(value):
  149. return clean(value)
  150. return default
  151. STATUS_PRIORITY = {
  152. "\u90ae\u4ef6\u9000\u56de": 90,
  153. "\u90e8\u5206\u90ae\u4ef6\u9000\u56de": 85,
  154. "\u5df2\u53d1\u9001\u90ae\u4ef6": 80,
  155. "\u5df2\u53d1\u90ae\u4ef6": 80,
  156. "\u5df2\u5efa\u8054": 70,
  157. "\u65e0\u6cd5\u786e\u8ba4": 30,
  158. "\u5f85\u53d1\u9001": 20,
  159. "\u672a\u53d1\u9001": 10,
  160. "\u672a\u8054\u7cfb": 5,
  161. "\u672a\u5efa\u8054": 5,
  162. }
  163. def normalize_status(value: str) -> str:
  164. value = clean(value)
  165. if value == "\u5df2\u53d1\u90ae\u4ef6":
  166. return "\u5df2\u53d1\u9001\u90ae\u4ef6"
  167. if value == "\u672a\u5efa\u8054":
  168. return "\u672a\u8054\u7cfb"
  169. return value
  170. def preferred_status(values: Sequence[str], default: str = DEFAULT_STATUS) -> str:
  171. statuses = [normalize_status(value) for value in values if clean(value)]
  172. if not statuses:
  173. return default
  174. return max(statuses, key=lambda value: STATUS_PRIORITY.get(value, 40))
  175. def preferred_name(values: Sequence[str]) -> str:
  176. cleaned = dedupe_keep_order(values)
  177. if not cleaned:
  178. return ""
  179. normalized_counts = Counter(normalize_name(value) for value in values if normalize_name(value))
  180. if not normalized_counts:
  181. return cleaned[0]
  182. best_key, _ = normalized_counts.most_common(1)[0]
  183. for value in cleaned:
  184. if normalize_name(value) == best_key:
  185. return value
  186. return cleaned[0]
  187. def dedupe_keep_order(values: Iterable[str]) -> List[str]:
  188. seen = set()
  189. output: List[str] = []
  190. for value in values:
  191. item = clean(value)
  192. key = item.casefold()
  193. if item and key not in seen:
  194. seen.add(key)
  195. output.append(item)
  196. return output
  197. def joined(values: Iterable[str]) -> str:
  198. return ";".join(dedupe_keep_order(values))
  199. def normalize_name(name: str) -> str:
  200. lowered = clean(name).casefold()
  201. lowered = re.sub(r"[^\w\s]+", " ", lowered, flags=re.U)
  202. words = [word for word in lowered.split() if word not in GENERIC_NAME_WORDS]
  203. return " ".join(words).strip()
  204. def normalize_phone(value: str) -> List[str]:
  205. phones: List[str] = []
  206. for match in PHONE_RE.findall(value or ""):
  207. digits = re.sub(r"\D+", "", match)
  208. if len(digits) >= 7 and digits not in phones:
  209. phones.append(digits)
  210. return phones
  211. def normalize_emails(value: str) -> List[str]:
  212. emails: List[str] = []
  213. for match in EMAIL_RE.findall(value or ""):
  214. email = match.casefold()
  215. if email not in emails:
  216. emails.append(email)
  217. return emails
  218. def normalize_url(url: str) -> str:
  219. text = clean(url)
  220. if not text:
  221. return ""
  222. if not re.match(r"^[a-z]+://", text, re.I):
  223. text = "https://" + text
  224. parsed = urlparse(text)
  225. host = parsed.netloc.casefold().removeprefix("www.")
  226. path = re.sub(r"/+$", "", parsed.path or "")
  227. query = ""
  228. if "google." in host and path.startswith("/maps"):
  229. pairs = [(k, v) for k, v in parse_qsl(parsed.query, keep_blank_values=False) if k in {"q", "query", "cid", "place_id"}]
  230. query = urlencode(pairs)
  231. if path in {"/maps/search", "/maps"} and not query:
  232. return ""
  233. return urlunparse(("https", host, path, "", query, ""))
  234. def header_map(ws) -> Dict[str, int]:
  235. raw_headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
  236. mapped: Dict[str, int] = {}
  237. for target, aliases in HEADER_ALIASES.items():
  238. for alias in aliases:
  239. if alias in raw_headers:
  240. mapped[target] = raw_headers[alias]
  241. break
  242. return mapped
  243. def row_value(ws, row: int, columns: Dict[str, int], key: str) -> str:
  244. col = columns.get(key)
  245. if not col:
  246. return ""
  247. return clean(ws.cell(row=row, column=col).value)
  248. def is_blank_reserved(record: Dict[str, str]) -> bool:
  249. evidence_keys = ["name", "link", "facebook_link", "linkedin_link", "google_maps_link", "website", "contact", "public_phone", "contact_phone", "public_email", "personal_email", "business", "note"]
  250. return not any(clean(record.get(key, "")) for key in evidence_keys)
  251. def source_values(sheet_name: str, record: Dict[str, str]) -> List[str]:
  252. if sheet_name == "汽车网站精选线索":
  253. return split_values(record.get("source_site", "")) or ["汽车网站精选线索"]
  254. return [SOURCE_MAP.get(sheet_name, sheet_name)]
  255. def all_record_links(values: Dict[str, str]) -> List[str]:
  256. links: List[str] = []
  257. for key in ["link", "facebook_link", "linkedin_link", "google_maps_link", "website"]:
  258. links.extend(split_values(values.get(key, "")))
  259. return dedupe_keep_order(links)
  260. def platform_links(record: Record) -> Dict[str, List[str]]:
  261. values = record.values
  262. result = {"website": [], "facebook": [], "linkedin": [], "google_maps": []}
  263. result["website"].extend(split_values(values.get("website", "")))
  264. result["facebook"].extend(split_values(values.get("facebook_link", "")))
  265. result["linkedin"].extend(split_values(values.get("linkedin_link", "")))
  266. result["google_maps"].extend(split_values(values.get("google_maps_link", "")))
  267. generic_links = split_values(values.get("link", ""))
  268. for link in generic_links:
  269. lower = link.casefold()
  270. if "facebook.com" in lower or record.sheet == "Facebook":
  271. result["facebook"].append(link)
  272. elif "linkedin.com" in lower or record.sheet == "LinkedIn":
  273. result["linkedin"].append(link)
  274. elif "google." in lower and "/maps" in lower or record.sheet == "Google Maps":
  275. result["google_maps"].append(link)
  276. else:
  277. result["website"].append(link)
  278. return {key: dedupe_keep_order(value) for key, value in result.items()}
  279. def combined_text(values: Dict[str, str], sheet_name: str, sources: Sequence[str]) -> str:
  280. return " ".join([
  281. sheet_name,
  282. " ".join(sources),
  283. values.get("name", ""),
  284. values.get("attribute", ""),
  285. values.get("type", ""),
  286. values.get("business", ""),
  287. values.get("note", ""),
  288. values.get("link", ""),
  289. values.get("website", ""),
  290. ]).casefold()
  291. def contains_any(text: str, keywords: Sequence[str]) -> bool:
  292. return any(keyword.casefold() in text for keyword in keywords)
  293. def classify_existing_type(value: str) -> Optional[Tuple[str, str]]:
  294. mapped = normalize_existing_classification("", value, value)
  295. return mapped if mapped != ("", "") else None
  296. def classify_attribute_and_type(values: Dict[str, str], sheet_name: str, sources: Sequence[str]) -> Tuple[str, str]:
  297. return classify_attribute_type(values, sheet_name=sheet_name, sources=sources)
  298. def read_records(wb) -> Tuple[List[Record], Dict[str, int], int]:
  299. records: List[Record] = []
  300. rows_by_sheet: Dict[str, int] = {}
  301. blank_rows = 0
  302. idx = 0
  303. for sheet_name in PLATFORM_SHEETS:
  304. if sheet_name not in wb.sheetnames:
  305. continue
  306. ws = wb[sheet_name]
  307. columns = header_map(ws)
  308. if "name" not in columns and "link" not in columns and "website" not in columns:
  309. continue
  310. for row in range(2, ws.max_row + 1):
  311. values = {key: row_value(ws, row, columns, key) for key in HEADER_ALIASES}
  312. if is_blank_reserved(values) or not any(values.get(key) for key in ["name", "link", "facebook_link", "linkedin_link", "google_maps_link", "website", "public_phone", "contact_phone", "public_email", "personal_email"]):
  313. blank_rows += 1
  314. continue
  315. idx += 1
  316. sources = source_values(sheet_name, values)
  317. values["attribute"], values["type"] = classify_attribute_and_type(values, sheet_name, sources)
  318. if not values.get("business"):
  319. values["business"] = "汽车渠道线索,需人工确认"
  320. links = all_record_links(values)
  321. records.append(Record(idx=idx, sheet=sheet_name, row=row, values=values, sources=sources, source_links=links))
  322. rows_by_sheet[sheet_name] = rows_by_sheet.get(sheet_name, 0) + 1
  323. return records, rows_by_sheet, blank_rows
  324. def choose_group_classification(group: Sequence[Record]) -> Tuple[str, str]:
  325. for item in group:
  326. attr = clean(item.values.get("attribute", ""))
  327. typ = clean(item.values.get("type", ""))
  328. if is_valid_pair(attr, typ):
  329. return attr, typ
  330. merged_values = {
  331. "attribute": joined(item.values.get("attribute", "") for item in group),
  332. "type": joined(item.values.get("type", "") for item in group),
  333. "name": joined(item.values.get("name", "") for item in group),
  334. "business": joined(item.values.get("business", "") for item in group),
  335. "note": joined(item.values.get("note", "") for item in group),
  336. "link": joined(item.values.get("link", "") for item in group),
  337. "website": joined(item.values.get("website", "") for item in group),
  338. }
  339. sheets = [item.sheet for item in group]
  340. sources = [source for item in group for source in item.sources]
  341. return classify_attribute_and_type(merged_values, joined(sheets), sources)
  342. def merge_records(records: Sequence[Record]) -> List[Dict[str, Any]]:
  343. dsu = DSU()
  344. buckets: Dict[str, int] = {}
  345. name_bucket: Dict[str, int] = {}
  346. for record in records:
  347. dsu.find(record.idx)
  348. strong_keys: List[str] = []
  349. for email in normalize_emails(joined([record.values.get("public_email", ""), record.values.get("personal_email", "")])):
  350. strong_keys.append("email:" + email)
  351. for phone in normalize_phone(joined([record.values.get("public_phone", ""), record.values.get("contact_phone", "")])):
  352. strong_keys.append("phone:" + phone)
  353. # Only platform/detail URLs are strong duplicate keys. Merchant homepages are
  354. # kept as evidence, but not used alone because group websites can represent
  355. # many brands, branches, or dealer pages.
  356. strong_link_values: List[str] = []
  357. for key_name in ["facebook_link", "linkedin_link", "google_maps_link", "link"]:
  358. for link in split_values(record.values.get(key_name, "")):
  359. lower = link.casefold()
  360. is_platform_detail = (
  361. "facebook.com" in lower
  362. or "linkedin.com" in lower
  363. or ("google." in lower and "/maps" in lower)
  364. )
  365. if is_platform_detail:
  366. strong_link_values.append(link)
  367. for link in strong_link_values:
  368. normalized = normalize_url(link)
  369. if normalized:
  370. strong_keys.append("url:" + normalized)
  371. for key in strong_keys:
  372. if key in buckets:
  373. dsu.union(record.idx, buckets[key])
  374. else:
  375. buckets[key] = record.idx
  376. for record in records:
  377. name_key = normalize_name(record.values.get("name", ""))
  378. if len(name_key) < 4:
  379. continue
  380. key = "name:" + name_key
  381. if key in name_bucket:
  382. dsu.union(record.idx, name_bucket[key])
  383. else:
  384. name_bucket[key] = record.idx
  385. grouped: Dict[int, List[Record]] = defaultdict(list)
  386. for record in records:
  387. grouped[dsu.find(record.idx)].append(record)
  388. output: List[Dict[str, Any]] = []
  389. for group in sorted(grouped.values(), key=lambda items: min(item.idx for item in items)):
  390. link_sets = {"website": [], "facebook": [], "linkedin": [], "google_maps": []}
  391. for item in group:
  392. item_links = platform_links(item)
  393. for key in link_sets:
  394. link_sets[key].extend(item_links[key])
  395. names = [item.values.get("name", "") for item in group]
  396. countries = [item.values.get("country", "") for item in group]
  397. cities = [item.values.get("city", "") for item in group]
  398. summary_attribute, summary_type = choose_group_classification(group)
  399. contacts = [item.values.get("contact", "") for item in group]
  400. positions = [item.values.get("position", "") for item in group]
  401. personal_emails = [value for item in group for value in split_values(item.values.get("personal_email", ""))]
  402. public_emails = [value for item in group for value in split_values(item.values.get("public_email", ""))]
  403. contact_phones = [value for item in group for value in split_values(item.values.get("contact_phone", ""))]
  404. public_phones = [value for item in group for value in split_values(item.values.get("public_phone", ""))]
  405. businesses = [item.values.get("business", "") for item in group]
  406. statuses = [item.values.get("status", "") for item in group]
  407. next_followups = [item.values.get("next_followup", "") for item in group]
  408. sources = [source for item in group for source in item.sources]
  409. sheets = [item.sheet for item in group]
  410. notes = [item.values.get("note", "") for item in group]
  411. source_evidence = [f"来自 {item.sheet} row {item.row}" for item in group]
  412. merged_count_note = f"合并来源数量:{len(group)}" if len(group) > 1 else "合并来源数量:1"
  413. source_note = f"客户来源:{joined(sources)};来源Sheet:{joined(sheets)}"
  414. business_note = f"主营业务汇总:{joined(businesses)}" if joined(businesses) else ""
  415. note = joined([merged_count_note, source_note, business_note, *source_evidence, *notes])
  416. row = {
  417. "公司姓名": preferred_name(names),
  418. "国家": first_non_empty(*countries, default=DEFAULT_COUNTRY),
  419. "城市": joined(cities),
  420. "客户类型": summary_type,
  421. "官网链接": joined(link_sets["website"]),
  422. "联系人": joined(contacts),
  423. "职位": joined(positions),
  424. "个人邮箱": joined(personal_emails),
  425. "联系人电话": joined(contact_phones),
  426. "Facebook主页链接": joined(link_sets["facebook"]),
  427. "linkined主页链接": joined(link_sets["linkedin"]),
  428. "google map链接": joined(link_sets["google_maps"]),
  429. "公共电话/WhatsApp": joined(public_phones),
  430. "公共邮箱": joined(public_emails),
  431. "客户属性": summary_attribute,
  432. "建联状态": first_non_empty(*statuses, default=DEFAULT_STATUS),
  433. "下次跟进": joined(next_followups),
  434. "备注": note,
  435. "_merge_count": len(group),
  436. "_sources": joined(sources),
  437. }
  438. output.append(row)
  439. return output
  440. def write_summary_sheet(wb, rows: Sequence[Dict[str, Any]], sheet_name: str) -> None:
  441. if sheet_name in wb.sheetnames:
  442. del wb[sheet_name]
  443. ws = wb.create_sheet(sheet_name, 0)
  444. ws.append(SUMMARY_HEADERS)
  445. for row in rows:
  446. ws.append([row.get(header, "") for header in SUMMARY_HEADERS])
  447. ws.freeze_panes = "A2"
  448. ws.auto_filter.ref = ws.dimensions
  449. header_fill = PatternFill(fill_type="solid", fgColor="D9EAF7")
  450. for cell in ws[1]:
  451. cell.font = Font(bold=True)
  452. cell.fill = header_fill
  453. widths = [28, 12, 18, 22, 36, 18, 18, 28, 24, 36, 36, 36, 24, 28, 20, 14, 18, 76]
  454. for idx, width in enumerate(widths, start=1):
  455. ws.column_dimensions[get_column_letter(idx)].width = width
  456. def lock_file_for(path: Path) -> Path:
  457. return path.with_name("~$" + path.name)
  458. def build_report(workbook_path: Path, backup_path: Optional[Path], records: Sequence[Record], rows: Sequence[Dict[str, Any]], rows_by_sheet: Dict[str, int], blank_rows: int, dry_run: bool, sheet_name: str) -> Dict[str, Any]:
  459. source_counts = Counter(source for record in records for source in record.sources)
  460. attribute_counts = Counter(row.get("客户属性", "") for row in rows if clean(row.get("客户属性", "")))
  461. type_counts = Counter(row.get("客户类型", "") for row in rows if clean(row.get("客户类型", "")))
  462. missing = {
  463. "客户属性": sum(1 for row in rows if not clean(row.get("客户属性", ""))),
  464. "客户类型": sum(1 for row in rows if not clean(row.get("客户类型", ""))),
  465. "主营业务": sum(1 for row in rows if "主营业务" in SUMMARY_HEADERS and not clean(row.get("主营业务", ""))),
  466. "备注": sum(1 for row in rows if not clean(row.get("备注", ""))),
  467. }
  468. invalid_category_rows = [
  469. {"name": row.get("公司姓名", ""), "attribute": row.get("客户属性", ""), "type": row.get("客户类型", ""), "sources": row.get("_sources", "")}
  470. for row in rows
  471. if not is_valid_pair(clean(row.get("客户属性", "")), clean(row.get("客户类型", "")))
  472. ]
  473. samples = []
  474. for row in rows:
  475. merge_count = int(row.get("_merge_count", 0) or 0)
  476. if merge_count > 1:
  477. samples.append({"name": row.get("公司姓名", ""), "attribute": row.get("客户属性", ""), "type": row.get("客户类型", ""), "sources": row.get("_sources", ""), "merged_source_rows": merge_count})
  478. if len(samples) >= 5:
  479. break
  480. return {
  481. "workbook": str(workbook_path),
  482. "summary_sheet": sheet_name,
  483. "summary_headers": SUMMARY_HEADERS,
  484. "dry_run": dry_run,
  485. "backup": str(backup_path) if backup_path else "",
  486. "input_valid_rows": len(records),
  487. "summary_rows": len(rows),
  488. "merged_duplicates": len(records) - len(rows),
  489. "rows_by_sheet": rows_by_sheet,
  490. "source_counts": dict(source_counts),
  491. "attribute_counts": dict(attribute_counts),
  492. "type_counts": dict(type_counts),
  493. "missing_counts": missing,
  494. "invalid_category_count": len(invalid_category_rows),
  495. "invalid_category_rows": invalid_category_rows[:50],
  496. "skipped_blank_or_reserved_rows": blank_rows,
  497. "merge_samples": samples,
  498. }
  499. def save_json_report(report: Dict[str, Any], output: str) -> None:
  500. if not output:
  501. return
  502. path = resolve_artifact_path(output, kind="summary_report", default_name="report.json")
  503. path.parent.mkdir(parents=True, exist_ok=True)
  504. path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
  505. def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
  506. parser = argparse.ArgumentParser(description="Build or preview the consolidated customer summary sheet.")
  507. parser.add_argument("--excel", default="", help="Workbook path. If omitted, resolve the project workbook by skill rules.")
  508. parser.add_argument("--summary-sheet", default=SUMMARY_SHEET, help="Summary sheet name.")
  509. parser.add_argument("--write-summary", action="store_true", help="Write or overwrite the summary sheet. Omit for preview only.")
  510. parser.add_argument("--dry-run", action="store_true", help="Preview only; never saves the workbook.")
  511. parser.add_argument("--no-backup", action="store_true", help="Skip backup when writing.")
  512. parser.add_argument("--output", default="", help="Optional JSON report path.")
  513. parser.add_argument("--run-id", default="", help="Run ID used for artifact and backup paths.")
  514. return parser.parse_args(argv)
  515. def main(argv: Optional[Sequence[str]] = None) -> int:
  516. args = parse_args(argv)
  517. resolved = resolve_workbook_path(args.excel, create_from_template=False)
  518. workbook_path = resolved.get("path")
  519. if not workbook_path:
  520. raise FileNotFoundError("No outreach workbook found. Pass --excel or create one from the skill blank template in write-enabled workflows.")
  521. workbook_path = Path(workbook_path)
  522. sheet_name = clean(args.summary_sheet) or SUMMARY_SHEET
  523. should_write = bool(args.write_summary and not args.dry_run)
  524. if should_write and lock_file_for(workbook_path).exists():
  525. raise PermissionError(f"Workbook appears to be open in Excel: {lock_file_for(workbook_path)}")
  526. wb = load_workbook(workbook_path)
  527. records, rows_by_sheet, blank_rows = read_records(wb)
  528. rows = merge_records(records)
  529. backup_path: Optional[Path] = None
  530. if should_write:
  531. if not args.no_backup:
  532. backup_path = create_backup_once(workbook_path, purpose="summary", run_id=args.run_id or None)
  533. write_summary_sheet(wb, rows, sheet_name)
  534. wb.save(workbook_path)
  535. report = build_report(workbook_path, backup_path, records, rows, rows_by_sheet, blank_rows, dry_run=not should_write, sheet_name=sheet_name)
  536. save_json_report(report, args.output)
  537. print(json.dumps(report, ensure_ascii=False, indent=2))
  538. return 0
  539. if __name__ == "__main__":
  540. raise SystemExit(main())