""" 财务报销自动化 — Web 界面 用户上传 PDF 发票,配置账号信息,自动完成: 1. 发票提取 2. 浏览器填报(可选) 发票类型区分: - 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程 - 普通发票:生成易耗品出库单,走普通报销流程 启动: uv run python src/web/app.py 访问: http://localhost:5000 """ import json import logging import sys import threading import time import uuid from pathlib import Path from typing import Any, cast from urllib.parse import quote from flask import Flask, Response, jsonify, render_template, request, stream_with_context # 确保项目根目录在 sys.path PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent sys.path.insert(0, str(PROJECT_ROOT)) from src import get_logger # noqa: E402, I001 from src.config import load_config as load_project_config # noqa: E402, I001 from src.doc.extractor import ( # noqa: E402, I001 extract_invoices, ) from src.doc.fill_consumable_doc import ( # noqa: E402, I001 CONSUMABLE_DOC_FILENAME, fill_consumable_from_template, ) from src.doc.invoice import ( # noqa: E402, I001 load_csv, load_invoice_csv, save_csv as save_payment_csv, save_invoice_csv, save_application_json, ) fill_log = get_logger("fill_consumable_doc") CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME app = Flask(__name__, template_folder="templates") UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads" SESSION_LOG_FILE = "session.log" SESSION_RESULT_FILE = "result.json" INVOICE_GROUPS_FILE = "invoice_groups.json" def _save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None: """保存发票分类结果到 session 目录的 JSON 文件""" groups_path = session_dir / INVOICE_GROUPS_FILE # 只保存各组的数量统计,避免重复存储完整发票数据 data = { "travel_count": len(groups.get("travel", [])), "general_count": len(groups.get("general", [])), "application_count": len(groups.get("application", [])), } with open(groups_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def _load_invoice_groups(session_dir: Path) -> dict[str, int] | None: """从 session 目录加载发票分类统计""" groups_path = session_dir / INVOICE_GROUPS_FILE if not groups_path.exists(): return None try: with open(groups_path, encoding="utf-8") as f: return cast(dict[str, int] | None, json.load(f)) except Exception: return None 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) -> None: try: msg = self.format(record) + "\n" with self._lock: self._file.write(msg) self._file.flush() except Exception: pass def close_file(self) -> None: 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", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]: logger = logging.getLogger(name) logger.setLevel(logging.INFO) logger.addHandler(handler) return handler def _remove_log_collector(handler: _SSELogHandler) -> None: for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]: logging.getLogger(name).removeHandler(handler) handler.close_file() # ================================================================ # 出库单填写 # ================================================================ def _load_session_config(session_dir: Path) -> dict[str, Any]: 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_payment_csv(session_dir: Path) -> Path | None: """查找支付记录 CSV(payment_records.csv)""" csv_path = session_dir / "payment_records.csv" if csv_path.exists(): return csv_path for f in session_dir.glob("*.csv"): if f.name != SESSION_RESULT_FILE: return f return None def _resolve_invoice_csv(session_dir: Path) -> Path | None: """查找发票级别 CSV(invoice_summary.csv)""" csv_path = session_dir / "invoice_summary.csv" if csv_path.exists(): return csv_path for f in session_dir.glob("*.csv"): if f.name != SESSION_RESULT_FILE: return f return None # ================================================================ # 出库单填写 # ================================================================ def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]: """根据 CSV 填写易耗品出库单,供会话目录下载。 从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。 """ if not CONSUMABLE_TEMPLATE.exists(): fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE) return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"} # 从统一的分类结果读取,避免重复解析 CSV/JSON groups = _load_invoice_groups(session_dir) if groups is None: return {"ok": False, "error": "未找到发票分类数据,请先处理"} if not groups.get("general_count", 0): fill_log.info("纯差旅发票,跳过易耗品出库单生成") return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"} csv_path = _resolve_payment_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[str, Any], session_id: str, doc_fill: dict[str, Any]) -> 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 elif doc_fill.get("skipped"): # 差旅发票,跳过出库单生成(不是错误) result["doc_ok"] = None result["doc_skipped"] = True result["doc_message"] = doc_fill.get("error", "") else: result["doc_ok"] = False result["doc_error"] = doc_fill.get("error", "未知错误") # ================================================================ # 管道入口 # ================================================================ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]: """在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件 注意:不再自动提交财务系统。提交通由 /api/submit-financial/ 触发。 在发票提取和匹配完成后立即判断报销类型: - 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json - 普通发票:无需额外提取(normal_info.json 待实现) """ start = time.time() # ---- Step 1: 发票提取 ---- invoices, applications, groups = extract_invoices(str(session_dir)) if not invoices: return {"ok": False, "error": "未提取到任何发票数据"} # 保存 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") # 保存分类结果(供后续步骤统一读取) _save_invoice_groups(session_dir, groups) # ---- Step 2: 差旅/普通信息提取 ---- is_travel = bool(groups.get("travel")) and not bool(groups.get("general")) if is_travel: from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache # 检查缓存是否已有 cache_map = load_cache(session_dir) if not cache_map.get("travel_info"): fill_log.info("开始提取差旅信息...") travel_info = extract_travel_info(source_dir=session_dir) cache_dir = session_dir / CACHE_DIR_NAME cache_dir.mkdir(parents=True, exist_ok=True) with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f: json.dump(travel_info, f, ensure_ascii=False, indent=2) fill_log.info("差旅信息已保存到缓存") else: from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache # 检查缓存是否已有 cache_map = load_cache(session_dir) if not cache_map.get("normal_info"): fill_log.info("开始提取普通发票信息...") normal_info = extract_normal_info(source_dir=session_dir) cache_dir = session_dir / CACHE_DIR_NAME cache_dir.mkdir(parents=True, exist_ok=True) with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f: json.dump(normal_info, f, ensure_ascii=False, indent=2) fill_log.info("普通发票信息已保存到缓存") # 统计发票总数 invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices) elapsed = time.time() - start result = { "ok": True, "elapsed": f"{elapsed:.1f}s", "invoice_count": invoice_count, "csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv", # 发票类型统计 "travel_count": len(groups["travel"]), "general_count": len(groups["general"]), } 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]: """执行财务系统填报(从前端确认后调用) 从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式: - 纯差旅发票:差旅报销模式(TODO) - 含普通发票:普通报销模式 """ csv_path = session_dir / "payment_records.csv" if not csv_path.exists(): return {"ok": False, "error": "未找到发票数据,请先处理"} from src.bot import run_bot_web # 从统一的分类结果读取,避免重复解析 groups = _load_invoice_groups(session_dir) if groups: if groups.get("travel_count", 0) and not groups.get("general_count", 0): fill_log.info("检测到纯差旅发票,使用差旅报销模式") # TODO: 差旅报销填报流程 else: fill_log.info("检测到普通发票,使用普通报销模式") run_bot_web(config, session_dir) return {"ok": True} # ================================================================ # Flask 路由 # ================================================================ @app.route("/") def index() -> Any: return render_template("index.html") @app.route("/api/session", methods=["POST"]) def create_session() -> Any: """创建上传会话,返回 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) -> Any: """上传 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/", methods=["GET"]) def list_files(session_id: str) -> Any: """列出会话目录中的文件""" 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) -> Any: """启动管道处理(仅发票提取,不自动提交财务系统)""" session_dir = _validate_session(session_id) if isinstance(session_dir, tuple): return session_dir body = request.get_json(silent=True) or {} # 读取配置 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() -> None: result = {"ok": False, "error": "未知错误"} try: 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) -> Any: """SSE 日志流""" session_dir = _validate_session(session_id) if isinstance(session_dir, tuple): return session_dir def generate() -> Any: # 先发送已有日志 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) -> Any: """下载生成的文件""" 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" 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/config/", methods=["GET"]) def get_session_config(session_id: str) -> Any: """获取当前会话的配置(供前端回填表单)""" session_dir = _validate_session(session_id) if isinstance(session_dir, tuple): return session_dir 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 jsonify( { "username": config.get("username", ""), "password": "", # 不返回密码 "default_name": config.get("default_name", ""), "default_card_no": config.get("default_card_no", ""), "default_person_id": config.get("default_person_id", ""), "consumable_storage": config.get("consumable_storage", ""), } ) @app.route("/api/data/", methods=["GET"]) def get_invoice_data(session_id: str) -> Any: """读取发票数据并返回 JSON(供前端表格编辑)""" session_dir = _validate_session(session_id) if isinstance(session_dir, tuple): return session_dir # 优先读取支付记录 CSV payment_csv = session_dir / "payment_records.csv" if payment_csv.exists(): rows = load_csv(payment_csv) if rows is not None: data: list[dict[str, Any]] = [] for i, row in enumerate(rows): entry: dict[str, Any] = 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": payment_csv.name, "fields": fields, "data": data}) # 回退到发票级别 CSV invoice_csv = session_dir / "invoice_summary.csv" if invoice_csv.exists(): rows = load_invoice_csv(invoice_csv) if rows is not None: invoice_data: list[dict[str, Any]] = [] for i, row in enumerate(rows): entry2: dict[str, Any] = dict(row) entry2["__row"] = i invoice_data.append(entry2) fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else [] return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data}) # 最后尝试任意 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] rows = load_csv(csv_path) if rows is None: rows = load_invoice_csv(csv_path) if rows is not None: fallback_data: list[dict[str, Any]] = [] for i, row in enumerate(rows): entry3: dict[str, Any] = dict(row) entry3["__row"] = i fallback_data.append(entry3) 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": fallback_data}) return jsonify({"error": "未找到发票数据,请先处理"}), 404 @app.route("/api/save/", methods=["POST"]) def save_invoice_data(session_id: str) -> Any: """保存前端编辑后的发票数据到 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_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: dict[str, str | bool | None] = {"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 elif doc_fill.get("skipped"): resp["doc_ok"] = None resp["doc_skipped"] = True else: resp["doc_ok"] = False resp["doc_error"] = doc_fill.get("error") or "" return jsonify(resp) @app.route("/api/submit-financial/", methods=["POST"]) def submit_financial(session_id: str) -> Any: """手动触发财务系统填报""" 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() -> None: 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) -> Path | tuple[Response, int]: session_dir = UPLOAD_BASE / session_id if not session_dir.exists(): return jsonify({"error": "会话不存在"}), 404 return session_dir def _build_web_config(body: dict[str, Any]) -> dict[str, Any]: """从请求体构建配置""" 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) -> Any: """移动端上传页面""" 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) -> Any: """移动端上传图片(复用 PC 上传逻辑)""" return upload_file(session_id) if __name__ == "__main__": UPLOAD_BASE.mkdir(parents=True, exist_ok=True) print("启动 Web 服务: http://localhost:5000") app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)