fix(requisition): 模板三级解析,空白模板入库,产物只写会话 artifacts

历史清洗删掉了根目录两份 低值易耗品申购*.xls——它们其实是**已填写的真实申购单**
(半导体型号、单价、项目名都在),不是模板;同时旧实现「项目根 glob 取名字最大者」会把
上一次生成的产物当模板,所以一并修掉:

- 新增 resolve_requisition_template():REQUISITION_TEMPLATE 环境变量 → templates/ → 项目根(兼容旧位置);
  环境变量指向不存在的文件时告警并继续回退,不静默失败
- 入库 templates/低值易耗品申购.xls 空白件(由旧文件删掉第 3 行起的全部内容得到,UsedRange 只剩 2 行:
  标题 + 表头;真实采购明细不进仓库)
- generate_requisition 默认输出改为 <会话>/artifacts/低值易耗品申购<ts>.xls,不再写模板旁边
- mcp/tools/documents.py 的 _find_template 改为复用同一解析函数,避免两处规则漂移
- 模块 docstring 与 CLI help 里 src/web/uploads 的过时示例改为 cases/
- tests:5 个纯单测覆盖解析优先级(env 优先 / templates 胜过根目录 / 根目录取最新 /
  env 指向不存在的文件时回退 / 全都没有时返回 None),不依赖 COM

