From 7e900ebdea3e1374f8e02e8439d9731514fbcaec Mon Sep 17 00:00:00 2001 From: wandering Date: Mon, 19 Oct 2026 23:57:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(requisition):=20=E6=A8=A1=E6=9D=BF=E4=B8=89?= =?UTF-8?q?=E7=BA=A7=E8=A7=A3=E6=9E=90=EF=BC=8C=E7=A9=BA=E7=99=BD=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=85=A5=E5=BA=93=EF=BC=8C=E4=BA=A7=E7=89=A9=E5=8F=AA?= =?UTF-8?q?=E5=86=99=E4=BC=9A=E8=AF=9D=20artifacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史清洗删掉了根目录两份 低值易耗品申购*.xls——它们其实是**已填写的真实申购单** (半导体型号、单价、项目名都在),不是模板;同时旧实现「项目根 glob 取名字最大者」会把 上一次生成的产物当模板,所以一并修掉: - 新增 resolve_requisition_template():REQUISITION_TEMPLATE 环境变量 → templates/ → 项目根(兼容旧位置); 环境变量指向不存在的文件时告警并继续回退,不静默失败 - 入库 templates/低值易耗品申购.xls 空白件(由旧文件删掉第 3 行起的全部内容得到,UsedRange 只剩 2 行: 标题 + 表头;真实采购明细不进仓库) - generate_requisition 默认输出改为 <会话>/artifacts/低值易耗品申购.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 工具。 --- src/infra/documents/__init__.py | 4 ++ src/infra/documents/requisition.py | 65 ++++++++++++++++++++++------- src/mcp/tools/documents.py | 17 +++++--- templates/低值易耗品申购.xls | Bin 0 -> 19456 bytes tests/test_requisition.py | 36 ++++++++++++++++ 5 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 templates/低值易耗品申购.xls diff --git a/src/infra/documents/__init__.py b/src/infra/documents/__init__.py index 6932619..7ad8bca 100644 --- a/src/infra/documents/__init__.py +++ b/src/infra/documents/__init__.py @@ -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", diff --git a/src/infra/documents/requisition.py b/src/infra/documents/requisition.py index 9a47651..d8d207d 100644 --- a/src/infra/documents/requisition.py +++ b/src/infra/documents/requisition.py @@ -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() diff --git a/src/mcp/tools/documents.py b/src/mcp/tools/documents.py index bdbd8ac..f71ca79 100644 --- a/src/mcp/tools/documents.py +++ b/src/mcp/tools/documents.py @@ -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) diff --git a/templates/低值易耗品申购.xls b/templates/低值易耗品申购.xls new file mode 100644 index 0000000000000000000000000000000000000000..67d753d3ca2ebd3b8ed04d3a241adc6edc9dfdb6 GIT binary patch literal 19456 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 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