723 lines
26 KiB
Python
723 lines
26 KiB
Python
"""
|
||
财务报销自动化 — 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)
|