验证:真实 Excel COM 端到端 2 项 → 产物 UsedRange 5 行(标题/表头/2 明细/合计),第 6 行起为空,
无旧明细残留;fill_requisition_excel 原本就会先删第 3 行起内容,这条也实测确认过。
门禁:ruff / format --check / mypy src / deptry 全绿,pytest 112 passed,mcp_smoke 17 工具。
This commit is contained in:
wandering
2026-10-19 23:57:44 +08:00
parent e1acf2de84
commit 7e900ebdea
5 changed files with 102 additions and 20 deletions
+4
View File
@@ -18,15 +18,19 @@ from .invoice import (
from .requisition import (
DEFAULT_PROJECT,
REQUISITION_FILENAME,
TEMPLATE_ENV_VAR,
fill_requisition_excel,
generate_requisition,
load_project_desc,
load_requisition_items,
resolve_requisition_template,
)
__all__ = [
"CACHE_DIR_NAME",
"DEFAULT_PROJECT",
"TEMPLATE_ENV_VAR",
"resolve_requisition_template",
"INVOICE_LEVEL_COLUMNS",
"PAYMENT_RECORD_COLUMNS",
"REQUISITION_FILENAME",
+50 -15
View File
@@ -13,14 +13,15 @@
(旧 Web 会话目录回退 agent_state.json)
用法:
uv run python -m src.infra.documents.requisition --session src/web/uploads/35c63986686b
uv run python -m src.infra.documents.requisition --session src/web/uploads/0c9b7e7ef1ac --exclude 千斤顶
uv run python -m src.infra.documents.requisition --session cases/20261019-6月毕业采集
uv run python -m src.infra.documents.requisition --session cases/xxx --exclude 千斤顶
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import time
@@ -51,6 +52,11 @@ COL_DATE = 8
DEFAULT_PROJECT = "电子元器件及工具耗材采购"
# 模板查找:环境变量 REQUISITION_TEMPLATE -> templates/ -> 项目根(兼容旧位置)。
# 模板应是空白件:只含标题行与表头行。
TEMPLATE_ENV_VAR = "REQUISITION_TEMPLATE"
TEMPLATE_SUBDIR = "templates"
# 泛化规格名称:这类值对申购单无意义,统一替换为"多品种"
GENERIC_SPECS = {"集成电路", "电阻", "连接器", "电子元件", "二极管", "芯片"}
@@ -161,6 +167,27 @@ def load_project_desc(session_dir: str | Path, fallback: str = DEFAULT_PROJECT)
return _read_desc(root_dir / AGENT_STATE_FILENAME, nested=True) or fallback
def resolve_requisition_template(project_root: str | Path | None = None) -> Path | None:
"""定位申购单模板;找不到返回 None,由调用方决定怎么报错。
优先级:环境变量 REQUISITION_TEMPLATE 指定的文件 → 项目内 templates/ 目录 → 项目根(兼容旧位置)。
同一目录内取名字最大的一个,方便并存多版本模板。
"""
root = Path(project_root) if project_root is not None else Path(__file__).resolve().parents[3]
env = os.environ.get(TEMPLATE_ENV_VAR, "").strip()
if env:
candidate = Path(env)
if candidate.is_file():
return candidate
log.warning("环境变量 %s 指向的文件不存在: %s", TEMPLATE_ENV_VAR, env)
stem = REQUISITION_FILENAME.replace(".xls", "")
for directory in (root / TEMPLATE_SUBDIR, root):
candidates = sorted(directory.glob(f"{stem}*.xls"))
if candidates:
return candidates[-1]
return None
def fill_requisition_excel(
template_path: str | Path,
output_path: str | Path,
@@ -169,8 +196,9 @@ def fill_requisition_excel(
) -> Path:
"""将申购明细填入 Excel 模板并另存为新文件。
模板须含标题行与表头行(表头: 序号|物品名称|规格|数量|单价(含税)|金额|对应实验实训项目|数据采集时间),
数据从第 3 行开始填写,末尾追加合计行。
模板须含标题行与表头行(表头: 序号|物品名称|规格|数量|单价(含税)|金额|对应实验实训项目|数据采集时间)。
写入前会先删除第 3 行起的既有内容,所以误拿已填写的文件当模板也不会残留旧明细,
但模板本身应当是空白件(templates/低值易耗品申购.xls)。数据从第 3 行起填写,末尾追加合计行。
"""
template_path = Path(template_path)
output_path = Path(output_path)
@@ -233,22 +261,25 @@ def generate_requisition(
) -> Path:
"""一站式生成申购单:读取缓存 -> 去重 -> 填表。
默认以项目根目录下已存在的 `低值易耗品申购*.xls` 为模板,
输出为同目录下带时间戳的新文件。
模板由 resolve_requisition_template() 决定;输出默认落在会话目录的 artifacts/ 下,
不再写到模板旁边——否则上一次的产物会被下一次当成模板。
"""
session_dir = Path(session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
template = Path(template_path) if template_path is not None else None
if template is None:
root = Path(__file__).resolve().parents[3]
candidates = sorted(root.glob(f"{REQUISITION_FILENAME.replace('.xls', '')}*.xls"))
if not candidates:
raise FileNotFoundError(f"未找到申购单模板: {root / REQUISITION_FILENAME}")
template = candidates[-1]
template = resolve_requisition_template()
if template is None:
raise FileNotFoundError(
f"未找到申购单模板:放一份空白件到 templates/{REQUISITION_FILENAME},"
f"或用环境变量 {TEMPLATE_ENV_VAR} 指定路径"
)
if output_path is None:
ts = int(time.time() * 1000)
output_path = template.parent / f"低值易耗品申购{ts}.xls"
out_dir = session_dir / "artifacts"
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)
desc = project_desc or load_project_desc(session_dir)
@@ -257,9 +288,13 @@ def generate_requisition(
def main() -> None:
parser = argparse.ArgumentParser(description="生成低值易耗品申购单 Excel")
parser.add_argument("--session", default=".", help="会话目录(含 .invoice_cache/)")
parser.add_argument("--template", default=None, help="申购单模板 xls 路径(默认取项目根目录现有模板)")
parser.add_argument("--output", default=None, help="输出 xls 路径(默认同目录带时间戳新文件)")
parser.add_argument("--session", default=".", help="报销会话目录(含 .invoice_cache/)")
parser.add_argument(
"--template",
default=None,
help="申购单模板 xls 路径(默认:环境变量 REQUISITION_TEMPLATE -> templates/ -> 项目根)",
)
parser.add_argument("--output", default=None, help="输出 xls 路径(默认写在会话目录 artifacts/ 下,带时间戳)")
parser.add_argument("--project", default=None, help="对应实验实训项目(默认读 normal_info.json)")
parser.add_argument("--exclude", default="", help="排除物品名称含这些关键词的发票,逗号分隔(如: 千斤顶,台钻)")
args = parser.parse_args()
+12 -5
View File
@@ -11,7 +11,12 @@ from typing import Any
from ...cases import ARTIFACTS_DIR_NAME, CACHE_DIR_NAME, PROJECT_ROOT, CaseStore
from ...infra.com_thread import run_com
from ...infra.documents import REQUISITION_FILENAME, generate_requisition, load_requisition_items
from ...infra.documents import (
REQUISITION_FILENAME,
generate_requisition,
load_requisition_items,
resolve_requisition_template,
)
from ..envelope import fail, ok
_store = CaseStore()
@@ -32,7 +37,10 @@ def doc_requisition(case_id: str, exclude: list[str] | None = None, project: str
return fail("没有可申购的普通发票", next_action="确认已抽取普通发票,或本会话是纯差旅无需申购单")
ts_template = _find_template()
if ts_template is None:
return fail(f"未找到申购单模板 {REQUISITION_FILENAME}*.xls", next_action="把模板放到项目根目录")
return fail(
f"未找到申购单空白模板 {REQUISITION_FILENAME}",
next_action="放一份到 templates/ 目录,或设置环境变量 REQUISITION_TEMPLATE 指向模板文件",
)
out = d / ARTIFACTS_DIR_NAME / f"低值易耗品申购_{d.name}.xls"
out.parent.mkdir(parents=True, exist_ok=True)
try:
@@ -65,6 +73,5 @@ def _groups(d: Path) -> dict[str, Any] | None:
def _find_template() -> Path | None:
stem = REQUISITION_FILENAME.replace(".xls", "")
candidates = sorted(PROJECT_ROOT.glob(f"{stem}*.xls"))
return candidates[-1] if candidates else None
"""模板位置统一由 requisition.resolve_requisition_template 决定,不在这里另写一套。"""
return resolve_requisition_template(PROJECT_ROOT)
Binary file not shown.
+36
View File
@@ -12,6 +12,7 @@ from src.infra.documents.requisition import (
guess_spec,
load_project_desc,
load_requisition_items,
resolve_requisition_template,
)
@@ -175,3 +176,38 @@ class TestLoadProjectDesc:
def test_falls_back_on_corrupt_file(self, tmp_path: Path) -> None:
(tmp_path / "agent_state.json").write_text("not json", encoding="utf-8")
assert load_project_desc(tmp_path) == "电子元器件及工具耗材采购"
class TestResolveRequisitionTemplate:
"""模板解析不涉及 COM,可以纯单测覆盖优先级。"""
def test_env_var_wins(self, tmp_path: Path, monkeypatch) -> None:
custom = tmp_path / "自定义模板.xls"
custom.write_bytes(b"x")
monkeypatch.setenv("REQUISITION_TEMPLATE", str(custom))
assert resolve_requisition_template(tmp_path / "proj") == custom
def test_templates_dir_beats_root(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.delenv("REQUISITION_TEMPLATE", raising=False)
(tmp_path / "templates").mkdir()
blank = tmp_path / "templates" / "低值易耗品申购.xls"
blank.write_bytes(b"a")
(tmp_path / "低值易耗品申购999.xls").write_bytes(b"b")
assert resolve_requisition_template(tmp_path) == blank
def test_root_fallback_takes_latest_name(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.delenv("REQUISITION_TEMPLATE", raising=False)
(tmp_path / "低值易耗品申购111.xls").write_bytes(b"a")
newest = tmp_path / "低值易耗品申购222.xls"
newest.write_bytes(b"b")
assert resolve_requisition_template(tmp_path) == newest
def test_env_pointing_nowhere_falls_back(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("REQUISITION_TEMPLATE", str(tmp_path / "不存在.xls"))
legacy = tmp_path / "低值易耗品申购.xls"
legacy.write_bytes(b"b")
assert resolve_requisition_template(tmp_path) == legacy
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