excel_io.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  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. COLUMN_ALIASES = {
  34. "客户姓名/公司": ["客户姓名/公司", "公司名称", "名称"],
  35. "公司名称": ["公司名称", "客户姓名/公司", "名称"],
  36. "主页/链接": ["主页/链接", "linkin链接", "LinkedIn链接", "链接", "网址"],
  37. "公司官网": ["公司官网", "官网", "官方网站", "Website", "Company Website"],
  38. "linkin链接": ["linkin链接", "LinkedIn链接", "主页/链接"],
  39. "电话/WhatsApp": ["电话/WhatsApp", "公司公共电话", "电话", "WhatsApp"],
  40. "公司公共电话": ["公司公共电话", "电话/WhatsApp", "电话"],
  41. "邮箱": ["邮箱", "公司公共邮箱(任一有效即可)", "公司公共邮箱", "公共邮箱"],
  42. "公司公共邮箱(任一有效即可)": ["公司公共邮箱(任一有效即可)", "公司公共邮箱", "公共邮箱", "邮箱"],
  43. "个人邮箱(不一定有效)": ["个人邮箱(不一定有效)", "个人邮箱"],
  44. "个人邮箱": ["个人邮箱", "个人邮箱(不一定有效)"],
  45. "主营业务": ["主营业务", "公司主营业务"],
  46. "公司主营业务": ["公司主营业务", "主营业务"],
  47. "建联状态": ["建联状态", "建联情况"],
  48. "建联情况": ["建联情况", "建联状态"],
  49. }
  50. def get_sheet_columns(sheet_name: str) -> List[str]:
  51. name = sheet_name.strip()
  52. if name == "Facebook":
  53. return FACEBOOK_COLUMNS
  54. if name == "LinkedIn":
  55. return LINKEDIN_COLUMNS
  56. if name == "Google Maps":
  57. return GOOGLE_MAPS_COLUMNS
  58. if name == "汽车网站精选线索":
  59. return AUTO_WEBSITE_COLUMNS
  60. return STANDARD_COLUMNS
  61. def _aliases(column: str) -> List[str]:
  62. values = COLUMN_ALIASES.get(column, [column])
  63. return list(dict.fromkeys([column, *values]))
  64. def _first_value(record: Dict[str, Any], column: str) -> Any:
  65. for key in _aliases(column):
  66. value = record.get(key, "")
  67. if value not in (None, ""):
  68. return value
  69. return ""
  70. def _headers(ws) -> List[str]:
  71. values = []
  72. for cell in ws[1]:
  73. value = "" if cell.value is None else str(cell.value).strip()
  74. if value:
  75. values.append(value)
  76. return values
  77. def _header_index(ws) -> Dict[str, int]:
  78. return {header: idx for idx, header in enumerate(_headers(ws), start=1)}
  79. def _find_header(headers: Dict[str, int], column: str) -> Optional[str]:
  80. for alias in _aliases(column):
  81. if alias in headers:
  82. return alias
  83. return None
  84. def _cell_value(row_values: Dict[str, Any], column: str) -> str:
  85. for alias in _aliases(column):
  86. value = row_values.get(alias, "")
  87. if value not in (None, ""):
  88. return str(value).strip()
  89. return ""
  90. def _copy_row_style(ws, source_row: int, target_row: int) -> None:
  91. if source_row < 1 or source_row == target_row:
  92. return
  93. for col in range(1, ws.max_column + 1):
  94. source = ws.cell(source_row, col)
  95. target = ws.cell(target_row, col)
  96. if source.has_style:
  97. target._style = copy(source._style)
  98. if source.number_format:
  99. target.number_format = source.number_format
  100. if source.alignment:
  101. target.alignment = copy(source.alignment)
  102. if source.protection:
  103. target.protection = copy(source.protection)
  104. def read_sheet(excel_path: str, sheet_name: str) -> pd.DataFrame:
  105. path = Path(excel_path)
  106. if not path.exists():
  107. raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
  108. df = pd.read_excel(excel_path, sheet_name=sheet_name)
  109. expected_cols = get_sheet_columns(sheet_name)
  110. for col in expected_cols:
  111. if col in df.columns:
  112. continue
  113. for alias in _aliases(col):
  114. if alias in df.columns:
  115. df[col] = df[alias]
  116. break
  117. else:
  118. df[col] = ""
  119. return df
  120. def normalize_record(record: Dict[str, Any], sheet_name: str) -> Dict[str, Any]:
  121. normalized: Dict[str, Any] = {}
  122. for col in get_sheet_columns(sheet_name):
  123. normalized[col] = _first_value(record, col)
  124. return normalized
  125. def append_records(
  126. excel_path: str,
  127. sheet_name: str,
  128. records: List[Dict[str, Any]],
  129. dedup_keys: Optional[List[str]] = None,
  130. ) -> Dict[str, Any]:
  131. if not records:
  132. return {"appended": 0, "skipped": 0, "total": 0}
  133. path = Path(excel_path)
  134. if not path.exists():
  135. raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
  136. wb = load_workbook(path)
  137. if sheet_name not in wb.sheetnames:
  138. ws = wb.create_sheet(sheet_name)
  139. for idx, header in enumerate(get_sheet_columns(sheet_name), start=1):
  140. ws.cell(1, idx, header)
  141. else:
  142. ws = wb[sheet_name]
  143. headers = _header_index(ws)
  144. if not headers:
  145. for idx, header in enumerate(get_sheet_columns(sheet_name), start=1):
  146. ws.cell(1, idx, header)
  147. headers = _header_index(ws)
  148. for expected_header in get_sheet_columns(sheet_name):
  149. if expected_header not in headers and not _find_header(headers, expected_header):
  150. next_col = ws.max_column + 1
  151. ws.cell(1, next_col, expected_header)
  152. headers[expected_header] = next_col
  153. keys = [key for key in (dedup_keys or []) if _find_header(headers, key)]
  154. existing_signatures = set()
  155. if keys:
  156. for row in ws.iter_rows(min_row=2, values_only=False):
  157. values = {header: row[col_idx - 1].value for header, col_idx in headers.items() if col_idx <= len(row)}
  158. sig = "|".join(_cell_value(values, key).casefold() for key in keys)
  159. if sig.strip("|"):
  160. existing_signatures.add(sig)
  161. appended = 0
  162. skipped = 0
  163. last_style_row = ws.max_row if ws.max_row > 1 else 1
  164. for record in records:
  165. normalized = normalize_record(record, sheet_name)
  166. if keys:
  167. sig = "|".join(str(_first_value(normalized, key)).strip().casefold() for key in keys)
  168. if sig.strip("|") and sig in existing_signatures:
  169. skipped += 1
  170. continue
  171. if sig.strip("|"):
  172. existing_signatures.add(sig)
  173. target_row = ws.max_row + 1
  174. _copy_row_style(ws, last_style_row, target_row)
  175. for header, col_idx in headers.items():
  176. if header.startswith("Unnamed"):
  177. continue
  178. value = _first_value(normalized, header)
  179. if header == "序号" and value == "":
  180. value = target_row - 1
  181. ws.cell(target_row, col_idx, value)
  182. appended += 1
  183. last_style_row = target_row
  184. wb.save(path)
  185. return {"appended": appended, "skipped": skipped, "total": max(ws.max_row - 1, 0)}
  186. def update_status(
  187. excel_path: str,
  188. sheet_name: str,
  189. filters: Dict[str, Any],
  190. updates: Dict[str, Any],
  191. ) -> int:
  192. path = Path(excel_path)
  193. if not path.exists():
  194. raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
  195. wb = load_workbook(path)
  196. if sheet_name not in wb.sheetnames:
  197. raise ValueError(f"Sheet 不存在: {sheet_name}")
  198. ws = wb[sheet_name]
  199. headers = _header_index(ws)
  200. updated = 0
  201. for row_idx in range(2, ws.max_row + 1):
  202. row_values = {header: ws.cell(row_idx, col_idx).value for header, col_idx in headers.items()}
  203. matched = True
  204. for key, expected in filters.items():
  205. actual = _cell_value(row_values, key)
  206. if actual != str(expected):
  207. matched = False
  208. break
  209. if not matched:
  210. continue
  211. for key, value in updates.items():
  212. header = _find_header(headers, key)
  213. if header:
  214. ws.cell(row_idx, headers[header], value)
  215. updated += 1
  216. wb.save(path)
  217. return updated