feat: Web 表格编辑、易耗品出库单自动生成与文档完善

- 新增 fill_consumable_doc,根据 CSV 填写 Word 出库单(宋体五号)
- Web 处理完成后自动生成出库单并提供下载
- 前端拆分为 static 资源,支持在线编辑 CSV 与分步提交财务系统
- 补充 API.md、README(含 Mermaid 数据流)及 config.example.json

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wandering
2026-05-26 14:17:26 +08:00
parent 70f61be3aa
commit a72c4ffaab
12 changed files with 1674 additions and 442 deletions

View File

@@ -16,6 +16,7 @@ import threading
import time
import uuid
from pathlib import Path
from urllib.parse import quote
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
@@ -25,8 +26,16 @@ sys.path.insert(0, str(PROJECT_ROOT))
from app.config import load_config as load_project_config
from app.extractor import extract_invoices, save_csv, save_markdown
from app.fill_consumable_doc import (
CONSUMABLE_DOC_FILENAME,
fill_consumable_from_template,
)
from app import get_logger
from app.ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv as load_ocr_csv
fill_log = get_logger("fill_consumable_doc")
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "web" / "uploads"
@@ -73,7 +82,7 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
handler.setFormatter(fmt)
handler.setLevel(logging.INFO)
for name in ["extractor", "ocr", "pipeline", "bot"]:
for name in ["extractor", "ocr", "pipeline", "bot", "fill_consumable_doc"]:
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.addHandler(handler)
@@ -82,21 +91,79 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
def _remove_log_collector(handler: _SSELogHandler):
for name in ["extractor", "ocr", "pipeline", "bot"]:
for name in ["extractor", "ocr", "pipeline", "bot", "fill_consumable_doc"]:
logging.getLogger(name).removeHandler(handler)
handler.close_file()
# ================================================================
# 管道入口
# 出库单填写
# ================================================================
def _load_session_config(session_dir: Path) -> dict:
config = load_project_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
config.update(json.load(f))
return config
def _resolve_invoice_csv(session_dir: Path) -> Path | None:
csv_path = session_dir / "invoice_summary.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
return f
return None
def _try_fill_consumable_doc(session_dir: Path, config: dict) -> dict:
"""根据 CSV 填写易耗品出库单,供会话目录下载。"""
if not CONSUMABLE_TEMPLATE.exists():
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
csv_path = _resolve_invoice_csv(session_dir)
if csv_path is None:
return {"ok": False, "error": "未找到发票 CSV"}
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
try:
fill_log.info("开始填写出库单: %s", out_doc.name)
fill_consumable_from_template(
csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config
)
fill_log.info("出库单填写完成")
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
except ImportError:
fill_log.error("填写出库单需要 pywin32请执行: pip install pywin32")
return {"ok": False, "error": "服务器未安装 pywin32无法生成 Word 出库单"}
except Exception as e:
fill_log.exception("填写出库单失败: %s", e)
return {"ok": False, "error": str(e)}
def _append_doc_download(result: dict, session_id: str, doc_fill: dict) -> None:
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
result["doc_ok"] = True
else:
result["doc_ok"] = False
result["doc_error"] = doc_fill.get("error", "未知错误")
# ================================================================
# 管道入口
# ================================================================
def run_pipeline_web(session_dir: Path, config: dict, run_bot: bool = False):
"""在 Web 会话目录中执行管道,结果写入 session 目录下的文件"""
def run_pipeline_web(session_dir: Path, config: dict):
"""在 Web 会话目录中执行提取+OCR,结果写入 session 目录下的文件
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
"""
start = time.time()
# ---- Step 1: 发票提取 ----
@@ -117,25 +184,21 @@ def run_pipeline_web(session_dir: Path, config: dict, run_bot: bool = False):
save_ocr_csv(csv_path, rows)
save_markdown_from_csv(csv_path, rows)
# ---- Step 3: 浏览器填报(可选)----
if run_bot:
from app.bot import load_invoice_data, run_bot_web
bot_invoices = load_invoice_data(str(csv_path), config)
run_bot_web(config, bot_invoices, session_dir)
elapsed = time.time() - start
return {
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": len(rows),
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
"md_url": f"/api/download/{session_dir.name}/invoice_summary.md",
}
doc_fill = _try_fill_consumable_doc(session_dir, config)
_append_doc_download(result, session_dir.name, doc_fill)
return result
def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str, run_bot: bool = False):
"""直接使用上传的 CSV 文件进行填报,跳过 PDF 提取和 OCR"""
def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str):
"""直接使用上传的 CSV 文件,跳过 PDF 提取和 OCR"""
start = time.time()
csv_path = session_dir / csv_filename
@@ -147,20 +210,29 @@ def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str, run
if rows is None:
return {"ok": False, "error": "CSV 读取失败"}
# 浏览器填报
if run_bot:
from app.bot import load_invoice_data, run_bot_web
bot_invoices = load_invoice_data(str(csv_path), config)
run_bot_web(config, bot_invoices, session_dir)
elapsed = time.time() - start
return {
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": len(rows),
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
}
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) -> dict:
"""执行财务系统填报(从前端确认后调用)"""
csv_path = session_dir / "invoice_summary.csv"
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from app.bot import load_invoice_data, run_bot_web
bot_invoices = load_invoice_data(str(csv_path), config)
run_bot_web(config, bot_invoices, session_dir)
return {"ok": True}
# ================================================================
@@ -230,13 +302,12 @@ def list_files(session_id: str):
@app.route("/api/process/<session_id>", methods=["POST"])
def start_process(session_id: str):
"""启动管道处理"""
"""启动管道处理(仅提取+OCR不自动提交财务系统"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
run_bot_flag = body.get("submit", False)
mode = body.get("mode", "auto") # "pdf", "csv", or "auto"
# 读取配置
@@ -253,27 +324,24 @@ def start_process(session_id: str):
result = {"ok": False, "error": "未知错误"}
try:
if mode == "csv":
# CSV 模式:直接使用上传的 CSV跳过 PDF 提取和 OCR
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, run_bot_flag)
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name)
else:
# 自动检测:如果有 CSV 则走 csv 管道,否则走 pdf 管道
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, run_bot_flag)
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name)
else:
result = run_pipeline_web(session_dir, config, run_bot_flag)
result = run_pipeline_web(session_dir, config)
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, (KeyboardInterrupt, SystemExit)):
raise
finally:
try:
# 原子写入:先写临时文件,再重命名,避免 SSE 读到截断的空文件
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
@@ -337,11 +405,156 @@ def download_file(session_id: str, filename: str):
if isinstance(session_dir, tuple):
return session_dir
filepath = session_dir / filename
# 防止路径穿越
safe_name = Path(filename).name
filepath = session_dir / safe_name
if not filepath.exists():
return jsonify({"error": "文件不存在"}), 404
return Response(filepath.read_bytes(), mimetype="application/octet-stream")
if safe_name.endswith(".doc"):
mimetype = "application/msword"
elif safe_name.endswith(".csv"):
mimetype = "text/csv; charset=utf-8"
elif safe_name.endswith(".md"):
mimetype = "text/markdown; charset=utf-8"
else:
mimetype = "application/octet-stream"
disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}"
return Response(
filepath.read_bytes(),
mimetype=mimetype,
headers={"Content-Disposition": disposition},
)
@app.route("/api/data/<session_id>", methods=["GET"])
def get_invoice_data(session_id: str):
"""读取发票数据并返回 JSON供前端表格编辑"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
csv_path = session_dir / "invoice_summary.csv"
if not csv_path.exists():
# CSV 模式下可能是其他文件名
csv_files = list(session_dir.glob("*.csv"))
csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
if csv_files:
csv_path = csv_files[0]
else:
return jsonify({"error": "未找到发票数据,请先处理"}), 404
rows = load_ocr_csv(csv_path)
if rows is None:
return jsonify({"error": "CSV 读取失败"}), 500
# 添加行号用于编辑追踪
data = []
for i, row in enumerate(rows):
entry = dict(row)
entry["__row"] = i
data.append(entry)
# 返回原始字段顺序(去掉内部字段)
fields = [k for k in rows[0].keys() if not k.startswith('__')] if rows else []
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": data})
@app.route("/api/save/<session_id>", methods=["POST"])
def save_invoice_data(session_id: str):
"""保存前端编辑后的发票数据到 CSV"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
data = body.get("data", [])
csv_filename = body.get("csv_filename", "invoice_summary.csv")
csv_path = session_dir / csv_filename
if not csv_path.exists():
return jsonify({"error": "CSV 文件不存在"}), 404
# 读取原 CSV 获取字段顺序(使用第一个数据的 keys
original_rows = load_ocr_csv(csv_path)
if original_rows is None or len(original_rows) == 0:
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
# 从原数据中获取字段顺序(去掉内部字段)
fieldnames = list(original_rows[0].keys())
import csv as csv_module
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for entry in data:
row = {k: entry.get(k, "") for k in fieldnames}
writer.writerow(row)
resp = {"ok": True}
config = _load_session_config(session_dir)
doc_fill = _try_fill_consumable_doc(session_dir, config)
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
resp["doc_ok"] = True
else:
resp["doc_ok"] = False
resp["doc_error"] = doc_fill.get("error")
return jsonify(resp)
@app.route("/api/submit-financial/<session_id>", methods=["POST"])
def submit_financial(session_id: str):
"""手动触发财务系统填报"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 读取配置
config_path = session_dir / "config.json"
if not config_path.exists():
return jsonify({"error": "未找到配置,请先配置后处理"}), 400
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
# 清除上次处理留下的结果文件,避免 SSE 误判为已完成
result_file = session_dir / SESSION_RESULT_FILE
if result_file.exists():
result_file.unlink()
# 在后台线程执行提交
handler = _install_log_collector(session_dir)
def _run():
result = {"ok": False, "error": "未知错误"}
try:
submit_result = run_financial_submit(session_dir, config)
if submit_result.get("ok"):
result = {"ok": True}
else:
result = submit_result
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, (KeyboardInterrupt, SystemExit)):
raise
finally:
try:
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump({"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")}, f, ensure_ascii=False)
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
except Exception:
pass
_remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
# ================================================================
@@ -358,7 +571,14 @@ def _validate_session(session_id: str):
def _build_web_config(body: dict) -> dict:
"""从请求体构建配置"""
config = load_project_config()
for key in ("username", "password", "default_name", "default_card_no", "default_person_id"):
for key in (
"username",
"password",
"default_name",
"default_card_no",
"default_person_id",
"consumable_storage",
):
if body.get(key):
config[key] = body[key]
return config

