完成差旅发票录入流程

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

19
.agents/README.md Normal file
View File

@@ -0,0 +1,19 @@
---
last_reviewed: 2026-06-11
---
# .agents — 项目内部维护目录
本目录存放维护人员与自动化代理相关资料,不属于对外公开的用户文档。
## 目录结构
| 子目录 | 说明 |
|--------|------|
| `docs/` | 维护文档:规范、经验总结、实施方案、操作指南 |
| `skills/` | Cursor Agent 技能:定义自动化工作流和代码检查流程 |
## 文档边界
- 面向开源用户、外部贡献者的公开文档统一放置在项目根目录 `docs/` 下。
- 维护规范、实施方案、经验总结、拉取请求佐证材料与各类内部记录资料,均统一放置在本目录下。

View File

@@ -0,0 +1,36 @@
---
last_reviewed: 2026-06-11
---
# 出现问题好的排查流程
## 出现问题后的排查流程
```
日志报错
├─ 1. 定位错误源码
│ 根据日志标签 + 错误信息 → 找到报错函数和校验条件
├─ 2. 分析日志时间线
│ 对比错误时间与前后事件 → 判断是主流程还是试探性调用
├─ 3. 编写诊断脚本(隔离测试)
│ │
│ ├─ 正常 save/load 循环 → 确认基础路径是否健康
│ ├─ 编码异常检测 → BOM、GBK 混入等
│ ├─ 数据流变更检测 → 前端编辑/外部写入后列结构变化
│ └─ 回退链检测 → glob 扫描到非预期文件
├─ 4. 最小化复现
│ 针对失败的测试用例,提取最简输入证明根因
└─ 5. 修复 + 验证
最小改动修复 → 确认不影响正常输入
```
## 关键判断点
- 错误不影响主流程 → 优先排查试探性调用和回退链
- 列名校验失败但文件肉眼正常 → 优先检查 BOM 和不可见字符
- 错误出现在外部数据入口 → 优先检查编码兼容性和数据清洗

17
.agents/skills/README.md Normal file
View File

@@ -0,0 +1,17 @@
---
last_reviewed: 2026-06-11
---
# .agents/skills — Cursor Agent 技能目录
存放 Cursor Agent 可调用的自动化技能定义。
## 技能清单
| 技能 | 说明 |
|------|------|
| `pre-commit-check/` | 提交前代码质量检查:运行 ruff lint/format、mypy 类型检查、deptry 依赖审计,自动修复可修复问题 |
## 使用方式
Agent 在用户请求提交代码或检查代码质量时自动触发对应技能,无需手动调用。

View File

@@ -0,0 +1,68 @@
---
name: pre-commit-check
description: >-
Run pre-commit code quality checks (ruff lint/format, mypy type check, deptry dependency audit)
and fix any issues before committing. Use when the user wants to commit code, asks to check code
quality, or mentions pre-commit validation. Also use when preparing code for submission or
when the user says "check before commit" or "run checks".
---
# Pre-Commit Code Quality Check
## Workflow
Run all checks in parallel first, then fix any issues iteratively:
```
Step 1: Run checks (parallel)
- uv run ruff check .
- uv run ruff format --check .
- uv run mypy src/
- uv run deptry .
Step 2: If any check fails, fix issues
- ruff check --fix . (auto-fix lint issues)
- ruff format . (auto-format)
- Fix type annotation errors manually
Step 3: Re-run all checks to confirm
Step 4: Report results with table
```
## Common Fixes
| Error | Fix |
|-------|-----|
| `UP038` | Replace `isinstance(e, (X, Y))` with `isinstance(e, X \| Y)` |
| `no-untyped-def` | Add return type annotation (`-> None` for void functions) |
| `I001` | Run `uv run ruff check --fix .` |
| `no-any-return` | Use `cast(Type, expression)` from `typing` |
| `type-arg` missing | Add type arguments: `dict[str, Any]` instead of `dict` |
| `unused-ignore` | Remove stale `# type: ignore` comments |
## Type Annotation Rules
- Functions that modify data in-place and return nothing: `-> None`
- Functions that call untyped external APIs: add `-> Any` return type
- Dicts that hold mixed types (str + float): use `dict[str, Any]`
- Import `Any` from `typing` when needed
- Import `cast` from `typing` when `no-any-return` triggers
## Verification Report
After all checks pass, report:
| 检查项 | 状态 |
|--------|------|
| `ruff check .` | ✅ |
| `ruff format --check .` | ✅ |
| `mypy src/` | ✅ |
| `deptry .` | ✅ |
## Notes
- All commands use `uv run` prefix (project virtual environment)
- `mypy` has `strict = true` in `pyproject.toml` - expect strict type checking
- `ignore_missing_imports = true` is set, so missing third-party stubs are OK
- If `tests.*` override shows "unused section" note, it's normal (no tests dir yet)
- Never skip a check - all four must pass before committing

BIN
.coverage

Binary file not shown.

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# 系统 URL 配置服务端专用Web 用户无需配置)
# 复制此文件为 .env 并填入实际值
SSO_LOGIN_URL=https://your-sso-url/login
PORTAL_URL=https://your-portal-url/oshall
REIMBURSE_URL=http://your-reimburse-host:8081
REIMBURSE_PAGE=/expen/common/common?v=4.0
TRAVEL_PAGE=/expen/travel/travel?v=4.0
# LLM 配置
LLM_MODEL=your-model-name
LLM_API_BASE=http://your-llm-host:port/v1
LLM_API_KEY=your-api-key

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ __pycache__/
logs/
.vscode/
uploads/
.env
scripts/data/

View File

