完成差旅发票录入流程

This commit is contained in:
wandering
2026-06-11 19:22:34 +08:00
parent a3d77307aa
commit 3bd325e827
46 changed files with 3033 additions and 1199 deletions
+33 -82
View File
@@ -38,13 +38,32 @@ from src.doc.fill_consumable_doc import ( # noqa: E402, I001
fill_consumable_from_template,
)
from src.doc.invoice import ( # noqa: E402, I001
classify_invoice_batch,
load_csv,
load_invoice_csv,
save_csv as save_payment_csv,
save_invoice_csv,
save_application_json,
)
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
@@ -161,7 +180,7 @@ def _has_general_invoices(rows: list[dict[str, str]]) -> bool:
invoices.append(row)
if not invoices:
return False
groups = classify_invoice_batch(invoices)
groups = _classify_invoice_batch(invoices)
return len(groups["general"]) > 0
@@ -179,7 +198,7 @@ def _get_invoice_groups(rows: list[dict[str, str]]) -> dict[str, int]:
# 发票级别格式:直接使用
elif "发票类型" in row:
invoices.append(row)
groups = classify_invoice_batch(invoices)
groups = _classify_invoice_batch(invoices)
return {
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
@@ -250,14 +269,18 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
start = time.time()
# ---- Step 1: 发票提取 ----
invoices, groups = extract_invoices(str(session_dir))
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
# 保存两个 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
# 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
# 出差申请单单独保存
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
@@ -276,50 +299,6 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
return result
def run_csv_pipeline_web(session_dir: Path, config: dict[str, Any], csv_filename: str) -> dict[str, Any]:
"""直接使用上传的 CSV 文件,跳过 PDF 提取"""
start = time.time()
csv_path = session_dir / csv_filename
if not csv_path.exists():
return {"ok": False, "error": "CSV 文件不存在"}
# 尝试读取支付记录格式
rows = load_csv(csv_path)
if rows is None:
# 尝试读取发票级别格式
invoice_rows = load_invoice_csv(csv_path)
if invoice_rows is None:
return {"ok": False, "error": "CSV 读取失败"}
# 发票级别格式:直接统计
type_stats = _get_invoice_groups(invoice_rows)
elapsed = time.time() - start
return {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": len(invoice_rows),
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
"travel_count": type_stats["travel_count"],
"general_count": type_stats["general_count"],
}
# 支付记录格式:统计发票类型
type_stats = _get_invoice_groups(rows)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": type_stats["travel_count"] + type_stats["general_count"],
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
"travel_count": type_stats["travel_count"],
"general_count": type_stats["general_count"],
}
doc_fill = _try_fill_consumable_doc(session_dir, config)
_append_doc_download(result, session_dir.name, doc_fill)
return result
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
@@ -331,9 +310,10 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str,
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from src.bot import load_invoice_data, run_bot_web
from src.bot import run_bot_web
bot_invoices = load_invoice_data(str(csv_path), config)
# TODO: 改为从缓存读取或直接请求 LLM,与差旅报销保持一致
# bot_invoices = load_invoice_data(str(csv_path), config)
# 判断发票类型
rows = load_csv(csv_path)
@@ -345,7 +325,7 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str,
else:
fill_log.info("检测到普通发票,使用普通报销模式")
run_bot_web(config, bot_invoices, session_dir)
run_bot_web(config, session_dir)
return {"ok": True}
@@ -384,22 +364,6 @@ def upload_file(session_id: str) -> Any:
return jsonify({"ok": True, "filename": safe_name})
@app.route("/api/upload-csv/<session_id>", methods=["POST"])
def upload_csv(session_id: str) -> Any:
"""上传 CSV 发票数据文件(跳过 PDF 提取)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
safe_name = Path(f.filename).name
f.save(str(session_dir / safe_name))
return jsonify({"ok": True, "filename": safe_name})
@app.route("/api/files/<session_id>", methods=["GET"])
def list_files(session_id: str) -> Any:
"""列出会话目录中的文件"""
@@ -420,7 +384,6 @@ def start_process(session_id: str) -> Any:
return session_dir
body = request.get_json(silent=True) or {}
mode = body.get("mode", "auto") # "pdf", "csv", or "auto"
# 读取配置
config = _build_web_config(body)
@@ -435,19 +398,7 @@ def start_process(session_id: str) -> Any:
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
if mode == "csv":
csv_files = list(session_dir.glob("*.csv"))
if not csv_files:
result = {"ok": False, "error": "未找到 CSV 文件"}
else:
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name)
else:
csv_files = list(session_dir.glob("*.csv"))
pdf_files = list(session_dir.glob("*.pdf"))
if csv_files and not pdf_files:
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name)
else:
result = run_pipeline_web(session_dir, config)
result = run_pipeline_web(session_dir, config)
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):