完成差旅发票录入流程
This commit is contained in:
@@ -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 日志流 |
|
||||
|
||||
115
src/web/app.py
115
src/web/app.py
@@ -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
18
src/web/static/README.md
Normal 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,无构建工具,保持单页应用轻量可维护。
|
||||
@@ -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()">×</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';
|
||||
}
|
||||
|
||||
// ---- 发票数据编辑 ----
|
||||
|
||||
14
src/web/templates/README.md
Normal file
14
src/web/templates/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# src/web/templates — HTML 模板目录
|
||||
|
||||
存放 Flask 渲染的 HTML 模板文件。
|
||||
|
||||
## 模板清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `index.html` | PC 端主界面:包含文件上传区、配置表单、处理按钮、SSE 日志面板、可编辑发票表格、下载链接、财务提交按钮、移动端二维码 |
|
||||
| `mobile_upload.html` | 移动端上传页面:支持拍照/相册选择,上传至当前会话 |
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user