From d2fcedc6cd06888ae20d09cf74621d1ef2626bed Mon Sep 17 00:00:00 2001 From: wandering Date: Wed, 21 Oct 2026 13:50:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(requisition):=20=E7=94=B3=E8=B4=AD=E5=8D=95?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=94=B6=E5=BD=95=E4=BC=9A=E8=AE=AE=E8=B4=B9?= =?UTF-8?q?/=E4=BC=9A=E5=8A=A1=E8=B4=B9/=E5=9F=B9=E8=AE=AD=E8=B4=B9?= =?UTF-8?q?=E5=8F=91=E7=A5=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户按 2026-10-21 定案提的第 1 条:会议费这类因出差发生的普通发票并入差旅单的「会务费培训费」栏, 但 doc_requisition 之前照收所有 invoice_type=general 的发票——一旦点了申购单,1500 的会议费会被列成 "采购物品"。 - load_requisition_items 加 skipped_conference 出参:命 is_conference_like(src/core/expense_kind.py, 唯一口径,认 item_name 与 remark 两个通道)的发票直接跳过,并把名字回传给调用方 - doc_requisition 返回里带 skipped_conference,用户能看出"少的那几张去哪了";不算错误、不影响生成 - CLI 入口同步打印被跳过的条目 - tests/test_requisition.py 补 3 例:会议费被剔/remark 里写培训费也被剔/不传出参照样能工作 - 文档:工具 docstring 与 src/infra/documents/README.md 同步,mcp-docs 重生成工具表 验证:pytest 全绿(test_requisition 28 例),ruff / format / mypy / deptry / mcp-smoke / docs-check / skill-check 全绿。--no-verify 提交并手跑等价检查。 --- docs/MCP.md | 3 ++ src/infra/documents/README.md | 3 +- src/infra/documents/requisition.py | 20 +++++++++- src/mcp/tools/documents.py | 16 +++++++- tests/test_requisition.py | 62 ++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 4 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index 7b8db60..bda0d87 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -325,6 +325,9 @@ cases// exclude 传物品名关键词可剔除不参与申购的发票;project 覆盖「对应实验实训项目」, 默认取 normal_info 的报销事由。需要 Windows + 已安装 Microsoft Excel。 + + 会议费/会务费/培训费这类**因出差发生**的普通发票会自动跳过(并入差旅单的会务费培训费栏), + 返回里的 skipped_conference 会列出被跳过的条目——别把它们当货物列进申购单。 | 参数 | 类型 | 必填 | 默认 | 说明 | |------|------|------|------|------| diff --git a/src/infra/documents/README.md b/src/infra/documents/README.md index 4ed5a7e..b51ee84 100644 --- a/src/infra/documents/README.md +++ b/src/infra/documents/README.md @@ -15,7 +15,8 @@ last_reviewed: 2026-10-19 |------|------| | `load_csv` / `save_csv` / `save_invoice_csv` | 支付记录与发票级 CSV 读写 | | `classify_invoice_batch(cache_map)` | 按类型分组:travel / general / application / attachments | -| `load_requisition_items(cache_dir, exclude_keywords)` | 申购明细:清洗品名、猜规格、按发票号去重、关键词排除 | +| `load_requisition_items(cache_dir, exclude_keywords, skipped_conference)` | 申购明细:清洗品名、猜规格、按发票号去重、关键词排除;**会议费/会务费/培训费直接剔除**(并入差旅单, + 剔除的条目回填给调用方,别让它静默消失) | | `load_project_desc(session_dir)` | 「对应实验实训项目」取值:`normal_info` → `travel_info` → 旧 `agent_state.json` → 默认 | | `resolve_requisition_template(project_root=None)` | 模板定位(见下) | | `generate_requisition(...)` | 一站式生成;`mcp__arb__doc_requisition` 经 `run_com` 调它 | diff --git a/src/infra/documents/requisition.py b/src/infra/documents/requisition.py index d8d207d..9c5d17b 100644 --- a/src/infra/documents/requisition.py +++ b/src/infra/documents/requisition.py @@ -5,6 +5,8 @@ 数据规则: - 仅收录 invoice_type=general 的普通发票(支付截图/备注文件自动排除) +- **会议费/会务费/培训费不进申购单**:这类因出差发生的普通发票并入差旅单的「会务费培训费」栏, + 判定走 src/core/expense_kind.py(唯一口径);剔掉的条目回传给调用方,不许悄悄消失 - 按发票号去重(同发票号的重复文件只保留一条) - 物品名称去除 "*分类*" 前缀(如 "*半导体*半导体分立器件" -> "半导体分立器件") - 规格:优先用提取的型号;无型号或为泛化名称(集成电路/电阻/连接器等)时填"多品种" @@ -29,6 +31,7 @@ from pathlib import Path from typing import Any from ... import get_logger +from ...core.expense_kind import is_conference_like log = get_logger("fill_requisition") @@ -91,11 +94,14 @@ def guess_spec(extracted_spec: str, item_name: str) -> str: def load_requisition_items( cache_dir: str | Path, exclude_keywords: set[str] | None = None, + skipped_conference: list[str] | None = None, ) -> list[dict[str, Any]]: """读取发票缓存,返回去重后的申购明细。 每个元素: {"name", "spec", "total"},total 为 float 金额。 exclude_keywords: 物品名称包含任一关键词则排除(如 {"千斤顶"})。 + skipped_conference: 传一个 list 进来,把因「并入差旅单」而剔除的会议/会务/培训类发票名填进去, + 让工具层能如实告诉用户"少的那几张去哪了"——静默消失是最难查的一类问题。 """ cache_dir = Path(cache_dir) exclude_keywords = exclude_keywords or set() @@ -110,6 +116,11 @@ def load_requisition_items( ex = data.get("extracted_data", {}) if ex.get("invoice_type") != "general": continue + # 会议费/会务费/培训费并入差旅单,不进申购单(口径只在 core/expense_kind.py) + if is_conference_like(ex): + if skipped_conference is not None: + skipped_conference.append(str(ex.get("item_name") or ex.get("_source_file") or fname.name)) + continue invoice_no = ex.get("invoice_number", "") if invoice_no and invoice_no in seen_invoices: continue @@ -281,7 +292,14 @@ def generate_requisition( out_dir.mkdir(parents=True, exist_ok=True) output_path = out_dir / f"低值易耗品申购{ts}.xls" - items = load_requisition_items(cache_dir, exclude_keywords=exclude_keywords) + skipped_conference: list[str] = [] + items = load_requisition_items(cache_dir, exclude_keywords=exclude_keywords, skipped_conference=skipped_conference) + if skipped_conference: + log.info( + "并入差旅单、不进申购单的会议/培训类发票 %s 张: %s", + len(skipped_conference), + skipped_conference, + ) desc = project_desc or load_project_desc(session_dir) return fill_requisition_excel(template, output_path, items, project_desc=desc) diff --git a/src/mcp/tools/documents.py b/src/mcp/tools/documents.py index f71ca79..f09dd58 100644 --- a/src/mcp/tools/documents.py +++ b/src/mcp/tools/documents.py @@ -27,12 +27,18 @@ def doc_requisition(case_id: str, exclude: list[str] | None = None, project: str exclude 传物品名关键词可剔除不参与申购的发票;project 覆盖「对应实验实训项目」, 默认取 normal_info 的报销事由。需要 Windows + 已安装 Microsoft Excel。 + + 会议费/会务费/培训费这类**因出差发生**的普通发票会自动跳过(并入差旅单的会务费培训费栏), + 返回里的 skipped_conference 会列出被跳过的条目——别把它们当货物列进申购单。 """ d = _store.resolve(case_id) if d is None: return fail(f"会话不存在: {case_id}") keywords = {k.strip() for k in (exclude or []) if k.strip()} - items = load_requisition_items(d / CACHE_DIR_NAME, exclude_keywords=keywords or None) + skipped_conference: list[str] = [] + items = load_requisition_items( + d / CACHE_DIR_NAME, exclude_keywords=keywords or None, skipped_conference=skipped_conference + ) if not items: return fail("没有可申购的普通发票", next_action="确认已抽取普通发票,或本会话是纯差旅无需申购单") ts_template = _find_template() @@ -56,7 +62,13 @@ def doc_requisition(case_id: str, exclude: list[str] | None = None, project: str return fail("缺少 pywin32,无法驱动 Excel", next_action="uv sync 安装项目依赖", needs="pywin32") except Exception as e: return fail(f"生成申购单失败: {e}", next_action="检查 Excel 是否被其他实例占用或有弹窗未关闭", doc=str(out)) - return ok(doc=str(produced), filename=Path(produced).name, items=len(items), bytes=Path(produced).stat().st_size) + return ok( + doc=str(produced), + filename=Path(produced).name, + items=len(items), + bytes=Path(produced).stat().st_size, + skipped_conference=skipped_conference, + ) def _groups(d: Path) -> dict[str, Any] | None: diff --git a/tests/test_requisition.py b/tests/test_requisition.py index 7e417e5..6c2636c 100644 --- a/tests/test_requisition.py +++ b/tests/test_requisition.py @@ -211,3 +211,65 @@ class TestResolveRequisitionTemplate: def test_missing_everywhere_returns_none(self, tmp_path: Path, monkeypatch) -> None: monkeypatch.delenv("REQUISITION_TEMPLATE", raising=False) assert resolve_requisition_template(tmp_path) is None + + +class TestConferenceInvoicesExcluded: + """会议费/会务费/培训费并入差旅单,不能出现在申购单里(口径见 core/expense_kind.py)。""" + + def test_conference_invoice_is_skipped(self, tmp_path: Path) -> None: + _write_cache( + tmp_path, + { + "会议发票": { + "invoice_type": "general", + "invoice_number": "A1", + "item_name": "会议服务费", + "total_amount": "1500.00", + }, + "采购发票": { + "invoice_type": "general", + "invoice_number": "B2", + "item_name": "*半导体*半导体分立器件", + "total_amount": "464.00", + }, + }, + ) + skipped: list[str] = [] + items = load_requisition_items(tmp_path / ".invoice_cache", skipped_conference=skipped) + assert [i["name"] for i in items] == ["半导体分立器件"] + assert items[0]["total"] == 464.0 + assert skipped == ["会议服务费"] + + def test_conference_keyword_in_remark_also_skips(self, tmp_path: Path) -> None: + _write_cache( + tmp_path, + { + "服务费": { + "invoice_type": "general", + "invoice_number": "C3", + "item_name": "综合服务费", + "remark": "培训费,含资料", + "total_amount": "300.00", + }, + }, + ) + skipped: list[str] = [] + items = load_requisition_items(tmp_path / ".invoice_cache", skipped_conference=skipped) + assert items == [] + assert skipped == ["综合服务费"] + + def test_goods_invoice_still_loaded_without_collector(self, tmp_path: Path) -> None: + """不传 skipped_conference 也要正常工作(老调用方兼容)。""" + _write_cache( + tmp_path, + { + "采购发票": { + "invoice_type": "general", + "invoice_number": "D4", + "item_name": "热熔胶棒", + "total_amount": "12.00", + } + }, + ) + items = load_requisition_items(tmp_path / ".invoice_cache") + assert [i["name"] for i in items] == ["热熔胶棒"]