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:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user