重构项目为LLM 驱动
This commit is contained in:
42
src/__init__.py
Normal file
42
src/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""财务报销自动化工具包"""
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_LOG_FMT = "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s"
|
||||
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||
|
||||
|
||||
def _get_log_file() -> Path:
|
||||
"""返回当日日志文件路径,如 logs/2026-06-09.log"""
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return _LOG_DIR / f"{datetime.date.today():%Y-%m-%d}.log"
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""获取带时间戳的日志记录器
|
||||
|
||||
输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ...
|
||||
日志同时输出到终端和 logs/<日期>.log
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
if not logger.handlers:
|
||||
logger.setLevel(logging.INFO)
|
||||
formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT)
|
||||
|
||||
# 终端输出
|
||||
utf8_stream = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
stream_handler = logging.StreamHandler(utf8_stream)
|
||||
stream_handler.setFormatter(formatter)
|
||||
logger.addHandler(stream_handler)
|
||||
|
||||
# 文件输出
|
||||
file_handler = logging.FileHandler(str(_get_log_file()), encoding="utf-8")
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
457
src/bot.py
Normal file
457
src/bot.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
浏览器自动化填报
|
||||
|
||||
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
|
||||
|
||||
对外接口:
|
||||
load_invoice_data(csv_path, config) -> list[dict] 从 CSV 加载并补全默认值
|
||||
run_bot(config, invoices) 启动浏览器并执行填报流程
|
||||
"""
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import get_logger
|
||||
|
||||
log = get_logger("bot")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 日期格式化
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_date(date_str: str) -> str:
|
||||
"""将 '2026/5/13' 或 '2026-5-13' 转为 '2026-05-13'"""
|
||||
if not date_str:
|
||||
return ""
|
||||
parts = date_str.replace("-", "/").split("/")
|
||||
if len(parts) == 3:
|
||||
return f"{parts[0].zfill(4)}-{parts[1].zfill(2)}-{parts[2].zfill(2)}"
|
||||
return date_str
|
||||
|
||||
|
||||
def _safe_float(value: str | None, default: float = 0.0) -> float:
|
||||
"""安全转换为浮点数,空值或转换失败时返回默认值"""
|
||||
if value is None or str(value).strip() == "":
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CSV 数据加载
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_invoice_data(csv_path: str, config: dict[str, str | Path]) -> list[dict[str, str | float | Any | Path]]:
|
||||
"""从 CSV 加载发票数据,自动补全空白字段的默认值
|
||||
|
||||
CSV 以支付记录为主键,每行包含 _invoices_json 字段(JSON 序列化的发票列表)。
|
||||
本函数还原为发票级别的数据列表。
|
||||
"""
|
||||
import json
|
||||
|
||||
invoices = []
|
||||
with open(csv_path, encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
invoices_json = row.get("_invoices_json", "")
|
||||
if not invoices_json:
|
||||
continue
|
||||
try:
|
||||
inv_list = json.loads(invoices_json)
|
||||
for inv in inv_list:
|
||||
invoices.append(
|
||||
{
|
||||
"seq": row.get("序号", ""),
|
||||
"invoice_no": inv.get("发票号码", ""),
|
||||
"invoice_date": inv.get("开票日期", ""),
|
||||
"item_name": inv.get("项目名称", ""),
|
||||
"spec_model": inv.get("规格型号", ""),
|
||||
"total_amount": _safe_float(inv.get("价税合计")),
|
||||
"seller_name": inv.get("销售方名称", ""),
|
||||
"person_name": inv.get("人员姓名") or config.get("default_name", ""),
|
||||
"card_date": _format_date(row.get("刷卡日期") or ""),
|
||||
"card_no": row.get("公务卡号") or config.get("default_card_no", ""),
|
||||
"card_amount": _safe_float(row.get("刷卡金额")),
|
||||
"remark": row.get("备注") or "",
|
||||
"person_id": row.get("工号") or config.get("default_person_id", ""),
|
||||
}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
log.warning(f"无法解析发票 JSON: {invoices_json[:50]}...")
|
||||
return invoices
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 报销机器人
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class ReimburseBot:
|
||||
"""财务报销自动化机器人"""
|
||||
|
||||
def __init__(self, config: dict[str, Any], headless: bool = False):
|
||||
self.config = config
|
||||
self.headless = headless
|
||||
self.work_dir: Path | None = None
|
||||
self.browser: Any = None
|
||||
self.context: Any = None
|
||||
self.page: Any = None
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
self._pw_ctx = sync_playwright()
|
||||
self.pw = self._pw_ctx.__enter__()
|
||||
|
||||
def launch(self) -> None:
|
||||
"""启动浏览器"""
|
||||
self.browser = self.pw.chromium.launch(headless=self.headless)
|
||||
self.context = self.browser.new_context(viewport={"width": 1920, "height": 1080})
|
||||
self.page = self.context.new_page()
|
||||
self.page.set_default_timeout(30000)
|
||||
|
||||
def login_portal(self) -> None:
|
||||
"""登录信息门户"""
|
||||
log.info("登录信息门户...")
|
||||
|
||||
self.page.goto(self.config["sso_login_url"], wait_until="domcontentloaded")
|
||||
self._wait_for('text="微信扫码登录"', timeout=5000)
|
||||
|
||||
try:
|
||||
self.page.fill('input[placeholder*="工号"], input[placeholder*="学号"]', self.config["username"])
|
||||
self.page.fill('input[placeholder*="密码"]', self.config["password"])
|
||||
except Exception:
|
||||
log.warning("未找到登录输入框,可能已登录")
|
||||
|
||||
try:
|
||||
checkbox = self.page.query_selector('input[type="checkbox"]')
|
||||
if checkbox and not checkbox.is_checked():
|
||||
checkbox.click()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for selector in ['button:has-text("登录")', 'input[value="登录"]', 'text="登录"']:
|
||||
try:
|
||||
self.page.click(selector, timeout=3000)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
self._wait_for_portal()
|
||||
|
||||
def _wait_for_portal(self) -> None:
|
||||
"""等待跳转到统一信息平台"""
|
||||
for _ in range(30):
|
||||
self.page.wait_for_timeout(1000)
|
||||
url = self.page.url
|
||||
if any(kw in url for kw in ("tyrz.fynu.edu.cn/zs-uip", "tyrz.fynu.edu.cn/oshall", "portal")):
|
||||
self._screenshot("portal_loaded")
|
||||
return
|
||||
log.error("等待门户跳转超时")
|
||||
self._screenshot("portal_timeout")
|
||||
raise TimeoutError("登录超时,未跳转到信息门户")
|
||||
|
||||
def navigate_to_reimburse(self) -> None:
|
||||
"""从统一信息平台进入报销系统"""
|
||||
log.info("进入报销系统...")
|
||||
self._wait_for('text="快捷入口"', timeout=5000)
|
||||
|
||||
try:
|
||||
self.page.click('text="财务系统"', timeout=5000)
|
||||
except Exception:
|
||||
log.warning("未找到财务系统入口")
|
||||
|
||||
new_tab = None
|
||||
for _ in range(15):
|
||||
self.page.wait_for_timeout(1000)
|
||||
for p in self.context.pages:
|
||||
if "dddl" in p.url or "210.45.32.214" in p.url:
|
||||
new_tab = p
|
||||
break
|
||||
if new_tab:
|
||||
break
|
||||
|
||||
if new_tab:
|
||||
self.page = new_tab
|
||||
self._wait_for('text="网络报销"', timeout=5000)
|
||||
else:
|
||||
log.warning(f"未找到单点登录页面,当前 URL: {self.page.url}")
|
||||
|
||||
for p in self.context.pages[:-1]:
|
||||
try:
|
||||
p.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
link = self.page.query_selector('a:has(img[src*="wlbx"])')
|
||||
if link:
|
||||
reimburse_url = link.get_attribute("href")
|
||||
self.page.goto(reimburse_url, wait_until="domcontentloaded", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._wait_for('text="报销录入"', timeout=5000)
|
||||
common_url = self.config["reimburse_url"] + self.config["reimburse_page"]
|
||||
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
|
||||
self._wait_for('text="单据状态:"', timeout=5000)
|
||||
|
||||
def open_reimburse_menu(self) -> None:
|
||||
"""点击「新增」创建新报销单"""
|
||||
log.info("创建新报销单...")
|
||||
self.page.wait_for_timeout(2000)
|
||||
|
||||
try:
|
||||
self.page.click('button:has-text("新增")', timeout=5000)
|
||||
except Exception:
|
||||
try:
|
||||
self.page.click("text=新增", timeout=3000)
|
||||
except Exception as err:
|
||||
self._screenshot("no_add_button")
|
||||
raise RuntimeError("无法点击新增按钮") from err
|
||||
|
||||
self.page.wait_for_timeout(3000)
|
||||
self._screenshot("after_add_click")
|
||||
|
||||
def fill_basic_info(self, description: str = "元器件采购报销") -> None:
|
||||
"""填写基本信息"""
|
||||
log.info("填写基本信息...")
|
||||
|
||||
try:
|
||||
self.page.fill("#EXPENEXPLAIN", description)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.page.click("#PROJECTCODE", timeout=10000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._screenshot("step3_project_modal")
|
||||
|
||||
try:
|
||||
self.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
|
||||
first_row = self.page.query_selector("#promodal .fixed-table-body tbody tr")
|
||||
if first_row:
|
||||
first_row.click()
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._screenshot("step3_project_selected")
|
||||
|
||||
try:
|
||||
self.page.click("#saveAndNext", timeout=5000)
|
||||
self.page.wait_for_timeout(2000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._screenshot("step3_done")
|
||||
|
||||
def add_reimburse_items(self, invoices: list[dict[str, Any]]) -> None:
|
||||
"""录入报销明细(一条总明细)"""
|
||||
card_amount = sum(inv["card_amount"] for inv in invoices)
|
||||
log.info(f"录入报销明细 (合计 ¥{card_amount:.2f})...")
|
||||
|
||||
try:
|
||||
self.page.click("#insertDetail", timeout=5000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
self._wait_for('text="经济事项名称"', timeout=5000)
|
||||
self.page.click("#economicscode2")
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
try:
|
||||
self.page.wait_for_selector("#econmodal .fixed-table-body tbody tr", timeout=10000)
|
||||
rows = self.page.query_selector_all("#econmodal .fixed-table-body tbody tr")
|
||||
if len(rows) >= 3:
|
||||
rows[2].click()
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.page.fill('input[name="expenPwCommondetail.HOWBILLS"]', f"{len(invoices)}")
|
||||
self.page.fill("#je_zwzcdz", f"{card_amount:.2f}")
|
||||
self.page.click("#detailAdd", timeout=3000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
self._screenshot("item_total")
|
||||
except Exception as e:
|
||||
log.error(f"录入总明细失败: {e}")
|
||||
self._screenshot("item_total_error")
|
||||
raise
|
||||
|
||||
def fill_payment(self, invoices: list[dict[str, Any]]) -> None:
|
||||
"""录入支付信息"""
|
||||
log.info("录入支付信息...")
|
||||
try:
|
||||
self.page.click('text="下一步(支付方式)"', timeout=5000)
|
||||
self._wait_for('text="下一步(附件清单)"', timeout=5000)
|
||||
|
||||
for inv in invoices:
|
||||
self.page.click("#insertPay", timeout=5000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
self.page.fill("#personid2", inv["person_id"])
|
||||
self.page.fill("#accountname2", inv["person_name"])
|
||||
self.page.fill("#receiptdate2", inv["card_date"])
|
||||
self.page.fill("#localaccount2", inv["card_no"])
|
||||
self.page.fill("#receiptmoney2", str(inv["card_amount"]))
|
||||
self.page.fill("#money2", str(inv["card_amount"]))
|
||||
self.page.fill("#merchant2", inv["seller_name"])
|
||||
self.page.fill("#smark2", inv["remark"])
|
||||
self.page.click("#payAdd", timeout=3000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"支付方式录入失败: {e}")
|
||||
self._screenshot("step5_error")
|
||||
raise
|
||||
|
||||
self._screenshot("step5_done")
|
||||
|
||||
def upload_attachments(self, invoices: list[dict[str, Any]]) -> None:
|
||||
"""上传发票附件"""
|
||||
log.info("上传附件...")
|
||||
|
||||
try:
|
||||
self.page.click("#next3", timeout=5000)
|
||||
self._wait_for("#submit2", timeout=5000)
|
||||
|
||||
attachment_files = sorted((self.work_dir or Path(__file__).parent.parent).glob("*.pdf"))
|
||||
if not attachment_files:
|
||||
log.warning("未找到附件 PDF,跳过附件上传")
|
||||
return
|
||||
|
||||
for i, inv in enumerate(invoices):
|
||||
file_path = attachment_files[i] if i < len(attachment_files) else None
|
||||
try:
|
||||
self._wait_for("#insertAcc", timeout=20000) # 等待增加按钮出现
|
||||
self.page.click("#insertAcc", timeout=5000) # 点击增加按钮出现
|
||||
self._wait_for("#fjlx", timeout=5000)
|
||||
self.page.select_option("#fjlx", "1")
|
||||
explanation = f"{inv['item_name']} - {inv['invoice_no']}"
|
||||
self.page.fill("#fpsmxx", explanation)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if file_path and file_path.exists():
|
||||
try:
|
||||
self.page.set_input_files("#file", str(file_path))
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"文件上传失败: {e}")
|
||||
try:
|
||||
self.page.click("#cjtj", timeout=5000)
|
||||
except Exception:
|
||||
try:
|
||||
self.page.press("body", "Escape")
|
||||
except Exception:
|
||||
pass
|
||||
log.info("上传附件完成")
|
||||
except Exception as e:
|
||||
log.error(f"附件上传失败: {e}")
|
||||
self._screenshot("step6_error")
|
||||
raise
|
||||
|
||||
self._screenshot("step6_done")
|
||||
|
||||
def submit(self) -> None:
|
||||
"""提交报销单"""
|
||||
log.info("提交报销单...")
|
||||
try:
|
||||
self.page.click("#submit", timeout=5000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
self._screenshot("submitted")
|
||||
except Exception as e:
|
||||
log.error(f"提交失败: {e}")
|
||||
self._screenshot("submit_error")
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭浏览器"""
|
||||
if self.context:
|
||||
self.context.close()
|
||||
if self.browser:
|
||||
self.browser.close()
|
||||
try:
|
||||
self._pw_ctx.__exit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 辅助方法
|
||||
# --------------------------------------------------------
|
||||
|
||||
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
|
||||
self.page.wait_for_selector(selector, timeout=timeout)
|
||||
|
||||
def _screenshot(self, name: str) -> None:
|
||||
img_dir = Path(__file__).parent.parent / "images"
|
||||
img_dir.mkdir(exist_ok=True)
|
||||
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 对外入口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_bot(
|
||||
config: dict[str, Any], invoices: list[dict[str, Any]], headless: bool = False, work_dir: Path | None = None
|
||||
) -> None:
|
||||
"""执行完整的浏览器填报流程"""
|
||||
if not config["username"] or not config["password"]:
|
||||
raise ValueError("缺少用户名或密码")
|
||||
|
||||
bot = ReimburseBot(config, headless=headless)
|
||||
bot.work_dir = work_dir
|
||||
try:
|
||||
bot.launch()
|
||||
bot.login_portal()
|
||||
bot.navigate_to_reimburse()
|
||||
bot.open_reimburse_menu()
|
||||
bot.fill_basic_info()
|
||||
bot.add_reimburse_items(invoices)
|
||||
bot.fill_payment(invoices)
|
||||
bot.upload_attachments(invoices)
|
||||
# bot.submit() # 确认无误后再取消注释
|
||||
except Exception as e:
|
||||
log.error(f"操作失败: {e}")
|
||||
try:
|
||||
bot._screenshot("error")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
bot.close()
|
||||
|
||||
|
||||
def run_bot_web(config: dict[str, Any], invoices: list[dict[str, Any]], work_dir: Path) -> None:
|
||||
"""Web 模式填报 — headless,附件从指定目录读取"""
|
||||
if not config["username"] or not config["password"]:
|
||||
raise ValueError("缺少用户名或密码")
|
||||
|
||||
bot = ReimburseBot(config, headless=True)
|
||||
bot.work_dir = work_dir
|
||||
try:
|
||||
bot.launch()
|
||||
bot.login_portal()
|
||||
bot.navigate_to_reimburse()
|
||||
bot.open_reimburse_menu()
|
||||
bot.fill_basic_info()
|
||||
bot.add_reimburse_items(invoices)
|
||||
bot.fill_payment(invoices)
|
||||
bot.upload_attachments(invoices)
|
||||
except Exception as e:
|
||||
log.error(f"操作失败: {e}")
|
||||
try:
|
||||
bot._screenshot("error")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
bot.close()
|
||||
49
src/config.py
Normal file
49
src/config.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
配置加载
|
||||
|
||||
从项目根目录的 config.json 读取配置,返回结构化的配置字典。
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_CONFIG_PATH = Path(__file__).parent.parent / "config.json"
|
||||
|
||||
|
||||
def load_config() -> dict[str, str | Path]:
|
||||
"""加载并合并配置,缺失字段使用默认值"""
|
||||
raw = {}
|
||||
if _CONFIG_PATH.exists():
|
||||
with open(_CONFIG_PATH, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
project_root = _CONFIG_PATH.parent
|
||||
|
||||
return {
|
||||
"sso_login_url": raw.get("sso_login_url", "https://tyrz.fynu.edu.cn/sso/login"),
|
||||
"portal_url": raw.get("portal_url", "https://tyrz.fynu.edu.cn/oshall"),
|
||||
"reimburse_url": raw.get("reimburse_url", "http://210.45.32.214:8081"),
|
||||
"reimburse_page": raw.get("reimburse_page", "/expen/common/common?v=4.0"),
|
||||
"username": raw.get("username", ""),
|
||||
"password": raw.get("password", ""),
|
||||
"default_name": raw.get("default_name", ""),
|
||||
"default_card_no": raw.get("default_card_no", ""),
|
||||
"default_person_id": raw.get("default_person_id", ""),
|
||||
"consumable_storage": raw.get("consumable_storage", "躬行楼 C205"),
|
||||
"attachment_dir": project_root / "attachments",
|
||||
}
|
||||
|
||||
|
||||
def get_llm_config() -> dict[str, str]:
|
||||
"""加载 LLM 配置,缺失字段使用默认值"""
|
||||
raw = {}
|
||||
if _CONFIG_PATH.exists():
|
||||
with open(_CONFIG_PATH, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
llm_raw = raw.get("llm", {})
|
||||
return {
|
||||
"model": llm_raw.get("model", "qwen-vl-max"),
|
||||
"api_base": llm_raw.get("api_base", "http://localhost:8080/v1"),
|
||||
"api_key": llm_raw.get("api_key", "lm-studio"),
|
||||
}
|
||||
49
src/doc/README.md
Normal file
49
src/doc/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
|
||||
## last_reviewed: 2026-06-09
|
||||
|
||||
# src/doc — 文档处理模块
|
||||
|
||||
负责发票信息提取、基于 LLM 的支付截图信息识别、以及将数据填入 Word 出库单模板。
|
||||
|
||||
## 模块清单
|
||||
|
||||
|
||||
| 文件 | 作用 |
|
||||
| ------------------------ | -------------------------------------------------- |
|
||||
| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 |
|
||||
| `pdf.py` | PDF 文件发现与文本提取(pdfplumber) |
|
||||
| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态) |
|
||||
| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 |
|
||||
| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 |
|
||||
| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板(pywin32 COM) |
|
||||
| `prompt.py` | LLM 提示词模板加载 |
|
||||
| `prompts/` | 提示词模板文件(`invoice_system.md`、`card_info_system.md`) |
|
||||
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
|
||||
支付截图 → llm_extractor.py → [刷卡记录]
|
||||
↓
|
||||
matcher.py(按金额贪心匹配,容差 10 元)
|
||||
↓
|
||||
invoice.py 分类 → CSV(已回填刷卡日期/卡号/金额)
|
||||
↓
|
||||
fill_consumable_doc → 易耗品出库单.doc
|
||||
```
|
||||
|
||||
## 依赖说明
|
||||
|
||||
- **pdfplumber** — PDF 文本提取
|
||||
- **pywin32** — Word COM 自动化(仅 Windows)
|
||||
- **llama-index** — LLM 信息提取
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `fill_consumable_doc.py` 依赖 Microsoft Word + COM,仅 Windows 可用
|
||||
- LLM 提取不会覆盖 CSV 中已有非空字段
|
||||
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
|
||||
- LLM 提取失败时直接报错,无正则回退
|
||||
|
||||
4
src/doc/__init__.py
Normal file
4
src/doc/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""文档处理模块
|
||||
|
||||
包含发票提取、LLM 信息提取、出库单填写等功能。
|
||||
"""
|
||||
65
src/doc/extractor.py
Normal file
65
src/doc/extractor.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""发票提取编排
|
||||
|
||||
串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类,生成支付记录列表。
|
||||
|
||||
对外接口:
|
||||
extract_invoices(directory) -> tuple[list[dict], dict]
|
||||
"""
|
||||
|
||||
from .. import get_logger
|
||||
from .invoice import classify_invoice_batch
|
||||
from .llm_extractor import extract_invoice_from_text
|
||||
from .matcher import match_invoices_to_cards
|
||||
from .pdf import extract_text_from_pdf, find_pdf_files
|
||||
|
||||
log = get_logger("extractor")
|
||||
|
||||
|
||||
def extract_invoices(
|
||||
directory: str = ".",
|
||||
) -> tuple[list[dict[str, str]], dict[str, list[dict[str, str]]]]:
|
||||
"""扫描目录下所有 PDF,提取发票信息并匹配支付记录
|
||||
|
||||
Returns:
|
||||
(payment_records, groups): 支付记录列表和按发票类型分组的字典
|
||||
groups = {'travel': [差旅发票], 'general': [普通发票]}
|
||||
"""
|
||||
pdf_files = find_pdf_files(directory)
|
||||
if not pdf_files:
|
||||
log.warning("未找到 PDF 文件")
|
||||
return [], {"travel": [], "general": []}
|
||||
|
||||
log.info(f"发现 {len(pdf_files)} 个 PDF 文件")
|
||||
|
||||
all_invoices = []
|
||||
for pdf_path in pdf_files:
|
||||
text = extract_text_from_pdf(pdf_path)
|
||||
if not text:
|
||||
log.warning(f"未能提取文本: {pdf_path.name}")
|
||||
continue
|
||||
|
||||
invoice = extract_invoice_from_text(text, pdf_path.name)
|
||||
|
||||
if invoice and invoice.get("发票号码"):
|
||||
all_invoices.append(invoice)
|
||||
log.info(f"[{invoice['发票类型']}] 已解析: {pdf_path.name}")
|
||||
else:
|
||||
log.warning(f"未能解析: {pdf_path.name}")
|
||||
|
||||
if all_invoices:
|
||||
log.info(f"共处理 {len(all_invoices)} 张发票")
|
||||
else:
|
||||
log.warning("未成功解析任何发票")
|
||||
|
||||
# 将支付截图与发票进行金额匹配,返回以支付记录为主键的列表
|
||||
payment_records = match_invoices_to_cards(all_invoices, directory)
|
||||
|
||||
# 从支付记录中还原所有发票用于分类
|
||||
all_invoices_restored: list[dict[str, str]] = []
|
||||
for record in payment_records:
|
||||
all_invoices_restored.extend(record.get("_matched_invoices", []))
|
||||
|
||||
groups = classify_invoice_batch(all_invoices_restored)
|
||||
log.info(f"差旅发票: {len(groups['travel'])} 张, 普通发票: {len(groups['general'])} 张")
|
||||
|
||||
return payment_records, groups
|
||||
258
src/doc/fill_consumable_doc.py
Normal file
258
src/doc/fill_consumable_doc.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。
|
||||
|
||||
仅写入表格数据单元格,保留原模板字体、边框与版式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from ..bot import load_invoice_data
|
||||
from ..config import load_config
|
||||
|
||||
log = get_logger("fill_consumable_doc")
|
||||
|
||||
CONSUMABLE_DOC_FILENAME = "易耗品、出库单.doc"
|
||||
|
||||
# Word COM 常量
|
||||
WD_CHARACTER = 1
|
||||
|
||||
# 表格统一字体:宋体、五号(10.5 磅)
|
||||
TABLE_FONT_NAME = "宋体"
|
||||
TABLE_FONT_SIZE = 10.5
|
||||
|
||||
|
||||
def _split_name_spec(left: str) -> tuple[str, str]:
|
||||
m = re.search(r"(\S+一批)\s*$", left)
|
||||
if m:
|
||||
return m.group(1), left[: m.start()].strip()
|
||||
parts = left.split(" ", 1)
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return left, ""
|
||||
|
||||
|
||||
def parse_spec_model(spec: str) -> dict[str, str]:
|
||||
spec = (spec or "").strip()
|
||||
if " 个 " not in spec:
|
||||
return {
|
||||
"product_name": spec,
|
||||
"spec": "",
|
||||
"unit": "",
|
||||
"qty": "",
|
||||
"unit_price": "",
|
||||
}
|
||||
|
||||
left, right = spec.split(" 个 ", 1)
|
||||
product_name, model_spec = _split_name_spec(left.strip())
|
||||
tokens = right.split()
|
||||
|
||||
qty = ""
|
||||
unit_price = ""
|
||||
if len(tokens) >= 4 and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]):
|
||||
qty, unit_price = tokens[0], tokens[1]
|
||||
elif tokens and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]):
|
||||
qty, unit_price = "1", tokens[0]
|
||||
|
||||
return {
|
||||
"product_name": product_name,
|
||||
"spec": model_spec,
|
||||
"unit": "个",
|
||||
"qty": qty,
|
||||
"unit_price": unit_price,
|
||||
}
|
||||
|
||||
|
||||
def _format_money(value: str | float) -> str:
|
||||
"""单价、金额:固定保留两位小数。"""
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
return f"{num:.2f}"
|
||||
|
||||
|
||||
def _today_cn_date() -> str:
|
||||
"""当前日期,格式:2026年5月26日"""
|
||||
today = date.today()
|
||||
return f"{today.year}年{today.month}月{today.day}日"
|
||||
|
||||
|
||||
def _apply_font(rng: Any) -> None:
|
||||
"""将范围字体设为宋体五号(含数字与英文)。"""
|
||||
font = rng.Font
|
||||
font.Name = TABLE_FONT_NAME
|
||||
font.NameFarEast = TABLE_FONT_NAME
|
||||
font.NameAscii = TABLE_FONT_NAME
|
||||
font.NameOther = TABLE_FONT_NAME
|
||||
font.NameBi = TABLE_FONT_NAME
|
||||
font.Size = TABLE_FONT_SIZE
|
||||
|
||||
|
||||
def _set_cell_value(cell: Any, text: str) -> None:
|
||||
"""写入单元格正文(不含末尾单元格标记)。"""
|
||||
rng = cell.Range
|
||||
rng.MoveEnd(WD_CHARACTER, -1)
|
||||
rng.Text = "" if text is None else str(text)
|
||||
_apply_font(rng)
|
||||
|
||||
|
||||
def _normalize_table_font(tbl: Any) -> None:
|
||||
"""填写完成后统一整张表的字体。"""
|
||||
for row in tbl.Rows:
|
||||
for cell in row.Cells:
|
||||
rng = cell.Range
|
||||
rng.MoveEnd(WD_CHARACTER, -1)
|
||||
_apply_font(rng)
|
||||
|
||||
|
||||
def _replace_date_in_doc(doc: Any, new_date: str) -> None:
|
||||
"""仅替换表头段落中的日期文字,不改动段落其余部分。"""
|
||||
if not new_date:
|
||||
return
|
||||
try:
|
||||
para = doc.Paragraphs(3)
|
||||
except Exception:
|
||||
return
|
||||
rng = para.Range
|
||||
text = rng.Text.replace("\r", "").replace("\x07", "")
|
||||
m = re.search(r"\d{4}年\d{1,2}月\d{1,2}日", text)
|
||||
if not m:
|
||||
return
|
||||
start = rng.Start + m.start()
|
||||
end = rng.Start + m.end()
|
||||
doc.Range(Start=start, End=end).Text = new_date
|
||||
|
||||
|
||||
def fill_consumable_doc(
|
||||
csv_path: str | Path,
|
||||
doc_path: str | Path,
|
||||
config: dict[str, Any] | None = None,
|
||||
backup: bool = True,
|
||||
) -> Path:
|
||||
csv_path = Path(csv_path)
|
||||
doc_path = Path(doc_path)
|
||||
if config is None:
|
||||
config = load_config()
|
||||
invoices = load_invoice_data(str(csv_path), config)
|
||||
|
||||
if backup:
|
||||
bak = doc_path.with_suffix(doc_path.suffix + ".bak")
|
||||
shutil.copy2(doc_path, bak)
|
||||
|
||||
import pythoncom
|
||||
import win32com.client
|
||||
|
||||
pythoncom.CoInitialize()
|
||||
try:
|
||||
word = win32com.client.Dispatch("Word.Application")
|
||||
word.Visible = False
|
||||
word.DisplayAlerts = 0
|
||||
doc = word.Documents.Open(str(doc_path.resolve()))
|
||||
|
||||
try:
|
||||
_replace_date_in_doc(doc, _today_cn_date())
|
||||
|
||||
tbl = doc.Tables(1)
|
||||
storage = config.get("consumable_storage", "躬行楼 C205")
|
||||
|
||||
for i, inv in enumerate(invoices):
|
||||
row_idx = i + 2
|
||||
if row_idx > tbl.Rows.Count:
|
||||
break
|
||||
|
||||
parsed = parse_spec_model(str(inv.get("spec_model", "")))
|
||||
# 当规格型号为空时,从项目名称提取产品信息
|
||||
if not parsed["product_name"]:
|
||||
item_name = str(inv.get("item_name", ""))
|
||||
# 去除 "*分类*" 前缀(如 "*电子工业设备*元件盒" -> "元件盒")
|
||||
if "*" in item_name:
|
||||
item_name = item_name.split("*")[-1].strip()
|
||||
parsed["product_name"] = item_name
|
||||
|
||||
card_amount_raw = inv.get("card_amount") or 0
|
||||
card_amount: float = float(str(card_amount_raw).replace(",", ""))
|
||||
qty_str = parsed["qty"]
|
||||
qty_val = int(qty_str) if qty_str and qty_str.isdigit() else 0
|
||||
|
||||
# 金额填写刷卡金额,单价由刷卡金额反算
|
||||
amount = _format_money(card_amount)
|
||||
unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount)
|
||||
|
||||
# 数量:去掉前导零;若无数量则默认为 1
|
||||
qty = str(qty_val) if qty_val > 0 else "1"
|
||||
|
||||
values = [
|
||||
str(inv.get("seq", i + 1)),
|
||||
parsed["product_name"],
|
||||
parsed["spec"],
|
||||
parsed["unit"],
|
||||
qty,
|
||||
unit_price,
|
||||
amount,
|
||||
"", # 购货人签字 — 保持空白
|
||||
storage,
|
||||
"", # 领用人签字 — 保持空白
|
||||
"", # 备注 — 保持空白,避免撑破版式
|
||||
]
|
||||
|
||||
for col_idx, val in enumerate(values, start=1):
|
||||
_set_cell_value(tbl.Cell(row_idx, col_idx), str(val))
|
||||
|
||||
_normalize_table_font(tbl)
|
||||
|
||||
doc.Save()
|
||||
finally:
|
||||
doc.Close()
|
||||
word.Quit()
|
||||
finally:
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
return doc_path
|
||||
|
||||
|
||||
def fill_consumable_from_template(
|
||||
csv_path: str | Path,
|
||||
template_path: str | Path,
|
||||
output_path: str | Path,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> Path:
|
||||
"""从模板复制并填写出库单(Web 会话每次从模板重新生成)。"""
|
||||
template_path = Path(template_path)
|
||||
output_path = Path(output_path)
|
||||
if not template_path.exists():
|
||||
raise FileNotFoundError(f"出库单模板不存在: {template_path}")
|
||||
shutil.copy2(template_path, output_path)
|
||||
return fill_consumable_doc(csv_path, output_path, config=config, backup=False)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(description="将发票 CSV 填入易耗品出库单")
|
||||
parser.add_argument("--csv", default=str(root / "invoice_summary.csv"))
|
||||
parser.add_argument("--doc", default=str(root / "易耗品、出库单.doc"))
|
||||
parser.add_argument("--config", default=str(root / "config.json"))
|
||||
parser.add_argument("--no-backup", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = None
|
||||
if Path(args.config).exists():
|
||||
import json
|
||||
|
||||
with open(args.config, encoding="utf-8") as f:
|
||||
cfg = {**load_config(), **json.load(f)}
|
||||
out = fill_consumable_doc(args.csv, args.doc, config=cfg, backup=not args.no_backup)
|
||||
print(f"已填写并保存: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
273
src/doc/invoice.py
Normal file
273
src/doc/invoice.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""发票数据模型与 CSV 工具
|
||||
|
||||
定义发票类型常量、CSV 列结构,提供发票分类和 CSV 读写功能。
|
||||
|
||||
对外接口:
|
||||
INVOICE_LEVEL_COLUMNS 发票级别 CSV 列定义
|
||||
PAYMENT_RECORD_COLUMNS 支付记录级别 CSV 列定义
|
||||
INVOICE_TYPE_* 发票类型常量
|
||||
is_travel_invoice(type) 判断是否为差旅发票
|
||||
classify_invoice_batch(invoices) 按类型分组
|
||||
load_csv(path) 读取支付记录 CSV
|
||||
save_csv(payment_records, path) 保存支付记录 CSV
|
||||
save_invoice_csv(payment_records, path) 保存发票级别 CSV
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .. import get_logger
|
||||
|
||||
log = get_logger("invoice")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CSV 列定义
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# 发票级别 CSV 列(用于 invoice_summary.csv,每行一张发票)
|
||||
INVOICE_LEVEL_COLUMNS = [
|
||||
"序号",
|
||||
"发票类型",
|
||||
"发票号码",
|
||||
"开票日期",
|
||||
"项目名称",
|
||||
"规格型号",
|
||||
"价税合计",
|
||||
"销售方名称",
|
||||
"出发站",
|
||||
"到达站",
|
||||
"车次",
|
||||
"乘车日期",
|
||||
"座位等级",
|
||||
"人员姓名",
|
||||
"刷卡日期",
|
||||
"公务卡号",
|
||||
"刷卡金额",
|
||||
"备注",
|
||||
"工号",
|
||||
]
|
||||
|
||||
# 支付记录级别 CSV 列(用于 payment_records.csv,每行一笔支付)
|
||||
PAYMENT_RECORD_COLUMNS = [
|
||||
"序号",
|
||||
# 支付信息
|
||||
"刷卡日期",
|
||||
"公务卡号",
|
||||
"刷卡金额",
|
||||
# 发票聚合信息
|
||||
"关联发票数",
|
||||
"发票详情", # 格式: 类型[号码]¥金额 | 类型[号码]¥金额
|
||||
"备注",
|
||||
# 内部字段(用于下游解析)
|
||||
"_invoices_json", # JSON 序列化的发票列表,供 bot/fill_doc 使用
|
||||
"工号",
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 发票类型常量
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
INVOICE_TYPE_TRAIN = "高铁票"
|
||||
INVOICE_TYPE_HOTEL = "酒店住宿"
|
||||
INVOICE_TYPE_GENERAL = "普通发票"
|
||||
|
||||
INVOICE_TYPE_TRAVEL = frozenset([INVOICE_TYPE_TRAIN, INVOICE_TYPE_HOTEL])
|
||||
|
||||
|
||||
def is_travel_invoice(invoice_type: str) -> bool:
|
||||
"""判断是否为差旅发票(高铁票/酒店住宿)"""
|
||||
return invoice_type in INVOICE_TYPE_TRAVEL
|
||||
|
||||
|
||||
def classify_invoice_batch(invoices: list[dict[str, str]]) -> dict[str, list[dict[str, str]]]:
|
||||
"""将发票列表按类型分组:{'travel': [...], 'general': [...]}"""
|
||||
travel = []
|
||||
general = []
|
||||
for inv in invoices:
|
||||
inv_type = inv.get("发票类型", INVOICE_TYPE_GENERAL)
|
||||
if is_travel_invoice(inv_type):
|
||||
travel.append(inv)
|
||||
else:
|
||||
general.append(inv)
|
||||
return {"travel": travel, "general": general}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CSV 读写工具
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clean_invoice_for_json(inv: dict[str, str]) -> dict[str, str]:
|
||||
"""清理发票字典中的内部字段,保留可序列化的字段"""
|
||||
clean = {}
|
||||
for k, v in inv.items():
|
||||
if k.startswith("_"):
|
||||
continue
|
||||
clean[k] = v
|
||||
return clean
|
||||
|
||||
|
||||
def load_csv(csv_path: Path) -> list[dict[str, str]] | None:
|
||||
"""读取支付记录 CSV 为 dict 列表,失败返回 None"""
|
||||
try:
|
||||
with open(csv_path, encoding="utf-8", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
fieldnames = reader.fieldnames or []
|
||||
missing = [c for c in PAYMENT_RECORD_COLUMNS if c not in fieldnames]
|
||||
if missing:
|
||||
log.error(f"CSV 缺少必要列: {missing}")
|
||||
return None
|
||||
return [row for row in reader]
|
||||
except FileNotFoundError:
|
||||
log.error(f"CSV 文件不存在: {csv_path.name}")
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"CSV 读取失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def load_invoice_csv(csv_path: Path) -> list[dict[str, str]] | None:
|
||||
"""读取发票级别 CSV 为 dict 列表(每行一张发票),失败返回 None"""
|
||||
try:
|
||||
with open(csv_path, encoding="utf-8", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
fieldnames = reader.fieldnames or []
|
||||
missing = [c for c in INVOICE_LEVEL_COLUMNS if c not in fieldnames]
|
||||
if missing:
|
||||
log.error(f"发票 CSV 缺少必要列: {missing}")
|
||||
return None
|
||||
return [row for row in reader]
|
||||
except FileNotFoundError:
|
||||
log.error(f"发票 CSV 文件不存在: {csv_path.name}")
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"发票 CSV 读取失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def load_invoices_from_csv(csv_path: Path) -> list[dict[str, str]] | None:
|
||||
"""从支付记录 CSV 中还原发票级别的数据(供 bot/fill_doc 使用)
|
||||
|
||||
读取 _invoices_json 字段,反序列化后展平为发票列表。
|
||||
"""
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
return None
|
||||
|
||||
invoices = []
|
||||
for row in rows:
|
||||
invoices_json = row.get("_invoices_json", "")
|
||||
if not invoices_json:
|
||||
continue
|
||||
try:
|
||||
inv_list = json.loads(invoices_json)
|
||||
for inv in inv_list:
|
||||
# 从支付记录回填刷卡信息
|
||||
inv["刷卡日期"] = row.get("刷卡日期", inv.get("刷卡日期", ""))
|
||||
inv["公务卡号"] = row.get("公务卡号", inv.get("公务卡号", ""))
|
||||
inv["刷卡金额"] = row.get("刷卡金额", inv.get("刷卡金额", ""))
|
||||
invoices.append(inv)
|
||||
except json.JSONDecodeError:
|
||||
log.warning(f"无法解析发票 JSON: {invoices_json[:50]}...")
|
||||
return invoices
|
||||
|
||||
|
||||
def save_csv(
|
||||
payment_records: list[dict[str, str]],
|
||||
output_path: str | Path = "payment_records.csv",
|
||||
) -> None:
|
||||
"""将支付记录列表保存为 CSV(以支付记录为主键)
|
||||
|
||||
每条支付记录包含:
|
||||
- 刷卡日期、公务卡号、刷卡金额(支付信息)
|
||||
- 关联发票数、发票详情(发票聚合信息)
|
||||
- _matched_invoices(内部字段,序列化为 JSON 存储在 CSV 中)
|
||||
"""
|
||||
csv_path = Path(output_path)
|
||||
|
||||
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(PAYMENT_RECORD_COLUMNS)
|
||||
|
||||
for idx, record in enumerate(payment_records, 1):
|
||||
# 序列化关联发票为 JSON
|
||||
matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment]
|
||||
invoices_json = json.dumps(
|
||||
[_clean_invoice_for_json(inv) for inv in matched_invoices],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
idx,
|
||||
record.get("刷卡日期", ""),
|
||||
record.get("公务卡号", ""),
|
||||
record.get("刷卡金额", ""),
|
||||
record.get("关联发票数", str(len(matched_invoices))),
|
||||
record.get("发票详情", ""),
|
||||
record.get("备注", ""),
|
||||
invoices_json,
|
||||
record.get("工号", ""),
|
||||
]
|
||||
)
|
||||
|
||||
log.info(f"支付记录 CSV 已保存: {csv_path.name}")
|
||||
|
||||
|
||||
def save_invoice_csv(
|
||||
payment_records: list[dict[str, str]],
|
||||
output_path: str | Path = "invoice_summary.csv",
|
||||
) -> None:
|
||||
"""将支付记录展平为发票级别 CSV(每行一张发票)
|
||||
|
||||
从 _matched_invoices 中还原每张发票,回填刷卡信息,
|
||||
生成以发票为主键的 CSV,用于人工填写报销单参考。
|
||||
"""
|
||||
csv_path = Path(output_path)
|
||||
|
||||
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(INVOICE_LEVEL_COLUMNS)
|
||||
|
||||
idx = 1
|
||||
for record in payment_records:
|
||||
matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment]
|
||||
for inv in matched_invoices:
|
||||
clean_inv = _clean_invoice_for_json(inv)
|
||||
writer.writerow(
|
||||
[
|
||||
idx,
|
||||
clean_inv.get("发票类型", ""),
|
||||
clean_inv.get("发票号码", ""),
|
||||
clean_inv.get("开票日期", ""),
|
||||
clean_inv.get("项目名称", ""),
|
||||
clean_inv.get("规格型号", ""),
|
||||
clean_inv.get("价税合计", ""),
|
||||
clean_inv.get("销售方名称", ""),
|
||||
clean_inv.get("出发站", ""),
|
||||
clean_inv.get("到达站", ""),
|
||||
clean_inv.get("车次", ""),
|
||||
clean_inv.get("乘车日期", ""),
|
||||
clean_inv.get("座位等级", ""),
|
||||
clean_inv.get("人员姓名", ""),
|
||||
record.get("刷卡日期", ""),
|
||||
record.get("公务卡号", ""),
|
||||
record.get("刷卡金额", ""),
|
||||
record.get("备注", ""),
|
||||
record.get("工号", ""),
|
||||
]
|
||||
)
|
||||
idx += 1
|
||||
|
||||
log.info(f"发票级别 CSV 已保存: {csv_path.name}")
|
||||
|
||||
|
||||
def save_csv_rows(csv_path: Path, rows: list[dict[str, str]]) -> None:
|
||||
"""将 dict 列表保存为支付记录 CSV(用于更新已有 CSV)"""
|
||||
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=PAYMENT_RECORD_COLUMNS)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
log.info(f"CSV 已保存: {csv_path.name}")
|
||||
210
src/doc/llm_extractor.py
Normal file
210
src/doc/llm_extractor.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
LLM 信息提取
|
||||
|
||||
使用 LLM 从 PDF 文本中提取结构化数据,以及从支付截图中提取刷卡信息。
|
||||
支持 JSON 格式输出,字段与 CSV_COLUMNS 对齐。
|
||||
|
||||
对外接口:
|
||||
extract_invoice_from_text(text, file_name) -> dict 从 PDF 文本提取发票信息
|
||||
extract_card_info_from_image(image_path) -> dict 从支付截图提取刷卡信息
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from .. import get_logger
|
||||
from .prompt import build_card_info_system_prompt, build_invoice_system_prompt
|
||||
|
||||
log = get_logger("llm_extractor")
|
||||
|
||||
|
||||
def _create_llm() -> Any:
|
||||
"""根据配置文件创建 LLM 实例。"""
|
||||
try:
|
||||
from llama_index.llms.openai_like import OpenAILike
|
||||
except ImportError:
|
||||
log.error("缺少 llama-index-llms-openai-like,请执行: uv pip install llama-index-llms-openai-like")
|
||||
raise
|
||||
|
||||
from ..config import get_llm_config
|
||||
|
||||
llm_config = get_llm_config()
|
||||
return OpenAILike(
|
||||
model=llm_config["model"],
|
||||
api_base=llm_config["api_base"],
|
||||
api_key=llm_config.get("api_key", "lm-studio"),
|
||||
temperature=0.1,
|
||||
max_tokens=8192,
|
||||
request_timeout=600.0,
|
||||
is_chat_model=True,
|
||||
)
|
||||
|
||||
|
||||
def _llm_query(system_prompt: str, user_content: str, max_tokens: int = 4096) -> str:
|
||||
"""发送请求到 LLM 并返回完整响应文本。"""
|
||||
from llama_index.core.llms import ChatMessage
|
||||
|
||||
from ..config import get_llm_config
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="system", content=system_prompt),
|
||||
ChatMessage(role="user", content=user_content),
|
||||
]
|
||||
|
||||
llm_config = get_llm_config()
|
||||
llm = _create_llm()
|
||||
log.info("开始请求 LLM (model=%s, base=%s)", llm_config["model"], llm_config["api_base"])
|
||||
|
||||
try:
|
||||
parts = []
|
||||
for resp in llm.stream_chat(
|
||||
messages,
|
||||
temperature=0.1,
|
||||
max_tokens=max_tokens,
|
||||
extra_body={"reasoning_effort": "none"},
|
||||
):
|
||||
delta = resp.delta
|
||||
if delta:
|
||||
parts.append(delta)
|
||||
text = "".join(parts)
|
||||
log.info("LLM 请求完成,响应总长度: %d 字符", len(text))
|
||||
log.info("LLM 响应: %s", text)
|
||||
return text
|
||||
except Exception as e:
|
||||
log.error("LLM 请求失败: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def _parse_json_response(text: str) -> dict[str, Any]:
|
||||
"""从 LLM 响应中提取 JSON,处理可能的 Markdown 包裹。"""
|
||||
text = text.strip()
|
||||
|
||||
# 处理 ```json ... ``` 包裹
|
||||
if "```" in text:
|
||||
# 提取第一个代码块
|
||||
start = text.find("```") + 3
|
||||
end = text.find("```", start)
|
||||
if end > start:
|
||||
text = text[start:end].strip()
|
||||
|
||||
# 去掉可能的前缀 (如 "json")
|
||||
if text.lower().startswith("json"):
|
||||
text = text[4:].strip()
|
||||
|
||||
return cast(dict[str, Any], json.loads(text))
|
||||
|
||||
|
||||
def extract_invoice_from_text(text: str, file_name: str = "") -> dict[str, Any]:
|
||||
"""从 PDF 发票文本中提取结构化数据。
|
||||
|
||||
Args:
|
||||
text: PDF 提取的文本内容。
|
||||
file_name: 原始文件名(用于日志)。
|
||||
|
||||
Returns:
|
||||
包含所有 CSV_COLUMNS 字段的字典。
|
||||
"""
|
||||
system_prompt = build_invoice_system_prompt()
|
||||
user_content = f"请分析以下发票文本并提取信息:\n\n文件名: {file_name}\n\n---\n\n{text}\n\n---"
|
||||
|
||||
try:
|
||||
response = _llm_query(system_prompt, user_content, max_tokens=4096)
|
||||
result = _parse_json_response(response)
|
||||
log.info("LLM 发票提取成功: %s", file_name)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.error("LLM 发票提取失败: %s (%s)", file_name, e)
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 支付截图信息提取(多模态)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _image_to_base64(image_path: Path) -> str:
|
||||
"""将图片文件读取为 base64 字符串。"""
|
||||
with open(image_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
|
||||
def _llm_query_multimodal(
|
||||
system_prompt: str,
|
||||
text: str,
|
||||
image_b64: str,
|
||||
max_tokens: int = 4096,
|
||||
) -> str:
|
||||
"""发送多模态请求(文本 + 图片)到 LLM。"""
|
||||
from llama_index.core.base.llms.types import ImageBlock, TextBlock
|
||||
from llama_index.core.llms import ChatMessage
|
||||
|
||||
from ..config import get_llm_config
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="system", content=system_prompt),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
blocks=[
|
||||
TextBlock(text=text),
|
||||
ImageBlock(
|
||||
url=f"data:image/jpeg;base64,{image_b64}",
|
||||
detail="high",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
llm_config = get_llm_config()
|
||||
llm = _create_llm()
|
||||
log.info(
|
||||
"开始请求 LLM 多模态 (model=%s, base=%s)",
|
||||
llm_config["model"],
|
||||
llm_config["api_base"],
|
||||
)
|
||||
|
||||
try:
|
||||
parts = []
|
||||
for resp in llm.stream_chat(
|
||||
messages,
|
||||
temperature=0.1,
|
||||
max_tokens=max_tokens,
|
||||
extra_body={"reasoning_effort": "none"},
|
||||
):
|
||||
delta = resp.delta
|
||||
if delta:
|
||||
parts.append(delta)
|
||||
text = "".join(parts)
|
||||
log.info("LLM 多模态请求完成,响应总长度: %d 字符", len(text))
|
||||
log.info("LLM 多模态响应: %s", text)
|
||||
return text
|
||||
except Exception as e:
|
||||
log.error("LLM 多模态请求失败: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def extract_card_info_from_image(image_path: Path) -> dict[str, Any]:
|
||||
"""从支付截图中提取刷卡信息。
|
||||
|
||||
Args:
|
||||
image_path: 支付截图图片路径。
|
||||
|
||||
Returns:
|
||||
包含刷卡日期、刷卡金额、公务卡号的字典。
|
||||
"""
|
||||
system_prompt = build_card_info_system_prompt()
|
||||
user_text = f"请分析以下支付截图并提取信息:\n\n文件名: {image_path.name}"
|
||||
|
||||
image_b64 = _image_to_base64(image_path)
|
||||
|
||||
try:
|
||||
response = _llm_query_multimodal(system_prompt, user_text, image_b64, max_tokens=4096)
|
||||
result = _parse_json_response(response)
|
||||
log.info("LLM 支付截图提取成功: %s", image_path.name)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.error("LLM 支付截图提取失败: %s (%s)", image_path.name, e)
|
||||
raise
|
||||
395
src/doc/matcher.py
Normal file
395
src/doc/matcher.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""发票与支付截图匹配
|
||||
|
||||
将提取到的发票数据与支付截图中的刷卡记录进行金额匹配,
|
||||
输出以支付记录为主键的结果列表。
|
||||
|
||||
## 业务约束
|
||||
|
||||
- 发票数 >= 付款记录数(最少一张发票对应一张付款记录)
|
||||
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
|
||||
- 若发票数 == 付款数,走一对一匹配,无需一对多
|
||||
|
||||
## 匹配流程
|
||||
|
||||
1. 扫描目录下图片文件,调用 LLM 提取刷卡信息(日期/金额/卡号)
|
||||
2. 解析发票和刷卡记录的金额,进行总额校验
|
||||
- 发票总额 < 刷卡总额时发出 warning
|
||||
3. 按金额降序排序
|
||||
4. 根据数量关系选择匹配策略:
|
||||
- 数量相等 → 一对一匹配:按金额从大到小依次配对,相对容差内即匹配
|
||||
- 发票更多 → 一对多匹配:对每张刷卡记录贪心凑金额,相对容差内结束
|
||||
5. 构建以支付记录为主键的结果列表
|
||||
6. 未匹配的发票单独作为一条记录(无刷卡信息)
|
||||
7. 清理内部字段,输出支付记录列表
|
||||
|
||||
## 容差计算
|
||||
|
||||
使用相对容差(默认 3%),以刷卡金额为基准:
|
||||
- ¥2900 发票 vs ¥2850 刷卡 → 差 ¥50,容差 ¥85.5 → 匹配成功
|
||||
- ¥100 发票 vs ¥95 刷卡 → 差 ¥5,容差 ¥3.0 → 不匹配(需精确匹配或调整)
|
||||
|
||||
## 一对多匹配细节
|
||||
|
||||
- 对每张刷卡记录,维护 remaining(剩余待匹配金额)
|
||||
- 遍历未分配的发票(按金额降序):
|
||||
- 若发票金额 + 容差 >= remaining,视为最后一张,匹配后退出
|
||||
- 否则发票金额不超过 remaining + 容差即可匹配
|
||||
- 匹配后 remaining 为负且超出容差时回滚最后一张发票
|
||||
- 每张发票只会被分配一次
|
||||
|
||||
## 对外接口
|
||||
|
||||
match_invoices_to_cards(invoices, directory, tolerance) -> list[dict]
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from .llm_extractor import extract_card_info_from_image
|
||||
|
||||
log = get_logger("matcher")
|
||||
|
||||
# 支持的图片扩展名
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
||||
|
||||
|
||||
def _find_images(directory: str) -> list[Path]:
|
||||
"""在目录下查找支付截图图片文件"""
|
||||
dir_path = Path(directory)
|
||||
images = [f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in IMAGE_EXTENSIONS]
|
||||
return sorted(images)
|
||||
|
||||
|
||||
def _safe_float(value: str | None, default: float = 0.0) -> float:
|
||||
"""安全转换为浮点数"""
|
||||
if value is None or str(value).strip() == "":
|
||||
return default
|
||||
try:
|
||||
return float(str(value).replace(",", ""))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _extract_all_cards(directory: str) -> list[dict[str, Any]]:
|
||||
"""提取目录下所有支付截图的刷卡信息"""
|
||||
images = _find_images(directory)
|
||||
if not images:
|
||||
log.warning("未找到支付截图图片")
|
||||
return []
|
||||
|
||||
log.info(f"发现 {len(images)} 张支付截图")
|
||||
cards = []
|
||||
for img_path in images:
|
||||
try:
|
||||
card_info = extract_card_info_from_image(img_path)
|
||||
card_info["_source_file"] = img_path.name
|
||||
cards.append(card_info)
|
||||
log.info(f"[支付截图] 已解析: {img_path.name}")
|
||||
except Exception as e:
|
||||
log.warning(f"支付截图解析失败 {img_path.name}: {e}")
|
||||
|
||||
log.info(f"共提取 {len(cards)} 条刷卡记录")
|
||||
return cards
|
||||
|
||||
|
||||
def _build_invoice_summary(invoices: list[dict[str, Any]]) -> str:
|
||||
"""将多张发票信息汇总为备注字符串"""
|
||||
parts = []
|
||||
for inv in invoices:
|
||||
person_name = inv.get("人员姓名") or inv.get("发票号码", "未知")
|
||||
inv_type = inv.get("发票类型", "未知")
|
||||
amount = inv.get("价税合计", "未知")
|
||||
parts.append(f"{inv_type}[{person_name}]¥{amount}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _relative_tolerance(base: float, rate: float = 0.05) -> float:
|
||||
"""根据基准金额计算相对容差(默认 5%)"""
|
||||
return abs(base) * rate
|
||||
|
||||
|
||||
def match_invoices_to_cards(
|
||||
invoices: list[dict[str, Any]],
|
||||
directory: str,
|
||||
tolerance: float = 0.03,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""将发票与支付截图按金额匹配,输出以支付记录为主键的结果列表
|
||||
|
||||
业务约束:
|
||||
- 发票数 >= 付款记录数
|
||||
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
|
||||
- 若发票数 == 付款数,一对一匹配,无需一对多
|
||||
|
||||
Args:
|
||||
invoices: 发票列表,需包含 "价税合计" 字段
|
||||
directory: 支付截图所在目录
|
||||
tolerance: 金额匹配容差比例(默认 0.05 = 5%)
|
||||
|
||||
Returns:
|
||||
以支付记录为主键的结果列表,每条记录包含:
|
||||
- 刷卡日期、公务卡号、刷卡金额(支付信息)
|
||||
- 关联发票列表(_matched_invoices)
|
||||
- 发票详情备注
|
||||
- 未匹配发票单独作为一条无刷卡信息的记录
|
||||
"""
|
||||
cards = _extract_all_cards(directory)
|
||||
if not cards:
|
||||
log.warning("无刷卡记录可供匹配,发票将保持原状")
|
||||
# 无刷卡记录时,每张发票作为独立记录返回
|
||||
return _invoices_to_records(invoices)
|
||||
|
||||
# 解析金额
|
||||
for card in cards:
|
||||
card["_amount"] = _safe_float(card.get("刷卡金额"))
|
||||
for inv in invoices:
|
||||
inv["_amount"] = _safe_float(inv.get("价税合计"))
|
||||
|
||||
# 数据校验
|
||||
total_invoices = sum(inv["_amount"] for inv in invoices)
|
||||
total_cards = sum(card["_amount"] for card in cards)
|
||||
log.info(
|
||||
f"金额校验: 发票总额 ¥{total_invoices:.2f}, 刷卡总额 ¥{total_cards:.2f}, "
|
||||
f"发票数 {len(invoices)}, 刷卡数 {len(cards)}"
|
||||
)
|
||||
|
||||
total_tolerance = _relative_tolerance(max(total_invoices, total_cards), tolerance)
|
||||
if total_invoices < total_cards - total_tolerance:
|
||||
log.warning(
|
||||
f"发票总额 (¥{total_invoices:.2f}) 小于刷卡总额 (¥{total_cards:.2f}),"
|
||||
f"超出容差 {tolerance * 100:.0f}%,匹配结果可能有偏差"
|
||||
)
|
||||
|
||||
# 按金额降序排序
|
||||
cards.sort(key=lambda c: c["_amount"], reverse=True)
|
||||
invoices.sort(key=lambda i: i["_amount"], reverse=True)
|
||||
|
||||
# 执行匹配,返回 {card_index: [invoice_indices]} 的映射
|
||||
card_to_invoices = _match(cards, invoices, tolerance)
|
||||
|
||||
# 构建支付记录列表
|
||||
records = _build_payment_records(cards, invoices, card_to_invoices)
|
||||
|
||||
# 清理内部字段
|
||||
for inv in invoices:
|
||||
inv.pop("_amount", None)
|
||||
for card in cards:
|
||||
card.pop("_amount", None)
|
||||
|
||||
# 统计
|
||||
matched_invoices = sum(len(inv_list) for inv_list in card_to_invoices.values())
|
||||
unmatched_count = len(invoices) - matched_invoices
|
||||
log.info(f"匹配完成: {len(records)} 条支付记录, {matched_invoices}/{len(invoices)} 张发票已关联")
|
||||
if unmatched_count:
|
||||
log.info(f"未匹配发票: {unmatched_count} 张(已单独列为记录)")
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _match(
|
||||
cards: list[dict[str, Any]],
|
||||
invoices: list[dict[str, Any]],
|
||||
tolerance: float,
|
||||
) -> dict[int, list[int]]:
|
||||
"""执行匹配,返回 {card_index: [invoice_indices]} 的映射
|
||||
|
||||
tolerance 为相对容差比例(如 0.05 表示 5%)
|
||||
"""
|
||||
result: dict[int, list[int]] = {}
|
||||
assigned: set[int] = set()
|
||||
|
||||
if len(invoices) == len(cards):
|
||||
_match_one_to_one(invoices, cards, tolerance, assigned, result)
|
||||
else:
|
||||
_match_one_to_many(invoices, cards, tolerance, assigned, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _match_one_to_one(
|
||||
invoices: list[dict[str, Any]],
|
||||
cards: list[dict[str, Any]],
|
||||
tolerance: float,
|
||||
assigned: set[int],
|
||||
result: dict[int, list[int]],
|
||||
) -> None:
|
||||
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对
|
||||
|
||||
tolerance 为相对容差比例,以刷卡金额为基准计算
|
||||
"""
|
||||
for card_idx, card in enumerate(cards):
|
||||
if card_idx >= len(invoices):
|
||||
break
|
||||
inv = invoices[card_idx]
|
||||
diff = abs(inv["_amount"] - card["_amount"])
|
||||
card_tol = _relative_tolerance(card["_amount"], tolerance)
|
||||
if diff <= card_tol:
|
||||
assigned.add(card_idx)
|
||||
result[card_idx] = [card_idx]
|
||||
log.info(
|
||||
f"[一对一] {inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
|
||||
f"↔ {card.get('_source_file', '未知')} ¥{card['_amount']:.2f}"
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
f"[一对一] 金额偏差超出容差: "
|
||||
f"{inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
|
||||
f"vs ¥{card['_amount']:.2f} (差 ¥{diff:.2f}, 容差 ¥{card_tol:.2f})"
|
||||
)
|
||||
|
||||
|
||||
def _match_one_to_many(
|
||||
invoices: list[dict[str, Any]],
|
||||
cards: list[dict[str, Any]],
|
||||
tolerance: float,
|
||||
assigned: set[int],
|
||||
result: dict[int, list[int]],
|
||||
) -> None:
|
||||
"""一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配
|
||||
|
||||
tolerance 为相对容差比例(如 0.05 表示 5%),以刷卡金额为基准计算
|
||||
|
||||
匹配分两阶段:
|
||||
1. 精确匹配:先扫描金额完全相等(差值 <= 0.01 元)的发票-刷卡对,直接锁定
|
||||
2. 贪心匹配:剩余未分配的发票和刷卡记录走贪心凑金额
|
||||
"""
|
||||
|
||||
# ---- 阶段 1:精确匹配(金额差 <= 0.01 元视为相等)----
|
||||
exact_tolerance = 0.01
|
||||
for card_idx, card in enumerate(cards):
|
||||
card_amount = card["_amount"]
|
||||
if card_amount <= 0:
|
||||
continue
|
||||
|
||||
for idx, inv in enumerate(invoices):
|
||||
if idx in assigned:
|
||||
continue
|
||||
inv_amount = inv["_amount"]
|
||||
if inv_amount <= 0:
|
||||
continue
|
||||
|
||||
if abs(inv_amount - card_amount) <= exact_tolerance:
|
||||
assigned.add(idx)
|
||||
result[card_idx] = [idx]
|
||||
log.info(
|
||||
f"[一对多-精确] {inv.get('发票号码', '未知')} ¥{inv_amount:.2f} "
|
||||
f"↔ {card.get('_source_file', '未知')} ¥{card_amount:.2f}"
|
||||
)
|
||||
break # 每张刷卡只精确匹配一张发票
|
||||
|
||||
# ---- 阶段 2:贪心匹配(仅处理未精确匹配的刷卡记录)----
|
||||
for card_idx, card in enumerate(cards):
|
||||
if card_idx in result: # 已在阶段 1 精确匹配
|
||||
continue
|
||||
|
||||
card_amount = card["_amount"]
|
||||
if card_amount <= 0:
|
||||
continue
|
||||
|
||||
# 以刷卡金额为基准计算相对容差
|
||||
card_tol = _relative_tolerance(card_amount, tolerance)
|
||||
|
||||
remaining = card_amount
|
||||
matched_indices: list[int] = []
|
||||
|
||||
for idx, inv in enumerate(invoices):
|
||||
if idx in assigned:
|
||||
continue
|
||||
if remaining <= card_tol:
|
||||
break
|
||||
|
||||
inv_amount = inv["_amount"]
|
||||
if inv_amount <= 0:
|
||||
continue
|
||||
|
||||
# 最后一张发票:金额 + 容差 >= remaining 即可
|
||||
# 中间发票:金额不超过 remaining + 容差
|
||||
if inv_amount + card_tol >= remaining:
|
||||
is_match = True
|
||||
else:
|
||||
is_match = inv_amount <= remaining + card_tol
|
||||
|
||||
if is_match:
|
||||
assigned.add(idx)
|
||||
matched_indices.append(idx)
|
||||
remaining -= inv_amount
|
||||
if remaining <= card_tol:
|
||||
break
|
||||
|
||||
# 回滚:如果匹配后 remaining 为负且超出容差
|
||||
if remaining < -card_tol and matched_indices:
|
||||
last_idx = matched_indices.pop()
|
||||
assigned.discard(last_idx)
|
||||
remaining += invoices[last_idx]["_amount"]
|
||||
|
||||
# 记录匹配结果
|
||||
if matched_indices:
|
||||
result[card_idx] = matched_indices
|
||||
for idx in matched_indices:
|
||||
inv = invoices[idx]
|
||||
log.info(
|
||||
f"[一对多-贪心] {inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
|
||||
f"→ {card.get('_source_file', '未知')} ¥{card['_amount']:.2f}"
|
||||
)
|
||||
|
||||
|
||||
def _build_payment_records(
|
||||
cards: list[dict[str, Any]],
|
||||
invoices: list[dict[str, Any]],
|
||||
card_to_invoices: dict[int, list[int]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""构建以支付记录为主键的结果列表"""
|
||||
records: list[dict[str, Any]] = []
|
||||
|
||||
# 已有匹配记录的支付
|
||||
for card_idx, inv_indices in card_to_invoices.items():
|
||||
card = cards[card_idx]
|
||||
matched_invs = [invoices[idx] for idx in inv_indices]
|
||||
|
||||
record = {
|
||||
"刷卡日期": card.get("刷卡日期", ""),
|
||||
"公务卡号": card.get("公务卡号", ""),
|
||||
"刷卡金额": str(card["_amount"]),
|
||||
"关联发票数": str(len(matched_invs)),
|
||||
"发票详情": _build_invoice_summary(matched_invs),
|
||||
"备注": "",
|
||||
"_matched_invoices": matched_invs,
|
||||
}
|
||||
records.append(record)
|
||||
|
||||
# 未匹配的发票,单独作为记录
|
||||
matched_indices = set()
|
||||
for inv_indices in card_to_invoices.values():
|
||||
matched_indices.update(inv_indices)
|
||||
|
||||
unmatched = [inv for idx, inv in enumerate(invoices) if idx not in matched_indices]
|
||||
for inv in unmatched:
|
||||
record = {
|
||||
"刷卡日期": "",
|
||||
"公务卡号": "",
|
||||
"刷卡金额": "",
|
||||
"关联发票数": "1",
|
||||
"发票详情": _build_invoice_summary([inv]),
|
||||
"备注": "未匹配到支付记录",
|
||||
"_matched_invoices": [inv],
|
||||
}
|
||||
records.append(record)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _invoices_to_records(invoices: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""无刷卡记录时,将每张发票转为独立记录"""
|
||||
records = []
|
||||
for inv in invoices:
|
||||
record = {
|
||||
"刷卡日期": "",
|
||||
"公务卡号": "",
|
||||
"刷卡金额": "",
|
||||
"关联发票数": "1",
|
||||
"发票详情": _build_invoice_summary([inv]),
|
||||
"备注": "",
|
||||
"_matched_invoices": [inv],
|
||||
}
|
||||
records.append(record)
|
||||
return records
|
||||
42
src/doc/pdf.py
Normal file
42
src/doc/pdf.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""PDF 文件发现与文本提取
|
||||
|
||||
从 PDF 发票文件中提取原始文本内容。
|
||||
|
||||
对外接口:
|
||||
find_pdf_files(directory) -> list[Path] 查找目录下所有 PDF
|
||||
extract_text_from_pdf(filepath) -> str 提取 PDF 文本
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .. import get_logger
|
||||
|
||||
log = get_logger("pdf")
|
||||
|
||||
|
||||
def find_pdf_files(directory: str = ".") -> list[Path]:
|
||||
"""查找目录下所有 PDF 文件(非递归)"""
|
||||
pdf_dir = Path(directory)
|
||||
if not pdf_dir.exists():
|
||||
return []
|
||||
return sorted(pdf_dir.glob("*.pdf"))
|
||||
|
||||
|
||||
def extract_text_from_pdf(filepath: Path) -> str:
|
||||
"""从单个 PDF 中提取全部文本"""
|
||||
try:
|
||||
import pdfplumber
|
||||
except ImportError as err:
|
||||
raise ImportError("缺少 pdfplumber,请执行: uv pip install pdfplumber") from err
|
||||
|
||||
try:
|
||||
parts = []
|
||||
with pdfplumber.open(filepath) as pdf:
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
except Exception as e:
|
||||
log.error(f"无法读取 {filepath.name}: {e}")
|
||||
return ""
|
||||
26
src/doc/prompt.py
Normal file
26
src/doc/prompt.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
LLM 提示词模板
|
||||
|
||||
从 src/prompts/ 目录加载 .md 文件作为提示词模板。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
_PROMPTS_DIR = os.path.join(os.path.dirname(__file__), "prompts")
|
||||
|
||||
|
||||
def _load_prompt(filename: str) -> str:
|
||||
"""从 prompts 目录加载提示词文件内容。"""
|
||||
path = os.path.join(_PROMPTS_DIR, filename)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def build_invoice_system_prompt() -> str:
|
||||
"""构建发票提取系统提示词。"""
|
||||
return _load_prompt("invoice_system.md")
|
||||
|
||||
|
||||
def build_card_info_system_prompt() -> str:
|
||||
"""构建支付截图信息提取系统提示词。"""
|
||||
return _load_prompt("card_info_system.md")
|
||||
11
src/doc/prompts/card_info_system.md
Normal file
11
src/doc/prompts/card_info_system.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 支付截图信息提取系统提示词
|
||||
|
||||
你是财务支付截图信息提取助手。你的任务是从支付截图(银行转账记录、微信/支付宝付款凭证等)中提取结构化信息,并以 JSON 格式返回。
|
||||
|
||||
需要提取的字段(全部必填,无法识别时返回空字符串):
|
||||
|
||||
1. 刷卡日期: 支付发生的日期,格式为 YYYY/M/D
|
||||
2. 刷卡金额: 实际支付金额,只保留数字(如 123.45)
|
||||
3. 公务卡号: 付款银行卡号,如果截图中有显示则提取,没有则返回空字符串
|
||||
|
||||
严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。
|
||||
33
src/doc/prompts/invoice_system.md
Normal file
33
src/doc/prompts/invoice_system.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 发票提取系统提示词
|
||||
|
||||
你是财务文档信息提取助手。你的任务是从发票文本中提取结构化信息,并以 JSON 格式返回。
|
||||
|
||||
需要提取的字段(全部必填,无法识别时返回空字符串):
|
||||
先判断发票类型,如果是高铁票/或者火车票,返回如下字段:
|
||||
1. 发票类型: "高铁票"
|
||||
2. 发票号码: 发票的唯一编号
|
||||
3. 开票日期: 格式为 YYYY/M/D
|
||||
4. 乘车日期: 格式为 YYYY/M/D
|
||||
5. 出发站: 没有留空
|
||||
6. 到达站: 没有留空
|
||||
7. 座位等级: 没有留空
|
||||
8. 车次: 没有留空
|
||||
9. 人员姓名: 没有留空
|
||||
10. 价税合计:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息
|
||||
|
||||
如果是酒店住宿(酒店住宿通产包含关键字:住宿服务,酒店,生产生活服务等,请仔细分析,这种发票和普通发票类似),返回如下字段:
|
||||
1. 发票类型: "酒店住宿"
|
||||
2. 发票号码: 发票的唯一编号
|
||||
3. 开票日期: 格式为 YYYY/M/D
|
||||
4. 价税合计: 金额数字
|
||||
|
||||
如果是普通发票,返回如下字段:
|
||||
1. 发票类型: "普通发票"
|
||||
2. 发票号码: 发票的唯一编号
|
||||
3. 开票日期: 格式为 YYYY/M/D
|
||||
4. 项目名称: 商品或服务名称,总结的人能看懂
|
||||
5. 规格型号: 规格描述
|
||||
6. 价税合计: 金额数字
|
||||
7. 销售方名称: 卖方全称
|
||||
|
||||
严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。
|
||||
59
src/main.py
Normal file
59
src/main.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
财务报销自动化
|
||||
|
||||
依次执行:
|
||||
1. 发票提取 — 从 PDF 发票提取信息,生成 invoice_summary.csv
|
||||
2. 报销提交 — 打开浏览器登录财务系统并自动填报
|
||||
|
||||
用法:
|
||||
python run.py # 全流程
|
||||
python run.py --step invoice # 仅发票提取
|
||||
python run.py --step submit # 仅浏览器填报
|
||||
python run.py -u 工号 -p 密码 # 覆盖登录凭据
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 确保项目根目录在 sys.path 中
|
||||
sys.path.insert(0, str(Path(__file__).parent.resolve().parent))
|
||||
|
||||
from src.pipeline import run_pipeline
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="财务报销自动化 - 发票提取 → 浏览器填报",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--step",
|
||||
choices=["all", "invoice", "submit"],
|
||||
default="all",
|
||||
help="执行步骤 (默认: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-u",
|
||||
"--username",
|
||||
default=None,
|
||||
help="信息门户登录账号(覆盖 config.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--password",
|
||||
default=None,
|
||||
help="信息门户登录密码(覆盖 config.json)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
exit_code = run_pipeline(
|
||||
step=args.step,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
)
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
136
src/pipeline.py
Normal file
136
src/pipeline.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
报销全流程编排
|
||||
|
||||
将发票提取 → 浏览器填报串联为一条管道,
|
||||
数据在内存中流转,同时生成 CSV 中间产物。
|
||||
|
||||
发票类型区分:
|
||||
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
||||
- 普通发票:生成易耗品出库单,走普通报销流程
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import get_logger
|
||||
from .config import load_config
|
||||
from .doc.extractor import extract_invoices
|
||||
from .doc.invoice import (
|
||||
classify_invoice_batch,
|
||||
load_invoices_from_csv,
|
||||
save_invoice_csv,
|
||||
)
|
||||
from .doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
|
||||
log = get_logger("pipeline")
|
||||
|
||||
|
||||
def _find_upload_directory(project_dir: Path) -> Path | None:
|
||||
"""自动发现 uploads 目录下最新且包含 PDF 的会话文件夹"""
|
||||
uploads_base = project_dir / "src" / "web" / "uploads"
|
||||
if not uploads_base.is_dir():
|
||||
return None
|
||||
|
||||
# 只选包含 PDF 的目录
|
||||
valid_dirs = [d for d in uploads_base.iterdir() if d.is_dir() and list(d.glob("*.pdf"))]
|
||||
if not valid_dirs:
|
||||
return None
|
||||
|
||||
# 按修改时间排序,取最新
|
||||
session_dirs = sorted(
|
||||
valid_dirs,
|
||||
key=lambda d: d.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
return session_dirs[0]
|
||||
|
||||
|
||||
def _classify_from_csv(csv_path: Path) -> dict[str, list[dict[str, Any]]]:
|
||||
"""从已生成的 CSV 中读取发票数据并按类型分组"""
|
||||
rows = load_invoices_from_csv(csv_path)
|
||||
if rows is None:
|
||||
return {"travel": [], "general": []}
|
||||
return classify_invoice_batch(rows)
|
||||
|
||||
|
||||
def run_pipeline(step: str = "all", username: str | None = None, password: str | None = None) -> int:
|
||||
"""执行报销流程
|
||||
|
||||
Args:
|
||||
step: all | invoice | submit
|
||||
username: 覆盖 config.json 中的用户名
|
||||
password: 覆盖 config.json 中的密码
|
||||
"""
|
||||
config = load_config()
|
||||
if username:
|
||||
config["username"] = username
|
||||
if password:
|
||||
config["password"] = password
|
||||
|
||||
# 工作目录(项目根目录)
|
||||
project_dir = Path(__file__).parent.parent
|
||||
|
||||
# --------------------------------------------------
|
||||
# Step 1: 发票提取
|
||||
# --------------------------------------------------
|
||||
payment_records: list[dict[str, str]] | None = None
|
||||
groups: dict[str, list[dict[str, str]]] | None = None
|
||||
|
||||
if step in ("all", "invoice"):
|
||||
log.info("=" * 60)
|
||||
log.info("[1/2] 发票提取")
|
||||
log.info("=" * 60)
|
||||
|
||||
payment_records, groups = extract_invoices(str(project_dir))
|
||||
if not payment_records:
|
||||
log.error("未提取到任何发票数据")
|
||||
return 1
|
||||
|
||||
save_payment_csv(payment_records, project_dir / "payment_records.csv")
|
||||
save_invoice_csv(payment_records, project_dir / "invoice_summary.csv")
|
||||
|
||||
# 打印分类结果
|
||||
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张")
|
||||
|
||||
if step == "invoice":
|
||||
log.info("[1/2] 发票提取 完成")
|
||||
return 0
|
||||
|
||||
# --------------------------------------------------
|
||||
# Step 2: 浏览器填报
|
||||
# --------------------------------------------------
|
||||
if step in ("all", "submit"):
|
||||
log.info("=" * 60)
|
||||
log.info("[2/2] 报销提交")
|
||||
log.info("=" * 60)
|
||||
|
||||
from .bot import load_invoice_data, run_bot
|
||||
|
||||
csv_path = project_dir / "payment_records.csv"
|
||||
bot_invoices = load_invoice_data(str(csv_path), config)
|
||||
|
||||
# 根据发票类型选择填报模式
|
||||
if groups is None:
|
||||
groups = _classify_from_csv(csv_path)
|
||||
|
||||
if groups["travel"] and not groups["general"]:
|
||||
log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||
# TODO: 差旅报销填报流程
|
||||
run_bot(config, bot_invoices)
|
||||
else:
|
||||
log.info("检测到普通发票,使用普通报销模式")
|
||||
run_bot(config, bot_invoices)
|
||||
|
||||
if step == "submit":
|
||||
log.info("[2/2] 报销提交 完成")
|
||||
return 0
|
||||
|
||||
# --------------------------------------------------
|
||||
# 全流程完成
|
||||
# --------------------------------------------------
|
||||
log.info("=" * 60)
|
||||
log.info("全流程执行完毕")
|
||||
log.info("=" * 60)
|
||||
return 0
|
||||
83
src/web/README.md
Normal file
83
src/web/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
---
|
||||
|
||||
# src/web 模块设计说明
|
||||
|
||||
## 设计思路
|
||||
|
||||
`src/web` 是一个基于 Flask 的轻量级 Web 界面,为财务报销自动化管道提供可视化操作入口。核心设计原则:
|
||||
|
||||
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
|
||||
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
|
||||
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5,不引入构建工具,保持单页应用轻量可维护。
|
||||
- **双模式支持**:PDF 发票提取模式和 CSV 快捷上传模式,后者跳过 LLM 识别和 PDF 解析,直接处理已有发票数据。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/web/
|
||||
├── app.py # Flask 应用入口,路由、管道编排、日志收集
|
||||
├── templates/
|
||||
│ ├── index.html # PC 端主界面(上传、配置、处理、编辑、提交)
|
||||
│ └── mobile_upload.html # 移动端上传页面(拍照/相册选择)
|
||||
└── static/
|
||||
├── css/
|
||||
│ └── index.css # 全局样式(上传区、日志面板、可编辑表格)
|
||||
└── js/
|
||||
└── index.js # 前端逻辑(上传、SSE 日志、表格编辑、二维码同步)
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
用户上传文件 → 创建 session → 文件写入 uploads/<sid>/
|
||||
↓
|
||||
后台线程执行管道: extract_invoices() → enrich_with_llm() → save_csv()
|
||||
↓
|
||||
结果写入 session 目录: invoice_summary.csv / result.json / session.log
|
||||
↓
|
||||
前端 SSE 轮询 result.json 变化 → 显示完成状态
|
||||
↓
|
||||
前端加载 CSV 数据 → 可编辑表格展示 → 用户修改后保存
|
||||
↓
|
||||
用户点击提交 → run_financial_submit() → bot 自动填报财务系统
|
||||
```
|
||||
|
||||
## API 路由
|
||||
|
||||
| 方法 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 主界面 |
|
||||
| POST | `/api/session` | 创建会话,返回 session_id |
|
||||
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
|
||||
| POST | `/api/upload-csv/<sid>` | 上传 CSV 发票数据 |
|
||||
| GET | `/api/files/<sid>` | 列出会话文件 |
|
||||
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
|
||||
| GET | `/api/logs/<sid>` | SSE 日志流 |
|
||||
| GET | `/api/data/<sid>` | 获取发票数据 JSON |
|
||||
| POST | `/api/save/<sid>` | 保存前端编辑的发票数据 |
|
||||
| GET | `/api/download/<sid>/<filename>` | 下载生成文件 |
|
||||
| POST | `/api/submit-financial/<sid>` | 手动触发财务系统填报 |
|
||||
| GET | `/mobile/<sid>` | 移动端上传页面 |
|
||||
|
||||
## 关键机制
|
||||
|
||||
### 日志收集
|
||||
|
||||
`_SSELogHandler` 将管道日志写入 `session.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
|
||||
|
||||
### 发票类型分流
|
||||
|
||||
- **差旅发票**(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
||||
- **普通发票**:生成易耗品出库单(Word 文档),走普通报销流程
|
||||
|
||||
`classify_invoice_batch()` 根据发票内容自动分类,`_try_fill_consumable_doc()` 仅对普通发票生成出库单。
|
||||
|
||||
### 移动端同步
|
||||
|
||||
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的图片通过 `syncFiles()` 轮询同步到 PC 端内存中的 `imgFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
|
||||
|
||||
### 配置管理
|
||||
|
||||
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端支持通过上传 `config.json` 快速填充配置表单。
|
||||
772
src/web/app.py
Normal file
772
src/web/app.py
Normal file
@@ -0,0 +1,772 @@
|
||||
"""
|
||||
财务报销自动化 — 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
|
||||
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
|
||||
classify_invoice_batch,
|
||||
load_csv,
|
||||
load_invoice_csv,
|
||||
save_csv as save_payment_csv,
|
||||
save_invoice_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 / "src" / "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) -> 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 _has_general_invoices(rows: list[dict[str, str]]) -> bool:
|
||||
"""检查发票列表中是否包含普通发票(需要生成易耗品出库单)"""
|
||||
invoices = []
|
||||
for row in rows:
|
||||
# 支付记录格式:从 _invoices_json 还原
|
||||
invoices_json = row.get("_invoices_json", "")
|
||||
if invoices_json:
|
||||
try:
|
||||
invoices.extend(json.loads(invoices_json))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 发票级别格式:直接使用
|
||||
elif "发票类型" in row:
|
||||
invoices.append(row)
|
||||
if not invoices:
|
||||
return False
|
||||
groups = classify_invoice_batch(invoices)
|
||||
return len(groups["general"]) > 0
|
||||
|
||||
|
||||
def _get_invoice_groups(rows: list[dict[str, str]]) -> dict[str, int]:
|
||||
"""统计发票类型分布"""
|
||||
invoices = []
|
||||
for row in rows:
|
||||
# 支付记录格式:从 _invoices_json 还原
|
||||
invoices_json = row.get("_invoices_json", "")
|
||||
if invoices_json:
|
||||
try:
|
||||
invoices.extend(json.loads(invoices_json))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 发票级别格式:直接使用
|
||||
elif "发票类型" in row:
|
||||
invoices.append(row)
|
||||
groups = classify_invoice_batch(invoices)
|
||||
return {
|
||||
"travel_count": len(groups["travel"]),
|
||||
"general_count": len(groups["general"]),
|
||||
}
|
||||
|
||||
|
||||
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""根据 CSV 填写易耗品出库单,供会话目录下载。
|
||||
|
||||
仅当存在普通发票时才生成出库单。纯差旅发票跳过。
|
||||
"""
|
||||
if not CONSUMABLE_TEMPLATE.exists():
|
||||
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
|
||||
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
|
||||
|
||||
csv_path = _resolve_payment_csv(session_dir)
|
||||
if csv_path is None:
|
||||
return {"ok": False, "error": "未找到发票 CSV"}
|
||||
|
||||
# 检查是否有普通发票
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
return {"ok": False, "error": "CSV 读取失败"}
|
||||
|
||||
if not _has_general_invoices(rows):
|
||||
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
|
||||
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
|
||||
|
||||
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> 触发。
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
# ---- Step 1: 发票提取 ----
|
||||
invoices, 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")
|
||||
|
||||
# 统计发票总数
|
||||
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_csv_pipeline_web(session_dir: Path, config: dict[str, Any], csv_filename: str) -> dict[str, Any]:
|
||||
"""直接使用上传的 CSV 文件,跳过 PDF 提取"""
|
||||
start = time.time()
|
||||
|
||||
csv_path = session_dir / csv_filename
|
||||
if not csv_path.exists():
|
||||
return {"ok": False, "error": "CSV 文件不存在"}
|
||||
|
||||
# 尝试读取支付记录格式
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
# 尝试读取发票级别格式
|
||||
invoice_rows = load_invoice_csv(csv_path)
|
||||
if invoice_rows is None:
|
||||
return {"ok": False, "error": "CSV 读取失败"}
|
||||
# 发票级别格式:直接统计
|
||||
type_stats = _get_invoice_groups(invoice_rows)
|
||||
elapsed = time.time() - start
|
||||
return {
|
||||
"ok": True,
|
||||
"elapsed": f"{elapsed:.1f}s",
|
||||
"invoice_count": len(invoice_rows),
|
||||
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
|
||||
"travel_count": type_stats["travel_count"],
|
||||
"general_count": type_stats["general_count"],
|
||||
}
|
||||
|
||||
# 支付记录格式:统计发票类型
|
||||
type_stats = _get_invoice_groups(rows)
|
||||
|
||||
elapsed = time.time() - start
|
||||
result = {
|
||||
"ok": True,
|
||||
"elapsed": f"{elapsed:.1f}s",
|
||||
"invoice_count": type_stats["travel_count"] + type_stats["general_count"],
|
||||
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
|
||||
"travel_count": type_stats["travel_count"],
|
||||
"general_count": type_stats["general_count"],
|
||||
}
|
||||
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]:
|
||||
"""执行财务系统填报(从前端确认后调用)
|
||||
|
||||
根据发票类型选择填报模式:
|
||||
- 纯差旅发票:差旅报销模式(TODO)
|
||||
- 含普通发票:普通报销模式
|
||||
"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
if not csv_path.exists():
|
||||
return {"ok": False, "error": "未找到发票数据,请先处理"}
|
||||
|
||||
from src.bot import load_invoice_data, run_bot_web
|
||||
|
||||
bot_invoices = load_invoice_data(str(csv_path), config)
|
||||
|
||||
# 判断发票类型
|
||||
rows = load_csv(csv_path)
|
||||
if rows:
|
||||
invoice_groups = _get_invoice_groups(rows)
|
||||
if invoice_groups["travel_count"] and not invoice_groups["general_count"]:
|
||||
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||
# TODO: 差旅报销填报流程
|
||||
else:
|
||||
fill_log.info("检测到普通发票,使用普通报销模式")
|
||||
|
||||
run_bot_web(config, bot_invoices, 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/upload-csv/<session_id>", methods=["POST"])
|
||||
def upload_csv(session_id: str) -> Any:
|
||||
"""上传 CSV 发票数据文件(跳过 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 {}
|
||||
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() -> None:
|
||||
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/<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)
|
||||
24
src/web/static/css/index.css
Normal file
24
src/web/static/css/index.css
Normal file
@@ -0,0 +1,24 @@
|
||||
body { background: #f5f7fa; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
|
||||
.upload-zone {
|
||||
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
|
||||
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
|
||||
}
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
|
||||
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
|
||||
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
|
||||
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
|
||||
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
|
||||
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
.log-container .empty { color: #666; font-style: italic; }
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
|
||||
.btn-process { font-size: 17px; padding: 10px 40px; }
|
||||
.status-badge { font-size: 13px; }
|
||||
|
||||
/* 可编辑表格 */
|
||||
.table-editable { font-size: 13px; }
|
||||
.table-editable th { position: sticky; top: 0; background: #f8f9fa; z-index: 1; font-weight: 600; white-space: nowrap; }
|
||||
.table-editable td { vertical-align: middle; }
|
||||
.table-editable input.form-control { font-size: 13px; padding: 4px 8px; min-width: 80px; }
|
||||
.table-wrapper { max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 8px; }
|
||||
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }
|
||||
515
src/web/static/js/index.js
Normal file
515
src/web/static/js/index.js
Normal file
@@ -0,0 +1,515 @@
|
||||
let sessionId = null;
|
||||
const pdfFiles = [], imgFiles = [];
|
||||
let csvFile = null;
|
||||
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
|
||||
let csvFilename = ''; // 当前 CSV 文件名
|
||||
let lastDownloadUrls = {}; // 最近一次可下载文件链接
|
||||
|
||||
// ---- Session ----
|
||||
async function ensureSession() {
|
||||
if (sessionId) return sessionId;
|
||||
const r = await fetch('/api/session', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
sessionId = d.session_id;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
// ---- 上传 ----
|
||||
function handleFiles(input, type) {
|
||||
const files = Array.from(input.files);
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
|
||||
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
|
||||
|
||||
files.forEach(f => {
|
||||
if (!list.find(x => x.name === f.name)) {
|
||||
f.__source = 'local'; // 标记为本地手动选择
|
||||
list.push(f);
|
||||
}
|
||||
});
|
||||
|
||||
renderFileList(type);
|
||||
document.getElementById(zoneId).classList.add('active');
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removeFile(type, index) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
list.splice(index, 1);
|
||||
renderFileList(type);
|
||||
if (list.length === 0) {
|
||||
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileList(type) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
|
||||
box.innerHTML = list.map((f, i) =>
|
||||
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">×</span></span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ---- CSV 上传 ----
|
||||
function handleCsvFile(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
csvFile = file;
|
||||
document.getElementById('csv-zone').classList.add('active');
|
||||
document.getElementById('csv-list').innerHTML =
|
||||
`<span class="file-tag">${file.name}<span class="remove" onclick="event.stopPropagation();removeCsvFile()">×</span></span>`;
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removeCsvFile() {
|
||||
csvFile = null;
|
||||
document.getElementById('csv-zone').classList.remove('active');
|
||||
document.getElementById('csv-list').innerHTML = '';
|
||||
}
|
||||
|
||||
// ---- 配置上传 ----
|
||||
function handleConfigUpload(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
const cfg = JSON.parse(e.target.result);
|
||||
const map = {
|
||||
'cfg-username': cfg.username,
|
||||
'cfg-password': cfg.password,
|
||||
'cfg-name': cfg.default_name,
|
||||
'cfg-card': cfg.default_card_no,
|
||||
'cfg-person-id': cfg.default_person_id,
|
||||
'cfg-storage': cfg.consumable_storage,
|
||||
};
|
||||
for (const [id, val] of Object.entries(map)) {
|
||||
if (val) document.getElementById(id).value = val;
|
||||
}
|
||||
// 同步 config.json 中的工号和公务卡号到表格
|
||||
if (cfg.username) syncConfigToTable('工号');
|
||||
if (cfg.default_card_no) syncConfigToTable('公务卡号');
|
||||
alert('配置已加载');
|
||||
} catch (err) {
|
||||
alert('config.json 解析失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// ---- 拖拽 ----
|
||||
['pdf','img'].forEach(type => {
|
||||
const zone = document.getElementById(type + '-zone');
|
||||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||
zone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('dragover');
|
||||
const files = Array.from(e.dataTransfer.files).filter(f => {
|
||||
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
|
||||
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
|
||||
});
|
||||
if (files.length) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
files.forEach(f => {
|
||||
f.__source = 'local'; // 标记为本地拖拽
|
||||
if (!list.find(x => x.name === f.name)) list.push(f);
|
||||
});
|
||||
renderFileList(type);
|
||||
zone.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// CSV 拖拽
|
||||
const csvZone = document.getElementById('csv-zone');
|
||||
csvZone.addEventListener('dragover', e => { e.preventDefault(); csvZone.classList.add('dragover'); });
|
||||
csvZone.addEventListener('dragleave', () => csvZone.classList.remove('dragover'));
|
||||
csvZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
csvZone.classList.remove('dragover');
|
||||
const file = Array.from(e.dataTransfer.files).find(f => f.name.toLowerCase().endsWith('.csv'));
|
||||
if (file) handleCsvFile({ files: [file] });
|
||||
});
|
||||
|
||||
// ---- 处理 ----
|
||||
async function startProcess() {
|
||||
const isCsvMode = !!csvFile;
|
||||
if (!isCsvMode && !pdfFiles.length && !imgFiles.length) {
|
||||
alert('请先上传文件或 CSV');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-start');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '处理中...';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
|
||||
document.getElementById('log-box').innerHTML = '';
|
||||
document.getElementById('edit-section').style.display = 'none';
|
||||
document.getElementById('download-section').style.display = 'none';
|
||||
|
||||
try {
|
||||
await ensureSession();
|
||||
|
||||
if (isCsvMode) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', csvFile);
|
||||
await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd });
|
||||
} else {
|
||||
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
|
||||
for (const {f} of allFiles) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
|
||||
|
||||
const cfg = {
|
||||
username: document.getElementById('cfg-username').value,
|
||||
password: document.getElementById('cfg-password').value,
|
||||
default_name: document.getElementById('cfg-name').value,
|
||||
default_card_no: document.getElementById('cfg-card').value,
|
||||
default_person_id: document.getElementById('cfg-person-id').value,
|
||||
consumable_storage: document.getElementById('cfg-storage').value,
|
||||
mode: isCsvMode ? 'csv' : 'auto',
|
||||
};
|
||||
|
||||
await fetch(`/api/process/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
|
||||
// 监听 SSE 日志
|
||||
const es = new EventSource(`/api/logs/${sessionId}`);
|
||||
const logBox = document.getElementById('log-box');
|
||||
let firstLine = true;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
const result = msg.result;
|
||||
document.getElementById('status').innerHTML = result.ok
|
||||
? '<span class="badge bg-success status-badge">完成</span>'
|
||||
: '<span class="badge bg-danger status-badge">失败</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
|
||||
if (result.ok) {
|
||||
showDownloadLinks(result);
|
||||
loadInvoiceData(); // 加载可编辑数据
|
||||
} else {
|
||||
alert('处理失败: ' + (result.error || '未知错误'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
logBox.innerHTML += e.data;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
alert('请求失败: ' + (e.message || '未知错误'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 下载链接 ----
|
||||
function showDownloadLinks(result) {
|
||||
const section = document.getElementById('download-section');
|
||||
const box = document.getElementById('download-links');
|
||||
const warn = document.getElementById('doc-fill-warning');
|
||||
if (!section || !box) return;
|
||||
|
||||
const items = [];
|
||||
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
|
||||
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
|
||||
|
||||
lastDownloadUrls = {};
|
||||
items.forEach(it => { lastDownloadUrls[it.label] = it.url; });
|
||||
|
||||
box.innerHTML = items.map(it =>
|
||||
`<a class="btn btn-outline-primary btn-sm" href="${it.url}" download>${it.label}</a>`
|
||||
).join('');
|
||||
|
||||
if (warn) {
|
||||
if (result.doc_ok === false && result.doc_error) {
|
||||
warn.style.display = 'block';
|
||||
warn.textContent = '出库单未生成:' + result.doc_error;
|
||||
} else {
|
||||
warn.style.display = 'none';
|
||||
warn.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
section.style.display = items.length || (result.doc_ok === false) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ---- 发票数据编辑 ----
|
||||
async function loadInvoiceData() {
|
||||
try {
|
||||
const r = await fetch(`/api/data/${sessionId}`);
|
||||
const d = await r.json();
|
||||
if (d.error) return;
|
||||
|
||||
csvFilename = d.csv_filename || 'invoice_summary.csv';
|
||||
invoiceData = d.data || [];
|
||||
const fields = d.fields || Object.keys(invoiceData[0] || {}).filter(k => !k.startsWith('__'));
|
||||
renderTable(invoiceData, fields);
|
||||
|
||||
// 渲染后自动将配置区的工号/公务卡号同步到表格
|
||||
syncConfigToTable('工号');
|
||||
syncConfigToTable('公务卡号');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 配置区 → 表格的同步映射:工号 ↔ cfg-username,公务卡号 ↔ cfg-card
|
||||
const CONFIG_SYNC = {
|
||||
'工号': 'cfg-username',
|
||||
'公务卡号': 'cfg-card',
|
||||
};
|
||||
|
||||
// 从配置区输入框的值更新到所有表格行
|
||||
function syncConfigToTable(field) {
|
||||
const inputId = CONFIG_SYNC[field];
|
||||
if (!inputId) return;
|
||||
const val = document.getElementById(inputId).value || '';
|
||||
invoiceData.forEach((row, i) => {
|
||||
row[field] = val;
|
||||
});
|
||||
// 更新表格中对应单元格的显示
|
||||
const tbody = document.getElementById('invoice-tbody');
|
||||
if (!tbody) return;
|
||||
const rows = tbody.querySelectorAll('tr');
|
||||
rows.forEach((tr, i) => {
|
||||
const inputs = tr.querySelectorAll('input');
|
||||
fieldsCache.forEach((f, colIdx) => {
|
||||
if (f === field && inputs[colIdx]) {
|
||||
inputs[colIdx].value = val;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let fieldsCache = []; // renderTable 渲染后的字段列表,用于定位列索引
|
||||
|
||||
function renderTable(data, fields) {
|
||||
const section = document.getElementById('edit-section');
|
||||
if (!data.length) { section.style.display = 'none'; return; }
|
||||
section.style.display = 'block';
|
||||
|
||||
// 使用后端返回的字段顺序, fallback 到 Object.keys
|
||||
if (!fields || !fields.length) {
|
||||
fields = Object.keys(data[0]).filter(k => !k.startsWith('__'));
|
||||
}
|
||||
fieldsCache = fields;
|
||||
|
||||
// 表头
|
||||
document.getElementById('invoice-thead').innerHTML = `
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
${fields.map(f => `<th>${f}</th>`).join('')}
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// 表体:每行首列为序号,其后为各字段 input
|
||||
const rows = data.map((row, i) => {
|
||||
const cells = fields.map(f => {
|
||||
let onChangeStr = `invoiceData[${i}]['${f.replace(/'/g, "\\'")}']=this.value`;
|
||||
// 如果该字段参与配置区同步,额外调用 syncTableToConfig
|
||||
if (CONFIG_SYNC[f]) {
|
||||
onChangeStr += `;syncTableToConfig('${f.replace(/'/g, "\\'")}', this.value)`;
|
||||
}
|
||||
return `<td><input class="form-control form-control-sm" value="${escapeHtml(String(row[f] || ''))}"
|
||||
onchange="${onChangeStr}"></td>`;
|
||||
}).join('');
|
||||
return `<tr><td style="width:40px;text-align:center">${i + 1}</td>${cells}</tr>`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('invoice-tbody').innerHTML = rows;
|
||||
}
|
||||
|
||||
// 从表格修改同步回配置区
|
||||
function syncTableToConfig(field, value) {
|
||||
const inputId = CONFIG_SYNC[field];
|
||||
if (inputId) {
|
||||
document.getElementById(inputId).value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// 将当前表格编辑内容写回服务器(内部调用)
|
||||
async function saveInvoiceData() {
|
||||
try {
|
||||
const r = await fetch(`/api/save/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: invoiceData, csv_filename: csvFilename }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.doc_url || d.doc_ok === false) {
|
||||
showDownloadLinks({
|
||||
csv_url: lastDownloadUrls['invoice_summary.csv'],
|
||||
doc_url: d.doc_url,
|
||||
doc_ok: d.doc_ok,
|
||||
doc_error: d.doc_error,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('自动保存失败,继续提交:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 提交到财务系统 ----
|
||||
async function submitFinancial() {
|
||||
const btn = document.getElementById('btn-submit');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '提交中...';
|
||||
document.getElementById('log-box').innerHTML = '';
|
||||
|
||||
try {
|
||||
// 提交前先自动保存当前表格编辑内容
|
||||
await saveInvoiceData();
|
||||
|
||||
await fetch(`/api/submit-financial/${sessionId}`, { method: 'POST' });
|
||||
|
||||
// 监听日志流(复用 SSE)
|
||||
const es = new EventSource(`/api/logs/${sessionId}`);
|
||||
const logBox = document.getElementById('log-box');
|
||||
let firstLine = true;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
btn.disabled = false;
|
||||
const result = msg.result;
|
||||
if (result && result.submit_ok) {
|
||||
btn.textContent = '✅ 提交完成';
|
||||
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 3000);
|
||||
} else {
|
||||
const errorMsg = result?.submit_error || result?.error || '未知错误';
|
||||
btn.textContent = '❌ 提交失败';
|
||||
logBox.innerHTML += `<div style="color: #e74c3c; font-weight: bold;">❌ 提交失败:${errorMsg}</div>`;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
console.warn('提交失败:', errorMsg);
|
||||
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
logBox.innerHTML += e.data;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 提交到财务系统';
|
||||
};
|
||||
} catch (e) {
|
||||
alert('提交失败: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 提交到财务系统';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 二维码 / 手机扫码上传 ----
|
||||
let qrGenerated = false;
|
||||
let syncTimer = null;
|
||||
|
||||
async function generateQr() {
|
||||
await ensureSession();
|
||||
const mobileUrl = window.location.origin + '/mobile/' + sessionId;
|
||||
const box = document.getElementById('qrcode');
|
||||
box.innerHTML = '';
|
||||
new QRCode(box, {
|
||||
text: mobileUrl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
colorDark: '#333',
|
||||
colorLight: '#fff',
|
||||
});
|
||||
qrGenerated = true;
|
||||
}
|
||||
|
||||
// ---- 实时同步:轮询服务器文件列表,检测手机端上传的新图片 ----
|
||||
async function startSync() {
|
||||
if (syncTimer) return;
|
||||
await syncFiles();
|
||||
syncTimer = setInterval(syncFiles, 3000);
|
||||
}
|
||||
|
||||
function stopSync() {
|
||||
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
|
||||
}
|
||||
|
||||
async function syncFiles() {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const r = await fetch(`/api/files/${sessionId}`);
|
||||
const d = await r.json();
|
||||
const serverNames = new Set(d.images || []);
|
||||
const localNames = new Set(imgFiles.map(f => f.name));
|
||||
|
||||
for (const name of serverNames) {
|
||||
if (!localNames.has(name)) {
|
||||
const resp = await fetch(`/api/download/${sessionId}/${encodeURIComponent(name)}`);
|
||||
const blob = await resp.blob();
|
||||
const file = new File([blob], name, { type: blob.type });
|
||||
file.__source = 'server'; // 标记为扫码上传
|
||||
imgFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// 只清理来自服务器但已不存在的文件,保留本地手动选择的文件
|
||||
for (const f of [...imgFiles]) {
|
||||
if (!serverNames.has(f.name) && f.__source !== 'local') {
|
||||
const idx = imgFiles.indexOf(f);
|
||||
if (idx > -1) imgFiles.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
renderFileList('img');
|
||||
if (imgFiles.length) {
|
||||
document.getElementById('img-zone').classList.add('active');
|
||||
} else {
|
||||
document.getElementById('img-zone').classList.remove('active');
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
generateQr();
|
||||
startSync();
|
||||
|
||||
// ---- 配置区 → 表格的双向同步绑定 ----
|
||||
Object.values(CONFIG_SYNC).forEach(inputId => {
|
||||
const el = document.getElementById(inputId);
|
||||
if (el) {
|
||||
el.addEventListener('input', () => {
|
||||
// 根据 inputId 反查对应的字段名
|
||||
const field = Object.entries(CONFIG_SYNC).find(([, id]) => id === inputId)?.[0];
|
||||
if (field) syncConfigToTable(field);
|
||||
});
|
||||
}
|
||||
});
|
||||
138
src/web/templates/index.html
Normal file
138
src/web/templates/index.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>财务报销自动化</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/static/css/index.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header text-center mb-4">
|
||||
<h3>财务报销自动化</h3>
|
||||
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、LLM 识别并填报</p>
|
||||
</div>
|
||||
|
||||
<div class="container" style="max-width:960px">
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="section-title">📄 发票 PDF <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||||
<div class="upload-zone" id="pdf-zone" onclick="document.getElementById('pdf-input').click()">
|
||||
<div class="icon">📁</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传 PDF 文件</div>
|
||||
<div id="pdf-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="section-title">🖼️ 支付截图 <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||||
<div class="upload-zone" id="img-zone" onclick="document.getElementById('img-input').click()">
|
||||
<div class="icon">🖼️</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传图片文件</div>
|
||||
<div id="img-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
|
||||
</div>
|
||||
<div class="col-md-4 text-center">
|
||||
<div class="section-title">📱 手机扫码上传</div>
|
||||
<div id="qrcode" style="display:inline-block"></div>
|
||||
<p class="text-muted mt-2 mb-0" style="font-size:12px">扫码即可拍照上传</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSV 快捷上传 -->
|
||||
<div class="mb-4">
|
||||
<div class="section-title">📊 CSV 快捷上传 <span class="text-muted fw-normal" style="font-size:12px">(已有发票数据 CSV 可直接上传,跳过提取和 LLM 识别)</span></div>
|
||||
<div class="upload-zone" id="csv-zone" onclick="document.getElementById('csv-input').click()">
|
||||
<div class="icon">📊</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传 CSV 文件</div>
|
||||
<div id="csv-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="csv-input" accept=".csv" hidden onchange="handleCsvFile(this)">
|
||||
</div>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="section-title">⚙️ 配置
|
||||
<span style="font-size:12px;font-weight:normal;cursor:pointer;color:#667eea;margin-left:8px" onclick="document.getElementById('config-upload').click()">
|
||||
📤 上传 config.json
|
||||
</span>
|
||||
<input type="file" id="config-upload" accept=".json" hidden onchange="handleConfigUpload(this)">
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">账号 (工号)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-username" placeholder="202xxxx">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">密码</label>
|
||||
<input type="password" class="form-control form-control-sm" id="cfg-password" placeholder="登录密码">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">默认姓名</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-name" placeholder="张三">
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">公务卡号</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-card" placeholder="628288...">
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">存放地点(出库单)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-storage" placeholder="新工科楼">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="text-center mb-4">
|
||||
<button class="btn btn-primary btn-process" id="btn-start" onclick="startProcess()">开始处理</button>
|
||||
<span id="status" class="ms-3"></span>
|
||||
</div>
|
||||
|
||||
<!-- 下载区 -->
|
||||
<div class="card mb-4" id="download-section" style="display:none">
|
||||
<div class="card-body py-3">
|
||||
<div class="section-title mb-2">📥 下载文件</div>
|
||||
<div id="download-links" class="d-flex flex-wrap gap-2"></div>
|
||||
<div id="doc-fill-warning" class="text-warning mt-2" style="font-size:13px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发票数据编辑区 -->
|
||||
<div class="card mb-4" id="edit-section" style="display:none">
|
||||
<div class="card-body">
|
||||
<div class="section-title d-flex justify-content-between align-items-center">
|
||||
<span>📝 发票数据(可编辑)</span>
|
||||
<div class="d-flex justify-content-end align-items-center">
|
||||
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="edit-note">直接点击单元格即可编辑,提交时自动保存当前修改。</p>
|
||||
<div class="table-wrapper">
|
||||
<table class="table table-sm table-bordered table-editable mb-0" id="invoice-table">
|
||||
<thead id="invoice-thead"></thead>
|
||||
<tbody id="invoice-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<div class="section-title">📋 处理日志</div>
|
||||
<div class="log-container mb-4" id="log-box"><div class="empty">等待开始...</div></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
<script src="/static/js/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
124
src/web/templates/mobile_upload.html
Normal file
124
src/web/templates/mobile_upload.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>支付截图上传</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f5f7fa; padding-bottom: 40px; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 20px 16px; text-align: center; }
|
||||
.upload-btn { width: 100%; padding: 18px; font-size: 16px; border-radius: 12px; margin-top: 16px; }
|
||||
.img-preview { width: 100px; height: 100px; object-fit: cover; border-radius: 8px; margin: 4px; }
|
||||
.file-card { background: #fff; border-radius: 8px; padding: 8px; margin-bottom: 8px; display: flex; align-items: center; gap: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.file-card img { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; }
|
||||
.file-card .info { flex: 1; min-width: 0; }
|
||||
.file-card .name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-card .remove { color: #c00; cursor: pointer; padding: 4px 8px; }
|
||||
.status-msg { text-align: center; padding: 12px; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% if error %}
|
||||
<div class="header"><h5>错误</h5><p>{{ error }}</p></div>
|
||||
{% else %}
|
||||
|
||||
<div class="header">
|
||||
<h5>📸 支付截图上传</h5>
|
||||
<p class="mb-0 opacity-75" style="font-size:13px">拍照或从相册选择图片</p>
|
||||
</div>
|
||||
|
||||
<div class="container" style="max-width:480px">
|
||||
|
||||
<!-- 上传按钮 -->
|
||||
<input type="file" id="file-input" accept="image/*" multiple hidden onchange="handleSelect(this)">
|
||||
|
||||
<button class="btn btn-primary upload-btn" onclick="document.getElementById('file-input').click()">
|
||||
📷 选择图片 / 拍照
|
||||
</button>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<div id="upload-status" class="status-msg mt-3"></div>
|
||||
|
||||
<!-- 已上传图片列表 -->
|
||||
<h6 class="mt-3 mb-2">已上传 ({{ server_count | default(0) }})</h6>
|
||||
<div id="file-list"></div>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
const sessionId = "{{ session_id }}";
|
||||
|
||||
// ---- 加载已有文件 ----
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const r = await fetch(`/api/files/${sessionId}`);
|
||||
const d = await r.json();
|
||||
renderList(d.images || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(images) {
|
||||
const box = document.getElementById('file-list');
|
||||
if (!images.length) {
|
||||
box.innerHTML = '<p class="text-muted text-center" style="font-size:13px">暂无图片</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = images.map(name => `
|
||||
<div class="file-card">
|
||||
<img src="/api/download/${sessionId}/${encodeURIComponent(name)}#t=image/png" alt="">
|
||||
<div class="info"><div class="name">${name}</div></div>
|
||||
<span class="remove" onclick="removeFile('${name}')">×</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ---- 上传 ----
|
||||
async function handleSelect(input) {
|
||||
const files = Array.from(input.files);
|
||||
if (!files.length) return;
|
||||
|
||||
const status = document.getElementById('upload-status');
|
||||
let done = 0;
|
||||
status.innerHTML = `<div class="text-primary">上传中... 0/${files.length}</div>`;
|
||||
|
||||
for (const f of files) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
||||
done++;
|
||||
status.innerHTML = `<div class="text-primary">上传中... ${done}/${files.length}</div>`;
|
||||
} catch (e) {
|
||||
status.innerHTML = `<div class="text-danger">${f.name} 上传失败</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (done === files.length) {
|
||||
status.innerHTML = `<div class="text-success">✅ ${done} 张图片上传成功</div>`;
|
||||
} else {
|
||||
status.innerHTML = `<div class="text-warning">${done}/${files.length} 张上传成功</div>`;
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
// ---- 删除(前端视觉移除,实际保留在服务器)----
|
||||
function removeFile(name) {
|
||||
// 移动端暂不支持删除,提示用户回到 PC 端管理
|
||||
if (confirm('文件已上传到服务器。如需删除请回到电脑端操作。')) {
|
||||
loadFiles();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 定时刷新列表(同步 PC 端新增的文件)----
|
||||
setInterval(loadFiles, 5000);
|
||||
loadFiles();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user