完成差旅发票录入流程
19
.agents/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# .agents — 项目内部维护目录
|
||||||
|
|
||||||
|
本目录存放维护人员与自动化代理相关资料,不属于对外公开的用户文档。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
| 子目录 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `docs/` | 维护文档:规范、经验总结、实施方案、操作指南 |
|
||||||
|
| `skills/` | Cursor Agent 技能:定义自动化工作流和代码检查流程 |
|
||||||
|
|
||||||
|
## 文档边界
|
||||||
|
|
||||||
|
- 面向开源用户、外部贡献者的公开文档统一放置在项目根目录 `docs/` 下。
|
||||||
|
- 维护规范、实施方案、经验总结、拉取请求佐证材料与各类内部记录资料,均统一放置在本目录下。
|
||||||
36
.agents/docs/good-experience/2026-06-11-CSV-BOM-列名校验失败.md
Normal 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
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# .agents/skills — Cursor Agent 技能目录
|
||||||
|
|
||||||
|
存放 Cursor Agent 可调用的自动化技能定义。
|
||||||
|
|
||||||
|
## 技能清单
|
||||||
|
|
||||||
|
| 技能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `pre-commit-check/` | 提交前代码质量检查:运行 ruff lint/format、mypy 类型检查、deptry 依赖审计,自动修复可修复问题 |
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
Agent 在用户请求提交代码或检查代码质量时自动触发对应技能,无需手动调用。
|
||||||
68
.agents/skills/pre-commit-check/SKILL.md
Normal 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
|
||||||
13
.env.example
Normal 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
@@ -9,3 +9,5 @@ __pycache__/
|
|||||||
logs/
|
logs/
|
||||||
.vscode/
|
.vscode/
|
||||||
uploads/
|
uploads/
|
||||||
|
.env
|
||||||
|
scripts/data/
|
||||||
@@ -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(流编号 6),stderr(流编号 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
@@ -1,6 +1,6 @@
|
|||||||
# 财务报销自动化
|
# 财务报销自动化
|
||||||
|
|
||||||
自动从 PDF 发票提取信息,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
|
自动从 PDF 发票或图片中提取信息,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
|
||||||
|
|
||||||
**支持发票类型区分**:系统自动识别高铁票、酒店住宿等差旅发票与普通发票。差旅发票不生成易耗品出库单,走差旅报销流程;普通发票生成出库单,走普通报销流程。
|
**支持发票类型区分**:系统自动识别高铁票、酒店住宿等差旅发票与普通发票。差旅发票不生成易耗品出库单,走差旅报销流程;普通发票生成出库单,走普通报销流程。
|
||||||
|
|
||||||
@@ -11,8 +11,9 @@
|
|||||||
├── uv.lock # 依赖锁定文件
|
├── uv.lock # 依赖锁定文件
|
||||||
├── Makefile # 任务脚本(跨平台)
|
├── Makefile # 任务脚本(跨平台)
|
||||||
├── tasks.py # 任务脚本(Windows 兼容)
|
├── tasks.py # 任务脚本(Windows 兼容)
|
||||||
├── config.json # 配置文件(登录凭据、默认值等)
|
├── .pre-commit-config.yaml # pre-commit 钩子配置
|
||||||
├── config.example.json # 配置示例
|
├── .env.example # 环境变量示例(SSO 地址、LLM 配置等)
|
||||||
|
├── config.example.json # 用户配置示例
|
||||||
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板
|
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── __init__.py # 包初始化 / 日志器
|
│ ├── __init__.py # 包初始化 / 日志器
|
||||||
@@ -22,8 +23,9 @@
|
|||||||
│ ├── main.py # CLI 入口
|
│ ├── main.py # CLI 入口
|
||||||
│ ├── doc/ # 文档处理模块
|
│ ├── doc/ # 文档处理模块
|
||||||
│ │ ├── extractor.py # 编排入口:串联 PDF 读取 → LLM 提取 → 分类
|
│ │ ├── extractor.py # 编排入口:串联 PDF 读取 → LLM 提取 → 分类
|
||||||
│ │ ├── pdf.py # PDF 文件发现与文本提取
|
│ │ ├── pdf.py # PDF 图片渲染(PyMuPDF,供多模态 LLM 使用)
|
||||||
│ │ ├── llm_extractor.py # LLM 信息提取
|
│ │ ├── llm_extractor.py # LLM 信息提取
|
||||||
|
│ │ ├── matcher.py # 数据匹配与校验
|
||||||
│ │ ├── invoice.py # 发票类型常量、分类逻辑、CSV 读写工具
|
│ │ ├── invoice.py # 发票类型常量、分类逻辑、CSV 读写工具
|
||||||
│ │ ├── fill_consumable_doc.py # 将 CSV 填入易耗品出库单(Word COM)
|
│ │ ├── fill_consumable_doc.py # 将 CSV 填入易耗品出库单(Word COM)
|
||||||
│ │ ├── prompt.py # LLM 提示词模板
|
│ │ ├── prompt.py # LLM 提示词模板
|
||||||
@@ -33,19 +35,24 @@
|
|||||||
│ ├── templates/
|
│ ├── templates/
|
||||||
│ │ ├── index.html # PC 端主页
|
│ │ ├── index.html # PC 端主页
|
||||||
│ │ └── mobile_upload.html # 移动端扫码上传
|
│ │ └── mobile_upload.html # 移动端扫码上传
|
||||||
│ ├── static/ # 前端 CSS / JS
|
│ ├── static/
|
||||||
|
│ │ ├── css/ # 样式文件
|
||||||
|
│ │ └── js/ # 前端脚本
|
||||||
│ └── uploads/ # 按会话隔离的上传与产物目录
|
│ └── uploads/ # 按会话隔离的上传与产物目录
|
||||||
|
├── scripts/ # CLI 数据目录
|
||||||
|
│ ├── data/ # 发票源文件、config.json 与 .invoice_cache 缓存
|
||||||
|
│ └── test_*.py # 测试脚本
|
||||||
├── tests/ # 测试目录
|
├── tests/ # 测试目录
|
||||||
├── *.pdf # 发票 PDF(CLI 模式,放在项目根目录)
|
├── docs/ # 用户文档(API 说明、操作指南等)
|
||||||
├── invoice_summary.csv # 发票汇总表(CLI 产物)
|
├── images/ # 浏览器调试截图
|
||||||
└── images/ # 浏览器调试截图
|
└── *.pdf / *.jpg / *.png # 发票 PDF 或图片(CLI 模式,放在 scripts/data/)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 数据流
|
## 数据流
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
PDF[PDF 发票] --> Extract[extractor 提取]
|
PDF[PDF 发票 / 图片] --> Extract[extractor 提取]
|
||||||
Extract --> Classify{发票类型分类}
|
Extract --> Classify{发票类型分类}
|
||||||
Classify -->|差旅发票| Travel[高铁票 / 酒店住宿]
|
Classify -->|差旅发票| Travel[高铁票 / 酒店住宿]
|
||||||
Classify -->|普通发票| General[普通发票]
|
Classify -->|普通发票| General[普通发票]
|
||||||
@@ -54,7 +61,7 @@ flowchart TB
|
|||||||
CSV -->|仅普通发票| Fill[fill_consumable_doc]
|
CSV -->|仅普通发票| Fill[fill_consumable_doc]
|
||||||
Fill --> Doc[易耗品、出库单.doc]
|
Fill --> Doc[易耗品、出库单.doc]
|
||||||
CSV --> Bot[bot 浏览器自动化]
|
CSV --> Bot[bot 浏览器自动化]
|
||||||
Bot -->|差旅模式| Submit_T[差旅报销填报<br/>TODO]
|
Bot -->|差旅模式| Submit_T[差旅报销填报]
|
||||||
Bot -->|普通模式| Submit_G[普通报销填报]
|
Bot -->|普通模式| Submit_G[普通报销填报]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -79,18 +86,19 @@ python tasks.py install
|
|||||||
|
|
||||||
| 依赖 | 用途 |
|
| 依赖 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| pdfplumber | PDF 发票文本提取 |
|
| PyMuPDF | PDF 图片渲染(供多模态 LLM 使用) |
|
||||||
|
| llama-index | LLM 信息提取(发票识别、差旅信息提取) |
|
||||||
| playwright | 财务系统浏览器自动化 |
|
| playwright | 财务系统浏览器自动化 |
|
||||||
| flask | Web 服务 |
|
| flask | Web 服务 |
|
||||||
| pywin32 | 填写 Word 出库单(`fill_consumable_doc`) |
|
| pywin32 | 填写 Word 出库单(`fill_consumable_doc`) |
|
||||||
|
|
||||||
### 2. 准备数据(CLI 模式)
|
### 2. 准备数据(CLI 模式)
|
||||||
|
|
||||||
将发票 PDF 放在项目根目录下。
|
将发票 PDF 或图片(`.jpg`、`.png`、`.webp`、`.bmp`)放在 `scripts/data/` 目录下。
|
||||||
|
|
||||||
### 3. 配置
|
### 3. 配置
|
||||||
|
|
||||||
编辑 `config.json`:
|
编辑 `scripts/config.json`(参考 `config.example.json`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -99,7 +107,7 @@ python tasks.py install
|
|||||||
"default_name": "默认报销人姓名",
|
"default_name": "默认报销人姓名",
|
||||||
"default_card_no": "默认公务卡号",
|
"default_card_no": "默认公务卡号",
|
||||||
"default_person_id": "默认人员编号",
|
"default_person_id": "默认人员编号",
|
||||||
"consumable_storage": "躬行楼 C205"
|
"consumable_storage": "物料存储地"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -109,8 +117,20 @@ python tasks.py install
|
|||||||
| `default_name` | 默认报销人姓名 |
|
| `default_name` | 默认报销人姓名 |
|
||||||
| `default_card_no` | 默认公务卡号 |
|
| `default_card_no` | 默认公务卡号 |
|
||||||
| `default_person_id` | 默认人员编号(工号) |
|
| `default_person_id` | 默认人员编号(工号) |
|
||||||
| `consumable_storage` | 出库单「存放地点」列默认值 |
|
| `consumable_storage` | 出库单「存放地点」列默认值(默认: `躬行楼 C205`) |
|
||||||
| `sso_login_url` 等 | 系统 URL,一般无需修改 |
|
|
||||||
|
**服务端配置**(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)
|
### 4. 运行(CLI)
|
||||||
|
|
||||||
@@ -135,39 +155,62 @@ uv run python src/main.py -u 工号 -p 密码
|
|||||||
```bash
|
```bash
|
||||||
uv run python -m src.doc.fill_consumable_doc
|
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 --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 备份
|
uv run python -m src.doc.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
|
||||||
```
|
```
|
||||||
|
|
||||||
填写规则概要:
|
填写规则概要:
|
||||||
|
|
||||||
- 表头「日期」使用**填写当天**的日期(非发票开票日期)
|
- 表头「日期」使用**填写当天**的日期(非发票开票日期)
|
||||||
- 从 `规格型号` 解析品名、规格、单位、数量、单价;`价税合计` 写入金额列
|
- 从 `spec_model` 解析品名、规格、单位、数量、单价;`card_amount` 写入金额列
|
||||||
- 单价/金额保留两位小数;表格内统一为 **宋体五号(10.5 磅)**
|
- 单价/金额保留两位小数;表格内统一为 **宋体五号(10.5 磅)**
|
||||||
- 存放地点取自 `consumable_storage`;购货人/领用人签字、备注保持空白
|
- 存放地点取自 `consumable_storage`(默认: `躬行楼 C205`);购货人/领用人签字、备注保持空白
|
||||||
|
|
||||||
## 执行步骤说明
|
## 执行步骤说明
|
||||||
|
|
||||||
| 步骤 | 命令 | 说明 |
|
| 步骤 | 命令 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 发票提取 | `--step invoice` | 扫描根目录 PDF,生成 `invoice_summary.csv` |
|
| 发票提取 | `--step invoice` | 扫描 `scripts/data/` 目录的 PDF 和图片,生成 `invoice_summary.csv` |
|
||||||
| 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 |
|
| 浏览器填报 | `--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 字段说明
|
||||||
|
|
||||||
|
发票级别 CSV(`invoice_summary.csv`)使用英文列名:
|
||||||
|
|
||||||
| 列名 | 说明 |
|
| 列名 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 序号 | 行号 |
|
| `index` | 行号 |
|
||||||
| 发票类型 | 自动识别:`高铁票` / `酒店住宿` / `普通发票` |
|
| `invoice_type` | 发票类型:`train` / `hotel` / `general` |
|
||||||
| 发票号码 | 电子发票号码 |
|
| `invoice_number` | 电子发票号码 |
|
||||||
| 开票日期 | 开票日期 |
|
| `invoice_date` | 开票日期 |
|
||||||
| 项目名称 / 规格型号 | 货物或应税劳务信息(差旅发票为出发站→到达站) |
|
| `item_name` | 货物或应税劳务名称 |
|
||||||
| 价税合计 | 发票含税金额 |
|
| `spec_model` | 规格型号(差旅发票为出发站→到达站) |
|
||||||
| 销售方名称 | 销方名称 |
|
| `total_amount` | 发票含税金额 |
|
||||||
| 出发站 / 到达站 / 车次 / 乘车日期 / 座位等级 | 高铁票专用字段 |
|
| `seller_name` | 销方名称 |
|
||||||
| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | 可手工或 Web 端编辑补充 |
|
| `departure` / `arrival` | 出发站 / 到达站(高铁票专用) |
|
||||||
| 备注 / 工号 | 可手工或 Web 端编辑补充 |
|
| `train_no` / `ride_date` / `seat_class` | 车次 / 乘车日期 / 座位等级(高铁票专用) |
|
||||||
|
| `person_name` | 人员姓名 |
|
||||||
|
| `card_date` / `card_no` / `card_amount` | 刷卡日期 / 公务卡号 / 刷卡金额 |
|
||||||
|
| `remark` | 备注 |
|
||||||
|
| `person_id` | 工号 |
|
||||||
|
|
||||||
## Web 服务
|
## Web 服务
|
||||||
|
|
||||||
@@ -181,7 +224,7 @@ uv run python src/web/app.py
|
|||||||
|
|
||||||
### 推荐使用流程
|
### 推荐使用流程
|
||||||
|
|
||||||
1. 上传 PDF(或上传已有 CSV)
|
1. 上传 PDF 或图片(或上传已有 CSV)
|
||||||
2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充
|
2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充
|
||||||
3. 点击 **开始处理** — 完成发票提取、生成 CSV,系统自动识别发票类型并分类统计
|
3. 点击 **开始处理** — 完成发票提取、生成 CSV,系统自动识别发票类型并分类统计
|
||||||
4. **普通发票**:自动生成 **易耗品、出库单.doc**,可下载
|
4. **普通发票**:自动生成 **易耗品、出库单.doc**,可下载
|
||||||
@@ -189,30 +232,24 @@ uv run python src/web/app.py
|
|||||||
6. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
|
6. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
|
||||||
7. 确认无误后点击 **提交到财务系统**
|
7. 确认无误后点击 **提交到财务系统**
|
||||||
|
|
||||||
### 处理模式
|
### 处理流程
|
||||||
|
|
||||||
| 模式 | 入口 | 说明 |
|
上传 PDF 或图片 → LLM 识别文档类型 → 结构化提取 → 分类处理
|
||||||
|------|------|------|
|
|
||||||
| **PDF 模式** | 上传 PDF | 提取发票 → 生成 CSV + 自动分类 → 普通发票生成出库单 |
|
|
||||||
| **CSV 快捷模式** | 仅上传 CSV | 跳过提取,读取 CSV 中的发票类型,按需生成出库单 |
|
|
||||||
|
|
||||||
### 发票类型区分
|
系统通过 LLM 多模态识别自动判断每张文档的类型,无需手动指定:
|
||||||
|
|
||||||
系统自动识别以下发票类型并按类型分流:
|
| 文档类型 | 处理方式 | 状态 |
|
||||||
|
|----------|----------|------|
|
||||||
| 发票类型 | 识别依据 | 出库单 | 填报模式 |
|
| 发票(高铁票/酒店住宿/普通发票) | 提取发票信息 → 金额匹配 → 分类 | 已实现 |
|
||||||
|----------|----------|--------|----------|
|
| 支付记录(刷卡截图) | 提取刷卡信息 → 与发票匹配 | 已实现 |
|
||||||
| **高铁票** | 含"电子客票"、"中国铁路"、"12306"等关键词 | 不生成 | 差旅报销(TODO) |
|
| 出差事前申请单 | 提取出差事由、地点、时间 | 已实现 |
|
||||||
| **酒店住宿** | 含"住宿费"、"餐饮服务"、"租赁服务"等关键词 | 不生成 | 差旅报销(TODO) |
|
| 飞机票 | 同高铁票处理流程 | 计划中 |
|
||||||
| **普通发票** | 其他所有发票 | 自动生成 | 普通报销(已实现) |
|
|
||||||
|
|
||||||
当同一批次同时包含差旅发票和普通发票时,系统会为普通发票生成出库单,并在填报时分别处理。
|
|
||||||
|
|
||||||
### 功能一览
|
### 功能一览
|
||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 发票提取 | 上传 PDF 后自动完成 |
|
| 发票提取 | 上传 PDF 或图片后自动完成 |
|
||||||
| 发票类型自动分类 | 高铁票/酒店住宿/普通发票,自动分流处理 |
|
| 发票类型自动分类 | 高铁票/酒店住宿/普通发票,自动分流处理 |
|
||||||
| 易耗品出库单 | 仅普通发票自动生成 Word,差旅发票跳过 |
|
| 易耗品出库单 | 仅普通发票自动生成 Word,差旅发票跳过 |
|
||||||
| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 |
|
| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 |
|
||||||
@@ -221,9 +258,26 @@ uv run python src/web/app.py
|
|||||||
| 配置上传 | 支持上传 `config.json` 填充表单 |
|
| 配置上传 | 支持上传 `config.json` 填充表单 |
|
||||||
| 手机扫码上传 | 二维码打开移动端页面,拍照上传,PC 端轮询同步 |
|
| 手机扫码上传 | 二维码打开移动端页面,拍照上传,PC 端轮询同步 |
|
||||||
|
|
||||||
> Web 端浏览器填报以无头模式运行。未上传 PDF 时,填报阶段会跳过附件上传。
|
> Web 端浏览器填报以无头模式运行。未上传 PDF 或图片时,填报阶段会跳过附件上传。
|
||||||
> 出库单生成需要 **Windows + Word + pywin32**;若失败,页面会显示具体原因,CSV 等其它产物仍可正常使用。
|
> 出库单生成需要 **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)。
|
接口说明见 [API.md](./API.md)。
|
||||||
|
|
||||||
## 开发任务
|
## 开发任务
|
||||||
@@ -254,5 +308,5 @@ uv run python src/web/app.py
|
|||||||
- 浏览器填报时会打开或使用 Chromium,请勿手动干扰自动化流程
|
- 浏览器填报时会打开或使用 Chromium,请勿手动干扰自动化流程
|
||||||
- 调试截图保存在 `images/` 目录
|
- 调试截图保存在 `images/` 目录
|
||||||
- 项目根目录需保留 `易耗品、出库单.doc` 模板;Web 每次从模板复制到会话目录再填写,不修改原模板
|
- 项目根目录需保留 `易耗品、出库单.doc` 模板;Web 每次从模板复制到会话目录再填写,不修改原模板
|
||||||
- `config.json` 含敏感信息,请勿提交到公开仓库
|
- `scripts/config.json` 含敏感信息,请勿提交到公开仓库
|
||||||
- **发票类型区分**:差旅发票(高铁票/酒店住宿)不会生成易耗品出库单,当前差旅报销填报流程仍在开发中(TODO)
|
- **发票类型区分**:差旅发票(高铁票/酒店住宿)不会生成易耗品出库单,差旅报销填报流程已完整实现(含差旅信息提取、明细录入、支付方式、补助清单、附件上传)
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
{
|
{
|
||||||
"username": "你的工号",
|
"username": "你的工号",
|
||||||
"password": "你的密码",
|
"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_name": "默认报销人姓名",
|
||||||
"default_card_no": "默认公务卡号",
|
"default_card_no": "默认公务卡号",
|
||||||
"default_person_id": "默认人员编号",
|
"default_person_id": "默认人员编号",
|
||||||
"consumable_storage": "躬行楼 C205",
|
"consumable_storage": "躬行楼 C205"
|
||||||
"llm": {
|
|
||||||
"model": "你的模型名称",
|
|
||||||
"api_base": "你的API地址",
|
|
||||||
"api_key": "你的API密钥"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
17
config.json
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
54
docs/API.md
@@ -14,16 +14,15 @@ last_reviewed: 2026-06-09
|
|||||||
| 1 | GET | `/` | PC 端主页 |
|
| 1 | GET | `/` | PC 端主页 |
|
||||||
| 2 | POST | `/api/session` | 创建会话 |
|
| 2 | POST | `/api/session` | 创建会话 |
|
||||||
| 3 | POST | `/api/upload/<session_id>` | 上传文件(PDF/图片) |
|
| 3 | POST | `/api/upload/<session_id>` | 上传文件(PDF/图片) |
|
||||||
| 4 | POST | `/api/upload-csv/<session_id>` | 上传 CSV 发票数据 |
|
| 4 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
|
||||||
| 5 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
|
| 5 | POST | `/api/process/<session_id>` | 启动处理(提取+LLM识别+出库单) |
|
||||||
| 6 | POST | `/api/process/<session_id>` | 启动处理(提取+LLM识别+出库单) |
|
| 6 | GET | `/api/logs/<session_id>` | SSE 日志流 |
|
||||||
| 7 | GET | `/api/logs/<session_id>` | SSE 日志流 |
|
| 7 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
|
||||||
| 8 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
|
| 8 | GET | `/api/data/<session_id>` | 获取发票数据(JSON) |
|
||||||
| 9 | GET | `/api/data/<session_id>` | 获取发票数据(JSON) |
|
| 9 | POST | `/api/save/<session_id>` | 保存编辑后的发票数据 |
|
||||||
| 10 | POST | `/api/save/<session_id>` | 保存编辑后的发票数据 |
|
| 10 | POST | `/api/submit-financial/<session_id>` | 提交到财务系统 |
|
||||||
| 11 | POST | `/api/submit-financial/<session_id>` | 提交到财务系统 |
|
| 11 | GET | `/mobile/<session_id>` | 移动端上传页面 |
|
||||||
| 12 | GET | `/mobile/<session_id>` | 移动端上传页面 |
|
| 12 | POST | `/api/mobile-upload/<session_id>` | 移动端上传图片 |
|
||||||
| 13 | POST | `/api/mobile-upload/<session_id>` | 移动端上传图片 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,28 +77,7 @@ HTTP `400`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. 上传 CSV 发票数据
|
### 3. 列出会话文件
|
||||||
|
|
||||||
```
|
|
||||||
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. 列出会话文件
|
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/files/<session_id>
|
GET /api/files/<session_id>
|
||||||
@@ -129,7 +107,6 @@ Content-Type: application/json
|
|||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| mode | string | 否 | `"pdf"` / `"csv"` / `"auto"`(默认 `auto`) |
|
|
||||||
| username | string | 否 | 财务系统工号 |
|
| username | string | 否 | 财务系统工号 |
|
||||||
| password | string | 否 | 登录密码 |
|
| password | string | 否 | 登录密码 |
|
||||||
| default_name | string | 否 | 默认报销人姓名 |
|
| default_name | string | 否 | 默认报销人姓名 |
|
||||||
@@ -137,15 +114,7 @@ Content-Type: application/json
|
|||||||
| default_person_id | string | 否 | 默认人员编号 |
|
| default_person_id | string | 否 | 默认人员编号 |
|
||||||
| consumable_storage | string | 否 | 出库单存放地点;未填则用 `config.json` 中的值 |
|
| 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`
|
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv`
|
||||||
2. 对支付截图多模态 LLM 识别,回填刷卡字段
|
2. 对支付截图多模态 LLM 识别,回填刷卡字段
|
||||||
@@ -270,7 +239,6 @@ GET /api/download/<session_id>/<filename>
|
|||||||
|--------|------|
|
|--------|------|
|
||||||
| `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) |
|
| `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) |
|
||||||
| `易耗品、出库单.doc` | 自动填写的出库单 |
|
| `易耗品、出库单.doc` | 自动填写的出库单 |
|
||||||
| 用户上传的 CSV 名 | CSV 快捷模式下的原始文件 |
|
|
||||||
|
|
||||||
**错误:**
|
**错误:**
|
||||||
|
|
||||||
|
|||||||
18
docs/README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# docs — 公开文档目录
|
||||||
|
|
||||||
|
本目录存放面向项目用户和外部贡献者的公开文档及说明文件。
|
||||||
|
|
||||||
|
## 文档清单
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `API.md` | Web API 完整接口文档:路由、请求/响应格式、SSE 日志流、错误码、端到端流程 |
|
||||||
|
| `报销操作指南.md` | 面向最终用户的操作步骤说明 |
|
||||||
|
|
||||||
|
## 文档边界
|
||||||
|
|
||||||
|
维护规范、实施方案、经验总结等内部资料统一放置在 `.agents/` 目录下,不混入本目录。
|
||||||
@@ -65,37 +65,11 @@ uv run python src/main.py -u 202407021 -p "your_password"
|
|||||||
|
|
||||||
### 3.1 发票数据 CSV
|
### 3.1 发票数据 CSV
|
||||||
|
|
||||||
系统支持两种数据输入方式:
|
|
||||||
|
|
||||||
**方式 A:PDF 发票 + 支付截图(自动提取)**
|
|
||||||
|
|
||||||
上传 PDF 发票文件和支付截图,系统自动完成:
|
上传 PDF 发票文件和支付截图,系统自动完成:
|
||||||
1. 从 PDF 提取发票信息
|
1. 从 PDF 提取发票信息
|
||||||
2. 多模态 LLM 识别支付截图中的刷卡信息
|
2. 多模态 LLM 识别支付截图中的刷卡信息
|
||||||
3. 生成 `invoice_summary.csv`
|
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 发票类型分类
|
### 3.2 发票类型分类
|
||||||
|
|
||||||
系统自动将发票分为两类,影响后续处理流程:
|
系统自动将发票分为两类,影响后续处理流程:
|
||||||
@@ -125,7 +99,6 @@ uv run python src/web/app.py
|
|||||||
1. **发票 PDF**:点击或拖拽上传 PDF 文件(支持多选)
|
1. **发票 PDF**:点击或拖拽上传 PDF 文件(支持多选)
|
||||||
2. **支付截图**:点击或拖拽上传图片文件(支持多选)
|
2. **支付截图**:点击或拖拽上传图片文件(支持多选)
|
||||||
3. **手机扫码上传**:扫描页面二维码,通过手机拍照上传支付截图
|
3. **手机扫码上传**:扫描页面二维码,通过手机拍照上传支付截图
|
||||||
4. **CSV 快捷上传**:已有发票数据 CSV 可直接上传,跳过提取和 LLM 识别
|
|
||||||
|
|
||||||
### 4.3 配置信息
|
### 4.3 配置信息
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 68 KiB |
BIN
images/debug_item_total_error.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 61 KiB |
BIN
images/debug_step5_error.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
images/debug_subsidy_done.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
images/debug_subsidy_error.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/debug_travel_attachment_done.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
images/debug_travel_basic_done.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
@@ -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,,,,,,,,,,,,
|
|
||||||
|
1536
pipeline.log
@@ -6,7 +6,7 @@ requires-python = ">=3.12"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=3.0",
|
"flask>=3.0",
|
||||||
"playwright>=1.40",
|
"playwright>=1.40",
|
||||||
"pdfplumber>=0.10",
|
"PyMuPDF>=1.24",
|
||||||
"pywin32>=306",
|
"pywin32>=306",
|
||||||
"llama-index>=0.12.0",
|
"llama-index>=0.12.0",
|
||||||
"llama-index-llms-openai-like==0.7.2",
|
"llama-index-llms-openai-like==0.7.2",
|
||||||
|
|||||||
21
scripts/README.md
Normal 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
|
||||||
|
```
|
||||||
81
scripts/test_multimodal.py
Normal 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()
|
||||||
54
scripts/test_travel_info.py
Normal 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
@@ -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) |
|
||||||
|
|
||||||
|
**注意**:差旅发票和普通发票不支持混报,混合时会报错。
|
||||||
@@ -24,7 +24,10 @@ def get_logger(name: str) -> logging.Logger:
|
|||||||
日志同时输出到终端和 logs/<日期>.log
|
日志同时输出到终端和 logs/<日期>.log
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger(name)
|
logger = logging.getLogger(name)
|
||||||
if not logger.handlers:
|
|
||||||
|
# 使用专属标记判断是否已初始化标准 handler(stream + file),
|
||||||
|
# 避免被 _SSELogHandler 等外部 handler 干扰
|
||||||
|
if not getattr(logger, "_standard_handlers_initialized", False):
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT)
|
formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT)
|
||||||
|
|
||||||
@@ -39,4 +42,6 @@ def get_logger(name: str) -> logging.Logger:
|
|||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
logger._standard_handlers_initialized = True # type: ignore[attr-defined]
|
||||||
|
|
||||||
return logger
|
return logger
|
||||||
|
|||||||
426
src/bot.py
@@ -4,11 +4,10 @@
|
|||||||
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
|
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
|
||||||
|
|
||||||
对外接口:
|
对外接口:
|
||||||
load_invoice_data(csv_path, config) -> list[dict] 从 CSV 加载并补全默认值
|
|
||||||
run_bot(config, invoices) 启动浏览器并执行填报流程
|
run_bot(config, invoices) 启动浏览器并执行填报流程
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import csv
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -32,59 +31,22 @@ def _format_date(date_str: str) -> str:
|
|||||||
return date_str
|
return date_str
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(value: str | None, default: float = 0.0) -> float:
|
def _classify_invoice_batch(
|
||||||
"""安全转换为浮点数,空值或转换失败时返回默认值"""
|
invoices: list[dict[str, str]],
|
||||||
if value is None or str(value).strip() == "":
|
) -> dict[str, list[dict[str, str]]]:
|
||||||
return default
|
"""按发票类型分组"""
|
||||||
try:
|
travel: list[dict[str, str]] = []
|
||||||
return float(value)
|
general: list[dict[str, str]] = []
|
||||||
except (ValueError, TypeError):
|
application: list[dict[str, str]] = []
|
||||||
return default
|
for inv in invoices:
|
||||||
|
inv_type = inv.get("invoice_type", "general")
|
||||||
|
if inv_type == "application":
|
||||||
# ------------------------------------------------------------------
|
application.append(inv)
|
||||||
# CSV 数据加载
|
elif inv_type in ("train", "hotel"):
|
||||||
# ------------------------------------------------------------------
|
travel.append(inv)
|
||||||
|
else:
|
||||||
|
general.append(inv)
|
||||||
def load_invoice_data(csv_path: str, config: dict[str, str | Path]) -> list[dict[str, str | float | Any | Path]]:
|
return {"travel": travel, "general": general, "application": application}
|
||||||
"""从 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
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -95,7 +57,7 @@ def load_invoice_data(csv_path: str, config: dict[str, str | Path]) -> list[dict
|
|||||||
class ReimburseBot:
|
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.config = config
|
||||||
self.headless = headless
|
self.headless = headless
|
||||||
self.work_dir: Path | None = None
|
self.work_dir: Path | None = None
|
||||||
@@ -111,7 +73,7 @@ class ReimburseBot:
|
|||||||
def launch(self) -> None:
|
def launch(self) -> None:
|
||||||
"""启动浏览器"""
|
"""启动浏览器"""
|
||||||
self.browser = self.pw.chromium.launch(headless=self.headless)
|
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 = self.context.new_page()
|
||||||
self.page.set_default_timeout(30000)
|
self.page.set_default_timeout(30000)
|
||||||
|
|
||||||
@@ -156,7 +118,7 @@ class ReimburseBot:
|
|||||||
self._screenshot("portal_timeout")
|
self._screenshot("portal_timeout")
|
||||||
raise TimeoutError("登录超时,未跳转到信息门户")
|
raise TimeoutError("登录超时,未跳转到信息门户")
|
||||||
|
|
||||||
def navigate_to_reimburse(self) -> None:
|
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
|
||||||
"""从统一信息平台进入报销系统"""
|
"""从统一信息平台进入报销系统"""
|
||||||
log.info("进入报销系统...")
|
log.info("进入报销系统...")
|
||||||
self._wait_for('text="快捷入口"', timeout=5000)
|
self._wait_for('text="快捷入口"', timeout=5000)
|
||||||
@@ -197,17 +159,17 @@ class ReimburseBot:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
self._wait_for('text="报销录入"', timeout=5000)
|
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.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
|
||||||
self._wait_for('text="单据状态:"', timeout=5000)
|
self._wait_for('text="单据状态:"', timeout=5000)
|
||||||
|
|
||||||
def open_reimburse_menu(self) -> None:
|
def create_new_form(self) -> None:
|
||||||
"""点击「新增」创建新报销单"""
|
"""点击「新增」创建新单据"""
|
||||||
log.info("创建新报销单...")
|
log.info("创建新单据...")
|
||||||
self.page.wait_for_timeout(2000)
|
self.page.wait_for_timeout(2000)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.page.click('button:has-text("新增")', timeout=5000)
|
self.page.click("#insert", timeout=5000)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
self.page.click("text=新增", timeout=3000)
|
self.page.click("text=新增", timeout=3000)
|
||||||
@@ -218,6 +180,31 @@ class ReimburseBot:
|
|||||||
self.page.wait_for_timeout(3000)
|
self.page.wait_for_timeout(3000)
|
||||||
self._screenshot("after_add_click")
|
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:
|
def fill_basic_info(self, description: str = "元器件采购报销") -> None:
|
||||||
"""填写基本信息"""
|
"""填写基本信息"""
|
||||||
log.info("填写基本信息...")
|
log.info("填写基本信息...")
|
||||||
@@ -254,6 +241,96 @@ class ReimburseBot:
|
|||||||
|
|
||||||
self._screenshot("step3_done")
|
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:
|
def add_reimburse_items(self, invoices: list[dict[str, Any]]) -> None:
|
||||||
"""录入报销明细(一条总明细)"""
|
"""录入报销明细(一条总明细)"""
|
||||||
card_amount = sum(inv["card_amount"] for inv in invoices)
|
card_amount = sum(inv["card_amount"] for inv in invoices)
|
||||||
@@ -261,7 +338,6 @@ class ReimburseBot:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.page.click("#insertDetail", timeout=5000)
|
self.page.click("#insertDetail", timeout=5000)
|
||||||
self.page.wait_for_timeout(1000)
|
|
||||||
self._wait_for('text="经济事项名称"', timeout=5000)
|
self._wait_for('text="经济事项名称"', timeout=5000)
|
||||||
self.page.click("#economicscode2")
|
self.page.click("#economicscode2")
|
||||||
self.page.wait_for_timeout(1000)
|
self.page.wait_for_timeout(1000)
|
||||||
@@ -286,6 +362,33 @@ class ReimburseBot:
|
|||||||
self._screenshot("item_total_error")
|
self._screenshot("item_total_error")
|
||||||
raise
|
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:
|
def fill_payment(self, invoices: list[dict[str, Any]]) -> None:
|
||||||
"""录入支付信息"""
|
"""录入支付信息"""
|
||||||
log.info("录入支付信息...")
|
log.info("录入支付信息...")
|
||||||
@@ -314,11 +417,10 @@ class ReimburseBot:
|
|||||||
self._screenshot("step5_done")
|
self._screenshot("step5_done")
|
||||||
|
|
||||||
def upload_attachments(self, invoices: list[dict[str, Any]]) -> None:
|
def upload_attachments(self, invoices: list[dict[str, Any]]) -> None:
|
||||||
"""上传发票附件"""
|
"""上传附件"""
|
||||||
log.info("上传附件...")
|
log.info("上传附件...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.page.click("#next3", timeout=5000)
|
self.page.click("#next4", timeout=5000)
|
||||||
self._wait_for("#submit2", timeout=5000)
|
self._wait_for("#submit2", timeout=5000)
|
||||||
|
|
||||||
attachment_files = sorted((self.work_dir or Path(__file__).parent.parent).glob("*.pdf"))
|
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):
|
for i, inv in enumerate(invoices):
|
||||||
file_path = attachment_files[i] if i < len(attachment_files) else None
|
file_path = attachment_files[i] if i < len(attachment_files) else None
|
||||||
try:
|
try:
|
||||||
self._wait_for("#insertAcc", timeout=20000) # 等待增加按钮出现
|
self._wait_for("#insertAcc", timeout=20000)
|
||||||
self.page.click("#insertAcc", timeout=5000) # 点击增加按钮出现
|
self.page.click("#insertAcc", timeout=5000)
|
||||||
self._wait_for("#fjlx", timeout=5000)
|
self._wait_for("#fjlx", timeout=5000)
|
||||||
self.page.select_option("#fjlx", "1")
|
self.page.select_option("#fjlx", "1")
|
||||||
explanation = f"{inv['item_name']} - {inv['invoice_no']}"
|
explanation = f"{inv['item_name']} - {inv['invoice_no']}"
|
||||||
@@ -359,17 +461,33 @@ class ReimburseBot:
|
|||||||
|
|
||||||
self._screenshot("step6_done")
|
self._screenshot("step6_done")
|
||||||
|
|
||||||
def submit(self) -> None:
|
def upload_travel_attachments(self, attachment_info: list[dict[str, Any]]) -> None:
|
||||||
"""提交报销单"""
|
"""上传差旅附件"""
|
||||||
log.info("提交报销单...")
|
log.info("上传差旅附件...")
|
||||||
try:
|
try:
|
||||||
self.page.click("#submit", timeout=5000)
|
self.page.click("#next4", timeout=5000)
|
||||||
self.page.wait_for_timeout(1000)
|
self._wait_for("#submit2", timeout=5000)
|
||||||
self._screenshot("submitted")
|
|
||||||
|
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:
|
except Exception as e:
|
||||||
log.error(f"提交失败: {e}")
|
log.error(f"差旅附件上传失败: {e}")
|
||||||
self._screenshot("submit_error")
|
self._screenshot("travel_attachment_error")
|
||||||
raise
|
raise
|
||||||
|
self._screenshot("travel_attachment_done")
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""关闭浏览器"""
|
"""关闭浏览器"""
|
||||||
@@ -382,10 +500,6 @@ class ReimburseBot:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# --------------------------------------------------------
|
|
||||||
# 辅助方法
|
|
||||||
# --------------------------------------------------------
|
|
||||||
|
|
||||||
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
|
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
|
||||||
self.page.wait_for_selector(selector, timeout=timeout)
|
self.page.wait_for_selector(selector, timeout=timeout)
|
||||||
|
|
||||||
@@ -394,31 +508,131 @@ class ReimburseBot:
|
|||||||
img_dir.mkdir(exist_ok=True)
|
img_dir.mkdir(exist_ok=True)
|
||||||
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
|
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(
|
def run_bot(config: dict[str, Any], headless: bool = False, work_dir: Path | None = None) -> None:
|
||||||
config: dict[str, Any], invoices: list[dict[str, Any]], headless: bool = False, work_dir: Path | None = None
|
"""执行完整的浏览器填报流程,根据发票类型自动路由"""
|
||||||
) -> None:
|
|
||||||
"""执行完整的浏览器填报流程"""
|
|
||||||
if not config["username"] or not config["password"]:
|
if not config["username"] or not config["password"]:
|
||||||
raise ValueError("缺少用户名或密码")
|
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
|
bot.work_dir = work_dir
|
||||||
try:
|
try:
|
||||||
bot.launch()
|
bot.launch()
|
||||||
bot.login_portal()
|
bot.login_portal()
|
||||||
bot.navigate_to_reimburse()
|
|
||||||
bot.open_reimburse_menu()
|
if is_travel:
|
||||||
bot.fill_basic_info()
|
log.info("处理差旅发票...")
|
||||||
bot.add_reimburse_items(invoices)
|
bot.navigate_to_reimburse(page_key="travel_page")
|
||||||
bot.fill_payment(invoices)
|
bot.create_new_form()
|
||||||
bot.upload_attachments(invoices)
|
basic_info = travel_info["basic_info"]
|
||||||
# bot.submit() # 确认无误后再取消注释
|
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:
|
except Exception as e:
|
||||||
log.error(f"操作失败: {e}")
|
log.error(f"操作失败: {e}")
|
||||||
try:
|
try:
|
||||||
@@ -430,28 +644,6 @@ def run_bot(
|
|||||||
bot.close()
|
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,附件从指定目录读取"""
|
"""Web 模式填报 — headless,附件从指定目录读取"""
|
||||||
if not config["username"] or not config["password"]:
|
run_bot(config, headless=True, work_dir=work_dir)
|
||||||
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()
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
配置加载
|
配置加载
|
||||||
|
|
||||||
从项目根目录的 config.json 读取配置,返回结构化的配置字典。
|
从 scripts/data/config.json 读取用户配置,从环境变量读取服务端配置(LLM + 系统 URL)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
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]:
|
def load_config() -> dict[str, str | Path]:
|
||||||
@@ -20,10 +21,11 @@ def load_config() -> dict[str, str | Path]:
|
|||||||
project_root = _CONFIG_PATH.parent
|
project_root = _CONFIG_PATH.parent
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"sso_login_url": raw.get("sso_login_url", "https://tyrz.fynu.edu.cn/sso/login"),
|
"sso_login_url": os.environ.get("SSO_LOGIN_URL", "https://tyrz.fynu.edu.cn/sso/login"),
|
||||||
"portal_url": raw.get("portal_url", "https://tyrz.fynu.edu.cn/oshall"),
|
"portal_url": os.environ.get("PORTAL_URL", "https://tyrz.fynu.edu.cn/oshall"),
|
||||||
"reimburse_url": raw.get("reimburse_url", "http://210.45.32.214:8081"),
|
"reimburse_url": os.environ.get("REIMBURSE_URL", "http://210.45.32.214:8081"),
|
||||||
"reimburse_page": raw.get("reimburse_page", "/expen/common/common?v=4.0"),
|
"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", ""),
|
"username": raw.get("username", ""),
|
||||||
"password": raw.get("password", ""),
|
"password": raw.get("password", ""),
|
||||||
"default_name": raw.get("default_name", ""),
|
"default_name": raw.get("default_name", ""),
|
||||||
@@ -35,15 +37,9 @@ def load_config() -> dict[str, str | Path]:
|
|||||||
|
|
||||||
|
|
||||||
def get_llm_config() -> dict[str, str]:
|
def get_llm_config() -> dict[str, str]:
|
||||||
"""加载 LLM 配置,缺失字段使用默认值"""
|
"""加载 LLM 配置,优先从环境变量读取,缺失字段使用默认值"""
|
||||||
raw = {}
|
|
||||||
if _CONFIG_PATH.exists():
|
|
||||||
with open(_CONFIG_PATH, encoding="utf-8") as f:
|
|
||||||
raw = json.load(f)
|
|
||||||
|
|
||||||
llm_raw = raw.get("llm", {})
|
|
||||||
return {
|
return {
|
||||||
"model": llm_raw.get("model", "qwen-vl-max"),
|
"model": os.environ.get("LLM_MODEL", "qwen-vl-max"),
|
||||||
"api_base": llm_raw.get("api_base", "http://localhost:8080/v1"),
|
"api_base": os.environ.get("LLM_API_BASE", "http://localhost:8080/v1"),
|
||||||
"api_key": llm_raw.get("api_key", "lm-studio"),
|
"api_key": os.environ.get("LLM_API_KEY", "lm-studio"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
|
last_reviewed: 2026-06-09
|
||||||
## last_reviewed: 2026-06-09
|
---
|
||||||
|
|
||||||
# src/doc — 文档处理模块
|
# src/doc — 文档处理模块
|
||||||
|
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
| 文件 | 作用 |
|
| 文件 | 作用 |
|
||||||
| ------------------------ | -------------------------------------------------- |
|
| ------------------------ | -------------------------------------------------- |
|
||||||
| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 |
|
| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 |
|
||||||
| `pdf.py` | PDF 文件发现与文本提取(pdfplumber) |
|
| `pdf.py` | PDF 图片渲染(PyMuPDF) |
|
||||||
| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态) |
|
| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态) |
|
||||||
| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 |
|
| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 |
|
||||||
| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 |
|
| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 |
|
||||||
| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板(pywin32 COM) |
|
| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板(pywin32 COM) |
|
||||||
| `prompt.py` | LLM 提示词模板加载 |
|
| `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 → [发票列表]
|
PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
|
||||||
支付截图 → llm_extractor.py → [刷卡记录]
|
支付截图 → llm_extractor.py → [刷卡记录]
|
||||||
↓
|
↓
|
||||||
matcher.py(按金额贪心匹配,容差 10 元)
|
matcher.py(按金额贪心匹配,相对容差 3%)
|
||||||
↓
|
↓
|
||||||
invoice.py 分类 → CSV(已回填刷卡日期/卡号/金额)
|
invoice.py 分类 → CSV(已回填刷卡日期/卡号/金额)
|
||||||
↓
|
↓
|
||||||
@@ -36,7 +36,7 @@ PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
|
|||||||
|
|
||||||
## 依赖说明
|
## 依赖说明
|
||||||
|
|
||||||
- **pdfplumber** — PDF 文本提取
|
- **PyMuPDF (pymupdf)** — PDF 图片渲染
|
||||||
- **pywin32** — Word COM 自动化(仅 Windows)
|
- **pywin32** — Word COM 自动化(仅 Windows)
|
||||||
- **llama-index** — LLM 信息提取
|
- **llama-index** — LLM 信息提取
|
||||||
|
|
||||||
@@ -46,4 +46,3 @@ PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
|
|||||||
- LLM 提取不会覆盖 CSV 中已有非空字段
|
- LLM 提取不会覆盖 CSV 中已有非空字段
|
||||||
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
|
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
|
||||||
- LLM 提取失败时直接报错,无正则回退
|
- LLM 提取失败时直接报错,无正则回退
|
||||||
|
|
||||||
|
|||||||
@@ -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 .. import get_logger
|
||||||
from .invoice import classify_invoice_batch
|
from .llm_extractor import extract_document
|
||||||
from .llm_extractor import extract_invoice_from_text
|
|
||||||
from .matcher import match_invoices_to_cards
|
from .matcher import match_invoices_to_cards
|
||||||
from .pdf import extract_text_from_pdf, find_pdf_files
|
|
||||||
|
|
||||||
log = get_logger("extractor")
|
log = get_logger("extractor")
|
||||||
|
|
||||||
|
|
||||||
def extract_invoices(
|
def _classify_invoice_batch(
|
||||||
directory: str = ".",
|
invoices: list[dict[str, str]],
|
||||||
) -> tuple[list[dict[str, str]], dict[str, list[dict[str, str]]]]:
|
) -> dict[str, list[dict[str, str]]]:
|
||||||
"""扫描目录下所有 PDF,提取发票信息并匹配支付记录
|
"""按发票类型分组"""
|
||||||
|
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:
|
Returns:
|
||||||
(payment_records, groups): 支付记录列表和按发票类型分组的字典
|
提取结果字典,失败时返回 None。
|
||||||
groups = {'travel': [差旅发票], 'general': [普通发票]}
|
|
||||||
"""
|
"""
|
||||||
pdf_files = find_pdf_files(directory)
|
json_path = _get_json_path(file_path, cache_dir)
|
||||||
if not pdf_files:
|
cached = _load_from_cache(json_path)
|
||||||
log.warning("未找到 PDF 文件")
|
if cached:
|
||||||
return [], {"travel": [], "general": []}
|
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 = []
|
all_invoices = []
|
||||||
for pdf_path in pdf_files:
|
all_cards = []
|
||||||
text = extract_text_from_pdf(pdf_path)
|
applications = []
|
||||||
if not text:
|
|
||||||
log.warning(f"未能提取文本: {pdf_path.name}")
|
for file_path in all_files:
|
||||||
|
result = _extract_document(file_path, cache_dir)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
log.warning(f"未能解析: {file_path.name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
invoice = extract_invoice_from_text(text, pdf_path.name)
|
inv_type = result.get("invoice_type", "")
|
||||||
|
|
||||||
if invoice and invoice.get("发票号码"):
|
if inv_type == "application":
|
||||||
all_invoices.append(invoice)
|
applications.append(result)
|
||||||
log.info(f"[{invoice['发票类型']}] 已解析: {pdf_path.name}")
|
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:
|
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)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)} 份")
|
||||||
log.info(f"共处理 {len(all_invoices)} 张发票")
|
|
||||||
else:
|
if not all_invoices:
|
||||||
log.warning("未成功解析任何发票")
|
log.warning("未成功解析任何发票")
|
||||||
|
|
||||||
# 将支付截图与发票进行金额匹配,返回以支付记录为主键的列表
|
payment_records = match_invoices_to_cards(all_invoices, all_cards)
|
||||||
payment_records = match_invoices_to_cards(all_invoices, directory)
|
_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:
|
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)
|
groups = _classify_invoice_batch(all_documents)
|
||||||
log.info(f"差旅发票: {len(groups['travel'])} 张, 普通发票: {len(groups['general'])} 张")
|
log.info(
|
||||||
|
f"文档分类: 差旅发票 {len(groups['travel'])} 张, "
|
||||||
|
f"普通发票 {len(groups['general'])} 张, "
|
||||||
|
f"出差事前申请单 {len(groups['application'])} 份"
|
||||||
|
)
|
||||||
|
|
||||||
return payment_records, groups
|
return payment_records, applications, groups
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .. import get_logger
|
from .. import get_logger
|
||||||
from ..bot import load_invoice_data
|
|
||||||
from ..config import load_config
|
from ..config import load_config
|
||||||
|
from ..doc.invoice import load_invoice_csv
|
||||||
|
|
||||||
log = get_logger("fill_consumable_doc")
|
log = get_logger("fill_consumable_doc")
|
||||||
|
|
||||||
@@ -143,7 +143,8 @@ def fill_consumable_doc(
|
|||||||
doc_path = Path(doc_path)
|
doc_path = Path(doc_path)
|
||||||
if config is None:
|
if config is None:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
invoices = load_invoice_data(str(csv_path), config)
|
|
||||||
|
invoices = load_invoice_csv(csv_path.parent / "invoice_summary.csv") or []
|
||||||
|
|
||||||
if backup:
|
if backup:
|
||||||
bak = doc_path.with_suffix(doc_path.suffix + ".bak")
|
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"
|
qty = str(qty_val) if qty_val > 0 else "1"
|
||||||
|
|
||||||
values = [
|
values = [
|
||||||
str(inv.get("seq", i + 1)),
|
str(inv.get("index", i + 1)),
|
||||||
parsed["product_name"],
|
parsed["product_name"],
|
||||||
parsed["spec"],
|
parsed["spec"],
|
||||||
parsed["unit"],
|
parsed["unit"],
|
||||||
@@ -240,7 +241,7 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser(description="将发票 CSV 填入易耗品出库单")
|
parser = argparse.ArgumentParser(description="将发票 CSV 填入易耗品出库单")
|
||||||
parser.add_argument("--csv", default=str(root / "invoice_summary.csv"))
|
parser.add_argument("--csv", default=str(root / "invoice_summary.csv"))
|
||||||
parser.add_argument("--doc", default=str(root / "易耗品、出库单.doc"))
|
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")
|
parser.add_argument("--no-backup", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
"""发票数据模型与 CSV 工具
|
"""发票数据模型与 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
|
load_csv(path) 读取支付记录 CSV
|
||||||
save_csv(payment_records, path) 保存支付记录 CSV
|
save_csv(payment_records, path) 保存支付记录 CSV
|
||||||
save_invoice_csv(payment_records, path) 保存发票级别 CSV
|
save_invoice_csv(payment_records, path) 保存发票级别 CSV
|
||||||
|
save_application_json(applications, path) 保存出差申请单 JSON
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
@@ -21,76 +17,63 @@ from .. import get_logger
|
|||||||
|
|
||||||
log = get_logger("invoice")
|
log = get_logger("invoice")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# CSV 列名
|
||||||
# CSV 列定义
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 发票级别 CSV 列(用于 invoice_summary.csv,每行一张发票)
|
|
||||||
INVOICE_LEVEL_COLUMNS = [
|
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 = [
|
PAYMENT_RECORD_COLUMNS = [
|
||||||
"序号",
|
"index",
|
||||||
# 支付信息
|
"card_date",
|
||||||
"刷卡日期",
|
"card_no",
|
||||||
"公务卡号",
|
"card_amount",
|
||||||
"刷卡金额",
|
"relative_invoice_count",
|
||||||
# 发票聚合信息
|
"invoice_detail",
|
||||||
"关联发票数",
|
"remark",
|
||||||
"发票详情", # 格式: 类型[号码]¥金额 | 类型[号码]¥金额
|
"_matched_invoices",
|
||||||
"备注",
|
"person_id",
|
||||||
# 内部字段(用于下游解析)
|
|
||||||
"_invoices_json", # JSON 序列化的发票列表,供 bot/fill_doc 使用
|
|
||||||
"工号",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 发票类型常量
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
INVOICE_TYPE_TRAIN = "高铁票"
|
def _is_application_document(invoice_type: str) -> bool:
|
||||||
INVOICE_TYPE_HOTEL = "酒店住宿"
|
"""判断是否为出差事前申请单"""
|
||||||
INVOICE_TYPE_GENERAL = "普通发票"
|
return invoice_type == "application"
|
||||||
|
|
||||||
INVOICE_TYPE_TRAVEL = frozenset([INVOICE_TYPE_TRAIN, INVOICE_TYPE_HOTEL])
|
|
||||||
|
|
||||||
|
|
||||||
def is_travel_invoice(invoice_type: str) -> bool:
|
def _classify_invoice_batch(
|
||||||
"""判断是否为差旅发票(高铁票/酒店住宿)"""
|
invoices: list[dict[str, str]],
|
||||||
return invoice_type in INVOICE_TYPE_TRAVEL
|
) -> dict[str, list[dict[str, str]]]:
|
||||||
|
"""按发票类型分组"""
|
||||||
|
travel: list[dict[str, str]] = []
|
||||||
def classify_invoice_batch(invoices: list[dict[str, str]]) -> dict[str, list[dict[str, str]]]:
|
general: list[dict[str, str]] = []
|
||||||
"""将发票列表按类型分组:{'travel': [...], 'general': [...]}"""
|
application: list[dict[str, str]] = []
|
||||||
travel = []
|
|
||||||
general = []
|
|
||||||
for inv in invoices:
|
for inv in invoices:
|
||||||
inv_type = inv.get("发票类型", INVOICE_TYPE_GENERAL)
|
inv_type = inv.get("invoice_type", "general")
|
||||||
if is_travel_invoice(inv_type):
|
if _is_application_document(inv_type):
|
||||||
|
application.append(inv)
|
||||||
|
elif inv_type in ("train", "hotel"):
|
||||||
travel.append(inv)
|
travel.append(inv)
|
||||||
else:
|
else:
|
||||||
general.append(inv)
|
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
|
return clean
|
||||||
|
|
||||||
|
|
||||||
def load_csv(csv_path: Path) -> list[dict[str, str]] | None:
|
def _load_csv(
|
||||||
"""读取支付记录 CSV 为 dict 列表,失败返回 None"""
|
csv_path: Path,
|
||||||
|
required_columns: list[str],
|
||||||
|
label: str = "CSV",
|
||||||
|
) -> list[dict[str, str]] | None:
|
||||||
|
"""通用 CSV 读取器:按 required_columns 校验列,失败返回 None"""
|
||||||
try:
|
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)
|
reader = csv.DictReader(f)
|
||||||
fieldnames = reader.fieldnames or []
|
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:
|
if missing:
|
||||||
log.error(f"CSV 缺少必要列: {missing}")
|
log.error(f"{label} 缺少必要列: {missing}")
|
||||||
return None
|
return None
|
||||||
return [row for row in reader]
|
return [row for row in reader]
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
log.error(f"CSV 文件不存在: {csv_path.name}")
|
log.error(f"{label} 文件不存在: {csv_path.name}")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"CSV 读取失败: {e}")
|
log.error(f"{label} 读取失败: {e}")
|
||||||
return None
|
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:
|
def load_invoice_csv(csv_path: Path) -> list[dict[str, str]] | None:
|
||||||
"""读取发票级别 CSV 为 dict 列表(每行一张发票),失败返回 None"""
|
"""读取发票级别 CSV 为 dict 列表(每行一张发票),失败返回 None"""
|
||||||
try:
|
return _load_csv(csv_path, INVOICE_LEVEL_COLUMNS, "发票 CSV")
|
||||||
with open(csv_path, encoding="utf-8", newline="") as f:
|
|
||||||
reader = csv.DictReader(f)
|
|
||||||
fieldnames = reader.fieldnames or []
|
|
||||||
missing = [c for c in INVOICE_LEVEL_COLUMNS if c not in fieldnames]
|
|
||||||
if missing:
|
|
||||||
log.error(f"发票 CSV 缺少必要列: {missing}")
|
|
||||||
return None
|
|
||||||
return [row for row in reader]
|
|
||||||
except FileNotFoundError:
|
|
||||||
log.error(f"发票 CSV 文件不存在: {csv_path.name}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"发票 CSV 读取失败: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_invoices_from_csv(csv_path: Path) -> list[dict[str, str]] | None:
|
|
||||||
"""从支付记录 CSV 中还原发票级别的数据(供 bot/fill_doc 使用)
|
|
||||||
|
|
||||||
读取 _invoices_json 字段,反序列化后展平为发票列表。
|
|
||||||
"""
|
|
||||||
rows = load_csv(csv_path)
|
|
||||||
if rows is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
invoices = []
|
|
||||||
for row in rows:
|
|
||||||
invoices_json = row.get("_invoices_json", "")
|
|
||||||
if not invoices_json:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
inv_list = json.loads(invoices_json)
|
|
||||||
for inv in inv_list:
|
|
||||||
# 从支付记录回填刷卡信息
|
|
||||||
inv["刷卡日期"] = row.get("刷卡日期", inv.get("刷卡日期", ""))
|
|
||||||
inv["公务卡号"] = row.get("公务卡号", inv.get("公务卡号", ""))
|
|
||||||
inv["刷卡金额"] = row.get("刷卡金额", inv.get("刷卡金额", ""))
|
|
||||||
invoices.append(inv)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
log.warning(f"无法解析发票 JSON: {invoices_json[:50]}...")
|
|
||||||
return invoices
|
|
||||||
|
|
||||||
|
|
||||||
def save_csv(
|
def save_csv(
|
||||||
@@ -180,8 +131,8 @@ def save_csv(
|
|||||||
"""将支付记录列表保存为 CSV(以支付记录为主键)
|
"""将支付记录列表保存为 CSV(以支付记录为主键)
|
||||||
|
|
||||||
每条支付记录包含:
|
每条支付记录包含:
|
||||||
- 刷卡日期、公务卡号、刷卡金额(支付信息)
|
- card_date, card_no, card_amount(支付信息)
|
||||||
- 关联发票数、发票详情(发票聚合信息)
|
- relative_invoice_count, invoice_detail(发票聚合信息)
|
||||||
- _matched_invoices(内部字段,序列化为 JSON 存储在 CSV 中)
|
- _matched_invoices(内部字段,序列化为 JSON 存储在 CSV 中)
|
||||||
"""
|
"""
|
||||||
csv_path = Path(output_path)
|
csv_path = Path(output_path)
|
||||||
@@ -191,7 +142,6 @@ def save_csv(
|
|||||||
writer.writerow(PAYMENT_RECORD_COLUMNS)
|
writer.writerow(PAYMENT_RECORD_COLUMNS)
|
||||||
|
|
||||||
for idx, record in enumerate(payment_records, 1):
|
for idx, record in enumerate(payment_records, 1):
|
||||||
# 序列化关联发票为 JSON
|
|
||||||
matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment]
|
matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment]
|
||||||
invoices_json = json.dumps(
|
invoices_json = json.dumps(
|
||||||
[_clean_invoice_for_json(inv) for inv in matched_invoices],
|
[_clean_invoice_for_json(inv) for inv in matched_invoices],
|
||||||
@@ -201,14 +151,14 @@ def save_csv(
|
|||||||
writer.writerow(
|
writer.writerow(
|
||||||
[
|
[
|
||||||
idx,
|
idx,
|
||||||
record.get("刷卡日期", ""),
|
record.get("card_date", ""),
|
||||||
record.get("公务卡号", ""),
|
record.get("card_no", ""),
|
||||||
record.get("刷卡金额", ""),
|
record.get("card_amount", ""),
|
||||||
record.get("关联发票数", str(len(matched_invoices))),
|
record.get("relative_invoice_count", str(len(matched_invoices))),
|
||||||
record.get("发票详情", ""),
|
record.get("invoice_detail", ""),
|
||||||
record.get("备注", ""),
|
record.get("remark", ""),
|
||||||
invoices_json,
|
invoices_json,
|
||||||
record.get("工号", ""),
|
record.get("person_id", ""),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -223,6 +173,7 @@ def save_invoice_csv(
|
|||||||
|
|
||||||
从 _matched_invoices 中还原每张发票,回填刷卡信息,
|
从 _matched_invoices 中还原每张发票,回填刷卡信息,
|
||||||
生成以发票为主键的 CSV,用于人工填写报销单参考。
|
生成以发票为主键的 CSV,用于人工填写报销单参考。
|
||||||
|
出差事前申请单不会被写入此文件(它们有独立的 CSV)。
|
||||||
"""
|
"""
|
||||||
csv_path = Path(output_path)
|
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]
|
matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment]
|
||||||
for inv in matched_invoices:
|
for inv in matched_invoices:
|
||||||
clean_inv = _clean_invoice_for_json(inv)
|
clean_inv = _clean_invoice_for_json(inv)
|
||||||
|
if _is_application_document(clean_inv.get("invoice_type", "")):
|
||||||
|
continue
|
||||||
writer.writerow(
|
writer.writerow(
|
||||||
[
|
[
|
||||||
idx,
|
idx,
|
||||||
clean_inv.get("发票类型", ""),
|
clean_inv.get("invoice_type", ""),
|
||||||
clean_inv.get("发票号码", ""),
|
clean_inv.get("invoice_number", ""),
|
||||||
clean_inv.get("开票日期", ""),
|
clean_inv.get("invoice_date", ""),
|
||||||
clean_inv.get("项目名称", ""),
|
clean_inv.get("item_name", ""),
|
||||||
clean_inv.get("规格型号", ""),
|
clean_inv.get("spec_model", ""),
|
||||||
clean_inv.get("价税合计", ""),
|
clean_inv.get("total_amount", ""),
|
||||||
clean_inv.get("销售方名称", ""),
|
clean_inv.get("seller_name", ""),
|
||||||
clean_inv.get("出发站", ""),
|
clean_inv.get("departure", ""),
|
||||||
clean_inv.get("到达站", ""),
|
clean_inv.get("arrival", ""),
|
||||||
clean_inv.get("车次", ""),
|
clean_inv.get("train_no", ""),
|
||||||
clean_inv.get("乘车日期", ""),
|
clean_inv.get("ride_date", ""),
|
||||||
clean_inv.get("座位等级", ""),
|
clean_inv.get("seat_class", ""),
|
||||||
clean_inv.get("人员姓名", ""),
|
clean_inv.get("person_name", ""),
|
||||||
record.get("刷卡日期", ""),
|
record.get("card_date", ""),
|
||||||
record.get("公务卡号", ""),
|
record.get("card_no", ""),
|
||||||
record.get("刷卡金额", ""),
|
record.get("card_amount", ""),
|
||||||
record.get("备注", ""),
|
record.get("remark", ""),
|
||||||
record.get("工号", ""),
|
record.get("person_id", ""),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
idx += 1
|
idx += 1
|
||||||
@@ -263,11 +216,18 @@ def save_invoice_csv(
|
|||||||
log.info(f"发票级别 CSV 已保存: {csv_path.name}")
|
log.info(f"发票级别 CSV 已保存: {csv_path.name}")
|
||||||
|
|
||||||
|
|
||||||
def save_csv_rows(csv_path: Path, rows: list[dict[str, str]]) -> None:
|
def save_application_json(
|
||||||
"""将 dict 列表保存为支付记录 CSV(用于更新已有 CSV)"""
|
applications: list[dict[str, str]],
|
||||||
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
output_path: str | Path = "travel_applications.json",
|
||||||
writer = csv.DictWriter(f, fieldnames=PAYMENT_RECORD_COLUMNS)
|
) -> None:
|
||||||
writer.writeheader()
|
"""将出差事前申请单列表保存为独立 JSON 文件
|
||||||
writer.writerows(rows)
|
|
||||||
|
|
||||||
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}")
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
"""
|
"""LLM 信息提取
|
||||||
LLM 信息提取
|
|
||||||
|
|
||||||
使用 LLM 从 PDF 文本中提取结构化数据,以及从支付截图中提取刷卡信息。
|
使用 LLM 从 PDF 文本/图片、支付截图中提取结构化数据。
|
||||||
支持 JSON 格式输出,字段与 CSV_COLUMNS 对齐。
|
|
||||||
|
|
||||||
对外接口:
|
## 功能模块
|
||||||
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
|
from __future__ import annotations
|
||||||
@@ -17,7 +24,10 @@ from pathlib import Path
|
|||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from .. import get_logger
|
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")
|
log = get_logger("llm_extractor")
|
||||||
|
|
||||||
@@ -38,47 +48,12 @@ def _create_llm() -> Any:
|
|||||||
api_base=llm_config["api_base"],
|
api_base=llm_config["api_base"],
|
||||||
api_key=llm_config.get("api_key", "lm-studio"),
|
api_key=llm_config.get("api_key", "lm-studio"),
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
max_tokens=8192,
|
max_tokens=65535,
|
||||||
request_timeout=600.0,
|
request_timeout=600.0,
|
||||||
is_chat_model=True,
|
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]:
|
def _parse_json_response(text: str) -> dict[str, Any]:
|
||||||
"""从 LLM 响应中提取 JSON,处理可能的 Markdown 包裹。"""
|
"""从 LLM 响应中提取 JSON,处理可能的 Markdown 包裹。"""
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
@@ -98,31 +73,8 @@ def _parse_json_response(text: str) -> dict[str, Any]:
|
|||||||
return cast(dict[str, Any], json.loads(text))
|
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(
|
def _llm_query_multimodal(
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
text: str,
|
text: str | None = None,
|
||||||
image_b64: str,
|
image_b64s: list[str] | None = None,
|
||||||
max_tokens: int = 4096,
|
blocks: list[Any] | None = None,
|
||||||
|
reasoning_effort: str = "none",
|
||||||
) -> str:
|
) -> 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.base.llms.types import ImageBlock, TextBlock
|
||||||
from llama_index.core.llms import ChatMessage
|
from llama_index.core.llms import ChatMessage
|
||||||
|
|
||||||
from ..config import get_llm_config
|
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 = [
|
messages = [
|
||||||
ChatMessage(role="system", content=system_prompt),
|
ChatMessage(role="system", content=system_prompt),
|
||||||
ChatMessage(
|
ChatMessage(role="user", blocks=final_blocks),
|
||||||
role="user",
|
|
||||||
blocks=[
|
|
||||||
TextBlock(text=text),
|
|
||||||
ImageBlock(
|
|
||||||
url=f"data:image/jpeg;base64,{image_b64}",
|
|
||||||
detail="high",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
llm_config = get_llm_config()
|
llm_config = get_llm_config()
|
||||||
llm = _create_llm()
|
llm = _create_llm()
|
||||||
log.info(
|
log.info(
|
||||||
"开始请求 LLM 多模态 (model=%s, base=%s)",
|
"开始请求 LLM 多模态 (model=%s, base=%s, blocks=%d)",
|
||||||
llm_config["model"],
|
llm_config["model"],
|
||||||
llm_config["api_base"],
|
llm_config["api_base"],
|
||||||
|
len(final_blocks),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -171,8 +140,7 @@ def _llm_query_multimodal(
|
|||||||
for resp in llm.stream_chat(
|
for resp in llm.stream_chat(
|
||||||
messages,
|
messages,
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
max_tokens=max_tokens,
|
extra_body={"reasoning_effort": reasoning_effort},
|
||||||
extra_body={"reasoning_effort": "none"},
|
|
||||||
):
|
):
|
||||||
delta = resp.delta
|
delta = resp.delta
|
||||||
if delta:
|
if delta:
|
||||||
@@ -186,25 +154,173 @@ def _llm_query_multimodal(
|
|||||||
raise
|
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:
|
Args:
|
||||||
image_path: 支付截图图片路径。
|
file_path: 文件路径(支持 PDF 和图片格式)。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
包含刷卡日期、刷卡金额、公务卡号的字典。
|
包含提取字段的字典。
|
||||||
"""
|
"""
|
||||||
system_prompt = build_card_info_system_prompt()
|
from .pdf import render_pdf_to_images
|
||||||
user_text = f"请分析以下支付截图并提取信息:\n\n文件名: {image_path.name}"
|
|
||||||
|
|
||||||
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:
|
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)
|
result = _parse_json_response(response)
|
||||||
log.info("LLM 支付截图提取成功: %s", image_path.name)
|
log.info("LLM 文档提取成功: %s", file_path.name)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
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
|
raise
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""发票与支付截图匹配
|
"""发票与支付记录匹配
|
||||||
|
|
||||||
将提取到的发票数据与支付截图中的刷卡记录进行金额匹配,
|
将提取到的发票数据与支付记录进行金额匹配,
|
||||||
输出以支付记录为主键的结果列表。
|
输出以支付记录为主键的结果列表。
|
||||||
|
|
||||||
|
支付记录由统一提取模块(extractor)根据 LLM 返回的「invoice_type」字段分类而来,
|
||||||
|
不再按文件类型假设文档类型。
|
||||||
|
|
||||||
## 业务约束
|
## 业务约束
|
||||||
|
|
||||||
- 发票数 >= 付款记录数(最少一张发票对应一张付款记录)
|
- 发票数 >= 付款记录数(最少一张发票对应一张付款记录)
|
||||||
@@ -11,7 +14,7 @@
|
|||||||
|
|
||||||
## 匹配流程
|
## 匹配流程
|
||||||
|
|
||||||
1. 扫描目录下图片文件,调用 LLM 提取刷卡信息(日期/金额/卡号)
|
1. 接收分类好的发票和支付记录列表
|
||||||
2. 解析发票和刷卡记录的金额,进行总额校验
|
2. 解析发票和刷卡记录的金额,进行总额校验
|
||||||
- 发票总额 < 刷卡总额时发出 warning
|
- 发票总额 < 刷卡总额时发出 warning
|
||||||
3. 按金额降序排序
|
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 typing import Any
|
||||||
|
|
||||||
from .. import get_logger
|
from .. import get_logger
|
||||||
from .llm_extractor import extract_card_info_from_image
|
|
||||||
|
|
||||||
log = get_logger("matcher")
|
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:
|
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
|
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:
|
def _build_invoice_summary(invoices: list[dict[str, Any]]) -> str:
|
||||||
"""将多张发票信息汇总为备注字符串"""
|
"""将多张发票信息汇总为备注字符串"""
|
||||||
parts = []
|
parts = []
|
||||||
for inv in invoices:
|
for inv in invoices:
|
||||||
person_name = inv.get("人员姓名") or inv.get("发票号码", "未知")
|
inv_type = inv.get("invoice_type", "unknown")
|
||||||
inv_type = inv.get("发票类型", "未知")
|
amount = inv.get("total_amount", "unknown")
|
||||||
amount = inv.get("价税合计", "未知")
|
|
||||||
parts.append(f"{inv_type}[{person_name}]¥{amount}")
|
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)
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _relative_tolerance(base: float, rate: float = 0.05) -> float:
|
def _relative_tolerance(base: float, rate: float = 0.03) -> float:
|
||||||
"""根据基准金额计算相对容差(默认 5%)"""
|
"""根据基准金额计算相对容差(默认 3%)"""
|
||||||
return abs(base) * rate
|
return abs(base) * rate
|
||||||
|
|
||||||
|
|
||||||
def match_invoices_to_cards(
|
def match_invoices_to_cards(
|
||||||
invoices: list[dict[str, Any]],
|
invoices: list[dict[str, Any]],
|
||||||
directory: str,
|
cards: list[dict[str, Any]] | None = None,
|
||||||
tolerance: float = 0.03,
|
tolerance: float = 0.03,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""将发票与支付截图按金额匹配,输出以支付记录为主键的结果列表
|
"""将发票与支付记录按金额匹配,输出以支付记录为主键的结果列表
|
||||||
|
|
||||||
|
支付记录由统一提取模块(extractor)根据 LLM 返回的「invoice_type」字段分类而来。
|
||||||
|
|
||||||
业务约束:
|
业务约束:
|
||||||
- 发票数 >= 付款记录数
|
- 发票数 >= 付款记录数
|
||||||
@@ -122,28 +100,26 @@ def match_invoices_to_cards(
|
|||||||
- 若发票数 == 付款数,一对一匹配,无需一对多
|
- 若发票数 == 付款数,一对一匹配,无需一对多
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
invoices: 发票列表,需包含 "价税合计" 字段
|
invoices: 发票列表,需包含 "total_amount" 字段
|
||||||
directory: 支付截图所在目录
|
cards: 支付记录列表,需包含 "card_amount" 字段(由 extractor 分类提供)
|
||||||
tolerance: 金额匹配容差比例(默认 0.05 = 5%)
|
tolerance: 金额匹配容差比例(默认 0.03 = 3%)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
以支付记录为主键的结果列表,每条记录包含:
|
以支付记录为主键的结果列表,每条记录包含:
|
||||||
- 刷卡日期、公务卡号、刷卡金额(支付信息)
|
- card_date, card_no, card_amount(支付信息)
|
||||||
- 关联发票列表(_matched_invoices)
|
- 关联发票列表(_matched_invoices)
|
||||||
- 发票详情备注
|
- 发票详情备注
|
||||||
- 未匹配发票单独作为一条无刷卡信息的记录
|
- 未匹配发票单独作为一条无刷卡信息的记录
|
||||||
"""
|
"""
|
||||||
cards = _extract_all_cards(directory)
|
|
||||||
if not cards:
|
if not cards:
|
||||||
log.warning("无刷卡记录可供匹配,发票将保持原状")
|
log.warning("无支付记录可供匹配,发票将保持原状")
|
||||||
# 无刷卡记录时,每张发票作为独立记录返回
|
|
||||||
return _invoices_to_records(invoices)
|
return _invoices_to_records(invoices)
|
||||||
|
|
||||||
# 解析金额
|
# 解析金额
|
||||||
for card in cards:
|
for card in cards:
|
||||||
card["_amount"] = _safe_float(card.get("刷卡金额"))
|
card["_amount"] = _safe_float(card.get("card_amount"))
|
||||||
for inv in invoices:
|
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)
|
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)
|
total_tolerance = _relative_tolerance(max(total_invoices, total_cards), tolerance)
|
||||||
if total_invoices < total_cards - total_tolerance:
|
if total_invoices < total_cards - total_tolerance:
|
||||||
log.warning(
|
log.warning(
|
||||||
f"发票总额 (¥{total_invoices:.2f}) 小于刷卡总额 (¥{total_cards:.2f}),"
|
f"发票总额 (¥{total_invoices:.2f}) 小于刷卡总额 (¥{total_cards:.2f}), "
|
||||||
f"超出容差 {tolerance * 100:.0f}%,匹配结果可能有偏差"
|
f"超出容差 {tolerance * 100:.0f}%, 匹配结果可能有偏差"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 按金额降序排序
|
# 按金额降序排序
|
||||||
@@ -193,7 +169,7 @@ def _match(
|
|||||||
) -> dict[int, list[int]]:
|
) -> dict[int, list[int]]:
|
||||||
"""执行匹配,返回 {card_index: [invoice_indices]} 的映射
|
"""执行匹配,返回 {card_index: [invoice_indices]} 的映射
|
||||||
|
|
||||||
tolerance 为相对容差比例(如 0.05 表示 5%)
|
tolerance 为相对容差比例(如 0.03 表示 3%)
|
||||||
"""
|
"""
|
||||||
result: dict[int, list[int]] = {}
|
result: dict[int, list[int]] = {}
|
||||||
assigned: set[int] = set()
|
assigned: set[int] = set()
|
||||||
@@ -213,10 +189,7 @@ def _match_one_to_one(
|
|||||||
assigned: set[int],
|
assigned: set[int],
|
||||||
result: dict[int, list[int]],
|
result: dict[int, list[int]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对
|
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对"""
|
||||||
|
|
||||||
tolerance 为相对容差比例,以刷卡金额为基准计算
|
|
||||||
"""
|
|
||||||
for card_idx, card in enumerate(cards):
|
for card_idx, card in enumerate(cards):
|
||||||
if card_idx >= len(invoices):
|
if card_idx >= len(invoices):
|
||||||
break
|
break
|
||||||
@@ -227,13 +200,13 @@ def _match_one_to_one(
|
|||||||
assigned.add(card_idx)
|
assigned.add(card_idx)
|
||||||
result[card_idx] = [card_idx]
|
result[card_idx] = [card_idx]
|
||||||
log.info(
|
log.info(
|
||||||
f"[一对一] {inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
|
f"[一对一] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
|
||||||
f"↔ {card.get('_source_file', '未知')} ¥{card['_amount']:.2f}"
|
f"↔ {card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
log.warning(
|
log.warning(
|
||||||
f"[一对一] 金额偏差超出容差: "
|
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})"
|
f"vs ¥{card['_amount']:.2f} (差 ¥{diff:.2f}, 容差 ¥{card_tol:.2f})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -245,14 +218,7 @@ def _match_one_to_many(
|
|||||||
assigned: set[int],
|
assigned: set[int],
|
||||||
result: dict[int, list[int]],
|
result: dict[int, list[int]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配
|
"""一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配"""
|
||||||
|
|
||||||
tolerance 为相对容差比例(如 0.05 表示 5%),以刷卡金额为基准计算
|
|
||||||
|
|
||||||
匹配分两阶段:
|
|
||||||
1. 精确匹配:先扫描金额完全相等(差值 <= 0.01 元)的发票-刷卡对,直接锁定
|
|
||||||
2. 贪心匹配:剩余未分配的发票和刷卡记录走贪心凑金额
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ---- 阶段 1:精确匹配(金额差 <= 0.01 元视为相等)----
|
# ---- 阶段 1:精确匹配(金额差 <= 0.01 元视为相等)----
|
||||||
exact_tolerance = 0.01
|
exact_tolerance = 0.01
|
||||||
@@ -272,21 +238,20 @@ def _match_one_to_many(
|
|||||||
assigned.add(idx)
|
assigned.add(idx)
|
||||||
result[card_idx] = [idx]
|
result[card_idx] = [idx]
|
||||||
log.info(
|
log.info(
|
||||||
f"[一对多-精确] {inv.get('发票号码', '未知')} ¥{inv_amount:.2f} "
|
f"[一对多-精确] {inv.get('invoice_number', 'unknown')} ¥{inv_amount:.2f} "
|
||||||
f"↔ {card.get('_source_file', '未知')} ¥{card_amount:.2f}"
|
f"↔ {card.get('_source_file', 'unknown')} ¥{card_amount:.2f}"
|
||||||
)
|
)
|
||||||
break # 每张刷卡只精确匹配一张发票
|
break
|
||||||
|
|
||||||
# ---- 阶段 2:贪心匹配(仅处理未精确匹配的刷卡记录)----
|
# ---- 阶段 2:贪心匹配(仅处理未精确匹配的刷卡记录)----
|
||||||
for card_idx, card in enumerate(cards):
|
for card_idx, card in enumerate(cards):
|
||||||
if card_idx in result: # 已在阶段 1 精确匹配
|
if card_idx in result:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
card_amount = card["_amount"]
|
card_amount = card["_amount"]
|
||||||
if card_amount <= 0:
|
if card_amount <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 以刷卡金额为基准计算相对容差
|
|
||||||
card_tol = _relative_tolerance(card_amount, tolerance)
|
card_tol = _relative_tolerance(card_amount, tolerance)
|
||||||
|
|
||||||
remaining = card_amount
|
remaining = card_amount
|
||||||
@@ -302,8 +267,6 @@ def _match_one_to_many(
|
|||||||
if inv_amount <= 0:
|
if inv_amount <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 最后一张发票:金额 + 容差 >= remaining 即可
|
|
||||||
# 中间发票:金额不超过 remaining + 容差
|
|
||||||
if inv_amount + card_tol >= remaining:
|
if inv_amount + card_tol >= remaining:
|
||||||
is_match = True
|
is_match = True
|
||||||
else:
|
else:
|
||||||
@@ -328,8 +291,8 @@ def _match_one_to_many(
|
|||||||
for idx in matched_indices:
|
for idx in matched_indices:
|
||||||
inv = invoices[idx]
|
inv = invoices[idx]
|
||||||
log.info(
|
log.info(
|
||||||
f"[一对多-贪心] {inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
|
f"[一对多-贪心] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
|
||||||
f"→ {card.get('_source_file', '未知')} ¥{card['_amount']:.2f}"
|
f"→ {card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -341,18 +304,18 @@ def _build_payment_records(
|
|||||||
"""构建以支付记录为主键的结果列表"""
|
"""构建以支付记录为主键的结果列表"""
|
||||||
records: list[dict[str, Any]] = []
|
records: list[dict[str, Any]] = []
|
||||||
|
|
||||||
# 已有匹配记录的支付
|
|
||||||
for card_idx, inv_indices in card_to_invoices.items():
|
for card_idx, inv_indices in card_to_invoices.items():
|
||||||
card = cards[card_idx]
|
card = cards[card_idx]
|
||||||
matched_invs = [invoices[idx] for idx in inv_indices]
|
matched_invs = [invoices[idx] for idx in inv_indices]
|
||||||
|
|
||||||
record = {
|
record = {
|
||||||
"刷卡日期": card.get("刷卡日期", ""),
|
"card_date": card.get("card_date", ""),
|
||||||
"公务卡号": card.get("公务卡号", ""),
|
"card_no": card.get("card_no", ""),
|
||||||
"刷卡金额": str(card["_amount"]),
|
"card_amount": str(card["_amount"]),
|
||||||
"关联发票数": str(len(matched_invs)),
|
"relative_invoice_count": str(len(matched_invs)),
|
||||||
"发票详情": _build_invoice_summary(matched_invs),
|
"invoice_detail": _build_invoice_summary(matched_invs),
|
||||||
"备注": "",
|
"remark": "",
|
||||||
|
"_source_file": card.get("_source_file", ""),
|
||||||
"_matched_invoices": matched_invs,
|
"_matched_invoices": matched_invs,
|
||||||
}
|
}
|
||||||
records.append(record)
|
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]
|
unmatched = [inv for idx, inv in enumerate(invoices) if idx not in matched_indices]
|
||||||
for inv in unmatched:
|
for inv in unmatched:
|
||||||
record = {
|
record = {
|
||||||
"刷卡日期": "",
|
"card_date": "",
|
||||||
"公务卡号": "",
|
"card_no": "",
|
||||||
"刷卡金额": "",
|
"card_amount": "",
|
||||||
"关联发票数": "1",
|
"relative_invoice_count": "1",
|
||||||
"发票详情": _build_invoice_summary([inv]),
|
"invoice_detail": _build_invoice_summary([inv]),
|
||||||
"备注": "未匹配到支付记录",
|
"remark": "unmatched",
|
||||||
"_matched_invoices": [inv],
|
"_matched_invoices": [inv],
|
||||||
}
|
}
|
||||||
records.append(record)
|
records.append(record)
|
||||||
@@ -383,12 +346,12 @@ def _invoices_to_records(invoices: list[dict[str, Any]]) -> list[dict[str, Any]]
|
|||||||
records = []
|
records = []
|
||||||
for inv in invoices:
|
for inv in invoices:
|
||||||
record = {
|
record = {
|
||||||
"刷卡日期": "",
|
"card_date": "",
|
||||||
"公务卡号": "",
|
"card_no": "",
|
||||||
"刷卡金额": "",
|
"card_amount": "",
|
||||||
"关联发票数": "1",
|
"relative_invoice_count": "1",
|
||||||
"发票详情": _build_invoice_summary([inv]),
|
"invoice_detail": _build_invoice_summary([inv]),
|
||||||
"备注": "",
|
"remark": "",
|
||||||
"_matched_invoices": [inv],
|
"_matched_invoices": [inv],
|
||||||
}
|
}
|
||||||
records.append(record)
|
records.append(record)
|
||||||
|
|||||||
@@ -1,42 +1,46 @@
|
|||||||
"""PDF 文件发现与文本提取
|
"""PDF 图片渲染
|
||||||
|
|
||||||
从 PDF 发票文件中提取原始文本内容。
|
从 PDF 发票文件中渲染为图片供多模态 LLM 使用。
|
||||||
|
|
||||||
对外接口:
|
对外接口:
|
||||||
find_pdf_files(directory) -> list[Path] 查找目录下所有 PDF
|
render_pdf_to_images(filepath, dpi) -list[str] 渲染 PDF 为图片字节
|
||||||
extract_text_from_pdf(filepath) -> str 提取 PDF 文本
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import fitz
|
||||||
|
|
||||||
from .. import get_logger
|
from .. import get_logger
|
||||||
|
|
||||||
log = get_logger("pdf")
|
log = get_logger("pdf")
|
||||||
|
|
||||||
|
|
||||||
def find_pdf_files(directory: str = ".") -> list[Path]:
|
def render_pdf_to_images(filepath: Path, dpi: int = 300) -> list[str]:
|
||||||
"""查找目录下所有 PDF 文件(非递归)"""
|
"""将 PDF 渲染为图片,返回 base64 编码的 JPEG 字符串列表。
|
||||||
pdf_dir = Path(directory)
|
|
||||||
if not pdf_dir.exists():
|
|
||||||
return []
|
|
||||||
return sorted(pdf_dir.glob("*.pdf"))
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: PDF 文件路径。
|
||||||
|
dpi: 渲染分辨率(默认 150,平衡质量与速度)。
|
||||||
|
|
||||||
def extract_text_from_pdf(filepath: Path) -> str:
|
Returns:
|
||||||
"""从单个 PDF 中提取全部文本"""
|
base64 编码的 JPEG 图片字符串列表(每页一个)。
|
||||||
|
"""
|
||||||
|
images = []
|
||||||
try:
|
try:
|
||||||
import pdfplumber
|
doc = fitz.open(filepath)
|
||||||
except ImportError as err:
|
zoom = dpi / 72.0 # 72 DPI 是 fitz 默认
|
||||||
raise ImportError("缺少 pdfplumber,请执行: uv pip install pdfplumber") from err
|
matrix = fitz.Matrix(zoom, zoom)
|
||||||
|
|
||||||
try:
|
for page in doc:
|
||||||
parts = []
|
pix = page.get_pixmap(matrix=matrix)
|
||||||
with pdfplumber.open(filepath) as pdf:
|
jpg_bytes = pix.tobytes("jpg")
|
||||||
for page in pdf.pages:
|
b64 = base64.b64encode(jpg_bytes).decode("utf-8")
|
||||||
text = page.extract_text()
|
images.append(b64)
|
||||||
if text:
|
|
||||||
parts.append(text)
|
doc.close()
|
||||||
return "\n".join(parts)
|
log.info(f"PDF 渲染成功: {filepath.name} ({len(images)} 页, {dpi} DPI)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"无法读取 {filepath.name}: {e}")
|
log.error(f"PDF 渲染失败 {filepath.name}: {e}")
|
||||||
return ""
|
|
||||||
|
return images
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ def build_invoice_system_prompt() -> str:
|
|||||||
return _load_prompt("invoice_system.md")
|
return _load_prompt("invoice_system.md")
|
||||||
|
|
||||||
|
|
||||||
def build_card_info_system_prompt() -> str:
|
def build_travel_info_system_prompt() -> str:
|
||||||
"""构建支付截图信息提取系统提示词。"""
|
"""构建差旅信息提取系统提示词。"""
|
||||||
return _load_prompt("card_info_system.md")
|
return _load_prompt("travel_info_system.md")
|
||||||
|
|||||||
20
src/doc/prompts/README.md
Normal 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
|
||||||
|
```
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 支付截图信息提取系统提示词
|
|
||||||
|
|
||||||
你是财务支付截图信息提取助手。你的任务是从支付截图(银行转账记录、微信/支付宝付款凭证等)中提取结构化信息,并以 JSON 格式返回。
|
|
||||||
|
|
||||||
需要提取的字段(全部必填,无法识别时返回空字符串):
|
|
||||||
|
|
||||||
1. 刷卡日期: 支付发生的日期,格式为 YYYY/M/D
|
|
||||||
2. 刷卡金额: 实际支付金额,只保留数字(如 123.45)
|
|
||||||
3. 公务卡号: 付款银行卡号,如果截图中有显示则提取,没有则返回空字符串
|
|
||||||
|
|
||||||
严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。
|
|
||||||
@@ -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. 发票类型: "高铁票"
|
1. invoice_type: "train"
|
||||||
2. 发票号码: 发票的唯一编号
|
2. invoice_number: 发票的唯一编号
|
||||||
3. 开票日期: 格式为 YYYY/M/D
|
3. invoice_date: 格式为 YYYY-M-D
|
||||||
4. 乘车日期: 格式为 YYYY/M/D
|
4. ride_date: 格式为 YYYY-M-D
|
||||||
5. 出发站: 没有留空
|
5. departure: 没有留空
|
||||||
6. 到达站: 没有留空
|
6. arrival: 没有留空
|
||||||
7. 座位等级: 没有留空
|
7. seat_class: 没有留空
|
||||||
8. 车次: 没有留空
|
8. train_no: 没有留空
|
||||||
9. 人员姓名: 没有留空
|
9. person_name: 没有留空
|
||||||
10. 价税合计:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息
|
10. total_amount:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息
|
||||||
|
|
||||||
如果是酒店住宿(酒店住宿通产包含关键字:住宿服务,酒店,生产生活服务等,请仔细分析,这种发票和普通发票类似),返回如下字段:
|
如果是酒店住宿(酒店住宿通产包含关键字:住宿服务,酒店,生产生活服务等,请仔细分析,这种发票和普通发票类似),返回如下字段:
|
||||||
1. 发票类型: "酒店住宿"
|
1. invoice_type: "hotel"
|
||||||
2. 发票号码: 发票的唯一编号
|
2. invoice_number: 发票的唯一编号
|
||||||
3. 开票日期: 格式为 YYYY/M/D
|
3. invoice_date: 格式为 YYYY-M-D
|
||||||
4. 价税合计: 金额数字
|
4. total_amount: 金额数字
|
||||||
|
|
||||||
如果是普通发票,返回如下字段:
|
如果是普通发票,返回如下字段:
|
||||||
1. 发票类型: "普通发票"
|
1. invoice_type: "general"
|
||||||
2. 发票号码: 发票的唯一编号
|
2. invoice_number: 发票的唯一编号
|
||||||
3. 开票日期: 格式为 YYYY/M/D
|
3. invoice_date: 格式为 YYYY-M-D
|
||||||
4. 项目名称: 商品或服务名称,总结的人能看懂
|
4. item_name: 商品或服务名称,总结的人能看懂
|
||||||
5. 规格型号: 规格描述
|
5. spec_model: 规格描述
|
||||||
6. 价税合计: 金额数字
|
6. total_amount: 金额数字
|
||||||
7. 销售方名称: 卖方全称
|
7. seller_name: 卖方全称
|
||||||
|
|
||||||
严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。
|
**千万注意!千万注意!**:严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。
|
||||||
123
src/doc/prompts/travel_info_system.md
Normal 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 必须语法正确,无多余逗号、引号等语法错误
|
||||||
|
* 必须严格遵守所有强制性类型约束,任何违反类型要求的输出均视为无效
|
||||||
10
src/main.py
@@ -37,13 +37,18 @@ def main() -> None:
|
|||||||
"-u",
|
"-u",
|
||||||
"--username",
|
"--username",
|
||||||
default=None,
|
default=None,
|
||||||
help="信息门户登录账号(覆盖 config.json)",
|
help="信息门户登录账号(覆盖 scripts/config.json)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-p",
|
"-p",
|
||||||
"--password",
|
"--password",
|
||||||
default=None,
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -51,6 +56,7 @@ def main() -> None:
|
|||||||
step=args.step,
|
step=args.step,
|
||||||
username=args.username,
|
username=args.username,
|
||||||
password=args.password,
|
password=args.password,
|
||||||
|
cache_dir=args.cache_dir,
|
||||||
)
|
)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
数据在内存中流转,同时生成 CSV 中间产物。
|
数据在内存中流转,同时生成 CSV 中间产物。
|
||||||
|
|
||||||
发票类型区分:
|
发票类型区分:
|
||||||
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
- 差旅发票(train/hotel):不生成易耗品出库单,走差旅报销流程
|
||||||
- 普通发票:生成易耗品出库单,走普通报销流程
|
- 普通发票(general):生成易耗品出库单,走普通报销流程
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,52 +16,57 @@ from . import get_logger
|
|||||||
from .config import load_config
|
from .config import load_config
|
||||||
from .doc.extractor import extract_invoices
|
from .doc.extractor import extract_invoices
|
||||||
from .doc.invoice import (
|
from .doc.invoice import (
|
||||||
classify_invoice_batch,
|
save_application_json,
|
||||||
load_invoices_from_csv,
|
|
||||||
save_invoice_csv,
|
save_invoice_csv,
|
||||||
)
|
)
|
||||||
from .doc.invoice import (
|
from .doc.invoice import (
|
||||||
save_csv as save_payment_csv,
|
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")
|
log = get_logger("pipeline")
|
||||||
|
|
||||||
|
|
||||||
def _find_upload_directory(project_dir: Path) -> Path | None:
|
def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
|
||||||
"""自动发现 uploads 目录下最新且包含 PDF 的会话文件夹"""
|
"""从缓存目录读取发票数据并按类型分组"""
|
||||||
uploads_base = project_dir / "src" / "web" / "uploads"
|
from .doc.llm_extractor import load_cache
|
||||||
if not uploads_base.is_dir():
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 只选包含 PDF 的目录
|
cache_map = load_cache(cache_path)
|
||||||
valid_dirs = [d for d in uploads_base.iterdir() if d.is_dir() and list(d.glob("*.pdf"))]
|
invoices = [data for data in cache_map.values() if data.get("invoice_type") not in ("application", "payment")]
|
||||||
if not valid_dirs:
|
return _classify_invoice_batch(invoices)
|
||||||
return None
|
|
||||||
|
|
||||||
# 按修改时间排序,取最新
|
|
||||||
session_dirs = sorted(
|
|
||||||
valid_dirs,
|
|
||||||
key=lambda d: d.stat().st_mtime,
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
return session_dirs[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_from_csv(csv_path: Path) -> dict[str, list[dict[str, Any]]]:
|
def run_pipeline(
|
||||||
"""从已生成的 CSV 中读取发票数据并按类型分组"""
|
step: str = "all",
|
||||||
rows = load_invoices_from_csv(csv_path)
|
username: str | None = None,
|
||||||
if rows is None:
|
password: str | None = None,
|
||||||
return {"travel": [], "general": []}
|
cache_dir: str | None = None,
|
||||||
return classify_invoice_batch(rows)
|
) -> int:
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline(step: str = "all", username: str | None = None, password: str | None = None) -> int:
|
|
||||||
"""执行报销流程
|
"""执行报销流程
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
step: all | invoice | submit
|
step: all | invoice | submit
|
||||||
username: 覆盖 config.json 中的用户名
|
username: 覆盖 config.json 中的用户名
|
||||||
password: 覆盖 config.json 中的密码
|
password: 覆盖 config.json 中的密码
|
||||||
|
cache_dir: 发票缓存目录(包含 .invoice_cache 子目录)
|
||||||
"""
|
"""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
if username:
|
if username:
|
||||||
@@ -69,8 +74,8 @@ def run_pipeline(step: str = "all", username: str | None = None, password: str |
|
|||||||
if password:
|
if password:
|
||||||
config["password"] = password
|
config["password"] = password
|
||||||
|
|
||||||
# 工作目录(项目根目录)
|
|
||||||
project_dir = Path(__file__).parent.parent
|
project_dir = Path(__file__).parent.parent
|
||||||
|
cache_path = Path(cache_dir) if cache_dir else project_dir / "scripts" / "data"
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
# Step 1: 发票提取
|
# Step 1: 发票提取
|
||||||
@@ -83,15 +88,17 @@ def run_pipeline(step: str = "all", username: str | None = None, password: str |
|
|||||||
log.info("[1/2] 发票提取")
|
log.info("[1/2] 发票提取")
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
|
|
||||||
payment_records, groups = extract_invoices(str(project_dir))
|
payment_records, applications, groups = extract_invoices(str(cache_path))
|
||||||
if not payment_records:
|
if not payment_records:
|
||||||
log.error("未提取到任何发票数据")
|
log.error("未提取到任何发票数据")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
save_payment_csv(payment_records, project_dir / "payment_records.csv")
|
save_payment_csv(payment_records, cache_path / "payment_records.csv")
|
||||||
save_invoice_csv(payment_records, project_dir / "invoice_summary.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'])} 张")
|
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张")
|
||||||
|
|
||||||
if step == "invoice":
|
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("[2/2] 报销提交")
|
||||||
log.info("=" * 60)
|
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:
|
if groups is None:
|
||||||
groups = _classify_from_csv(csv_path)
|
groups = _classify_from_cache(cache_path)
|
||||||
|
|
||||||
if groups["travel"] and not groups["general"]:
|
if groups["travel"] and not groups["general"]:
|
||||||
log.info("检测到纯差旅发票,使用差旅报销模式")
|
log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||||
# TODO: 差旅报销填报流程
|
run_bot(config, work_dir=cache_path)
|
||||||
run_bot(config, bot_invoices)
|
|
||||||
else:
|
else:
|
||||||
log.info("检测到普通发票,使用普通报销模式")
|
log.info("检测到普通发票,使用普通报销模式")
|
||||||
run_bot(config, bot_invoices)
|
run_bot(config, work_dir=cache_path)
|
||||||
|
|
||||||
if step == "submit":
|
if step == "submit":
|
||||||
log.info("[2/2] 报销提交 完成")
|
log.info("[2/2] 报销提交 完成")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 全流程完成
|
|
||||||
# --------------------------------------------------
|
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
log.info("全流程执行完毕")
|
log.info("全流程执行完毕")
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ last_reviewed: 2026-06-09
|
|||||||
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
|
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
|
||||||
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
|
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
|
||||||
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5,不引入构建工具,保持单页应用轻量可维护。
|
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5,不引入构建工具,保持单页应用轻量可维护。
|
||||||
- **双模式支持**:PDF 发票提取模式和 CSV 快捷上传模式,后者跳过 LLM 识别和 PDF 解析,直接处理已有发票数据。
|
|
||||||
|
|
||||||
## 文件结构
|
## 文件结构
|
||||||
|
|
||||||
@@ -51,7 +50,6 @@ src/web/
|
|||||||
| GET | `/` | 主界面 |
|
| GET | `/` | 主界面 |
|
||||||
| POST | `/api/session` | 创建会话,返回 session_id |
|
| POST | `/api/session` | 创建会话,返回 session_id |
|
||||||
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
|
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
|
||||||
| POST | `/api/upload-csv/<sid>` | 上传 CSV 发票数据 |
|
|
||||||
| GET | `/api/files/<sid>` | 列出会话文件 |
|
| GET | `/api/files/<sid>` | 列出会话文件 |
|
||||||
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
|
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
|
||||||
| GET | `/api/logs/<sid>` | SSE 日志流 |
|
| GET | `/api/logs/<sid>` | SSE 日志流 |
|
||||||
|
|||||||
115
src/web/app.py
@@ -38,13 +38,32 @@ from src.doc.fill_consumable_doc import ( # noqa: E402, I001
|
|||||||
fill_consumable_from_template,
|
fill_consumable_from_template,
|
||||||
)
|
)
|
||||||
from src.doc.invoice import ( # noqa: E402, I001
|
from src.doc.invoice import ( # noqa: E402, I001
|
||||||
classify_invoice_batch,
|
|
||||||
load_csv,
|
load_csv,
|
||||||
load_invoice_csv,
|
load_invoice_csv,
|
||||||
save_csv as save_payment_csv,
|
save_csv as save_payment_csv,
|
||||||
save_invoice_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")
|
fill_log = get_logger("fill_consumable_doc")
|
||||||
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
|
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
|
||||||
|
|
||||||
@@ -161,7 +180,7 @@ def _has_general_invoices(rows: list[dict[str, str]]) -> bool:
|
|||||||
invoices.append(row)
|
invoices.append(row)
|
||||||
if not invoices:
|
if not invoices:
|
||||||
return False
|
return False
|
||||||
groups = classify_invoice_batch(invoices)
|
groups = _classify_invoice_batch(invoices)
|
||||||
return len(groups["general"]) > 0
|
return len(groups["general"]) > 0
|
||||||
|
|
||||||
|
|
||||||
@@ -179,7 +198,7 @@ def _get_invoice_groups(rows: list[dict[str, str]]) -> dict[str, int]:
|
|||||||
# 发票级别格式:直接使用
|
# 发票级别格式:直接使用
|
||||||
elif "发票类型" in row:
|
elif "发票类型" in row:
|
||||||
invoices.append(row)
|
invoices.append(row)
|
||||||
groups = classify_invoice_batch(invoices)
|
groups = _classify_invoice_batch(invoices)
|
||||||
return {
|
return {
|
||||||
"travel_count": len(groups["travel"]),
|
"travel_count": len(groups["travel"]),
|
||||||
"general_count": len(groups["general"]),
|
"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()
|
start = time.time()
|
||||||
|
|
||||||
# ---- Step 1: 发票提取 ----
|
# ---- Step 1: 发票提取 ----
|
||||||
invoices, groups = extract_invoices(str(session_dir))
|
invoices, applications, groups = extract_invoices(str(session_dir))
|
||||||
if not invoices:
|
if not invoices:
|
||||||
return {"ok": False, "error": "未提取到任何发票数据"}
|
return {"ok": False, "error": "未提取到任何发票数据"}
|
||||||
|
|
||||||
# 保存两个 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
|
# 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
|
||||||
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
||||||
save_invoice_csv(invoices, session_dir / "invoice_summary.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)
|
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
|
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]:
|
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():
|
if not csv_path.exists():
|
||||||
return {"ok": False, "error": "未找到发票数据,请先处理"}
|
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)
|
rows = load_csv(csv_path)
|
||||||
@@ -345,7 +325,7 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str,
|
|||||||
else:
|
else:
|
||||||
fill_log.info("检测到普通发票,使用普通报销模式")
|
fill_log.info("检测到普通发票,使用普通报销模式")
|
||||||
|
|
||||||
run_bot_web(config, bot_invoices, session_dir)
|
run_bot_web(config, session_dir)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -384,22 +364,6 @@ def upload_file(session_id: str) -> Any:
|
|||||||
return jsonify({"ok": True, "filename": safe_name})
|
return jsonify({"ok": True, "filename": safe_name})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/upload-csv/<session_id>", methods=["POST"])
|
|
||||||
def upload_csv(session_id: str) -> Any:
|
|
||||||
"""上传 CSV 发票数据文件(跳过 PDF 提取)"""
|
|
||||||
session_dir = _validate_session(session_id)
|
|
||||||
if isinstance(session_dir, tuple):
|
|
||||||
return session_dir
|
|
||||||
|
|
||||||
f = request.files.get("file")
|
|
||||||
if not f or not f.filename:
|
|
||||||
return jsonify({"error": "未选择文件"}), 400
|
|
||||||
|
|
||||||
safe_name = Path(f.filename).name
|
|
||||||
f.save(str(session_dir / safe_name))
|
|
||||||
return jsonify({"ok": True, "filename": safe_name})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/files/<session_id>", methods=["GET"])
|
@app.route("/api/files/<session_id>", methods=["GET"])
|
||||||
def list_files(session_id: str) -> Any:
|
def list_files(session_id: str) -> Any:
|
||||||
"""列出会话目录中的文件"""
|
"""列出会话目录中的文件"""
|
||||||
@@ -420,7 +384,6 @@ def start_process(session_id: str) -> Any:
|
|||||||
return session_dir
|
return session_dir
|
||||||
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
mode = body.get("mode", "auto") # "pdf", "csv", or "auto"
|
|
||||||
|
|
||||||
# 读取配置
|
# 读取配置
|
||||||
config = _build_web_config(body)
|
config = _build_web_config(body)
|
||||||
@@ -435,19 +398,7 @@ def start_process(session_id: str) -> Any:
|
|||||||
def _run() -> None:
|
def _run() -> None:
|
||||||
result = {"ok": False, "error": "未知错误"}
|
result = {"ok": False, "error": "未知错误"}
|
||||||
try:
|
try:
|
||||||
if mode == "csv":
|
result = run_pipeline_web(session_dir, config)
|
||||||
csv_files = list(session_dir.glob("*.csv"))
|
|
||||||
if not csv_files:
|
|
||||||
result = {"ok": False, "error": "未找到 CSV 文件"}
|
|
||||||
else:
|
|
||||||
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name)
|
|
||||||
else:
|
|
||||||
csv_files = list(session_dir.glob("*.csv"))
|
|
||||||
pdf_files = list(session_dir.glob("*.pdf"))
|
|
||||||
if csv_files and not pdf_files:
|
|
||||||
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name)
|
|
||||||
else:
|
|
||||||
result = run_pipeline_web(session_dir, config)
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
result = {"ok": False, "error": str(e)}
|
result = {"ok": False, "error": str(e)}
|
||||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||||
|
|||||||
18
src/web/static/README.md
Normal 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,无构建工具,保持单页应用轻量可维护。
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
let sessionId = null;
|
let sessionId = null;
|
||||||
const pdfFiles = [], imgFiles = [];
|
const pdfFiles = [], imgFiles = [];
|
||||||
let csvFile = null;
|
|
||||||
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
|
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
|
||||||
let csvFilename = ''; // 当前 CSV 文件名
|
let csvFilename = ''; // 当前 CSV 文件名
|
||||||
let lastDownloadUrls = {}; // 最近一次可下载文件链接
|
let lastDownloadUrls = {}; // 最近一次可下载文件链接
|
||||||
@@ -50,23 +49,6 @@ function renderFileList(type) {
|
|||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- CSV 上传 ----
|
|
||||||
function handleCsvFile(input) {
|
|
||||||
const file = input.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
csvFile = file;
|
|
||||||
document.getElementById('csv-zone').classList.add('active');
|
|
||||||
document.getElementById('csv-list').innerHTML =
|
|
||||||
`<span class="file-tag">${file.name}<span class="remove" onclick="event.stopPropagation();removeCsvFile()">×</span></span>`;
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeCsvFile() {
|
|
||||||
csvFile = null;
|
|
||||||
document.getElementById('csv-zone').classList.remove('active');
|
|
||||||
document.getElementById('csv-list').innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 配置上传 ----
|
// ---- 配置上传 ----
|
||||||
function handleConfigUpload(input) {
|
function handleConfigUpload(input) {
|
||||||
const file = input.files[0];
|
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() {
|
async function startProcess() {
|
||||||
const isCsvMode = !!csvFile;
|
if (!pdfFiles.length && !imgFiles.length) {
|
||||||
if (!isCsvMode && !pdfFiles.length && !imgFiles.length) {
|
alert('请先上传文件或图片');
|
||||||
alert('请先上传文件或 CSV');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,17 +122,11 @@ async function startProcess() {
|
|||||||
try {
|
try {
|
||||||
await ensureSession();
|
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();
|
const fd = new FormData();
|
||||||
fd.append('file', csvFile);
|
fd.append('file', f);
|
||||||
await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd });
|
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
||||||
} else {
|
|
||||||
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
|
|
||||||
for (const {f} of allFiles) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', f);
|
|
||||||
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
|
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_card_no: document.getElementById('cfg-card').value,
|
||||||
default_person_id: document.getElementById('cfg-person-id').value,
|
default_person_id: document.getElementById('cfg-person-id').value,
|
||||||
consumable_storage: document.getElementById('cfg-storage').value,
|
consumable_storage: document.getElementById('cfg-storage').value,
|
||||||
mode: isCsvMode ? 'csv' : 'auto',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await fetch(`/api/process/${sessionId}`, {
|
await fetch(`/api/process/${sessionId}`, {
|
||||||
@@ -248,8 +211,13 @@ function showDownloadLinks(result) {
|
|||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
if (warn) {
|
if (warn) {
|
||||||
if (result.doc_ok === false && result.doc_error) {
|
if (result.doc_skipped) {
|
||||||
warn.style.display = 'block';
|
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;
|
warn.textContent = '出库单未生成:' + result.doc_error;
|
||||||
} else {
|
} else {
|
||||||
warn.style.display = 'none';
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 发票数据编辑 ----
|
// ---- 发票数据编辑 ----
|
||||||
|
|||||||
14
src/web/templates/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# src/web/templates — HTML 模板目录
|
||||||
|
|
||||||
|
存放 Flask 渲染的 HTML 模板文件。
|
||||||
|
|
||||||
|
## 模板清单
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `index.html` | PC 端主界面:包含文件上传区、配置表单、处理按钮、SSE 日志面板、可编辑发票表格、下载链接、财务提交按钮、移动端二维码 |
|
||||||
|
| `mobile_upload.html` | 移动端上传页面:支持拍照/相册选择,上传至当前会话 |
|
||||||
@@ -43,17 +43,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -111,7 +100,7 @@
|
|||||||
<div class="card mb-4" id="edit-section" style="display:none">
|
<div class="card mb-4" id="edit-section" style="display:none">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="section-title d-flex justify-content-between align-items-center">
|
<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">
|
<div class="d-flex justify-content-end align-items-center">
|
||||||
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
|
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
23
tests/README.md
Normal 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
@@ -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
|
||||||
@@ -5,14 +5,36 @@
|
|||||||
|
|
||||||
from src.doc.invoice import (
|
from src.doc.invoice import (
|
||||||
INVOICE_LEVEL_COLUMNS,
|
INVOICE_LEVEL_COLUMNS,
|
||||||
INVOICE_TYPE_GENERAL,
|
|
||||||
INVOICE_TYPE_HOTEL,
|
|
||||||
INVOICE_TYPE_TRAIN,
|
|
||||||
INVOICE_TYPE_TRAVEL,
|
|
||||||
PAYMENT_RECORD_COLUMNS,
|
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:
|
class TestInvoiceConstants:
|
||||||
@@ -47,7 +69,7 @@ class TestIsTravelInvoice:
|
|||||||
assert is_travel_invoice(INVOICE_TYPE_GENERAL) is False
|
assert is_travel_invoice(INVOICE_TYPE_GENERAL) is False
|
||||||
|
|
||||||
def test_unknown_not_travel(self) -> None:
|
def test_unknown_not_travel(self) -> None:
|
||||||
assert is_travel_invoice("未知类型") is False
|
assert is_travel_invoice("unknown") is False
|
||||||
|
|
||||||
|
|
||||||
class TestClassifyInvoiceBatch:
|
class TestClassifyInvoiceBatch:
|
||||||
@@ -55,12 +77,12 @@ class TestClassifyInvoiceBatch:
|
|||||||
|
|
||||||
def test_empty_list(self) -> None:
|
def test_empty_list(self) -> None:
|
||||||
result = classify_invoice_batch([])
|
result = classify_invoice_batch([])
|
||||||
assert result == {"travel": [], "general": []}
|
assert result == {"travel": [], "general": [], "application": []}
|
||||||
|
|
||||||
def test_all_travel(self) -> None:
|
def test_all_travel(self) -> None:
|
||||||
invoices = [
|
invoices = [
|
||||||
{"发票类型": INVOICE_TYPE_TRAIN, "发票号码": "001"},
|
{K_INVOICE_TYPE: INVOICE_TYPE_TRAIN, K_INVOICE_NUMBER: "001"},
|
||||||
{"发票类型": INVOICE_TYPE_HOTEL, "发票号码": "002"},
|
{K_INVOICE_TYPE: INVOICE_TYPE_HOTEL, K_INVOICE_NUMBER: "002"},
|
||||||
]
|
]
|
||||||
result = classify_invoice_batch(invoices)
|
result = classify_invoice_batch(invoices)
|
||||||
assert len(result["travel"]) == 2
|
assert len(result["travel"]) == 2
|
||||||
@@ -68,7 +90,7 @@ class TestClassifyInvoiceBatch:
|
|||||||
|
|
||||||
def test_all_general(self) -> None:
|
def test_all_general(self) -> None:
|
||||||
invoices = [
|
invoices = [
|
||||||
{"发票类型": INVOICE_TYPE_GENERAL, "发票号码": "001"},
|
{K_INVOICE_TYPE: INVOICE_TYPE_GENERAL, K_INVOICE_NUMBER: "001"},
|
||||||
]
|
]
|
||||||
result = classify_invoice_batch(invoices)
|
result = classify_invoice_batch(invoices)
|
||||||
assert len(result["travel"]) == 0
|
assert len(result["travel"]) == 0
|
||||||
@@ -76,14 +98,14 @@ class TestClassifyInvoiceBatch:
|
|||||||
|
|
||||||
def test_mixed(self) -> None:
|
def test_mixed(self) -> None:
|
||||||
invoices = [
|
invoices = [
|
||||||
{"发票类型": INVOICE_TYPE_TRAIN, "发票号码": "001"},
|
{K_INVOICE_TYPE: INVOICE_TYPE_TRAIN, K_INVOICE_NUMBER: "001"},
|
||||||
{"发票类型": INVOICE_TYPE_GENERAL, "发票号码": "002"},
|
{K_INVOICE_TYPE: INVOICE_TYPE_GENERAL, K_INVOICE_NUMBER: "002"},
|
||||||
]
|
]
|
||||||
result = classify_invoice_batch(invoices)
|
result = classify_invoice_batch(invoices)
|
||||||
assert len(result["travel"]) == 1
|
assert len(result["travel"]) == 1
|
||||||
assert len(result["general"]) == 1
|
assert len(result["general"]) == 1
|
||||||
|
|
||||||
def test_missing_type_defaults_to_general(self) -> None:
|
def test_missing_type_defaults_to_general(self) -> None:
|
||||||
invoices = [{"发票号码": "001"}]
|
invoices = [{K_INVOICE_NUMBER: "001"}]
|
||||||
result = classify_invoice_batch(invoices)
|
result = classify_invoice_batch(invoices)
|
||||||
assert len(result["general"]) == 1
|
assert len(result["general"]) == 1
|
||||||
|
|||||||
202
tests/test_llm_extractor.py
Normal 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
@@ -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
@@ -229,8 +229,8 @@ dependencies = [
|
|||||||
{ name = "flask" },
|
{ name = "flask" },
|
||||||
{ name = "llama-index" },
|
{ name = "llama-index" },
|
||||||
{ name = "llama-index-llms-openai-like" },
|
{ name = "llama-index-llms-openai-like" },
|
||||||
{ name = "pdfplumber" },
|
|
||||||
{ name = "playwright" },
|
{ name = "playwright" },
|
||||||
|
{ name = "pymupdf" },
|
||||||
{ name = "pywin32" },
|
{ name = "pywin32" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -249,8 +249,8 @@ requires-dist = [
|
|||||||
{ name = "flask", specifier = ">=3.0" },
|
{ name = "flask", specifier = ">=3.0" },
|
||||||
{ name = "llama-index", specifier = ">=0.12.0" },
|
{ name = "llama-index", specifier = ">=0.12.0" },
|
||||||
{ name = "llama-index-llms-openai-like", specifier = "==0.7.2" },
|
{ name = "llama-index-llms-openai-like", specifier = "==0.7.2" },
|
||||||
{ name = "pdfplumber", specifier = ">=0.10" },
|
|
||||||
{ name = "playwright", specifier = ">=1.40" },
|
{ name = "playwright", specifier = ">=1.40" },
|
||||||
|
{ name = "pymupdf", specifier = ">=1.24" },
|
||||||
{ name = "pywin32", specifier = ">=306" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "cfgv"
|
name = "cfgv"
|
||||||
version = "3.5.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "dataclasses-json"
|
name = "dataclasses-json"
|
||||||
version = "0.6.7"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "12.2.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.4"
|
version = "2.13.4"
|
||||||
@@ -1985,32 +1839,19 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pypdfium2"
|
name = "pymupdf"
|
||||||
version = "5.9.0"
|
version = "1.27.2.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||||
{ 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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||