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:
@@ -325,6 +325,9 @@ cases/<case_id>/
|
||||
|
||||
exclude 传物品名关键词可剔除不参与申购的发票;project 覆盖「对应实验实训项目」,
|
||||
默认取 normal_info 的报销事由。需要 Windows + 已安装 Microsoft Excel。
|
||||
|
||||
会议费/会务费/培训费这类**因出差发生**的普通发票会自动跳过(并入差旅单的会务费培训费栏),
|
||||
返回里的 skipped_conference 会列出被跳过的条目——别把它们当货物列进申购单。
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
|
||||
@@ -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` 调它 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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] == ["热熔胶棒"]
|
||||
|
||||
Reference in New Issue
Block a user