""" 财务报销自动化 — 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 urllib.parse import quote 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.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" 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", "fill_consumable_doc"]: 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", "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): """在 Web 会话目录中执行提取+OCR,结果写入 session 目录下的文件 注意:不再自动提交财务系统。提交通由 /api/submit-financial/ 触发。 """ 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) elapsed = time.time() - start 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): """直接使用上传的 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 读取失败"} elapsed = time.time() - start 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} # ================================================================ # 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/", 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/", 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/", 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/", 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 {} 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_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) 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(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/") 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//") def download_file(session_id: str, filename: str): """下载生成的文件""" session_dir = _validate_session(session_id) if isinstance(session_dir, tuple): return session_dir # 防止路径穿越 safe_name = Path(filename).name filepath = session_dir / safe_name if not filepath.exists(): return jsonify({"error": "文件不存在"}), 404 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/", 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/", 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/", 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"}) # ================================================================ # 辅助函数 # ================================================================ 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", "consumable_storage", ): 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/") 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/", 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)