build_customer_summary.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. '''Build or repair the single customer summary sheet.'''
  2. from __future__ import annotations
  3. import argparse
  4. import json
  5. import re
  6. from collections import Counter
  7. from pathlib import Path
  8. from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
  9. from openpyxl import load_workbook
  10. from openpyxl.styles import Font, PatternFill
  11. from openpyxl.utils import get_column_letter
  12. try:
  13. from .artifact_manager import create_backup_once, resolve_artifact_path
  14. from .workbook_resolver import resolve_workbook_path
  15. from .direct_summary import SUMMARY_COLUMNS, SUMMARY_SHEET, identity_keys, merge_record, normalize_summary_record
  16. except ImportError: # pragma: no cover
  17. from artifact_manager import create_backup_once, resolve_artifact_path
  18. from workbook_resolver import resolve_workbook_path
  19. from direct_summary import SUMMARY_COLUMNS, SUMMARY_SHEET, identity_keys, merge_record, normalize_summary_record
  20. CONVERSATION_SHEET = 'Facebook\u5bf9\u8bdd\u8bb0\u5f55'
  21. SKIP_SHEETS = {'\u586b\u5199\u8bf4\u660e', '\u9644\u4ef6', CONVERSATION_SHEET}
  22. LEGACY_SOURCE_SHEETS = {
  23. 'Facebook', 'LinkedIn', 'Google Maps', 'TikTok', '\u534f\u4f1a\u5546\u4f1a',
  24. '\u672c\u5730\u6c7d\u8f66\u7f51\u7ad9', '\u6c7d\u8f66\u7f51\u7ad9\u7cbe\u9009\u7ebf\u7d22',
  25. 'Sheet11', '\u653f\u5e9c\u91c7\u8d2d\u6295\u6807', '\u6d4b\u8bc4\u535a\u4e3b',
  26. }
  27. def clean(value: Any) -> str:
  28. if value is None:
  29. return ''
  30. return re.sub(r'\s+', ' ', str(value).strip())
  31. def headers_for(ws) -> Dict[str, int]:
  32. return {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
  33. def row_to_record(ws, row_idx: int, headers: Dict[str, int]) -> Dict[str, Any]:
  34. return {header: ws.cell(row_idx, col_idx).value for header, col_idx in headers.items()}
  35. def has_customer_evidence(record: Dict[str, Any]) -> bool:
  36. evidence = [
  37. '\u516c\u53f8\u59d3\u540d', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u516c\u53f8\u540d\u79f0',
  38. '\u4e3b\u9875/\u94fe\u63a5', '\u5b98\u7f51\u94fe\u63a5', '\u516c\u53f8\u5b98\u7f51',
  39. 'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5',
  40. '\u516c\u5171\u90ae\u7bb1', '\u4e2a\u4eba\u90ae\u7bb1', '\u90ae\u7bb1',
  41. '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u7535\u8bdd/WhatsApp', '\u8054\u7cfb\u4eba\u7535\u8bdd',
  42. ]
  43. return any(clean(record.get(key)) for key in evidence)
  44. def source_sheets(wb) -> List[str]:
  45. ordered: List[str] = []
  46. if SUMMARY_SHEET in wb.sheetnames:
  47. ordered.append(SUMMARY_SHEET)
  48. for sheet in wb.sheetnames:
  49. if sheet == SUMMARY_SHEET or sheet in SKIP_SHEETS:
  50. continue
  51. if sheet in LEGACY_SOURCE_SHEETS:
  52. ordered.append(sheet)
  53. return ordered
  54. def read_all_customer_records(wb) -> Tuple[List[Dict[str, Any]], Counter, int]:
  55. records: List[Dict[str, Any]] = []
  56. source_counts: Counter = Counter()
  57. skipped_blank = 0
  58. for sheet in source_sheets(wb):
  59. ws = wb[sheet]
  60. headers = headers_for(ws)
  61. if not headers:
  62. continue
  63. for row_idx in range(2, ws.max_row + 1):
  64. raw = row_to_record(ws, row_idx, headers)
  65. if not has_customer_evidence(raw):
  66. skipped_blank += 1
  67. continue
  68. normalized = normalize_summary_record(raw, sheet)
  69. if not has_customer_evidence(normalized):
  70. skipped_blank += 1
  71. continue
  72. records.append(normalized)
  73. source_counts[sheet] += 1
  74. return records, source_counts, skipped_blank
  75. def merge_records(records: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
  76. output: List[Dict[str, Any]] = []
  77. key_to_index: Dict[Tuple[str, str], int] = {}
  78. for record in records:
  79. match_idx: Optional[int] = None
  80. for key in identity_keys(record):
  81. if key in key_to_index:
  82. match_idx = key_to_index[key]
  83. break
  84. if match_idx is None:
  85. output.append(record)
  86. match_idx = len(output) - 1
  87. else:
  88. output[match_idx] = merge_record(output[match_idx], record)
  89. for key in identity_keys(output[match_idx]):
  90. key_to_index.setdefault(key, match_idx)
  91. return output
  92. def write_summary_sheet(wb, rows: Sequence[Dict[str, Any]], sheet_name: str) -> None:
  93. if sheet_name in wb.sheetnames:
  94. del wb[sheet_name]
  95. ws = wb.create_sheet(sheet_name, 0)
  96. ws.append(SUMMARY_COLUMNS)
  97. for row in rows:
  98. ws.append([row.get(header, '') for header in SUMMARY_COLUMNS])
  99. ws.freeze_panes = 'A2'
  100. ws.auto_filter.ref = ws.dimensions
  101. header_fill = PatternFill(fill_type='solid', fgColor='D9EAF7')
  102. for cell in ws[1]:
  103. cell.font = Font(bold=True)
  104. cell.fill = header_fill
  105. widths = [28, 12, 16, 22, 34, 18, 18, 28, 24, 34, 34, 34, 24, 28, 20, 12, 20, 18, 18, 80]
  106. for idx, width in enumerate(widths, start=1):
  107. ws.column_dimensions[get_column_letter(idx)].width = width
  108. def lock_file_for(path: Path) -> Path:
  109. return path.with_name('~$' + path.name)
  110. def build_report(workbook_path: Path, backup_path: Optional[Path], rows: Sequence[Dict[str, Any]], source_counts: Counter, skipped_blank: int, dry_run: bool, sheet_name: str) -> Dict[str, Any]:
  111. grade_col = '\u7ebf\u7d22\u7b49\u7ea7'
  112. manual_col = '\u9700\u4eba\u5de5\u786e\u8ba4'
  113. attr_col = '\u5ba2\u6237\u5c5e\u6027'
  114. type_col = '\u5ba2\u6237\u7c7b\u578b'
  115. return {
  116. 'workbook': str(workbook_path),
  117. 'summary_sheet': sheet_name,
  118. 'summary_headers': SUMMARY_COLUMNS,
  119. 'dry_run': dry_run,
  120. 'backup': str(backup_path) if backup_path else '',
  121. 'summary_rows': len(rows),
  122. 'source_rows': dict(source_counts),
  123. 'skipped_blank_or_reserved_rows': skipped_blank,
  124. 'attribute_counts': dict(Counter(clean(row.get(attr_col)) for row in rows if clean(row.get(attr_col)))),
  125. 'type_counts': dict(Counter(clean(row.get(type_col)) for row in rows if clean(row.get(type_col)))),
  126. 'lead_grade_counts': dict(Counter(clean(row.get(grade_col)) for row in rows if clean(row.get(grade_col)))),
  127. 'manual_review_counts': dict(Counter(clean(row.get(manual_col)) for row in rows if clean(row.get(manual_col)))),
  128. 'missing_counts': {
  129. '\u5ba2\u6237\u5c5e\u6027': sum(1 for row in rows if not clean(row.get(attr_col))),
  130. '\u5ba2\u6237\u7c7b\u578b': sum(1 for row in rows if not clean(row.get(type_col))),
  131. '\u7ebf\u7d22\u7b49\u7ea7': sum(1 for row in rows if not clean(row.get(grade_col))),
  132. '\u9700\u4eba\u5de5\u786e\u8ba4': sum(1 for row in rows if not clean(row.get(manual_col))),
  133. '\u5907\u6ce8': sum(1 for row in rows if not clean(row.get('\u5907\u6ce8'))),
  134. },
  135. }
  136. def save_json_report(report: Dict[str, Any], output: str) -> None:
  137. if not output:
  138. return
  139. path = resolve_artifact_path(output, kind='summary_report', default_name='report.json')
  140. path.parent.mkdir(parents=True, exist_ok=True)
  141. path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
  142. def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
  143. parser = argparse.ArgumentParser(description='Build or preview the single customer summary sheet.')
  144. parser.add_argument('--excel', default='', help='Workbook path. If omitted, resolve the project workbook by skill rules.')
  145. parser.add_argument('--summary-sheet', default=SUMMARY_SHEET, help='Summary sheet name.')
  146. parser.add_argument('--write-summary', action='store_true', help='Write or overwrite the summary sheet. Omit for preview only.')
  147. parser.add_argument('--dry-run', action='store_true', help='Preview only; never saves the workbook.')
  148. parser.add_argument('--no-backup', action='store_true', help='Skip backup when writing.')
  149. parser.add_argument('--output', default='', help='Optional JSON report path.')
  150. parser.add_argument('--run-id', default='', help='Run ID used for artifact and backup paths.')
  151. return parser.parse_args(argv)
  152. def main(argv: Optional[Sequence[str]] = None) -> int:
  153. args = parse_args(argv)
  154. resolved = resolve_workbook_path(args.excel, create_from_template=False)
  155. workbook_path = resolved.get('path')
  156. if not workbook_path:
  157. raise FileNotFoundError('No outreach workbook found. Pass --excel or create one from the skill blank template in write-enabled workflows.')
  158. workbook_path = Path(workbook_path)
  159. sheet_name = clean(args.summary_sheet) or SUMMARY_SHEET
  160. should_write = bool(args.write_summary and not args.dry_run)
  161. if should_write and lock_file_for(workbook_path).exists():
  162. raise PermissionError(f'Workbook appears to be open in Excel: {lock_file_for(workbook_path)}')
  163. wb = load_workbook(workbook_path)
  164. records, source_counts, skipped_blank = read_all_customer_records(wb)
  165. rows = merge_records(records)
  166. backup_path: Optional[Path] = None
  167. if should_write:
  168. if not args.no_backup:
  169. backup_path = create_backup_once(workbook_path, purpose='summary', run_id=args.run_id or None)
  170. write_summary_sheet(wb, rows, sheet_name)
  171. wb.save(workbook_path)
  172. report = build_report(workbook_path, backup_path, rows, source_counts, skipped_blank, dry_run=not should_write, sheet_name=sheet_name)
  173. save_json_report(report, args.output)
  174. print(json.dumps(report, ensure_ascii=False, indent=2))
  175. return 0
  176. if __name__ == '__main__':
  177. raise SystemExit(main())