24
web/static/css/index.css Normal file
View File

@@ -0,0 +1,24 @@
body { background: #f5f7fa; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
.upload-zone {
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
}
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.log-container .empty { color: #666; font-style: italic; }
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
.btn-process { font-size: 17px; padding: 10px 40px; }
.status-badge { font-size: 13px; }
/* 可编辑表格 */
.table-editable { font-size: 13px; }
.table-editable th { position: sticky; top: 0; background: #f8f9fa; z-index: 1; font-weight: 600; white-space: nowrap; }
.table-editable td { vertical-align: middle; }
.table-editable input.form-control { font-size: 13px; padding: 4px 8px; min-width: 80px; }
.table-wrapper { max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 8px; }
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }

517
web/static/js/index.js Normal file
View File

@@ -0,0 +1,517 @@
let sessionId = null;
const pdfFiles = [], imgFiles = [];
let csvFile = null;
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
let csvFilename = ''; // 当前 CSV 文件名
let lastDownloadUrls = {}; // 最近一次可下载文件链接
// ---- Session ----
async function ensureSession() {
if (sessionId) return sessionId;
const r = await fetch('/api/session', { method: 'POST' });
const d = await r.json();
sessionId = d.session_id;
return sessionId;
}
// ---- 上传 ----
function handleFiles(input, type) {
const files = Array.from(input.files);
const list = type === 'pdf' ? pdfFiles : imgFiles;
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
files.forEach(f => {
if (!list.find(x => x.name === f.name)) {
f.__source = 'local'; // 标记为本地手动选择
list.push(f);
}
});
renderFileList(type);
document.getElementById(zoneId).classList.add('active');
input.value = '';
}
function removeFile(type, index) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
list.splice(index, 1);
renderFileList(type);
if (list.length === 0) {
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
}
}
function renderFileList(type) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
box.innerHTML = list.map((f, i) =>
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">&times;</span></span>`
).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];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const cfg = JSON.parse(e.target.result);
const map = {
'cfg-username': cfg.username,
'cfg-password': cfg.password,
'cfg-name': cfg.default_name,
'cfg-card': cfg.default_card_no,
'cfg-person-id': cfg.default_person_id,
'cfg-storage': cfg.consumable_storage,
};
for (const [id, val] of Object.entries(map)) {
if (val) document.getElementById(id).value = val;
}
// 同步 config.json 中的工号和公务卡号到表格
if (cfg.username) syncConfigToTable('工号');
if (cfg.default_card_no) syncConfigToTable('公务卡号');
alert('配置已加载');
} catch (err) {
alert('config.json 解析失败: ' + err.message);
}
};
reader.readAsText(file);
input.value = '';
}
// ---- 拖拽 ----
['pdf','img'].forEach(type => {
const zone = document.getElementById(type + '-zone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files).filter(f => {
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
});
if (files.length) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
files.forEach(f => {
f.__source = 'local'; // 标记为本地拖拽
if (!list.find(x => x.name === f.name)) list.push(f);
});
renderFileList(type);
zone.classList.add('active');
}
});
});
// 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');
return;
}
const btn = document.getElementById('btn-start');
btn.disabled = true;
btn.textContent = '处理中...';
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
document.getElementById('log-box').innerHTML = '';
document.getElementById('edit-section').style.display = 'none';
document.getElementById('download-section').style.display = 'none';
try {
await ensureSession();
if (isCsvMode) {
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 });
}
}
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
const cfg = {
username: document.getElementById('cfg-username').value,
password: document.getElementById('cfg-password').value,
default_name: document.getElementById('cfg-name').value,
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}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
// 监听 SSE 日志
const es = new EventSource(`/api/logs/${sessionId}`);
const logBox = document.getElementById('log-box');
let firstLine = true;
es.addEventListener('message', e => {
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
const result = msg.result;
document.getElementById('status').innerHTML = result.ok
? '<span class="badge bg-success status-badge">完成</span>'
: '<span class="badge bg-danger status-badge">失败</span>';
btn.disabled = false;
btn.textContent = '开始处理';
if (result.ok) {
showDownloadLinks(result);
loadInvoiceData(); // 加载可编辑数据
} else {
alert('处理失败: ' + (result.error || '未知错误'));
}
return;
}
} catch (err) {}
logBox.innerHTML += e.data;
logBox.scrollTop = logBox.scrollHeight;
});
es.onerror = () => {
es.close();
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
btn.disabled = false;
btn.textContent = '开始处理';
};
} catch (e) {
alert('请求失败: ' + (e.message || '未知错误'));
btn.disabled = false;
btn.textContent = '开始处理';
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
}
}
// ---- 下载链接 ----
function showDownloadLinks(result) {
const section = document.getElementById('download-section');
const box = document.getElementById('download-links');
const warn = document.getElementById('doc-fill-warning');
if (!section || !box) return;
const items = [];
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
if (result.md_url) items.push({ label: 'invoice_summary.md', url: result.md_url });
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
lastDownloadUrls = {};
items.forEach(it => { lastDownloadUrls[it.label] = it.url; });
box.innerHTML = items.map(it =>
`<a class="btn btn-outline-primary btn-sm" href="${it.url}" download>${it.label}</a>`
).join('');
if (warn) {
if (result.doc_ok === false && result.doc_error) {
warn.style.display = 'block';
warn.textContent = '出库单未生成:' + result.doc_error;
} else {
warn.style.display = 'none';
warn.textContent = '';
}
}
section.style.display = items.length || (result.doc_ok === false) ? 'block' : 'none';
}
// ---- 发票数据编辑 ----
async function loadInvoiceData() {
try {
const r = await fetch(`/api/data/${sessionId}`);
const d = await r.json();
if (d.error) return;
csvFilename = d.csv_filename || 'invoice_summary.csv';
invoiceData = d.data || [];
const fields = d.fields || Object.keys(invoiceData[0] || {}).filter(k => !k.startsWith('__'));
renderTable(invoiceData, fields);
// 渲染后自动将配置区的工号/公务卡号同步到表格
syncConfigToTable('工号');
syncConfigToTable('公务卡号');
} catch (e) { /* ignore */ }
}
// 配置区 → 表格的同步映射:工号 ↔ cfg-username公务卡号 ↔ cfg-card
const CONFIG_SYNC = {
'工号': 'cfg-username',
'公务卡号': 'cfg-card',
};
// 从配置区输入框的值更新到所有表格行
function syncConfigToTable(field) {
const inputId = CONFIG_SYNC[field];
if (!inputId) return;
const val = document.getElementById(inputId).value || '';
invoiceData.forEach((row, i) => {
row[field] = val;
});
// 更新表格中对应单元格的显示
const tbody = document.getElementById('invoice-tbody');
if (!tbody) return;
const rows = tbody.querySelectorAll('tr');
rows.forEach((tr, i) => {
const inputs = tr.querySelectorAll('input');
fieldsCache.forEach((f, colIdx) => {
if (f === field && inputs[colIdx]) {
inputs[colIdx].value = val;
}
});
});
}
let fieldsCache = []; // renderTable 渲染后的字段列表,用于定位列索引
function renderTable(data, fields) {
const section = document.getElementById('edit-section');
if (!data.length) { section.style.display = 'none'; return; }
section.style.display = 'block';
// 使用后端返回的字段顺序, fallback 到 Object.keys
if (!fields || !fields.length) {
fields = Object.keys(data[0]).filter(k => !k.startsWith('__'));
}
fieldsCache = fields;
// 表头
document.getElementById('invoice-thead').innerHTML = `
<tr>
<th style="width:40px">#</th>
${fields.map(f => `<th>${f}</th>`).join('')}
</tr>
`;
// 表体:每行首列为序号,其后为各字段 input
const rows = data.map((row, i) => {
const cells = fields.map(f => {
let onChangeStr = `invoiceData[${i}]['${f.replace(/'/g, "\\'")}']=this.value`;
// 如果该字段参与配置区同步,额外调用 syncTableToConfig
if (CONFIG_SYNC[f]) {
onChangeStr += `;syncTableToConfig('${f.replace(/'/g, "\\'")}', this.value)`;
}
return `<td><input class="form-control form-control-sm" value="${escapeHtml(String(row[f] || ''))}"
onchange="${onChangeStr}"></td>`;
}).join('');
return `<tr><td style="width:40px;text-align:center">${i + 1}</td>${cells}</tr>`;
}).join('');
document.getElementById('invoice-tbody').innerHTML = rows;
}
// 从表格修改同步回配置区
function syncTableToConfig(field, value) {
const inputId = CONFIG_SYNC[field];
if (inputId) {
document.getElementById(inputId).value = value;
}
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 将当前表格编辑内容写回服务器(内部调用)
async function saveInvoiceData() {
try {
const r = await fetch(`/api/save/${sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: invoiceData, csv_filename: csvFilename }),
});
const d = await r.json();
if (d.doc_url || d.doc_ok === false) {
showDownloadLinks({
csv_url: lastDownloadUrls['invoice_summary.csv'],
md_url: lastDownloadUrls['invoice_summary.md'],
doc_url: d.doc_url,
doc_ok: d.doc_ok,
doc_error: d.doc_error,
});
}
} catch (e) {
console.warn('自动保存失败,继续提交:', e);
}
}
// ---- 提交到财务系统 ----
async function submitFinancial() {
const btn = document.getElementById('btn-submit');
btn.disabled = true;
btn.textContent = '提交中...';
document.getElementById('log-box').innerHTML = '';
try {
// 提交前先自动保存当前表格编辑内容
await saveInvoiceData();
await fetch(`/api/submit-financial/${sessionId}`, { method: 'POST' });
// 监听日志流(复用 SSE
const es = new EventSource(`/api/logs/${sessionId}`);
const logBox = document.getElementById('log-box');
let firstLine = true;
es.addEventListener('message', e => {
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
btn.disabled = false;
const result = msg.result;
if (result && result.submit_ok) {
btn.textContent = '✅ 提交完成';
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 3000);
} else {
const errorMsg = result?.submit_error || result?.error || '未知错误';
btn.textContent = '❌ 提交失败';
logBox.innerHTML += `<div style="color: #e74c3c; font-weight: bold;">❌ 提交失败:${errorMsg}</div>`;
logBox.scrollTop = logBox.scrollHeight;
console.warn('提交失败:', errorMsg);
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 5000);
}
return;
}
} catch (err) {}
logBox.innerHTML += e.data;
logBox.scrollTop = logBox.scrollHeight;
});
es.onerror = () => {
es.close();
btn.disabled = false;
btn.textContent = '🚀 提交到财务系统';
};
} catch (e) {
alert('提交失败: ' + e.message);
btn.disabled = false;
btn.textContent = '🚀 提交到财务系统';
}
}
// ---- 二维码 / 手机扫码上传 ----
let qrGenerated = false;
let syncTimer = null;
async function generateQr() {
await ensureSession();
const mobileUrl = window.location.origin + '/mobile/' + sessionId;
const box = document.getElementById('qrcode');
box.innerHTML = '';
new QRCode(box, {
text: mobileUrl,
width: 120,
height: 120,
colorDark: '#333',
colorLight: '#fff',
});
qrGenerated = true;
}
// ---- 实时同步:轮询服务器文件列表,检测手机端上传的新图片 ----
async function startSync() {
if (syncTimer) return;
await syncFiles();
syncTimer = setInterval(syncFiles, 3000);
}
function stopSync() {
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
}
async function syncFiles() {
if (!sessionId) return;
try {
const r = await fetch(`/api/files/${sessionId}`);
const d = await r.json();
const serverNames = new Set(d.images || []);
const localNames = new Set(imgFiles.map(f => f.name));
for (const name of serverNames) {
if (!localNames.has(name)) {
const resp = await fetch(`/api/download/${sessionId}/${encodeURIComponent(name)}`);
const blob = await resp.blob();
const file = new File([blob], name, { type: blob.type });
file.__source = 'server'; // 标记为扫码上传
imgFiles.push(file);
}
}
// 只清理来自服务器但已不存在的文件,保留本地手动选择的文件
for (const f of [...imgFiles]) {
if (!serverNames.has(f.name) && f.__source !== 'local') {
const idx = imgFiles.indexOf(f);
if (idx > -1) imgFiles.splice(idx, 1);
}
}
renderFileList('img');
if (imgFiles.length) {
document.getElementById('img-zone').classList.add('active');
} else {
document.getElementById('img-zone').classList.remove('active');
}
} catch (e) { /* ignore */ }
}
generateQr();
startSync();
// ---- 配置区 → 表格的双向同步绑定 ----
Object.values(CONFIG_SYNC).forEach(inputId => {
const el = document.getElementById(inputId);
if (el) {
el.addEventListener('input', () => {
// 根据 inputId 反查对应的字段名
const field = Object.entries(CONFIG_SYNC).find(([, id]) => id === inputId)?.[0];
if (field) syncConfigToTable(field);
});
}
});

View File

@@ -5,26 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>财务报销自动化</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f5f7fa; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
.upload-zone {
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
}
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.log-container .empty { color: #666; font-style: italic; }
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
.btn-process { font-size: 17px; padding: 10px 40px; }
.status-badge { font-size: 13px; }
.result-box { background: #fff; border-radius: 8px; padding: 16px; display: none; }
.result-box a { text-decoration: none; }
</style>
<link href="/static/css/index.css" rel="stylesheet">
</head>
<body>
@@ -103,11 +84,9 @@ body { background: #f5f7fa; }
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
</div>
<div class="col-sm-6 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="cfg-submit">
<label class="form-check-label" for="cfg-submit" style="font-size:13px">同时提交到财务系统(需浏览器自动化)</label>
</div>
<div class="col-sm-6">
<label class="form-label" style="font-size:12px;margin-bottom:2px">存放地点(出库单)</label>
<input type="text" class="form-control form-control-sm" id="cfg-storage" placeholder="新工科楼">
</div>
</div>
</div>
@@ -119,10 +98,32 @@ body { background: #f5f7fa; }
<span id="status" class="ms-3"></span>
</div>
<!-- 结果 -->
<div class="result-box mb-3" id="result-box">
<div class="section-title">📊 处理结果</div>
<div id="result-content"></div>
<!-- 下载区 -->
<div class="card mb-4" id="download-section" style="display:none">
<div class="card-body py-3">
<div class="section-title mb-2">📥 下载文件</div>
<div id="download-links" class="d-flex flex-wrap gap-2"></div>
<div id="doc-fill-warning" class="text-warning mt-2" style="font-size:13px;display:none"></div>
</div>
</div>
<!-- 发票数据编辑区 -->
<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>
<div class="d-flex justify-content-end align-items-center">
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
</div>
</div>
<p class="edit-note">直接点击单元格即可编辑,提交时自动保存当前修改。</p>
<div class="table-wrapper">
<table class="table table-sm table-bordered table-editable mb-0" id="invoice-table">
<thead id="invoice-thead"></thead>
<tbody id="invoice-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- 日志 -->
@@ -132,307 +133,6 @@ body { background: #f5f7fa; }
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script>
let sessionId = null;
const pdfFiles = [], imgFiles = [];
let csvFile = null;
// ---- Session ----
async function ensureSession() {
if (sessionId) return sessionId;
const r = await fetch('/api/session', { method: 'POST' });
const d = await r.json();
sessionId = d.session_id;
return sessionId;
}
// ---- 上传 ----
function handleFiles(input, type) {
const files = Array.from(input.files);
const list = type === 'pdf' ? pdfFiles : imgFiles;
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
files.forEach(f => {
if (!list.find(x => x.name === f.name)) list.push(f);
});
renderFileList(type);
document.getElementById(zoneId).classList.add('active');
input.value = '';
}
function removeFile(type, index) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
list.splice(index, 1);
renderFileList(type);
if (list.length === 0) {
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
}
}
function renderFileList(type) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
box.innerHTML = list.map((f, i) =>
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">&times;</span></span>`
).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];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const cfg = JSON.parse(e.target.result);
const map = {
'cfg-username': cfg.username,
'cfg-password': cfg.password,
'cfg-name': cfg.default_name,
'cfg-card': cfg.default_card_no,
'cfg-person-id': cfg.default_person_id,
};
for (const [id, val] of Object.entries(map)) {
if (val) document.getElementById(id).value = val;
}
alert('配置已加载');
} catch (err) {
alert('config.json 解析失败: ' + err.message);
}
};
reader.readAsText(file);
input.value = '';
}
// ---- 拖拽 ----
['pdf','img'].forEach(type => {
const zone = document.getElementById(type + '-zone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files).filter(f => {
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
});
if (files.length) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
files.forEach(f => { if (!list.find(x => x.name === f.name)) list.push(f); });
renderFileList(type);
zone.classList.add('active');
}
});
});
// 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');
return;
}
const btn = document.getElementById('btn-start');
btn.disabled = true;
btn.textContent = '处理中...';
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
document.getElementById('log-box').innerHTML = '';
document.getElementById('result-box').style.display = 'none';
try {
await ensureSession();
if (isCsvMode) {
// CSV 模式:只上传 CSV 文件
const fd = new FormData();
fd.append('file', csvFile);
await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd });
} else {
// 传统模式:上传 PDF 和图片
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 });
}
}
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
// 启动管道
const cfg = {
username: document.getElementById('cfg-username').value,
password: document.getElementById('cfg-password').value,
default_name: document.getElementById('cfg-name').value,
default_card_no: document.getElementById('cfg-card').value,
default_person_id: document.getElementById('cfg-person-id').value,
submit: document.getElementById('cfg-submit').checked,
mode: isCsvMode ? 'csv' : 'auto',
};
await fetch(`/api/process/${sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
// 监听 SSE 日志
const es = new EventSource(`/api/logs/${sessionId}`);
const logBox = document.getElementById('log-box');
let firstLine = true;
es.addEventListener('message', e => {
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
// 尝试解析 JSON完成信号否则按日志文本处理
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
const result = msg.result;
document.getElementById('status').innerHTML = result.ok
? '<span class="badge bg-success status-badge">完成</span>'
: '<span class="badge bg-danger status-badge">失败</span>';
btn.disabled = false;
btn.textContent = '开始处理';
if (result.ok) {
showResult(result);
} else {
document.getElementById('result-box').style.display = 'block';
document.getElementById('result-content').innerHTML = '<span class="text-danger">' + (result.error || '处理失败') + '</span>';
}
return;
}
} catch (err) {}
logBox.innerHTML += e.data;
logBox.scrollTop = logBox.scrollHeight;
});
es.onerror = () => {
es.close();
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
btn.disabled = false;
btn.textContent = '开始处理';
};
} catch (e) {
alert('请求失败: ' + (e.message || '未知错误'));
btn.disabled = false;
btn.textContent = '开始处理';
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
}
}
function showResult(r) {
const box = document.getElementById('result-box');
box.style.display = 'block';
const mdLink = r.md_url ? `<a class="btn btn-sm btn-outline-secondary" href="${r.md_url}" download>下载 Markdown</a>` : '';
document.getElementById('result-content').innerHTML = `
<p>处理 <b>${r.invoice_count}</b> 张发票,耗时 ${r.elapsed}</p>
<a class="btn btn-sm btn-outline-primary me-2" href="${r.csv_url}" download>下载 CSV</a>
${mdLink}
`;
}
// ---- 二维码 / 手机扫码上传 ----
let qrGenerated = false;
let syncTimer = null;
async function generateQr() {
await ensureSession();
const mobileUrl = window.location.origin + '/mobile/' + sessionId;
const box = document.getElementById('qrcode');
box.innerHTML = '';
new QRCode(box, {
text: mobileUrl,
width: 120,
height: 120,
colorDark: '#333',
colorLight: '#fff',
});
qrGenerated = true;
}
// ---- 实时同步:轮询服务器文件列表,检测手机端上传的新图片 ----
async function startSync() {
if (syncTimer) return;
await syncFiles(); // 立即执行一次
syncTimer = setInterval(syncFiles, 3000);
}
function stopSync() {
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
}
async function syncFiles() {
if (!sessionId) return;
try {
const r = await fetch(`/api/files/${sessionId}`);
const d = await r.json();
const serverNames = new Set(d.images || []);
const localNames = new Set(imgFiles.map(f => f.name));
// 检测服务器上有但本地没有的图片(手机端上传的)
for (const name of serverNames) {
if (!localNames.has(name)) {
// 从服务器下载图片并加入本地列表
const resp = await fetch(`/api/download/${sessionId}/${encodeURIComponent(name)}`);
const blob = await resp.blob();
const file = new File([blob], name, { type: blob.type });
imgFiles.push(file);
}
}
// 检测本地有但服务器没有的图片(被手机端删除了)
for (const f of [...imgFiles]) {
if (!serverNames.has(f.name)) {
const idx = imgFiles.indexOf(f);
if (idx > -1) imgFiles.splice(idx, 1);
}
}
renderFileList('img');
if (imgFiles.length) {
document.getElementById('img-zone').classList.add('active');
} else {
document.getElementById('img-zone').classList.remove('active');
}
} catch (e) { /* ignore */ }
}
// ---- 页面加载时自动生成二维码并启动同步 ----
generateQr();
startSync();
</script>
<script src="/static/js/index.js"></script>
</body>
</html>