完成差旅发票录入流程

This commit is contained in:
wandering
2026-06-11 19:22:34 +08:00
parent cf567c22f2
commit 10115214aa
50 changed files with 3033 additions and 2756 deletions

View File

@@ -11,7 +11,6 @@ last_reviewed: 2026-06-09
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5不引入构建工具保持单页应用轻量可维护。
- **双模式支持**PDF 发票提取模式和 CSV 快捷上传模式,后者跳过 LLM 识别和 PDF 解析,直接处理已有发票数据。
## 文件结构
@@ -51,7 +50,6 @@ src/web/
| GET | `/` | 主界面 |
| POST | `/api/session` | 创建会话,返回 session_id |
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
| POST | `/api/upload-csv/<sid>` | 上传 CSV 发票数据 |
| GET | `/api/files/<sid>` | 列出会话文件 |
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
| GET | `/api/logs/<sid>` | SSE 日志流 |

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):

18
src/web/static/README.md Normal file
View File

@@ -0,0 +1,18 @@
---
last_reviewed: 2026-06-11
---
# src/web/static — 静态资源目录
存放 Web 界面的 CSS 样式表和 JavaScript 前端逻辑。
## 文件结构
| 路径 | 说明 |
|------|------|
| `css/index.css` | 全局样式:上传区域、日志面板、可编辑表格、状态徽章 |
| `js/index.js` | 前端逻辑文件上传、SSE 日志监听、发票数据编辑、配置同步、移动端扫码上传、财务提交 |
## 技术栈
原生 JavaScript + Bootstrap 5无构建工具保持单页应用轻量可维护。

View File

@@ -1,6 +1,5 @@
let sessionId = null;
const pdfFiles = [], imgFiles = [];
let csvFile = null;
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
let csvFilename = ''; // 当前 CSV 文件名
let lastDownloadUrls = {}; // 最近一次可下载文件链接
@@ -50,23 +49,6 @@ function renderFileList(type) {
).join('');
}
// ---- CSV 上传 ----
function handleCsvFile(input) {
const file = input.files[0];
if (!file) return;
csvFile = file;
document.getElementById('csv-zone').classList.add('active');
document.getElementById('csv-list').innerHTML =
`<span class="file-tag">${file.name}<span class="remove" onclick="event.stopPropagation();removeCsvFile()">&times;</span></span>`;
input.value = '';
}
function removeCsvFile() {
csvFile = null;
document.getElementById('csv-zone').classList.remove('active');
document.getElementById('csv-list').innerHTML = '';
}
// ---- 配置上传 ----
function handleConfigUpload(input) {
const file = input.files[0];
@@ -122,22 +104,10 @@ function handleConfigUpload(input) {
});
});
// CSV 拖拽
const csvZone = document.getElementById('csv-zone');
csvZone.addEventListener('dragover', e => { e.preventDefault(); csvZone.classList.add('dragover'); });
csvZone.addEventListener('dragleave', () => csvZone.classList.remove('dragover'));
csvZone.addEventListener('drop', e => {
e.preventDefault();
csvZone.classList.remove('dragover');
const file = Array.from(e.dataTransfer.files).find(f => f.name.toLowerCase().endsWith('.csv'));
if (file) handleCsvFile({ files: [file] });
});
// ---- 处理 ----
async function startProcess() {
const isCsvMode = !!csvFile;
if (!isCsvMode && !pdfFiles.length && !imgFiles.length) {
alert('请先上传文件或 CSV');
if (!pdfFiles.length && !imgFiles.length) {
alert('请先上传文件或图片');
return;
}
@@ -152,17 +122,11 @@ async function startProcess() {
try {
await ensureSession();
if (isCsvMode) {
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
for (const {f} of allFiles) {
const fd = new FormData();
fd.append('file', csvFile);
await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd });
} else {
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
for (const {f} of allFiles) {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
}
fd.append('file', f);
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
}
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
@@ -174,7 +138,6 @@ async function startProcess() {
default_card_no: document.getElementById('cfg-card').value,
default_person_id: document.getElementById('cfg-person-id').value,
consumable_storage: document.getElementById('cfg-storage').value,
mode: isCsvMode ? 'csv' : 'auto',
};
await fetch(`/api/process/${sessionId}`, {
@@ -248,8 +211,13 @@ function showDownloadLinks(result) {
).join('');
if (warn) {
if (result.doc_ok === false && result.doc_error) {
if (result.doc_skipped) {
warn.style.display = 'block';
warn.style.color = '#0d6efd';
warn.textContent = result.doc_message || '差旅报销无需生成易耗品出库单';
} else if (result.doc_ok === false && result.doc_error) {
warn.style.display = 'block';
warn.style.color = '';
warn.textContent = '出库单未生成:' + result.doc_error;
} else {
warn.style.display = 'none';
@@ -257,7 +225,7 @@ function showDownloadLinks(result) {
}
}
section.style.display = items.length || (result.doc_ok === false) ? 'block' : 'none';
section.style.display = items.length || (result.doc_ok === false) || result.doc_skipped ? 'block' : 'none';
}
// ---- 发票数据编辑 ----

View File

@@ -0,0 +1,14 @@
---
last_reviewed: 2026-06-11
---
# src/web/templates — HTML 模板目录
存放 Flask 渲染的 HTML 模板文件。
## 模板清单
| 文件 | 说明 |
|------|------|
| `index.html` | PC 端主界面包含文件上传区、配置表单、处理按钮、SSE 日志面板、可编辑发票表格、下载链接、财务提交按钮、移动端二维码 |
| `mobile_upload.html` | 移动端上传页面:支持拍照/相册选择,上传至当前会话 |

View File

@@ -43,17 +43,6 @@
</div>
</div>
<!-- CSV 快捷上传 -->
<div class="mb-4">
<div class="section-title">📊 CSV 快捷上传 <span class="text-muted fw-normal" style="font-size:12px">(已有发票数据 CSV 可直接上传,跳过提取和 LLM 识别)</span></div>
<div class="upload-zone" id="csv-zone" onclick="document.getElementById('csv-input').click()">
<div class="icon">📊</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传 CSV 文件</div>
<div id="csv-list" class="mt-2"></div>
</div>
<input type="file" id="csv-input" accept=".csv" hidden onchange="handleCsvFile(this)">
</div>
<!-- 配置表单 -->
<div class="card mb-4">
<div class="card-body">
@@ -111,7 +100,7 @@
<div class="card mb-4" id="edit-section" style="display:none">
<div class="card-body">
<div class="section-title d-flex justify-content-between align-items-center">
<span>📝 发票数据(可编辑)</span>
<span>📝 付款记录(可编辑)</span>
<div class="d-flex justify-content-end align-items-center">
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
</div>