Compare commits

...

2 Commits

Author SHA1 Message Date
wandering
5c35bd06d6 差旅报销和普通报销都已经完成 2026-06-12 12:08:27 +08:00
wandering
7aa8ca86f2 日常报销和差旅报销都可以走通 2026-06-12 12:06:06 +08:00
35 changed files with 1343 additions and 810 deletions

BIN
.coverage

Binary file not shown.

1
.gitignore vendored
View File

@@ -10,4 +10,5 @@ logs/
.vscode/
uploads/
.env
images/
scripts/data/

View File

@@ -52,19 +52,66 @@
```mermaid
flowchart TB
PDF[PDF 发票 / 图片] --> Extract[extractor 提取]
Extract --> Classify{发票类型分类}
PDF[PDF 发票 / 图片] --> Extract[extractor 多模态提取]
Extract --> Cache[(.invoice_cache/*.json)]
Cache --> Classify{发票类型分类}
Classify -->|差旅发票| Travel[高铁票 / 酒店住宿]
Classify -->|普通发票| General[普通发票]
Travel --> CSV[(invoice_summary.csv)]
General --> CSV
CSV -->|仅普通发票| Fill[fill_consumable_doc]
Classify -->|支付记录| Payment[支付截图]
Classify -->|申请单| Application[出差事前申请单]
Travel --> Matcher[matcher 金额匹配]
Payment --> Matcher
Matcher --> MatchResult[(match_result.json)]
Cache --> TravelLLM[LLM 差旅信息提取]
MatchResult --> TravelLLM
TravelLLM --> TravelInfo[(travel_info.json)]
Cache --> NormalLLM[LLM 普通发票信息提取]
MatchResult --> NormalLLM
NormalLLM --> NormalInfo[(normal_info.json)]
TravelInfo -->|差旅基本信息| Bot_T[bot/travel.py<br/>差旅填报流程]
TravelInfo -->|报销明细| Bot_T
TravelInfo -->|支付方式| Bot_T
TravelInfo -->|补助清单| Bot_T
TravelInfo -->|附件清单| Bot_T
Bot_T --> Submit_T[差旅报销提交]
NormalInfo -->|报销说明| Bot_G[bot/normal.py<br/>普通填报流程]
NormalInfo -->|发票总数/金额| Bot_G
NormalInfo -->|支付方式| Bot_G
NormalInfo -->|附件清单| Bot_G
Bot_G --> Submit_G[普通报销提交]
General --> CSV[(invoice_summary.csv)]
CSV --> Fill[fill_consumable_doc]
Fill --> Doc[易耗品、出库单.doc]
CSV --> Bot[bot 浏览器自动化]
Bot -->|差旅模式| Submit_T[差旅报销填报]
Bot -->|普通模式| Submit_G[普通报销填报]
```
### 关键中间产物
| 文件 | 生成阶段 | 作用 |
|------|---------|------|
| `.invoice_cache/*.json` | extractor 提取 | 单张发票/支付记录/申请单的结构化数据 |
| `match_result.json` | matcher 匹配 | 支付截图与发票的关联关系(按金额匹配) |
| `travel_info.json` | LLM 差旅信息提取 | 综合发票缓存 + 匹配结果,生成差旅报销所需的全部结构化数据 |
| `normal_info.json` | LLM 普通发票信息提取 | 综合普通发票 + 匹配结果,生成普通报销所需的全部结构化数据 |
| `invoice_summary.csv` | extractor 提取 | 普通发票汇总(用于生成易耗品出库单) |
### bot 模块架构
`bot/` 包负责浏览器自动化填报,仅接收已提取的信息并执行填报操作,不承担信息提取职责:
| 模块 | 职责 |
|------|------|
| `bot/base.py` | `BaseBot` 基类:浏览器生命周期、登录、导航、截图 |
| `bot/travel.py` | 差旅填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
| `bot/normal.py` | 普通填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
| `bot/__init__.py` | 入口函数:`run_bot()` / `run_bot_web()`,负责类型判断和流程路由 |
## 环境要求
- **Python 3.12+**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-11
last_reviewed: 2026-06-12
---
# src — 主源码目录
@@ -12,41 +12,42 @@ last_reviewed: 2026-06-11
|-----------|------|
| `__init__.py` | 包初始化:提供 `get_logger()` 日志工厂(支持终端 + 文件双输出,按日期自动分文件) |
| `config.py` | 配置加载:从 `config.json` 读取用户凭据和默认值从环境变量读取服务端配置SSO 地址、LLM 参数) |
| `pipeline.py` | 流程编排:串联发票提取 → 分类 → CSV 保存 → 浏览器填报,支持分步执行 |
| `pipeline.py` | 流程编排:串联发票提取 → 类型判断 → 差旅/普通信息提取 → 浏览器填报,支持分步执行 |
| `main.py` | CLI 入口:支持 `--step` 分步执行、`-u/-p` 覆盖凭据、`--cache-dir` 指定缓存目录 |
| `bot.py` | 浏览器自动化Playwright 驱动的财务系统填报机器人(含差旅/普通两种模式,支持 headless |
| `bot/` | 浏览器自动化Playwright 驱动的财务系统填报机器人(仅负责接收信息并填报 |
| `doc/` | 文档处理模块PDF 渲染、LLM 提取、支付匹配、发票分类、出库单生成 |
| `web/` | Web 界面模块Flask 应用、SSE 日志、可编辑表格、移动端上传、会话隔离 |
## 数据流
```mermaid
graph TD
A[CLI/Web 入口] --> B["pipeline.py (编排)"]
B --> C["doc/extractor.py (统一提取入口)"]
C --> D["doc/pdf.py (PDF 渲染为图片)"]
C --> E["doc/llm_extractor.py (多模态 LLM 识别)"]
E --> F["发票 invoice_type=train/hotel/general"]
E --> G["支付记录 invoice_type=payment"]
E --> H["出差事前申请单 invoice_type=application"]
C --> I["doc/matcher.py (发票与支付记录按金额匹配)"]
I --> J["一对一匹配 发票数 == 刷卡数"]
I --> K["一对多匹配 贪心算法 相对容差 3%"]
C --> L["doc/invoice.py (CSV/JSON 读写)"]
L --> M["payment_records.csv (支付记录级别)"]
L --> N["invoice_summary.csv (发票级别)"]
L --> O["travel_applications.json (出差申请单)"]
B --> R{"判断报销类型"}
R -->|差旅| T["doc/llm_extractor.py (差旅信息提取)"]
R -->|普通| V["doc/llm_extractor.py (普通发票信息提取)"]
T --> W["travel_info.json (差旅信息: 交通/住宿明细、补贴、附件清单)"]
V --> X["normal_info.json (普通发票信息: 报销说明、发票总数、总金额、支付方式、附件清单)"]
W --> P["bot/ (浏览器填报 - 仅接收信息并填报)"]
X --> P
P --> Q["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
P --> S["普通模式: 基本信息 → 录入明细 → 支付信息 → 上传附件"]
```
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 (浏览器填报)
├── 差旅模式: 差旅信息提取 → 填报差旅单 → 上传差旅附件
└── 普通模式: 基本信息 → 录入明细 → 支付信息 → 上传附件
```
**数据流变更2026-06-11** 差旅信息提取从 `bot.py` 提升到 `pipeline.py` 编排层。在发票提取和匹配完成后立即判断报销类型,差旅发票调用 LLM 提取 `travel_info.json`,普通发票调用 LLM 提取 `normal_info.json`。Bot 仅负责接收信息并填报,不再承担信息提取职责。
## 文档处理子模块 (`doc/`)
@@ -57,6 +58,7 @@ CLI/Web 入口 → pipeline.py (编排)
- **JSON 缓存**:提取结果缓存于 `.invoice_cache/`,避免重复处理
- **金额匹配**:支持一对多匹配,相对容差 3%,未匹配发票单独列为记录
- **差旅信息提取**:综合发票、支付记录和匹配结果,提取出差事由、地点、时间等
- **普通发票信息提取**:综合普通发票、支付记录和匹配结果,提取报销说明、发票总数、总金额、支付方式、附件清单
- **出库单生成**:将 CSV 数据填入 Word 模板pywin32 COM仅 Windows
## Web 界面子模块 (`web/`)

View File

@@ -22,15 +22,26 @@ def get_logger(name: str) -> logging.Logger:
输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ...
日志同时输出到终端和 logs/<日期>.log
只在最顶层的 logger 上添加标准 handlerstream + file
子 logger 通过 propagate 将日志传递给父 logger 统一处理。
这样 _SSELogHandler 只需挂在父 logger 上即可捕获所有子日志。
"""
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
# 使用专属标记判断是否已初始化标准 handlerstream + file
# 避免被 _SSELogHandler 等外部 handler 干扰
if not getattr(logger, "_standard_handlers_initialized", False):
logger.setLevel(logging.INFO)
# 检查是否有父 logger 已初始化标准 handler
# 如果有,子 logger 不重复添加,靠 propagate 传递即可
parent_name = name.rsplit(".", 1)[0] if "." in name else None
parent_has_handlers = False
if parent_name:
parent = logging.getLogger(parent_name)
parent_has_handlers = getattr(parent, "_standard_handlers_initialized", False)
if not getattr(logger, "_standard_handlers_initialized", False) and not parent_has_handlers:
formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT)
# 只有没有已初始化的父 logger 时,才添加标准 handler
# 终端输出
utf8_stream = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
stream_handler = logging.StreamHandler(utf8_stream)

View File

@@ -1,649 +0,0 @@
"""
浏览器自动化填报
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
对外接口:
run_bot(config, invoices) 启动浏览器并执行填报流程
"""
import json
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 _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}
# ------------------------------------------------------------------
# 报销机器人
# ------------------------------------------------------------------
class ReimburseBot:
"""财务报销自动化机器人"""
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
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": 1360, "height": 768})
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, page_key: str = "reimburse_page") -> 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[page_key]
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
self._wait_for('text="单据状态:"', timeout=5000)
def create_new_form(self) -> None:
"""点击「新增」创建新单据"""
log.info("创建新单据...")
self.page.wait_for_timeout(2000)
try:
self.page.click("#insert", 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_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("填写基本信息...")
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_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)
log.info(f"录入报销明细 (合计 ¥{card_amount:.2f})...")
try:
self.page.click("#insertDetail", timeout=5000)
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_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("录入支付信息...")
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("#next4", 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 upload_travel_attachments(self, attachment_info: list[dict[str, Any]]) -> None:
"""上传差旅附件"""
log.info("上传差旅附件...")
try:
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("travel_attachment_error")
raise
self._screenshot("travel_attachment_done")
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 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], headless: bool = False, work_dir: Path | None = None) -> None:
"""执行完整的浏览器填报流程,根据发票类型自动路由"""
if not config["username"] or not config["password"]:
raise ValueError("缺少用户名或密码")
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()
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:
bot._screenshot("error")
except Exception:
pass
raise
finally:
bot.close()
def run_bot_web(config: dict[str, Any], work_dir: Path) -> None:
"""Web 模式填报 — headless附件从指定目录读取"""
run_bot(config, headless=True, work_dir=work_dir)

33
src/bot/README.md Normal file
View File

@@ -0,0 +1,33 @@
---
last_reviewed: 2026-06-12
---
# bot — 浏览器自动化填报模块
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
## 模块清单
| 文件 | 说明 |
|------|------|
| `__init__.py` | 对外入口:`run_bot()``run_bot_web()`,负责类型判断和流程路由 |
| `base.py` | `BaseBot` 基类:浏览器生命周期、登录、导航、截图、日期格式化 |
| `travel.py` | 差旅报销填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
| `normal.py` | 普通发票报销填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
## 架构设计
```
run_bot(config, travel_info, normal_info)
├── 创建 BaseBot启动浏览器登录门户
├── travel_info 存在 → travel.run(bot, travel_info)
└── normal_info 存在 → normal.run(bot, normal_info)
```
- **`BaseBot`** 只保留公共操作launch、login、navigate、create_new_form、close、screenshot
- **差旅/普通流程** 作为独立函数接受 `bot: BaseBot` 参数,符合函数式编程偏好
- **`__init__.py`** 仅做路由分发,不包含具体填报逻辑
## 变更历史
- **2026-06-12**:从 `bot.py` 单文件重构为 `bot/` 包,分离差旅和普通报销逻辑

93
src/bot/__init__.py Normal file
View File

@@ -0,0 +1,93 @@
"""
浏览器自动化填报
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
对外接口:
run_bot(config, travel_info, normal_info) 启动浏览器并执行填报流程
run_bot_web(config, work_dir) Web 模式填报(从缓存加载信息)
"""
from pathlib import Path
from typing import Any
from .. import get_logger
from .base import BaseBot
log = get_logger("bot")
def run_bot(
config: dict[str, Any],
headless: bool = False,
work_dir: Path | None = None,
travel_info: dict[str, Any] | None = None,
normal_info: dict[str, Any] | None = None,
) -> None:
"""执行完整的浏览器填报流程,根据发票类型自动路由
Args:
config: 配置字典。
headless: 是否无头模式。
work_dir: 工作目录。
travel_info: 差旅信息(由 pipeline 层提前提取并传入,非差旅时传 None
normal_info: 普通发票信息(由 pipeline 层提前提取并传入,非普通时传 None
"""
if not config["username"] or not config["password"]:
raise ValueError("缺少用户名或密码")
if not work_dir:
raise ValueError("缺少工作目录")
bot = BaseBot(config, headless=headless)
bot.work_dir = work_dir
try:
bot.launch()
bot.login_portal()
if travel_info is not None:
log.info("处理差旅发票...")
bot.navigate_to_reimburse(page_key="travel_page")
bot.create_new_form()
from . import travel
travel.run(bot, travel_info)
elif normal_info is not None:
log.info("处理普通发票...")
bot.navigate_to_reimburse(page_key="reimburse_page")
bot.create_new_form()
from . import normal
normal.run(bot, normal_info)
else:
raise ValueError("缺少差旅信息travel_info和普通发票信息normal_info无法继续填报")
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], work_dir: Path) -> None:
"""Web 模式填报 — headless附件从指定目录读取
Web 端的信息提取由 app.py 的管道负责,此处从缓存加载。
"""
from ..doc.llm_extractor import load_cache
cache_map = load_cache(work_dir)
travel_info = cache_map.get("travel_info")
normal_info = cache_map.get("normal_info")
run_bot(
config,
headless=True,
work_dir=work_dir,
travel_info=travel_info,
normal_info=normal_info,
)

204
src/bot/base.py Normal file
View File

@@ -0,0 +1,204 @@
"""
浏览器自动化填报 — 公共基类
提供浏览器生命周期管理、登录、导航、截图等公共操作。
"""
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
# ------------------------------------------------------------------
# 基类
# ------------------------------------------------------------------
class BaseBot:
"""浏览器自动化基类 — 管理浏览器生命周期与公共操作"""
def __init__(self, config: dict[str, Any], headless: bool = False) -> None:
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": 1360, "height": 768})
self.page = self.context.new_page()
self.page.set_default_timeout(30000)
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 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, page_key: str = "reimburse_page") -> 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[page_key]
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
self._wait_for('text="单据状态:"', timeout=5000)
def create_new_form(self) -> None:
"""点击「新增」创建新单据"""
log.info("创建新单据...")
self.page.wait_for_timeout(2000)
try:
self.page.click("#insert", 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 _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.parent / "images"
img_dir.mkdir(exist_ok=True)
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))

176
src/bot/normal.py Normal file
View File

@@ -0,0 +1,176 @@
"""
普通报销填报流程
负责普通发票报销的完整填报步骤:
基本信息 → 总明细 → 支付方式 → 附件上传
"""
from typing import Any
from .. import get_logger
from .base import BaseBot, format_date
log = get_logger("bot.normal")
# ------------------------------------------------------------------
# 普通报销流程
# ------------------------------------------------------------------
def run(
bot: BaseBot,
normal_info: dict[str, Any],
) -> None:
"""执行普通发票报销填报流程
Args:
bot: 已启动并登录的 BaseBot 实例。
normal_info: 普通发票信息字典。
"""
log.info("开始普通发票报销填报...")
log.info("填写基本信息...")
description = normal_info.get("basic_info", {}).get("reimbursement_description", "元器件采购报销")
fill_basic_info(bot, description)
total_invoices = normal_info.get("reimbursement_details", {}).get("total_invoices", 0)
total_amount = normal_info.get("reimbursement_details", {}).get("total_amount", 0)
log.info(f"录入普通发票总明细 (共 {total_invoices} 张, 合计 ¥{total_amount:.2f})...")
add_normal_item(bot, total_invoices, total_amount)
payment_info = normal_info.get("payment_methods", [])
log.info(f"录入普通发票支付信息 (共 {len(payment_info)} 笔)...")
fill_normal_payment(bot, payment_info)
log.info("上传普通发票附件...")
attachment_info = normal_info.get("attachments", [])
upload_normal_attachments(bot, attachment_info)
log.info("普通发票报销填报完成")
# ------------------------------------------------------------------
# 基本信息
# ------------------------------------------------------------------
def fill_basic_info(bot: BaseBot, description: str = "元器件采购报销") -> None:
"""填写基本信息"""
bot.page.fill("#EXPENEXPLAIN", description)
bot.page.click("#PROJECTCODE", timeout=10000)
bot.page.wait_for_timeout(1000)
bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr")
if first_row:
first_row.click()
bot.page.wait_for_timeout(1000)
bot.page.click("#saveAndNext", timeout=5000)
bot.page.wait_for_timeout(2000)
bot._screenshot("step3_done")
# ------------------------------------------------------------------
# 总明细
# ------------------------------------------------------------------
def add_normal_item(bot: BaseBot, total_invoices: int, total_amount: float) -> None:
"""录入普通发票总明细"""
try:
bot.page.click("#insertDetail", timeout=5000)
bot._wait_for('text="经济事项名称"', timeout=5000)
bot.page.click("#economicscode2")
bot.page.wait_for_timeout(1000)
try:
bot.page.wait_for_selector("#econmodal .fixed-table-body tbody tr", timeout=10000)
rows = bot.page.query_selector_all("#econmodal .fixed-table-body tbody tr")
if len(rows) >= 3:
rows[2].click()
bot.page.wait_for_timeout(1000)
except Exception:
pass
bot.page.fill('input[name="expenPwCommondetail.HOWBILLS"]', str(total_invoices))
bot.page.fill("#je_zwzcdz", f"{total_amount:.2f}")
bot.page.click("#detailAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
bot._screenshot("normal_item_done")
except Exception as e:
log.error(f"录入总明细失败: {e}")
bot._screenshot("normal_item_error")
raise
# ------------------------------------------------------------------
# 支付方式
# ------------------------------------------------------------------
def fill_normal_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> None:
"""录入普通发票支付信息"""
try:
bot.page.click('text="下一步(支付方式)"', timeout=5000)
bot._wait_for('text="下一步(附件清单)"', timeout=5000)
for info in payment_info:
bot.page.click("#insertPay", timeout=5000)
bot.page.wait_for_timeout(1000)
bot.page.fill("#personid2", bot.config["default_name"])
bot.page.fill("#accountname2", bot.config["default_person_id"])
bot.page.fill("#receiptdate2", format_date(str(info.get("card_date", ""))))
bot.page.fill("#localaccount2", bot.config["default_card_no"])
bot.page.fill("#receiptmoney2", str(info.get("card_amount", 0)))
bot.page.fill("#money2", str(info.get("card_amount", 0)))
bot.page.fill("#merchant2", str(info.get("merchant", "")))
bot.page.fill("#smark2", str(info.get("remark", "")))
bot.page.click("#payAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"支付方式录入失败: {e}")
bot._screenshot("normal_payment_error")
raise
bot._screenshot("normal_payment_done")
# ------------------------------------------------------------------
# 附件上传
# ------------------------------------------------------------------
def upload_normal_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]]) -> None:
"""上传普通发票附件"""
log.info(f"上传普通发票附件 (共 {len(attachment_info)} 个)...")
try:
bot.page.click("#next3", timeout=5000)
bot._wait_for("#submit2", timeout=5000)
for info in attachment_info:
attachment_file = bot.work_dir / info["filename"]
bot._wait_for("#insertAcc", timeout=20000)
bot.page.click("#insertAcc", timeout=5000)
bot._wait_for("#fjlx", timeout=5000)
if info.get("attachment_type") == "invoice":
bot.page.select_option("#fjlx", "1")
else:
bot.page.select_option("#fjlx", "2")
bot.page.fill("#fpsmxx", info.get("attachment_desc", ""))
if attachment_file and attachment_file.exists():
bot.page.set_input_files("#file", str(attachment_file))
bot.page.wait_for_timeout(1000)
bot.page.click("#cjtj", timeout=5000)
log.info("上传普通发票附件完成")
except Exception as e:
log.error(f"附件上传失败: {e}")
bot._screenshot("normal_attachment_error")
raise
bot._screenshot("normal_attachment_done")

295
src/bot/travel.py Normal file
View File

@@ -0,0 +1,295 @@
"""
差旅报销填报流程
负责差旅报销的完整填报步骤:
基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传
"""
from typing import Any
from .. import get_logger
from .base import BaseBot, format_date
log = get_logger("bot.travel")
# ------------------------------------------------------------------
# 差旅填报流程
# ------------------------------------------------------------------
def run(bot: BaseBot, travel_info: dict[str, Any]) -> None:
"""执行差旅报销填报流程
Args:
bot: 已启动并登录的 BaseBot 实例。
travel_info: 差旅信息字典(包含 basic_info、reimbursement_details 等)。
"""
log.info("开始差旅报销填报...")
log.info("填写差旅报销信息...")
basic_info = travel_info["basic_info"]
fill_travel_info(bot, basic_info)
log.info("填写差旅报销明细...")
travel_items = travel_info["reimbursement_details"]
add_travel_items(bot, travel_items)
log.info("填写差旅报销支付方式...")
payment_info = travel_info["payment_methods"]
fill_travel_payment(bot, payment_info)
log.info("填写差旅报销补助清单...")
subsidy_info = travel_info["subsidy_list"]
fill_travel_subsidy(bot, subsidy_info)
log.info("上传差旅报销附件...")
attachment_info = travel_info["attachments"]
upload_travel_attachments(bot, attachment_info)
log.info("差旅报销填报完成")
# ------------------------------------------------------------------
# 基本信息
# ------------------------------------------------------------------
def fill_travel_info(bot: BaseBot, basic_info: dict[str, Any]) -> None:
"""填写差旅报销基本信息"""
try:
bot.page.fill("#CAUSE", basic_info.get("travel_purpose", ""))
bot.page.fill("#SITE", basic_info.get("travel_location", ""))
bot.page.click("#PROJECTCODE", timeout=10000)
bot.page.wait_for_timeout(1000)
bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr")
if first_row:
first_row.click()
bot.page.wait_for_timeout(1000)
bot.page.fill("#THEKSRQ", format_date(basic_info.get("start_date", "")))
bot.page.fill("#THEJSRQ", format_date(basic_info.get("end_date", "")))
bot.page.click("#saveAndNext", timeout=5000)
bot.page.wait_for_timeout(2000)
bot._screenshot("travel_basic_done")
except Exception:
log.error("填写基本信息失败")
bot._screenshot("travel_basic_error")
# ------------------------------------------------------------------
# 差旅明细
# ------------------------------------------------------------------
def add_travel_items(bot: BaseBot, 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:
bot.page.click("#insertDetail", timeout=5000)
bot._wait_for('text="增加明细"', timeout=5000)
bot.page.select_option("#cost", "1")
bot.page.wait_for_timeout(500)
vehicle = item.get("vehicle_type", "")
if vehicle in vehicle_map:
bot.page.select_option("#jtgj", vehicle_map[vehicle])
bot.page.fill("#ksdd", item.get("departure_place", ""))
bot.page.fill("#jsdd", item.get("arrival_place", ""))
bot.page.fill(
'#t1 input[name="expenPwTraveldetail.MONEY"]',
str(item.get("amount", "")),
)
bot.page.fill(
'#t1 input[name="expenPwTraveldetail.HOWBILL"]',
str(item.get("bill_count", "")),
)
bot.page.fill(
'#t1 input[name="expenPwTraveldetail.SMARK"]',
str(item.get("remark", "")),
)
bot.page.click("#detailAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
hotel_info = travel_items.get("hotel_fee") or []
for item in hotel_info:
bot.page.click("#insertDetail", timeout=5000)
bot._wait_for('text="增加明细"', timeout=5000)
bot.page.select_option("#cost", "2")
bot.page.wait_for_timeout(500)
bot.page.fill("#ksrq2", format_date(str(item.get("checkin_date", ""))))
bot.page.fill("#jsrq2", format_date(str(item.get("checkout_date", ""))))
bot.page.fill("#ts2", str(item.get("days", "")))
bot.page.fill("#rs2", str(item.get("person_count", "")))
bot.page.fill(
'#t2 input[name="expenPwTraveldetail.FPMONEY"]',
str(item.get("invoice_amount", "")),
)
bot.page.fill(
'#t2 input[name="expenPwTraveldetail.MONEY"]',
str(item.get("reimburse_amount", "")),
)
bot.page.fill(
'#t2 input[name="expenPwTraveldetail.SMARK"]',
str(item.get("remark", "")),
)
bot.page.click("#detailAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
conference_info = travel_items.get("conference_fee") or []
for item in conference_info:
bot.page.click("#insertDetail", timeout=5000)
bot._wait_for('text="增加明细"', timeout=5000)
bot.page.select_option("#cost", "3")
bot.page.wait_for_timeout(500)
bot.page.fill(
'#t3 input[name="expenPwTraveldetail.HOWBILL"]',
str(item.get("bill_count", "")),
)
bot.page.fill(
'#t3 input[name="expenPwTraveldetail.MONEY"]',
str(item.get("amount", "")),
)
bot.page.fill(
'#t3 input[name="expenPwTraveldetail.SMARK"]',
str(item.get("remark", "")),
)
bot.page.click("#detailAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"录入总明细失败: {e}")
bot._screenshot("item_total_error")
raise
# ------------------------------------------------------------------
# 支付方式
# ------------------------------------------------------------------
def fill_travel_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> None:
"""录入差旅支付信息"""
try:
bot.page.click('text="下一步(支付方式)"', timeout=5000)
bot._wait_for('text="下一步(补助清单)"', timeout=5000)
for info in payment_info:
bot.page.click("#insertPay", timeout=5000)
bot.page.wait_for_timeout(1000)
bot.page.fill("#personid2", bot.config["default_name"])
bot.page.fill("#accountname2", bot.config["default_person_id"])
bot.page.fill("#receiptdate2", format_date(info["card_date"]))
bot.page.fill("#localaccount2", bot.config["default_card_no"])
bot.page.fill("#receiptmoney2", str(info["card_amount"]))
bot.page.fill("#money2", str(info["card_amount"]))
bot.page.fill("#merchant2", info.get("merchant", ""))
bot.page.fill("#smark2", info.get("remark", ""))
bot.page.click("#payAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"支付方式录入失败: {e}")
bot._screenshot("step5_error")
raise
bot._screenshot("step5_done")
# ------------------------------------------------------------------
# 补助清单
# ------------------------------------------------------------------
def fill_travel_subsidy(bot: BaseBot, subsidy_info: list[dict[str, Any]]) -> None:
"""录入差旅补助清单"""
try:
bot.page.click("#next3", timeout=5000)
bot._wait_for("#next4", timeout=5000)
for info in subsidy_info:
bot.page.click("#insertSubsidy", timeout=5000)
bot._wait_for('text="增加补助清单"', timeout=5000)
bot.page.click("#jzg3", timeout=5000)
bot.page.wait_for_timeout(500)
if info["person_name"] and info["person_name"] != "":
bot.page.fill("#seacher", info["person_name"])
elif info["person_id"] and info["person_id"] != "":
bot.page.fill("#seacher", info["person_id"])
else:
raise ValueError(f"人员编号和人员姓名不能同时为空: {info}")
bot.page.click("#cx", timeout=5000)
bot.page.wait_for_selector("div.fixed-table-loading", state="hidden", timeout=10000)
bot.page.click("#tableEmp tbody tr", timeout=10000)
bot.page.wait_for_timeout(1000)
open_bank = bot.page.input_value("#openbank1")
if not open_bank:
log.info("员工开户行未填写,默认填写中国工商银行")
bot.page.fill("#openbank1", "中国工商银行")
bot.page.fill("#startdate1", format_date(info["start_date"]))
bot.page.fill("#enddate1", format_date(info["end_date"]))
bot.page.fill("#trafficdays1", str(info["days"]))
bot.page.fill("#fooddays1", str(info["days"]))
bot.page.fill("#trafficnorm1", str(80))
bot.page.fill("#foodnorm1", str(100))
trafficmoney = int(info["days"]) * 80
foodmoney = int(info["days"]) * 100
subsidymoney = trafficmoney + foodmoney
bot.page.fill("#trafficmoney1", str(trafficmoney))
bot.page.fill("#foodmoney1", str(foodmoney))
bot.page.fill("#subsidymoney1", str(subsidymoney))
bot.page.click("#add", timeout=3000)
bot.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"差旅补助清单录入失败: {e}")
bot._screenshot("subsidy_error")
raise
bot._screenshot("subsidy_done")
# ------------------------------------------------------------------
# 附件上传
# ------------------------------------------------------------------
def upload_travel_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]]) -> None:
"""上传差旅附件"""
try:
bot.page.click("#next4", timeout=5000)
bot._wait_for("#submit2", timeout=5000)
for info in attachment_info:
attachment_file = bot.work_dir / info["filename"]
bot._wait_for("#insertAcc", timeout=20000)
bot.page.click("#insertAcc", timeout=5000)
bot._wait_for("#fjlx", timeout=5000)
if info["attachment_type"] == "invoice":
bot.page.select_option("#fjlx", "1")
else:
bot.page.select_option("#fjlx", "2")
bot.page.fill("#fpsmxx", info["attachment_desc"])
if attachment_file and attachment_file.exists():
bot.page.set_input_files("#file", str(attachment_file))
bot.page.wait_for_timeout(1000)
bot.page.click("#cjtj", timeout=5000)
log.info("上传差旅附件完成")
except Exception as e:
log.error(f"差旅附件上传失败: {e}")
bot._screenshot("travel_attachment_error")
raise
bot._screenshot("travel_attachment_done")

View File

@@ -1,10 +1,10 @@
---
last_reviewed: 2026-06-09
last_reviewed: 2026-06-11
---
# src/doc — 文档处理模块
负责发票信息提取、基于 LLM 的支付截图信息识别、以及将数据填入 Word 出库单模板。
负责发票信息提取、基于 LLM 的支付截图信息识别、差旅/普通报销信息提取、以及将数据填入 Word 出库单模板。
## 模块清单
@@ -13,12 +13,12 @@ last_reviewed: 2026-06-09
| ------------------------ | -------------------------------------------------- |
| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 |
| `pdf.py` | PDF 图片渲染PyMuPDF |
| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态 |
| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态 + 差旅/普通报销信息综合提取) |
| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 |
| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 |
| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板pywin32 COM |
| `prompt.py` | LLM 提示词模板加载 |
| `prompts/` | 提示词模板文件(`invoice_system.md``travel_info_system.md` |
| `prompts/` | 提示词模板文件(`invoice_system.md``travel_info_system.md``normal_info_system.md` |
## 数据流
@@ -32,6 +32,11 @@ PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
invoice.py 分类 → CSV已回填刷卡日期/卡号/金额)
fill_consumable_doc → 易耗品出库单.doc
[发票列表 + 匹配结果] → llm_extractor.py
差旅发票 → extract_travel_info() → travel_info.json
普通发票 → extract_normal_info() → normal_info.json
```
## 依赖说明
@@ -46,3 +51,10 @@ PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
- LLM 提取不会覆盖 CSV 中已有非空字段
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
- LLM 提取失败时直接报错,无正则回退
## 变更说明2026-06-11
- `llm_extractor.py` 新增 `extract_normal_info()`:综合普通发票、支付记录和匹配结果,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json`
- `llm_extractor.py``load_cache()` 扩展支持加载 `normal_info.json`
- `prompt.py` 新增 `build_normal_info_system_prompt()`:加载 `normal_info_system.md`
- `prompts/` 新增 `normal_info_system.md`:普通发票信息提取的系统提示词

View File

@@ -51,16 +51,17 @@ def _get_cache_dir(source_dir: Path) -> Path:
def _get_json_path(file_path: Path, cache_dir: Path) -> Path:
"""根据文件路径生成对应的 JSON 缓存路径"""
return cache_dir / f"{file_path.stem}.json"
"""根据文件路径生成对应的 JSON 缓存路径(包含后缀名以区分同名的 PDF/图片)"""
return cache_dir / f"{file_path.stem}{file_path.suffix}.json"
def _save_to_cache(file_path: Path, extracted_data: dict[str, Any], cache_dir: Path) -> Path:
"""将提取结果保存到 JSON 缓存文件,并记录源文件路径"""
"""将提取结果保存到 JSON 缓存文件,并记录源文件路径和后缀名"""
json_path = _get_json_path(file_path, cache_dir)
cache_data = {
"source_file": str(file_path),
"source_filename": file_path.name,
"source_extension": file_path.suffix.lower(),
"extracted_data": extracted_data,
}
with open(json_path, "w", encoding="utf-8") as f:
@@ -69,13 +70,16 @@ def _save_to_cache(file_path: Path, extracted_data: dict[str, Any], cache_dir: P
return json_path
def _load_from_cache(json_path: Path) -> dict[str, Any] | None:
"""从 JSON 缓存文件加载提取结果"""
def _load_from_cache(json_path: Path, expected_extension: str | None = None) -> 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)
# 校验后缀名是否一致,防止同名不同后缀的文件误命中缓存
if expected_extension and cache_data.get("source_extension", "").lower() != expected_extension.lower():
return None
result: dict[str, Any] | None = cache_data.get("extracted_data")
return result
except Exception as e:
@@ -100,7 +104,7 @@ def _extract_document(file_path: Path, cache_dir: Path) -> dict[str, str] | None
提取结果字典,失败时返回 None。
"""
json_path = _get_json_path(file_path, cache_dir)
cached = _load_from_cache(json_path)
cached = _load_from_cache(json_path, expected_extension=file_path.suffix.lower())
if cached:
cached["_source_file"] = file_path.name
log.info(f"使用缓存: {file_path.name}")

View File

@@ -26,6 +26,7 @@ from typing import Any, cast
from .. import get_logger
from .prompt import (
build_invoice_system_prompt,
build_normal_info_system_prompt,
build_travel_info_system_prompt,
)
@@ -218,9 +219,9 @@ def load_cache(source_dir: Path) -> dict[str, Any]:
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
# travel_info.json / normal_info.json 结构不同,直接存储
if json_path.name in ("travel_info.json", "normal_info.json"):
cache_map[json_path.name.replace(".json", "")] = cache_data
continue
extracted = cache_data.get("extracted_data", {})
@@ -324,3 +325,71 @@ def extract_travel_info(
except Exception as e:
log.error("LLM 差旅信息提取失败: %s", e)
raise
# ------------------------------------------------------------------
# 普通发票信息提取
# ------------------------------------------------------------------
def extract_normal_info(
source_dir: Path | None = None,
) -> dict[str, Any]:
"""根据普通发票(非差旅),让 LLM 提取报销相关信息。
仅支持从 JSON 缓存加载数据。
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
Returns:
包含报销说明、发票总数、总金额、支付方式、附件清单等字段的字典。
"""
if not source_dir:
log.warning("未提供 source_dir无法加载缓存数据")
return {}
system_prompt = build_normal_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

@@ -18,12 +18,13 @@
2. 解析发票和刷卡记录的金额,进行总额校验
- 发票总额 < 刷卡总额时发出 warning
3. 按金额降序排序
4. 根据数量关系选择匹配策略:
4. 文件名匹配(最高优先级):发票和刷卡记录的文件名(不含后缀)一致时直接匹配
5. 根据数量关系选择匹配策略:
- 数量相等 → 一对一匹配:按金额从大到小依次配对,相对容差内即匹配
- 发票更多 → 一对多匹配:对每张刷卡记录贪心凑金额,相对容差内结束
5. 构建以支付记录为主键的结果列表
6. 未匹配的发票单独作为一条记录(无刷卡信息)
7. 清理内部字段,输出支付记录列表
6. 构建以支付记录为主键的结果列表
7. 未匹配的发票单独作为一条记录(无刷卡信息)
8. 清理内部字段,输出支付记录列表
## 容差计算
@@ -45,6 +46,7 @@
match_invoices_to_cards(invoices, cards, tolerance) -> list[dict]
"""
from pathlib import Path
from typing import Any
from .. import get_logger
@@ -170,10 +172,18 @@ def _match(
"""执行匹配,返回 {card_index: [invoice_indices]} 的映射
tolerance 为相对容差比例(如 0.03 表示 3%
匹配优先级(从高到低):
1. 文件名匹配:发票和刷卡记录的文件名(不含后缀)一致时直接匹配
2. 精确匹配:金额差 <= 0.01 元
3. 一对一 / 一对多贪心匹配:按金额容差匹配
"""
result: dict[int, list[int]] = {}
assigned: set[int] = set()
# ---- 阶段 0文件名匹配最高优先级----
_match_by_filename(invoices, cards, assigned, result)
if len(invoices) == len(cards):
_match_one_to_one(invoices, cards, tolerance, assigned, result)
else:
@@ -182,6 +192,34 @@ def _match(
return result
def _match_by_filename(
invoices: list[dict[str, Any]],
cards: list[dict[str, Any]],
assigned: set[int],
result: dict[int, list[int]],
) -> None:
"""文件名匹配:发票和刷卡记录的文件名(不含后缀)一致时直接匹配"""
for card_idx, card in enumerate(cards):
card_name = card.get("_source_file", "")
if not card_name:
continue
card_stem = Path(card_name).stem
for idx, inv in enumerate(invoices):
if idx in assigned:
continue
inv_name = inv.get("_source_file", "")
if not inv_name:
continue
inv_stem = Path(inv_name).stem
if inv_stem == card_stem:
assigned.add(idx)
result[card_idx] = [idx]
log.info(f"[文件名匹配] {inv_name}{card_name}")
break
def _match_one_to_one(
invoices: list[dict[str, Any]],
cards: list[dict[str, Any]],

View File

@@ -24,3 +24,8 @@ def build_invoice_system_prompt() -> str:
def build_travel_info_system_prompt() -> str:
"""构建差旅信息提取系统提示词。"""
return _load_prompt("travel_info_system.md")
def build_normal_info_system_prompt() -> str:
"""构建普通发票信息提取系统提示词。"""
return _load_prompt("normal_info_system.md")

View File

@@ -1,4 +1,4 @@
你是财务文档信息提取助手。你的任务是从图片中提取结构化信息,可能是支付截图、银行转账记录、微信/支付宝付款凭证等,也可能是发票文件,也可能是出差事前申请单,不管任何形式都要用统一的 JSON 格式返回信息。
你是财务文档信息提取助手。你的任务是从图片中提取结构化信息,可能是支付截图、银行转账记录、微信/支付宝付款凭证等,也可能是发票文件,也可能是出差事前申请单,也可能是易耗品、出库单,不管任何形式都要用统一的 JSON 格式返回信息。
第一步要先判断是,支付记录、高铁票、酒店住宿,普通发票,然后不同类型输出的信息不同。
@@ -36,6 +36,11 @@
"invoice_date": "2026-06-03",
"total_amount": "536.00"//必填项
}
```json
{
"invoice_type": "note",//必填项且只有这一项
}
```
## 重要规则
- **必填项**invoice_type、invoice_date、ride_date、departure、arrival、person_name、total_amount 字段为必填,必须填写。
@@ -85,4 +90,7 @@
6. total_amount: 金额数字
7. seller_name: 卖方全称
如果是易耗品出入库单,返回如下字段:
1. invoice_type: "note"
**千万注意!千万注意!**:严格只输出 JSON不要输出任何其他文字、Markdown 标记或解释。

View File

@@ -0,0 +1,78 @@
# 普通报销信息提取系统提示词
你是财务报销信息提取助手。根据发票信息和付款记录,提取报销相关的结构化数据,并以严格符合类型要求的 JSON 格式返回。
> **核心原则**:类型约束为最高优先级规则,任何情况下不得违反。
## 强制类型约束
以下类型规则为最高优先级,任何情况下不得违反。
### 1. 根节点字段(共 4 个,类型不可变更)
| 字段名 | 强制类型 | 空值处理 |
| --- | --- | --- |
| `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 |
| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含下述 2 个子字段 |
| `payment_methods` | 数组 (list) | 必填,无数据时赋值为 `[]` |
| `attachments` | 数组 (list) | 必填,无数据时赋值为 `[]` |
### 2. `reimbursement_details` 子字段(共 2 个)
| 字段名 | 强制类型 | 空值处理 |
| --- | --- | --- |
| `total_invoices` | 数字 (int) | 必填 |
| `total_amount` | 数字 (float) | 必填 |
### 3. 禁止行为
* 省略任何根节点字段或 `reimbursement_details` 的子字段
*`payment_methods``attachments` 赋值为 `null`、字符串、数字或对象
*`reimbursement_details` 中添加未定义的子字段
* 合并不同模块的数组数据
**正确示例**
```json
{
"basic_info": {...},
"reimbursement_details": {...},
"payment_methods": [],
"attachments": [{"filename":"发票.pdf", ...}]
}
```
**错误示例**
```json
{
"basic_info": {...},
"reimbursement_details": {...},
"payment_methods": null,
}
```
## 输入数据说明
你会收到以下数据:
1. **发票信息**:包含购买物品的发票信息
2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息
需要提取的信息:
1. `basic_info`:(必填,每一项都必须填,给出合理的猜测)
1. `reimbursement_description`根据所有信息写一句20字以内的报销说明
2. `reimbursement_details`:(至少有一项)
1. `total_invoices`: 发票的总份数
2. `total_amount`:填写付款记录总金额
3. `payment_methods`:(多少笔支付记录就有多少条;多项请采用上述通用 JSON 数组格式)
1. `card_date`:根据付款记录,格式 YYYY-M-D
2. `card_amount`:根据付款记录填写,单位为元,数字
3. `merchant`:根据发票信息推测商户信息
4. `remark`:说明该笔付款关联的发票信息
4. `attachments`:(必填,用户已经告诉你所有文件了`【源文件: {filename}】`"invoice_type": "payment"的不作为附件)
1. `filename`: 严格使用用户提供的原始文件名,不得修改任何字符
2. `attachment_type`从以下两个选项中选择invoice、other
3. `attachment_desc`:简要描述该文件的基本信息
## 最终输出要求
* 仅输出纯 JSON 字符串,不包含任何思考过程、解释文字或 Markdown 标记
* JSON 语法必须正确,无多余逗号、引号等错误
* 严格遵守所有强制性类型约束,违反类型要求的输出视为无效

View File

@@ -3,7 +3,7 @@
你是财务差旅信息提取助手。你的任务是根据发票信息、付款记录,提取出差相关的结构化信息,并以严格符合以下类型要求的 JSON 格式返回。所有类型约束为最高优先级规则,任何情况下不得违反。
🔴 最高优先级:强制性类型约束(优先级高于所有其他规则)
1. 根节点必须包含且仅包含以下 6 个字段,字段类型绝对不可变更:
1. 根节点必须包含且仅包含以下 5 个字段,字段类型绝对不可变更:
| 字段名 | 强制类型 | 空值处理规则 |
| ------ | --------- | ------------------ |

View File

@@ -1,16 +1,22 @@
"""
报销全流程编排
将发票提取 → 浏览器填报串联为一条管道,
将发票提取 → 差旅/普通信息提取 → 浏览器填报串联为一条管道,
数据在内存中流转,同时生成 CSV 中间产物。
发票类型区分:
- 差旅发票train/hotel不生成易耗品出库单走差旅报销流程
- 普通发票general生成易耗品出库单走普通报销流程
数据流变更2026-06-11
在发票提取和匹配完成后立即判断报销类型(差旅/普通),
差旅调用 LLM 提取 travel_info.json普通预留 normal_info.json。
Bot 仅负责接收信息并填报,不再承担信息提取职责。
"""
import json
from pathlib import Path
from typing import Any
from typing import Any, cast
from . import get_logger
from .config import load_config
@@ -22,6 +28,12 @@ from .doc.invoice import (
from .doc.invoice import (
save_csv as save_payment_csv,
)
from .doc.llm_extractor import (
CACHE_DIR_NAME,
extract_normal_info,
extract_travel_info,
load_cache,
)
def _classify_invoice_batch(
@@ -54,6 +66,64 @@ def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
return _classify_invoice_batch(invoices)
def _extract_travel_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None:
"""当存在差旅发票时,调用 LLM 提取差旅信息并缓存。
Returns:
差旅信息字典,非差旅时返回 None。
"""
if not groups.get("travel"):
return None
from .doc.llm_extractor import CACHE_DIR_NAME, load_cache
# 检查缓存是否已有
cache_map = load_cache(cache_path)
if cache_map.get("travel_info"):
log.info("使用已有差旅信息缓存")
return cast(dict[str, Any] | None, cache_map["travel_info"])
log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=cache_path)
# 保存到缓存
cache_dir = cache_path / 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("差旅信息已保存到缓存")
return travel_info
def _extract_normal_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None:
"""当存在普通发票时,调用 LLM 提取普通报销信息并缓存。
Returns:
普通报销信息字典,非普通时返回 None。
"""
if not groups.get("general"):
return None
# 检查缓存是否已有
cache_map = load_cache(cache_path)
if cache_map.get("normal_info"):
log.info("使用已有普通发票信息缓存")
return cast(dict[str, Any] | None, cache_map["normal_info"])
log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=cache_path)
# 保存到缓存
cache_dir = cache_path / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
json.dump(normal_info, f, ensure_ascii=False, indent=2)
log.info("普通发票信息已保存到缓存")
return normal_info
def run_pipeline(
step: str = "all",
username: str | None = None,
@@ -78,10 +148,12 @@ def run_pipeline(
cache_path = Path(cache_dir) if cache_dir else project_dir / "scripts" / "data"
# --------------------------------------------------
# Step 1: 发票提取
# Step 1: 发票提取 + 类型判断 + 信息提取
# --------------------------------------------------
payment_records: list[dict[str, str]] | None = None
groups: dict[str, list[dict[str, str]]] | None = None
travel_info: dict[str, Any] | None = None
normal_info: dict[str, Any] | None = None
if step in ("all", "invoice"):
log.info("=" * 60)
@@ -101,6 +173,13 @@ def run_pipeline(
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])}")
# 发票提取完成后立即判断类型
is_travel = bool(groups["travel"]) and not bool(groups["general"])
if is_travel:
travel_info = _extract_travel_info_if_needed(groups, cache_path)
else:
normal_info = _extract_normal_info_if_needed(groups, cache_path)
if step == "invoice":
log.info("[1/2] 发票提取 完成")
return 0
@@ -118,12 +197,17 @@ def run_pipeline(
if groups is None:
groups = _classify_from_cache(cache_path)
if groups["travel"] and not groups["general"]:
is_travel = bool(groups["travel"]) and not bool(groups["general"])
if is_travel:
if travel_info is None:
travel_info = _extract_travel_info_if_needed(groups, cache_path)
log.info("检测到纯差旅发票,使用差旅报销模式")
run_bot(config, work_dir=cache_path)
run_bot(config, work_dir=cache_path, travel_info=travel_info)
else:
if normal_info is None:
normal_info = _extract_normal_info_if_needed(groups, cache_path)
log.info("检测到普通发票,使用普通报销模式")
run_bot(config, work_dir=cache_path)
run_bot(config, work_dir=cache_path, normal_info=normal_info)
if step == "submit":
log.info("[2/2] 报销提交 完成")

View File

@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-09
last_reviewed: 2026-06-11
---
# src/web 模块设计说明
@@ -29,18 +29,32 @@ src/web/
## 数据流
```
用户上传文件 → 创建 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 自动填报财务系统
```mermaid
graph TD
A[用户上传文件] --> B[创建 session]
B --> C[文件写入 uploads/<sid>/]
C --> D[后台线程执行管道]
D --> E["extract_invoices()"]
E --> F["enrich_with_llm()"]
F --> G["save_csv()"]
G --> H["结果写入 session 目录"]
H --> I["invoice_summary.csv"]
H --> J["invoice_groups.json"]
H --> K["result.json"]
H --> L["session.log"]
E --> M{"判断报销类型"}
M -->|差旅| N["extract_travel_info()"]
M -->|普通| O["extract_normal_info()"]
N --> P["travel_info.json"]
O --> Q["normal_info.json"]
K --> R["前端 SSE 轮询 → 显示完成状态"]
R --> S["前端加载 CSV → 可编辑表格"]
S --> T["用户修改后保存"]
T --> U["用户点击提交"]
U --> V["run_financial_submit()"]
V --> W["bot 自动填报财务系统"]
W --> X["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
W --> Y["普通模式: normal_info.json → 基本信息 → 录入明细 → 支付信息 → 上传附件"]
```
## API 路由
@@ -70,7 +84,16 @@ src/web/
- **差旅发票**(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- **普通发票**生成易耗品出库单Word 文档),走普通报销流程
`classify_invoice_batch()` 根据发票内容自动分类,`_try_fill_consumable_doc()` 仅对普通发票生成出库单
`extract_invoices()` 在提取阶段完成分类,结果保存为 `invoice_groups.json`(包含 `travel_count``general_count``application_count`)。后续步骤(出库单生成、财务填报)统一从该文件读取分类结果,避免重复解析 CSV 和 JSON 字段
### LLM 信息提取2026-06-11
发票提取和匹配完成后,根据类型分流调用 LLM 提取结构化报销信息:
- **差旅发票**:调用 `extract_travel_info()`,提取出差事由、地点、时间、交通/住宿明细、补助清单、支付方式、附件清单,缓存为 `travel_info.json`
- **普通发票**:调用 `extract_normal_info()`,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json`
Bot 填报时优先使用 LLM 提取的信息(`travel_info`/`normal_info`),降级时从缓存加载原始发票数据。
### 移动端同步

View File

@@ -19,7 +19,7 @@ import threading
import time
import uuid
from pathlib import Path
from typing import Any
from typing import Any, cast
from urllib.parse import quote
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
@@ -46,24 +46,6 @@ from src.doc.invoice import ( # noqa: E402, I001
)
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
@@ -72,11 +54,32 @@ app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
SESSION_LOG_FILE = "session.log"
SESSION_RESULT_FILE = "result.json"
INVOICE_GROUPS_FILE = "invoice_groups.json"
# ================================================================
# 日志收集器 — 捕获管道日志到文件SSE 端点通过 tail -f 读取
# ================================================================
def _save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
"""保存发票分类结果到 session 目录的 JSON 文件"""
groups_path = session_dir / INVOICE_GROUPS_FILE
# 只保存各组的数量统计,避免重复存储完整发票数据
data = {
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
}
with open(groups_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _load_invoice_groups(session_dir: Path) -> dict[str, int] | None:
"""从 session 目录加载发票分类统计"""
groups_path = session_dir / INVOICE_GROUPS_FILE
if not groups_path.exists():
return None
try:
with open(groups_path, encoding="utf-8") as f:
return cast(dict[str, int] | None, json.load(f))
except Exception:
return None
class _SSELogHandler(logging.Handler):
@@ -164,69 +167,33 @@ def _resolve_invoice_csv(session_dir: Path) -> Path | None:
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 填写易耗品出库单,供会话目录下载。
仅当存在普通发票时才生成出库单。纯差旅发票跳过。
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
"""
if not CONSUMABLE_TEMPLATE.exists():
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
# 从统一的分类结果读取,避免重复解析 CSV/JSON
groups = _load_invoice_groups(session_dir)
if groups is None:
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
if not groups.get("general_count", 0):
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
csv_path = _resolve_payment_csv(session_dir)
if csv_path is None:
return {"ok": False, "error": "未找到发票 CSV"}
# 检查是否有普通发票
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)
@@ -265,6 +232,10 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
在发票提取和匹配完成后立即判断报销类型:
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
- 普通发票无需额外提取normal_info.json 待实现)
"""
start = time.time()
@@ -281,6 +252,38 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
# 保存分类结果(供后续步骤统一读取)
_save_invoice_groups(session_dir, groups)
# ---- Step 2: 差旅/普通信息提取 ----
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
if is_travel:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache
# 检查缓存是否已有
cache_map = load_cache(session_dir)
if not cache_map.get("travel_info"):
fill_log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
json.dump(travel_info, f, ensure_ascii=False, indent=2)
fill_log.info("差旅信息已保存到缓存")
else:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache
# 检查缓存是否已有
cache_map = load_cache(session_dir)
if not cache_map.get("normal_info"):
fill_log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
json.dump(normal_info, f, ensure_ascii=False, indent=2)
fill_log.info("普通发票信息已保存到缓存")
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
@@ -302,7 +305,7 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
根据发票类型选择填报模式:
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
- 纯差旅发票差旅报销模式TODO
- 含普通发票:普通报销模式
"""
@@ -312,14 +315,10 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str,
from src.bot import run_bot_web
# TODO: 改为从缓存读取或直接请求 LLM与差旅报销保持一致
# 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"]:
# 从统一的分类结果读取,避免重复解析
groups = _load_invoice_groups(session_dir)
if groups:
if groups.get("travel_count", 0) and not groups.get("general_count", 0):
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
# TODO: 差旅报销填报流程
else: