"""金额匹配、信息落盘与规则校验工具 匹配是贪心算法,结果可能合理但分配错误;因此 match_payments 明确返回 review_required,不要把「匹配成功」当作「已核对」。 """ from __future__ import annotations from pathlib import Path from typing import Any from ...cases import CACHE_DIR_NAME, CaseStore from ...core.extraction.cache import save_json from ...core.matching.runner import match_from_cache from ...core.validation import validate_extracted_info from ...core.validation.validator import get_validation_rules from ..envelope import fail, ok _store = CaseStore() _INFO_KEYS = {"travel": "travel_info", "normal": "normal_info"} 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, manual_groups=manual_groups) if not result.get("ok"): return fail( str(result.get("reason")), next_action=str(result.get("next_action", "")), **({"detail": result.get("detail")} if result.get("detail") else {}), ) return ok(case_id=case_id, **result) def save_info(case_id: str, info_type: str, data: dict[str, Any]) -> dict[str, Any]: """写 travel_info 或 normal_info 到缓存,并立刻跑一次规则校验。 info_type 取 travel|normal。完整性以返回的 validation 为准, 不要自己在 data 里塞 can_submit。 规则里带 max_len_bytes 的是**文本宽度**上限(汉字算 2、字母数字算 1): 备注写太长,财务后端不回"太长了"而是一记 500,页面上什么都看不到, 所以在这里就拦住,missing_fields 会写成 "payment_methods[0].remark|备注过长:宽度 171 > 上限 100(约 50 个汉字),请改短"。 """ d = _store.resolve(case_id) if d is None: return fail(f"会话不存在: {case_id}") if info_type not in _INFO_KEYS: return fail(f"info_type 必须是 {list(_INFO_KEYS)},收到 {info_type!r}") if not isinstance(data, dict) or not data: return fail("data 必须是非空对象") data = {k: v for k, v in data.items() if k not in ("can_submit", "suggestion")} key = _INFO_KEYS[info_type] path = save_json(d / CACHE_DIR_NAME / (key + ".json"), data) report = validate_extracted_info(data, invoice_type=info_type) return ok( saved=str(path), info_type=info_type, validation=_report_dict(report), next_action="校验通过即可生成产物/填报" if report.valid else "按 missing_fields 与 missing_materials 补材料或改抽取,然后重跑 save_info", ) def validate_info(case_id: str, info_type: str | None = None, draft: dict[str, Any] | None = None) -> dict[str, Any]: """校验报销信息完整性。省略 draft 时校验缓存里已存的 info。 也可传 draft 先试校验不落盘,适合改字段时反复迭代。 """ d = _store.resolve(case_id) if d is None: return fail(f"会话不存在: {case_id}") groups_path = d / "invoice_groups.json" if info_type is None: info_type = "travel" if (d / CACHE_DIR_NAME / "travel_info.json").exists() else "normal" if info_type not in _INFO_KEYS: return fail(f"info_type 必须是 {list(_INFO_KEYS)}") key = _INFO_KEYS[info_type] data = draft if data is None: data = load_from_cache_info(d, key) if data is None: return fail(f"缓存里没有 {key}.json", next_action="先 save_info,或用 draft 参数试校验") report = validate_extracted_info(data, invoice_type=info_type) payload = _report_dict(report) if groups_path.exists() and info_type == "travel": # 财务口径(用户 2026-10-21 定案):因出差发生的会议费/会务费/培训费并入差旅单, # 只有货物类普通发票才必须另走普通报销——别把前者也说成"只能走普通报销" goods = _store.goods_general_items(d) if goods: payload["note"] = ( "有货物类普通发票(" + "、".join(goods[:3]) + ")不能并入差旅单:" "另建 normal_info 走普通报销并生成申购单;差旅单只留车票/住宿/会务费" ) else: payload["note"] = ( "发票分组已就绪:会议费/会务费/培训费这类因出差发生的普通发票" "并入差旅单的「会务费培训费」栏即可,会议通知作为附件" ) return ok(case_id=case_id, info_type=info_type, **payload) def rules_get(info_type: str | None = None) -> dict[str, Any]: """返回声明式校验规则(config/validation_rules.json),供补字段前对照。""" if info_type is None: return ok(travel=get_rules_dict("travel"), normal=get_rules_dict("normal")) if info_type not in _INFO_KEYS: return fail(f"info_type 必须是 {list(_INFO_KEYS)}") return ok(info_type=info_type, rules=get_rules_dict(info_type)) def info_skeleton(info_type: str) -> dict[str, Any]: """按校验规则生成「形状完整、值为空」的可填骨架。 规则里 required 为 true 的字段(含 remark 这类备注)要求键必须存在, 所以别凭记忆拼 JSON:先取骨架再逐个填值。骨架与 validation_rules.json 同源,不会漂移。 """ if info_type not in _INFO_KEYS: return fail(f"info_type 必须是 {list(_INFO_KEYS)}") rules = get_validation_rules(info_type) skeleton: dict[str, Any] = {} for field_rule in rules.get("fields", []): _place(skeleton, list(field_rule["path"]), "") for array_rule in rules.get("arrays", []): min_items = int(array_rule.get("min_items", 1) or 0) template: dict[str, Any] = {} for element_field in array_rule.get("element_fields", []): if isinstance(element_field, str): template[element_field] = "" else: _place(template, list(element_field["path"]), "") _place(skeleton, list(array_rule["path"]), [template] if min_items > 0 else []) return ok( info_type=info_type, skeleton=skeleton, rule_description=str(rules.get("description", "")), how_to="复制 skeleton,把空字符串换成真实值(确实没有就给空串);数组条数不够就整份复制模板。填完用 mcp__arb__save_info 落盘。", note="required 为 true 的字段必须存在(包括值为空串的备注类字段),这是刻意约束,不要省略键。", ) def _place(target: dict[str, Any], path: list[str], value: Any) -> None: """按 path 逐层建 dict,已存在则不覆盖。""" node: dict[str, Any] = target for key in path[:-1]: nxt = node.get(key) if not isinstance(nxt, dict): nxt = {} node[key] = nxt node = nxt node.setdefault(path[-1], value) def get_rules_dict(info_type: str) -> dict[str, Any]: """把规则对象转成可 JSON 化的字典(custom_check 是函数,用名字占位)。""" rules = get_validation_rules(info_type) plain: dict[str, Any] = {"description": rules.get("description", "")} plain["fields"] = [_strip_callable(r) for r in rules.get("fields", [])] plain["arrays"] = [ {**_strip_callable(r), "element_fields": [_strip_callable(e) for e in r.get("element_fields", [])]} for r in rules.get("arrays", []) ] return plain def load_from_cache_info(d: Path, key: str) -> dict[str, Any] | None: from ...core.extraction.cache import load_cache value = load_cache(d).get(key) return value if isinstance(value, dict) else None def _strip_callable(rule: Any) -> dict[str, Any]: out: dict[str, Any] = dict(rule) check = out.get("custom_check") if check is not None and not isinstance(check, str): out["custom_check"] = getattr(check, "__name__", "") return out def _report_dict(report: Any) -> dict[str, Any]: return { "valid": bool(report.valid), "missing_fields": list(report.missing_fields), "missing_materials": list(report.missing_materials), "confidence": round(float(report.confidence), 3), "suggestion": str(report.suggestion), }