fix(requisition): 申购单不再收录会议费/会务费/培训费发票

用户按 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 提交并手跑等价检查。
This commit is contained in:
wandering
2026-10-21 13:50:05 +08:00
parent 0d9cc7b4ca
commit d2fcedc6cd
5 changed files with 100 additions and 4 deletions
+3
View File
@@ -325,6 +325,9 @@ cases/<case_id>/
exclude 传物品名关键词可剔除不参与申购的发票;project 覆盖「对应实验实训项目」,
默认取 normal_info 的报销事由。需要 Windows + 已安装 Microsoft Excel。
会议费/会务费/培训费这类**因出差发生**的普通发票会自动跳过(并入差旅单的会务费培训费栏),
返回里的 skipped_conference 会列出被跳过的条目——别把它们当货物列进申购单。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
+2 -1
View File
@@ -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` 调它 |
+19 -1
View File
@@ -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)
+14 -2
View File
@@ -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:
+62
View File
@@ -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] == ["热熔胶棒"]