完成差旅发票录入流程

This commit is contained in:
wandering
2026-06-11 19:22:34 +08:00
parent cf567c22f2
commit 10115214aa
50 changed files with 3033 additions and 2756 deletions

92
src/README.md Normal file
View File

@@ -0,0 +1,92 @@
---
last_reviewed: 2026-06-11
---
# src — 主源码目录
包含财务报销自动化系统的核心模块。
## 模块清单
| 文件/目录 | 说明 |
|-----------|------|
| `__init__.py` | 包初始化:提供 `get_logger()` 日志工厂(支持终端 + 文件双输出,按日期自动分文件) |
| `config.py` | 配置加载:从 `config.json` 读取用户凭据和默认值从环境变量读取服务端配置SSO 地址、LLM 参数) |
| `pipeline.py` | 流程编排:串联发票提取 → 分类 → CSV 保存 → 浏览器填报,支持分步执行 |
| `main.py` | CLI 入口:支持 `--step` 分步执行、`-u/-p` 覆盖凭据、`--cache-dir` 指定缓存目录 |
| `bot.py` | 浏览器自动化Playwright 驱动的财务系统填报机器人(含差旅/普通两种模式,支持 headless |
| `doc/` | 文档处理模块PDF 渲染、LLM 提取、支付匹配、发票分类、出库单生成 |
| `web/` | Web 界面模块Flask 应用、SSE 日志、可编辑表格、移动端上传、会话隔离 |
## 数据流
```
CLI/Web 入口 → pipeline.py (编排)
doc/extractor.py (统一提取入口)
┌──── doc/pdf.py (PDF 渲染为图片)
├── doc/llm_extractor.py (多模态 LLM 识别)
│ ├── 发票 (invoice_type=train/hotel/general)
│ ├── 支付记录 (invoice_type=payment)
│ └── 出差事前申请单 (invoice_type=application)
├── doc/matcher.py (发票与支付记录按金额匹配)
│ ├── 一对一匹配 (发票数 == 刷卡数)
│ └── 一对多匹配 (贪心算法,相对容差 3%)
└── doc/invoice.py (CSV/JSON 读写)
├── payment_records.csv (支付记录级别)
├── invoice_summary.csv (发票级别)
└── travel_applications.json (出差申请单)
doc/fill_consumable_doc.py (普通发票 → Word 出库单)
bot.py (浏览器填报)
├── 差旅模式: 差旅信息提取 → 填报差旅单 → 上传差旅附件
└── 普通模式: 基本信息 → 录入明细 → 支付信息 → 上传附件
```
## 文档处理子模块 (`doc/`)
详见 [`doc/README.md`](doc/README.md)
核心能力:
- **统一文档提取**LLM 自行判断文档类型(发票/支付记录/出差事前申请单),无需正则回退
- **JSON 缓存**:提取结果缓存于 `.invoice_cache/`,避免重复处理
- **金额匹配**:支持一对多匹配,相对容差 3%,未匹配发票单独列为记录
- **差旅信息提取**:综合发票、支付记录和匹配结果,提取出差事由、地点、时间等
- **出库单生成**:将 CSV 数据填入 Word 模板pywin32 COM仅 Windows
## Web 界面子模块 (`web/`)
详见 [`web/README.md`](web/README.md)
核心能力:
- **会话隔离**:每次上传生成独立 `session_id`,文件/日志/配置/结果各自隔离
- **移动端同步**PC 端生成二维码指向 `/mobile/<sid>`,跨设备协作上传
- **可编辑表格**:前端加载 CSV 数据,支持在线编辑后保存
## 启动方式
```bash
# CLI 模式
uv run python src/main.py --step all
# Web 模式
uv run python src/web/app.py
# 访问: http://localhost:5000
```
## 发票类型与路由
系统根据 `invoice_type` 字段自动分流:
| 发票类型 | 走什么流程 | 是否生成出库单 |
|----------|-----------|--------------|
| `train` / `hotel` | 差旅报销 | 否 |
| `general` | 普通报销 | 是(易耗品出库单) |
| `application` | 出差事前申请单 | 否(单独存储为 JSON |
**注意**:差旅发票和普通发票不支持混报,混合时会报错。

View File

@@ -24,7 +24,10 @@ def get_logger(name: str) -> logging.Logger:
日志同时输出到终端和 logs/<日期>.log
"""
logger = logging.getLogger(name)
if not logger.handlers:
# 使用专属标记判断是否已初始化标准 handlerstream + file
# 避免被 _SSELogHandler 等外部 handler 干扰
if not getattr(logger, "_standard_handlers_initialized", False):
logger.setLevel(logging.INFO)
formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT)
@@ -39,4 +42,6 @@ def get_logger(name: str) -> logging.Logger:
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger._standard_handlers_initialized = True # type: ignore[attr-defined]
return logger

View File

@@ -4,11 +4,10 @@
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
对外接口:
load_invoice_data(csv_path, config) -> list[dict] 从 CSV 加载并补全默认值
run_bot(config, invoices) 启动浏览器并执行填报流程
"""
import csv
import json
from pathlib import Path
from typing import Any
@@ -32,59 +31,22 @@ def _format_date(date_str: str) -> str:
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
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("invoice_type", "general")
if inv_type == "application":
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general, "application": application}
# ------------------------------------------------------------------
@@ -95,7 +57,7 @@ def load_invoice_data(csv_path: str, config: dict[str, str | Path]) -> list[dict
class ReimburseBot:
"""财务报销自动化机器人"""
def __init__(self, config: dict[str, Any], headless: bool = False):
def __init__(self, config: dict[str, Any], headless: bool = False, work_dir: Path | None = None):
self.config = config
self.headless = headless
self.work_dir: Path | None = None
@@ -111,7 +73,7 @@ class ReimburseBot:
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.context = self.browser.new_context(viewport={"width": 1360, "height": 768})
self.page = self.context.new_page()
self.page.set_default_timeout(30000)
@@ -156,7 +118,7 @@ class ReimburseBot:
self._screenshot("portal_timeout")
raise TimeoutError("登录超时,未跳转到信息门户")
def navigate_to_reimburse(self) -> None:
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
"""从统一信息平台进入报销系统"""
log.info("进入报销系统...")
self._wait_for('text="快捷入口"', timeout=5000)
@@ -197,17 +159,17 @@ class ReimburseBot:
pass
self._wait_for('text="报销录入"', timeout=5000)
common_url = self.config["reimburse_url"] + self.config["reimburse_page"]
common_url = self.config["reimburse_url"] + self.config[page_key]
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
self._wait_for('text="单据状态:"', timeout=5000)
def open_reimburse_menu(self) -> None:
"""点击「新增」创建新报销"""
log.info("创建新报销单...")
def create_new_form(self) -> None:
"""点击「新增」创建新单"""
log.info("创建新单...")
self.page.wait_for_timeout(2000)
try:
self.page.click('button:has-text("新增")', timeout=5000)
self.page.click("#insert", timeout=5000)
except Exception:
try:
self.page.click("text=新增", timeout=3000)
@@ -218,6 +180,31 @@ class ReimburseBot:
self.page.wait_for_timeout(3000)
self._screenshot("after_add_click")
def fill_travel_info(self, basic_info: dict[str, Any]) -> None:
"""填写差旅报销基本信息"""
log.info("填写差旅报销信息...")
try:
self.page.fill("#CAUSE", basic_info.get("travel_purpose", ""))
self.page.fill("#SITE", basic_info.get("travel_location", ""))
self.page.click("#PROJECTCODE", timeout=10000)
self.page.wait_for_timeout(1000)
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)
self.page.fill("#THEKSRQ", _format_date(basic_info.get("start_date", "")))
self.page.fill("#THEJSRQ", _format_date(basic_info.get("end_date", "")))
self.page.click("#saveAndNext", timeout=5000)
self.page.wait_for_timeout(2000)
self._screenshot("travel_basic_done")
except Exception:
log.error("填写基本信息失败")
self._screenshot("travel_basic_error")
def fill_basic_info(self, description: str = "元器件采购报销") -> None:
"""填写基本信息"""
log.info("填写基本信息...")
@@ -254,6 +241,96 @@ class ReimburseBot:
self._screenshot("step3_done")
def add_travel_items(self, travel_items: dict[str, Any]) -> None:
"""录入差旅报销明细"""
vehicle_map = {
"火车": "01",
"汽车": "02",
"轮船": "03",
"自带车": "04",
"公务车": "05",
"飞机": "06",
"租车": "07",
"自驾车": "08",
}
try:
traffic_info = travel_items.get("transport_fee") or []
for item in traffic_info:
self.page.click("#insertDetail", timeout=5000)
self._wait_for('text="增加明细"', timeout=5000)
self.page.select_option("#cost", "1")
self.page.wait_for_timeout(500)
vehicle = item.get("vehicle_type", "")
if vehicle in vehicle_map:
self.page.select_option("#jtgj", vehicle_map[vehicle])
self.page.fill("#ksdd", item.get("departure_place", ""))
self.page.fill("#jsdd", item.get("arrival_place", ""))
self.page.fill(
'#t1 input[name="expenPwTraveldetail.MONEY"]',
str(item.get("amount", "")),
)
self.page.fill(
'#t1 input[name="expenPwTraveldetail.HOWBILL"]',
str(item.get("bill_count", "")),
)
self.page.fill(
'#t1 input[name="expenPwTraveldetail.SMARK"]',
str(item.get("remark", "")),
)
self.page.click("#detailAdd", timeout=3000)
self.page.wait_for_timeout(1000)
hotel_info = travel_items.get("hotel_fee") or []
for item in hotel_info:
self.page.click("#insertDetail", timeout=5000)
self._wait_for('text="增加明细"', timeout=5000)
self.page.select_option("#cost", "2")
self.page.wait_for_timeout(500)
self.page.fill("#ksrq2", _format_date(str(item.get("checkin_date", ""))))
self.page.fill("#jsrq2", _format_date(str(item.get("checkout_date", ""))))
self.page.fill("#ts2", str(item.get("days", "")))
self.page.fill("#rs2", str(item.get("person_count", "")))
self.page.fill(
'#t2 input[name="expenPwTraveldetail.FPMONEY"]',
str(item.get("invoice_amount", "")),
)
self.page.fill(
'#t2 input[name="expenPwTraveldetail.MONEY"]',
str(item.get("reimburse_amount", "")),
)
self.page.fill(
'#t2 input[name="expenPwTraveldetail.SMARK"]',
str(item.get("remark", "")),
)
self.page.click("#detailAdd", timeout=3000)
self.page.wait_for_timeout(1000)
conference_info = travel_items.get("conference_fee") or []
for item in conference_info:
self.page.click("#insertDetail", timeout=5000)
self._wait_for('text="增加明细"', timeout=5000)
self.page.select_option("#cost", "3")
self.page.wait_for_timeout(500)
self.page.fill(
'#t3 input[name="expenPwTraveldetail.HOWBILL"]',
str(item.get("bill_count", "")),
)
self.page.fill(
'#t3 input[name="expenPwTraveldetail.MONEY"]',
str(item.get("amount", "")),
)
self.page.fill(
'#t3 input[name="expenPwTraveldetail.SMARK"]',
str(item.get("remark", "")),
)
self.page.click("#detailAdd", timeout=3000)
self.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"录入总明细失败: {e}")
self._screenshot("item_total_error")
raise
def add_reimburse_items(self, invoices: list[dict[str, Any]]) -> None:
"""录入报销明细(一条总明细)"""
card_amount = sum(inv["card_amount"] for inv in invoices)
@@ -261,7 +338,6 @@ class ReimburseBot:
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)
@@ -286,6 +362,33 @@ class ReimburseBot:
self._screenshot("item_total_error")
raise
def fill_travel_payment(self, payment_info: list[dict[str, Any]]) -> None:
"""录入差旅支付信息"""
log.info("录入差旅支付信息...")
try:
self.page.click('text="下一步(支付方式)"', timeout=5000)
self._wait_for('text="下一步(补助清单)"', timeout=5000)
for info in payment_info:
self.page.click("#insertPay", timeout=5000)
self.page.wait_for_timeout(1000)
self.page.fill("#personid2", self.config["default_name"])
self.page.fill("#accountname2", self.config["default_person_id"])
self.page.fill("#receiptdate2", _format_date(info["card_date"]))
self.page.fill("#localaccount2", self.config["default_card_no"])
self.page.fill("#receiptmoney2", str(info["card_amount"]))
self.page.fill("#money2", str(info["card_amount"]))
self.page.fill("#merchant2", info.get("merchant", ""))
self.page.fill("#smark2", info.get("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 fill_payment(self, invoices: list[dict[str, Any]]) -> None:
"""录入支付信息"""
log.info("录入支付信息...")
@@ -314,11 +417,10 @@ class ReimburseBot:
self._screenshot("step5_done")
def upload_attachments(self, invoices: list[dict[str, Any]]) -> None:
"""上传发票附件"""
"""上传附件"""
log.info("上传附件...")
try:
self.page.click("#next3", timeout=5000)
self.page.click("#next4", timeout=5000)
self._wait_for("#submit2", timeout=5000)
attachment_files = sorted((self.work_dir or Path(__file__).parent.parent).glob("*.pdf"))
@@ -329,8 +431,8 @@ class ReimburseBot:
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("#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']}"
@@ -359,17 +461,33 @@ class ReimburseBot:
self._screenshot("step6_done")
def submit(self) -> None:
"""提交报销单"""
log.info("提交报销单...")
def upload_travel_attachments(self, attachment_info: list[dict[str, Any]]) -> None:
"""上传差旅附件"""
log.info("上传差旅附件...")
try:
self.page.click("#submit", timeout=5000)
self.page.wait_for_timeout(1000)
self._screenshot("submitted")
self.page.click("#next4", timeout=5000)
self._wait_for("#submit2", timeout=5000)
for info in attachment_info:
attachment_file = self.work_dir / info["filename"]
self._wait_for("#insertAcc", timeout=20000)
self.page.click("#insertAcc", timeout=5000)
self._wait_for("#fjlx", timeout=5000)
if info["attachment_type"] == "invoice":
self.page.select_option("#fjlx", "1")
else:
self.page.select_option("#fjlx", "2")
self.page.fill("#fpsmxx", info["attachment_desc"])
if attachment_file and attachment_file.exists():
self.page.set_input_files("#file", str(attachment_file))
self.page.wait_for_timeout(1000)
self.page.click("#cjtj", timeout=5000)
log.info("上传差旅附件完成")
except Exception as e:
log.error(f"提交失败: {e}")
self._screenshot("submit_error")
log.error(f"差旅附件上传失败: {e}")
self._screenshot("travel_attachment_error")
raise
self._screenshot("travel_attachment_done")
def close(self) -> None:
"""关闭浏览器"""
@@ -382,10 +500,6 @@ class ReimburseBot:
except Exception:
pass
# --------------------------------------------------------
# 辅助方法
# --------------------------------------------------------
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
self.page.wait_for_selector(selector, timeout=timeout)
@@ -394,31 +508,131 @@ class ReimburseBot:
img_dir.mkdir(exist_ok=True)
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
def fill_travel_subsidy(self, subsidy_info: list[dict[str, Any]]) -> None:
"""录入差旅补助清单"""
log.info("录入差旅补助清单...")
try:
self.page.click("#next3", timeout=5000)
self._wait_for("#next4", timeout=5000)
for info in subsidy_info:
self.page.click("#insertSubsidy", timeout=5000)
self._wait_for('text="增加补助清单"', timeout=5000)
self.page.click("#jzg3", timeout=5000)
self.page.wait_for_timeout(500)
if info["person_name"] and info["person_name"] != "":
self.page.fill("#seacher", info["person_name"])
elif info["person_id"] and info["person_id"] != "":
self.page.fill("#seacher", info["person_id"])
else:
raise ValueError(f"人员编号和人员姓名不能同时为空: {info}")
self.page.click("#cx", timeout=5000)
self.page.wait_for_selector("div.fixed-table-loading", state="hidden", timeout=10000)
self.page.click("#tableEmp tbody tr", timeout=10000)
self.page.wait_for_timeout(1000)
open_bank = self.page.input_value("#openbank1")
if not open_bank:
log.info("员工开户行未填写,默认填写中国工商银行")
self.page.fill("#openbank1", "中国工商银行")
self.page.fill("#startdate1", _format_date(info["start_date"]))
self.page.fill("#enddate1", _format_date(info["end_date"]))
self.page.fill("#trafficdays1", str(info["days"]))
self.page.fill("#fooddays1", str(info["days"]))
self.page.fill("#trafficnorm1", str(80))
self.page.fill("#foodnorm1", str(100))
trafficmoney = int(info["days"]) * 80
foodmoney = int(info["days"]) * 100
subsidymoney = trafficmoney + foodmoney
self.page.fill("#trafficmoney1", str(trafficmoney))
self.page.fill("#foodmoney1", str(foodmoney))
self.page.fill("#subsidymoney1", str(subsidymoney))
self.page.click("#add", timeout=3000)
self.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"差旅补助清单录入失败: {e}")
self._screenshot("subsidy_error")
raise
self._screenshot("subsidy_done")
# ------------------------------------------------------------------
# 对外入口
# ------------------------------------------------------------------
def run_bot(
config: dict[str, Any], invoices: list[dict[str, Any]], headless: bool = False, work_dir: Path | None = None
) -> None:
"""执行完整的浏览器填报流程"""
def run_bot(config: 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)
if not work_dir:
raise ValueError("缺少工作目录")
from .doc.llm_extractor import load_cache
cache_map = load_cache(work_dir)
doc_values = [v for k, v in cache_map.items() if k != "travel_info"]
invoices = [data for data in doc_values if data.get("invoice_type") not in ("application", "payment")]
applications = [data for data in doc_values if data.get("invoice_type") == "application"]
groups = _classify_invoice_batch(invoices)
travel_invoices = groups["travel"]
general_invoices = groups["general"]
log.info(
f"发票分类: 差旅 {len(travel_invoices)} 张, 普通 {len(general_invoices)} 张, "
f"出差事前申请单 {len(applications)}"
)
if travel_invoices and general_invoices:
raise ValueError(
f"发票类型混合(差旅 {len(travel_invoices)} 张 + 普通 {len(general_invoices)} 张),不支持混报,请分开提交"
)
is_travel = bool(travel_invoices)
travel_info: dict[str, Any] = cache_map.get("travel_info", {})
if is_travel and not travel_info:
from .doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info
travel_info = extract_travel_info(source_dir=work_dir)
cache_dir = work_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
json.dump(travel_info, f, ensure_ascii=False, indent=2)
log.info("差旅信息已保存到缓存")
bot = ReimburseBot(config, headless=headless, work_dir=work_dir)
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() # 确认无误后再取消注释
if is_travel:
log.info("处理差旅发票...")
bot.navigate_to_reimburse(page_key="travel_page")
bot.create_new_form()
basic_info = travel_info["basic_info"]
bot.fill_travel_info(basic_info)
travel_items = travel_info["reimbursement_details"]
bot.add_travel_items(travel_items)
payment_info = travel_info["payment_methods"]
bot.fill_travel_payment(payment_info)
subsidy_info = travel_info["subsidy_list"]
bot.fill_travel_subsidy(subsidy_info)
attachment_info = travel_info["attachments"]
bot.upload_travel_attachments(attachment_info)
else:
log.info("处理普通发票...")
bot.navigate_to_reimburse(page_key="reimburse_page")
bot.create_new_form()
bot.fill_basic_info()
bot.add_reimburse_items(general_invoices)
bot.fill_payment(general_invoices)
bot.upload_attachments(general_invoices)
except Exception as e:
log.error(f"操作失败: {e}")
try:
@@ -430,28 +644,6 @@ def run_bot(
bot.close()
def run_bot_web(config: dict[str, Any], invoices: list[dict[str, Any]], work_dir: Path) -> None:
def run_bot_web(config: 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()
run_bot(config, headless=True, work_dir=work_dir)

View File

@@ -1,13 +1,14 @@
"""
配置加载
项目根目录的 config.json 读取配置,返回结构化的配置字典
scripts/data/config.json 读取用户配置,从环境变量读取服务端配置LLM + 系统 URL
"""
import json
import os
from pathlib import Path
_CONFIG_PATH = Path(__file__).parent.parent / "config.json"
_CONFIG_PATH = Path(__file__).parent.parent.parent / "scripts" / "data" / "config.json"
def load_config() -> dict[str, str | Path]:
@@ -20,10 +21,11 @@ def load_config() -> dict[str, str | Path]:
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"),
"sso_login_url": os.environ.get("SSO_LOGIN_URL", "https://tyrz.fynu.edu.cn/sso/login"),
"portal_url": os.environ.get("PORTAL_URL", "https://tyrz.fynu.edu.cn/oshall"),
"reimburse_url": os.environ.get("REIMBURSE_URL", "http://210.45.32.214:8081"),
"reimburse_page": os.environ.get("REIMBURSE_PAGE", "/expen/common/common?v=4.0"),
"travel_page": os.environ.get("TRAVEL_PAGE", "/expen/travel/travel?v=4.0"),
"username": raw.get("username", ""),
"password": raw.get("password", ""),
"default_name": raw.get("default_name", ""),
@@ -35,15 +37,9 @@ def load_config() -> dict[str, str | Path]:
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", {})
"""加载 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"),
"model": os.environ.get("LLM_MODEL", "qwen-vl-max"),
"api_base": os.environ.get("LLM_API_BASE", "http://localhost:8080/v1"),
"api_key": os.environ.get("LLM_API_KEY", "lm-studio"),
}

View File

@@ -1,6 +1,6 @@
---
## last_reviewed: 2026-06-09
last_reviewed: 2026-06-09
---
# src/doc — 文档处理模块
@@ -12,13 +12,13 @@
| 文件 | 作用 |
| ------------------------ | -------------------------------------------------- |
| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 |
| `pdf.py` | PDF 文件发现与文本提取pdfplumber |
| `pdf.py` | PDF 图片渲染PyMuPDF |
| `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` |
| `prompts/` | 提示词模板文件(`invoice_system.md``travel_info_system.md` |
## 数据流
@@ -27,7 +27,7 @@
PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
支付截图 → llm_extractor.py → [刷卡记录]
matcher.py按金额贪心匹配容差 10 元
matcher.py按金额贪心匹配相对容差 3%
invoice.py 分类 → CSV已回填刷卡日期/卡号/金额)
@@ -36,7 +36,7 @@ PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
## 依赖说明
- **pdfplumber** — PDF 文本提取
- **PyMuPDF (pymupdf)** — PDF 图片渲染
- **pywin32** — Word COM 自动化(仅 Windows
- **llama-index** — LLM 信息提取
@@ -45,5 +45,4 @@ PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
- `fill_consumable_doc.py` 依赖 Microsoft Word + COM仅 Windows 可用
- LLM 提取不会覆盖 CSV 中已有非空字段
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
- LLM 提取失败时直接报错,无正则回退
- LLM 提取失败时直接报错,无正则回退

View File

@@ -1,65 +1,237 @@
"""发票提取编排
串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类,生成支付记录列表。
统一扫描目录下所有文件PDF + 图片),通过 LLM 提取结构化数据,
根据 LLM 返回的「invoice_type」字段自动分类为发票/支付记录/出差事前申请单。
对外接口:
extract_invoices(directory) -> tuple[list[dict], dict]
extract_invoices(directory) -> tuple[list[dict], list[dict], dict]
"""
import json
from pathlib import Path
from typing import Any
from .. import get_logger
from .invoice import classify_invoice_batch
from .llm_extractor import extract_invoice_from_text
from .llm_extractor import extract_document
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,提取发票信息并匹配支付记录
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("invoice_type", "general")
if inv_type == "application":
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general, "application": application}
# JSON 缓存目录(相对于源文件目录)
CACHE_DIR_NAME = ".invoice_cache"
# 支持的文件扩展名
SUPPORTED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".bmp"}
def _get_cache_dir(source_dir: Path) -> Path:
"""获取缓存目录路径"""
cache_dir = source_dir / CACHE_DIR_NAME
cache_dir.mkdir(exist_ok=True)
return cache_dir
def _get_json_path(file_path: Path, cache_dir: Path) -> Path:
"""根据文件路径生成对应的 JSON 缓存路径"""
return cache_dir / f"{file_path.stem}.json"
def _save_to_cache(file_path: Path, extracted_data: dict[str, Any], cache_dir: Path) -> Path:
"""将提取结果保存到 JSON 缓存文件,并记录源文件路径"""
json_path = _get_json_path(file_path, cache_dir)
cache_data = {
"source_file": str(file_path),
"source_filename": file_path.name,
"extracted_data": extracted_data,
}
with open(json_path, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=2)
log.info(f"提取结果已缓存: {json_path.name}")
return json_path
def _load_from_cache(json_path: Path) -> dict[str, Any] | None:
"""从 JSON 缓存文件加载提取结果"""
if not json_path.exists():
return None
try:
with open(json_path, encoding="utf-8") as f:
cache_data: dict[str, Any] = json.load(f)
result: dict[str, Any] | None = cache_data.get("extracted_data")
return result
except Exception as e:
log.warning(f"读取缓存失败 {json_path.name}: {e}")
return None
def _extract_document(file_path: Path, cache_dir: Path) -> dict[str, str] | None:
"""提取单个文件的结构化信息,优先使用缓存。
根据 LLM 返回的「invoice_type」字段自动分类
- "payment" -> 支付记录
- "application" -> 申请单
- 有 "invoice_number" -> 发票
- 其他 -> 无法识别
Args:
file_path: 文件路径PDF 或图片)。
cache_dir: JSON 缓存目录。
Returns:
(payment_records, groups): 支付记录列表和按发票类型分组的字典
groups = {'travel': [差旅发票], 'general': [普通发票]}
提取结果字典,失败时返回 None。
"""
pdf_files = find_pdf_files(directory)
if not pdf_files:
log.warning("未找到 PDF 文件")
return [], {"travel": [], "general": []}
json_path = _get_json_path(file_path, cache_dir)
cached = _load_from_cache(json_path)
if cached:
cached["_source_file"] = file_path.name
log.info(f"使用缓存: {file_path.name}")
return cached
log.info(f"发现 {len(pdf_files)} 个 PDF 文件")
log.info(f"使用多模态提取: {file_path.name}")
try:
result = extract_document(file_path)
if result:
result["_source_file"] = file_path.name
_save_to_cache(file_path, result, cache_dir)
return result
except Exception as e:
log.warning(f"多模态提取失败: {file_path.name} ({e})")
return None
def _find_all_files(directory: str) -> list[Path]:
"""扫描目录下所有支持的文件PDF + 图片)"""
dir_path = Path(directory)
files = [f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS]
return sorted(files)
def _save_match_result(payment_records: list[dict[str, Any]], cache_dir: Path) -> None:
"""将发票与支付记录的匹配结果保存到缓存。"""
match_data: dict[str, list[dict[str, Any]]] = {}
for record in payment_records:
card_source = record.get("_source_file", "")
matched = record.get("_matched_invoices", [])
card_amount = record.get("card_amount", "")
if matched:
invoice_list = [
{
"file": inv.get("_source_file", ""),
"type": inv.get("invoice_type", ""),
"amount": inv.get("total_amount", inv.get("total_amount", "")),
}
for inv in matched
]
if card_source:
match_data[f"{card_source}{card_amount})"] = invoice_list
else:
key = "__unmatched__"
if key not in match_data:
match_data[key] = []
match_data[key].extend(invoice_list)
if not match_data:
return
match_path = cache_dir / "match_result.json"
with open(match_path, "w", encoding="utf-8") as f:
json.dump(match_data, f, ensure_ascii=False, indent=2)
log.info(f"匹配结果已缓存: {match_path.name}")
def extract_invoices(
directory: str = ".",
) -> tuple[list[dict[str, str]], list[dict[str, str]], dict[str, list[dict[str, str]]]]:
"""扫描目录下所有文件,提取信息并匹配支付记录
统一使用 LLM 提取根据返回的「invoice_type」自动分类
- 发票(有 invoice_number-> 参与金额匹配
- 支付记录invoice_type="payment"-> 参与金额匹配
- 出差事前申请单 -> 单独存储,不参与匹配
Returns:
(payment_records, applications, groups):
- payment_records: 支付记录列表(仅包含真实发票,不含申请单)
- applications: 出差事前申请单列表(单独存储,不参与支付匹配)
- groups: 按文档类型分组的字典
{'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单]}
"""
source_dir = Path(directory)
cache_dir = _get_cache_dir(source_dir)
all_files = _find_all_files(directory)
if not all_files:
log.warning("未找到支持的文件")
return [], [], {"travel": [], "general": [], "application": []}
log.info(f"发现 {len(all_files)} 个文件")
all_invoices = []
for pdf_path in pdf_files:
text = extract_text_from_pdf(pdf_path)
if not text:
log.warning(f"未能提取文本: {pdf_path.name}")
all_cards = []
applications = []
for file_path in all_files:
result = _extract_document(file_path, cache_dir)
if not result:
log.warning(f"未能解析: {file_path.name}")
continue
invoice = extract_invoice_from_text(text, pdf_path.name)
inv_type = result.get("invoice_type", "")
if invoice and invoice.get("发票号码"):
all_invoices.append(invoice)
log.info(f"[{invoice['发票类型']}] 已解析: {pdf_path.name}")
if inv_type == "application":
applications.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif inv_type == "payment":
all_cards.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif result.get("invoice_number"):
all_invoices.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
else:
log.warning(f"未能解析: {pdf_path.name}")
log.warning(f"无法分类: {file_path.name} (invoice_type={inv_type})")
if all_invoices:
log.info(f"共处理 {len(all_invoices)} 张发票")
else:
log.info(f"分类结果: 发票 {len(all_invoices)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)}")
if not all_invoices:
log.warning("未成功解析任何发票")
# 将支付截图与发票进行金额匹配,返回以支付记录为主键的列表
payment_records = match_invoices_to_cards(all_invoices, directory)
payment_records = match_invoices_to_cards(all_invoices, all_cards)
_save_match_result(payment_records, cache_dir)
# 从支付记录中还原所有发票用于分类
all_invoices_restored: list[dict[str, str]] = []
# 构建分类
all_documents: list[dict[str, str]] = []
for record in payment_records:
all_invoices_restored.extend(record.get("_matched_invoices", []))
all_documents.extend(record.get("_matched_invoices", []))
all_documents.extend(applications)
groups = classify_invoice_batch(all_invoices_restored)
log.info(f"差旅发票: {len(groups['travel'])} 张, 普通发票: {len(groups['general'])}")
groups = _classify_invoice_batch(all_documents)
log.info(
f"文档分类: 差旅发票 {len(groups['travel'])} 张, "
f"普通发票 {len(groups['general'])} 张, "
f"出差事前申请单 {len(groups['application'])}"
)
return payment_records, groups
return payment_records, applications, groups

View File

@@ -14,8 +14,8 @@ from pathlib import Path
from typing import Any
from .. import get_logger
from ..bot import load_invoice_data
from ..config import load_config
from ..doc.invoice import load_invoice_csv
log = get_logger("fill_consumable_doc")
@@ -143,7 +143,8 @@ def fill_consumable_doc(
doc_path = Path(doc_path)
if config is None:
config = load_config()
invoices = load_invoice_data(str(csv_path), config)
invoices = load_invoice_csv(csv_path.parent / "invoice_summary.csv") or []
if backup:
bak = doc_path.with_suffix(doc_path.suffix + ".bak")
@@ -192,7 +193,7 @@ def fill_consumable_doc(
qty = str(qty_val) if qty_val > 0 else "1"
values = [
str(inv.get("seq", i + 1)),
str(inv.get("index", i + 1)),
parsed["product_name"],
parsed["spec"],
parsed["unit"],
@@ -240,7 +241,7 @@ def main() -> None:
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("--config", default=str(root / "scripts" / "data" / "config.json"))
parser.add_argument("--no-backup", action="store_true")
args = parser.parse_args()

View File

@@ -1,16 +1,12 @@
"""发票数据模型与 CSV 工具
定义发票类型常量、CSV 列结构,提供发票分类和 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
save_application_json(applications, path) 保存出差申请单 JSON
"""
import csv
@@ -21,76 +17,63 @@ from .. import get_logger
log = get_logger("invoice")
# ------------------------------------------------------------------
# CSV 列定义
# ------------------------------------------------------------------
# 发票级别 CSV 列(用于 invoice_summary.csv每行一张发票
# CSV 列名
INVOICE_LEVEL_COLUMNS = [
"序号",
"发票类型",
"发票号码",
"开票日期",
"项目名称",
"规格型号",
"价税合计",
"销售方名称",
"出发站",
"到达站",
"车次",
"乘车日期",
"座位等级",
"人员姓名",
"刷卡日期",
"公务卡号",
"刷卡金额",
"备注",
"工号",
"index",
"invoice_type",
"invoice_number",
"invoice_date",
"item_name",
"spec_model",
"total_amount",
"seller_name",
"departure",
"arrival",
"train_no",
"ride_date",
"seat_class",
"person_name",
"card_date",
"card_no",
"card_amount",
"remark",
"person_id",
]
# 支付记录级别 CSV 列(用于 payment_records.csv每行一笔支付
PAYMENT_RECORD_COLUMNS = [
"序号",
# 支付信息
"刷卡日期",
"公务卡号",
"刷卡金额",
# 发票聚合信息
"关联发票数",
"发票详情", # 格式: 类型[号码]¥金额 | 类型[号码]¥金额
"备注",
# 内部字段(用于下游解析)
"_invoices_json", # JSON 序列化的发票列表,供 bot/fill_doc 使用
"工号",
"index",
"card_date",
"card_no",
"card_amount",
"relative_invoice_count",
"invoice_detail",
"remark",
"_matched_invoices",
"person_id",
]
# ------------------------------------------------------------------
# 发票类型常量
# ------------------------------------------------------------------
INVOICE_TYPE_TRAIN = "高铁票"
INVOICE_TYPE_HOTEL = "酒店住宿"
INVOICE_TYPE_GENERAL = "普通发票"
INVOICE_TYPE_TRAVEL = frozenset([INVOICE_TYPE_TRAIN, INVOICE_TYPE_HOTEL])
def _is_application_document(invoice_type: str) -> bool:
"""判断是否为出差事前申请单"""
return invoice_type == "application"
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 = []
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("发票类型", INVOICE_TYPE_GENERAL)
if is_travel_invoice(inv_type):
inv_type = inv.get("invoice_type", "general")
if _is_application_document(inv_type):
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general}
return {"travel": travel, "general": general, "application": application}
# ------------------------------------------------------------------
@@ -108,69 +91,37 @@ def _clean_invoice_for_json(inv: dict[str, str]) -> dict[str, str]:
return clean
def load_csv(csv_path: Path) -> list[dict[str, str]] | None:
"""读取支付记录 CSV 为 dict 列表,失败返回 None"""
def _load_csv(
csv_path: Path,
required_columns: list[str],
label: str = "CSV",
) -> list[dict[str, str]] | None:
"""通用 CSV 读取器:按 required_columns 校验列,失败返回 None"""
try:
with open(csv_path, encoding="utf-8", newline="") as f:
with open(csv_path, encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames or []
missing = [c for c in PAYMENT_RECORD_COLUMNS if c not in fieldnames]
missing = [c for c in required_columns if c not in fieldnames]
if missing:
log.error(f"CSV 缺少必要列: {missing}")
log.error(f"{label} 缺少必要列: {missing}")
return None
return [row for row in reader]
except FileNotFoundError:
log.error(f"CSV 文件不存在: {csv_path.name}")
log.error(f"{label} 文件不存在: {csv_path.name}")
return None
except Exception as e:
log.error(f"CSV 读取失败: {e}")
log.error(f"{label} 读取失败: {e}")
return None
def load_csv(csv_path: Path) -> list[dict[str, str]] | None:
"""读取支付记录 CSV 为 dict 列表,失败返回 None"""
return _load_csv(csv_path, PAYMENT_RECORD_COLUMNS, "CSV")
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
return _load_csv(csv_path, INVOICE_LEVEL_COLUMNS, "发票 CSV")
def save_csv(
@@ -180,8 +131,8 @@ def save_csv(
"""将支付记录列表保存为 CSV以支付记录为主键
每条支付记录包含:
- 刷卡日期、公务卡号、刷卡金额(支付信息)
- 关联发票数、发票详情(发票聚合信息)
- card_date, card_no, card_amount(支付信息)
- relative_invoice_count, invoice_detail(发票聚合信息)
- _matched_invoices内部字段序列化为 JSON 存储在 CSV 中)
"""
csv_path = Path(output_path)
@@ -191,7 +142,6 @@ def save_csv(
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],
@@ -201,14 +151,14 @@ def save_csv(
writer.writerow(
[
idx,
record.get("刷卡日期", ""),
record.get("公务卡号", ""),
record.get("刷卡金额", ""),
record.get("关联发票数", str(len(matched_invoices))),
record.get("发票详情", ""),
record.get("备注", ""),
record.get("card_date", ""),
record.get("card_no", ""),
record.get("card_amount", ""),
record.get("relative_invoice_count", str(len(matched_invoices))),
record.get("invoice_detail", ""),
record.get("remark", ""),
invoices_json,
record.get("工号", ""),
record.get("person_id", ""),
]
)
@@ -223,6 +173,7 @@ def save_invoice_csv(
从 _matched_invoices 中还原每张发票,回填刷卡信息,
生成以发票为主键的 CSV用于人工填写报销单参考。
出差事前申请单不会被写入此文件(它们有独立的 CSV
"""
csv_path = Path(output_path)
@@ -235,27 +186,29 @@ def save_invoice_csv(
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)
if _is_application_document(clean_inv.get("invoice_type", "")):
continue
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("工号", ""),
clean_inv.get("invoice_type", ""),
clean_inv.get("invoice_number", ""),
clean_inv.get("invoice_date", ""),
clean_inv.get("item_name", ""),
clean_inv.get("spec_model", ""),
clean_inv.get("total_amount", ""),
clean_inv.get("seller_name", ""),
clean_inv.get("departure", ""),
clean_inv.get("arrival", ""),
clean_inv.get("train_no", ""),
clean_inv.get("ride_date", ""),
clean_inv.get("seat_class", ""),
clean_inv.get("person_name", ""),
record.get("card_date", ""),
record.get("card_no", ""),
record.get("card_amount", ""),
record.get("remark", ""),
record.get("person_id", ""),
]
)
idx += 1
@@ -263,11 +216,18 @@ def save_invoice_csv(
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)
def save_application_json(
applications: list[dict[str, str]],
output_path: str | Path = "travel_applications.json",
) -> None:
"""将出差事前申请单列表保存为独立 JSON 文件
log.info(f"CSV 已保存: {csv_path.name}")
使用 JSON 保留完整嵌套结构(如出差人员信息的列表形式),
避免 CSV 扁平化导致的字段丢失。
"""
json_path = Path(output_path)
with open(json_path, "w", encoding="utf-8") as f:
json.dump(applications, f, ensure_ascii=False, indent=2)
log.info(f"出差申请单 JSON 已保存: {json_path.name}")

