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] == ["热熔胶棒"]