@@ -1,103 +0,0 @@
# PowerShell 使用注意事项
> 适用于 Windows 环境下的 Shell 命令执行。以下坑点均来自实际踩坑记录。
---
## 1. 没有 `head` / `tail` / `sed` / `awk`
PowerShell 不是 bash这些 Unix 工具不存在。
| bash | PowerShell 替代 |
|------|-----------------|
| `cmd \| head -n 5` | `cmd \| Select-Object -First 5` |
| `cmd \| tail -n 3` | `cmd \| Select-Object -Last 3` |
| `grep pattern file` | `Select-String pattern file`(或直接调用 `rg` |
**优先用 ripgrep (`rg`)**:搜索文件内容时直接用 `rg`,不依赖 PowerShell 内置工具。
---
## 2. 引号嵌套规则严格
PowerShell 对外部命令的引号处理不同于 bash
- `"` 是双引号字符串,内部变量会展开
- `'` 是单引号字符串,不展开变量
- `-c "..."` 里再嵌套 Python 的 `"..."``r'...'` 时,层数超过两层极易出错
**示例:**
```powershell
# 危险:$e 会被 PowerShell 当作变量展开
python -c "print(f'error: {$e}')"
# 安全:用单引号包裹整个 -c 参数PowerShell 不展开单引号)
python -c 'print("hello")'
```
**最佳实践**:复杂命令写进临时 `.py` 脚本文件执行,而不是用 `-c` 一行塞完。
---
## 3. `$` 变量展开干扰
在双引号字符串中PowerShell 会尝试解析 `$var``${expr}`。如果 Python 代码里包含 f-string 的 `{...}` 或正则里的 `$`,会被 PowerShell 提前处理。
**应对**:用单引号包裹整个命令参数,或将逻辑写进脚本文件。
---
## 4. Unicode / GBK 编码冲突
Windows PowerShell 默认控制台编码是 GBK。当 Python 输出包含非 GBK 字符如全角符号、emoji、CJK 扩展字符)时:
```
UnicodeEncodeError: 'gbk' codec can't encode character '\uff08' in position X
```
**解决:**
- Python 脚本开头加 `sys.stdout.reconfigure(encoding='utf-8')`
- 或设置环境变量 `$env:PYTHONIOENCODING = 'utf-8'`
- 或将输出写入文件而非直接打印到控制台
---
## 5. stderr 重定向行为不一致
bash 的 `2>&1` 在 PowerShell 中对外部程序(如 Python有时能工作但格式可能错乱。PowerShell 默认只捕获 stdout流编号 6stderr流编号 2需要显式合并。
| 写法 | 说明 |
|------|------|
| `cmd 2>&1` | stderr 合并到 stdout对外部程序可用但不稳定 |
| `cmd *>&1` | PowerShell 特有语法,捕获所有输出流,更可靠 |
**实际表现**Python Traceback 走 stderr`2>&1` 有时被 PowerShell 拦截后格式错乱。建议优先写脚本文件执行。
---
## 6. 中文路径在错误回显中乱码
命令失败时PowerShell 的 stderr 回显中包含中文的路径会显示为 `<60><><EFBFBD><EFBFBD><EFBFBD>`。不影响命令执行本身,但会让错误信息难以阅读。
**应对**:优先通过 Python 脚本自身打印结构化错误(写入文件或返回 JSON而非依赖 PowerShell 的错误捕获来诊断问题。
---
## 7. 管道传递的是对象而非文本
PowerShell 的管道传递的是 .NET 对象而非文本字节流。当把 PowerShell 管道接到外部程序时,对象的 `ToString()` 可能不是预期的格式。
---
## 核心原则速查
1. **复杂命令写脚本**:超过一行的操作,写成 `.py``.ps1` 文件执行
2. **优先用 ripgrep (`rg`)**:搜索文件内容时直接用 `rg`
3. **编码问题提前处理**Python 入口设 `sys.stdout.reconfigure(encoding='utf-8')`
4. **避免 `-c` 嵌套引号超过两层**
---
*创建于 2026-05-27 | Windows PowerShell + Python 环境*

154
README.md
View File

@@ -1,6 +1,6 @@
# 财务报销自动化
自动从 PDF 发票提取信息,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
自动从 PDF 发票或图片中提取信息,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
**支持发票类型区分**:系统自动识别高铁票、酒店住宿等差旅发票与普通发票。差旅发票不生成易耗品出库单,走差旅报销流程;普通发票生成出库单,走普通报销流程。
@@ -11,8 +11,9 @@
├── uv.lock # 依赖锁定文件
├── Makefile # 任务脚本(跨平台)
├── tasks.py # 任务脚本Windows 兼容)
├── config.json # 配置文件(登录凭据、默认值等)
├── config.example.json # 配置示例
├── .pre-commit-config.yaml # pre-commit 钩子配置
├── .env.example # 环境变量示例SSO 地址、LLM 配置等)
├── config.example.json # 用户配置示例
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板
├── src/
│ ├── __init__.py # 包初始化 / 日志器
@@ -22,8 +23,9 @@
│ ├── main.py # CLI 入口
│ ├── doc/ # 文档处理模块
│ │ ├── extractor.py # 编排入口:串联 PDF 读取 → LLM 提取 → 分类
│ │ ├── pdf.py # PDF 文件发现与文本提取
│ │ ├── pdf.py # PDF 图片渲染PyMuPDF供多模态 LLM 使用)
│ │ ├── llm_extractor.py # LLM 信息提取
│ │ ├── matcher.py # 数据匹配与校验
│ │ ├── invoice.py # 发票类型常量、分类逻辑、CSV 读写工具
│ │ ├── fill_consumable_doc.py # 将 CSV 填入易耗品出库单Word COM
│ │ ├── prompt.py # LLM 提示词模板
@@ -33,19 +35,24 @@
│ ├── templates/
│ │ ├── index.html # PC 端主页
│ │ └── mobile_upload.html # 移动端扫码上传
│ ├── static/ # 前端 CSS / JS
│ ├── static/
│ │ ├── css/ # 样式文件
│ │ └── js/ # 前端脚本
│ └── uploads/ # 按会话隔离的上传与产物目录
├── scripts/ # CLI 数据目录
│ ├── data/ # 发票源文件、config.json 与 .invoice_cache 缓存
│ └── test_*.py # 测试脚本
├── tests/ # 测试目录
├── *.pdf # 发票 PDFCLI 模式,放在项目根目录
├── invoice_summary.csv # 发票汇总表CLI 产物)
└── images/ # 浏览器调试截图
├── docs/ # 用户文档API 说明、操作指南等
├── images/ # 浏览器调试截图
└── *.pdf / *.jpg / *.png # 发票 PDF 或图片CLI 模式,放在 scripts/data/
```
## 数据流
```mermaid
flowchart TB
PDF[PDF 发票] --> Extract[extractor 提取]
PDF[PDF 发票 / 图片] --> Extract[extractor 提取]
Extract --> Classify{发票类型分类}
Classify -->|差旅发票| Travel[高铁票 / 酒店住宿]
Classify -->|普通发票| General[普通发票]
@@ -54,7 +61,7 @@ flowchart TB
CSV -->|仅普通发票| Fill[fill_consumable_doc]
Fill --> Doc[易耗品、出库单.doc]
CSV --> Bot[bot 浏览器自动化]
Bot -->|差旅模式| Submit_T[差旅报销填报<br/>TODO]
Bot -->|差旅模式| Submit_T[差旅报销填报]
Bot -->|普通模式| Submit_G[普通报销填报]
```
@@ -79,18 +86,19 @@ python tasks.py install
| 依赖 | 用途 |
|------|------|
| pdfplumber | PDF 发票文本提取 |
| PyMuPDF | PDF 图片渲染(供多模态 LLM 使用) |
| llama-index | LLM 信息提取(发票识别、差旅信息提取) |
| playwright | 财务系统浏览器自动化 |
| flask | Web 服务 |
| pywin32 | 填写 Word 出库单(`fill_consumable_doc` |
### 2. 准备数据CLI 模式)
将发票 PDF 放在项目根目录下。
将发票 PDF 或图片(`.jpg``.png``.webp``.bmp`)放在 `scripts/data/` 目录下。
### 3. 配置
编辑 `config.json`
编辑 `scripts/config.json`(参考 `config.example.json`
```json
{
@@ -99,7 +107,7 @@ python tasks.py install
"default_name": "默认报销人姓名",
"default_card_no": "默认公务卡号",
"default_person_id": "默认人员编号",
"consumable_storage": "躬行楼 C205"
"consumable_storage": "物料存储地"
}
```
@@ -109,8 +117,20 @@ python tasks.py install
| `default_name` | 默认报销人姓名 |
| `default_card_no` | 默认公务卡号 |
| `default_person_id` | 默认人员编号(工号) |
| `consumable_storage` | 出库单「存放地点」列默认值 |
| `sso_login_url` 等 | 系统 URL一般无需修改 |
| `consumable_storage` | 出库单「存放地点」列默认值(默认: `躬行楼 C205` |
**服务端配置**SSO 地址、报销系统 URL、LLM 参数)通过环境变量提供,有默认值,一般无需修改:
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `SSO_LOGIN_URL` | `https://tyrz.fynu.edu.cn/sso/login` | SSO 登录地址 |
| `PORTAL_URL` | `https://tyrz.fynu.edu.cn/oshall` | 统一信息平台地址 |
| `REIMBURSE_URL` | `http://210.45.32.214:8081` | 报销系统地址 |
| `REIMBURSE_PAGE` | `/expen/common/common?v=4.0` | 普通报销页面路径 |
| `TRAVEL_PAGE` | `/expen/travel/travel?v=4.0` | 差旅报销页面路径 |
| `LLM_MODEL` | `qwen-vl-max` | LLM 模型名称 |
| `LLM_API_BASE` | `http://localhost:8080/v1` | LLM API 地址 |
| `LLM_API_KEY` | `lm-studio` | LLM API 密钥 |
### 4. 运行CLI
@@ -135,39 +155,62 @@ uv run python src/main.py -u 工号 -p 密码
```bash
uv run python -m src.doc.fill_consumable_doc
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
uv run python -m src.doc.fill_consumable_doc --config scripts/config.json # 指定配置文件
uv run python -m src.doc.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
```
填写规则概要:
- 表头「日期」使用**填写当天**的日期(非发票开票日期)
-`规格型号` 解析品名、规格、单位、数量、单价;`价税合计` 写入金额列
-`spec_model` 解析品名、规格、单位、数量、单价;`card_amount` 写入金额列
- 单价/金额保留两位小数;表格内统一为 **宋体五号10.5 磅)**
- 存放地点取自 `consumable_storage`;购货人/领用人签字、备注保持空白
- 存放地点取自 `consumable_storage`(默认: `躬行楼 C205`;购货人/领用人签字、备注保持空白
## 执行步骤说明
| 步骤 | 命令 | 说明 |
|------|------|------|
| 发票提取 | `--step invoice` | 扫描目录 PDF生成 `invoice_summary.csv` |
| 发票提取 | `--step invoice` | 扫描 `scripts/data/` 目录 PDF 和图片,生成 `invoice_summary.csv` |
| 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 |
> 分步执行时,上一步的 CSV 会自动成为下一步的输入。
> 全流程执行时数据在内存中流转CSV 为参考产物。分步执行时,缓存数据会自动成为下一步的输入。
### 缓存机制
系统使用 JSON 缓存作为数据中转站,串联整个处理流程:
```
源文件 (PDF/图片) → LLM 多模态提取 → JSON 缓存 → 匹配/分类/填报
```
| 缓存文件 | 说明 |
|----------|------|
| `<文件名>.json` | 每个源文件的 LLM 提取结果(发票信息、支付记录等) |
| `match_result.json` | 发票与支付记录的匹配结果 |
| `travel_info.json` | 差旅信息(事由、地点、时间等)提取结果 |
缓存位置CLI 模式为 `scripts/data/.invoice_cache/`Web 模式为 `src/web/uploads/<session_id>/.invoice_cache/`。缓存文件与源文件同名(如 `发票1.pdf` 对应 `.invoice_cache/发票1.json`),后续步骤(金额匹配、发票分类、浏览器填报)均从缓存读取结构化数据。删除缓存后下次处理会重新提取。
### CSV 字段说明
发票级别 CSV`invoice_summary.csv`)使用英文列名:
| 列名 | 说明 |
|------|------|
| 序号 | 行号 |
| 发票类型 | 自动识别:`高铁票` / `酒店住宿` / `普通发票` |
| 发票号码 | 电子发票号码 |
| 开票日期 | 开票日期 |
| 项目名称 / 规格型号 | 货物或应税劳务信息(差旅发票为出发站→到达站) |
| 价税合计 | 发票含税金额 |
| 销售方名称 | 销方名称 |
| 出发站 / 到达站 / 车次 / 乘车日期 / 座位等级 | 高铁票专用字段 |
| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | 可手工或 Web 端编辑补充 |
| 备注 / 工号 | 可手工或 Web 端编辑补充 |
| `index` | 行号 |
| `invoice_type` | 发票类型:`train` / `hotel` / `general` |
| `invoice_number` | 电子发票号码 |
| `invoice_date` | 开票日期 |
| `item_name` | 货物或应税劳务名称 |
| `spec_model` | 规格型号(差旅发票为出发站→到达站) |
| `total_amount` | 发票含税金额 |
| `seller_name` | 销方名称 |
| `departure` / `arrival` | 出发站 / 到达站(高铁票专用) |
| `train_no` / `ride_date` / `seat_class` | 车次 / 乘车日期 / 座位等级(高铁票专用) |
| `person_name` | 人员姓名 |
| `card_date` / `card_no` / `card_amount` | 刷卡日期 / 公务卡号 / 刷卡金额 |
| `remark` | 备注 |
| `person_id` | 工号 |
## Web 服务
@@ -181,7 +224,7 @@ uv run python src/web/app.py
### 推荐使用流程
1. 上传 PDF或上传已有 CSV
1. 上传 PDF 或图片(或上传已有 CSV
2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充
3. 点击 **开始处理** — 完成发票提取、生成 CSV系统自动识别发票类型并分类统计
4. **普通发票**:自动生成 **易耗品、出库单.doc**,可下载
@@ -189,30 +232,24 @@ uv run python src/web/app.py
6. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
7. 确认无误后点击 **提交到财务系统**
### 处理模式
### 处理流程
| 模式 | 入口 | 说明 |
|------|------|------|
| **PDF 模式** | 上传 PDF | 提取发票 → 生成 CSV + 自动分类 → 普通发票生成出库单 |
| **CSV 快捷模式** | 仅上传 CSV | 跳过提取,读取 CSV 中的发票类型,按需生成出库单 |
上传 PDF 或图片 → LLM 识别文档类型 → 结构化提取 → 分类处理
### 发票类型区分
系统通过 LLM 多模态识别自动判断每张文档的类型,无需手动指定:
系统自动识别以下发票类型并按类型分流:
| 发票类型 | 识别依据 | 出库单 | 填报模式 |
|----------|----------|--------|----------|
| **高铁票** | 含"电子客票"、"中国铁路"、"12306"等关键词 | 不生成 | 差旅报销TODO |
| **酒店住宿** | 含"住宿费"、"餐饮服务"、"租赁服务"等关键词 | 不生成 | 差旅报销TODO |
| **普通发票** | 其他所有发票 | 自动生成 | 普通报销(已实现) |
当同一批次同时包含差旅发票和普通发票时,系统会为普通发票生成出库单,并在填报时分别处理。
| 文档类型 | 处理方式 | 状态 |
|----------|----------|------|
| 发票(高铁票/酒店住宿/普通发票) | 提取发票信息 → 金额匹配 → 分类 | 已实现 |
| 支付记录(刷卡截图) | 提取刷卡信息 → 与发票匹配 | 已实现 |
| 出差事前申请单 | 提取出差事由、地点、时间 | 已实现 |
| 飞机票 | 同高铁票处理流程 | 计划中 |
### 功能一览
| 功能 | 说明 |
|------|------|
| 发票提取 | 上传 PDF 后自动完成 |
| 发票提取 | 上传 PDF 或图片后自动完成 |
| 发票类型自动分类 | 高铁票/酒店住宿/普通发票,自动分流处理 |
| 易耗品出库单 | 仅普通发票自动生成 Word差旅发票跳过 |
| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 |
@@ -221,9 +258,26 @@ uv run python src/web/app.py
| 配置上传 | 支持上传 `config.json` 填充表单 |
| 手机扫码上传 | 二维码打开移动端页面拍照上传PC 端轮询同步 |
> Web 端浏览器填报以无头模式运行。未上传 PDF 时,填报阶段会跳过附件上传。
> Web 端浏览器填报以无头模式运行。未上传 PDF 或图片时,填报阶段会跳过附件上传。
> 出库单生成需要 **Windows + Word + pywin32**若失败页面会显示具体原因CSV 等其它产物仍可正常使用。
### 会话产物
每次上传生成独立会话,产物存放在 `src/web/uploads/<session_id>/`
| 产物 | 说明 |
|------|------|
| `invoice_summary.csv` | 发票汇总数据(含 LLM 识别结果) |
| `payment_records.csv` | 支付记录级别数据(含匹配结果) |
| `travel_applications.json` | 出差事前申请单数据JSON 格式) |
| `易耗品、出库单.doc` | 自动填写的出库单(仅普通发票) |
| `config.json` | 当次会话配置 |
| `session.log` | 处理日志 |
| `result.json` | 处理结果 |
| `.invoice_cache/` | LLM 提取结果缓存JSON 格式,避免重复处理) |
会话缓存机制与 CLI 模式相同,缓存目录中的 JSON 数据是后续匹配、分类和填报的唯一数据来源。
接口说明见 [API.md](./API.md)。
## 开发任务
@@ -254,5 +308,5 @@ uv run python src/web/app.py
- 浏览器填报时会打开或使用 Chromium请勿手动干扰自动化流程
- 调试截图保存在 `images/` 目录
- 项目根目录需保留 `易耗品、出库单.doc` 模板Web 每次从模板复制到会话目录再填写,不修改原模板
- `config.json` 含敏感信息,请勿提交到公开仓库
- **发票类型区分**:差旅发票(高铁票/酒店住宿)不会生成易耗品出库单,当前差旅报销填报流程仍在开发中TODO
- `scripts/config.json` 含敏感信息,请勿提交到公开仓库
- **发票类型区分**:差旅发票(高铁票/酒店住宿)不会生成易耗品出库单,差旅报销填报流程已完整实现(含差旅信息提取、明细录入、支付方式、补助清单、附件上传

View File

@@ -1,17 +1,8 @@
{
"username": "你的工号",
"password": "你的密码",
"sso_login_url": "https://tyrz.fynu.edu.cn/sso/login",
"portal_url": "https://tyrz.fynu.edu.cn/oshall",
"reimburse_url": "http://210.45.32.214:8081",
"reimburse_page": "/expen/common/common?v=4.0",
"default_name": "默认报销人姓名",
"default_card_no": "默认公务卡号",
"default_person_id": "默认人员编号",
"consumable_storage": "躬行楼 C205",
"llm": {
"model": "你的模型名称",
"api_base": "你的API地址",
"api_key": "你的API密钥"
}
"consumable_storage": "躬行楼 C205"
}

View File

@@ -1,17 +0,0 @@
{
"username": "202407021",
"password": "wang!1624155937",
"sso_login_url": "https://tyrz.fynu.edu.cn/sso/login",
"portal_url": "https://tyrz.fynu.edu.cn/oshall",
"reimburse_url": "http://210.45.32.214:8081",
"reimburse_page": "/expen/common/common?v=4.0",
"default_name": "王建锋",
"default_card_no": "6282880139161682",
"default_person_id": "202407021",
"consumable_storage": "新工科 D605",
"llm": {
"model": "qwen/qwen3.5-9b",
"api_base": "http://100.123.83.115:1234/v1",
"api_key": "123456"
}
}

View File

@@ -14,16 +14,15 @@ last_reviewed: 2026-06-09
| 1 | GET | `/` | PC 端主页 |
| 2 | POST | `/api/session` | 创建会话 |
| 3 | POST | `/api/upload/<session_id>` | 上传文件PDF/图片) |
| 4 | POST | `/api/upload-csv/<session_id>` | 上传 CSV 发票数据 |
| 5 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
| 6 | POST | `/api/process/<session_id>` | 启动处理(提取+LLM识别+出库单) |
| 7 | GET | `/api/logs/<session_id>` | SSE 日志流 |
| 8 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
| 9 | GET | `/api/data/<session_id>` | 获取发票数据JSON |
| 10 | POST | `/api/save/<session_id>` | 保存编辑后的发票数据 |
| 11 | POST | `/api/submit-financial/<session_id>` | 提交到财务系统 |
| 12 | GET | `/mobile/<session_id>` | 移动端上传页面 |
| 13 | POST | `/api/mobile-upload/<session_id>` | 移动端上传图片 |
| 4 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
| 5 | POST | `/api/process/<session_id>` | 启动处理(提取+LLM识别+出库单) |
| 6 | GET | `/api/logs/<session_id>` | SSE 日志流 |
| 7 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
| 8 | GET | `/api/data/<session_id>` | 获取发票数据JSON |
| 9 | POST | `/api/save/<session_id>` | 保存编辑后的发票数据 |
| 10 | POST | `/api/submit-financial/<session_id>` | 提交到财务系统 |
| 11 | GET | `/mobile/<session_id>` | 移动端上传页面 |
| 12 | POST | `/api/mobile-upload/<session_id>` | 移动端上传图片 |
---
@@ -78,28 +77,7 @@ HTTP `400`
---
### 3. 上传 CSV 发票数据
```
POST /api/upload-csv/<session_id>
Content-Type: multipart/form-data
```
| 字段 | 类型 | 说明 |
|------|------|------|
| file | File | 发票汇总 CSV |
**响应(成功):**
```json
{ "ok": true, "filename": "invoice_summary.csv" }
```
> 上传 CSV 后可跳过 PDF 提取和 LLM 识别,直接进入处理/编辑流程。
---
### 4. 列出会话文件
### 3. 列出会话文件
```
GET /api/files/<session_id>
@@ -129,7 +107,6 @@ Content-Type: application/json
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| mode | string | 否 | `"pdf"` / `"csv"` / `"auto"`(默认 `auto` |
| username | string | 否 | 财务系统工号 |
| password | string | 否 | 登录密码 |
| default_name | string | 否 | 默认报销人姓名 |
@@ -137,15 +114,7 @@ Content-Type: application/json
| default_person_id | string | 否 | 默认人员编号 |
| consumable_storage | string | 否 | 出库单存放地点;未填则用 `config.json` 中的值 |
**mode 行为:**
| mode | 行为 |
|------|------|
| `auto` | 仅有 CSV、无 PDF → CSV 模式;否则 → PDF 提取 + LLM 识别 |
| `csv` | 使用已上传 CSV跳过提取与 LLM 识别 |
| `pdf` | 执行 PDF 提取 + LLM 识别 |
**处理内容PDF 模式):**
**处理内容:**
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv`
2. 对支付截图多模态 LLM 识别,回填刷卡字段
@@ -270,7 +239,6 @@ GET /api/download/<session_id>/<filename>
|--------|------|
| `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) |
| `易耗品、出库单.doc` | 自动填写的出库单 |
| 用户上传的 CSV 名 | CSV 快捷模式下的原始文件 |
**错误:**

18
docs/README.md Normal file
View File

@@ -0,0 +1,18 @@
---
last_reviewed: 2026-06-11
---
# docs — 公开文档目录
本目录存放面向项目用户和外部贡献者的公开文档及说明文件。
## 文档清单
| 文件 | 说明 |
|------|------|
| `API.md` | Web API 完整接口文档:路由、请求/响应格式、SSE 日志流、错误码、端到端流程 |
| `报销操作指南.md` | 面向最终用户的操作步骤说明 |
## 文档边界
维护规范、实施方案、经验总结等内部资料统一放置在 `.agents/` 目录下,不混入本目录。

View File

@@ -65,37 +65,11 @@ uv run python src/main.py -u 202407021 -p "your_password"
### 3.1 发票数据 CSV
系统支持两种数据输入方式:
**方式 APDF 发票 + 支付截图(自动提取)**
上传 PDF 发票文件和支付截图,系统自动完成:
1. 从 PDF 提取发票信息
2. 多模态 LLM 识别支付截图中的刷卡信息
3. 生成 `invoice_summary.csv`
**方式 B直接上传 CSV快捷模式**
已有发票数据 CSV 可直接上传,跳过 PDF 提取和 LLM 识别步骤。
CSV 需包含以下列:
| 列名 | 说明 | 示例 |
|------|------|------|
| 序号 | 发票序号 | 1, 2, 3... |
| 发票号码 | 发票编号 | 26442000005432652421 |
| 开票日期 | 发票开具日期 | 2026/5/18 |
| 项目名称 | 采购项目名称 | 电阻一批 |
| 规格型号 | 规格型号 | — |
| 价税合计 | 发票金额 | 2900.00 |
| 销售方名称 | 商户/销售方 | 佛山市泓宇芯科技有限公司 |
| 人员姓名 | 报销人 | 王建锋(默认值) |
| 刷卡日期 | 公务卡消费日期 | 2026/5/18 → 自动转为 2026-05-18 |
| 公务卡号 | 公务卡卡号 | 6282880139161682默认值 |
| 刷卡金额 | 实际刷卡金额 | 2900.00 |
| 备注 | 备注信息 | — |
| 工号 | 人员工号 | 202407021默认值 |
### 3.2 发票类型分类
系统自动将发票分为两类,影响后续处理流程:
@@ -125,7 +99,6 @@ uv run python src/web/app.py
1. **发票 PDF**:点击或拖拽上传 PDF 文件(支持多选)
2. **支付截图**:点击或拖拽上传图片文件(支持多选)
3. **手机扫码上传**:扫描页面二维码,通过手机拍照上传支付截图
4. **CSV 快捷上传**:已有发票数据 CSV 可直接上传,跳过提取和 LLM 识别
### 4.3 配置信息

View File

@@ -1,4 +0,0 @@
序号,发票类型,发票号码,开票日期,项目名称,规格型号,价税合计,销售方名称,出发站,到达站,车次,乘车日期,座位等级,人员姓名,刷卡日期,公务卡号,刷卡金额,备注,工号
1,高铁票,26349119343000154520,2026/3/23,,,,,阜阳西,无锡东,G7221,2026/3/21,二等座,王建锋,,,,,
2,高铁票,26329166851000278168,2026/3/23,,,,,无锡东,阜阳西,G1826,2026/3/22,二等座,王建锋,,,,,
3,酒店住宿,26322000002199439186,2026/3/23,*住宿服务*住宿费 间 天 1 347.735849056604 347.74 6% 20.86,住宿费 间 天 1 347.735849056604 347.74 6% 20.86,368.6,,,,,,,,,,,,
1 序号 发票类型 发票号码 开票日期 项目名称 规格型号 价税合计 销售方名称 出发站 到达站 车次 乘车日期 座位等级 人员姓名 刷卡日期 公务卡号 刷卡金额 备注 工号
2 1 高铁票 26349119343000154520 2026/3/23 阜阳西 无锡东 G7221 2026/3/21 二等座 王建锋
3 2 高铁票 26329166851000278168 2026/3/23 无锡东 阜阳西 G1826 2026/3/22 二等座 王建锋
4 3 酒店住宿 26322000002199439186 2026/3/23 *住宿服务*住宿费 间 天 1 347.735849056604 347.74 6% 20.86 住宿费 间 天 1 347.735849056604 347.74 6% 20.86 368.6

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ requires-python = ">=3.12"
dependencies = [
"flask>=3.0",
"playwright>=1.40",
"pdfplumber>=0.10",
"PyMuPDF>=1.24",
"pywin32>=306",
"llama-index>=0.12.0",
"llama-index-llms-openai-like==0.7.2",

21
scripts/README.md Normal file
View File

@@ -0,0 +1,21 @@
---
last_reviewed: 2026-06-11
---
# scripts — 测试脚本目录
存放用于测试各模块功能的独立脚本,可直接运行。
## 脚本清单
| 文件 | 说明 |
|------|------|
| `test_multimodal.py` | 测试 PDF 多模态提取完整链路PDF 渲染 + LLM 提取) |
| `test_travel_info.py` | 测试差旅信息提取函数(数据从 `data/.invoice_cache` 缓存加载) |
## 运行方式
```bash
uv run python scripts/test_multimodal.py
uv run python scripts/test_travel_info.py
```

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""测试 PDF 多模态提取完整链路
用法:
python scripts/test_multimodal.py
"""
import io
import sys
from pathlib import Path
# Windows 终端强制 UTF-8
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT)) # noqa: E402
from src.doc.llm_extractor import extract_document # noqa: E402
from src.doc.pdf import render_pdf_to_images # noqa: E402
def test_render() -> None:
"""测试 PDF 渲染"""
pdf_path = ROOT / "事前申请单.pdf"
if not pdf_path.exists():
print(f"跳过: {pdf_path.name} 不存在")
return
print("=" * 60)
print("测试 PDF 渲染")
print("=" * 60)
try:
images = render_pdf_to_images(pdf_path, dpi=150)
if images:
print(f"成功渲染 {len(images)}")
for i, img_b64 in enumerate(images):
print(f"{i + 1} 页: base64 长度 {len(img_b64)} 字符")
else:
print("渲染返回空列表!")
except ImportError as e:
print(f"导入失败: {e}")
except Exception as e:
print(f"渲染异常: {e}")
def test_multimodal_extract() -> None:
"""测试完整的多模态提取链路"""
pdf_path = ROOT / "事前申请单.pdf"
if not pdf_path.exists():
print(f"跳过: {pdf_path.name} 不存在")
return
print()
print("=" * 60)
print("测试多模态提取")
print("=" * 60)
try:
result = extract_document(pdf_path)
if result:
print("提取成功:")
import json
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print("提取返回空字典!")
except Exception as e:
print(f"提取异常: {e}")
import traceback
traceback.print_exc()
def main() -> None:
test_render()
test_multimodal_extract()
print()
print("测试完成!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""直接测试 extract_travel_info 函数
所有数据从 .invoice_cache 缓存中自动加载,无需手动构造样本数据。
用法:
python scripts/test_travel_info.py
"""
import json
import sys
from pathlib import Path
# 项目根目录
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT)) # noqa: E402
from src.doc.llm_extractor import extract_travel_info # noqa: E402
def main() -> None:
source_dir = ROOT / "scripts" / "data"
if not source_dir.exists():
print(f"源文件目录不存在: {source_dir}")
sys.exit(1)
print("=" * 60)
print("测试 extract_travel_info数据来自缓存")
print("=" * 60)
print(f"源文件目录: {source_dir}")
print()
try:
result = extract_travel_info(
source_dir=source_dir,
)
print()
print("=" * 60)
print("提取结果:")
print("=" * 60)
print(json.dumps(result, ensure_ascii=False, indent=2))
print()
print("测试通过!")
except Exception as e:
print(f"测试失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

92
src/README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,8 +14,8 @@ from pathlib import Path
from typing import Any
from .. import get_logger
from ..bot import load_invoice_data
from ..config import load_config
from ..doc.invoice import load_invoice_csv
log = get_logger("fill_consumable_doc")
@@ -143,7 +143,8 @@ def fill_consumable_doc(
doc_path = Path(doc_path)
if config is None:
config = load_config()
invoices = load_invoice_data(str(csv_path), config)
invoices = load_invoice_csv(csv_path.parent / "invoice_summary.csv") or []
if backup:
bak = doc_path.with_suffix(doc_path.suffix + ".bak")
@@ -192,7 +193,7 @@ def fill_consumable_doc(
qty = str(qty_val) if qty_val > 0 else "1"
values = [
str(inv.get("seq", i + 1)),
str(inv.get("index", i + 1)),
parsed["product_name"],
parsed["spec"],
parsed["unit"],
@@ -240,7 +241,7 @@ def main() -> None:
parser = argparse.ArgumentParser(description="将发票 CSV 填入易耗品出库单")
parser.add_argument("--csv", default=str(root / "invoice_summary.csv"))
parser.add_argument("--doc", default=str(root / "易耗品、出库单.doc"))
parser.add_argument("--config", default=str(root / "config.json"))
parser.add_argument("--config", default=str(root / "scripts" / "data" / "config.json"))
parser.add_argument("--no-backup", action="store_true")
args = parser.parse_args()

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,6 @@ def build_invoice_system_prompt() -> str:
return _load_prompt("invoice_system.md")
def build_card_info_system_prompt() -> str:
"""构建支付截图信息提取系统提示词。"""
return _load_prompt("card_info_system.md")
def build_travel_info_system_prompt() -> str:
"""构建差旅信息提取系统提示词。"""
return _load_prompt("travel_info_system.md")

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,8 @@
数据在内存中流转,同时生成 CSV 中间产物。
发票类型区分:
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- 普通发票:生成易耗品出库单,走普通报销流程
- 差旅发票(train/hotel):不生成易耗品出库单,走差旅报销流程
- 普通发票general:生成易耗品出库单,走普通报销流程
"""
from pathlib import Path
@@ -16,52 +16,57 @@ from . import get_logger
from .config import load_config
from .doc.extractor import extract_invoices
from .doc.invoice import (
classify_invoice_batch,
load_invoices_from_csv,
save_application_json,
save_invoice_csv,
)
from .doc.invoice import (
save_csv as save_payment_csv,
)
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("invoice_type", "general")
if inv_type == "application":
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general, "application": application}
log = get_logger("pipeline")
def _find_upload_directory(project_dir: Path) -> Path | None:
"""自动发现 uploads 目录下最新且包含 PDF 的会话文件夹"""
uploads_base = project_dir / "src" / "web" / "uploads"
if not uploads_base.is_dir():
return None
def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
"""从缓存目录读取发票数据并按类型分组"""
from .doc.llm_extractor import load_cache
# 只选包含 PDF 的目录
valid_dirs = [d for d in uploads_base.iterdir() if d.is_dir() and list(d.glob("*.pdf"))]
if not valid_dirs:
return None
# 按修改时间排序,取最新
session_dirs = sorted(
valid_dirs,
key=lambda d: d.stat().st_mtime,
reverse=True,
)
return session_dirs[0]
cache_map = load_cache(cache_path)
invoices = [data for data in cache_map.values() if data.get("invoice_type") not in ("application", "payment")]
return _classify_invoice_batch(invoices)
def _classify_from_csv(csv_path: Path) -> dict[str, list[dict[str, Any]]]:
"""从已生成的 CSV 中读取发票数据并按类型分组"""
rows = load_invoices_from_csv(csv_path)
if rows is None:
return {"travel": [], "general": []}
return classify_invoice_batch(rows)
def run_pipeline(step: str = "all", username: str | None = None, password: str | None = None) -> int:
def run_pipeline(
step: str = "all",
username: str | None = None,
password: str | None = None,
cache_dir: str | None = None,
) -> int:
"""执行报销流程
Args:
step: all | invoice | submit
username: 覆盖 config.json 中的用户名
password: 覆盖 config.json 中的密码
cache_dir: 发票缓存目录(包含 .invoice_cache 子目录)
"""
config = load_config()
if username:
@@ -69,8 +74,8 @@ def run_pipeline(step: str = "all", username: str | None = None, password: str |
if password:
config["password"] = password
# 工作目录(项目根目录)
project_dir = Path(__file__).parent.parent
cache_path = Path(cache_dir) if cache_dir else project_dir / "scripts" / "data"
# --------------------------------------------------
# Step 1: 发票提取
@@ -83,15 +88,17 @@ def run_pipeline(step: str = "all", username: str | None = None, password: str |
log.info("[1/2] 发票提取")
log.info("=" * 60)
payment_records, groups = extract_invoices(str(project_dir))
payment_records, applications, groups = extract_invoices(str(cache_path))
if not payment_records:
log.error("未提取到任何发票数据")
return 1
save_payment_csv(payment_records, project_dir / "payment_records.csv")
save_invoice_csv(payment_records, project_dir / "invoice_summary.csv")
save_payment_csv(payment_records, cache_path / "payment_records.csv")
save_invoice_csv(payment_records, cache_path / "invoice_summary.csv")
if applications:
save_application_json(applications, cache_path / "travel_applications.json")
# 打印分类结果
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])}")
if step == "invoice":
@@ -106,30 +113,22 @@ def run_pipeline(step: str = "all", username: str | None = None, password: str |
log.info("[2/2] 报销提交")
log.info("=" * 60)
from .bot import load_invoice_data, run_bot
from .bot import run_bot
csv_path = project_dir / "payment_records.csv"
bot_invoices = load_invoice_data(str(csv_path), config)
# 根据发票类型选择填报模式
if groups is None:
groups = _classify_from_csv(csv_path)
groups = _classify_from_cache(cache_path)
if groups["travel"] and not groups["general"]:
log.info("检测到纯差旅发票,使用差旅报销模式")
# TODO: 差旅报销填报流程
run_bot(config, bot_invoices)
run_bot(config, work_dir=cache_path)
else:
log.info("检测到普通发票,使用普通报销模式")
run_bot(config, bot_invoices)
run_bot(config, work_dir=cache_path)
if step == "submit":
log.info("[2/2] 报销提交 完成")
return 0
# --------------------------------------------------
# 全流程完成
# --------------------------------------------------
log.info("=" * 60)
log.info("全流程执行完毕")
log.info("=" * 60)

View File

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

View File

@@ -38,13 +38,32 @@ from src.doc.fill_consumable_doc import ( # noqa: E402, I001
fill_consumable_from_template,
)
from src.doc.invoice import ( # noqa: E402, I001
classify_invoice_batch,
load_csv,
load_invoice_csv,
save_csv as save_payment_csv,
save_invoice_csv,
save_application_json,
)
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("invoice_type", "general")
if inv_type == "application":
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general, "application": application}
fill_log = get_logger("fill_consumable_doc")
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
@@ -161,7 +180,7 @@ def _has_general_invoices(rows: list[dict[str, str]]) -> bool:
invoices.append(row)
if not invoices:
return False
groups = classify_invoice_batch(invoices)
groups = _classify_invoice_batch(invoices)
return len(groups["general"]) > 0
@@ -179,7 +198,7 @@ def _get_invoice_groups(rows: list[dict[str, str]]) -> dict[str, int]:
# 发票级别格式:直接使用
elif "发票类型" in row:
invoices.append(row)
groups = classify_invoice_batch(invoices)
groups = _classify_invoice_batch(invoices)
return {
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
@@ -250,14 +269,18 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
start = time.time()
# ---- Step 1: 发票提取 ----
invoices, groups = extract_invoices(str(session_dir))
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
# 保存两个 CSV支付记录级别供 bot/出库单使用)和发票级别(供人工参考)
# 保存 CSV支付记录级别供 bot/出库单使用)和发票级别(供人工参考)
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
# 出差申请单单独保存
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
@@ -276,50 +299,6 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
return result
def run_csv_pipeline_web(session_dir: Path, config: dict[str, Any], csv_filename: str) -> dict[str, Any]:
"""直接使用上传的 CSV 文件,跳过 PDF 提取"""
start = time.time()
csv_path = session_dir / csv_filename
if not csv_path.exists():
return {"ok": False, "error": "CSV 文件不存在"}
# 尝试读取支付记录格式
rows = load_csv(csv_path)
if rows is None:
# 尝试读取发票级别格式
invoice_rows = load_invoice_csv(csv_path)
if invoice_rows is None:
return {"ok": False, "error": "CSV 读取失败"}
# 发票级别格式:直接统计
type_stats = _get_invoice_groups(invoice_rows)
elapsed = time.time() - start
return {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": len(invoice_rows),
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
"travel_count": type_stats["travel_count"],
"general_count": type_stats["general_count"],
}
# 支付记录格式:统计发票类型
type_stats = _get_invoice_groups(rows)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": type_stats["travel_count"] + type_stats["general_count"],
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
"travel_count": type_stats["travel_count"],
"general_count": type_stats["general_count"],
}
doc_fill = _try_fill_consumable_doc(session_dir, config)
_append_doc_download(result, session_dir.name, doc_fill)
return result
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
@@ -331,9 +310,10 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str,
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from src.bot import load_invoice_data, run_bot_web
from src.bot import run_bot_web
bot_invoices = load_invoice_data(str(csv_path), config)
# TODO: 改为从缓存读取或直接请求 LLM与差旅报销保持一致
# bot_invoices = load_invoice_data(str(csv_path), config)
# 判断发票类型
rows = load_csv(csv_path)
@@ -345,7 +325,7 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str,
else:
fill_log.info("检测到普通发票,使用普通报销模式")
run_bot_web(config, bot_invoices, session_dir)
run_bot_web(config, session_dir)
return {"ok": True}
@@ -384,22 +364,6 @@ def upload_file(session_id: str) -> Any:
return jsonify({"ok": True, "filename": safe_name})
@app.route("/api/upload-csv/<session_id>", methods=["POST"])
def upload_csv(session_id: str) -> Any:
"""上传 CSV 发票数据文件(跳过 PDF 提取)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
safe_name = Path(f.filename).name
f.save(str(session_dir / safe_name))
return jsonify({"ok": True, "filename": safe_name})
@app.route("/api/files/<session_id>", methods=["GET"])
def list_files(session_id: str) -> Any:
"""列出会话目录中的文件"""
@@ -420,7 +384,6 @@ def start_process(session_id: str) -> Any:
return session_dir
body = request.get_json(silent=True) or {}
mode = body.get("mode", "auto") # "pdf", "csv", or "auto"
# 读取配置
config = _build_web_config(body)
@@ -435,19 +398,7 @@ def start_process(session_id: str) -> Any:
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
if mode == "csv":
csv_files = list(session_dir.glob("*.csv"))
if not csv_files:
result = {"ok": False, "error": "未找到 CSV 文件"}
else:
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name)
else:
csv_files = list(session_dir.glob("*.csv"))
pdf_files = list(session_dir.glob("*.pdf"))
if csv_files and not pdf_files:
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name)
else:
result = run_pipeline_web(session_dir, config)
result = run_pipeline_web(session_dir, config)
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):

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

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

View File

@@ -1,6 +1,5 @@
let sessionId = null;
const pdfFiles = [], imgFiles = [];
let csvFile = null;
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
let csvFilename = ''; // 当前 CSV 文件名
let lastDownloadUrls = {}; // 最近一次可下载文件链接
@@ -50,23 +49,6 @@ function renderFileList(type) {
).join('');
}
// ---- CSV 上传 ----
function handleCsvFile(input) {
const file = input.files[0];
if (!file) return;
csvFile = file;
document.getElementById('csv-zone').classList.add('active');
document.getElementById('csv-list').innerHTML =
`<span class="file-tag">${file.name}<span class="remove" onclick="event.stopPropagation();removeCsvFile()">&times;</span></span>`;
input.value = '';
}
function removeCsvFile() {
csvFile = null;
document.getElementById('csv-zone').classList.remove('active');
document.getElementById('csv-list').innerHTML = '';
}
// ---- 配置上传 ----
function handleConfigUpload(input) {
const file = input.files[0];
@@ -122,22 +104,10 @@ function handleConfigUpload(input) {
});
});
// CSV 拖拽
const csvZone = document.getElementById('csv-zone');
csvZone.addEventListener('dragover', e => { e.preventDefault(); csvZone.classList.add('dragover'); });
csvZone.addEventListener('dragleave', () => csvZone.classList.remove('dragover'));
csvZone.addEventListener('drop', e => {
e.preventDefault();
csvZone.classList.remove('dragover');
const file = Array.from(e.dataTransfer.files).find(f => f.name.toLowerCase().endsWith('.csv'));
if (file) handleCsvFile({ files: [file] });
});
// ---- 处理 ----
async function startProcess() {
const isCsvMode = !!csvFile;
if (!isCsvMode && !pdfFiles.length && !imgFiles.length) {
alert('请先上传文件或 CSV');
if (!pdfFiles.length && !imgFiles.length) {
alert('请先上传文件或图片');
return;
}
@@ -152,17 +122,11 @@ async function startProcess() {
try {
await ensureSession();
if (isCsvMode) {
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
for (const {f} of allFiles) {
const fd = new FormData();
fd.append('file', csvFile);
await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd });
} else {
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
for (const {f} of allFiles) {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
}
fd.append('file', f);
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
}
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
@@ -174,7 +138,6 @@ async function startProcess() {
default_card_no: document.getElementById('cfg-card').value,
default_person_id: document.getElementById('cfg-person-id').value,
consumable_storage: document.getElementById('cfg-storage').value,
mode: isCsvMode ? 'csv' : 'auto',
};
await fetch(`/api/process/${sessionId}`, {
@@ -248,8 +211,13 @@ function showDownloadLinks(result) {
).join('');
if (warn) {
if (result.doc_ok === false && result.doc_error) {
if (result.doc_skipped) {
warn.style.display = 'block';
warn.style.color = '#0d6efd';
warn.textContent = result.doc_message || '差旅报销无需生成易耗品出库单';
} else if (result.doc_ok === false && result.doc_error) {
warn.style.display = 'block';
warn.style.color = '';
warn.textContent = '出库单未生成:' + result.doc_error;
} else {
warn.style.display = 'none';
@@ -257,7 +225,7 @@ function showDownloadLinks(result) {
}
}
section.style.display = items.length || (result.doc_ok === false) ? 'block' : 'none';
section.style.display = items.length || (result.doc_ok === false) || result.doc_skipped ? 'block' : 'none';
}
// ---- 发票数据编辑 ----

View File

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

View File

@@ -43,17 +43,6 @@
</div>
</div>
<!-- CSV 快捷上传 -->
<div class="mb-4">
<div class="section-title">📊 CSV 快捷上传 <span class="text-muted fw-normal" style="font-size:12px">(已有发票数据 CSV 可直接上传,跳过提取和 LLM 识别)</span></div>
<div class="upload-zone" id="csv-zone" onclick="document.getElementById('csv-input').click()">
<div class="icon">📊</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传 CSV 文件</div>
<div id="csv-list" class="mt-2"></div>
</div>
<input type="file" id="csv-input" accept=".csv" hidden onchange="handleCsvFile(this)">
</div>
<!-- 配置表单 -->
<div class="card mb-4">
<div class="card-body">
@@ -111,7 +100,7 @@
<div class="card mb-4" id="edit-section" style="display:none">
<div class="card-body">
<div class="section-title d-flex justify-content-between align-items-center">
<span>📝 发票数据(可编辑)</span>
<span>📝 付款记录(可编辑)</span>
<div class="d-flex justify-content-end align-items-center">
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
</div>

23
tests/README.md Normal file
View File

@@ -0,0 +1,23 @@
---
last_reviewed: 2026-06-11
---
# tests — 单元测试目录
存放项目单元测试,使用 pytest 运行。
## 测试清单
| 文件 | 覆盖范围 |
|------|---------|
| `test_config.py` | 配置加载模块 |
| `test_extractor.py` | 发票提取编排:空目录、提取失败、正常流程、分类结果、申请单分离、支付匹配 |
| `test_invoice.py` | 发票分类、CSV 读写 |
| `test_llm_extractor.py` | LLM 信息提取 |
| `test_matcher.py` | 发票与支付记录匹配 |
## 运行方式
```bash
uv run pytest tests/
```

340
tests/test_extractor.py Normal file
View File

@@ -0,0 +1,340 @@
"""发票提取编排模块单元测试
覆盖范围:
- extract_invoices空目录、提取失败、正常流程、分类结果、申请单分离
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from src.doc.extractor import extract_invoices
# 字段键名(与源码中的字符串字面量保持一致)
K_INVOICE_TYPE = "invoice_type"
K_INVOICE_NUMBER = "invoice_number"
K_TOTAL_AMOUNT = "total_amount"
K_ITEM_NAME = "item_name"
K_PERSON_NAME = "person_name"
K_CARD_DATE = "card_date"
K_CARD_NO = "card_no"
K_CARD_AMOUNT = "card_amount"
K_MATCHED_INVOICES = "_matched_invoices"
K_RELATIVE_INVOICE_COUNT = "relative_invoice_count"
K_INVOICE_DETAIL = "invoice_detail"
K_REMARK = "remark"
# 发票类型
INVOICE_TYPE_TRAIN = "train"
INVOICE_TYPE_HOTEL = "hotel"
INVOICE_TYPE_GENERAL = "general"
INVOICE_TYPE_PAYMENT = "payment"
DOCUMENT_TYPE_APPLICATION = "application"
# ------------------------------------------------------------------
# Fixture helpers
# ------------------------------------------------------------------
def _make_invoice(number: str, amount: float, inv_type: str = INVOICE_TYPE_GENERAL) -> dict[str, Any]:
inv: dict[str, Any] = {
K_INVOICE_NUMBER: number,
K_INVOICE_TYPE: inv_type,
K_TOTAL_AMOUNT: str(amount),
}
if inv_type == INVOICE_TYPE_TRAIN:
inv[K_PERSON_NAME] = f"person{number}"
elif inv_type == INVOICE_TYPE_GENERAL:
inv[K_ITEM_NAME] = f"item{number}"
return inv
def _make_application() -> dict[str, Any]:
return {
K_INVOICE_TYPE: DOCUMENT_TYPE_APPLICATION,
"applicant": "张三",
}
def _make_card(amount: float) -> dict[str, Any]:
return {
K_INVOICE_TYPE: INVOICE_TYPE_PAYMENT,
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: f"{amount:.2f}",
}
# ------------------------------------------------------------------
# extract_invoices
# ------------------------------------------------------------------
class TestExtractInvoices:
"""extract_invoices 编排函数"""
def test_empty_directory(self, tmp_path: Path, monkeypatch):
monkeypatch.setattr("src.doc.extractor._find_all_files", lambda d: [])
records, apps, groups = extract_invoices(str(tmp_path))
assert records == []
assert apps == []
assert groups == {"travel": [], "general": [], "application": []}
def test_extraction_fails(self, tmp_path: Path, monkeypatch):
pdf = tmp_path / "broken.pdf"
pdf.touch()
monkeypatch.setattr("src.doc.extractor._find_all_files", lambda d: [pdf])
monkeypatch.setattr("src.doc.extractor._extract_document", lambda p, c: None)
records, apps, groups = extract_invoices(str(tmp_path))
assert records == []
assert apps == []
assert groups == {"travel": [], "general": [], "application": []}
def test_normal_flow_general_invoices(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "inv1.pdf"
pdf2 = tmp_path / "inv2.pdf"
pdf1.touch()
pdf2.touch()
inv1 = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
inv2 = _make_invoice("INV002", 200.0, INVOICE_TYPE_GENERAL)
monkeypatch.setattr("src.doc.extractor._find_all_files", lambda d: [pdf1, pdf2])
call_index = [0]
def fake_extract(path, cache_dir):
idx = call_index[0]
call_index[0] += 1
return inv1 if idx == 0 else inv2
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
K_RELATIVE_INVOICE_COUNT: "2",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv1, inv2],
}
]
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 1
assert records[0][K_RELATIVE_INVOICE_COUNT] == "2"
assert len(groups["travel"]) == 0
assert len(groups["general"]) == 2
def test_normal_flow_mixed_invoices(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "train.pdf"
pdf2 = tmp_path / "hotel.pdf"
pdf3 = tmp_path / "general.pdf"
pdf1.touch()
pdf2.touch()
pdf3.touch()
inv_train = _make_invoice("TRAIN001", 500.0, INVOICE_TYPE_TRAIN)
inv_hotel = _make_invoice("HOTEL001", 800.0, INVOICE_TYPE_HOTEL)
inv_general = _make_invoice("GEN001", 150.0, INVOICE_TYPE_GENERAL)
monkeypatch.setattr(
"src.doc.extractor._find_all_files",
lambda d: [pdf1, pdf2, pdf3],
)
invoices_list = [inv_train, inv_hotel, inv_general]
call_index = [0]
def fake_extract(path, cache_dir):
idx = call_index[0]
call_index[0] += 1
return invoices_list[idx]
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv_train],
},
{
K_CARD_DATE: "2026-01-02",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "800.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv_hotel],
},
{
K_CARD_DATE: "",
K_CARD_NO: "",
K_CARD_AMOUNT: "",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "unmatched",
K_MATCHED_INVOICES: [inv_general],
},
]
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 3
assert len(groups["travel"]) == 2
assert len(groups["general"]) == 1
travel_numbers = {inv[K_INVOICE_NUMBER] for inv in groups["travel"]}
assert "TRAIN001" in travel_numbers
assert "HOTEL001" in travel_numbers
assert groups["general"][0][K_INVOICE_NUMBER] == "GEN001"
def test_application_documents_separated(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "invoice.pdf"
pdf2 = tmp_path / "application.pdf"
pdf1.touch()
pdf2.touch()
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
app = _make_application()
monkeypatch.setattr(
"src.doc.extractor._find_all_files",
lambda d: [pdf1, pdf2],
)
results = [inv, app]
call_index = [0]
def fake_extract(path, cache_dir):
idx = call_index[0]
call_index[0] += 1
return results[idx]
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "300.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv],
}
]
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(apps) == 1
assert apps[0]["applicant"] == "张三"
assert len(groups["application"]) == 1
def test_payment_records_included(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "invoice.pdf"
pdf2 = tmp_path / "card.png"
pdf1.touch()
pdf2.touch()
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
card = _make_card(300.0)
monkeypatch.setattr(
"src.doc.extractor._find_all_files",
lambda d: [pdf1, pdf2],
)
results = [inv, card]
call_index = [0]
def fake_extract(path, cache_dir):
idx = call_index[0]
call_index[0] += 1
return results[idx]
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "300.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv],
}
]
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 1
assert len(groups["general"]) == 1
def test_partial_extraction_failure(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "good.pdf"
pdf2 = tmp_path / "bad.pdf"
pdf1.touch()
pdf2.touch()
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
monkeypatch.setattr(
"src.doc.extractor._find_all_files",
lambda d: [pdf1, pdf2],
)
results = [inv, None]
call_index = [0]
def fake_extract(path, cache_dir):
idx = call_index[0]
call_index[0] += 1
return results[idx]
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "300.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv],
}
]
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 1
assert len(groups["general"]) == 1

View File

@@ -5,14 +5,36 @@
from src.doc.invoice import (
INVOICE_LEVEL_COLUMNS,
INVOICE_TYPE_GENERAL,
INVOICE_TYPE_HOTEL,
INVOICE_TYPE_TRAIN,
INVOICE_TYPE_TRAVEL,
PAYMENT_RECORD_COLUMNS,
classify_invoice_batch,
is_travel_invoice,
)
from src.doc.invoice import (
_classify_invoice_batch as classify_invoice_batch,
)
# 字段键名与发票类型(与源码中的字符串字面量保持一致)
K_INVOICE_TYPE = "invoice_type"
K_INVOICE_NUMBER = "invoice_number"
K_TOTAL_AMOUNT = "total_amount"
K_ITEM_NAME = "item_name"
K_PERSON_NAME = "person_name"
K_CARD_DATE = "card_date"
K_CARD_NO = "card_no"
K_CARD_AMOUNT = "card_amount"
K_MATCHED_INVOICES = "_matched_invoices"
K_RELATIVE_INVOICE_COUNT = "relative_invoice_count"
K_INVOICE_DETAIL = "invoice_detail"
K_REMARK = "remark"
K_SOURCE_FILE = "source_file"
INVOICE_TYPE_TRAIN = "train"
INVOICE_TYPE_HOTEL = "hotel"
INVOICE_TYPE_GENERAL = "general"
DOCUMENT_TYPE_APPLICATION = "application"
INVOICE_TYPE_TRAVEL = [INVOICE_TYPE_TRAIN, INVOICE_TYPE_HOTEL]
def is_travel_invoice(invoice_type: str) -> bool:
return invoice_type in INVOICE_TYPE_TRAVEL
class TestInvoiceConstants:
@@ -47,7 +69,7 @@ class TestIsTravelInvoice:
assert is_travel_invoice(INVOICE_TYPE_GENERAL) is False
def test_unknown_not_travel(self) -> None:
assert is_travel_invoice("未知类型") is False
assert is_travel_invoice("unknown") is False
class TestClassifyInvoiceBatch:
@@ -55,12 +77,12 @@ class TestClassifyInvoiceBatch:
def test_empty_list(self) -> None:
result = classify_invoice_batch([])
assert result == {"travel": [], "general": []}
assert result == {"travel": [], "general": [], "application": []}
def test_all_travel(self) -> None:
invoices = [
{"发票类型": INVOICE_TYPE_TRAIN, "发票号码": "001"},
{"发票类型": INVOICE_TYPE_HOTEL, "发票号码": "002"},
{K_INVOICE_TYPE: INVOICE_TYPE_TRAIN, K_INVOICE_NUMBER: "001"},
{K_INVOICE_TYPE: INVOICE_TYPE_HOTEL, K_INVOICE_NUMBER: "002"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 2
@@ -68,7 +90,7 @@ class TestClassifyInvoiceBatch:
def test_all_general(self) -> None:
invoices = [
{"发票类型": INVOICE_TYPE_GENERAL, "发票号码": "001"},
{K_INVOICE_TYPE: INVOICE_TYPE_GENERAL, K_INVOICE_NUMBER: "001"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 0
@@ -76,14 +98,14 @@ class TestClassifyInvoiceBatch:
def test_mixed(self) -> None:
invoices = [
{"发票类型": INVOICE_TYPE_TRAIN, "发票号码": "001"},
{"发票类型": INVOICE_TYPE_GENERAL, "发票号码": "002"},
{K_INVOICE_TYPE: INVOICE_TYPE_TRAIN, K_INVOICE_NUMBER: "001"},
{K_INVOICE_TYPE: INVOICE_TYPE_GENERAL, K_INVOICE_NUMBER: "002"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 1
assert len(result["general"]) == 1
def test_missing_type_defaults_to_general(self) -> None:
invoices = [{"发票号码": "001"}]
invoices = [{K_INVOICE_NUMBER: "001"}]
result = classify_invoice_batch(invoices)
assert len(result["general"]) == 1

202
tests/test_llm_extractor.py Normal file
View File

@@ -0,0 +1,202 @@
"""LLM 信息提取模块单元测试
覆盖范围:
- _parse_json_response纯 JSON、Markdown 包裹、带前缀、解析失败
- _image_to_base64图片转 base64
- extract_document成功提取、LLM 失败
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
import pytest
from src.doc.llm_extractor import (
_image_to_base64,
_parse_json_response,
extract_document,
)
# 字段键名(与源码中的字符串字面量保持一致)
K_INVOICE_NUMBER = "invoice_number"
K_TOTAL_AMOUNT = "total_amount"
K_CARD_DATE = "card_date"
K_CARD_NO = "card_no"
K_CARD_AMOUNT = "card_amount"
# ------------------------------------------------------------------
# _parse_json_response
# ------------------------------------------------------------------
class TestParseJsonResponse:
"""JSON 响应解析"""
def test_pure_json(self):
raw = json.dumps({K_INVOICE_NUMBER: "123456", K_TOTAL_AMOUNT: "100.00"})
result = _parse_json_response(raw)
assert result[K_INVOICE_NUMBER] == "123456"
assert result[K_TOTAL_AMOUNT] == "100.00"
def test_markdown_json_block(self):
raw = f'```json\n{{"{K_INVOICE_NUMBER}": "789"}}\n```'
result = _parse_json_response(raw)
assert result[K_INVOICE_NUMBER] == "789"
def test_markdown_block_without_lang(self):
raw = '```\n{"key": "value"}\n```'
result = _parse_json_response(raw)
assert result["key"] == "value"
def test_json_prefix(self):
raw = f'json\n{{"{K_INVOICE_NUMBER}": "001"}}'
result = _parse_json_response(raw)
assert result[K_INVOICE_NUMBER] == "001"
def test_json_prefix_with_whitespace(self):
raw = ' json \n{"a": 1}'
result = _parse_json_response(raw)
assert result["a"] == 1
def test_nested_json(self):
raw = json.dumps({"outer": {"inner": [1, 2, 3]}})
result = _parse_json_response(raw)
assert result["outer"]["inner"] == [1, 2, 3]
def test_whitespace_around_json(self):
raw = ' \n {"x": 42} \n '
result = _parse_json_response(raw)
assert result["x"] == 42
def test_invalid_json_raises(self):
with pytest.raises(ValueError):
_parse_json_response("not json at all")
def test_empty_string_raises(self):
with pytest.raises(ValueError):
_parse_json_response("")
# ------------------------------------------------------------------
# _image_to_base64
# ------------------------------------------------------------------
class TestImageToBase64:
"""图片转 base64"""
def test_png_to_base64(self, tmp_path: Path):
content = b"\x89PNG\r\n\x1a\nfake_png_data"
img_path = tmp_path / "test.png"
img_path.write_bytes(content)
result = _image_to_base64(img_path)
assert isinstance(result, str)
assert base64.b64decode(result) == content
def test_jpg_to_base64(self, tmp_path: Path):
content = b"\xff\xd8\xff\xe0fake_jpg_data"
img_path = tmp_path / "test.jpg"
img_path.write_bytes(content)
result = _image_to_base64(img_path)
assert base64.b64decode(result) == content
def test_file_not_found_raises(self, tmp_path: Path):
img_path = tmp_path / "nonexistent.png"
with pytest.raises(FileNotFoundError):
_image_to_base64(img_path)
def test_returns_utf8_string(self, tmp_path: Path):
content = b"test_image_content"
img_path = tmp_path / "test.png"
img_path.write_bytes(content)
result = _image_to_base64(img_path)
assert type(result) is str
# ------------------------------------------------------------------
# extract_document (mock LLM)
# ------------------------------------------------------------------
class TestExtractDocument:
"""图片提取:通过 mock _llm_query_multimodal 避免真实 LLM 调用"""
def _mock_multimodal(self, monkeypatch, response_text: str):
def fake_query(system_prompt, text, image_b64, max_tokens=4096):
return response_text
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", fake_query)
def test_success(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
img_path.write_bytes(b"fake_image")
mock_result = {
K_CARD_DATE: "2026-01-10",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
}
self._mock_multimodal(monkeypatch, json.dumps(mock_result))
result = extract_document(img_path)
assert result[K_CARD_DATE] == "2026-01-10"
assert result[K_CARD_NO] == "6228480000000000"
assert result[K_CARD_AMOUNT] == "500.00"
def test_llm_failure_propagates(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
img_path.write_bytes(b"fake_image")
def fake_query(system_prompt, text, image_b64, max_tokens=4096):
raise RuntimeError("模型不可用")
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", fake_query)
with pytest.raises(RuntimeError, match="模型不可用"):
extract_document(img_path)
def test_file_not_found_propagates(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "missing.png"
self._mock_multimodal(monkeypatch, "{}")
with pytest.raises(FileNotFoundError):
extract_document(img_path)
def test_markdown_wrapped_json(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
img_path.write_bytes(b"fake_image")
mock_result = {
K_CARD_DATE: "2026-02-01",
K_CARD_NO: "6228481111111111",
K_CARD_AMOUNT: "300.00",
}
wrapped = f"```json\n{json.dumps(mock_result)}\n```"
self._mock_multimodal(monkeypatch, wrapped)
result = extract_document(img_path)
assert result[K_CARD_DATE] == "2026-02-01"
def test_image_encoded_as_base64(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
expected_content = b"test_image_data"
img_path.write_bytes(expected_content)
received_b64 = None
def capture_b64(system_prompt, text, image_b64s, max_tokens=4096):
nonlocal received_b64
received_b64 = image_b64s
return json.dumps({K_CARD_DATE: "2026-01-01", K_CARD_NO: "0000", K_CARD_AMOUNT: "100"})
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", capture_b64)
extract_document(img_path)
assert received_b64 is not None
assert isinstance(received_b64, list)
assert len(received_b64) >= 1
assert base64.b64decode(received_b64[0]) == expected_content

564
tests/test_matcher.py Normal file
View File

@@ -0,0 +1,564 @@
"""发票与支付记录匹配模块单元测试
覆盖范围:
- 辅助函数_safe_float, _relative_tolerance, _build_invoice_summary
- 一对一匹配:精确匹配、容差内匹配、容差外不匹配
- 一对多匹配:精确匹配阶段、贪心匹配阶段、回滚逻辑
- 记录构建_build_payment_records, _invoices_to_records
- 端到端match_invoices_to_cards直接传入分类好的支付记录
"""
from __future__ import annotations
from typing import Any
from src.doc.matcher import (
_build_invoice_summary,
_build_payment_records,
_invoices_to_records,
_match,
_match_one_to_many,
_match_one_to_one,
_relative_tolerance,
_safe_float,
match_invoices_to_cards,
)
# 字段键名(与源码中的字符串字面量保持一致)
K_INVOICE_TYPE = "invoice_type"
K_INVOICE_NUMBER = "invoice_number"
K_TOTAL_AMOUNT = "total_amount"
K_ITEM_NAME = "item_name"
K_PERSON_NAME = "person_name"
K_CARD_DATE = "card_date"
K_CARD_NO = "card_no"
K_CARD_AMOUNT = "card_amount"
K_MATCHED_INVOICES = "_matched_invoices"
K_RELATIVE_INVOICE_COUNT = "relative_invoice_count"
K_INVOICE_DETAIL = "invoice_detail"
K_REMARK = "remark"
K_SOURCE_FILE = "source_file"
INVOICE_TYPE_TRAIN = "train"
INVOICE_TYPE_HOTEL = "hotel"
INVOICE_TYPE_GENERAL = "general"
# ------------------------------------------------------------------
# Fixture helpers
# ------------------------------------------------------------------
def _make_invoice(number: str, amount: float, inv_type: str = INVOICE_TYPE_GENERAL) -> dict[str, Any]:
inv: dict[str, Any] = {
K_INVOICE_NUMBER: number,
K_INVOICE_TYPE: inv_type,
K_TOTAL_AMOUNT: str(amount),
}
if inv_type == INVOICE_TYPE_TRAIN:
inv[K_PERSON_NAME] = f"person{number}"
elif inv_type == INVOICE_TYPE_GENERAL:
inv[K_ITEM_NAME] = f"item{number}"
return inv
def _make_card(date: str, amount: float, card_no: str = "6228480000000000") -> dict[str, Any]:
return {
K_CARD_DATE: date,
K_CARD_NO: card_no,
K_CARD_AMOUNT: str(amount),
K_SOURCE_FILE: "card.png",
}
# ------------------------------------------------------------------
# 辅助函数
# ------------------------------------------------------------------
class TestSafeFloat:
"""安全浮点转换"""
def test_normal_string(self):
assert _safe_float("123.45") == 123.45
def test_with_comma(self):
assert _safe_float("1,234.56") == 1234.56
def test_none_returns_default(self):
assert _safe_float(None) == 0.0
def test_empty_string_returns_default(self):
assert _safe_float("") == 0.0
def test_whitespace_returns_default(self):
assert _safe_float(" ") == 0.0
def test_invalid_string_returns_default(self):
assert _safe_float("abc") == 0.0
def test_custom_default(self):
assert _safe_float(None, default=-1.0) == -1.0
def test_int_input(self):
assert _safe_float(42) == 42.0
class TestRelativeTolerance:
"""相对容差计算"""
def test_default_rate(self):
assert _relative_tolerance(1000) == 30.0
def test_custom_rate(self):
assert _relative_tolerance(1000, 0.03) == 30.0
def test_negative_base(self):
assert _relative_tolerance(-200, 0.03) == 6.0
def test_zero_base(self):
assert _relative_tolerance(0, 0.03) == 0.0
class TestBuildInvoiceSummary:
"""发票汇总字符串"""
def test_single_invoice(self):
invoices = [_make_invoice("INV001", 100.0)]
result = _build_invoice_summary(invoices)
assert "INV001" in result
assert "100.0" in result
def test_multiple_invoices(self):
invoices = [
_make_invoice("INV001", 100.0),
_make_invoice("INV002", 200.0),
]
result = _build_invoice_summary(invoices)
assert " | " in result
assert "INV001" in result
assert "INV002" in result
def test_train_uses_person_name(self):
invoices = [_make_invoice("TRAIN001", 500.0, INVOICE_TYPE_TRAIN)]
result = _build_invoice_summary(invoices)
assert "personTRAIN001" in result
assert INVOICE_TYPE_TRAIN in result
def test_hotel_uses_fixed_label(self):
invoices = [_make_invoice("HOTEL001", 800.0, INVOICE_TYPE_HOTEL)]
result = _build_invoice_summary(invoices)
assert "hotel[hotel]" in result
def test_general_uses_project_name(self):
invoices = [_make_invoice("INV001", 100.0, INVOICE_TYPE_GENERAL)]
result = _build_invoice_summary(invoices)
assert "itemINV001" in result
def test_missing_fields_falls_back_to_number(self):
inv = {K_INVOICE_NUMBER: "INV001", K_TOTAL_AMOUNT: "50.0"}
result = _build_invoice_summary([inv])
assert "INV001" in result
def test_empty_invoices_returns_empty(self):
result = _build_invoice_summary([])
assert result == ""
# ------------------------------------------------------------------
# 一对一匹配
# ------------------------------------------------------------------
class TestMatchOneToOne:
"""一对一匹配"""
def test_exact_match(self):
invoices = [_make_invoice("A", 500), _make_invoice("B", 300)]
cards = [_make_card("2026-01-01", 500), _make_card("2026-01-02", 300)]
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_one(invoices, cards, 0.03, assigned, result)
assert 0 in result
assert 1 in result
assert result[0] == [0]
assert result[1] == [1]
def test_within_tolerance(self):
invoices = [_make_invoice("A", 500)]
cards = [_make_card("2026-01-01", 490)]
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_one(invoices, cards, 0.03, assigned, result)
assert 0 in result
def test_outside_tolerance_no_match(self):
invoices = [_make_invoice("A", 500)]
cards = [_make_card("2026-01-01", 400)]
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_one(invoices, cards, 0.03, assigned, result)
assert 0 not in result
def test_sorted_by_amount_desc(self):
invoices = [_make_invoice("A", 100), _make_invoice("B", 500)]
cards = [_make_card("2026-01-01", 100), _make_card("2026-01-02", 500)]
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
invoices.sort(key=lambda i: i["_amount"], reverse=True)
cards.sort(key=lambda c: c["_amount"], reverse=True)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_one(invoices, cards, 0.03, assigned, result)
assert len(result) == 2
# ------------------------------------------------------------------
# 一对多匹配
# ------------------------------------------------------------------
class TestMatchOneToMany:
"""一对多匹配"""
def _prepare(self, invoices, cards):
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
invoices.sort(key=lambda i: i["_amount"], reverse=True)
cards.sort(key=lambda c: c["_amount"], reverse=True)
def test_exact_match_phase(self):
invoices = [
_make_invoice("A", 500),
_make_invoice("B", 300),
_make_invoice("C", 200),
]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
matched_inv = invoices[result[0][0]]
assert matched_inv["_amount"] == 500.0
def test_greedy_match_multiple_invoices(self):
invoices = [
_make_invoice("A", 300),
_make_invoice("B", 200),
_make_invoice("C", 100),
]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
assert len(result[0]) == 2
def test_greedy_with_remaining_invoice(self):
invoices = [
_make_invoice("A", 300),
_make_invoice("B", 200),
_make_invoice("C", 100),
]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
assert len(assigned) == 2
def test_rollback_when_over_shooting(self):
invoices = [
_make_invoice("A", 600),
_make_invoice("B", 500),
]
cards = [_make_card("2026-01-01", 1000)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
assert len(result[0]) == 1
def test_zero_amount_card_skipped(self):
invoices = [_make_invoice("A", 100)]
cards = [_make_card("2026-01-01", 0)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 not in result
def test_zero_amount_invoice_skipped(self):
invoices = [
_make_invoice("A", 100),
_make_invoice("B", 0),
]
cards = [_make_card("2026-01-01", 100)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
matched_inv = invoices[result[0][0]]
assert matched_inv["_amount"] == 100.0
def test_multiple_cards_greedy(self):
invoices = [
_make_invoice("A", 300),
_make_invoice("B", 200),
_make_invoice("C", 150),
_make_invoice("D", 100),
]
cards = [
_make_card("2026-01-01", 500),
_make_card("2026-01-02", 150),
]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
assert 1 in result
# ------------------------------------------------------------------
# _match 路由
# ------------------------------------------------------------------
class TestMatchRouter:
"""_match 根据数量选择策略"""
def _prepare(self, invoices, cards):
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
invoices.sort(key=lambda i: i["_amount"], reverse=True)
cards.sort(key=lambda c: c["_amount"], reverse=True)
def test_equal_count_routes_to_one_to_one(self):
invoices = [_make_invoice("A", 500)]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
result = _match(cards, invoices, 0.03)
assert 0 in result
def test_more_invoices_routes_to_one_to_many(self):
invoices = [_make_invoice("A", 300), _make_invoice("B", 200)]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
result = _match(cards, invoices, 0.03)
assert 0 in result
# ------------------------------------------------------------------
# 记录构建
# ------------------------------------------------------------------
class TestBuildPaymentRecords:
"""构建支付记录列表"""
def _prepare(self, invoices, cards):
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
def test_matched_records_have_card_info(self):
invoices = [_make_invoice("A", 500)]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
card_to_invoices = {0: [0]}
records = _build_payment_records(cards, invoices, card_to_invoices)
assert len(records) == 1
assert records[0][K_CARD_DATE] == "2026-01-01"
assert records[0][K_RELATIVE_INVOICE_COUNT] == "1"
assert "unmatched" not in records[0][K_REMARK]
def test_unmatched_invoices_become_separate_records(self):
invoices = [_make_invoice("A", 500), _make_invoice("B", 300)]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
card_to_invoices = {0: [0]}
records = _build_payment_records(cards, invoices, card_to_invoices)
assert len(records) == 2
unmatched = [r for r in records if r[K_REMARK] == "unmatched"]
assert len(unmatched) == 1
def test_empty_mapping_returns_no_records(self):
invoices = []
cards = []
records = _build_payment_records(cards, invoices, {})
assert records == []
class TestInvoicesToRecords:
"""无刷卡记录时将发票转为独立记录"""
def test_single_invoice(self):
invoices = [_make_invoice("A", 100)]
records = _invoices_to_records(invoices)
assert len(records) == 1
assert records[0][K_RELATIVE_INVOICE_COUNT] == "1"
def test_multiple_invoices(self):
invoices = [_make_invoice("A", 100), _make_invoice("B", 200)]
records = _invoices_to_records(invoices)
assert len(records) == 2
def test_empty_invoices(self):
records = _invoices_to_records([])
assert records == []
# ------------------------------------------------------------------
# 端到端集成
# ------------------------------------------------------------------
class TestMatchInvoicesToCards:
"""match_invoices_to_cards 端到端测试"""
def test_no_cards_returns_invoice_records(self):
invoices = [_make_invoice("A", 100), _make_invoice("B", 200)]
result = match_invoices_to_cards(invoices, cards=None)
assert len(result) == 2
for rec in result:
assert rec[K_CARD_DATE] == ""
assert rec[K_CARD_NO] == ""
def test_one_to_one_end_to_end(self):
cards = [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
}
]
invoices = [_make_invoice("A", 500)]
result = match_invoices_to_cards(invoices, cards=cards)
assert len(result) == 1
assert result[0][K_CARD_DATE] == "2026-01-01"
assert result[0][K_RELATIVE_INVOICE_COUNT] == "1"
assert "_amount" not in result[0]
def test_one_to_many_end_to_end(self):
cards = [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
}
]
invoices = [
_make_invoice("A", 300),
_make_invoice("B", 200),
]
result = match_invoices_to_cards(invoices, cards=cards)
assert len(result) == 1
assert result[0][K_RELATIVE_INVOICE_COUNT] == "2"
def test_unmatched_invoices_included(self):
cards = [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
}
]
invoices = [
_make_invoice("A", 500),
_make_invoice("B", 100),
]
result = match_invoices_to_cards(invoices, cards=cards)
assert len(result) == 2
unmatched = [r for r in result if r[K_REMARK] == "unmatched"]
assert len(unmatched) == 1
def test_internal_fields_cleaned(self):
cards = [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
}
]
invoices = [_make_invoice("A", 500)]
result = match_invoices_to_cards(invoices, cards=cards)
for inv in result[0][K_MATCHED_INVOICES]:
assert "_amount" not in inv
def test_empty_cards_list(self):
invoices = [_make_invoice("A", 100)]
result = match_invoices_to_cards(invoices, cards=[])
assert len(result) == 1
assert result[0][K_CARD_DATE] == ""
def test_multiple_cards(self):
cards = [
{K_CARD_DATE: "2026-01-01", K_CARD_NO: "6228480000000001", K_CARD_AMOUNT: "500.00"},
{K_CARD_DATE: "2026-01-02", K_CARD_NO: "6228480000000002", K_CARD_AMOUNT: "300.00"},
]
invoices = [
_make_invoice("A", 500),
_make_invoice("B", 300),
]
result = match_invoices_to_cards(invoices, cards=cards)
assert len(result) == 2

185
uv.lock generated
View File

@@ -229,8 +229,8 @@ dependencies = [
{ name = "flask" },
{ name = "llama-index" },
{ name = "llama-index-llms-openai-like" },
{ name = "pdfplumber" },
{ name = "playwright" },
{ name = "pymupdf" },
{ name = "pywin32" },
]
@@ -249,8 +249,8 @@ requires-dist = [
{ name = "flask", specifier = ">=3.0" },
{ name = "llama-index", specifier = ">=0.12.0" },
{ name = "llama-index-llms-openai-like", specifier = "==0.7.2" },
{ name = "pdfplumber", specifier = ">=0.10" },
{ name = "playwright", specifier = ">=1.40" },
{ name = "pymupdf", specifier = ">=1.24" },
{ name = "pywin32", specifier = ">=306" },
]
@@ -299,63 +299,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "cfgv"
version = "3.5.0"
@@ -543,59 +486,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" },
]
[[package]]
name = "cryptography"
version = "48.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
]
[[package]]
name = "dataclasses-json"
version = "0.6.7"
@@ -1621,33 +1511,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pdfminer-six"
version = "20251230"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" },
]
[[package]]
name = "pdfplumber"
version = "0.11.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pdfminer-six" },
{ name = "pillow" },
{ name = "pypdfium2" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
@@ -1864,15 +1727,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
@@ -1985,32 +1839,19 @@ wheels = [
]
[[package]]
name = "pypdfium2"
version = "5.9.0"
name = "pymupdf"
version = "1.27.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/98/6b44bf82ddb3c7a3e0249203772aad8981b4491d6227f182685f310faeff/pypdfium2-5.9.0.tar.gz", hash = "sha256:db1274bd27844db6fda17ef1dbcd0026c47d357437058d838e98060c0da9e92e", size = 272455, upload-time = "2026-06-01T15:43:38.08Z" }
sdist = { url = "https://files.pythonhosted.org/packages/22/32/708bedc9dde7b328d45abbc076091769d44f2f24ad151ad92d56a6ec142b/pymupdf-1.27.2.3.tar.gz", hash = "sha256:7a92faa25129e8bbec5e50eeb9214f187665428c31b05c4ef6e36c58c0b1c6d2", size = 85759618, upload-time = "2026-04-24T14:13:14.42Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/d9/59630cb40e5f37e7712e6ea65e9cac633f4195e8b737bb3a46054aa63340/pypdfium2-5.9.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:91914837c4a4285b3e0724a84eca8079363db7475acbcab405933d1807785664", size = 3407817, upload-time = "2026-06-01T15:42:58.426Z" },
{ url = "https://files.pythonhosted.org/packages/0f/3d/e205708835a3730d5242652b6577ac06ad4721e6fcef77cc7c9d3541c686/pypdfium2-5.9.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:90610d352f050b065b703f3a46602a852fce7dd8787300c8c7a472485b644d8f", size = 2862706, upload-time = "2026-06-01T15:43:00.581Z" },
{ url = "https://files.pythonhosted.org/packages/01/47/e843fb895a891438b3f8c6d834fdc9c19183cd60980fc9325429d5c01505/pypdfium2-5.9.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6c4fbe3a7190b329c526358fb2855d797f7b74b5ecfc61d19657ef20bcebc108", size = 3489945, upload-time = "2026-06-01T15:43:02.542Z" },
{ url = "https://files.pythonhosted.org/packages/35/bd/f5e6afd556f97fcaa2bec4cb04669664c166028fc2a059bd65447c852b43/pypdfium2-5.9.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:e93f0cf440169a3e445e6fbd06c803877e7418f3e13254287875cb67f208bb5a", size = 3674186, upload-time = "2026-06-01T15:43:04.496Z" },
{ url = "https://files.pythonhosted.org/packages/6d/4d/5286812216a292d51dfba8e7bff276da198f126508f8c2afa3630bf701dc/pypdfium2-5.9.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d902e03dff5efd51d93cd23d3e55bde53802fa6207bcd0e455239518859a069", size = 3669571, upload-time = "2026-06-01T15:43:06.571Z" },
{ url = "https://files.pythonhosted.org/packages/ac/c8/822db2c89baa13e6cee321d587fcd42df463a1fc2f7520b3f6814768bc71/pypdfium2-5.9.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cf38d7ad3575947b82384869f2ab69ba345eb21d83118d25db3e83f967b0421", size = 3400412, upload-time = "2026-06-01T15:43:08.35Z" },
{ url = "https://files.pythonhosted.org/packages/1a/dd/7d09d8cdc28383df13f739a97ac4f1215a704a97a29506dee2bf89d8a350/pypdfium2-5.9.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77f7479a28b43aa658735e3ce79cfd1fccd5d42db035c21bb4c26e8bd7e280e5", size = 3803326, upload-time = "2026-06-01T15:43:10.054Z" },
{ url = "https://files.pythonhosted.org/packages/99/58/3f4e04ffe1ae62b437de07a96da672091cef62b619d0dc78207c1af442e6/pypdfium2-5.9.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:07e6ba170d577eabf60dbba701d051c64318dd029d38ca5907d83ae1a66fe779", size = 4216890, upload-time = "2026-06-01T15:43:11.701Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f6/2dde4656750c4a6da99e1f070ca09d2b5a9d68186b42e711a1a3e5b1cb32/pypdfium2-5.9.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ce3a3dd23ec0adaa079d8be54565ba2aa2f6060e76a4989cd42dabc163d74ee", size = 3728830, upload-time = "2026-06-01T15:43:13.329Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ca/f2ff8b9200c7dfc5aee85126edc856eb93c7056085da2454a75ef1e4dbc4/pypdfium2-5.9.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae177938f5cf95a275db25a4f8553e2ebd954ecda2f9bc84848ba4b027ce438f", size = 4063322, upload-time = "2026-06-01T15:43:15.158Z" },
{ url = "https://files.pythonhosted.org/packages/64/88/0b587de03c873c28adc59f6ac959de4032d3f3bc946094523b14a192d9c3/pypdfium2-5.9.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffe49edde2ac86f28ca7e58f565255a442f38a7508fff31b79a55f508f25a31e", size = 4039738, upload-time = "2026-06-01T15:43:16.975Z" },
{ url = "https://files.pythonhosted.org/packages/83/4c/fa627f00a954e66465e929077cf43bd012595091fff82758d989486e7bdc/pypdfium2-5.9.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b7b760bc2957ecf73c274af6ed8b168a2dcb328ac0a0f7ed6123cd92f6e7c9c9", size = 4997259, upload-time = "2026-06-01T15:43:18.915Z" },
{ url = "https://files.pythonhosted.org/packages/32/f0/1736d80c5d12d931f74ca6b4213b006ee016ec33c6325fad870234cc240c/pypdfium2-5.9.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7cdc8e5d2f8d82add1e4f70a4fbe5f3b33c17f301ebde38c669fd7f78a7d032c", size = 4537061, upload-time = "2026-06-01T15:43:20.879Z" },
{ url = "https://files.pythonhosted.org/packages/01/00/aa8890dfd385b2e7365034231987029cff15cc7eb4f06e8380da5608738a/pypdfium2-5.9.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:38a058dbd4929acaf0ab9171179eb86c24d8c6655a6836006796105a9f200890", size = 5232786, upload-time = "2026-06-01T15:43:23.73Z" },
{ url = "https://files.pythonhosted.org/packages/65/12/8f45ea698781a0bed96ac4fbde440060790863273943461f0f160a993d52/pypdfium2-5.9.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:1894511a0e862e7ec5679f3a6dc43ac72c4ef92c7ca438357203913e8634a643", size = 5170121, upload-time = "2026-06-01T15:43:25.858Z" },
{ url = "https://files.pythonhosted.org/packages/25/bd/9bb6ba375796e1de1d6c1af8d8303dd1781190346871c81a94d4e09eddfd/pypdfium2-5.9.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:040f5513b808db705d4878f57e2bf0b9dc6e6a0ad8d765c36cf62febf3933b28", size = 4663540, upload-time = "2026-06-01T15:43:27.677Z" },
{ url = "https://files.pythonhosted.org/packages/d2/4a/fd103bac197f22038bf70be1f7507ced7519f1214ea0dae137f37803ab8a/pypdfium2-5.9.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:f4991ae39bcea757552579bba4aebfaedb71c96dd35c2292f957b8ac9132f1ff", size = 5090619, upload-time = "2026-06-01T15:43:29.522Z" },
{ url = "https://files.pythonhosted.org/packages/22/89/9531fa1e6e004fe522cdca0cd945cd6a9d7338e7125e6b0734d632d31fa6/pypdfium2-5.9.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:25ff1a5abd08ff9e87f62e5dac114ea95647c257fbbdbe029be8db71a6d7650b", size = 5050806, upload-time = "2026-06-01T15:43:31.322Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d0/e53c68555ff128b2470e4a468762b320d9c6ae2c914decea3487d923982f/pypdfium2-5.9.0-py3-none-win32.whl", hash = "sha256:b0057dc8c2033584dc3e61afb5f23a135dab52b081695b435e27f9b7b074c605", size = 3670966, upload-time = "2026-06-01T15:43:32.991Z" },
{ url = "https://files.pythonhosted.org/packages/da/0c/22e5fc035ad1594b44f265bc0a59ae34d377bc2ea74a92793e7a674bf96d/pypdfium2-5.9.0-py3-none-win_amd64.whl", hash = "sha256:06508c33b9772cf3878e48364c6e14c70cefc18a3abd6983ac9f338da9305275", size = 3800959, upload-time = "2026-06-01T15:43:34.536Z" },
{ url = "https://files.pythonhosted.org/packages/11/e3/cf1711add7add22a17f7c7633cd795edc92f17ab7bdf1930493ae0f56680/pypdfium2-5.9.0-py3-none-win_arm64.whl", hash = "sha256:565ddfc98795fd2f6054b544ee9791d7b9032f9cf77a57891b6e501fafd0ef3f", size = 3585718, upload-time = "2026-06-01T15:43:36.521Z" },
{ url = "https://files.pythonhosted.org/packages/dc/09/ddbdfa7ee91fbabd6f63d7d744884cbdfe3e7ff9b8604749fb38bddf5c5d/pymupdf-1.27.2.3-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc1bc3cae6e9e150b0dbb0a9221bdfd411d65f0db2fe359eaa22467d7cc2a05f", size = 24002636, upload-time = "2026-04-24T14:09:17.459Z" },
{ url = "https://files.pythonhosted.org/packages/01/89/3f8edd6c4f50ca370e2a2f2a3011face36f3760728ffe76dffec91c0fca0/pymupdf-1.27.2.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:660d93cb6da5bbddf11d3982ae27745dd3a9902d9f24cdb69adab83962294b5a", size = 23278238, upload-time = "2026-04-24T14:09:32.882Z" },
{ url = "https://files.pythonhosted.org/packages/c3/26/b7e5a70eb83bd189f8b5df87ec442746b992f2f632662839b288170d357d/pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1dd460a3ae4597a755f00a3bd9771f5ebf1531dc111f6a36bf05dd00a6b84425", size = 24333923, upload-time = "2026-04-24T14:09:47.341Z" },
{ url = "https://files.pythonhosted.org/packages/e4/a0/aa1ee2240f29481a04a827c313333b4ecd8a14d6ac3e15d3f41a30574781/pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:857842b4888827bd6155a1131341b2822a7ebe9a8c15a975fd7d490d7a64a30c", size = 24963198, upload-time = "2026-04-24T14:10:07.408Z" },
{ url = "https://files.pythonhosted.org/packages/69/49/4f742451f980840829fc00ba158bebb25d389c846d8f4f8c65936ee55de8/pymupdf-1.27.2.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:580983849c64a08d08344ca3d1580e87c01f046a8392421797bc850efd72a5b6", size = 25184609, upload-time = "2026-04-24T14:10:22.911Z" },
{ url = "https://files.pythonhosted.org/packages/f6/3f/3853d6608f394faf6eec2bd4e8ea9f6a00beea329b071abdb29f4164cc3d/pymupdf-1.27.2.3-cp310-abi3-win32.whl", hash = "sha256:a5c1088a87189891a4946ab314a14b7934ac4c5b6077f7e74ebee956f8906d0e", size = 18019286, upload-time = "2026-04-24T14:10:34.239Z" },
{ url = "https://files.pythonhosted.org/packages/44/47/5fb10fe73f96b31253a41647c362ea9e0380920bddf16028414a051247fc/pymupdf-1.27.2.3-cp310-abi3-win_amd64.whl", hash = "sha256:d20f68ef15195e073071dbc4ae7455257c7889af7584e39df490c0a92728526e", size = 19249102, upload-time = "2026-04-24T14:10:46.72Z" },
{ url = "https://files.pythonhosted.org/packages/53/a4/b9e91aac82293f9c954654c85581ee8212b5b05efadc534b581141241e6f/pymupdf-1.27.2.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:77691604c5d1d0233827139bbcdea61fd57879c84712b8e49b1f45520f7ab9c2", size = 25000393, upload-time = "2026-04-24T14:11:01.669Z" },
]
[[package]]