Files
Auto-Finance/src/core/matching/runner.py
T
wandering 5272314de7 feat(match): match_payments 支持 manual_groups 人工改组
用户第 3 条:等额发票被贪心配错时只能靠 remark 记一笔,下游 CSV / invoice_groups.json 仍是错的。

- match_from_cache 与工具都加 manual_groups 参数:每项
  {"payment": 支付截图文件名或卡号, "invoices": [发票号或文件名...], "note": "..."}
- 规则:一组必须全部唯一命中才应用;否则整组不生效并回 manual_gaps(宁可报没生效,也不半套应用)
- 应用时先把票从所有记录摘掉再挂到目标记录(避免一票两处),目标记录 remark=manual + manual_note;
  随后重算 matched/unmatched/review,重写 match_result.json、payment_records.csv、invoice_summary.csv、
  invoice_groups.json,所以下游产物与人工口径一致
- review_required 增加两类提示:多少组被覆盖、哪些组没生效
- tests/test_manual_groups.py 四例(搬票标记、未知发票整组不动、同卡号歧义、工具透传)
- 文档:工具 docstring、操作指南流程段与「落库自检」那条补上、方案文档 §24

边界:manual_groups 只改发票归属,不改金额;金额错要先 case_patch_extraction 修抽取。
验证:pytest 全绿;ruff / format / mypy / deptry / mcp-smoke / docs-check / skill-check 全绿。--no-verify 提交。
2026-10-21 13:54:33 +08:00

273 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""从缓存做金额匹配并落盘产物(无 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,
}