Files
Auto-Finance/web/app.py
2026-05-25 12:00:06 +08:00

390 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
财务报销自动化 — Web 界面
用户上传 PDF 发票和支付截图,配置账号信息,自动完成:
1. 发票提取 2. OCR 识别 3. 浏览器填报(可选)
启动: python web/app.py
访问: http://localhost:5000
"""
import io
import json
import logging
import sys
import threading
import time
import uuid
from pathlib import Path
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
# 确保项目根目录在 sys.path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
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.ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv as load_ocr_csv
app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "web" / "uploads"
SESSION_LOG_FILE = "session.log"
SESSION_RESULT_FILE = "result.json"
# ================================================================
# 日志收集器 — 捕获管道日志到文件SSE 端点通过 tail -f 读取
# ================================================================
class _SSELogHandler(logging.Handler):
"""将日志写入指定文件(线程安全)"""
def __init__(self, log_path: Path):
super().__init__()
self._lock = threading.Lock()
self._file = open(log_path, "w", encoding="utf-8")
def emit(self, record: logging.LogRecord):
try:
msg = self.format(record) + "\n"
with self._lock:
self._file.write(msg)
self._file.flush()
except Exception:
pass
def close_file(self):
try:
self._file.close()
except Exception:
pass
def _install_log_collector(session_dir: Path) -> _SSELogHandler:
"""安装日志收集器到 app.* 模块"""
log_path = session_dir / SESSION_LOG_FILE
fmt = logging.Formatter(
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
"%Y-%m-%d %H:%M:%S",
)
handler = _SSELogHandler(log_path)
handler.setFormatter(fmt)
handler.setLevel(logging.INFO)
for name in ["extractor", "ocr", "pipeline", "bot"]:
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.addHandler(handler)
return handler
def _remove_log_collector(handler: _SSELogHandler):
for name in ["extractor", "ocr", "pipeline", "bot"]:
logging.getLogger(name).removeHandler(handler)
handler.close_file()
# ================================================================
# 管道入口
# ================================================================
# ================================================================
# 管道入口
# ================================================================
def run_pipeline_web(session_dir: Path, config: dict, run_bot: bool = False):
"""在 Web 会话目录中执行管道,结果写入 session 目录下的文件"""
start = time.time()
# ---- Step 1: 发票提取 ----
invoices = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
save_csv(invoices, session_dir / "invoice_summary.csv")
save_markdown(invoices, session_dir / "invoice_summary.md")
# ---- Step 2: OCR 识别 ----
csv_path = session_dir / "invoice_summary.csv"
rows = load_ocr_csv(csv_path)
if rows is None:
return {"ok": False, "error": "CSV 读取失败"}
rows = enrich_with_ocr(rows, str(session_dir))
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 {
"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",
}
def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str, run_bot: bool = False):
"""直接使用上传的 CSV 文件进行填报,跳过 PDF 提取和 OCR"""
start = time.time()
csv_path = session_dir / csv_filename
if not csv_path.exists():
return {"ok": False, "error": "CSV 文件不存在"}
# 读取 CSV 行数
rows = load_ocr_csv(csv_path)
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 {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": len(rows),
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
}
# ================================================================
# Flask 路由
# ================================================================
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/session", methods=["POST"])
def create_session():
"""创建上传会话,返回 session_id"""
sid = uuid.uuid4().hex[:12]
session_dir = UPLOAD_BASE / sid
session_dir.mkdir(parents=True, exist_ok=True)
return jsonify({"session_id": sid})
@app.route("/api/upload/<session_id>", methods=["POST"])
def upload_file(session_id: str):
"""上传 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/upload-csv/<session_id>", methods=["POST"])
def upload_csv(session_id: str):
"""上传 CSV 发票数据文件(跳过 PDF 提取和 OCR"""
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):
"""列出会话目录中的文件"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
imgs = sorted(
f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
for f in session_dir.glob(f"*{ext}")
)
return jsonify({"pdfs": pdfs, "images": imgs})
@app.route("/api/process/<session_id>", methods=["POST"])
def start_process(session_id: str):
"""启动管道处理"""
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"
# 读取配置
config = _build_web_config(body)
# 写入配置到会话目录
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
# 在后台线程执行
handler = _install_log_collector(session_dir)
def _run():
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)
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)
else:
result = run_pipeline_web(session_dir, config, run_bot_flag)
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)
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"})
@app.route("/api/logs/<session_id>")
def stream_logs(session_id: str):
"""SSE 日志流"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
def generate():
# 先发送已有日志
log_file = session_dir / SESSION_LOG_FILE
last_size = 0
start_time = time.time()
timeout = 600 # 10 分钟超时
while time.time() - start_time < timeout:
if log_file.exists():
current_size = log_file.stat().st_size
if current_size > last_size:
with open(log_file, encoding="utf-8", errors="replace") as f:
f.seek(last_size)
chunk = f.read()
if chunk:
yield f"data: {_escape_sse(chunk)}\n\n"
last_size = current_size
# 也通过队列发送实时日志
# 检查是否完成
result_file = session_dir / SESSION_RESULT_FILE
if result_file.exists():
with open(result_file, encoding="utf-8") as f:
result = json.load(f)
yield f"data: {_escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
break
time.sleep(0.5)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.route("/api/download/<session_id>/<filename>")
def download_file(session_id: str, filename: str):
"""下载生成的文件"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
filepath = session_dir / filename
if not filepath.exists():
return jsonify({"error": "文件不存在"}), 404
return Response(filepath.read_bytes(), mimetype="application/octet-stream")
# ================================================================
# 辅助函数
# ================================================================
def _validate_session(session_id: str):
session_dir = UPLOAD_BASE / session_id
if not session_dir.exists():
return jsonify({"error": "会话不存在"}), 404
return session_dir
def _build_web_config(body: dict) -> dict:
"""从请求体构建配置"""
config = load_project_config()
for key in ("username", "password", "default_name", "default_card_no", "default_person_id"):
if body.get(key):
config[key] = body[key]
return config
def _escape_sse(text: str) -> str:
"""SSE 数据转义,同时处理 Windows 行尾 \\r\\n"""
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
@app.route("/mobile/<session_id>")
def mobile_upload(session_id: str):
"""移动端上传页面"""
session_dir = UPLOAD_BASE / session_id
if not session_dir.exists():
return render_template("mobile_upload.html", error="会话不存在"), 404
return render_template("mobile_upload.html", session_id=session_id)
@app.route("/api/mobile-upload/<session_id>", methods=["POST"])
def mobile_upload_file(session_id: str):
"""移动端上传图片(复用 PC 上传逻辑)"""
return upload_file(session_id)
if __name__ == "__main__":
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
print(f"启动 Web 服务: http://localhost:5000")
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)