日常报销和差旅报销都可以走通

This commit is contained in:
wandering
2026-06-12 12:06:06 +08:00
parent 10115214aa
commit 6d66a27aab
22 changed files with 1342 additions and 810 deletions

View File

@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-09
last_reviewed: 2026-06-11
---
# src/web 模块设计说明
@@ -29,18 +29,32 @@ src/web/
## 数据流
```
用户上传文件 → 创建 session → 文件写入 uploads/<sid>/
后台线程执行管道: extract_invoices() → enrich_with_llm() → save_csv()
结果写入 session 目录: invoice_summary.csv / result.json / session.log
前端 SSE 轮询 result.json 变化 → 显示完成状态
前端加载 CSV 数据 → 可编辑表格展示 → 用户修改后保存
用户点击提交 → run_financial_submit() → bot 自动填报财务系统
```mermaid
graph TD
A[用户上传文件] --> B[创建 session]
B --> C[文件写入 uploads/<sid>/]
C --> D[后台线程执行管道]
D --> E["extract_invoices()"]
E --> F["enrich_with_llm()"]
F --> G["save_csv()"]
G --> H["结果写入 session 目录"]
H --> I["invoice_summary.csv"]
H --> J["invoice_groups.json"]
H --> K["result.json"]
H --> L["session.log"]
E --> M{"判断报销类型"}
M -->|差旅| N["extract_travel_info()"]
M -->|普通| O["extract_normal_info()"]
N --> P["travel_info.json"]
O --> Q["normal_info.json"]
K --> R["前端 SSE 轮询 → 显示完成状态"]
R --> S["前端加载 CSV → 可编辑表格"]
S --> T["用户修改后保存"]
T --> U["用户点击提交"]
U --> V["run_financial_submit()"]
V --> W["bot 自动填报财务系统"]
W --> X["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
W --> Y["普通模式: normal_info.json → 基本信息 → 录入明细 → 支付信息 → 上传附件"]
```
## API 路由
@@ -70,7 +84,16 @@ src/web/
- **差旅发票**(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- **普通发票**生成易耗品出库单Word 文档),走普通报销流程
`classify_invoice_batch()` 根据发票内容自动分类,`_try_fill_consumable_doc()` 仅对普通发票生成出库单
`extract_invoices()` 在提取阶段完成分类,结果保存为 `invoice_groups.json`(包含 `travel_count``general_count``application_count`)。后续步骤(出库单生成、财务填报)统一从该文件读取分类结果,避免重复解析 CSV 和 JSON 字段
### LLM 信息提取2026-06-11
发票提取和匹配完成后,根据类型分流调用 LLM 提取结构化报销信息:
- **差旅发票**:调用 `extract_travel_info()`,提取出差事由、地点、时间、交通/住宿明细、补助清单、支付方式、附件清单,缓存为 `travel_info.json`
- **普通发票**:调用 `extract_normal_info()`,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json`
Bot 填报时优先使用 LLM 提取的信息(`travel_info`/`normal_info`),降级时从缓存加载原始发票数据。
### 移动端同步

View File

@@ -19,7 +19,7 @@ import threading
import time
import uuid
from pathlib import Path
from typing import Any
from typing import Any, cast
from urllib.parse import quote
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
@@ -46,24 +46,6 @@ from src.doc.invoice import ( # noqa: E402, I001
)
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("invoice_type", "general")
if inv_type == "application":
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general, "application": application}
fill_log = get_logger("fill_consumable_doc")
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
@@ -72,11 +54,32 @@ app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
SESSION_LOG_FILE = "session.log"
SESSION_RESULT_FILE = "result.json"
INVOICE_GROUPS_FILE = "invoice_groups.json"
# ================================================================
# 日志收集器 — 捕获管道日志到文件SSE 端点通过 tail -f 读取
# ================================================================
def _save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
"""保存发票分类结果到 session 目录的 JSON 文件"""
groups_path = session_dir / INVOICE_GROUPS_FILE
# 只保存各组的数量统计,避免重复存储完整发票数据
data = {
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
}
with open(groups_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _load_invoice_groups(session_dir: Path) -> dict[str, int] | None:
"""从 session 目录加载发票分类统计"""
groups_path = session_dir / INVOICE_GROUPS_FILE
if not groups_path.exists():
return None
try:
with open(groups_path, encoding="utf-8") as f:
return cast(dict[str, int] | None, json.load(f))
except Exception:
return None
class _SSELogHandler(logging.Handler):
@@ -164,69 +167,33 @@ def _resolve_invoice_csv(session_dir: Path) -> Path | None:
return None
def _has_general_invoices(rows: list[dict[str, str]]) -> bool:
"""检查发票列表中是否包含普通发票(需要生成易耗品出库单)"""
invoices = []
for row in rows:
# 支付记录格式:从 _invoices_json 还原
invoices_json = row.get("_invoices_json", "")
if invoices_json:
try:
invoices.extend(json.loads(invoices_json))
except json.JSONDecodeError:
pass
# 发票级别格式:直接使用
elif "发票类型" in row:
invoices.append(row)
if not invoices:
return False
groups = _classify_invoice_batch(invoices)
return len(groups["general"]) > 0
def _get_invoice_groups(rows: list[dict[str, str]]) -> dict[str, int]:
"""统计发票类型分布"""
invoices = []
for row in rows:
# 支付记录格式:从 _invoices_json 还原
invoices_json = row.get("_invoices_json", "")
if invoices_json:
try:
invoices.extend(json.loads(invoices_json))
except json.JSONDecodeError:
pass
# 发票级别格式:直接使用
elif "发票类型" in row:
invoices.append(row)
groups = _classify_invoice_batch(invoices)
return {
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
}
# ================================================================
# 出库单填写
# ================================================================
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""根据 CSV 填写易耗品出库单,供会话目录下载。
仅当存在普通发票时才生成出库单。纯差旅发票跳过。
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
"""
if not CONSUMABLE_TEMPLATE.exists():
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
# 从统一的分类结果读取,避免重复解析 CSV/JSON
groups = _load_invoice_groups(session_dir)
if groups is None:
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
if not groups.get("general_count", 0):
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
csv_path = _resolve_payment_csv(session_dir)
if csv_path is None:
return {"ok": False, "error": "未找到发票 CSV"}
# 检查是否有普通发票
rows = load_csv(csv_path)
if rows is None:
return {"ok": False, "error": "CSV 读取失败"}
if not _has_general_invoices(rows):
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
try:
fill_log.info("开始填写出库单: %s", out_doc.name)
@@ -265,6 +232,10 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
在发票提取和匹配完成后立即判断报销类型:
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
- 普通发票无需额外提取normal_info.json 待实现)
"""
start = time.time()
@@ -281,6 +252,38 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
# 保存分类结果(供后续步骤统一读取)
_save_invoice_groups(session_dir, groups)
# ---- Step 2: 差旅/普通信息提取 ----
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
if is_travel:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache
# 检查缓存是否已有
cache_map = load_cache(session_dir)
if not cache_map.get("travel_info"):
fill_log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
json.dump(travel_info, f, ensure_ascii=False, indent=2)
fill_log.info("差旅信息已保存到缓存")
else:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache
# 检查缓存是否已有
cache_map = load_cache(session_dir)
if not cache_map.get("normal_info"):
fill_log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
json.dump(normal_info, f, ensure_ascii=False, indent=2)
fill_log.info("普通发票信息已保存到缓存")
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
@@ -302,7 +305,7 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
根据发票类型选择填报模式:
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
- 纯差旅发票差旅报销模式TODO
- 含普通发票:普通报销模式
"""
@@ -312,14 +315,10 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str,
from src.bot import run_bot_web
# TODO: 改为从缓存读取或直接请求 LLM与差旅报销保持一致
# bot_invoices = load_invoice_data(str(csv_path), config)
# 判断发票类型
rows = load_csv(csv_path)
if rows:
invoice_groups = _get_invoice_groups(rows)
if invoice_groups["travel_count"] and not invoice_groups["general_count"]:
# 从统一的分类结果读取,避免重复解析
groups = _load_invoice_groups(session_dir)
if groups:
if groups.get("travel_count", 0) and not groups.get("general_count", 0):
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
# TODO: 差旅报销填报流程
else: