增加生成学校易耗品填写的转换程序

This commit is contained in:
wandering
2026-08-31 18:25:35 +08:00
parent 44496a54e6
commit 4fbb4c36ed
19 changed files with 777 additions and 365 deletions
+36 -1
View File
@@ -43,7 +43,8 @@
│ │ ├── documents/ # 文档处理
│ │ │ ├── invoice.py # 发票数据模型 + CSV 工具
│ │ │ ├── pdf.py # PDF 图片渲染
│ │ │ └── consumable.py # 易耗品出库单填写(Word COM)
│ │ │ ├── consumable.py # 易耗品出库单填写(Word COM)
│ │ │ └── requisition.py # 低值易耗品申购单填写(Excel COM)
│ │ └── llm/ # LLM 接口
│ │ ├── prompt.py # 提示词加载
│ │ └── prompts/ # 提示词模板文件
@@ -293,6 +294,40 @@ uv run python -m src.infra.documents.consumable --no-backup # 不生成 .doc.b
- 单价/金额保留两位小数;表格内统一为 **宋体五号(10.5 磅)**
- 存放地点取自 `consumable_storage`(默认: `躬行楼 C205`);购货人/领用人签字、备注保持空白
### 6. 生成低值易耗品申购单(CLI)
从 Web 会话目录的发票缓存读取普通发票,自动填入「低值易耗品申购」Excel 模板。
需本机已安装 **Microsoft Excel**(通过 COM 操作,仅 Windows 可用)。
```bash
# 传入 Web 会话目录(含 .invoice_cache/ 与 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 <会话目录> --template 模板.xls --output 输出.xls --project "项目名称"
```
参数说明:
| 参数 | 说明 |
|------|------|
| `--session` | Web 会话目录,脚本自动读取其中的 `.invoice_cache/` 与 `agent_state.json` |
| `--template` | 申购单 Excel 模板路径;默认取项目根目录下现有的 `低值易耗品申购*.xls` |
| `--output` | 输出文件路径;默认同目录生成带时间戳的新文件,不覆盖模板 |
| `--project` | 「对应实验实训项目」列内容;默认读 `agent_state.json` 的 `reimbursement_description` |
| `--exclude` | 排除物品名称含这些关键词的发票,逗号分隔 |
填写规则概要:
- 仅收录 `invoice_type=general` 的普通发票(支付截图/备注文件自动排除),按发票号去重
- 物品名称去除 `*分类*` 前缀(如 `*半导体*半导体分立器件` → `半导体分立器件`)
- 规格:优先用发票提取的型号;无型号或为泛化名称(集成电路/电阻/连接器等)时,从名称提取封装/型号(如 `SOP-8`、`TO-220`、`STM32F103C8T6`),仍无法确定则填 `多品种`
- 数量固定 `1`,单价(含税) = 金额 = 发票价税合计
- 末尾自动追加「合计」行
## 执行步骤说明
| 步骤 | 命令 | 说明 |
+22 -50
View File
@@ -21,9 +21,7 @@ from ..core.extraction import (
llm_query_text,
load_cache,
load_match_result,
merge_supplement_into_info,
parse_json_response,
process_user_supplement,
)
from ..core.validation import validate_extracted_info
from ..infra.llm import (
@@ -77,6 +75,7 @@ def _do_extraction_with_validation(
session: AgentSession,
previous_analysis: dict[str, Any] | None = None,
cache_map: dict[str, Any] | None = None,
user_supplement: str | None = None,
) -> dict[str, Any]:
"""Agent 调度的提取-校验-修正循环。
@@ -95,6 +94,7 @@ def _do_extraction_with_validation(
session_dir: 会话目录。
session: 当前 Agent 会话。
previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。
user_supplement: 用户文字补充(可选,传入时作为补充上下文)。
Returns:
校验通过的结构化数据(或达到重试上限后的最佳结果)。
@@ -108,7 +108,12 @@ def _do_extraction_with_validation(
else:
system_prompt = build_normal_info_system_prompt()
base_message = build_extraction_user_message(cache_map, match_result, previous_analysis=previous_analysis)
base_message = build_extraction_user_message(
cache_map,
match_result,
previous_analysis=previous_analysis,
user_supplement=user_supplement,
)
current_message = base_message
for attempt in range(1, MAX_VALIDATION_RETRIES + 1):
@@ -191,6 +196,7 @@ def run_agent_round(
session_dir: Path,
session: AgentSession,
new_files: list[str] | None = None,
user_supplement: str | None = None,
) -> AgentSession:
"""执行一轮 Agent 处理:提取-校验-修正循环。
@@ -198,6 +204,7 @@ def run_agent_round(
session_dir: 会话目录。
session: 当前 Agent 会话。
new_files: 新增的文件列表(可选,补充文件时传入)。
user_supplement: 用户文字补充(可选,传入时作为补充上下文,强制重新分析)。
Returns:
更新后的 Agent 会话。
@@ -230,22 +237,26 @@ def run_agent_round(
# ---- Step 1: 信息提取(Agent 调度校验-修正循环) ----
session.state = AgentState.EXTRACTING
# 判断是否为补充文件场景:有新文件传入时,加载上一轮分析结果作为上下文
# 判断是否为补充场景:文件补充或文字补充,加载上一轮分析结果作为上下文
cache_map = load_cache(session_dir)
is_supplement = bool(new_files)
is_supplement = bool(new_files) or bool(user_supplement)
previous_analysis = None
if is_supplement:
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
previous_analysis = cache_map.get(info_key)
if previous_analysis:
log.info("检测到补充文件,加载上一轮分析结果作为历史上下文")
log.info("检测到补充内容,加载上一轮分析结果作为历史上下文")
try:
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
should_reanalyze = not cache_map.get(info_key) or is_supplement
if should_reanalyze:
session.extracted_info = _do_extraction_with_validation(
session_dir, session, previous_analysis=previous_analysis, cache_map=cache_map
session_dir,
session,
previous_analysis=previous_analysis,
cache_map=cache_map,
user_supplement=user_supplement,
)
# 提取后立即写入缓存,后续步骤依赖此数据
save_cache_info(session_dir, info_key, session.extracted_info)
@@ -361,50 +372,11 @@ def process_user_text_supplement(
emit_agent_event(
session_dir,
"agent_supplement_received",
files=[user_text[:50]], # 简短显示
supplement_type="text",
text=user_text[:50],
)
# Step 1: LLM 分析用户文字
supplement_result = process_user_supplement(
user_text=user_text,
extracted_info=session.extracted_info,
invoice_type=session.invoice_type,
source_dir=session_dir,
)
updated_fields = supplement_result.get("updated_fields", {})
unparsed = supplement_result.get("unparsed_info", "")
if updated_fields:
# Step 2: 合并到已提取信息
session.extracted_info = merge_supplement_into_info(
session.extracted_info,
updated_fields,
)
# Step 3: 保存到缓存
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
save_cache_info(session_dir, info_key, session.extracted_info)
log.info("已更新 %s", info_key)
# Step 4: 重新执行 Agent 校验
session.state = AgentState.EXTRACTING
emit_agent_event(
session_dir,
"agent_state_change",
state=session.state,
round=session.rounds,
message="正在重新校验...",
)
session = run_agent_round(session_dir, session)
else:
# 没有可更新的字段
msg = unparsed or "未识别到可更新的报销信息"
emit_agent_event(
session_dir,
"agent_supplement_received",
files=[msg],
)
log.info("用户补充未识别到有效信息: %s", msg)
# 将用户文字补充作为上下文传给主提取流程,LLM 输出完整 JSON
session = run_agent_round(session_dir, session, user_supplement=user_text)
return session
-4
View File
@@ -19,9 +19,7 @@ from .llm_extractor import (
llm_query_text,
load_cache,
load_match_result,
merge_supplement_into_info,
parse_json_response,
process_user_supplement,
)
__all__ = [
@@ -34,9 +32,7 @@ __all__ = [
"load_cache",
"load_match_result",
"llm_query_text",
"merge_supplement_into_info",
"parse_json_response",
"process_user_supplement",
"EXTRACTION_PARALLEL_COUNT",
"FILE_EVENTS_LOG",
"SUPPORTED_EXTENSIONS",
+18 -7
View File
@@ -115,6 +115,8 @@ def _build_summary(data: dict[str, Any]) -> dict[str, str]:
"general": "普通发票",
"payment": "支付记录",
"application": "出差申请单",
"attachments": "其他附件",
"note": "易耗品单",
}
summary = {}
@@ -259,11 +261,12 @@ def extract_invoices(
- 出差事前申请单 -> 单独存储,不参与匹配
Returns:
(payment_records, applications, groups):
- payment_records: 支付记录列表(仅包含真实发票,不含申请单)
- applications: 出差事前申请单列表(单独存储,不参与支付匹配)
元组 (payment_records, applications, groups):
- payment_records: 支付记录列表(每项含关联发票 _matched_invoices)
- applications: 出差事前申请单列表
- groups: 按文档类型分组的字典
{'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单]}
{'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单],
'attachments': [其他附件]}
Raises:
ExtractionError: 当所有文件提取均失败时抛出,携带失败文件列表和原始错误。
@@ -280,13 +283,14 @@ def extract_invoices(
all_files = _find_all_files(directory)
if not all_files:
log.warning("未找到支持的文件")
return [], [], {"travel": [], "general": [], "application": []}
return [], [], {"travel": [], "general": [], "application": [], "attachments": []}
log.info(f"发现 {len(all_files)} 个文件")
all_invoices = []
all_cards = []
applications = []
attachments = []
# 记录失败文件及其错误信息
failed_files: list[tuple[str, str]] = []
@@ -318,6 +322,9 @@ def extract_invoices(
elif inv_type == "payment":
all_cards.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif inv_type in ("attachments", "note"):
attachments.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif result.get("invoice_number"):
all_invoices.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
@@ -334,7 +341,9 @@ def extract_invoices(
details=error_details,
)
log.info(f"分类结果: 发票 {len(all_invoices)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)} 份")
log.info(
f"分类结果: 发票 {len(all_invoices)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)} 份, 附件 {len(attachments)} 个"
)
if not all_invoices:
log.warning("未成功解析任何发票")
@@ -349,10 +358,12 @@ def extract_invoices(
all_documents.extend(applications)
groups = classify_invoice_batch(all_documents)
groups["attachments"] = attachments
log.info(
f"文档分类: 差旅发票 {len(groups['travel'])} 张, "
f"普通发票 {len(groups['general'])} 张, "
f"出差事前申请单 {len(groups['application'])} 份"
f"出差事前申请单 {len(groups['application'])} 份, "
f"附件 {len(groups['attachments'])} 个"
)
return payment_records, applications, groups
+5 -90
View File
@@ -43,7 +43,6 @@ from ...infra.documents.invoice import CACHE_DIR_NAME
from ...infra.llm.prompt import (
build_invoice_system_prompt,
build_normal_info_system_prompt,
build_supplement_system_prompt,
build_travel_info_system_prompt,
)
@@ -386,6 +385,7 @@ def build_extraction_user_message(
cache_map: dict[str, Any],
match_result: dict[str, list[dict[str, Any]]],
previous_analysis: dict[str, Any] | None = None,
user_supplement: str | None = None,
) -> str:
"""构建提取请求的用户消息(供 Agent 调度使用)。
@@ -393,6 +393,7 @@ def build_extraction_user_message(
cache_map: 缓存数据映射。
match_result: 匹配结果。
previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。
user_supplement: 用户文字补充(可选,传入时作为修正上下文)。
Returns:
拼接好的用户消息字符串。
@@ -411,6 +412,9 @@ def build_extraction_user_message(
+ json.dumps(previous_analysis, ensure_ascii=False, indent=2)
)
if user_supplement:
parts.append("【用户文字补充】用户补充了以下文字信息,请据此更新你的分析结果。\n" + user_supplement)
if match_result:
parts.append(
"【发票与支付记录匹配结果】"
@@ -500,92 +504,3 @@ def extract_normal_info(
包含报销说明、发票总数、总金额、支付方式、附件清单等字段的字典。
"""
return _extract_info(source_dir, build_normal_info_system_prompt(), "普通发票")
# ------------------------------------------------------------------
# 用户补充信息处理
# ------------------------------------------------------------------
def process_user_supplement(
user_text: str,
extracted_info: dict[str, Any],
invoice_type: str,
source_dir: Path | None = None,
) -> dict[str, Any]:
"""让用户补充的文字信息通过 LLM 分析,返回需要更新的字段。
Args:
user_text: 用户输入的文字。
extracted_info: 当前已提取的报销信息。
invoice_type: "travel" 或 "normal"。
source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。
Returns:
包含 updated_fields, changes, confidence, unparsed_info 的字典。
"""
system_prompt = build_supplement_system_prompt()
parts = [
f"发票类型: {'差旅报销' if invoice_type == 'travel' else '普通报销'}",
"",
"以下是当前已提取的报销信息:",
json.dumps(extracted_info, ensure_ascii=False, indent=2),
"",
f"用户补充信息:{user_text}",
"",
"=== 请分析用户输入并返回需要更新的字段 ===",
]
user_message = "\n".join(parts)
try:
response = llm_query_text(
system_prompt=system_prompt,
text=user_message,
reasoning_effort="low",
source_dir=source_dir,
)
result = parse_json_response(response)
log.info("LLM 补充信息分析完成")
return result
except Exception as e:
log.error("LLM 补充信息分析失败: %s", e)
return {
"updated_fields": {},
"changes": [],
"confidence": 0.0,
"unparsed_info": f"分析失败: {e}",
}
def merge_supplement_into_info(
extracted_info: dict[str, Any],
updated_fields: dict[str, Any],
) -> dict[str, Any]:
"""将 LLM 返回的更新字段合并到已提取的信息中。
支持点号路径(如 basic_info.travel_purpose)表示嵌套更新。
Args:
extracted_info: 当前已提取的报销信息。
updated_fields: LLM 返回的需要更新的字段。
Returns:
更新后的报销信息。
"""
import copy
result = copy.deepcopy(extracted_info)
for field_path, value in updated_fields.items():
parts = field_path.split(".")
current = result
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
log.info("更新字段 %s = %s", field_path, value)
return result
+2 -2
View File
@@ -25,7 +25,7 @@ log = get_logger("validator")
# 日期格式
# ------------------------------------------------------------------
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
DATE_PATTERN = re.compile(r"^\d{4}-(?:0?[1-9]|1[0-2])-(?:0?[1-9]|[12]\d|3[01])$")
def _is_valid_date(value: str) -> bool:
@@ -95,7 +95,7 @@ class ValidationConfig(TypedDict):
# 配置加载
# ------------------------------------------------------------------
_CONFIG_PATH = Path(__file__).parent.parent.parent / "config" / "validation_rules.json"
_CONFIG_PATH = Path(__file__).parent.parent.parent.parent / "config" / "validation_rules.json"
_cached_rules: ValidationConfig | None = None
+12
View File
@@ -19,6 +19,13 @@ from .invoice import (
save_csv,
save_invoice_csv,
)
from .requisition import (
DEFAULT_PROJECT,
REQUISITION_FILENAME,
fill_requisition_excel,
generate_requisition,
load_requisition_items,
)
__all__ = [
"CACHE_DIR_NAME",
@@ -33,4 +40,9 @@ __all__ = [
"CONSUMABLE_DOC_FILENAME",
"fill_consumable_doc",
"fill_consumable_from_template",
"REQUISITION_FILENAME",
"DEFAULT_PROJECT",
"load_requisition_items",
"fill_requisition_excel",
"generate_requisition",
]
+254
View File
@@ -0,0 +1,254 @@
"""将发票缓存数据填入「低值易耗品申购」Excel 模板。
从会话目录的 .invoice_cache/*.json 读取普通发票(invoice_type=general),
按发票号去重后填入 Excel 申购单模板(通过 Excel COM 操作,仅 Windows 可用)。
数据规则:
- 仅收录 invoice_type=general 的普通发票(支付截图/备注文件自动排除)
- 按发票号去重(同发票号的重复文件只保留一条)
- 物品名称去除 "*分类*" 前缀(如 "*半导体*半导体分立器件" -> "半导体分立器件")
- 规格:优先用提取的型号;无型号或为泛化名称(集成电路/电阻/连接器等)时填"多品种"
- 数量固定 1,单价(含税)=金额=发票价税合计
- 对应实验实训项目:默认取 agent_state.json 的 reimbursement_description
用法:
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 千斤顶
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import time
from pathlib import Path
from typing import Any
from ... import get_logger
log = get_logger("fill_requisition")
REQUISITION_FILENAME = "低值易耗品申购.xls"
CACHE_DIR_NAME = ".invoice_cache"
AGENT_STATE_FILENAME = "agent_state.json"
TITLE_ROW = 1
HEADER_ROW = 2
DATA_START_ROW = 3
COL_INDEX = 1
COL_NAME = 2
COL_SPEC = 3
COL_QTY = 4
COL_UNIT_PRICE = 5
COL_AMOUNT = 6
COL_PROJECT = 7
COL_DATE = 8
DEFAULT_PROJECT = "电子元器件及工具耗材采购"
# 泛化规格名称:这类值对申购单无意义,统一替换为"多品种"
GENERIC_SPECS = {"集成电路", "电阻", "连接器", "电子元件", "二极管", "芯片"}
# 从物品名称中提取型号/封装的正则(按顺序匹配,须含第 1 捕获组)
SPEC_PATTERNS = [
re.compile(r"(?:封装\s*)?(SOP-\d+|SOIC-\d+|SOT-\d+|DIP-\d+|QFP-\d+|QFN-\d+)", re.IGNORECASE),
re.compile(r"(STM32[A-Z0-9]+)", re.IGNORECASE),
re.compile(r"(TO-\d+)", re.IGNORECASE),
re.compile(r"(7\.5\*7\.5\*5)"),
]
def clean_item_name(raw_name: str) -> str:
"""去除物品名称中的 "*分类*" 前缀与首尾标点。"""
cleaned = re.sub(r"\*[\u4e00-\u9fff]+\*", "", raw_name)
cleaned = re.sub(r"^[、,,\s]+", "", cleaned)
return cleaned
def guess_spec(extracted_spec: str, item_name: str) -> str:
"""确定规格:优先用提取的型号,其次从名称提取,否则"多品种"。"""
spec = (extracted_spec or "").strip()
if spec and spec not in GENERIC_SPECS:
return spec
for pattern in SPEC_PATTERNS:
m = pattern.search(item_name)
if m:
return m.group(1).upper()
return "多品种"
def load_requisition_items(
cache_dir: str | Path,
exclude_keywords: set[str] | None = None,
) -> list[dict[str, Any]]:
"""读取发票缓存,返回去重后的申购明细。
每个元素: {"name", "spec", "total"},total 为 float 金额。
exclude_keywords: 物品名称包含任一关键词则排除(如 {"千斤顶"})。
"""
cache_dir = Path(cache_dir)
exclude_keywords = exclude_keywords or set()
seen_invoices: set[str] = set()
items: list[dict[str, Any]] = []
for fname in sorted(cache_dir.iterdir()):
if fname.suffix != ".json" or fname.name in ("match_result.json", "normal_info.json"):
continue
with open(fname, encoding="utf-8") as f:
data = json.load(f)
ex = data.get("extracted_data", {})
if ex.get("invoice_type") != "general":
continue
invoice_no = ex.get("invoice_number", "")
if invoice_no and invoice_no in seen_invoices:
continue
if invoice_no:
seen_invoices.add(invoice_no)
item_name = clean_item_name(ex.get("item_name", ""))
if not item_name:
continue
raw_name = ex.get("item_name", "")
if any(kw in raw_name for kw in exclude_keywords):
continue
try:
total = float(ex.get("total_amount", 0))
except (TypeError, ValueError):
total = 0.0
items.append(
{
"name": item_name,
"spec": guess_spec(ex.get("spec_model", ""), item_name),
"total": total,
}
)
return items
def load_project_desc(session_dir: str | Path, fallback: str = DEFAULT_PROJECT) -> str:
"""从 agent_state.json 读取报销描述作为实验实训项目,缺失时用回退值。"""
state_path = Path(session_dir) / AGENT_STATE_FILENAME
try:
with open(state_path, encoding="utf-8") as f:
state = json.load(f)
desc = state.get("extracted_info", {}).get("basic_info", {}).get("reimbursement_description")
return desc or fallback
except (OSError, ValueError):
return fallback
def fill_requisition_excel(
template_path: str | Path,
output_path: str | Path,
items: list[dict[str, Any]],
project_desc: str = DEFAULT_PROJECT,
) -> Path:
"""将申购明细填入 Excel 模板并另存为新文件。
模板须含标题行与表头行(表头: 序号|物品名称|规格|数量|单价(含税)|金额|对应实验实训项目|数据采集时间),
数据从第 3 行开始填写,末尾追加合计行。
"""
template_path = Path(template_path)
output_path = Path(output_path)
shutil.copy2(template_path, output_path)
import pythoncom
import win32com.client
pythoncom.CoInitialize()
excel: Any = None
wb: Any = None
try:
excel = win32com.client.Dispatch("Excel.Application")
excel.Visible = False
excel.DisplayAlerts = False
wb = excel.Workbooks.Open(str(output_path.resolve()))
ws = wb.Sheets(1)
used = ws.UsedRange.Rows.Count
if used > HEADER_ROW:
ws.Rows(f"{DATA_START_ROW}:{used}").Delete()
for i, item in enumerate(items):
row = DATA_START_ROW + i
ws.Cells(row, COL_INDEX).Value = i + 1
ws.Cells(row, COL_NAME).Value = item["name"]
ws.Cells(row, COL_SPEC).Value = item["spec"]
ws.Cells(row, COL_QTY).Value = 1
ws.Cells(row, COL_UNIT_PRICE).Value = item["total"]
ws.Cells(row, COL_AMOUNT).Value = item["total"]
ws.Cells(row, COL_PROJECT).Value = project_desc
ws.Cells(row, COL_DATE).Value = ""
total_row = DATA_START_ROW + len(items)
ws.Cells(total_row, COL_NAME).Value = "合计"
ws.Cells(total_row, COL_AMOUNT).Value = sum(item["total"] for item in items)
wb.Save()
log.info(
"已生成低值易耗品申购单: %s(%d 项,合计 %.2f 元)",
output_path.name,
len(items),
sum(item["total"] for item in items),
)
finally:
if wb is not None:
wb.Close()
if excel is not None:
excel.Quit()
pythoncom.CoUninitialize()
return output_path
def generate_requisition(
session_dir: str | Path,
template_path: str | Path | None = None,
output_path: str | Path | None = None,
project_desc: str | None = None,
exclude_keywords: set[str] | None = None,
) -> Path:
"""一站式生成申购单:读取缓存 -> 去重 -> 填表。
默认以项目根目录下已存在的 `低值易耗品申购*.xls` 为模板,
输出为同目录下带时间戳的新文件。
"""
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]
if output_path is None:
ts = int(time.time() * 1000)
output_path = template.parent / f"低值易耗品申购{ts}.xls"
items = load_requisition_items(cache_dir, exclude_keywords=exclude_keywords)
desc = project_desc or load_project_desc(session_dir)
return fill_requisition_excel(template, output_path, items, project_desc=desc)
def main() -> None:
parser = argparse.ArgumentParser(description="生成低值易耗品申购单 Excel")
parser.add_argument("--session", default=".", help="Web 会话目录(含 .invoice_cache/ 与 agent_state.json)")
parser.add_argument("--template", default=None, help="申购单模板 xls 路径(默认取项目根目录现有模板)")
parser.add_argument("--output", default=None, help="输出 xls 路径(默认同目录带时间戳新文件)")
parser.add_argument("--project", default=None, help="对应实验实训项目(默认读 agent_state.json)")
parser.add_argument("--exclude", default="", help="排除物品名称含这些关键词的发票,逗号分隔(如: 千斤顶,台钻)")
args = parser.parse_args()
exclude = {kw.strip() for kw in args.exclude.split(",") if kw.strip()}
out = generate_requisition(
args.session, args.template, args.output, project_desc=args.project, exclude_keywords=exclude
)
print(f"已生成: {out}")
if __name__ == "__main__":
main()
-2
View File
@@ -6,13 +6,11 @@
from .prompt import (
build_invoice_system_prompt,
build_normal_info_system_prompt,
build_supplement_system_prompt,
build_travel_info_system_prompt,
)
__all__ = [
"build_invoice_system_prompt",
"build_normal_info_system_prompt",
"build_supplement_system_prompt",
"build_travel_info_system_prompt",
]
-5
View File
@@ -28,8 +28,3 @@ def build_travel_info_system_prompt() -> str:
def build_normal_info_system_prompt() -> str:
"""构建普通发票信息提取系统提示词。"""
return _load_prompt("normal_info_system.md")
def build_supplement_system_prompt() -> str:
"""构建用户补充信息分析系统提示词。"""
return _load_prompt("supplement_system.md")
+27 -2
View File
@@ -12,7 +12,7 @@
## 第一步:类型判断
收到图片后,首先判断文档类型。类型共有以下 6 种:
收到图片后,首先判断文档类型。类型共有以下 7 种:
| 类型值 | 文档类别 | 识别特征 |
|--------|---------|---------|
@@ -22,6 +22,7 @@
| `general` | 普通发票 | 不属于以上类别的其他发票 |
| `application` | 出差事前申请单 | 含项目名称、出差事由、计划时间、出差人员等信息 |
| `note` | 易耗品/出入库单 | 易耗品出入库单、出库单等 |
| `attachments` | 其他附件 | 不属于以上任何类别的文件,如学习材料、合同、说明文档等 |
---
@@ -168,6 +169,22 @@
---
### 7. `attachments`(其他附件)
```json
{
"invoice_type": "attachments",
"description": ""
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `invoice_type` | 是 | 固定为 `"attachments"` |
| `description` | 是 | 文件内容简要描述(总结为人类可读的描述) |
---
## 强制性类型约束
### 根节点结构
@@ -178,7 +195,7 @@
| 约束 | 规则 |
|------|------|
| `invoice_type` | 字符串,必须为 6 种类型值之一 |
| `invoice_type` | 字符串,必须为 7 种类型值之一 |
| 日期字段 | 字符串格式 `YYYY-MM-DD`,无法识别时返回 `""` |
| 金额字段 | 字符串格式(如 `"115.50"`),找不到时返回 `"0"` |
| 文本字段 | 字符串,无法识别时返回 `""` |
@@ -227,6 +244,14 @@
}
```
**其他附件**:
```json
{
"invoice_type": "attachments",
"description": "安徽开放大学专业技术人员在线培训学习视频"
}
```
---
## 空值处理规则
+174 -65
View File
@@ -1,101 +1,210 @@
# 普通报销信息提取系统提示词
# 角色定义
你是财务报销信息提取助手。根据发票信息和付款记录,提取报销相关的结构化数据,并以严格符合类型要求的 JSON 格式返回。
你是极度严谨合规的财务报销信息提取助手。你严格恪守财务数据规范,以字段精准映射、结果零偏差为核心准则,输出直接客观,在不加入无关细节的前提下,交付完全符合要求的结构化提取结果。
> **核心原则**:类型约束为最高优先级规则,任何情况下不得违反。
你通常不会输出提取推导过程、数据来源说明与寒暄类话术,只返回严格匹配 schema 要求的标准 JSON 格式结果,除非用户非常明确地要求标注提取依据与异常说明。你只按规则输出结果,不需要解释输出逻辑,也不透露内部校验规则的细节。
## 强制类型约束
你具备普通报销全单据的交叉校验能力,当获取到发票信息和付款记录后,会自动完成金额一致性、信息完整性的校验;信息缺失时按 schema 规则做缺省标记,绝不臆造任何无原始依据的财务数据。
以下类型规则为最高优先级,任何情况下不得违反。
你始终以 schema 为唯一输出标尺,偏好强类型约束、层级清晰的结构化输出风格;合规性优先于信息完整性,所有提取动作严格遵循财务报销管理规范。
### 1. 根节点字段(共 6 个,类型不可变更)
**核心原则**:类型约束为最高优先级规则,任何情况下不得违反。
| 字段名 | 强制类型 | 空值处理 |
| --- | --- | --- |
| `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 |
| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含下述 2 个子字段 |
| `payment_methods` | 数组 (list) | 必填,无数据时赋值为 `[]` |
| `attachments` | 数组 (list) | 必填,无数据时赋值为 `[]` |
| `can_submit` | 布尔 (bool) | 必填,信息完整且逻辑自洽时为 `true`,否则为 `false` |
| `suggestion` | 字符串 (str) | 当 `can_submit` 为 `false` 时说明需补充的材料;为 `true` 时为空字符串 |
---
### 2. `reimbursement_details` 子字段(共 2 个)
## 输入数据说明
| 字段名 | 强制类型 | 空值处理 |
| --- | --- | --- |
| `total_invoices` | 数字 (int) | 必填 |
| `total_amount` | 数字 (float) | 必填 |
你会收到以下两类数据:
### 3. 禁止行为
| 优先级 | 数据类型 | 包含信息 | 备注 |
|--------|---------|---------|------|
| 1(最高) | 发票信息 | 发票号码、开票日期、商品/服务名称、金额、卖方名称 | 金额推断的依据 |
| 2 | 付款记录 | 刷卡日期、刷卡金额、公务卡号 | 金额推断的依据 |
* 省略任何根节点字段或 `reimbursement_details` 的子字段
* 将 `payment_methods` 或 `attachments` 赋值为 `null`、字符串、数字或对象
* 在 `reimbursement_details` 中添加未定义的子字段
* 合并不同模块的数组数据
**补充分析场景**:如果你收到「上一轮分析结果」,说明用户可能已补充新文件。请综合所有数据(含新文件和历史分析结果)重新分析,不要仅依赖上一轮的结果。如果新文件填补了之前的信息缺失,请相应更新分析结果。
---
## 强制性类型约束
### 根节点结构
根节点必须包含且仅包含以下 6 个字段,类型不可变更:
| 字段名 | 类型 | 空值处理 |
|--------|------|---------|
| `basic_info` | object | 必填,所有子字段必须存在 |
| `reimbursement_details` | object | 必填,必须且仅含 2 个子字段 |
| `payment_methods` | array | 无数据时返回 `[]` |
| `attachments` | array | 无数据时返回 `[]` |
| `can_submit` | boolean | 必填,信息完整且逻辑自洽时为 `true`,否则为 `false` |
| `suggestion` | string | 当 `can_submit` 为 `false` 时说明需补充的材料;为 `true` 时为空字符串 |
### 报销明细节点结构
`reimbursement_details` 必须包含且仅包含以下 2 个子字段:
| 子字段 | 类型 | 空值处理 |
|--------|------|---------|
| `total_invoices` | integer | 必填,发票总份数 |
| `total_amount` | number | 必填,付款记录总金额 |
### 绝对禁止行为
- 省略任何根节点字段或报销明细节点
- 将数组类型赋值为 `null`、字符串、数字或对象
- 在 `reimbursement_details` 中添加未定义的子字段
### 正确输出骨架
**正确示例**:
```json
{
"basic_info": {...},
"reimbursement_details": {...},
"basic_info": { /* 所有子字段完整存在 */ },
"reimbursement_details": {
"total_invoices": 0,
"total_amount": 0
},
"payment_methods": [],
"attachments": [{"filename":"发票.pdf", ...}],
"attachments": [],
"can_submit": true,
"suggestion": ""
}
```
**错误示例**:
```json
{
"basic_info": {...},
"reimbursement_details": {...},
"payment_methods": null,
}
---
## 字段 Schema
### 1. `basic_info`(全部必填,无直接信息时给出合理猜测)
| 字段 | 类型 | 说明 | 推断优先级 |
|------|------|------|-----------|
| `reimbursement_description` | string | 报销说明,20 字以内 | ①发票商品/服务名称 → ②根据所有信息总结 |
---
### 2. `reimbursement_details`
| 字段 | 类型 | 说明 |
|------|------|------|
| `total_invoices` | integer | 发票总份数(不包含支付记录),根据所有发票文件统计 |
| `total_amount` | number | 付款记录总金额,各笔 `card_amount` 之和 |
---
### 3. `payment_methods` 数组元素(多少笔支付就多少条)
| 字段 | 类型 | 说明 |
|------|------|------|
| `card_date` | string | 刷卡日期,格式 `YYYY-MM-DD` |
| `card_amount` | number | 刷卡金额(元) |
| `merchant` | string | 根据发票信息推测的商户名称 |
| `remark` | string | 说明该笔付款关联的发票信息 |
---
### 4. `attachments` 数组元素
| 字段 | 类型 | 说明 |
|------|------|------|
| `filename` | string | 严格使用原始文件名,不得修改任何字符 |
| `attachment_type` | string | 枚举:`invoice` / `other` |
| `attachment_desc` | string | 文件基本内容描述 |
**排除规则**:`invoice_type` 为 `payment` 的记录不作为附件。
---
## 推理规则
### 数据推断优先级链
```
金额推断:发票金额 > 付款记录金额
事由推断:发票商品/服务名称 > 文件内容总结
商户推断:发票卖方名称 > 根据商品类型合理推测
```
## 输入数据说明
你会收到以下数据:
1. **发票信息**:包含购买物品的发票信息
2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息
### 关键规则
**补充分析场景**:如果你收到「上一轮分析结果」,说明用户可能已补充新文件。请综合所有数据(含新文件和历史分析结果)重新分析,不要仅依赖上一轮的结果。如果新文件填补了之前的信息缺失,请相应更新分析结果。
1. **金额一致性**:`reimbursement_details.total_amount` 必须等于各笔 `payment_methods.card_amount` 之和
2. **发票统计**:`total_invoices` 统计所有 `invoice_type` 不为 `payment` 的文件数量
3. **支付记录排除**:付款记录不放入 `attachments`
4. **附件全覆盖**:所有源文件(除支付记录外)都应在 `attachments` 中有一项
5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null`
需要提取的信息:
1. `basic_info`:(必填,每一项都必须填,给出合理的猜测)
1. `reimbursement_description`:根据所有信息写一句20字以内的报销说明
2. `reimbursement_details`:(至少有一项)
1. `total_invoices`: 发票的总份数
2. `total_amount`:填写付款记录总金额
3. `payment_methods`:(多少笔支付记录就有多少条;多项请采用上述通用 JSON 数组格式)
1. `card_date`:根据付款记录,格式 YYYY-M-D
2. `card_amount`:根据付款记录填写,单位为元,数字
3. `merchant`:根据发票信息推测商户信息
4. `remark`:说明该笔付款关联的发票信息
4. `attachments`:(必填,用户已经告诉你所有文件了`【源文件: {filename}】`,"invoice_type": "payment"的不作为附件)
1. `filename`: 严格使用用户提供的原始文件名,不得修改任何字符
2. `attachment_type`:从以下两个选项中选择:invoice、other
3. `attachment_desc`:简要描述该文件的基本信息
## 语义完整性校验
### 语义完整性校验
提取完成后,需判断信息是否足够支撑填报。根据校验结果设置根节点的 `can_submit`(boolean)和 `suggestion`(string)字段。
**校验维度**:
- 支付金额总和是否与发票金额总和接近
- 报销说明是否明确具体
- 人员信息是否完整
- 支付方式是否与支付记录对应
- 每张发票是否都有对应的支付记录
- `reimbursement_details.total_amount` 与 `payment_methods` 各笔之和是否一致
- 不一致时说明缺发票或缺支付记录
- 每张发票是否都有对应的 `payment_methods` 项
- 报销说明是否清晰明确
**不需要关注的**
- 非必填项没有填写信息,不要提醒补充
**一定要关注的**
- `reimbursement_details` 和 `payment_methods` 两个的总金额应该一样,如果不一样,要么是缺发票,要么是缺支付记录,需要提醒用户
**判定标准**:
- `can_submit = true`:信息完整且逻辑自洽,`suggestion` 为空字符串
- `can_submit = false`:存在信息缺失或逻辑矛盾,`suggestion` 说明需要用户补充什么材料
### 示例
**支付方式**(2 笔支付):
```json
[
{
"card_date": "2026-06-30",
"card_amount": 180.0,
"merchant": "安徽开放大学",
"remark": "关联发票: 26342000002051980306.pdf (专业技术人员在线培训费)"
},
{
"card_date": "2026-06-08",
"card_amount": 921.0,
"merchant": "合肥京东嘉远贸易有限公司",
"remark": "关联发票: digital_26347000000158262080.pdf (电器电子产品及配件)"
}
]
```
**附件清单**(3 个文件,排除支付记录):
```json
[
{
"filename": "26342000002051980306.pdf",
"attachment_type": "invoice",
"attachment_desc": "安徽开放大学专业技术人员在线培训费发票"
},
{
"filename": "digital_26347000000158262080.pdf",
"attachment_type": "invoice",
"attachment_desc": "合肥京东嘉远贸易有限公司电器电子产品及配件发票"
},
{
"filename": "安徽开放大学学习视频180.pdf",
"attachment_type": "other",
"attachment_desc": "安徽开放大学专业技术人员在线培训学习视频"
}
]
```
---
## 最终输出要求
* 仅输出纯 JSON 字符串,不包含任何思考过程、解释文字或 Markdown 标记
* JSON 语法必须正确,无多余逗号、引号等错误
* 严格遵守所有强制性类型约束,违反类型要求的输出视为无效
- 严格只输出 JSON 字符串,不包含任何思考过程、解释文字、Markdown 标记或其他内容
- JSON 语法必须正确,无多余逗号、引号等错误
- 严格遵守所有类型约束,任何违反均视为无效输出
- 日期统一使用 `YYYY-MM-DD` 格式
- 金额使用数字类型(非字符串)
- 计数使用整数类型
-114
View File
@@ -1,114 +0,0 @@
## 角色定义
你是财务报销信息补充助手。你的任务是根据用户输入的文字信息,分析并更新已提取的报销信息 JSON。
**核心原则**:从用户输入中提取与报销相关的信息,智能合并到现有 JSON 中,不破坏已有数据。
---
## 输入数据说明
你会收到以下数据:
1. **用户输入的文字**:用户补充的信息说明
2. **当前已提取的报销信息 JSON**:包含基本信息、报销明细、支付方式等
3. **发票类型**:差旅报销或普通报销
---
## 处理规则
### 1. 信息提取
从用户文字中提取以下类型的信息:
- **差旅信息**:出差事由、出发地、目的地、出差日期、随行人员
- **支付信息**:支付方式、支付金额、支付渠道
- **发票信息**:发票号码、开票日期、金额
- **人员信息**:姓名、工号、卡号
- **其他**:报销说明、备注信息
### 2. 合并策略
- 如果用户提供的信息对应 JSON 中已存在的字段,则**更新**该字段
- 如果用户提供的信息是新增内容,则**添加**到合适的字段
- 如果用户信息模糊,尽量推断最可能的字段
- **不要删除**已有的信息,除非用户明确说"删除"或"修改为"
### 3. 差旅报销字段映射
| 用户可能说的内容 | 对应 JSON 字段 |
|----------------|--------------|
| 出差原因/目的 | `basic_info.travel_purpose` |
| 去哪里出差 | `basic_info.travel_location` |
| 出发日期 | `basic_info.start_date` |
| 返回日期 | `basic_info.end_date` |
| 同行人员 | `subsidy_list` 数组 |
| 交通方式 | `reimbursement_details.transport_fee` |
| 酒店信息 | `reimbursement_details.accommodation` |
### 4. 普通报销字段映射
| 用户可能说的内容 | 对应 JSON 字段 |
|----------------|--------------|
| 报销说明 | `basic_info.reimbursement_description` |
| 总金额 | `reimbursement_details.total_amount` |
| 支付方式 | `payment_methods` |
| 附件说明 | `attachments` |
---
## 输出格式
严格返回以下 JSON 格式:
```json
{
"updated_fields": {
"field_path": "新值",
"another_field": "新值"
},
"changes": ["修改说明1", "修改说明2"],
"confidence": 0.0到1.0之间的数字,
"unparsed_info": "无法解析的信息(如果有)"
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `updated_fields` | object | 需要更新的字段路径和值,使用点号表示嵌套路径 |
| `changes` | array | 人类可读的修改说明列表 |
| `confidence` | number | 解析置信度,1.0 表示完全确定 |
| `unparsed_info` | string | 无法解析的信息,为空字符串表示全部解析成功 |
### 示例
用户输入:"出差去北京开学术会议,时间是6月10日到6月15日"
输出:
```json
{
"updated_fields": {
"basic_info.travel_purpose": "参加学术会议",
"basic_info.travel_location": "北京",
"basic_info.start_date": "2026-06-10",
"basic_info.end_date": "2026-06-15"
},
"changes": [
"设置出差事由为参加学术会议",
"设置目的地为北京",
"设置出差时间为6月10日至6月15日"
],
"confidence": 0.95,
"unparsed_info": ""
}
```
---
## 最终输出要求
- 严格只输出 JSON 字符串
- JSON 语法必须正确
- 不要包含任何思考过程或解释文字
- 如果用户输入与报销无关,返回空的 `updated_fields` 并在 `unparsed_info` 中说明
+16 -3
View File
@@ -119,9 +119,17 @@ def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
for key, data in cache_map.items()
if key not in ("travel_info", "normal_info")
and isinstance(data, dict)
and data.get("invoice_type") not in ("application", "payment")
and data.get("invoice_type") not in ("application", "payment", "attachments", "note")
]
return classify_invoice_batch(invoices)
groups = classify_invoice_batch(invoices)
# 从缓存重新收集 attachments 类型
attachments = [
data
for key, data in cache_map.items()
if isinstance(data, dict) and data.get("invoice_type") in ("attachments", "note")
]
groups["attachments"] = attachments
return groups
def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
@@ -135,9 +143,11 @@ def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]
"travel": groups.get("travel", []),
"general": groups.get("general", []),
"application": groups.get("application", []),
"attachments": groups.get("attachments", []),
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
"attachments_count": len(groups.get("attachments", [])),
}
with open(groups_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
@@ -189,13 +199,16 @@ def process_invoices(
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in payment_records)
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张")
log.info(
f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张, 附件 {len(groups.get('attachments', []))} 个"
)
return {
"invoice_count": invoice_count,
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
"application_count": len(groups.get("application", [])),
"attachments_count": len(groups.get("attachments", [])),
}
+3 -2
View File
@@ -25,6 +25,7 @@ from src.config import (
load_config as load_project_config,
)
from src.infra.documents import load_csv, load_invoice_csv
from src.pipeline_core import save_invoice_groups
from . import pipeline_web, sse_handler
@@ -655,7 +656,7 @@ def agent_process(session_id: str) -> Any:
if applications:
save_application_json(applications, sd / "travel_applications.json")
pipeline_web.save_invoice_groups(sd, groups)
save_invoice_groups(sd, groups)
from src.pipeline_core import is_travel_invoice
@@ -718,7 +719,7 @@ def agent_supplement(session_id: str) -> Any:
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
pipeline_web.save_invoice_groups(session_dir, groups)
save_invoice_groups(session_dir, groups)
config_path = session_dir / "config.json"
config = {}
+7 -3
View File
@@ -94,8 +94,12 @@ function _handleAgentError(msg) {
* 收到补充文件
*/
function _handleSupplementReceived(msg) {
const fileNames = (msg.files || []).join('、');
addChatMessage(`已收到补充文件:${fileNames}`, 'done');
if (msg.supplement_type === 'text') {
addChatMessage(`已收到补充文字:${msg.text || ''}`, 'done');
} else {
const fileNames = (msg.files || []).join('、');
addChatMessage(`已收到补充文件:${fileNames}`, 'done');
}
}
/**
@@ -193,7 +197,7 @@ export async function handleUserSupplement(text) {
handleDoneResult(result);
},
handleFileProgress: false,
handleLLMStream: false,
handleLLMStream: true,
});
} else if (result.error) {
addChatMessage(`处理失败:${result.error}`, 'error');
+52 -14
View File
@@ -6,6 +6,15 @@
import { escapeHtml } from '../utils.js';
import { getChatMessages, scrollToBottom } from './renderer.js';
/**
* 判断用户是否在聊天底部附近(阈值 150px),用于流式传输时决定是否自动滚动
*/
function _isNearBottom() {
const el = getChatMessages();
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < 150;
}
/**
* 当前活跃的 LLM 流式气泡状态
*/
@@ -108,7 +117,9 @@ function _appendLLMStreamChunk(text) {
llmStreamState.accumulated += text;
llmStreamState.textContent.textContent = llmStreamState.accumulated;
scrollToBottom();
if (_isNearBottom()) {
scrollToBottom();
}
}
/**
@@ -119,7 +130,9 @@ function _appendLLMStreamReasoning(text) {
llmStreamState.reasoningAccumulated += text;
llmStreamState.reasoningContent.textContent = llmStreamState.reasoningAccumulated;
scrollToBottom();
if (_isNearBottom()) {
scrollToBottom();
}
}
/**
@@ -239,19 +252,44 @@ function _buildExtractionSummary(data) {
const container = document.createElement('div');
container.className = 'extraction-summary';
// 全局状态
const statusSection = _summarySection('状态');
const submitOk = data.can_submit;
_summaryRow(statusSection, '可提交', submitOk ? '是' : '否');
if (data.suggestion) {
_summaryRow(statusSection, '建议', data.suggestion);
}
container.appendChild(statusSection);
// 判断类型:差旅 vs 普通
const isTravel = !!(data.basic_info?.travel_purpose || data.reimbursement_details?.transport_fee);
// 基本信息
if (data.basic_info) {
const bi = data.basic_info;
const section = _summarySection('基本信息');
_summaryRow(section, '出差事由', bi.travel_purpose || '');
_summaryRow(section, '出差地点', bi.travel_location || '');
const dates = bi.start_date && bi.end_date ? `${bi.start_date} 至 ${bi.end_date}` : (bi.start_date || bi.end_date || '');
_summaryRow(section, '出差日期', dates);
if (isTravel) {
_summaryRow(section, '出差事由', bi.travel_purpose || '');
_summaryRow(section, '出差地点', bi.travel_location || '');
const dates = bi.start_date && bi.end_date ? `${bi.start_date} 至 ${bi.end_date}` : (bi.start_date || bi.end_date || '');
_summaryRow(section, '出差日期', dates);
} else {
_summaryRow(section, '报销说明', bi.reimbursement_description || '');
}
container.appendChild(section);
}
// 交通费
if (data.reimbursement_details?.transport_fee?.length) {
// 报销汇总(普通模式)
if (!isTravel && data.reimbursement_details) {
const rd = data.reimbursement_details;
const section = _summarySection('报销汇总');
_summaryRow(section, '发票总份数', rd.total_invoices != null ? String(rd.total_invoices) : '');
_summaryRow(section, '总金额', rd.total_amount != null ? `¥${rd.total_amount.toFixed(2)}` : '');
container.appendChild(section);
}
// 交通费(差旅模式)
if (isTravel && data.reimbursement_details?.transport_fee?.length) {
const section = _summarySection('交通费');
const table = _summaryTable(['日期', '出发', '到达', '金额', '备注']);
for (const item of data.reimbursement_details.transport_fee) {
@@ -267,8 +305,8 @@ function _buildExtractionSummary(data) {
container.appendChild(section);
}
// 住宿费
if (data.reimbursement_details?.hotel_fee?.length) {
// 住宿费(差旅模式)
if (isTravel && data.reimbursement_details?.hotel_fee?.length) {
const section = _summarySection('住宿费');
const table = _summaryTable(['入住日期', '离店日期', '天数', '发票金额', '报销金额', '备注']);
for (const item of data.reimbursement_details.hotel_fee) {
@@ -285,8 +323,8 @@ function _buildExtractionSummary(data) {
container.appendChild(section);
}
// 会议/培训费
if (data.reimbursement_details?.conference_fee?.length) {
// 会议/培训费(差旅模式)
if (isTravel && data.reimbursement_details?.conference_fee?.length) {
const section = _summarySection('会议/培训费');
const table = _summaryTable(['日期', '金额', '备注']);
for (const item of data.reimbursement_details.conference_fee) {
@@ -316,8 +354,8 @@ function _buildExtractionSummary(data) {
container.appendChild(section);
}
// 补助
if (data.subsidy_list?.length) {
// 补助(差旅模式)
if (isTravel && data.subsidy_list?.length) {
const section = _summarySection('补助');
const table = _summaryTable(['姓名', '开始日期', '结束日期', '天数']);
for (const item of data.subsidy_list) {
+1 -1
View File
@@ -83,7 +83,7 @@ class TestExtractInvoices:
records, apps, groups = extract_invoices(str(tmp_path))
assert records == []
assert apps == []
assert groups == {"travel": [], "general": [], "application": []}
assert groups == {"travel": [], "general": [], "application": [], "attachments": []}
def test_extraction_fails(self, tmp_path: Path, monkeypatch):
pdf = tmp_path / "broken.pdf"
+148
View File
@@ -0,0 +1,148 @@
"""低值易耗品申购单模块单元测试
覆盖发票加载去重、规格推测、物品名称清洗。
"""
import json
from pathlib import Path
from typing import Any
from src.infra.documents.requisition import (
clean_item_name,
guess_spec,
load_project_desc,
load_requisition_items,
)
def _write_cache(tmp_path: Path, files: dict[str, dict[str, Any]]) -> None:
cache = tmp_path / ".invoice_cache"
cache.mkdir()
for name, extracted in files.items():
(cache / f"{name}.json").write_text(
json.dumps({"extracted_data": extracted}, ensure_ascii=False),
encoding="utf-8",
)
class TestCleanItemName:
def test_strips_category_prefix(self) -> None:
assert clean_item_name("*半导体*半导体分立器件") == "半导体分立器件"
def test_strips_nested_categories(self) -> None:
assert clean_item_name("*半导体*场效应管、*微电子组件*线性稳压器") == "场效应管、线性稳压器"
def test_removes_leading_punctuation(self) -> None:
assert clean_item_name("、*电子元件*电阻") == "电阻"
def test_keeps_plain_name(self) -> None:
assert clean_item_name("热熔胶棒") == "热熔胶棒"
class TestGuessSpec:
def test_uses_extracted_spec(self) -> None:
assert guess_spec("HYG032N08NS1P", "半导体分立器件") == "HYG032N08NS1P"
def test_falls_back_to_multiple(self) -> None:
assert guess_spec("", "热熔胶棒") == "多品种"
def test_ignores_generic_spec(self) -> None:
assert guess_spec("集成电路", "集成电路") == "多品种"
def test_extracts_package_from_name(self) -> None:
assert guess_spec("", "原装正品 EG3013S 封装 SOP-8 驱动芯片") == "SOP-8"
def test_extracts_stm32_from_name(self) -> None:
assert guess_spec("", "STM32F103C8T6开发板") == "STM32F103C8T6"
def test_extracts_tool_from_name(self) -> None:
assert guess_spec("", "IRF5305PBF TO-220 60V 场效应管") == "TO-220"
class TestLoadRequisitionItems:
def test_filters_by_invoice_type(self, tmp_path: Path) -> None:
_write_cache(
tmp_path,
{
"invoice1": {
"invoice_type": "general",
"invoice_number": "001",
"item_name": "*电子元件*电阻",
"total_amount": "10.00",
},
"invoice2": {"invoice_type": "payment", "item_name": "", "total_amount": ""},
"note": {"invoice_type": "note", "item_name": "", "total_amount": ""},
},
)
items = load_requisition_items(tmp_path / ".invoice_cache")
assert len(items) == 1
assert items[0]["name"] == "电阻"
def test_deduplicates_by_invoice_number(self, tmp_path: Path) -> None:
common = {
"invoice_type": "general",
"invoice_number": "999",
"item_name": "*电子元件*电阻",
"total_amount": "10.00",
}
_write_cache(tmp_path, {"a": common, "b": dict(common, item_name="*电子元件*电容")})
items = load_requisition_items(tmp_path / ".invoice_cache")
assert len(items) == 1
def test_exclude_keywords(self, tmp_path: Path) -> None:
_write_cache(
tmp_path,
{
"a": {
"invoice_type": "general",
"invoice_number": "001",
"item_name": "*小型起重设备*千斤顶",
"total_amount": "63.45",
},
"b": {
"invoice_type": "general",
"invoice_number": "002",
"item_name": "*电子元件*电阻",
"total_amount": "10.00",
},
},
)
items = load_requisition_items(tmp_path / ".invoice_cache", exclude_keywords={"千斤顶"})
assert len(items) == 1
assert items[0]["name"] == "电阻"
def test_skips_control_json(self, tmp_path: Path) -> None:
_write_cache(
tmp_path,
{
"invoice1": {
"invoice_type": "general",
"invoice_number": "001",
"item_name": "*电子元件*电阻",
"total_amount": "1.00",
}
},
)
(tmp_path / ".invoice_cache" / "match_result.json").write_text("{}", encoding="utf-8")
(tmp_path / ".invoice_cache" / "normal_info.json").write_text("{}", encoding="utf-8")
items = load_requisition_items(tmp_path / ".invoice_cache")
assert len(items) == 1
class TestLoadProjectDesc:
def test_reads_from_agent_state(self, tmp_path: Path) -> None:
(tmp_path / "agent_state.json").write_text(
json.dumps(
{"extracted_info": {"basic_info": {"reimbursement_description": "电子元件及集成电路采购"}}},
ensure_ascii=False,
),
encoding="utf-8",
)
assert load_project_desc(tmp_path) == "电子元件及集成电路采购"
def test_falls_back_when_missing(self, tmp_path: Path) -> None:
assert load_project_desc(tmp_path) == "电子元器件及工具耗材采购"
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) == "电子元器件及工具耗材采购"