direct_summary.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. '''Direct, deduplicated writes to the single customer summary sheet.'''
  2. from __future__ import annotations
  3. import re
  4. from copy import copy
  5. from pathlib import Path
  6. from typing import Any, Dict, Iterable, List, Tuple
  7. from urllib.parse import urlparse
  8. from openpyxl import load_workbook
  9. SUMMARY_SHEET = '\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868'
  10. SUMMARY_COLUMNS = [
  11. '\u516c\u53f8\u59d3\u540d', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u7c7b\u578b', '\u5b98\u7f51\u94fe\u63a5',
  12. '\u8054\u7cfb\u4eba', '\u804c\u4f4d', '\u4e2a\u4eba\u90ae\u7bb1', '\u8054\u7cfb\u4eba\u7535\u8bdd',
  13. 'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5',
  14. '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u516c\u5171\u90ae\u7bb1', '\u5ba2\u6237\u5c5e\u6027',
  15. '\u7ebf\u7d22\u7b49\u7ea7', '\u9700\u4eba\u5de5\u786e\u8ba4', '\u5efa\u8054\u72b6\u6001',
  16. '\u4e0b\u6b21\u8ddf\u8fdb', '\u5907\u6ce8',
  17. ]
  18. NO = '\u5426'
  19. UNCONTACTED = '\u672a\u8054\u7cfb'
  20. SOURCE_PREFIX = '\u5ba2\u6237\u6765\u6e90\uff1a'
  21. MANUAL_FLAGS = [
  22. '\u4e3b\u4f53\u5f52\u5c5e\u5f85\u786e\u8ba4',
  23. '\u65b0\u8f66\u4e1a\u52a1\u5f85\u786e\u8ba4',
  24. '\u5e73\u53f0\u4e0e\u884c\u4e1a\u6e20\u9053\u4e3b\u4f53\u5f85\u786e\u8ba4',
  25. '\u5e73\u53f0\u4e0e\u884c\u4e1a\u6e20\u9053\u4f5c\u7528\u5f85\u786e\u8ba4',
  26. '\u4ec5\u7535\u8bdd/WhatsApp\u5f85\u4eba\u5de5\u786e\u8ba4',
  27. '\u8be6\u7ec6\u4fe1\u606f\u5f85\u786e\u8ba4',
  28. '\u6392\u4ed6\u534f\u8bae\u53ca\u65b0\u589e\u54c1\u724c\u6743\u9650\u5f85\u786e\u8ba4',
  29. ]
  30. MULTI_FIELDS = {
  31. '\u5b98\u7f51\u94fe\u63a5', '\u4e2a\u4eba\u90ae\u7bb1', '\u8054\u7cfb\u4eba\u7535\u8bdd',
  32. 'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5',
  33. '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u516c\u5171\u90ae\u7bb1', '\u8054\u7cfb\u4eba', '\u804c\u4f4d', '\u5907\u6ce8',
  34. }
  35. GRADE_ORDER = {'A': 3, 'B': 2, 'C': 1}
  36. PLATFORM_HOSTS = ('facebook.com', 'linkedin.com', 'google.com', 'maps.app.goo.gl', 'instagram.com', 'wa.me', 'youtube.com', 'tiktok.com')
  37. def clean(value: Any) -> str:
  38. if value is None:
  39. return ''
  40. return re.sub(r'\s+', ' ', str(value).strip())
  41. def first(record: Dict[str, Any], *keys: str) -> str:
  42. for key in keys:
  43. value = clean(record.get(key))
  44. if value:
  45. return value
  46. return ''
  47. def split_values(value: Any) -> List[str]:
  48. if isinstance(value, list):
  49. raw = [clean(item) for item in value]
  50. else:
  51. raw = [clean(item) for item in re.split(r'[\uff1b;|\n]+', clean(value))]
  52. return list(dict.fromkeys(item for item in raw if item))
  53. def merge_values(left: Any, right: Any) -> str:
  54. return '\uff1b'.join(dict.fromkeys([*split_values(left), *split_values(right)]))
  55. def normalized_name(value: str) -> str:
  56. value = clean(value).casefold()
  57. return re.sub(r'[^0-9a-z\u00c0-\u024f\u0600-\u06ff\u4e00-\u9fff]+', '', value)
  58. def normalized_url(value: str) -> str:
  59. value = clean(value).casefold().rstrip('/')
  60. parsed = urlparse(value if '://' in value else 'https://' + value)
  61. host = parsed.netloc.removeprefix('www.')
  62. path = parsed.path.rstrip('/')
  63. if 'google.' in host or 'maps.app.goo.gl' in host:
  64. return (host + path + ('?' + parsed.query if parsed.query else '')).rstrip('/')
  65. return (host + path).rstrip('/')
  66. def is_company_website(value: str) -> bool:
  67. parsed = urlparse(clean(value) if '://' in clean(value) else 'https://' + clean(value))
  68. host = parsed.netloc.casefold().removeprefix('www.')
  69. return bool(host and not any(item in host for item in PLATFORM_HOSTS))
  70. def host_contains(value: str, token: str) -> bool:
  71. parsed = urlparse(clean(value) if '://' in clean(value) else 'https://' + clean(value))
  72. return token in parsed.netloc.casefold()
  73. def row_dict(ws, row_idx: int, headers: Dict[str, int]) -> Dict[str, Any]:
  74. return {header: ws.cell(row_idx, col_idx).value for header, col_idx in headers.items()}
  75. def copy_row_style(ws, source_row: int, target_row: int) -> None:
  76. if source_row < 1 or source_row == target_row:
  77. return
  78. for col in range(1, ws.max_column + 1):
  79. source = ws.cell(source_row, col)
  80. target = ws.cell(target_row, col)
  81. if source.has_style:
  82. target._style = copy(source._style)
  83. if source.number_format:
  84. target.number_format = source.number_format
  85. if source.alignment:
  86. target.alignment = copy(source.alignment)
  87. if source.protection:
  88. target.protection = copy(source.protection)
  89. def ensure_summary_sheet(wb):
  90. if SUMMARY_SHEET in wb.sheetnames:
  91. ws = wb[SUMMARY_SHEET]
  92. else:
  93. ws = wb.create_sheet(SUMMARY_SHEET, 0)
  94. headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
  95. if not headers:
  96. for idx, header in enumerate(SUMMARY_COLUMNS, start=1):
  97. ws.cell(1, idx, header)
  98. headers = {header: idx for idx, header in enumerate(SUMMARY_COLUMNS, start=1)}
  99. for header in SUMMARY_COLUMNS:
  100. if header not in headers:
  101. col_idx = ws.max_column + 1
  102. ws.cell(1, col_idx, header)
  103. headers[header] = col_idx
  104. return ws, headers
  105. def note_text(record: Dict[str, Any], source_sheet: str) -> str:
  106. note = first(record, '\u5907\u6ce8', 'note', 'notes', 'remarks')
  107. source = first(record, '\u6765\u6e90\u7f51\u7ad9', 'source', 'source_site') or source_sheet
  108. if source and SOURCE_PREFIX not in note:
  109. note = merge_values(note, SOURCE_PREFIX + source)
  110. return note
  111. def infer_lead_grade(record: Dict[str, Any]) -> str:
  112. explicit = first(record, '\u7ebf\u7d22\u7b49\u7ea7', '\u7b49\u7ea7', 'lead_grade').upper()
  113. if explicit in GRADE_ORDER:
  114. return explicit
  115. text = ' '.join(clean(v) for v in record.values()).casefold()
  116. if re.search(r'import|distribut|dealer network|reseau|r\u00e9seau|national|multibrand|multi-brand|multimarque|association|chamber|platform|marketplace|fleet|group', text):
  117. return 'A'
  118. if re.search(r'showroom|dealer|concession|auto|motors|vehicule|v\u00e9hicule|car sales|new car|used car|occasion', text):
  119. return 'B'
  120. return 'C'
  121. def manual_review_value(record: Dict[str, Any], grade: str) -> str:
  122. explicit = first(record, '\u9700\u4eba\u5de5\u786e\u8ba4', 'manual_review')
  123. if explicit and explicit.casefold() not in {'no', 'false', '0', NO}:
  124. return explicit
  125. text = ' '.join(clean(v) for v in record.values())
  126. flags = [flag for flag in MANUAL_FLAGS if flag in text]
  127. risk_flags = record.get('risk_flags') or record.get('risk_flag') or record.get('\u98ce\u9669\u6807\u8bb0')
  128. if isinstance(risk_flags, list):
  129. flags.extend(clean(item) for item in risk_flags if clean(item))
  130. elif clean(risk_flags):
  131. flags.extend(split_values(risk_flags))
  132. if flags:
  133. return '\uff1b'.join(dict.fromkeys(flags))
  134. if grade == 'C':
  135. return '\u8be6\u7ec6\u4fe1\u606f\u5f85\u786e\u8ba4'
  136. return NO
  137. def normalize_summary_record(record: Dict[str, Any], source_sheet: str) -> Dict[str, Any]:
  138. link = first(record, '\u4e3b\u9875/\u94fe\u63a5', 'page_url', 'profile_url', 'link', 'url', 'Link', 'URL')
  139. website = first(record, '\u5b98\u7f51\u94fe\u63a5', '\u516c\u53f8\u5b98\u7f51', 'website', 'company_website')
  140. if not website and link and is_company_website(link):
  141. website = link
  142. facebook = first(record, 'Facebook\u4e3b\u9875\u94fe\u63a5', 'Facebook\u94fe\u63a5', 'facebook_link', 'facebook_url')
  143. linkedin = first(record, 'linkined\u4e3b\u9875\u94fe\u63a5', 'LinkedIn\u4e3b\u9875\u94fe\u63a5', 'linkin\u94fe\u63a5', 'linkedin_link', 'linkedin_url')
  144. maps = first(record, 'google map\u94fe\u63a5', 'Google Maps\u94fe\u63a5', 'maps_link', 'maps_url', 'google_maps_url')
  145. if link and not is_company_website(link):
  146. if host_contains(link, 'facebook.com') and not facebook:
  147. facebook = link
  148. elif host_contains(link, 'linkedin.com') and not linkedin:
  149. linkedin = link
  150. elif host_contains(link, 'google.') or host_contains(link, 'maps.app.goo.gl'):
  151. maps = link
  152. public_email = first(record, '\u516c\u5171\u90ae\u7bb1', '\u90ae\u7bb1', '\u516c\u53f8\u516c\u5171\u90ae\u7bb1\uff08\u4efb\u4e00\u6709\u6548\u5373\u53ef\uff09', 'email')
  153. personal_email = first(record, '\u4e2a\u4eba\u90ae\u7bb1', '\u4e2a\u4eba\u90ae\u7bb1\uff08\u4e0d\u4e00\u5b9a\u6709\u6548\uff09', 'personal_email')
  154. public_phone = first(record, '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u7535\u8bdd/WhatsApp', '\u516c\u53f8\u516c\u5171\u7535\u8bdd', 'phone', 'whatsapp')
  155. contact_phone = first(record, '\u8054\u7cfb\u4eba\u7535\u8bdd', 'contact_phone')
  156. grade = infer_lead_grade(record)
  157. normalized = {
  158. '\u516c\u53f8\u59d3\u540d': first(record, '\u516c\u53f8\u59d3\u540d', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u516c\u53f8\u540d\u79f0', 'name', 'dealer_name'),
  159. '\u56fd\u5bb6': first(record, '\u56fd\u5bb6', 'country'),
  160. '\u57ce\u5e02': first(record, '\u57ce\u5e02', 'city'),
  161. '\u5ba2\u6237\u7c7b\u578b': first(record, '\u5ba2\u6237\u7c7b\u578b', 'customer_type'),
  162. '\u5b98\u7f51\u94fe\u63a5': website,
  163. '\u8054\u7cfb\u4eba': first(record, '\u8054\u7cfb\u4eba', 'contact'),
  164. '\u804c\u4f4d': first(record, '\u804c\u4f4d', 'title', 'position'),
  165. '\u4e2a\u4eba\u90ae\u7bb1': personal_email,
  166. '\u8054\u7cfb\u4eba\u7535\u8bdd': contact_phone,
  167. 'Facebook\u4e3b\u9875\u94fe\u63a5': facebook,
  168. 'linkined\u4e3b\u9875\u94fe\u63a5': linkedin,
  169. 'google map\u94fe\u63a5': maps,
  170. '\u516c\u5171\u7535\u8bdd/WhatsApp': public_phone,
  171. '\u516c\u5171\u90ae\u7bb1': public_email,
  172. '\u5ba2\u6237\u5c5e\u6027': first(record, '\u5ba2\u6237\u5c5e\u6027', 'customer_attribute'),
  173. '\u7ebf\u7d22\u7b49\u7ea7': grade,
  174. '\u9700\u4eba\u5de5\u786e\u8ba4': manual_review_value(record, grade),
  175. '\u5efa\u8054\u72b6\u6001': first(record, '\u5efa\u8054\u72b6\u6001', '\u5efa\u8054\u60c5\u51b5', 'status') or UNCONTACTED,
  176. '\u4e0b\u6b21\u8ddf\u8fdb': first(record, '\u4e0b\u6b21\u8ddf\u8fdb', 'next_follow_up'),
  177. '\u5907\u6ce8': note_text(record, source_sheet),
  178. }
  179. return normalized
  180. def identity_keys(record: Dict[str, Any]) -> List[Tuple[str, str]]:
  181. keys: List[Tuple[str, str]] = []
  182. for field in ('\u516c\u5171\u90ae\u7bb1', '\u4e2a\u4eba\u90ae\u7bb1'):
  183. for value in split_values(record.get(field)):
  184. keys.append(('email', value.casefold()))
  185. for field in ('\u516c\u5171\u7535\u8bdd/WhatsApp', '\u8054\u7cfb\u4eba\u7535\u8bdd'):
  186. for value in split_values(record.get(field)):
  187. digits = re.sub(r'\D+', '', value)
  188. if len(digits) >= 7:
  189. keys.append(('phone', digits))
  190. for field in ('\u5b98\u7f51\u94fe\u63a5', 'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5'):
  191. for value in split_values(record.get(field)):
  192. url_key = normalized_url(value)
  193. if url_key:
  194. keys.append(('url', url_key))
  195. name_key = normalized_name(clean(record.get('\u516c\u53f8\u59d3\u540d')))
  196. if name_key:
  197. keys.append(('name', name_key))
  198. return list(dict.fromkeys(keys))
  199. def merge_record(existing: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[str, Any]:
  200. merged = dict(existing)
  201. for field in SUMMARY_COLUMNS:
  202. left = clean(merged.get(field))
  203. right = clean(incoming.get(field))
  204. if not left and right:
  205. merged[field] = right
  206. elif field in MULTI_FIELDS and right:
  207. merged[field] = merge_values(left, right)
  208. if GRADE_ORDER.get(clean(incoming.get('\u7ebf\u7d22\u7b49\u7ea7')), 0) > GRADE_ORDER.get(clean(merged.get('\u7ebf\u7d22\u7b49\u7ea7')), 0):
  209. merged['\u7ebf\u7d22\u7b49\u7ea7'] = incoming.get('\u7ebf\u7d22\u7b49\u7ea7')
  210. left_manual = clean(merged.get('\u9700\u4eba\u5de5\u786e\u8ba4'))
  211. right_manual = clean(incoming.get('\u9700\u4eba\u5de5\u786e\u8ba4'))
  212. if right_manual and right_manual != NO:
  213. merged['\u9700\u4eba\u5de5\u786e\u8ba4'] = merge_values('' if left_manual == NO else left_manual, right_manual)
  214. elif not left_manual:
  215. merged['\u9700\u4eba\u5de5\u786e\u8ba4'] = NO
  216. return merged
  217. def index_existing(ws, headers: Dict[str, int]) -> Dict[Tuple[str, str], int]:
  218. index: Dict[Tuple[str, str], int] = {}
  219. for row_idx in range(2, ws.max_row + 1):
  220. record = row_dict(ws, row_idx, headers)
  221. if not any(clean(record.get(col)) for col in SUMMARY_COLUMNS):
  222. continue
  223. for key in identity_keys(record):
  224. index.setdefault(key, row_idx)
  225. return index
  226. def write_row(ws, headers: Dict[str, int], row_idx: int, record: Dict[str, Any]) -> None:
  227. for header in SUMMARY_COLUMNS:
  228. ws.cell(row_idx, headers[header], record.get(header, ''))
  229. def append_summary_records(excel_path: str, records: Iterable[Dict[str, Any]], source_sheet: str = '') -> Dict[str, Any]:
  230. records = list(records)
  231. if not records:
  232. return {'appended': 0, 'merged': 0, 'skipped': 0, 'total': 0, 'sheet': SUMMARY_SHEET}
  233. path = Path(excel_path)
  234. if not path.exists():
  235. raise FileNotFoundError(f'Excel file not found: {excel_path}')
  236. wb = load_workbook(path)
  237. ws, headers = ensure_summary_sheet(wb)
  238. existing_index = index_existing(ws, headers)
  239. appended = 0
  240. merged = 0
  241. skipped = 0
  242. last_style_row = ws.max_row if ws.max_row > 1 else 1
  243. for raw in records:
  244. normalized = normalize_summary_record(raw, source_sheet)
  245. if not any(clean(normalized.get(field)) for field in ('\u516c\u53f8\u59d3\u540d', '\u5b98\u7f51\u94fe\u63a5', 'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5', '\u516c\u5171\u90ae\u7bb1', '\u516c\u5171\u7535\u8bdd/WhatsApp')):
  246. skipped += 1
  247. continue
  248. match_row = None
  249. for key in identity_keys(normalized):
  250. if key in existing_index:
  251. match_row = existing_index[key]
  252. break
  253. if match_row:
  254. existing = row_dict(ws, match_row, headers)
  255. write_row(ws, headers, match_row, merge_record(existing, normalized))
  256. for key in identity_keys(row_dict(ws, match_row, headers)):
  257. existing_index.setdefault(key, match_row)
  258. merged += 1
  259. continue
  260. row_idx = ws.max_row + 1
  261. copy_row_style(ws, last_style_row, row_idx)
  262. write_row(ws, headers, row_idx, normalized)
  263. for key in identity_keys(normalized):
  264. existing_index.setdefault(key, row_idx)
  265. appended += 1
  266. last_style_row = row_idx
  267. wb.save(path)
  268. return {'appended': appended, 'merged': merged, 'skipped': skipped, 'total': max(ws.max_row - 1, 0), 'sheet': SUMMARY_SHEET, 'source': source_sheet}