"""从缓存做金额匹配并落盘产物(无 LLM) 给 MCP 工具用的入口:读 .invoice_cache → 分类 → 金额匹配 → 写 match_result.json / CSV / invoice_groups.json → 返回可决策的结果。 """ from __future__ import annotations from pathlib import Path from typing import Any from ... import get_logger from ...core.extraction.cache import load_cache, save_json, save_match_result from ...infra.documents.invoice import ( classify_invoice_batch, save_application_json, save_csv, save_invoice_csv, ) from .matcher import match_invoices_to_cards log = get_logger("matching.runner") INVOICE_TYPES = {"train", "hotel", "general"} META_KEYS = {"travel_info", "normal_info", "match_result"} def split_cache(cache_map: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: """把缓存条目分成发票 / 支付记录 / 申请单 / 其他附件。""" buckets: dict[str, list[dict[str, Any]]] = {"invoices": [], "cards": [], "applications": [], "others": []} for key, data in cache_map.items(): if key in META_KEYS or not isinstance(data, dict): continue kind = str(data.get("invoice_type", "")) if kind == "payment": buckets["cards"].append(data) elif kind == "application": buckets["applications"].append(data) elif kind == "note" or kind == "attachments": buckets["others"].append(data) elif kind in INVOICE_TYPES or data.get("invoice_number"): buckets["invoices"].append(data) else: buckets["others"].append(data) return buckets def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, Any]]]) -> None: """写 invoice_groups.json:完整分组 + 计数,供后续阶段与产物判断。""" data = { "travel": groups.get("travel", []), "general": groups.get("general", []), "application": groups.get("application", []), "attachments": groups.get("attachments", []), "travel_count": len(groups.get("travel", [])), "general_count": len(groups.get("general", [])), "application_count": len(groups.get("application", [])), } save_json(session_dir / "invoice_groups.json", data) def _invoice_keys(inv: dict[str, Any]) -> set[str]: keys = set() for key in ("invoice_number", "_source_file"): value = str(inv.get(key) or "").strip() if value: keys.add(value) return keys def _payment_keys(rec: dict[str, Any]) -> set[str]: keys = set() for key in ("_source_file", "card_no"): value = str(rec.get(key) or "").strip() if value: keys.add(value) return keys def _apply_manual_groups( records: list[dict[str, Any]], invoices: list[dict[str, Any]], manual_groups: list[dict[str, Any]], ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]: """按人工指定的分组覆盖贪心匹配结果。 manual_groups 每项形如 {"invoices": [发票号或文件名, ...], "payment": 支付截图文件名或卡号, "note": "..."}。 规则:**一组必须全部唯一命中才应用**(payment 唯一 + 每张发票都能找到),否则整组不应用并进 gaps—— 宁可报"没生效"也不能半套应用,那会把账改得更乱。 """ applied: list[dict[str, Any]] = [] gaps: list[str] = [] for idx, group in enumerate(manual_groups, start=1): if not isinstance(group, dict): gaps.append(f"第 {idx} 组不是对象,已跳过") continue payment_id = str(group.get("payment") or "").strip() note = str(group.get("note") or "").strip() raw_ids = group.get("invoices") or [] if not payment_id or not isinstance(raw_ids, list) or not raw_ids: gaps.append(f"第 {idx} 组缺少 payment 或 invoices,已跳过") continue targets = [rec for rec in records if payment_id in _payment_keys(rec)] if len(targets) != 1: reason = "没命中任何支付记录" if not targets else f"命中 {len(targets)} 条支付记录,无法确定是哪一条" gaps.append(f"第 {idx} 组 payment={payment_id!r} {reason},整组未应用") continue resolved: list[dict[str, Any]] = [] unresolved: list[str] = [] for raw in raw_ids: wanted = str(raw or "").strip() hits = [inv for inv in invoices if wanted and wanted in _invoice_keys(inv)] if len(hits) != 1: unresolved.append(wanted or "(空)") continue resolved.append(hits[0]) if unresolved: gaps.append(f"第 {idx} 组有发票没唯一命中:{'、'.join(unresolved)},整组未应用") continue # 先从所有记录里摘掉这些发票,再挂到目标记录(避免同一张票出现在两条记录里) move_keys: set[str] = set() for inv in resolved: move_keys |= _invoice_keys(inv) for rec in records: kept = [x for x in rec.get("_matched_invoices", []) if not (_invoice_keys(x) & move_keys)] if len(kept) != len(rec.get("_matched_invoices", [])): rec["_matched_invoices"] = kept target = targets[0] existing = target.setdefault("_matched_invoices", []) existing.extend(resolved) target["remark"] = "manual" if note: target["manual_note"] = note applied.append( { "payment": str(target.get("_source_file") or payment_id), "invoices": [str(inv.get("invoice_number") or inv.get("_source_file") or "") for inv in resolved], "note": note, } ) return records, applied, gaps def match_from_cache( case_dir: Path, tolerance: float | None = None, manual_groups: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """按缓存内容执行金额匹配,返回结构化结果。 返回字段里 review_required 是必须人工/模型复核的可疑点, 不要当成错误——贪心匹配本来就可能在等额发票上给出错误分配。 manual_groups 见 _apply_manual_groups:人工指定「这几张票就是这一笔支付」,覆盖贪心结果; 没全部唯一命中的组不会应用,原因回在 manual_gaps 里(不许静默失败)。 """ cache_map = load_cache(case_dir) buckets = split_cache(cache_map) invoices, cards = buckets["invoices"], buckets["cards"] if not invoices: return { "ok": False, "reason": "缓存里没有发票条目", "detail": {"cached": len(cache_map)}, "next_action": "先完成逐张抽取", } kwargs: dict[str, Any] = {} if tolerance is None else {"tolerance": tolerance} records = match_invoices_to_cards(invoices, cards, **kwargs) manual_applied: list[dict[str, Any]] = [] manual_gaps: list[str] = [] if manual_groups: records, manual_applied, manual_gaps = _apply_manual_groups(records, invoices, manual_groups) cache_dir = case_dir / ".invoice_cache" save_match_result(records, cache_dir) matched: list[dict[str, Any]] = [] unmatched_invoices: list[dict[str, Any]] = [] for rec in records: inv_list = [ { "file": inv.get("_source_file", ""), "invoice_type": inv.get("invoice_type", ""), "invoice_number": inv.get("invoice_number", ""), "amount": str(inv.get("total_amount", "")), } for inv in rec.get("_matched_invoices", []) ] item = { "payment_file": rec.get("_source_file", ""), "card_date": rec.get("card_date", ""), "card_no": rec.get("card_no", ""), "card_amount": str(rec.get("card_amount", "")), "invoices": inv_list, } if rec.get("remark") == "unmatched" or not rec.get("card_amount"): unmatched_invoices.extend(inv_list) else: matched.append(item) matched_payment_files = {m["payment_file"] for m in matched if m["payment_file"]} unmatched_payments = [ { "payment_file": c.get("_source_file", ""), "card_date": c.get("card_date", ""), "card_amount": str(c.get("card_amount", "")), } for c in cards if c.get("_source_file") not in matched_payment_files ] docs: list[dict[str, Any]] = [] for rec in records: docs.extend(rec.get("_matched_invoices", [])) docs.extend(buckets["applications"]) groups: dict[str, Any] = dict(classify_invoice_batch(docs)) groups["attachments"] = buckets["others"] save_csv(records, case_dir / "payment_records.csv") save_invoice_csv(records, case_dir / "invoice_summary.csv") if buckets["applications"]: save_application_json(buckets["applications"], case_dir / "travel_applications.json") save_invoice_groups(case_dir, groups) invoice_total = sum(float(str(i.get("total_amount") or 0) or 0) for i in invoices) card_total = sum(float(str(c.get("card_amount") or 0) or 0) for c in cards) review: list[str] = [] if card_total > invoice_total > 0: review.append(f"发票合计 {invoice_total:.2f} 小于支付合计 {card_total:.2f},违反「发票只能比付款多」的业务约束") if unmatched_invoices: review.append(f"{len(unmatched_invoices)} 张发票没配上支付记录,需要确认是否漏了支付截图或金额抽错") if unmatched_payments: review.append(f"{len(unmatched_payments)} 条支付记录没配上发票,可能是非报销支出或发票漏传") multi = [m for m in matched if len(m["invoices"]) > 1] if multi: review.append(f"{len(multi)} 笔支付走了一对多贪心凑额,分配结果需逐笔核对(等额发票尤其容易配错)") if manual_applied: review.append( f"{len(manual_applied)} 组按人工指定覆盖了贪心匹配(对应记录 remark=manual),下游 CSV 已按新分组重算" ) if manual_gaps: review.append(f"人工分组有 {len(manual_gaps)} 条没生效:" + ";".join(manual_gaps)) has_travel = bool(groups.get("travel")) has_general = bool(groups.get("general")) if has_travel and not has_general: suggested = "travel" elif has_general: suggested = "normal" else: suggested = "unknown" return { "ok": True, "matched": matched, "unmatched_invoices": unmatched_invoices, "unmatched_payments": unmatched_payments, "review_required": review, "totals": { "invoice_count": len(invoices), "payment_count": len(cards), "invoice_total": round(invoice_total, 2), "card_total": round(card_total, 2), "travel": len(groups.get("travel", [])), "general": len(groups.get("general", [])), "application": len(groups.get("application", [])), "attachments": len(groups.get("attachments", [])), }, "suggested_invoice_type": suggested, "manual_applied": manual_applied, "manual_gaps": manual_gaps, }