View File

@@ -1,12 +1,19 @@
"""
LLM 信息提取
"""LLM 信息提取
使用 LLM 从 PDF 文本中提取结构化数据,以及从支付截图中提取刷卡信息
支持 JSON 格式输出,字段与 CSV_COLUMNS 对齐。
使用 LLM 从 PDF 文本/图片、支付截图中提取结构化数据。
对外接口:
extract_invoice_from_text(text, file_name) -> dict 从 PDF 文本提取发票信息
extract_card_info_from_image(image_path) -> dict 从支付截图提取刷卡信息
## 功能模块
- **统一文档提取**使用一套提示词LLM 自行判断文档类型(发票/支付记录/出差事前申请单等),支持 JSON 格式输出。
- **差旅信息提取**:综合多张发票、支付记录和匹配结果,提取出差事由、地点、时间等差旅相关信息。
- **缓存管理**:支持从 `.invoice_cache/` 目录加载已提取的结构化数据和匹配结果,避免重复处理。
## 对外接口
- `extract_document(file_path) -> dict` — 统一入口:从任意图片/PDF 提取信息
- `extract_travel_info(source_dir) -> dict` — 综合发票和匹配结果提取差旅信息
- `load_cache(source_dir) -> dict` — 加载缓存的结构化数据
- `load_match_result(source_dir) -> dict` — 加载发票与支付记录的匹配结果
"""
from __future__ import annotations
@@ -17,7 +24,10 @@ 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
from .prompt import (
build_invoice_system_prompt,
build_travel_info_system_prompt,
)
log = get_logger("llm_extractor")
@@ -38,47 +48,12 @@ def _create_llm() -> Any:
api_base=llm_config["api_base"],
api_key=llm_config.get("api_key", "lm-studio"),
temperature=0.1,
max_tokens=8192,
max_tokens=65535,
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()
@@ -98,31 +73,8 @@ def _parse_json_response(text: str) -> dict[str, Any]:
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
# ------------------------------------------------------------------
# 支付截图信息提取(多模态
# 统一文档提取(多模态,直接传图片给 LLM
# ------------------------------------------------------------------
@@ -134,36 +86,53 @@ def _image_to_base64(image_path: Path) -> str:
def _llm_query_multimodal(
system_prompt: str,
text: str,
image_b64: str,
max_tokens: int = 4096,
text: str | None = None,
image_b64s: list[str] | None = None,
blocks: list[Any] | None = None,
reasoning_effort: str = "none",
) -> str:
"""发送多模态请求(文本 + 图片)到 LLM。"""
"""发送多模态请求到 LLM。
Args:
system_prompt: 系统提示词。
text: 用户文本(与 image_b64s 配合使用,文本在前、图片在后)。
image_b64s: base64 编码的图片列表。
blocks: 预构建的内容块列表TextBlock/ImageBlock传入时忽略 text 和 image_b64s。
Returns:
LLM 响应文本。
"""
from llama_index.core.base.llms.types import ImageBlock, TextBlock
from llama_index.core.llms import ChatMessage
from ..config import get_llm_config
if blocks is not None:
final_blocks = blocks
else:
text = text or ""
image_b64s = image_b64s or []
final_blocks = [TextBlock(text=text)]
for img_b64 in image_b64s:
final_blocks.append(
ImageBlock(
url=f"data:image/jpeg;base64,{img_b64}",
detail="high",
)
)
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",
),
],
),
ChatMessage(role="user", blocks=final_blocks),
]
llm_config = get_llm_config()
llm = _create_llm()
log.info(
"开始请求 LLM 多模态 (model=%s, base=%s)",
"开始请求 LLM 多模态 (model=%s, base=%s, blocks=%d)",
llm_config["model"],
llm_config["api_base"],
len(final_blocks),
)
try:
@@ -171,8 +140,7 @@ def _llm_query_multimodal(
for resp in llm.stream_chat(
messages,
temperature=0.1,
max_tokens=max_tokens,
extra_body={"reasoning_effort": "none"},
extra_body={"reasoning_effort": reasoning_effort},
):
delta = resp.delta
if delta:
@@ -186,25 +154,173 @@ def _llm_query_multimodal(
raise
def extract_card_info_from_image(image_path: Path) -> dict[str, Any]:
"""从支付截图中提取刷卡信息。
def extract_document(file_path: Path) -> dict[str, Any]:
"""统一文档提取入口:从任意图片/PDF 中提取结构化信息。
LLM 会根据统一提示词自行判断文档类型(发票/支付记录/出差事前申请单等)。
Args:
image_path: 支付截图图片路径
file_path: 文件路径(支持 PDF 和图片格式)
Returns:
包含刷卡日期、刷卡金额、公务卡号的字典。
包含提取字段的字典。
"""
system_prompt = build_card_info_system_prompt()
user_text = f"请分析以下支付截图并提取信息:\n\n文件名: {image_path.name}"
from .pdf import render_pdf_to_images
image_b64 = _image_to_base64(image_path)
system_prompt = build_invoice_system_prompt()
user_text = f"请分析以下财务文档并提取信息:\n\n文件名: {file_path.name}"
# PDF 先渲染为图片
suffix = file_path.suffix.lower()
if suffix == ".pdf":
image_b64s = render_pdf_to_images(file_path)
else:
image_b64s = [_image_to_base64(file_path)]
if not image_b64s:
log.warning(f"文件渲染为空: {file_path.name}")
return {}
try:
response = _llm_query_multimodal(system_prompt, user_text, image_b64, max_tokens=4096)
response = _llm_query_multimodal(system_prompt, user_text, image_b64s)
result = _parse_json_response(response)
log.info("LLM 支付截图提取成功: %s", image_path.name)
log.info("LLM 文档提取成功: %s", file_path.name)
return result
except Exception as e:
log.error("LLM 支付截图提取失败: %s (%s)", image_path.name, e)
log.error("LLM 文档提取失败: %s (%s)", file_path.name, e)
raise
# ------------------------------------------------------------------
# 差旅信息提取
# ------------------------------------------------------------------
CACHE_DIR_NAME = ".invoice_cache"
def load_cache(source_dir: Path) -> dict[str, Any]:
"""从 JSON 缓存目录加载结构化数据,构建 source filename -> 缓存数据的映射。
Args:
source_dir: 源文件目录(包含 .invoice_cache 子目录)。
Returns:
{source_filename: extracted_data} 字典。
额外包含 "travel_info" 键(如果 travel_info.json 存在)。
"""
cache_map: dict[str, Any] = {}
cache_dir = source_dir / CACHE_DIR_NAME
if not cache_dir.exists():
return cache_map
for json_path in sorted(cache_dir.glob("*.json")):
try:
with open(json_path, encoding="utf-8") as f:
cache_data = json.load(f)
# travel_info.json 结构不同,直接存储
if json_path.name == "travel_info.json":
cache_map["travel_info"] = cache_data
continue
extracted = cache_data.get("extracted_data", {})
src_file = extracted.get("_source_file", "")
if src_file:
cache_map[src_file] = extracted
except Exception as e:
log.warning(f"读取缓存失败 {json_path.name}: {e}")
return cache_map
def load_match_result(source_dir: Path) -> dict[str, list[dict[str, Any]]]:
"""从 JSON 缓存目录加载发票与支付记录的匹配结果。
Args:
source_dir: 源文件目录(包含 .invoice_cache 子目录)。
Returns:
{支付记录源文件 (含金额): [发票信息列表]} 字典。
每个发票信息包含 file, type, amount 字段。
"""
cache_dir = source_dir / CACHE_DIR_NAME
match_path = cache_dir / "match_result.json"
if not match_path.exists():
return {}
try:
with open(match_path, encoding="utf-8") as f:
result: dict[str, list[dict[str, Any]]] = json.load(f)
return result
except Exception as e:
log.warning(f"读取匹配结果缓存失败: {e}")
return {}
def extract_travel_info(
source_dir: Path | None = None,
) -> dict[str, Any]:
"""根据差旅发票bot 格式),让 LLM 提取出差相关信息。
仅支持从 JSON 缓存加载数据。
bot 格式的发票包含以下字段:
- 发票类型, invoice_no, invoice_date, item_name, spec_model
- total_amount, seller_name, person_name, person_id
- card_date, card_no, card_amount, remark
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
Returns:
包含出差事由、地点、交通工具、时间、住宿信息等字段的字典。
"""
# 仅从 JSON 缓存加载结构化数据
if not source_dir:
log.warning("未提供 source_dir无法加载缓存数据")
return {}
system_prompt = build_travel_info_system_prompt()
# 构建 source filename -> 缓存数据的映射
cache_map = load_cache(source_dir)
# 加载发票与支付记录的匹配结果
match_result = load_match_result(source_dir)
# 拼接纯文本消息
parts = [
"以下是本次报销的所有源文件及其提取出的结构化数据。"
"每个源文件的数据来自 OCR 识别和发票信息提取,已按文件名分组展示。"
]
# 如果有匹配结果,作为额外上下文提供
if match_result:
parts.append(
"【发票与支付记录匹配结果】"
"以下数据已将发票信息与对应的支付记录进行关联匹配,"
"用于判断每笔支付对应的发票和商户信息。\n" + json.dumps(match_result, ensure_ascii=False, indent=2)
)
# 按源文件名提供结构化数据
for filename, extracted in cache_map.items():
parts.append(
f"【源文件: {filename}"
"以下为从该文件提取的结构化发票/支付/申请单数据。\n" + json.dumps(extracted, ensure_ascii=False, indent=2)
)
parts.append("\n=== 请返回 JSON 格式结果 ===")
user_message = "\n".join(parts)
log.info(f"user_message: {user_message}")
try:
response = _llm_query_multimodal(
system_prompt=system_prompt,
text=user_message,
reasoning_effort="low",
)
result = _parse_json_response(response)
log.info("LLM 差旅信息提取成功")
return result
except Exception as e:
log.error("LLM 差旅信息提取失败: %s", e)
raise

