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