增加生成学校易耗品填写的转换程序
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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": "安徽开放大学专业技术人员在线培训学习视频"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 空值处理规则
|
||||
|
||||
@@ -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` 格式
|
||||
- 金额使用数字类型(非字符串)
|
||||
- 计数使用整数类型
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = {}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) == "电子元器件及工具耗材采购"
|
||||
Reference in New Issue
Block a user