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

723 lines
26 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. 浏览器填报(可选)
发票类型区分:
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- 普通发票:生成易耗品出库单,走普通报销流程
启动: 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/<session_id> 触发。
在发票提取和匹配完成后立即判断报销类型:
- 差旅发票:调用 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/<session_id>", 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/<session_id>", 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/<session_id>", 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/<session_id>")
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/<session_id>/<filename>")
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/<session_id>", 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/<session_id>", 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/<session_id>", 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/<session_id>", 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/<session_id>")
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/<session_id>", 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)