diff --git a/.agents/docs/plans/MCP化改造方案-2026-10-19.md b/.agents/docs/plans/MCP化改造方案-2026-10-19.md index 2773b30..3876e1e 100644 --- a/.agents/docs/plans/MCP化改造方案-2026-10-19.md +++ b/.agents/docs/plans/MCP化改造方案-2026-10-19.md @@ -727,3 +727,19 @@ capture 只出现在 camera 段、脚本里不许再出现 `setAttribute`、两 还没做的:自检的选择器只在 FakePage 上验证过,**真实页面待联机确认**(尤其是 payment/subsidy 两步的表头关键词), 与第 4 条(乘车日期选择器)一起,等下一次联机填报时一并确认。 + +## 24. 人工改组入口 manual_groups(2026-10-21,用户第 3 条) + +用户指出:match_payments 缺人工改组入口,等额票配错只能靠 remark 记一笔,下游 CSV / invoice_groups.json 仍是错的。 + +- `match_from_cache(..., manual_groups=...)` 与工具参数同名透传;每项 + `{"payment": 支付截图文件名或卡号, "invoices": [发票号或文件名...], "note": "..."}` +- 规则:**一组必须全部唯一命中才应用**(payment 唯一 + 每张发票唯一);否则整组不生效,原因进 `manual_gaps`。 + 宁可报「没生效」也不半套应用——半套会把账改得更乱,而且用户没法从 CSV 看出来 +- 应用时先把这些发票从所有记录里摘掉再挂到目标记录,避免一张票出现在两条记录里; + 目标记录 remark=manual、附 manual_note,随后重算 matched/unmatched/review,重写 match_result.json、 + payment_records.csv、invoice_summary.csv、invoice_groups.json +- review_required 会加两条提示:多少组被人工覆盖、哪些组没生效 +- 单测 4 例:搬票并标记、未知发票整组不动、同卡号歧义报错、工具层参数透传 + +边界:manual_groups 只改「发票 ↔ 支付」的归属,不改金额;金额本身错了要先回去修抽取(case_patch_extraction)。 diff --git a/docs/MCP.md b/docs/MCP.md index e84f994..03056cc 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -260,11 +260,17 @@ cases// 返回 matched / unmatched_invoices / unmatched_payments / review_required。 未匹配与一对多凑额项必须复核,不要盲信贪心结果。 + + 等额发票被贪心配错时,用 manual_groups 人工改组(会重写 match_result.json / CSV / invoice_groups.json): + 每项 {"payment": 支付截图文件名或卡号, "invoices": [发票号或文件名, ...], "note": "为什么这样配"}。 + **一组必须全部唯一命中才应用**,否则整组不生效并回在 manual_gaps 里;应用过的在 manual_applied, + 对应记录的 remark 会变成 manual。别在半套命中的情况下改账。 | 参数 | 类型 | 必填 | 默认 | 说明 | |------|------|------|------|------| | `case_id` | string | 是 | — | — | | `tolerance` | number/null | 否 | null | — | +| `manual_groups` | array/null | 否 | null | — | ### `mcp__arb__info_skeleton` diff --git a/docs/报销操作指南.md b/docs/报销操作指南.md index 573892d..5ed695b 100644 --- a/docs/报销操作指南.md +++ b/docs/报销操作指南.md @@ -72,6 +72,7 @@ last_reviewed: 2026-10-19 | 3 | 渲染页面与文本层 | `case_render_pages` → `case_read_text` 或直接读 PNG | | 4 | 逐张抽取落盘;核对后局部修正 | `case_save_extraction` / `case_patch_extraction` | | 5 | 金额匹配,**逐条处理 `review_required`** | `match_payments` | +| 5b | 等额发票配错时人工改组(整组唯一命中才生效,没生效的原因在 `manual_gaps`) | `match_payments(manual_groups=...)` | | 6 | 取骨架 → 填值 → 落盘校验(试算用 `validate_info` 不落盘) | `info_skeleton` → `save_info` | | 7 | 普通发票生成申购单(`exclude` 剔除不参与申购的物品) | `doc_requisition` | | 8 | 随时复查态势;要重跑就清派生产物 | `case_status` / `case_reset` | @@ -101,6 +102,8 @@ last_reviewed: 2026-10-19 **什么时候算完成**:`validate_info` 报告 `valid: true` 且无业务约束违反。缺料就向用户要材料; Agent 不许自己宣布「可以提交了」。 +- 每步填报后工具会回读页面做**落库后自检**:数据行数与表尾合计跟 info 比对,差值写进 warnings, + 返回里的 self_check 是原始读数。看到「自检不一致」先看图再决定重填,别当没看见。 ### 产物位置 diff --git a/src/core/matching/runner.py b/src/core/matching/runner.py index 9ab6a12..5305fc8 100644 --- a/src/core/matching/runner.py +++ b/src/core/matching/runner.py @@ -59,11 +59,99 @@ def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, Any] save_json(session_dir / "invoice_groups.json", data) -def match_from_cache(case_dir: Path, tolerance: float | None = None) -> dict[str, Any]: +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) @@ -79,6 +167,10 @@ def match_from_cache(case_dir: Path, tolerance: float | None = None) -> dict[str 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) @@ -143,6 +235,12 @@ def match_from_cache(case_dir: Path, tolerance: float | None = None) -> dict[str 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: @@ -169,4 +267,6 @@ def match_from_cache(case_dir: Path, tolerance: float | None = None) -> dict[str "attachments": len(groups.get("attachments", [])), }, "suggested_invoice_type": suggested, + "manual_applied": manual_applied, + "manual_gaps": manual_gaps, } diff --git a/src/mcp/tools/analysis.py b/src/mcp/tools/analysis.py index 27681de..f9b2a6f 100644 --- a/src/mcp/tools/analysis.py +++ b/src/mcp/tools/analysis.py @@ -21,16 +21,25 @@ _store = CaseStore() _INFO_KEYS = {"travel": "travel_info", "normal": "normal_info"} -def match_payments(case_id: str, tolerance: float | None = None) -> dict[str, Any]: +def match_payments( + case_id: str, + tolerance: float | None = None, + manual_groups: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: """按缓存做发票与支付记录的金额匹配,并写出 CSV 与 invoice_groups.json。 返回 matched / unmatched_invoices / unmatched_payments / review_required。 未匹配与一对多凑额项必须复核,不要盲信贪心结果。 + + 等额发票被贪心配错时,用 manual_groups 人工改组(会重写 match_result.json / CSV / invoice_groups.json): + 每项 {"payment": 支付截图文件名或卡号, "invoices": [发票号或文件名, ...], "note": "为什么这样配"}。 + **一组必须全部唯一命中才应用**,否则整组不生效并回在 manual_gaps 里;应用过的在 manual_applied, + 对应记录的 remark 会变成 manual。别在半套命中的情况下改账。 """ d = _store.resolve(case_id) if d is None: return fail(f"会话不存在: {case_id}", next_action="先 case_create 并 case_add_files") - result = match_from_cache(d, tolerance=tolerance) + result = match_from_cache(d, tolerance=tolerance, manual_groups=manual_groups) if not result.get("ok"): return fail( str(result.get("reason")), diff --git a/tests/test_manual_groups.py b/tests/test_manual_groups.py new file mode 100644 index 0000000..2229e19 --- /dev/null +++ b/tests/test_manual_groups.py @@ -0,0 +1,87 @@ +"""人工改组(manual_groups):覆盖贪心匹配、必须整组命中、失败要报出来。""" + +from typing import Any + +from src.core.matching.runner import _apply_manual_groups +from src.mcp.tools import analysis +from src.mcp.tools.analysis import match_payments + + +def _records() -> list[dict[str, Any]]: + return [ + { + "_source_file": "支付1.jpg", + "card_no": "6282", + "card_amount": "100.00", + "_matched_invoices": [{"invoice_number": "A1", "_source_file": "发票A.pdf", "total_amount": "100.00"}], + }, + { + "_source_file": "支付2.jpg", + "card_no": "9999", + "card_amount": "50.00", + "_matched_invoices": [{"invoice_number": "B1", "_source_file": "发票B.pdf", "total_amount": "50.00"}], + }, + ] + + +def _invoices() -> list[dict[str, Any]]: + return [ + {"invoice_number": "A1", "_source_file": "发票A.pdf", "total_amount": "100.00"}, + {"invoice_number": "B1", "_source_file": "发票B.pdf", "total_amount": "50.00"}, + ] + + +def test_manual_group_moves_invoice_and_marks_record() -> None: + records = _records() + records, applied, gaps = _apply_manual_groups( + records, _invoices(), [{"payment": "支付2.jpg", "invoices": ["A1"], "note": "等额票配错"}] + ) + assert gaps == [] + assert applied and applied[0]["payment"] == "支付2.jpg" + second = records[1] + assert [x["invoice_number"] for x in second["_matched_invoices"]] == ["B1", "A1"] + assert second["remark"] == "manual" and second["manual_note"] == "等额票配错" + # A1 已从第一条记录摘掉,不会同时出现在两处 + assert records[0]["_matched_invoices"] == [] + + +def test_unknown_invoice_blocks_whole_group() -> None: + records, applied, gaps = _apply_manual_groups( + _records(), _invoices(), [{"payment": "支付2.jpg", "invoices": ["A1", "不存在"]}] + ) + assert applied == [] + assert gaps and "没唯一命中" in gaps[0] + # 整组未应用:记录保持原样,不能半套应用 + assert [x["invoice_number"] for x in records[0]["_matched_invoices"]] == ["A1"] + assert "remark" not in records[1] + + +def test_ambiguous_payment_is_reported() -> None: + records = _records() + records[1]["card_no"] = "6282" # 两条记录同卡号 + _, applied, gaps = _apply_manual_groups(records, _invoices(), [{"payment": "6282", "invoices": ["A1"]}]) + assert applied == [] and "命中 2 条" in gaps[0] + + +def test_tool_passes_manual_groups_through(monkeypatch: Any, tmp_path: Any) -> None: + seen: dict[str, Any] = {} + + def fake(case_dir: Any, tolerance: Any = None, manual_groups: Any = None) -> dict[str, Any]: + seen["groups"] = manual_groups + return { + "ok": True, + "matched": [], + "unmatched_invoices": [], + "unmatched_payments": [], + "review_required": [], + "totals": {}, + "suggested_invoice_type": "normal", + "manual_applied": [], + "manual_gaps": [], + } + + monkeypatch.setattr(analysis, "match_from_cache", fake) + monkeypatch.setattr(analysis._store, "resolve", lambda _cid: tmp_path) + groups = [{"payment": "支付1.jpg", "invoices": ["A1"]}] + result = match_payments("任意会话", manual_groups=groups) + assert result["ok"] is True and seen["groups"] == groups