| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """Validate translated Facebook conversations and write them to the workbook."""
- from __future__ import annotations
- import argparse
- import json
- import re
- import subprocess
- import sys
- from datetime import date, datetime, timedelta
- from pathlib import Path
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
- from openpyxl import load_workbook
- from openpyxl.styles import Font, PatternFill
- from openpyxl.utils import get_column_letter
- SCRIPT_DIR = Path(__file__).resolve().parent
- SKILL_ROOT = SCRIPT_DIR.parents[1]
- COMMON_DIR = SKILL_ROOT / "scripts" / "common"
- if str(COMMON_DIR) not in sys.path:
- sys.path.insert(0, str(COMMON_DIR))
- from artifact_manager import create_backup_once, new_run_id, project_root, resolve_artifact_path # type: ignore # noqa: E402
- from workbook_resolver import resolve_workbook_path # type: ignore # noqa: E402
- FACEBOOK_SHEET = "Facebook"
- CONVERSATION_SHEET = "Facebook对话记录"
- CONVERSATION_HEADERS = [
- "记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID",
- "消息时间", "消息方向", "发件人", "原文语言", "对话原文", "中文翻译", "消息类型",
- "是否有效客户回复", "合作意向", "意向判断依据", "下一步建议", "同步时间",
- "来源账号/Profile ID", "风险标记",
- ]
- INTENTS = ["明确有意向", "潜在意向", "需澄清", "暂不考虑", "明确拒绝"]
- STATUS_BY_INTENT = {
- "明确有意向": "已回复,有合作意向",
- "潜在意向": "已回复,待跟进",
- "需澄清": "已回复,待澄清",
- "暂不考虑": "已回复,暂不考虑",
- "明确拒绝": "已回复,明确拒绝",
- }
- FOLLOWUP_DAYS = {
- "明确有意向": ("business", 1),
- "潜在意向": ("business", 3),
- "需澄清": ("business", 2),
- "暂不考虑": ("calendar", 30),
- "明确拒绝": ("none", 0),
- }
- REPLY_STATUS_PHRASES = list(STATUS_BY_INTENT.values())
- NOTE_START = "【Facebook回复分析】"
- NOTE_END = "【/Facebook回复分析】"
- QUESTION_MARK_RE = re.compile(r"\?{3,}")
- CJK_RE = re.compile(r"[\u3400-\u9fff]")
- FACEBOOK_ALIASES = {
- "index": ["序号", "编号", "ID"],
- "company": ["客户姓名/公司", "公司名称", "公司姓名", "客户名称"],
- "link": ["主页/链接", "Facebook主页链接", "Facebook链接", "facebook链接"],
- "status": ["建联状态", "建联情况"],
- "followup": ["下次跟进", "下次跟进时间"],
- "note": ["备注", "说明"],
- }
- def clean(value: Any) -> str:
- if value is None:
- return ""
- return re.sub(r"\s+", " ", str(value).strip())
- def now_text() -> str:
- return datetime.now().astimezone().isoformat(timespec="seconds")
- def read_json(path: Path) -> Dict[str, Any]:
- return json.loads(path.read_text(encoding="utf-8-sig"))
- def write_json(path: Path, data: Dict[str, Any]) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
- def normalized_link(value: str) -> str:
- value = clean(value).casefold().split("?", 1)[0].rstrip("/")
- return value.removeprefix("https://").removeprefix("http://").removeprefix("www.")
- def list_values(value: Any) -> List[str]:
- if isinstance(value, list):
- return [clean(item) for item in value if clean(item)]
- return [part for part in re.split(r"[;;|]+", clean(value)) if part] if clean(value) else []
- def analysis_key(thread: Dict[str, Any]) -> Tuple[str, str, str]:
- return (
- clean(thread.get("customer_index")),
- clean(thread.get("thread_id")),
- normalized_link(clean(thread.get("facebook_link"))),
- )
- def raw_thread_key(thread: Dict[str, Any]) -> Tuple[str, str, str]:
- customer = thread.get("customer") or {}
- return (
- clean(customer.get("index")),
- clean(thread.get("thread_id") or customer.get("expected_identity")),
- normalized_link(clean(customer.get("facebook_link"))),
- )
- def fallback_analysis_match(raw_thread: Dict[str, Any], candidates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
- raw_key = raw_thread_key(raw_thread)
- for candidate in candidates:
- candidate_key = analysis_key(candidate)
- checks = [left and right and left == right for left, right in zip(raw_key, candidate_key)]
- if sum(checks) >= 2:
- return candidate
- return None
- def chinese_or_same(original: str, translated: str) -> str:
- original, translated = clean(original), clean(translated)
- if translated:
- return translated
- if not original:
- return ""
- if CJK_RE.search(original):
- return original
- raise ValueError(f"Missing Chinese translation for message: {original[:80]}")
- def validate_intent(value: str) -> str:
- value = clean(value)
- if value not in INTENTS:
- raise ValueError(f"Invalid cooperation intent: {value!r}. Expected one of {INTENTS}")
- return value
- def parse_date(value: str) -> Optional[date]:
- match = re.search(r"(20\d{2})[-/](\d{1,2})[-/](\d{1,2})", clean(value))
- if not match:
- return None
- try:
- return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
- except ValueError:
- return None
- def add_business_days(start: date, days: int) -> date:
- result = start
- while days > 0:
- result += timedelta(days=1)
- if result.weekday() < 5:
- days -= 1
- return result
- def default_followup(intent: str, latest_reply_at: str, explicit: str = "") -> str:
- if clean(explicit):
- parsed = parse_date(explicit)
- if not parsed:
- raise ValueError(f"Invalid next_followup date: {explicit!r}")
- return parsed.isoformat()
- kind, amount = FOLLOWUP_DAYS[intent]
- if kind == "none":
- return ""
- start = parse_date(latest_reply_at) or date.today()
- return (add_business_days(start, amount) if kind == "business" else start + timedelta(days=amount)).isoformat()
- def header_map(ws, aliases: Dict[str, List[str]]) -> Dict[str, int]:
- raw = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
- mapped: Dict[str, int] = {}
- for key, names in aliases.items():
- for name in names:
- if name in raw:
- mapped[key] = raw[name]
- break
- return mapped
- def ensure_conversation_sheet(wb, sheet_name: str):
- if sheet_name not in wb.sheetnames:
- ws = wb.create_sheet(sheet_name)
- ws.append(CONVERSATION_HEADERS)
- else:
- ws = wb[sheet_name]
- existing = {clean(cell.value) for cell in ws[1] if clean(cell.value)}
- for header in CONVERSATION_HEADERS:
- if header not in existing:
- ws.cell(row=1, column=ws.max_column + 1).value = header
- header_fill = PatternFill(fill_type="solid", fgColor="D9EAF7")
- for cell in ws[1]:
- cell.font, cell.fill = Font(bold=True), header_fill
- headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
- widths = [38, 12, 28, 38, 24, 22, 12, 20, 12, 56, 56, 14, 18, 16, 52, 52, 22, 22, 32]
- for header, width in zip(CONVERSATION_HEADERS, widths):
- ws.column_dimensions[get_column_letter(headers[header])].width = width
- ws.freeze_panes = "A2"
- ws.auto_filter.ref = ws.dimensions
- return ws
- def message_analysis_map(thread: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
- return {clean(item.get("record_id")): item for item in thread.get("messages", []) if clean(item.get("record_id"))}
- def merge_threads(raw: Dict[str, Any], analysis: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
- analysis_threads = analysis.get("threads") or []
- exact = {analysis_key(thread): thread for thread in analysis_threads}
- output_rows: List[Dict[str, Any]] = []
- customer_updates: List[Dict[str, Any]] = []
- warnings: List[str] = []
- sync_time = now_text()
- for raw_thread in raw.get("threads") or []:
- if raw_thread.get("status") != "collected":
- warnings.append(f"{clean((raw_thread.get('customer') or {}).get('company'))}: {clean(raw_thread.get('status'))}")
- continue
- messages = raw_thread.get("messages") or []
- if not messages:
- continue
- athread = exact.get(raw_thread_key(raw_thread)) or fallback_analysis_match(raw_thread, analysis_threads)
- if not athread:
- raise ValueError(f"Missing analysis thread for {raw_thread_key(raw_thread)}")
- amap = message_analysis_map(athread)
- customer = raw_thread.get("customer") or {}
- effective_reply_ids: List[str] = []
- for raw_message in messages:
- record_id = clean(raw_message.get("record_id"))
- if not record_id:
- raise ValueError("Raw message is missing record_id")
- analyzed = amap.get(record_id) or {}
- direction = clean(raw_message.get("direction"))
- kind = clean(raw_message.get("message_type")) or "文本"
- original = clean(raw_message.get("original_text"))
- translation = chinese_or_same(original, clean(analyzed.get("chinese_translation")))
- original_language = clean(analyzed.get("original_language")) or ("zh" if CJK_RE.search(original) else "")
- non_reply = direction != "客户回复" or kind in {"系统消息", "自动回复", "已读提示"}
- effective = bool(analyzed.get("is_effective_customer_reply")) and not non_reply
- intent = reason = action = ""
- if effective:
- intent = validate_intent(clean(analyzed.get("intent")))
- reason, action = clean(analyzed.get("intent_reason")), clean(analyzed.get("next_action"))
- if not reason or not action:
- raise ValueError(f"Effective reply {record_id} requires intent_reason and next_action")
- effective_reply_ids.append(record_id)
- risks = list_values(raw_message.get("risk_flags")) + list_values(analyzed.get("risk_flags"))
- output_rows.append({
- "记录ID": record_id,
- "客户序号": clean(customer.get("index")),
- "客户姓名/公司": clean(customer.get("company")),
- "Facebook主页链接": clean(customer.get("facebook_link")),
- "Messenger线程ID": clean(raw_thread.get("thread_id")),
- "消息时间": clean(analyzed.get("message_time")) or clean(raw_message.get("message_time_raw")),
- "消息方向": direction,
- "发件人": clean(raw_message.get("sender")),
- "原文语言": original_language,
- "对话原文": original,
- "中文翻译": translation,
- "消息类型": kind,
- "是否有效客户回复": "是" if effective else "否",
- "合作意向": intent,
- "意向判断依据": reason,
- "下一步建议": action,
- "同步时间": sync_time,
- "来源账号/Profile ID": clean(raw.get("profile_id")),
- "风险标记": ";".join(dict.fromkeys(risks)),
- })
- latest = athread.get("latest_analysis") or {}
- if effective_reply_ids:
- latest_record_id = clean(latest.get("latest_reply_record_id"))
- if latest_record_id not in effective_reply_ids:
- raise ValueError(f"latest_reply_record_id must reference an effective reply for {clean(customer.get('company'))}")
- latest_intent = validate_intent(clean(latest.get("intent")))
- summary = clean(latest.get("chinese_summary"))
- reason, next_action = clean(latest.get("intent_reason")), clean(latest.get("next_action"))
- latest_reply_at = clean(latest.get("latest_reply_at"))
- if not all([summary, reason, next_action, latest_reply_at]):
- raise ValueError(f"latest_analysis is incomplete for {clean(customer.get('company'))}")
- customer_updates.append({
- "index": clean(customer.get("index")),
- "company": clean(customer.get("company")),
- "facebook_link": clean(customer.get("facebook_link")),
- "latest_reply_at": latest_reply_at,
- "summary": summary,
- "intent": latest_intent,
- "intent_reason": reason,
- "next_action": next_action,
- "next_followup": default_followup(latest_intent, latest_reply_at, clean(latest.get("next_followup"))),
- "latest_reply_record_id": latest_record_id,
- })
- return output_rows, customer_updates, warnings
- def append_or_update_conversations(ws, rows: Iterable[Dict[str, Any]]) -> Tuple[int, int]:
- headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
- existing = {
- clean(ws.cell(row=row, column=headers["记录ID"]).value): row
- for row in range(2, ws.max_row + 1)
- if clean(ws.cell(row=row, column=headers["记录ID"]).value)
- }
- immutable = {"记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID", "对话原文"}
- added = updated = 0
- for item in rows:
- record_id = clean(item.get("记录ID"))
- if record_id in existing:
- target, changed = existing[record_id], False
- for header in CONVERSATION_HEADERS:
- if header in immutable:
- continue
- value = item.get(header, "")
- if value not in (None, "") and ws.cell(target, headers[header]).value != value:
- ws.cell(target, headers[header]).value = value
- changed = True
- updated += int(changed)
- else:
- target = ws.max_row + 1
- for header in CONVERSATION_HEADERS:
- ws.cell(target, headers[header]).value = item.get(header, "")
- existing[record_id] = target
- added += 1
- ws.auto_filter.ref = ws.dimensions
- return added, updated
- def merge_status(existing: str, intent: str) -> str:
- result = clean(existing)
- for phrase in REPLY_STATUS_PHRASES:
- result = result.replace(phrase, "")
- result = re.sub(r"[,,;;|]+", ",", result).strip(", ")
- latest = STATUS_BY_INTENT[intent]
- return ",".join(dict.fromkeys([clean(part) for part in [*result.split(","), *latest.split(",")] if clean(part)]))
- def reply_note(update: Dict[str, Any]) -> str:
- return (
- f"{NOTE_START}最新回复时间:{update['latest_reply_at']};中文摘要:{update['summary']};"
- f"合作意向:{update['intent']};判断依据:{update['intent_reason']};"
- f"下一步建议:{update['next_action']}{NOTE_END}"
- )
- def replace_reply_note(existing: str, block: str) -> str:
- existing = clean(existing)
- pattern = re.compile(re.escape(NOTE_START) + r".*?" + re.escape(NOTE_END))
- return clean(pattern.sub(block, existing)) if pattern.search(existing) else clean(existing + (" | " if existing else "") + block)
- def update_facebook_rows(wb, sheet_name: str, updates: Sequence[Dict[str, Any]]) -> Tuple[int, List[str]]:
- if sheet_name not in wb.sheetnames:
- raise KeyError(f"Sheet not found: {sheet_name}")
- ws = wb[sheet_name]
- columns = header_map(ws, FACEBOOK_ALIASES)
- required = {"index", "company", "link", "status", "followup", "note"}
- if missing := sorted(required - set(columns)):
- raise RuntimeError(f"Facebook sheet is missing columns: {', '.join(missing)}")
- by_index = {clean(item.get("index")): item for item in updates if clean(item.get("index"))}
- written, missing_customers, matched = 0, [], set()
- for row in range(2, ws.max_row + 1):
- row_index = clean(ws.cell(row, columns["index"]).value)
- update = by_index.get(row_index)
- if not update:
- continue
- if normalized_link(clean(ws.cell(row, columns["link"]).value)) != normalized_link(clean(update.get("facebook_link"))):
- missing_customers.append(f"{update.get('company')}: Facebook link mismatch")
- continue
- ws.cell(row, columns["status"]).value = merge_status(clean(ws.cell(row, columns["status"]).value), update["intent"])
- ws.cell(row, columns["followup"]).value = update["next_followup"]
- existing_note = clean(ws.cell(row, columns["note"]).value)
- ws.cell(row, columns["note"]).value = replace_reply_note(existing_note, reply_note(update))
- matched.add(row_index)
- written += 1
- for update in updates:
- if clean(update.get("index")) not in matched:
- missing_customers.append(f"{update.get('company')}: customer row not matched")
- return written, list(dict.fromkeys(missing_customers))
- def scan_question_marks(wb) -> Tuple[int, int]:
- cells = note_rows = 0
- for ws in wb.worksheets:
- note_columns = {idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value) in {"备注", "说明"}}
- bad_note_rows = set()
- for row in ws.iter_rows():
- for cell in row:
- if isinstance(cell.value, str) and QUESTION_MARK_RE.search(cell.value):
- cells += 1
- if cell.column in note_columns:
- bad_note_rows.add(cell.row)
- note_rows += len(bad_note_rows)
- return cells, note_rows
- def ensure_generated_text_clean(rows: Sequence[Dict[str, Any]], updates: Sequence[Dict[str, Any]]) -> None:
- for item in [*rows, *updates]:
- for key, value in item.items():
- if isinstance(value, str) and QUESTION_MARK_RE.search(value):
- raise ValueError(f"Repeated question marks detected before write: field={key}")
- def run_child(command: List[str]) -> Dict[str, Any]:
- completed = subprocess.run(command, text=True, encoding="utf-8", capture_output=True, check=True)
- try:
- return json.loads(completed.stdout)
- except json.JSONDecodeError:
- return {"stdout": completed.stdout.strip(), "stderr": completed.stderr.strip()}
- def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Write translated Facebook conversations and intent analysis to Excel.")
- parser.add_argument("--excel", default="")
- parser.add_argument("--transcript", default="")
- parser.add_argument("--analysis", default="")
- parser.add_argument("--facebook-sheet", default=FACEBOOK_SHEET)
- parser.add_argument("--conversation-sheet", default=CONVERSATION_SHEET)
- parser.add_argument("--write-workbook", action="store_true")
- parser.add_argument("--ensure-sheet-only", action="store_true")
- parser.add_argument("--refresh-summary", action="store_true")
- parser.add_argument("--refresh-dashboard", action="store_true")
- parser.add_argument("--latest-dashboard-dir", default="dashboards/latest")
- parser.add_argument("--run-id", default="")
- parser.add_argument("--output", default="")
- parser.add_argument("--no-backup", action="store_true")
- return parser.parse_args(argv)
- def main(argv: Optional[Sequence[str]] = None) -> int:
- args = parse_args(argv)
- run_id = args.run_id or new_run_id("facebook_conversation_write")
- resolved = resolve_workbook_path(args.excel, create_from_template=bool(args.write_workbook))
- workbook = resolved.get("path")
- if not workbook:
- raise FileNotFoundError("No outreach workbook found. Pass --excel.")
- workbook_path = Path(workbook)
- lock_path = workbook_path.with_name("~$" + workbook_path.name)
- if args.write_workbook and lock_path.exists():
- raise PermissionError(f"Workbook appears to be open: {lock_path}")
- rows, updates, warnings = [], [], []
- if not args.ensure_sheet_only:
- if not args.transcript or not args.analysis:
- raise ValueError("--transcript and --analysis are required unless --ensure-sheet-only is used")
- rows, updates, warnings = merge_threads(read_json(Path(args.transcript)), read_json(Path(args.analysis)))
- ensure_generated_text_clean(rows, updates)
- report_path = resolve_artifact_path(
- args.output, kind="facebook_conversation_write",
- default_name="facebook_conversation_write_report.json", run_id=run_id,
- )
- report: Dict[str, Any] = {
- "schema_version": "4.26",
- "run_id": run_id,
- "workbook": str(workbook_path),
- "dry_run": not args.write_workbook,
- "conversation_rows_ready": len(rows),
- "customer_updates_ready": len(updates),
- "intent_counts": {intent: sum(1 for item in updates if item.get("intent") == intent) for intent in INTENTS},
- "high_intent_customers": [
- {
- "customer": item["company"], "intent": item["intent"], "summary": item["summary"],
- "next_action": item["next_action"], "next_followup": item["next_followup"],
- }
- for item in updates if item.get("intent") in {"明确有意向", "潜在意向"}
- ],
- "warnings": warnings,
- }
- if not args.write_workbook:
- write_json(report_path, report)
- print(json.dumps({"report": str(report_path), **report}, ensure_ascii=False, indent=2))
- return 0
- backup_path = None if args.no_backup else create_backup_once(
- workbook_path, purpose="facebook_conversations", run_id=run_id,
- )
- wb = load_workbook(workbook_path)
- before_question_cells, before_question_notes = scan_question_marks(wb)
- if before_question_cells:
- raise ValueError(
- f"Workbook already contains repeated-question-mark corruption: cells={before_question_cells}, "
- f"note_rows={before_question_notes}"
- )
- conversation_ws = ensure_conversation_sheet(wb, args.conversation_sheet)
- added, conversation_updated = append_or_update_conversations(conversation_ws, rows)
- customer_rows_updated, missing_customers = (
- update_facebook_rows(wb, args.facebook_sheet, updates) if updates else (0, [])
- )
- if scan_question_marks(wb)[0]:
- raise ValueError("Repeated question marks detected before save")
- wb.save(workbook_path)
- summary_report, dashboard_report = {}, {}
- refresh_summary = bool(args.refresh_summary or args.refresh_dashboard)
- if refresh_summary:
- summary_report = run_child([
- sys.executable, str(SKILL_ROOT / "scripts" / "common" / "build_customer_summary.py"),
- "--excel", str(workbook_path), "--write-summary", "--no-backup", "--run-id", run_id,
- ])
- if args.refresh_dashboard:
- dashboard_report = run_child([
- sys.executable, str(SKILL_ROOT / "scripts" / "dashboard" / "build_dashboard.py"),
- "--excel", str(workbook_path), "--run-id", run_id,
- "--latest-dir", args.latest_dashboard_dir,
- ])
- reopened = load_workbook(workbook_path, data_only=True, read_only=True)
- question_mark_cells, question_mark_note_rows = scan_question_marks(reopened)
- reopened.close()
- report.update({
- "dry_run": False,
- "backup": str(backup_path) if backup_path else "",
- "conversation_rows_added": added,
- "conversation_rows_updated": conversation_updated,
- "customer_rows_updated": customer_rows_updated,
- "missing_customers": missing_customers,
- "question_mark_cells": question_mark_cells,
- "question_mark_note_rows": question_mark_note_rows,
- "summary_refreshed": refresh_summary,
- "summary_report": summary_report,
- "dashboard_refreshed": bool(args.refresh_dashboard),
- "dashboard_report": dashboard_report,
- })
- config_path = project_root(workbook_path.parent) / "feishu_sync_config.json"
- if config_path.exists():
- try:
- config = read_json(config_path)
- except Exception as exc:
- config = {"enabled": False, "config_error": str(exc)}
- report["feishu_sync"] = {
- "config_found": True,
- "enabled": bool(config.get("enabled")),
- "sync_required": bool(config.get("enabled")),
- "conversation_sheet_name": config.get("conversation_sheet_name", args.conversation_sheet),
- "sync_conversation_sheet": config.get("sync_conversation_sheet", True),
- "agent_action": "Use lark-sheets after local write; Python never stores Feishu credentials.",
- }
- else:
- report["feishu_sync"] = {
- "config_found": False,
- "enabled": False,
- "sync_required": False,
- "message": "本地建联表已更新;未发现 feishu_sync_config.json,飞书同步未执行。",
- }
- write_json(report_path, report)
- print(json.dumps({"report": str(report_path), **report}, ensure_ascii=False, indent=2))
- return 3 if question_mark_cells or missing_customers else 0
- if __name__ == "__main__":
- raise SystemExit(main())
|