excel_io.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. """
  2. Excel workbook helpers for the Wuling dealer outreach skill.
  3. Writes use openpyxl so the workbook structure, styles, data validations,
  4. images, and non-target sheets are preserved.
  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. STANDARD_COLUMNS = [
  13. "序号", "客户姓名/公司", "国家", "城市", "客户属性", "客户类型",
  14. "主页/链接", "联系人", "职位", "电话/WhatsApp", "邮箱",
  15. "主营业务", "建联状态", "下次跟进", "备注",
  16. ]
  17. FACEBOOK_COLUMNS = [*STANDARD_COLUMNS[:6], "公司官网", *STANDARD_COLUMNS[6:]]
  18. LINKEDIN_COLUMNS = [
  19. "公司名称", "国家", "城市", "客户属性", "客户类型", "linkin链接",
  20. "联系人", "职位", "公司公共电话", "公司公共邮箱(任一有效即可)",
  21. "个人邮箱(不一定有效)", "公司主营业务", "建联状态", "备注",
  22. ]
  23. GOOGLE_MAPS_COLUMNS = [
  24. "客户姓名/公司", "国家", "城市", "客户属性", "客户类型", "主页/链接",
  25. "联系人", "职位", "电话/WhatsApp", "邮箱", "主营业务",
  26. "建联状态", "下次跟进", "备注",
  27. ]
  28. AUTO_WEBSITE_COLUMNS = [
  29. "序号", "客户姓名/公司", "国家", "城市", "客户属性", "客户类型", "主页/链接",
  30. "来源网站", "联系人", "职位", "电话/WhatsApp", "邮箱", "主营业务",
  31. "建联状态", "下次跟进", "备注",
  32. ]
  33. FACEBOOK_CONVERSATION_COLUMNS = [
  34. "记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID",
  35. "消息时间", "消息方向", "发件人", "原文语言", "对话原文", "中文翻译", "消息类型",
  36. "是否有效客户回复", "合作意向", "意向判断依据", "下一步建议", "同步时间",
  37. "来源账号/Profile ID", "风险标记",
  38. ]
  39. COLUMN_ALIASES = {
  40. "客户姓名/公司": ["客户姓名/公司", "公司名称", "名称"],
  41. "公司名称": ["公司名称", "客户姓名/公司", "名称"],
  42. "主页/链接": ["主页/链接", "linkin链接", "LinkedIn链接", "链接", "网址"],
  43. "公司官网": ["公司官网", "官网", "官方网站", "Website", "Company Website"],
  44. "linkin链接": ["linkin链接", "LinkedIn链接", "主页/链接"],
  45. "电话/WhatsApp": ["电话/WhatsApp", "公司公共电话", "电话", "WhatsApp"],
  46. "公司公共电话": ["公司公共电话", "电话/WhatsApp", "电话"],
  47. "邮箱": ["邮箱", "公司公共邮箱(任一有效即可)", "公司公共邮箱", "公共邮箱"],
  48. "公司公共邮箱(任一有效即可)": ["公司公共邮箱(任一有效即可)", "公司公共邮箱", "公共邮箱", "邮箱"],
  49. "个人邮箱(不一定有效)": ["个人邮箱(不一定有效)", "个人邮箱"],
  50. "个人邮箱": ["个人邮箱", "个人邮箱(不一定有效)"],
  51. "主营业务": ["主营业务", "公司主营业务"],
  52. "公司主营业务": ["公司主营业务", "主营业务"],
  53. "建联状态": ["建联状态", "建联情况"],
  54. "建联情况": ["建联情况", "建联状态"],
  55. }
  56. def get_sheet_columns(sheet_name: str) -> List[str]:
  57. name = sheet_name.strip()
  58. if name == "Facebook":
  59. return FACEBOOK_COLUMNS
  60. if name == "LinkedIn":
  61. return LINKEDIN_COLUMNS
  62. if name == "Google Maps":
  63. return GOOGLE_MAPS_COLUMNS
  64. if name == "汽车网站精选线索":
  65. return AUTO_WEBSITE_COLUMNS
  66. if name == "Facebook对话记录":
  67. return FACEBOOK_CONVERSATION_COLUMNS
  68. return STANDARD_COLUMNS
  69. def _aliases(column: str) -> List[str]:
  70. values = COLUMN_ALIASES.get(column, [column])
  71. return list(dict.fromkeys([column, *values]))
  72. def _first_value(record: Dict[str, Any], column: str) -> Any:
  73. for key in _aliases(column):
  74. value = record.get(key, "")
  75. if value not in (None, ""):
  76. return value
  77. return ""
  78. def _headers(ws) -> List[str]:
  79. values = []
  80. for cell in ws[1]:
  81. value = "" if cell.value is None else str(cell.value).strip()
  82. if value:
  83. values.append(value)
  84. return values
  85. def _header_index(ws) -> Dict[str, int]:
  86. return {header: idx for idx, header in enumerate(_headers(ws), start=1)}
  87. def _find_header(headers: Dict[str, int], column: str) -> Optional[str]:
  88. for alias in _aliases(column):
  89. if alias in headers:
  90. return alias
  91. return None
  92. def _cell_value(row_values: Dict[str, Any], column: str) -> str:
  93. for alias in _aliases(column):
  94. value = row_values.get(alias, "")
  95. if value not in (None, ""):
  96. return str(value).strip()
  97. return ""
  98. def _copy_row_style(ws, source_row: int, target_row: int) -> None:
  99. if source_row < 1 or source_row == target_row:
  100. return
  101. for col in range(1, ws.max_column + 1):
  102. source = ws.cell(source_row, col)
  103. target = ws.cell(target_row, col)
  104. if source.has_style:
  105. target._style = copy(source._style)
  106. if source.number_format:
  107. target.number_format = source.number_format
  108. if source.alignment:
  109. target.alignment = copy(source.alignment)
  110. if source.protection:
  111. target.protection = copy(source.protection)
  112. def read_sheet(excel_path: str, sheet_name: str) -> pd.DataFrame:
  113. path = Path(excel_path)
  114. if not path.exists():
  115. raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
  116. df = pd.read_excel(excel_path, sheet_name=sheet_name)
  117. expected_cols = get_sheet_columns(sheet_name)
  118. for col in expected_cols:
  119. if col in df.columns:
  120. continue
  121. for alias in _aliases(col):
  122. if alias in df.columns:
  123. df[col] = df[alias]
  124. break
  125. else:
  126. df[col] = ""
  127. return df
  128. def normalize_record(record: Dict[str, Any], sheet_name: str) -> Dict[str, Any]:
  129. normalized: Dict[str, Any] = {}
  130. for col in get_sheet_columns(sheet_name):
  131. normalized[col] = _first_value(record, col)
  132. return normalized
  133. def append_records(
  134. excel_path: str,
  135. sheet_name: str,
  136. records: List[Dict[str, Any]],
  137. dedup_keys: Optional[List[str]] = None,
  138. ) -> Dict[str, Any]:
  139. if not records:
  140. return {"appended": 0, "skipped": 0, "total": 0}
  141. path = Path(excel_path)
  142. if not path.exists():
  143. raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
  144. wb = load_workbook(path)
  145. if sheet_name not in wb.sheetnames:
  146. ws = wb.create_sheet(sheet_name)
  147. for idx, header in enumerate(get_sheet_columns(sheet_name), start=1):
  148. ws.cell(1, idx, header)
  149. else:
  150. ws = wb[sheet_name]
  151. headers = _header_index(ws)
  152. if not headers:
  153. for idx, header in enumerate(get_sheet_columns(sheet_name), start=1):
  154. ws.cell(1, idx, header)
  155. headers = _header_index(ws)
  156. for expected_header in get_sheet_columns(sheet_name):
  157. if expected_header not in headers and not _find_header(headers, expected_header):
  158. next_col = ws.max_column + 1
  159. ws.cell(1, next_col, expected_header)
  160. headers[expected_header] = next_col
  161. keys = [key for key in (dedup_keys or []) if _find_header(headers, key)]
  162. existing_signatures = set()
  163. if keys:
  164. for row in ws.iter_rows(min_row=2, values_only=False):
  165. values = {header: row[col_idx - 1].value for header, col_idx in headers.items() if col_idx <= len(row)}
  166. sig = "|".join(_cell_value(values, key).casefold() for key in keys)
  167. if sig.strip("|"):
  168. existing_signatures.add(sig)
  169. appended = 0
  170. skipped = 0
  171. last_style_row = ws.max_row if ws.max_row > 1 else 1
  172. for record in records:
  173. normalized = normalize_record(record, sheet_name)
  174. if keys:
  175. sig = "|".join(str(_first_value(normalized, key)).strip().casefold() for key in keys)
  176. if sig.strip("|") and sig in existing_signatures:
  177. skipped += 1
  178. continue
  179. if sig.strip("|"):
  180. existing_signatures.add(sig)
  181. target_row = ws.max_row + 1
  182. _copy_row_style(ws, last_style_row, target_row)
  183. for header, col_idx in headers.items():
  184. if header.startswith("Unnamed"):
  185. continue
  186. value = _first_value(normalized, header)
  187. if header == "序号" and value == "":
  188. value = target_row - 1
  189. ws.cell(target_row, col_idx, value)
  190. appended += 1
  191. last_style_row = target_row
  192. wb.save(path)
  193. return {"appended": appended, "skipped": skipped, "total": max(ws.max_row - 1, 0)}
  194. def update_status(
  195. excel_path: str,
  196. sheet_name: str,
  197. filters: Dict[str, Any],
  198. updates: Dict[str, Any],
  199. ) -> int:
  200. path = Path(excel_path)
  201. if not path.exists():
  202. raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
  203. wb = load_workbook(path)
  204. if sheet_name not in wb.sheetnames:
  205. raise ValueError(f"Sheet 不存在: {sheet_name}")
  206. ws = wb[sheet_name]
  207. headers = _header_index(ws)
  208. updated = 0
  209. for row_idx in range(2, ws.max_row + 1):
  210. row_values = {header: ws.cell(row_idx, col_idx).value for header, col_idx in headers.items()}
  211. matched = True
  212. for key, expected in filters.items():
  213. actual = _cell_value(row_values, key)
  214. if actual != str(expected):
  215. matched = False
  216. break
  217. if not matched:
  218. continue
  219. for key, value in updates.items():
  220. header = _find_header(headers, key)
  221. if header:
  222. ws.cell(row_idx, headers[header], value)
  223. updated += 1
  224. wb.save(path)
  225. return updated