View File

@@ -1,8 +1,11 @@
"""发票与支付截图匹配
"""发票与支付记录匹配
将提取到的发票数据与支付截图中的刷卡记录进行金额匹配,
将提取到的发票数据与支付记录进行金额匹配,
输出以支付记录为主键的结果列表。
支付记录由统一提取模块extractor根据 LLM 返回的「invoice_type」字段分类而来
不再按文件类型假设文档类型。
## 业务约束
- 发票数 >= 付款记录数(最少一张发票对应一张付款记录)
@@ -11,7 +14,7 @@
## 匹配流程
1. 扫描目录下图片文件,调用 LLM 提取刷卡信息(日期/金额/卡号)
1. 接收分类好的发票和支付记录列表
2. 解析发票和刷卡记录的金额,进行总额校验
- 发票总额 < 刷卡总额时发出 warning
3. 按金额降序排序
@@ -39,27 +42,15 @@
## 对外接口
match_invoices_to_cards(invoices, directory, tolerance) -> list[dict]
match_invoices_to_cards(invoices, cards, 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:
"""安全转换为浮点数"""
@@ -71,50 +62,37 @@ def _safe_float(value: str | None, default: float = 0.0) -> float:
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}")
inv_type = inv.get("invoice_type", "unknown")
amount = inv.get("total_amount", "unknown")
if inv_type == "train":
label = inv.get("person_name") or inv.get("invoice_number", "unknown")
elif inv_type == "hotel":
label = "hotel"
else:
label = inv.get("item_name") or inv.get("invoice_number", "unknown")
parts.append(f"{inv_type}[{label}{amount}")
return " | ".join(parts)
def _relative_tolerance(base: float, rate: float = 0.05) -> float:
"""根据基准金额计算相对容差(默认 5%"""
def _relative_tolerance(base: float, rate: float = 0.03) -> float:
"""根据基准金额计算相对容差(默认 3%"""
return abs(base) * rate
def match_invoices_to_cards(
invoices: list[dict[str, Any]],
directory: str,
cards: list[dict[str, Any]] | None = None,
tolerance: float = 0.03,
) -> list[dict[str, Any]]:
"""将发票与支付截图按金额匹配,输出以支付记录为主键的结果列表
"""将发票与支付记录按金额匹配,输出以支付记录为主键的结果列表
支付记录由统一提取模块extractor根据 LLM 返回的「invoice_type」字段分类而来。
业务约束:
- 发票数 >= 付款记录数
@@ -122,28 +100,26 @@ def match_invoices_to_cards(
- 若发票数 == 付款数,一对一匹配,无需一对多
Args:
invoices: 发票列表,需包含 "价税合计" 字段
directory: 支付截图所在目录
tolerance: 金额匹配容差比例(默认 0.05 = 5%
invoices: 发票列表,需包含 "total_amount" 字段
cards: 支付记录列表,需包含 "card_amount" 字段(由 extractor 分类提供)
tolerance: 金额匹配容差比例(默认 0.03 = 3%
Returns:
以支付记录为主键的结果列表,每条记录包含:
- 刷卡日期、公务卡号、刷卡金额(支付信息)
- card_date, card_no, card_amount(支付信息)
- 关联发票列表_matched_invoices
- 发票详情备注
- 未匹配发票单独作为一条无刷卡信息的记录
"""
cards = _extract_all_cards(directory)
if not cards:
log.warning("刷卡记录可供匹配,发票将保持原状")
# 无刷卡记录时,每张发票作为独立记录返回
log.warning("支付记录可供匹配,发票将保持原状")
return _invoices_to_records(invoices)
# 解析金额
for card in cards:
card["_amount"] = _safe_float(card.get("刷卡金额"))
card["_amount"] = _safe_float(card.get("card_amount"))
for inv in invoices:
inv["_amount"] = _safe_float(inv.get("价税合计"))
inv["_amount"] = _safe_float(inv.get("total_amount"))
# 数据校验
total_invoices = sum(inv["_amount"] for inv in invoices)
@@ -156,8 +132,8 @@ def match_invoices_to_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}%匹配结果可能有偏差"
f"发票总额 (¥{total_invoices:.2f}) 小于刷卡总额 (¥{total_cards:.2f}), "
f"超出容差 {tolerance * 100:.0f}%, 匹配结果可能有偏差"
)
# 按金额降序排序
@@ -193,7 +169,7 @@ def _match(
) -> dict[int, list[int]]:
"""执行匹配,返回 {card_index: [invoice_indices]} 的映射
tolerance 为相对容差比例(如 0.05 表示 5%
tolerance 为相对容差比例(如 0.03 表示 3%
"""
result: dict[int, list[int]] = {}
assigned: set[int] = set()
@@ -213,10 +189,7 @@ def _match_one_to_one(
assigned: set[int],
result: dict[int, list[int]],
) -> None:
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对
tolerance 为相对容差比例,以刷卡金额为基准计算
"""
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对"""
for card_idx, card in enumerate(cards):
if card_idx >= len(invoices):
break
@@ -227,13 +200,13 @@ def _match_one_to_one(
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}"
f"[一对一] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
f"{card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}"
)
else:
log.warning(
f"[一对一] 金额偏差超出容差: "
f"{inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
f"{inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
f"vs ¥{card['_amount']:.2f} (差 ¥{diff:.2f}, 容差 ¥{card_tol:.2f})"
)
@@ -245,14 +218,7 @@ def _match_one_to_many(
assigned: set[int],
result: dict[int, list[int]],
) -> None:
"""一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配
tolerance 为相对容差比例(如 0.05 表示 5%),以刷卡金额为基准计算
匹配分两阶段:
1. 精确匹配:先扫描金额完全相等(差值 <= 0.01 元)的发票-刷卡对,直接锁定
2. 贪心匹配:剩余未分配的发票和刷卡记录走贪心凑金额
"""
"""一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配"""
# ---- 阶段 1精确匹配金额差 <= 0.01 元视为相等)----
exact_tolerance = 0.01
@@ -272,21 +238,20 @@ def _match_one_to_many(
assigned.add(idx)
result[card_idx] = [idx]
log.info(
f"[一对多-精确] {inv.get('发票号码', '未知')} ¥{inv_amount:.2f} "
f"{card.get('_source_file', '未知')} ¥{card_amount:.2f}"
f"[一对多-精确] {inv.get('invoice_number', 'unknown')} ¥{inv_amount:.2f} "
f"{card.get('_source_file', 'unknown')} ¥{card_amount:.2f}"
)
break # 每张刷卡只精确匹配一张发票
break
# ---- 阶段 2贪心匹配仅处理未精确匹配的刷卡记录----
for card_idx, card in enumerate(cards):
if card_idx in result: # 已在阶段 1 精确匹配
if card_idx in result:
continue
card_amount = card["_amount"]
if card_amount <= 0:
continue
# 以刷卡金额为基准计算相对容差
card_tol = _relative_tolerance(card_amount, tolerance)
remaining = card_amount
@@ -302,8 +267,6 @@ def _match_one_to_many(
if inv_amount <= 0:
continue
# 最后一张发票:金额 + 容差 >= remaining 即可
# 中间发票:金额不超过 remaining + 容差
if inv_amount + card_tol >= remaining:
is_match = True
else:
@@ -328,8 +291,8 @@ def _match_one_to_many(
for idx in matched_indices:
inv = invoices[idx]
log.info(
f"[一对多-贪心] {inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
f"{card.get('_source_file', '未知')} ¥{card['_amount']:.2f}"
f"[一对多-贪心] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
f"{card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}"
)
@@ -341,18 +304,18 @@ def _build_payment_records(
"""构建以支付记录为主键的结果列表"""
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),
"备注": "",
"card_date": card.get("card_date", ""),
"card_no": card.get("card_no", ""),
"card_amount": str(card["_amount"]),
"relative_invoice_count": str(len(matched_invs)),
"invoice_detail": _build_invoice_summary(matched_invs),
"remark": "",
"_source_file": card.get("_source_file", ""),
"_matched_invoices": matched_invs,
}
records.append(record)
@@ -365,12 +328,12 @@ def _build_payment_records(
unmatched = [inv for idx, inv in enumerate(invoices) if idx not in matched_indices]
for inv in unmatched:
record = {
"刷卡日期": "",
"公务卡号": "",
"刷卡金额": "",
"关联发票数": "1",
"发票详情": _build_invoice_summary([inv]),
"备注": "未匹配到支付记录",
"card_date": "",
"card_no": "",
"card_amount": "",
"relative_invoice_count": "1",
"invoice_detail": _build_invoice_summary([inv]),
"remark": "unmatched",
"_matched_invoices": [inv],
}
records.append(record)
@@ -383,12 +346,12 @@ def _invoices_to_records(invoices: list[dict[str, Any]]) -> list[dict[str, Any]]
records = []
for inv in invoices:
record = {
"刷卡日期": "",
"公务卡号": "",
"刷卡金额": "",
"关联发票数": "1",
"发票详情": _build_invoice_summary([inv]),
"备注": "",
"card_date": "",
"card_no": "",
"card_amount": "",
"relative_invoice_count": "1",
"invoice_detail": _build_invoice_summary([inv]),
"remark": "",
"_matched_invoices": [inv],
}
records.append(record)

View File

@@ -1,42 +1,46 @@
"""PDF 文件发现与文本提取
"""PDF 图片渲染
从 PDF 发票文件中提取原始文本内容
从 PDF 发票文件中渲染为图片供多模态 LLM 使用
对外接口:
find_pdf_files(directory) -> list[Path] 查找目录下所有 PDF
extract_text_from_pdf(filepath) -> str 提取 PDF 文本
render_pdf_to_images(filepath, dpi) -list[str] 渲染 PDF 为图片字节
"""
import base64
from pathlib import Path
import fitz
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 render_pdf_to_images(filepath: Path, dpi: int = 300) -> list[str]:
"""将 PDF 渲染为图片,返回 base64 编码的 JPEG 字符串列表。
Args:
filepath: PDF 文件路径。
dpi: 渲染分辨率(默认 150平衡质量与速度
def extract_text_from_pdf(filepath: Path) -> str:
"""从单个 PDF 中提取全部文本"""
Returns:
base64 编码的 JPEG 图片字符串列表(每页一个)。
"""
images = []
try:
import pdfplumber
except ImportError as err:
raise ImportError("缺少 pdfplumber请执行: uv pip install pdfplumber") from err
doc = fitz.open(filepath)
zoom = dpi / 72.0 # 72 DPI 是 fitz 默认
matrix = fitz.Matrix(zoom, zoom)
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)
for page in doc:
pix = page.get_pixmap(matrix=matrix)
jpg_bytes = pix.tobytes("jpg")
b64 = base64.b64encode(jpg_bytes).decode("utf-8")
images.append(b64)
doc.close()
log.info(f"PDF 渲染成功: {filepath.name} ({len(images)} 页, {dpi} DPI)")
except Exception as e:
log.error(f"无法读取 {filepath.name}: {e}")
return ""
log.error(f"PDF 渲染失败 {filepath.name}: {e}")
return images

View File

@@ -21,6 +21,6 @@ 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")
def build_travel_info_system_prompt() -> str:
"""构建差旅信息提取系统提示词。"""
return _load_prompt("travel_info_system.md")

20
src/doc/prompts/README.md Normal file
View File

@@ -0,0 +1,20 @@
---
last_reviewed: 2026-06-11
---
# src/doc/prompts — LLM 提示词模板
存放 LLM 信息提取使用的系统提示词模板文件,由 `src/doc/prompt.py` 动态加载。
## 模板清单
| 文件 | 用途 |
|------|------|
| `invoice_system.md` | 发票提取系统提示词:指导 LLM 从发票图片、支付截图、出差申请单等文档中提取结构化信息 |
| `travel_info_system.md` | 差旅信息提取系统提示词:指导 LLM 整合已结构化的发票信息、付款记录和出差申请单,生成差旅报销所需的结构化数据 |
## 加载方式
```python
from src.doc.prompt import build_invoice_system_prompt, build_travel_info_system_prompt
```

View File

@@ -1,11 +0,0 @@
# 支付截图信息提取系统提示词
你是财务支付截图信息提取助手。你的任务是从支付截图(银行转账记录、微信/支付宝付款凭证等)中提取结构化信息,并以 JSON 格式返回。
需要提取的字段(全部必填,无法识别时返回空字符串):
1. 刷卡日期: 支付发生的日期,格式为 YYYY/M/D
2. 刷卡金额: 实际支付金额,只保留数字(如 123.45
3. 公务卡号: 付款银行卡号,如果截图中有显示则提取,没有则返回空字符串
严格只输出 JSON不要输出任何其他文字、Markdown 标记或解释。

View File

@@ -1,33 +1,88 @@
# 发票提取系统提示词
你是财务文档信息提取助手。你的任务是从图片中提取结构化信息,可能是支付截图、银行转账记录、微信/支付宝付款凭证等,也可能是发票文件,也可能是出差事前申请单,不管任何形式都要用统一的 JSON 格式返回信息。
你是财务文档信息提取助手。你的任务是从发票文本中提取结构化信息,并以 JSON 格式返回
第一步要先判断是,支付记录、高铁票、酒店住宿,普通发票,然后不同类型输出的信息不同
需要提取的字段(全部必填,无法识别时返回空字符串):
## 输出示例
以下是火车票类型发票的完整示例:
```json
{
"invoice_type": "train", //必填项
"invoice_number": "26349119343000335414",
"invoice_date": "2026-06-05", //必填项
"ride_date": "2026-06-02", //必填项
"departure": "阜阳西", //必填项
"arrival": "合肥南", //必填项
"seat_class": "二等座",
"train_no": "G1967",
"person_name": "王建锋", //必填项
"total_amount": "115.50" //必填项
}
```
以下是支付记录的完整示例:
```json
{
"invoice_type": "payment",//必填项
"card_date": "2026-06-01",//必填项
"card_amount": "231.00",//必填项
"card_no": "6282****1682"
}
```
以下是酒店住宿发票的完整示例:
```json
{
"invoice_type": "hotel",//必填项
"invoice_number": "26342000001715702281",
"invoice_date": "2026-06-03",
"total_amount": "536.00"//必填项
}
```
## 重要规则
- **必填项**invoice_type、invoice_date、ride_date、departure、arrival、person_name、total_amount 字段为必填,必须填写。
- **空值处理**:可选字段如没有对应信息,返回空字符串;金额字段找不到才填`0`,否则尽量填写实际金额。
**支付记录**:如果是支付截图、银行转账记录、微信/支付宝付款凭证大概率就是支付记录,请返回如下字段(全部必填,无法识别时返回空字符串):
1. invoice_type: "payment"
2. card_date: 支付发生的日期,格式为 YYYY-M-D
3. card_amount: 实际支付金额,只保留数字(如 123.45
4. card_no: 付款银行卡号,如果截图中有显示则提取,没有则返回空字符串
**出差事前申请单**,如果是**出差事前申请单**请返回如下字段:
1. invoice_type: "application"
2. project_name通常是(项目编号/项目名称)
2. purpose一段文本描述
3. start_date格式为 YYYY-M-D
4. end_date格式为 YYYY-M-D
5. person_info包含
1. person_id字母+数字
2. person_name有编号就肯定由姓名
发票需要提取的字段(全部必填,无法识别时返回空字符串):
先判断发票类型,如果是高铁票/或者火车票,返回如下字段:
1. 发票类型: "高铁票"
2. 发票号码: 发票的唯一编号
3. 开票日期: 格式为 YYYY/M/D
4. 乘车日期: 格式为 YYYY/M/D
5. 出发站: 没有留空
6. 到达站: 没有留空
7. 座位等级: 没有留空
8. 车次: 没有留空
9. 人员姓名: 没有留空
10. 价税合计:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息
1. invoice_type: "train"
2. invoice_number: 发票的唯一编号
3. invoice_date: 格式为 YYYY-M-D
4. ride_date: 格式为 YYYY-M-D
5. departure: 没有留空
6. arrival: 没有留空
7. seat_class: 没有留空
8. train_no: 没有留空
9. person_name: 没有留空
10. total_amount:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息
如果是酒店住宿(酒店住宿通产包含关键字:住宿服务,酒店,生产生活服务等,请仔细分析,这种发票和普通发票类似),返回如下字段:
1. 发票类型: "酒店住宿"
2. 发票号码: 发票的唯一编号
3. 开票日期: 格式为 YYYY/M/D
4. 价税合计: 金额数字
1. invoice_type: "hotel"
2. invoice_number: 发票的唯一编号
3. invoice_date: 格式为 YYYY-M-D
4. total_amount: 金额数字
如果是普通发票,返回如下字段:
1. 发票类型: "普通发票"
2. 发票号码: 发票的唯一编号
3. 开票日期: 格式为 YYYY/M/D
4. 项目名称: 商品或服务名称,总结的人能看懂
5. 规格型号: 规格描述
6. 价税合计: 金额数字
7. 销售方名称: 卖方全称
1. invoice_type: "general"
2. invoice_number: 发票的唯一编号
3. invoice_date: 格式为 YYYY-M-D
4. item_name: 商品或服务名称,总结的人能看懂
5. spec_model: 规格描述
6. total_amount: 金额数字
7. seller_name: 卖方全称
严格只输出 JSON不要输出任何其他文字、Markdown 标记或解释。
**千万注意!千万注意!**严格只输出 JSON不要输出任何其他文字、Markdown 标记或解释。

View File

@@ -0,0 +1,123 @@
# 差旅信息提取系统提示词
你是财务差旅信息提取助手。你的任务是根据发票信息、付款记录,提取出差相关的结构化信息,并以严格符合以下类型要求的 JSON 格式返回。所有类型约束为最高优先级规则,任何情况下不得违反。
🔴 最高优先级:强制性类型约束(优先级高于所有其他规则)
1. 根节点必须包含且仅包含以下 6 个字段,字段类型绝对不可变更:
| 字段名 | 强制类型 | 空值处理规则 |
| ------ | --------- | ------------------ |
| `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 |
| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含以下 3 个子字段 |
| `payment_methods` | 数组 (list) | 必填,无数据时赋值为`[]` |
| `subsidy_list` | 数组 (list) | 必填,无数据时赋值为`[]` |
| `attachments` | 数组 (list) | 必填,无数据时赋值为`[]` |
2. `reimbursement_details`对象必须包含且仅包含以下 3 个子字段,每个子字段必须是数组类型:
|字段名|强制类型|空值处理规则|
|---|---|---|
|`transport_fee`|数组 (list)|无数据时赋值为`[]`|
|`hotel_fee`|数组 (list)|无数据时赋值为`[]`|
|`conference_fee`|数组 (list)|无数据时赋值为`[]`|
3. 绝对禁止以下行为:
* 省略上述任何一个根节点字段或报销明细的子字段
* 将数组类型的字段赋值为null、字符串、数字或对象
* 在报销明细中添加任何未定义的子字段
* 合并不同模块的数组数据
✅ 正确类型示例
```json
{
"basic_info": {...},
"reimbursement_details": {
"transport_fee": [{"vehicle_type":"train", ...}],
"hotel_fee": [],
"conference_fee": []
},
"payment_methods": [],
"subsidy_list": [{"person_name":"张三", ...}],
"attachments": [{"filename":"发票.pdf", ...}]
}
```
❌ 错误类型示例(绝对禁止)
```json
{
"basic_info": {...},
"reimbursement_details": {
"transport_fee": [{"vehicle_type":"train", ...}]
// 错误省略了hotel_fee和conference_fee字段
},
"payment_methods": null, // 错误数组类型不能为null
"subsidy_list": "" // 错误:数组类型不能为字符串
// 错误省略了attachments字段
}
```
## 输入数据说明
你会收到以下数据:
1. **发票信息**:包含高铁票(火车/飞机票)和酒店住宿发票的结构化提取数据
2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息
3. **出差事前申请单**(可选):包含项目名称、出差事由、出差时间、出差人员等信息
需要提取的信息:
1. `basic_info`:(必填,每一项都必须填,给出合理的猜测)
1. `travel_purpose`:如果有出差事前申请单,优先使用申请单中的出差事由;否则根据所有发票信息总结一个合理的出差事由(如"参加XX学术会议"、"前往XX办理公务"等)
2. `travel_location`:出差目的地,注意一定是从阜阳出发,根据交通工具出发点和目的地也可以推断得到出差地点,出差事前申请单也有说明
3. `start_date`:由交通工具发票的乘车日期推断,没有的话从一切可以知道的信息推断,格式 YYYY-M-D
4. `end_date`:由交通工具发票的乘车日期推断,没有的话从一切可以知道的信息推断,格式 YYYY-M-D
2. `reimbursement_details`:(至少有一项)
1. `transport_fee`:(如有,每一项都要必填,无直接信息时给出合理猜测;多项请采用上述通用 JSON 数组格式)
1. `vehicle_type`从以下选项中选择最符合的一个train、car、ship、personal_car、official_car、plane、rental_car、self_drive
2. `start_date`: 由交通工具发票的乘车日期填写,格式 YYYY-M-D
3. `end_date`: 由交通工具发票的乘车日期填写,格式 YYYY-M-D
4. `departure_place`:由交通工具发票的信息填写,通常是城市名称
5. `arrival_place`:由交通工具发票的信息填写,通常是城市名称
6. `amount`:由交通工具发票的信息填写,通常是城市名称
7. `bill_count`:由交通工具发票的信息填写,通常是城市名称
8. `remark`:填写基本信息,例如:王建锋和张国庆高铁票
2. `hotel_fee`:(如有,每一项都要必填,无直接信息时给出合理猜测;多项请采用上述通用 JSON 数组格式)
1. `checkin_date`:(酒店发票,通常不含)、(交通工具发票,优先级最高)、(出差事前申请单,时间有可能不对,实际不一定按照规划的进行,以交通工具离开阜阳时间为最高优先级)综合推断,格式 YYYY-M-D例如2026-06-01
2. `checkout_date`:(酒店发票,通常不含)、(交通工具发票,优先级最高)、(出差事前申请单,时间有可能不对,实际不一定按照规划的进行,以交通工具回阜阳时间为最高优先级)综合推断,格式 YYYY-M-D例如2026-06-03
3. `days`:结束日期 - 开始日期,整数,例如 2026-06-03 - 2026-06-01天数为 2 天
4. `person_count`:根据发票信息和车票信息综合判断住宿人数,有可能开成一张发票,人数一定是整数
5. `invoice_amount`:所有酒店住宿发票的价税合计总额,数字
6. `reimburse_amount`:所有酒店住宿付款记录的合计总额,数字
7. `remark`:根据所有信息综合判断住宿人员,然后就填写所有人姓名,例如:王建锋、张国庆住宿
3. `conference_fee`(如果有,每一项都要必填,给出合理的猜测)
1. `bill_count`:根据发票信息判断,有几张关于会务费培训费的发票,一定是整数
2. `amount`:会务费培训发票的总金额
3. `remark`:会务培训的基本信息
4. `payment_methods`:(多少笔支付记录就有多少条;多项请采用上述通用 JSON 数组格式)
1. `card_date`:根据付款记录,格式 YYYY-M-D
2. `card_amount`:根据付款记录填写,单位为元,数字
3. `merchant`:根据发票信息推测商户信息(高铁票统一为中国铁路)
4. `remark`:说明该笔付款关联的发票信息,例如:王建锋和张国庆从阜阳西 - 合肥南高铁票
5. `subsidy_list`:(必填;多项请采用上述通用 JSON 数组格式)
1. `person_id`:无直接信息时给出合理编号
2. `person_name`:根据车票、住宿等信息推断出差人员姓名
3. `start_date`:根据当前人员的来回的交通工具发票上的时间推断,如果没有依据基本信息中的日期信息,格式 YYYY-M-D例如2026-06-01
4. `end_date`:根据当前人员的来回的交通工具发票上的时间推断,如果没有依据基本信息中的日期信息,格式 YYYY-M-D例如2026-06-03
5. `days`:结束日期 - 开始日期 + 1整数例如2026-06-03 - 2026-06-01 + 1天数为 3 天)
6. `attachments`:(必填,用户已经告诉你所有文件了`【源文件: {filename}】`"invoice_type": "payment"的不作为附件)
1. `filename`: 严格使用用户提供的原始文件名,不得修改任何字符
2. `attachment_type`从以下两个选项中选择invoice、other
3. `attachment_desc`:简要描述该文件的基本信息
**推理规则**
- 补助清单由人员数量决定:例如`[{"person_id": "xxxxxxx", "person_name": "张三", "start_date":"2026-06-01", "end_date": "2026-06-03", "days": 3}, {"person_id": "2024xxxxx", "person_name": "李四", "start_date":"2026-06-01", "end_date": "2026-06-03", "days": 3}]`
- 支付方式示例:`[{"card_date": "2026-06-01","card_amount": 231.0,"merchant": "中国铁路网络有限公司","remark": "张国庆和王建锋从阜阳西-合肥南高铁票"},{"card_date": "2026-06-01","card_amount": 167.0,"merchant": "中国铁路网络有限公司","remark": "陈曙光从阜阳西-合肥南高铁票"}]`
- 交通费,去和回不能放在一起,最好放在两个交通费单里,去时放一个,回时放一个
- 如果有出差事前申请单,优先使用申请单中的出差事由
- 出差开始时间优先取最早的交通工具乘车日期,无交通工具发票时参考申请单时间
- 出差结束时间优先取最晚的交通工具乘车日期,无交通工具发票时参考申请单时间
- 若无交通工具发票,用开票日期和付款日期综合判断
- 住宿天数 = checkout_date - checkin_date 结果要大于等于 0
- 若只有单张酒店发票且无明确天数信息,住宿天数默认为 1
- 若只有单张酒店发票且无明确人数信息,住宿人数默认为 1
- 支付记录不放在附件中!
## 最终输出要求
* 严格只输出符合上述所有要求的 JSON 字符串
* 不要输出任何思考过程、解释文字、Markdown 标记或其他内容
* 输出的 JSON 必须语法正确,无多余逗号、引号等语法错误
* 必须严格遵守所有强制性类型约束,任何违反类型要求的输出均视为无效

View File

@@ -37,13 +37,18 @@ def main() -> None:
"-u",
"--username",
default=None,
help="信息门户登录账号(覆盖 config.json",
help="信息门户登录账号(覆盖 scripts/config.json",
)
parser.add_argument(
"-p",
"--password",
default=None,
help="信息门户登录密码(覆盖 config.json",
help="信息门户登录密码(覆盖 scripts/config.json",
)
parser.add_argument(
"--cache-dir",
default=None,
help="发票缓存目录(包含 .invoice_cache 子目录,默认: scripts/data",
)
args = parser.parse_args()
@@ -51,6 +56,7 @@ def main() -> None:
step=args.step,
username=args.username,
password=args.password,
cache_dir=args.cache_dir,
)
sys.exit(exit_code)

View File

@@ -5,8 +5,8 @@
数据在内存中流转,同时生成 CSV 中间产物。
发票类型区分:
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- 普通发票:生成易耗品出库单,走普通报销流程
- 差旅发票(train/hotel):不生成易耗品出库单,走差旅报销流程
- 普通发票general:生成易耗品出库单,走普通报销流程
"""
from pathlib import Path
@@ -16,52 +16,57 @@ 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_application_json,
save_invoice_csv,
)
from .doc.invoice import (
save_csv as save_payment_csv,
)
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("invoice_type", "general")
if inv_type == "application":
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general, "application": application}
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
def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
"""从缓存目录读取发票数据并按类型分组"""
from .doc.llm_extractor import load_cache
# 只选包含 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]
cache_map = load_cache(cache_path)
invoices = [data for data in cache_map.values() if data.get("invoice_type") not in ("application", "payment")]
return _classify_invoice_batch(invoices)
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:
def run_pipeline(
step: str = "all",
username: str | None = None,
password: str | None = None,
cache_dir: str | None = None,
) -> int:
"""执行报销流程
Args:
step: all | invoice | submit
username: 覆盖 config.json 中的用户名
password: 覆盖 config.json 中的密码
cache_dir: 发票缓存目录(包含 .invoice_cache 子目录)
"""
config = load_config()
if username:
@@ -69,8 +74,8 @@ def run_pipeline(step: str = "all", username: str | None = None, password: str |
if password:
config["password"] = password
# 工作目录(项目根目录)
project_dir = Path(__file__).parent.parent
cache_path = Path(cache_dir) if cache_dir else project_dir / "scripts" / "data"
# --------------------------------------------------
# Step 1: 发票提取
@@ -83,15 +88,17 @@ def run_pipeline(step: str = "all", username: str | None = None, password: str |
log.info("[1/2] 发票提取")
log.info("=" * 60)
payment_records, groups = extract_invoices(str(project_dir))
payment_records, applications, groups = extract_invoices(str(cache_path))
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")
save_payment_csv(payment_records, cache_path / "payment_records.csv")
save_invoice_csv(payment_records, cache_path / "invoice_summary.csv")
if applications:
save_application_json(applications, cache_path / "travel_applications.json")
# 打印分类结果
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])}")
if step == "invoice":
@@ -106,30 +113,22 @@ def run_pipeline(step: str = "all", username: str | None = None, password: str |
log.info("[2/2] 报销提交")
log.info("=" * 60)
from .bot import load_invoice_data, run_bot
from .bot import 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)
groups = _classify_from_cache(cache_path)
if groups["travel"] and not groups["general"]:
log.info("检测到纯差旅发票,使用差旅报销模式")
# TODO: 差旅报销填报流程
run_bot(config, bot_invoices)
run_bot(config, work_dir=cache_path)
else:
log.info("检测到普通发票,使用普通报销模式")
run_bot(config, bot_invoices)
run_bot(config, work_dir=cache_path)
if step == "submit":
log.info("[2/2] 报销提交 完成")
return 0
# --------------------------------------------------
# 全流程完成
# --------------------------------------------------
log.info("=" * 60)
log.info("全流程执行完毕")
log.info("=" * 60)

View File

@@ -11,7 +11,6 @@ last_reviewed: 2026-06-09
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5不引入构建工具保持单页应用轻量可维护。
- **双模式支持**PDF 发票提取模式和 CSV 快捷上传模式,后者跳过 LLM 识别和 PDF 解析,直接处理已有发票数据。
## 文件结构
@@ -51,7 +50,6 @@ src/web/
| 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 日志流 |

View File

@@ -38,13 +38,32 @@ from src.doc.fill_consumable_doc import ( # noqa: E402, I001
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,
save_application_json,
)
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("invoice_type", "general")
if inv_type == "application":
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general, "application": application}
fill_log = get_logger("fill_consumable_doc")
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
@@ -161,7 +180,7 @@ def _has_general_invoices(rows: list[dict[str, str]]) -> bool:
invoices.append(row)
if not invoices:
return False
groups = classify_invoice_batch(invoices)
groups = _classify_invoice_batch(invoices)
return len(groups["general"]) > 0
@@ -179,7 +198,7 @@ def _get_invoice_groups(rows: list[dict[str, str]]) -> dict[str, int]:
# 发票级别格式:直接使用
elif "发票类型" in row:
invoices.append(row)
groups = classify_invoice_batch(invoices)
groups = _classify_invoice_batch(invoices)
return {
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
@@ -250,14 +269,18 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
start = time.time()
# ---- Step 1: 发票提取 ----
invoices, groups = extract_invoices(str(session_dir))
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
# 保存两个 CSV支付记录级别供 bot/出库单使用)和发票级别(供人工参考)
# 保存 CSV支付记录级别供 bot/出库单使用)和发票级别(供人工参考)
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
# 出差申请单单独保存
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
@@ -276,50 +299,6 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
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]:
"""执行财务系统填报(从前端确认后调用)
@@ -331,9 +310,10 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str,
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from src.bot import load_invoice_data, run_bot_web
from src.bot import run_bot_web
bot_invoices = load_invoice_data(str(csv_path), config)
# TODO: 改为从缓存读取或直接请求 LLM与差旅报销保持一致
# bot_invoices = load_invoice_data(str(csv_path), config)
# 判断发票类型
rows = load_csv(csv_path)
@@ -345,7 +325,7 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str,
else:
fill_log.info("检测到普通发票,使用普通报销模式")
run_bot_web(config, bot_invoices, session_dir)
run_bot_web(config, session_dir)
return {"ok": True}
@@ -384,22 +364,6 @@ def upload_file(session_id: str) -> Any:
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:
"""列出会话目录中的文件"""
@@ -420,7 +384,6 @@ def start_process(session_id: str) -> Any:
return session_dir
body = request.get_json(silent=True) or {}
mode = body.get("mode", "auto") # "pdf", "csv", or "auto"
# 读取配置
config = _build_web_config(body)
@@ -435,19 +398,7 @@ def start_process(session_id: str) -> Any:
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)
result = run_pipeline_web(session_dir, config)
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):

18
src/web/static/README.md Normal file
View File

@@ -0,0 +1,18 @@
---
last_reviewed: 2026-06-11
---
# src/web/static — 静态资源目录
存放 Web 界面的 CSS 样式表和 JavaScript 前端逻辑。
## 文件结构
| 路径 | 说明 |
|------|------|
| `css/index.css` | 全局样式:上传区域、日志面板、可编辑表格、状态徽章 |
| `js/index.js` | 前端逻辑文件上传、SSE 日志监听、发票数据编辑、配置同步、移动端扫码上传、财务提交 |
## 技术栈
原生 JavaScript + Bootstrap 5无构建工具保持单页应用轻量可维护。

View File

@@ -1,6 +1,5 @@
let sessionId = null;
const pdfFiles = [], imgFiles = [];
let csvFile = null;
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
let csvFilename = ''; // 当前 CSV 文件名
let lastDownloadUrls = {}; // 最近一次可下载文件链接
@@ -50,23 +49,6 @@ function renderFileList(type) {
).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()">&times;</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];
@@ -122,22 +104,10 @@ function handleConfigUpload(input) {
});
});
// 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');
if (!pdfFiles.length && !imgFiles.length) {
alert('请先上传文件或图片');
return;
}
@@ -152,17 +122,11 @@ async function startProcess() {
try {
await ensureSession();
if (isCsvMode) {
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', 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 });
}
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>';
@@ -174,7 +138,6 @@ async function startProcess() {
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}`, {
@@ -248,8 +211,13 @@ function showDownloadLinks(result) {
).join('');
if (warn) {
if (result.doc_ok === false && result.doc_error) {
if (result.doc_skipped) {
warn.style.display = 'block';
warn.style.color = '#0d6efd';
warn.textContent = result.doc_message || '差旅报销无需生成易耗品出库单';
} else if (result.doc_ok === false && result.doc_error) {
warn.style.display = 'block';
warn.style.color = '';
warn.textContent = '出库单未生成:' + result.doc_error;
} else {
warn.style.display = 'none';
@@ -257,7 +225,7 @@ function showDownloadLinks(result) {
}
}
section.style.display = items.length || (result.doc_ok === false) ? 'block' : 'none';
section.style.display = items.length || (result.doc_ok === false) || result.doc_skipped ? 'block' : 'none';
}
// ---- 发票数据编辑 ----

View File

@@ -0,0 +1,14 @@
---
last_reviewed: 2026-06-11
---
# src/web/templates — HTML 模板目录
存放 Flask 渲染的 HTML 模板文件。
## 模板清单
| 文件 | 说明 |
|------|------|
| `index.html` | PC 端主界面包含文件上传区、配置表单、处理按钮、SSE 日志面板、可编辑发票表格、下载链接、财务提交按钮、移动端二维码 |
| `mobile_upload.html` | 移动端上传页面:支持拍照/相册选择,上传至当前会话 |

View File

@@ -43,17 +43,6 @@
</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">
@@ -111,7 +100,7 @@
<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>
<span>📝 付款记录(可编辑)</span>
<div class="d-flex justify-content-end align-items-center">
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
</div>