Compare commits
5 Commits
87c7b2d5be
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd91df3b9 | ||
|
|
6d66a27aab | ||
|
|
10115214aa | ||
|
|
cf567c22f2 | ||
|
|
98e3d21c83 |
1
.gitignore
vendored
@@ -10,4 +10,5 @@ logs/
|
||||
.vscode/
|
||||
uploads/
|
||||
.env
|
||||
images/
|
||||
scripts/data/
|
||||
63
README.md
@@ -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+**
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -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/`)
|
||||
|
||||
@@ -22,15 +22,26 @@ def get_logger(name: str) -> logging.Logger:
|
||||
|
||||
输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ...
|
||||
日志同时输出到终端和 logs/<日期>.log
|
||||
|
||||
只在最顶层的 logger 上添加标准 handler(stream + file),
|
||||
子 logger 通过 propagate 将日志传递给父 logger 统一处理。
|
||||
这样 _SSELogHandler 只需挂在父 logger 上即可捕获所有子日志。
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 使用专属标记判断是否已初始化标准 handler(stream + 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)
|
||||
|
||||
649
src/bot.py
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
@@ -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`:普通发票信息提取的系统提示词
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 标记或解释。
|
||||
78
src/doc/prompts/normal_info_system.md
Normal 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 语法必须正确,无多余逗号、引号等错误
|
||||
* 严格遵守所有强制性类型约束,违反类型要求的输出视为无效
|
||||
@@ -3,7 +3,7 @@
|
||||
你是财务差旅信息提取助手。你的任务是根据发票信息、付款记录,提取出差相关的结构化信息,并以严格符合以下类型要求的 JSON 格式返回。所有类型约束为最高优先级规则,任何情况下不得违反。
|
||||
|
||||
🔴 最高优先级:强制性类型约束(优先级高于所有其他规则)
|
||||
1. 根节点必须包含且仅包含以下 6 个字段,字段类型绝对不可变更:
|
||||
1. 根节点必须包含且仅包含以下 5 个字段,字段类型绝对不可变更:
|
||||
|
||||
| 字段名 | 强制类型 | 空值处理规则 |
|
||||
| ------ | --------- | ------------------ |
|
||||
|
||||
@@ -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] 报销提交 完成")
|
||||
|
||||
@@ -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`),降级时从缓存加载原始发票数据。
|
||||
|
||||
### 移动端同步
|
||||
|
||||
|
||||
159
src/web/app.py
@@ -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:
|
||||
|
||||