excel_io.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. '''
  2. Excel workbook helpers for the Wuling dealer outreach skill.
  3. New customer records are written directly to the single summary sheet. Legacy
  4. channel sheet names are accepted as source labels for compatibility.
  5. '''
  6. from __future__ import annotations
  7. from copy import copy
  8. from pathlib import Path
  9. from typing import Any, Dict, List, Optional
  10. import pandas as pd
  11. from openpyxl import load_workbook
  12. SUMMARY_SHEET = '\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868'
  13. CONVERSATION_SHEET = 'Facebook\u5bf9\u8bdd\u8bb0\u5f55'
  14. CHANNEL_SOURCE_SHEETS = {
  15. 'Facebook', 'LinkedIn', 'Google Maps', 'TikTok', '\u534f\u4f1a\u5546\u4f1a',
  16. '\u672c\u5730\u6c7d\u8f66\u7f51\u7ad9', '\u6c7d\u8f66\u7f51\u7ad9\u7cbe\u9009\u7ebf\u7d22',
  17. 'Sheet11', '\u653f\u5e9c\u91c7\u8d2d\u6295\u6807', '\u6d4b\u8bc4\u535a\u4e3b',
  18. }
  19. SUMMARY_COLUMNS = [
  20. '\u516c\u53f8\u59d3\u540d', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u7c7b\u578b', '\u5b98\u7f51\u94fe\u63a5',
  21. '\u8054\u7cfb\u4eba', '\u804c\u4f4d', '\u4e2a\u4eba\u90ae\u7bb1', '\u8054\u7cfb\u4eba\u7535\u8bdd',
  22. 'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5',
  23. '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u516c\u5171\u90ae\u7bb1', '\u5ba2\u6237\u5c5e\u6027',
  24. '\u7ebf\u7d22\u7b49\u7ea7', '\u9700\u4eba\u5de5\u786e\u8ba4', '\u5efa\u8054\u72b6\u6001',
  25. '\u4e0b\u6b21\u8ddf\u8fdb', '\u5907\u6ce8',
  26. ]
  27. STANDARD_COLUMNS = [
  28. '\u5e8f\u53f7', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u5c5e\u6027',
  29. '\u5ba2\u6237\u7c7b\u578b', '\u4e3b\u9875/\u94fe\u63a5', '\u8054\u7cfb\u4eba', '\u804c\u4f4d',
  30. '\u7535\u8bdd/WhatsApp', '\u90ae\u7bb1', '\u4e3b\u8425\u4e1a\u52a1', '\u5efa\u8054\u72b6\u6001',
  31. '\u4e0b\u6b21\u8ddf\u8fdb', '\u5907\u6ce8',
  32. ]
  33. FACEBOOK_COLUMNS = [*STANDARD_COLUMNS[:6], '\u516c\u53f8\u5b98\u7f51', *STANDARD_COLUMNS[6:]]
  34. LINKEDIN_COLUMNS = [
  35. '\u516c\u53f8\u540d\u79f0', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u5c5e\u6027', '\u5ba2\u6237\u7c7b\u578b',
  36. 'linkin\u94fe\u63a5', '\u8054\u7cfb\u4eba', '\u804c\u4f4d', '\u516c\u53f8\u516c\u5171\u7535\u8bdd',
  37. '\u516c\u53f8\u516c\u5171\u90ae\u7bb1\uff08\u4efb\u4e00\u6709\u6548\u5373\u53ef\uff09',
  38. '\u4e2a\u4eba\u90ae\u7bb1\uff08\u4e0d\u4e00\u5b9a\u6709\u6548\uff09', '\u516c\u53f8\u4e3b\u8425\u4e1a\u52a1',
  39. '\u5efa\u8054\u72b6\u6001', '\u5907\u6ce8',
  40. ]
  41. GOOGLE_MAPS_COLUMNS = [
  42. '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u5c5e\u6027', '\u5ba2\u6237\u7c7b\u578b',
  43. '\u4e3b\u9875/\u94fe\u63a5', '\u8054\u7cfb\u4eba', '\u804c\u4f4d', '\u7535\u8bdd/WhatsApp', '\u90ae\u7bb1',
  44. '\u4e3b\u8425\u4e1a\u52a1', '\u5efa\u8054\u72b6\u6001', '\u4e0b\u6b21\u8ddf\u8fdb', '\u5907\u6ce8',
  45. ]
  46. AUTO_WEBSITE_COLUMNS = [
  47. '\u5e8f\u53f7', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u5c5e\u6027',
  48. '\u5ba2\u6237\u7c7b\u578b', '\u4e3b\u9875/\u94fe\u63a5', '\u6765\u6e90\u7f51\u7ad9', '\u8054\u7cfb\u4eba',
  49. '\u804c\u4f4d', '\u7535\u8bdd/WhatsApp', '\u90ae\u7bb1', '\u4e3b\u8425\u4e1a\u52a1', '\u5efa\u8054\u72b6\u6001',
  50. '\u4e0b\u6b21\u8ddf\u8fdb', '\u5907\u6ce8',
  51. ]
  52. FACEBOOK_CONVERSATION_COLUMNS = [
  53. '\u8bb0\u5f55ID', '\u5ba2\u6237\u5e8f\u53f7', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', 'Facebook\u4e3b\u9875\u94fe\u63a5',
  54. 'Messenger\u7ebf\u7a0bID', '\u6d88\u606f\u65f6\u95f4', '\u6d88\u606f\u65b9\u5411', '\u53d1\u4ef6\u4eba',
  55. '\u539f\u6587\u8bed\u8a00', '\u5bf9\u8bdd\u539f\u6587', '\u4e2d\u6587\u7ffb\u8bd1', '\u6d88\u606f\u7c7b\u578b',
  56. '\u662f\u5426\u6709\u6548\u5ba2\u6237\u56de\u590d', '\u5408\u4f5c\u610f\u5411', '\u610f\u5411\u5224\u65ad\u4f9d\u636e',
  57. '\u4e0b\u4e00\u6b65\u5efa\u8bae', '\u540c\u6b65\u65f6\u95f4', '\u6765\u6e90\u8d26\u53f7/Profile ID', '\u98ce\u9669\u6807\u8bb0',
  58. ]
  59. COLUMN_ALIASES = {
  60. '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8': ['\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u516c\u53f8\u540d\u79f0', '\u516c\u53f8\u59d3\u540d', '\u540d\u79f0'],
  61. '\u516c\u53f8\u540d\u79f0': ['\u516c\u53f8\u540d\u79f0', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u516c\u53f8\u59d3\u540d', '\u540d\u79f0'],
  62. '\u4e3b\u9875/\u94fe\u63a5': ['\u4e3b\u9875/\u94fe\u63a5', 'linkin\u94fe\u63a5', 'LinkedIn\u94fe\u63a5', '\u94fe\u63a5', '\u7f51\u5740'],
  63. '\u516c\u53f8\u5b98\u7f51': ['\u516c\u53f8\u5b98\u7f51', '\u5b98\u7f51\u94fe\u63a5', '\u5b98\u7f51', 'Website', 'Company Website'],
  64. 'linkin\u94fe\u63a5': ['linkin\u94fe\u63a5', 'LinkedIn\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', '\u4e3b\u9875/\u94fe\u63a5'],
  65. '\u7535\u8bdd/WhatsApp': ['\u7535\u8bdd/WhatsApp', '\u516c\u53f8\u516c\u5171\u7535\u8bdd', '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u7535\u8bdd', 'WhatsApp'],
  66. '\u90ae\u7bb1': ['\u90ae\u7bb1', '\u516c\u5171\u90ae\u7bb1', '\u516c\u53f8\u516c\u5171\u90ae\u7bb1\uff08\u4efb\u4e00\u6709\u6548\u5373\u53ef\uff09', '\u516c\u53f8\u516c\u5171\u90ae\u7bb1'],
  67. '\u4e3b\u8425\u4e1a\u52a1': ['\u4e3b\u8425\u4e1a\u52a1', '\u516c\u53f8\u4e3b\u8425\u4e1a\u52a1'],
  68. '\u5efa\u8054\u72b6\u6001': ['\u5efa\u8054\u72b6\u6001', '\u5efa\u8054\u60c5\u51b5'],
  69. }
  70. def get_sheet_columns(sheet_name: str) -> List[str]:
  71. name = sheet_name.strip()
  72. if name == SUMMARY_SHEET:
  73. return SUMMARY_COLUMNS
  74. if name == 'Facebook':
  75. return FACEBOOK_COLUMNS
  76. if name == 'LinkedIn':
  77. return LINKEDIN_COLUMNS
  78. if name == 'Google Maps':
  79. return GOOGLE_MAPS_COLUMNS
  80. if name == '\u6c7d\u8f66\u7f51\u7ad9\u7cbe\u9009\u7ebf\u7d22':
  81. return AUTO_WEBSITE_COLUMNS
  82. if name == CONVERSATION_SHEET:
  83. return FACEBOOK_CONVERSATION_COLUMNS
  84. return STANDARD_COLUMNS
  85. def _aliases(column: str) -> List[str]:
  86. values = COLUMN_ALIASES.get(column, [column])
  87. return list(dict.fromkeys([column, *values]))
  88. def _first_value(record: Dict[str, Any], column: str) -> Any:
  89. for key in _aliases(column):
  90. value = record.get(key, '')
  91. if value not in (None, ''):
  92. return value
  93. return ''
  94. def _headers(ws) -> List[str]:
  95. values = []
  96. for cell in ws[1]:
  97. value = '' if cell.value is None else str(cell.value).strip()
  98. if value:
  99. values.append(value)
  100. return values
  101. def _header_index(ws) -> Dict[str, int]:
  102. return {header: idx for idx, header in enumerate(_headers(ws), start=1)}
  103. def _find_header(headers: Dict[str, int], column: str) -> Optional[str]:
  104. for alias in _aliases(column):
  105. if alias in headers:
  106. return alias
  107. return None
  108. def _cell_value(row_values: Dict[str, Any], column: str) -> str:
  109. for alias in _aliases(column):
  110. value = row_values.get(alias, '')
  111. if value not in (None, ''):
  112. return str(value).strip()
  113. return ''
  114. def _copy_row_style(ws, source_row: int, target_row: int) -> None:
  115. if source_row < 1 or source_row == target_row:
  116. return
  117. for col in range(1, ws.max_column + 1):
  118. source = ws.cell(source_row, col)
  119. target = ws.cell(target_row, col)
  120. if source.has_style:
  121. target._style = copy(source._style)
  122. if source.number_format:
  123. target.number_format = source.number_format
  124. if source.alignment:
  125. target.alignment = copy(source.alignment)
  126. if source.protection:
  127. target.protection = copy(source.protection)
  128. def read_sheet(excel_path: str, sheet_name: str) -> pd.DataFrame:
  129. path = Path(excel_path)
  130. if not path.exists():
  131. raise FileNotFoundError(f'Excel file not found: {excel_path}')
  132. df = pd.read_excel(excel_path, sheet_name=sheet_name)
  133. expected_cols = get_sheet_columns(sheet_name)
  134. for col in expected_cols:
  135. if col in df.columns:
  136. continue
  137. for alias in _aliases(col):
  138. if alias in df.columns:
  139. df[col] = df[alias]
  140. break
  141. else:
  142. df[col] = ''
  143. return df
  144. def normalize_record(record: Dict[str, Any], sheet_name: str) -> Dict[str, Any]:
  145. normalized: Dict[str, Any] = {}
  146. for col in get_sheet_columns(sheet_name):
  147. normalized[col] = _first_value(record, col)
  148. return normalized
  149. def append_records(excel_path: str, sheet_name: str, records: List[Dict[str, Any]], dedup_keys: Optional[List[str]] = None) -> Dict[str, Any]:
  150. if sheet_name in CHANNEL_SOURCE_SHEETS or sheet_name == SUMMARY_SHEET:
  151. try:
  152. from .direct_summary import append_summary_records
  153. except ImportError:
  154. from direct_summary import append_summary_records
  155. return append_summary_records(excel_path, records, source_sheet=sheet_name)
  156. if not records:
  157. return {'appended': 0, 'skipped': 0, 'total': 0}
  158. path = Path(excel_path)
  159. if not path.exists():
  160. raise FileNotFoundError(f'Excel file not found: {excel_path}')
  161. wb = load_workbook(path)
  162. if sheet_name not in wb.sheetnames:
  163. ws = wb.create_sheet(sheet_name)
  164. for idx, header in enumerate(get_sheet_columns(sheet_name), start=1):
  165. ws.cell(1, idx, header)
  166. else:
  167. ws = wb[sheet_name]
  168. headers = _header_index(ws)
  169. if not headers:
  170. for idx, header in enumerate(get_sheet_columns(sheet_name), start=1):
  171. ws.cell(1, idx, header)
  172. headers = _header_index(ws)
  173. for expected_header in get_sheet_columns(sheet_name):
  174. if expected_header not in headers and not _find_header(headers, expected_header):
  175. next_col = ws.max_column + 1
  176. ws.cell(1, next_col, expected_header)
  177. headers[expected_header] = next_col
  178. keys = [key for key in (dedup_keys or []) if _find_header(headers, key)]
  179. existing_signatures = set()
  180. if keys:
  181. for row in ws.iter_rows(min_row=2, values_only=False):
  182. values = {header: row[col_idx - 1].value for header, col_idx in headers.items() if col_idx <= len(row)}
  183. sig = '|'.join(_cell_value(values, key).casefold() for key in keys)
  184. if sig.strip('|'):
  185. existing_signatures.add(sig)
  186. appended = 0
  187. skipped = 0
  188. last_style_row = ws.max_row if ws.max_row > 1 else 1
  189. for record in records:
  190. normalized = normalize_record(record, sheet_name)
  191. if keys:
  192. sig = '|'.join(str(_first_value(normalized, key)).strip().casefold() for key in keys)
  193. if sig.strip('|') and sig in existing_signatures:
  194. skipped += 1
  195. continue
  196. if sig.strip('|'):
  197. existing_signatures.add(sig)
  198. target_row = ws.max_row + 1
  199. _copy_row_style(ws, last_style_row, target_row)
  200. for header, col_idx in headers.items():
  201. if header.startswith('Unnamed'):
  202. continue
  203. value = _first_value(normalized, header)
  204. if header == '\u5e8f\u53f7' and value == '':
  205. value = target_row - 1
  206. ws.cell(target_row, col_idx, value)
  207. appended += 1
  208. last_style_row = target_row
  209. wb.save(path)
  210. return {'appended': appended, 'skipped': skipped, 'total': max(ws.max_row - 1, 0)}
  211. def update_status(excel_path: str, sheet_name: str, filters: Dict[str, Any], updates: Dict[str, Any]) -> int:
  212. path = Path(excel_path)
  213. if not path.exists():
  214. raise FileNotFoundError(f'Excel file not found: {excel_path}')
  215. wb = load_workbook(path)
  216. if sheet_name not in wb.sheetnames:
  217. raise ValueError(f'Sheet not found: {sheet_name}')
  218. ws = wb[sheet_name]
  219. headers = _header_index(ws)
  220. updated = 0
  221. for row_idx in range(2, ws.max_row + 1):
  222. row_values = {header: ws.cell(row_idx, col_idx).value for header, col_idx in headers.items()}
  223. matched = True
  224. for key, expected in filters.items():
  225. actual = _cell_value(row_values, key)
  226. if actual != str(expected):
  227. matched = False
  228. break
  229. if not matched:
  230. continue
  231. for key, value in updates.items():
  232. header = _find_header(headers, key)
  233. if header:
  234. ws.cell(row_idx, headers[header], value)
  235. updated += 1
  236. wb.save(path)
  237. return updated