重构项目为LLM 驱动

This commit is contained in:
wandering
2026-06-09 16:25:20 +08:00
parent 0074975591
commit 98e3d21c83
52 changed files with 7405 additions and 1485 deletions

12
.agents/docs/README.md Normal file
View File

@@ -0,0 +1,12 @@
# 项目维护人员文档
本目录存放维护人员与自动化代理相关资料,主要用于项目运维,不属于对外公开的用户文档。
- standards/:存放维护人员需遵守的规范制度与校验规则
- plans/:存放实施方案与工作交接说明
- error-experience/、good-experience/:存放内部经验总结文档
- guides/:面向维护人员的工作流程及集成实操手册
- architecture/manifest.yaml记录可读性检查所覆盖的文件路径
对外用户文档及说明文件请统一放置在 docs/ 目录下。

View File

@@ -0,0 +1,59 @@
---
last_reviewed: 2026-06-09
---
# LlamaIndex 多模态消息格式错误
## 错误现象
```
pydantic_core._pydantic_core.ValidationError: 2 validation errors for ChatMessage
blocks.0
Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, ...
blocks.1
Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, ...
```
## 触发条件
- llama-index-core >= 0.14.x
- 使用 `ChatMessage` 构造多模态消息(文本 + 图片)
- 传入 OpenAI 格式的 `content` 列表:`[{"type": "text", ...}, {"type": "image_url", ...}]`
## 原因
`llama-index-core 0.14.x` 重构了 `ChatMessage` 的内部结构:
| 版本 | 字段 | 多模态 content 格式 |
|------|------|-------------------|
| 0.14.x | `role`, `blocks`, `additional_kwargs` | `TextBlock` / `ImageBlock` 实例 |
| 旧版本 | `role`, `content` | OpenAI 风格字典列表 |
底层 Pydantic 模型使用 `block_type` 作为 union discriminatorOpenAI 格式的 `{"type": "text", ...}` 字典不包含该字段,导致验证失败。
## 修复方法
**正确写法llama-index 原生 blocks 格式):**
```python
from llama_index.core.llms import ChatMessage
from llama_index.core.base.llms.types import ImageBlock, TextBlock
messages = [
ChatMessage(role="system", content=system_prompt),
ChatMessage(
role="user",
blocks=[
TextBlock(text="请分析这张图片"),
ImageBlock(
url=f"data:image/jpeg;base64,{image_b64}",
detail="high",
),
],
),
]
```
## 适用版本
- llama-index-core: 0.14.22
- llama-index-llms-openai-like: 0.7.2

View File

@@ -0,0 +1,261 @@
---
last_reviewed: 2026-06-09
---
# 工程实践指南
创建或修改任何 Python 项目时,必须严格遵守以下工程规范。
---
## 1. 包管理与虚拟环境
- **唯一包管理器**:使用 `uv`,不使用 `pip``pipenv``poetry`
- **依赖声明**:所有依赖统一在 `pyproject.toml` 中管理,遵循 PEP 621 + PEP 735。
- `[project].dependencies` 仅放运行时依赖。
- `[dependency-groups].dev` 放开发依赖测试、lint、类型检查等
- **版本锁定**:使用 `uv.lock` 锁定依赖版本,提交到版本控制。
- **安装命令**`uv sync` 创建虚拟环境并安装所有依赖。
- **运行命令**:所有 Python 命令通过 `uv run` 前缀执行,确保使用项目虚拟环境。
- **禁止**:全局安装 Python 包、手动 `python -m venv`、使用 `requirements.txt` 作为主要依赖文件。
### `pyproject.toml` 必填字段模板
```toml
[project]
name = "<project-name>"
version = "0.1.0"
description = "<项目描述>"
requires-python = ">=3.12"
dependencies = [
# 运行时依赖
]
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.9",
"mypy>=1.14",
"deptry>=0.22",
"pre-commit>=4.0",
]
```
---
## 2. 目录结构
```
<project-root>/
├── pyproject.toml # 项目配置(依赖 + 工具配置)
├── uv.lock # 依赖锁定文件
├── .pre-commit-config.yaml # 提交前检查配置
├── Makefile # Unix 任务脚本
├── tasks.py # Windows/跨平台任务脚本
├── .cursorignore # IDE 忽略配置
├── src/ # 源代码目录
│ └── main.py
├── tests/ # 测试目录
│ └── test_main.py
└── logs/ # 运行时产物(不提交)
```
- 源码统一放在 `src/` 下,不直接在根目录放业务代码。
- 测试统一放在 `tests/` 下。
- 运行时产物(日志、缓存、临时文件)不提交到版本控制。
---
## 3. 代码质量工具链
### 3.1 RuffLint + Format
`pyproject.toml` 中配置:
```toml
[tool.ruff]
target-version = "py312"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B"]
ignore = ["E501"]
```
- **规则覆盖**:格式错误(E/F/W)、导入排序(I)、命名规范(N)、语法升级(UP)、常见 Bug(B)。
- **格式化**:使用 `ruff format` 替代 Black。
- **运行方式**
- `uv run ruff check .` — 检查问题
- `uv run ruff check --fix .` — 自动修复
- `uv run ruff format .` — 格式化代码
- `uv run ruff format --check .` — 仅检查格式
### 3.2 MyPy静态类型检查
```toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
ignore_errors = true
```
- 启用严格模式,测试文件豁免。
- **运行方式**`uv run mypy src/main.py`
### 3.3 Deptry依赖审计
```toml
[tool.deptry]
ignore_notebooks = true
```
- 检测未使用、缺失、重复的依赖。
- **运行方式**`uv run deptry .`
---
## 4. 测试规范
### 4.1 Pytest 配置
```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
```
### 4.2 测试要求
- 测试文件命名:`test_*.py`,放在 `tests/` 目录。
- 测试函数/类命名:以 `test_``Test` 开头。
- 使用 `pytest.fixture` 管理测试资源。
- 必须使用 `pytest-cov` 生成覆盖率报告。
- **运行方式**
```bash
uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=term-missing
```
---
## 5. Pre-commit Hooks
`.pre-commit-config.yaml` 必须包含:
```yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.6
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
```
- 安装:`uv run pre-commit install`
- 手动运行:`uv run pre-commit run --all-files`
---
## 6. 任务运行器
### 6.1 MakefileUnix
```makefile
.PHONY: install check test run clean
install:
@uv sync
@uv run pre-commit install
check:
@uv lock --locked
@uv run ruff check .
@uv run ruff format --check .
@uv run mypy src/main.py
@uv run deptry .
test:
@uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=term-missing
run:
@uv run python src/main.py
clean:
@rm -rf .venv __pycache__ .pytest_cache .mypy_cache .ruff_cache
```
### 6.2 tasks.py跨平台
提供 `tasks.py` 作为 Windows 兼容的任务运行器,支持相同任务名:`install`、`check`、`test`、`run`、`clean`。
---
## 7. 版本控制忽略
`.cursorignore` / `.gitignore` 必须排除:
```
.venv/
__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.pyc
logs/
```
---
## 8. 开发工作流
新项目初始化顺序:
1. 创建 `pyproject.toml`,声明项目元数据和依赖。
2. 运行 `uv sync` 创建虚拟环境。
3. 创建 `.pre-commit-config.yaml`,运行 `uv run pre-commit install`。
4. 创建 `src/` 目录和入口文件。
5. 创建 `tests/` 目录和基础测试。
6. 创建 `Makefile` + `tasks.py`。
7. 运行 `make check` 或 `python tasks.py check` 验证代码质量。
8. 运行 `make test` 或 `python tasks.py test` 验证测试通过。
日常开发顺序:
1. `uv run ruff check --fix .` — 先修复 lint 问题。
2. `uv run ruff format .` — 格式化代码。
3. `uv run mypy src/` — 类型检查。
4. `uv run python -m pytest` — 运行测试。
5. 提交前 pre-commit 会自动执行检查和格式化。
---
## 9. 编码风格
- Python 3.12+ 语法,使用现代特性(如 `match/case`、类型合并 `X | Y`)。
- 函数和模块必须有 docstring。
- 优先使用类型注解,返回值类型必须标注。
- 行长度限制 120 字符。
- 导入按标准库 → 第三方 → 本地模块分组排序。
- 偏好函数式编程风格,避免不必要的面向对象封装。
- 配置与代码分离,使用常量或配置模块管理可变参数。
---
## 10. 强制检查清单
在提交代码或声明任务完成前,必须确认:
- [ ] `uv lock --locked` 通过(锁定文件与 pyproject.toml 一致)
- [ ] `uv run ruff check .` 无错误
- [ ] `uv run ruff format --check .` 无差异
- [ ] `uv run mypy src/` 无类型错误
- [ ] `uv run deptry .` 无依赖问题
- [ ] `uv run python -m pytest --cov` 全部通过且覆盖率合理
- [ ] 所有命令使用 `uv run` 前缀,无全局 pip 操作

View File

@@ -0,0 +1,9 @@
---
last_reviewed: 2026-06-09
---
# 标准元数据
`.agents/docs/standards/*.md` 下所有文件都必须包含 frontmatter字段包括
- `last_reviewed`:最近一次策略审查的 ISO 日期 `YYYY-MM-DD`

View File

@@ -0,0 +1,13 @@
---
last_reviewed: 2026-06-09
---
# 复利式工程实践
记录经验教训:
* 错误经验:`.agents/docs/error-experience/YYYY-MM-DD-<slug>.md`
* 正向经验:`.agents/docs/good-experience/YYYY-MM-DD-<slug>.md`
* 计划:`.agents/docs/plans/`
* 指南:`agents/docs/guides/`
在出现重大 bug、CI 失败或发现有价值模式后,创建一条条目并记录根因与经验。

View File

@@ -0,0 +1,34 @@
---
last_reviewed: 2026-06-09
---
# 调试规范
## 调试前检查清单
在循环调试任务前,需完成以下检查:
1. 梳理代码链路(最长耗时 5 分钟)。从入口函数追踪至异常执行环节,排查硬编码值、参数缺失或分支逻辑异常等问题。
2. 对比正常与异常场景。若功能 A 运行正常、功能 B 出现故障,梳理二者代码链路的差异,问题通常就出在差异部分。
3. 排查基础配置项。检查代理设置、环境变量、端口号、功能开关等。多数故障由配置问题导致,而非代码逻辑错误。
## 调试过程要求
1. 两次尝试原则。若同一排查方式(重跑测试、调整参数等)连续失败两次,立即停止,更换排查思路:
* 增加针对性日志或打印语句
* 阅读异常依赖库的源码
* 精简代码,复现最小故障案例
* 反思:自身哪些预设判断可能存在偏差
2. 禁止无限循环调试。定时任务仅用于监控正常运行的进程,不可作为调试工具。若定时循环连续两轮无进展,关闭循环,转为人工调试。
3. 记录排查思路。每开始一次尝试前,做好记录:
* 初步判断的问题原因
* 用于验证猜想的依据
* 本次准备执行的操作
* 避免重复无效尝试与逻辑死循环
## 调试收尾工作
1. 编写经验文档。所有非简单故障的调试工作,均需在`.agents/docs/error-experience/` 目录下新建记录文档,内容包含:
* 故障现象
* 历次排查操作及失败原因
* 最终解决方案
* 后续可借鉴的调试经验

BIN
.coverage Normal file

Binary file not shown.

7
.cursorignore Normal file
View File

@@ -0,0 +1,7 @@
.venv/
__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.playwright-mcp/
*.pyc

42
.gitignore vendored
View File

@@ -1,34 +1,10 @@
# Python .venv/
__pycache__/
*.py[cod]
*.pyo
*.egg-info/
dist/
build/
.eggs/
# Playwright MCP snapshots
.playwright-mcp/
# Local secrets & backups
config.json
*.doc.bak
# Uploads (user data)
web/uploads/
# Logs
pipeline.log
*.log
# Debug images
images/
# IDE
.vscode/
.idea/
.cursor/ .cursor/
__pycache__/
# OS .pytest_cache/
.DS_Store .mypy_cache/
Thumbs.db .ruff_cache/
.playwright-mcp/
*.pyc
logs/
.vscode/

7
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.6
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

19
AGENTS.md Normal file
View File

@@ -0,0 +1,19 @@
---
last_reviewed: 2026-06-09
---
# AGENTS 索引
本文件是规则的入口。详细策略文本位于 `.agents/docs/standards/*.md`
## 文档边界
* `docs/` 目录专门存放面向开源用户、外部贡献者的项目公开文档及说明文件。
* 维护规范、实施方案、经验总结、拉取请求佐证材料与各类内部记录资料,均统一放置在 `.agents/` 目录下,避免内部自动化流程相关内容混入公开文档目录。
* 每个文件夹下都有一个 `README.md` 文件用来交代这个文件夹的作用以及重要的信息。
## 标准目录
* 标准文档元数据:`.agents/docs/standards/README.md`
* 调试规范:`.agents/docs/standards/调试规范.md`
* 复利式工程实践:`.agents/docs/standards/复利式工程实践.md`

21
Makefile Normal file
View File

@@ -0,0 +1,21 @@
.PHONY: install check test run clean
install:
@uv sync
@uv run pre-commit install
check:
@uv lock --locked
@uv run ruff check .
@uv run ruff format --check .
@uv run mypy src/main.py
@uv run deptry .
test:
@uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=term-missing
run:
@uv run python src/main.py
clean:
@rm -rf .venv __pycache__ .pytest_cache .mypy_cache .ruff_cache

103
PowerShell注意事项.md Normal file
View File

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

158
README.md
View File

@@ -1,29 +1,42 @@
# 财务报销自动化 # 财务报销自动化
自动从 PDF 发票提取信息,OCR 识别支付记录截图,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。 自动从 PDF 发票提取信息,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
**支持发票类型区分**:系统自动识别高铁票、酒店住宿等差旅发票与普通发票。差旅发票不生成易耗品出库单,走差旅报销流程;普通发票生成出库单,走普通报销流程。
## 项目结构 ## 项目结构
``` ```
├── run.py # CLI 入口(发票提取 → OCR → 浏览器填报 ├── pyproject.toml # 项目配置(依赖、工具链
├── config.json # 配置文件(登录凭据、默认值、存放地点等) ├── uv.lock # 依赖锁定文件
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板Web/CLI 填写用 ├── Makefile # 任务脚本(跨平台
├── app/ ├── tasks.py # 任务脚本Windows 兼容)
├── config.json # 配置文件(登录凭据、默认值等)
├── config.example.json # 配置示例
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板
├── src/
│ ├── __init__.py # 包初始化 / 日志器
│ ├── config.py # 配置加载 │ ├── config.py # 配置加载
│ ├── extractor.py # PDF 发票信息提取
│ ├── ocr.py # OCR 刷卡信息识别
│ ├── bot.py # 浏览器自动填报 │ ├── bot.py # 浏览器自动填报
│ ├── pipeline.py # CLI 流程编排 │ ├── pipeline.py # CLI 流程编排
── fill_consumable_doc.py # 将 CSV 填入易耗品出库单Word COM ── main.py # CLI 入口
├── web/ │ ├── doc/ # 文档处理模块
│ │ ├── extractor.py # 编排入口:串联 PDF 读取 → LLM 提取 → 分类
│ │ ├── pdf.py # PDF 文件发现与文本提取
│ │ ├── llm_extractor.py # LLM 信息提取
│ │ ├── invoice.py # 发票类型常量、分类逻辑、CSV 读写工具
│ │ ├── fill_consumable_doc.py # 将 CSV 填入易耗品出库单Word COM
│ │ ├── prompt.py # LLM 提示词模板
│ │ └── prompts/ # 提示词模板文件
│ └── web/
│ ├── app.py # Web 服务入口 │ ├── app.py # Web 服务入口
│ ├── templates/ │ ├── templates/
│ │ ├── index.html # PC 端主页 │ │ ├── index.html # PC 端主页
│ │ └── mobile_upload.html # 移动端扫码上传 │ │ └── mobile_upload.html # 移动端扫码上传
│ ├── static/ # 前端 CSS / JS │ ├── static/ # 前端 CSS / JS
│ └── uploads/ # 按会话隔离的上传与产物目录 │ └── uploads/ # 按会话隔离的上传与产物目录
├── tests/ # 测试目录
├── *.pdf # 发票 PDFCLI 模式,放在项目根目录) ├── *.pdf # 发票 PDFCLI 模式,放在项目根目录)
├── *.png / *.jpg # 与 PDF 配对的支付截图
├── invoice_summary.csv # 发票汇总表CLI 产物) ├── invoice_summary.csv # 发票汇总表CLI 产物)
└── images/ # 浏览器调试截图 └── images/ # 浏览器调试截图
``` ```
@@ -33,49 +46,47 @@
```mermaid ```mermaid
flowchart TB flowchart TB
PDF[PDF 发票] --> Extract[extractor 提取] PDF[PDF 发票] --> Extract[extractor 提取]
Extract --> List[发票列表] Extract --> Classify{发票类型分类}
Img[支付截图] --> OCR[OCR 识别] Classify -->|差旅发票| Travel[高铁票 / 酒店住宿]
List --> OCR Classify -->|普通发票| General[普通发票]
OCR --> CSV[(invoice_summary.csv)] Travel --> CSV[(invoice_summary.csv)]
CSV --> Fill[fill_consumable_doc] General --> CSV
CSV -->|仅普通发票| Fill[fill_consumable_doc]
Fill --> Doc[易耗品、出库单.doc] Fill --> Doc[易耗品、出库单.doc]
CSV --> Bot[bot 浏览器自动化] CSV --> Bot[bot 浏览器自动化]
Bot --> Submit[财务系统填报<br/>可选] Bot -->|差旅模式| Submit_T[差旅报销填报<br/>TODO]
Bot -->|普通模式| Submit_G[普通报销填报]
``` ```
## 环境要求 ## 环境要求
- Python 3.10+ - **Python 3.12+**
- **uv** 包管理器([安装指南](https://docs.astral.sh/uv/getting-started/installation/)
- Windows易耗品出库单填写依赖 Microsoft Word + COM仅 Windows 可用) - Windows易耗品出库单填写依赖 Microsoft Word + COM仅 Windows 可用)
- 主要依赖见下方安装步骤
## 快速开始 ## 快速开始
### 1. 安装依赖 ### 1. 安装依赖
> OCR 相关依赖建议在已有 PaddleOCR 的环境中安装(如 MinerU 虚拟环境)。
```bash ```bash
pip install pdfplumber==0.11.9 paddleocr==2.8.1 playwright==1.60.0 flask==3.0.3 pywin32 # 同步所有依赖(运行时 + 开发工具)
playwright install chromium make install
# Windows 上等效命令:
python tasks.py install
``` ```
项目使用 `uv` 管理依赖,所有包版本锁定在 `uv.lock` 中,确保可复现。
| 依赖 | 用途 | | 依赖 | 用途 |
|------|------| |------|------|
| pdfplumber | PDF 发票文本提取 | | pdfplumber | PDF 发票文本提取 |
| paddleocr | 支付截图 OCR |
| playwright | 财务系统浏览器自动化 | | playwright | 财务系统浏览器自动化 |
| flask | Web 服务 | | flask | Web 服务 |
| pywin32 | 填写 Word 出库单(`fill_consumable_doc` | | pywin32 | 填写 Word 出库单(`fill_consumable_doc` |
### 2. 准备数据CLI 模式) ### 2. 准备数据CLI 模式)
将发票 PDF 和对应的支付截图放在项目根目录下。脚本会自动匹配 PDF 与截图: 将发票 PDF 放在项目根目录下。
1. **优先文件名匹配** — PDF 与截图同名(如 `发票.pdf``发票.png`
2. **金额近邻匹配** — 文件名不同时,按价税合计与刷卡金额就近配对
截图支持格式:`.png``.jpg``.jpeg``.bmp``.webp`
### 3. 配置 ### 3. 配置
@@ -104,16 +115,17 @@ playwright install chromium
### 4. 运行CLI ### 4. 运行CLI
```bash ```bash
# 全流程(发票提取 → OCR 识别 → 浏览器填报) # 全流程(发票提取 → 浏览器填报)
python run.py make run
# Windows 等效:
python tasks.py run
# 仅执行某一步 # 仅执行某一步
python run.py --step invoice # 仅发票提取 uv run python src/main.py --step invoice # 仅发票提取
python run.py --step ocr # 仅 OCR 识别 uv run python src/main.py --step submit # 仅浏览器填报
python run.py --step submit # 仅浏览器填报
# 覆盖配置中的登录凭据 # 覆盖配置中的登录凭据
python run.py -u 工号 -p 密码 uv run python src/main.py -u 工号 -p 密码
``` ```
### 5. 填写易耗品出库单CLI ### 5. 填写易耗品出库单CLI
@@ -121,9 +133,9 @@ python run.py -u 工号 -p 密码
需已生成 `invoice_summary.csv`,且本机已安装 **Microsoft Word** 需已生成 `invoice_summary.csv`,且本机已安装 **Microsoft Word**
```bash ```bash
python -m app.fill_consumable_doc uv run python -m src.doc.fill_consumable_doc
python -m app.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc" uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份 uv run python -m src.doc.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
``` ```
填写规则概要: 填写规则概要:
@@ -137,8 +149,7 @@ python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
| 步骤 | 命令 | 说明 | | 步骤 | 命令 | 说明 |
|------|------|------| |------|------|------|
| 发票提取 | `--step invoice` | 扫描根目录 PDF生成 `invoice_summary.csv` / `.md` | | 发票提取 | `--step invoice` | 扫描根目录 PDF生成 `invoice_summary.csv` |
| OCR 识别 | `--step ocr` | 识别支付截图,回填刷卡日期、金额、持卡人等到 CSV |
| 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 | | 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 |
> 分步执行时,上一步的 CSV 会自动成为下一步的输入。 > 分步执行时,上一步的 CSV 会自动成为下一步的输入。
@@ -148,12 +159,14 @@ python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
| 列名 | 说明 | | 列名 | 说明 |
|------|------| |------|------|
| 序号 | 行号 | | 序号 | 行号 |
| 发票类型 | 自动识别:`高铁票` / `酒店住宿` / `普通发票` |
| 发票号码 | 电子发票号码 | | 发票号码 | 电子发票号码 |
| 开票日期 | 开票日期 | | 开票日期 | 开票日期 |
| 项目名称 / 规格型号 | 货物或应税劳务信息 | | 项目名称 / 规格型号 | 货物或应税劳务信息(差旅发票为出发站→到达站) |
| 价税合计 | 发票含税金额 | | 价税合计 | 发票含税金额 |
| 销售方名称 | 销方名称 | | 销售方名称 | 销方名称 |
| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | OCR 自支付截图回填 | | 出发站 / 到达站 / 车次 / 乘车日期 / 座位等级 | 高铁票专用字段 |
| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | 可手工或 Web 端编辑补充 |
| 备注 / 工号 | 可手工或 Web 端编辑补充 | | 备注 / 工号 | 可手工或 Web 端编辑补充 |
## Web 服务 ## Web 服务
@@ -161,48 +174,85 @@ python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
提供浏览器界面:上传文件 → 自动处理 → 在线编辑 → 下载产物 → 可选提交财务系统。 提供浏览器界面:上传文件 → 自动处理 → 在线编辑 → 下载产物 → 可选提交财务系统。
```bash ```bash
python web/app.py uv run python src/web/app.py
``` ```
访问 `http://localhost:5000` 访问 `http://localhost:5000`
### 推荐使用流程 ### 推荐使用流程
1. 上传 PDF + 支付截图(或上传已有 CSV 1. 上传 PDF或上传已有 CSV
2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充 2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充
3. 点击 **开始处理** — 完成发票提取、OCR、生成 CSV并自动填写 **易耗品、出库单.doc** 3. 点击 **开始处理** — 完成发票提取、生成 CSV系统自动识别发票类型并分类统计
4. **下载文件** 区域下载 CSV / Markdown / 出库单 Word 4. **普通发票**:自动生成 **易耗品、出库单.doc**,可下载
5. 在表格中核对、修改发票数据(提交财务系统前会自动保存) 5. **差旅发票**(高铁票/酒店住宿):跳过出库单生成,直接进入差旅报销流程
6. 确认无误后点击 **提交财务系统** 6. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
7. 确认无误后点击 **提交到财务系统**
### 处理模式 ### 处理模式
| 模式 | 入口 | 说明 | | 模式 | 入口 | 说明 |
|------|------|------| |------|------|------|
| **PDF 模式** | 上传 PDF + 截图 | 提取发票 → OCR → 生成 CSV + 出库单 | | **PDF 模式** | 上传 PDF | 提取发票 → 生成 CSV + 自动分类 → 普通发票生成出库单 |
| **CSV 快捷模式** | 仅上传 CSV | 跳过提取与 OCR直接生成出库单并进入编辑/提交 | | **CSV 快捷模式** | 仅上传 CSV | 跳过提取,读取 CSV 中的发票类型,按需生成出库单 |
### 发票类型区分
系统自动识别以下发票类型并按类型分流:
| 发票类型 | 识别依据 | 出库单 | 填报模式 |
|----------|----------|--------|----------|
| **高铁票** | 含"电子客票"、"中国铁路"、"12306"等关键词 | 不生成 | 差旅报销TODO |
| **酒店住宿** | 含"住宿费"、"餐饮服务"、"租赁服务"等关键词 | 不生成 | 差旅报销TODO |
| **普通发票** | 其他所有发票 | 自动生成 | 普通报销(已实现) |
当同一批次同时包含差旅发票和普通发票时,系统会为普通发票生成出库单,并在填报时分别处理。
### 功能一览 ### 功能一览
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
| 发票提取 + OCR | 上传 PDF 后自动完成 | | 发票提取 | 上传 PDF 后自动完成 |
| 易耗品出库单 | 处理完成后自动生成 Word可下载 | | 发票类型自动分类 | 高铁票/酒店住宿/普通发票,自动分流处理 |
| 易耗品出库单 | 仅普通发票自动生成 Word差旅发票跳过 |
| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 | | 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 |
| 财务系统填报 | 单独按钮触发,处理阶段不会自动提交 | | 财务系统填报 | 单独按钮触发,处理阶段不会自动提交 |
| 实时日志 | SSE 推送处理进度 | | 实时日志 | SSE 推送处理进度 |
| 配置上传 | 支持上传 `config.json` 填充表单 | | 配置上传 | 支持上传 `config.json` 填充表单 |
| 手机扫码上传 | 二维码打开移动端页面,拍照上传支付截图PC 端轮询同步 | | 手机扫码上传 | 二维码打开移动端页面拍照上传PC 端轮询同步 |
> Web 端浏览器填报以无头模式运行。未上传 PDF 时,填报阶段会跳过附件上传。 > Web 端浏览器填报以无头模式运行。未上传 PDF 时,填报阶段会跳过附件上传。
> 出库单生成需要 **Windows + Word + pywin32**若失败页面会显示具体原因CSV 等其它产物仍可正常使用。 > 出库单生成需要 **Windows + Word + pywin32**若失败页面会显示具体原因CSV 等其它产物仍可正常使用。
接口说明见 [API.md](./API.md)。 接口说明见 [API.md](./API.md)。
## 开发任务
项目提供统一的任务脚本,支持跨平台使用:
| 任务 | Makefile | tasks.py | 说明 |
|------|----------|----------|------|
| 安装依赖 | `make install` | `python tasks.py install` | 同步依赖 + 安装 pre-commit |
| 代码检查 | `make check` | `python tasks.py check` | Ruff lint + 格式化 + MyPy 类型检查 + deptry 依赖检查 |
| 运行测试 | `make test` | `python tasks.py test` | pytest + 覆盖率报告 |
| 运行 CLI | `make run` | `python tasks.py run` | 执行全流程 |
| 清理缓存 | `make clean` | `python tasks.py clean` | 删除虚拟环境和缓存 |
## 代码质量
项目配置了完整的代码质量工具链:
- **Ruff** — 快速 lint 检查和代码格式化(替代 flake8 + isort + black
- **MyPy** — 严格模式类型检查(`strict = true`
- **deptry** — 检测未声明、未使用、过时依赖
- **pre-commit** — 提交前自动运行 Ruff 检查和格式化
所有检查通过后方可提交代码。
## 注意事项 ## 注意事项
- 浏览器填报时会打开或使用 Chromium请勿手动干扰自动化流程 - 浏览器填报时会打开或使用 Chromium请勿手动干扰自动化流程
- 调试截图保存在 `images/` 目录 - 调试截图保存在 `images/` 目录
- OCR 不会覆盖 CSV 中已有非空字段
- 项目根目录需保留 `易耗品、出库单.doc` 模板Web 每次从模板复制到会话目录再填写,不修改原模板 - 项目根目录需保留 `易耗品、出库单.doc` 模板Web 每次从模板复制到会话目录再填写,不修改原模板
- `config.json` 含敏感信息,请勿提交到公开仓库 - `config.json` 含敏感信息,请勿提交到公开仓库
- **发票类型区分**:差旅发票(高铁票/酒店住宿不会生成易耗品出库单当前差旅报销填报流程仍在开发中TODO

View File

@@ -1,256 +0,0 @@
"""
PDF 发票信息提取
从 PDF 发票文件中提取关键字段,输出为标准化的发票数据列表。
对外接口:
extract_invoices(directory) -> list[dict] 扫描目录下所有 PDF 并提取
save_csv(invoices, path) 保存为 CSV
save_markdown(invoices, path) 保存为 Markdown 汇总
"""
import csv
import re
from pathlib import Path
from . import get_logger
log = get_logger("extractor")
CSV_COLUMNS = [
"序号", "发票号码", "开票日期", "项目名称", "规格型号",
"价税合计", "销售方名称", "人员姓名", "刷卡日期",
"公务卡号", "刷卡金额", "备注", "工号",
]
# ------------------------------------------------------------------
# PDF 文件发现与文本提取
# ------------------------------------------------------------------
def find_pdf_files(directory: str = ".") -> list[Path]:
"""查找目录下所有 PDF 文件(非递归)"""
pdf_dir = Path(directory)
if not pdf_dir.exists():
return []
return sorted(pdf_dir.glob("*.pdf"))
def extract_text_from_pdf(filepath: Path) -> str:
"""从单个 PDF 中提取全部文本"""
try:
import pdfplumber
except ImportError:
raise ImportError("缺少 pdfplumber请执行: pip install pdfplumber")
try:
parts = []
with pdfplumber.open(filepath) as pdf:
for page in pdf.pages:
text = page.extract_text()
if text:
parts.append(text)
return "\n".join(parts)
except Exception as e:
log.error(f"无法读取 {filepath.name}: {e}")
return ""
# ------------------------------------------------------------------
# 字段解析
# ------------------------------------------------------------------
def _first(regexes: list[str], text: str) -> str | None:
"""尝试多个正则,返回第一个匹配组的文本"""
for pattern in regexes:
m = re.search(pattern, text)
if m:
return m.group(1).strip()
return None
def _parse_line_item(line: str) -> dict | None:
"""解析单行明细(*分类*具体名称 格式)"""
m = re.match(r"\*([^*]+)\*\s*(.+)", line)
if m:
return {
"项目名称": f"*{m.group(1).strip()}*{m.group(2).strip()}",
"规格型号": m.group(2).strip(),
}
return None
def _extract_line_items(text: str) -> list[dict]:
"""从发票文本中提取所有明细行"""
items = []
skip_keywords = ["项目名称", "合 计", "价税合计", "备注", "开票人"]
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if any(kw in line for kw in skip_keywords):
continue
if "*" in line:
item = _parse_line_item(line)
if item:
items.append(item)
return items
def _format_date(date_raw: str) -> str:
"""将「2026年5月18日」转为「2026/5/18」"""
m = re.match(r"(\d{4})年(\d{1,2})月(\d{1,2})日", date_raw)
if m:
return f"{m.group(1)}/{m.group(2)}/{m.group(3)}"
return date_raw
def parse_invoice(text: str) -> dict:
"""从发票文本中提取关键字段,返回 dict
返回字段:
发票号码, 开票日期, 销售方名称, 价税合计, _items (明细列表)
其他字段(人员姓名等)留空,后续由 OCR 步骤填充
"""
invoice: dict[str, str] = {}
invoice["发票号码"] = _first([r"发票号码[:]?\s*(\d+)"], text) or ""
date_raw = _first([r"开票日期[:]?\s*(\d{4}\d{1,2}月\d{1,2}日)"], text) or ""
invoice["开票日期"] = _format_date(date_raw) if date_raw else ""
invoice["销售方名称"] = _first(
[
r"\s*售?\s*方?\s*名称[:]?\s*(.+?)(?:\n|$)",
r"\s*名称[:]?\s*(.+?)(?:\n|$)",
],
text,
) or ""
invoice["价税合计"] = _first(
[r"价税合计.*?(小写)[¥¥]?\s*(\d+\.?\d*)"], text
) or ""
invoice["_items"] = _extract_line_items(text)
# 以下字段无法从 PDF 提取,留空由 OCR 步骤填充
for key in ("项目名称", "规格型号", "人员姓名", "刷卡日期",
"公务卡号", "刷卡金额", "备注", "工号"):
if key not in invoice:
invoice[key] = ""
return invoice
# ------------------------------------------------------------------
# CSV / Markdown 输出
# ------------------------------------------------------------------
def save_csv(invoices: list[dict], output_path: str | Path = "invoice_summary.csv"):
"""将发票列表保存为 CSV"""
csv_path = Path(output_path)
with open(csv_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(CSV_COLUMNS)
for idx, inv in enumerate(invoices, 1):
items = inv.get("_items", [])
first_item = items[0] if items else {}
writer.writerow([
idx,
inv.get("发票号码", ""),
inv.get("开票日期", ""),
first_item.get("项目名称", inv.get("项目名称", "")),
first_item.get("规格型号", inv.get("规格型号", "")),
inv.get("价税合计", ""),
inv.get("销售方名称", ""),
inv.get("人员姓名", ""),
inv.get("刷卡日期", ""),
inv.get("公务卡号", ""),
inv.get("刷卡金额", ""),
inv.get("备注", ""),
inv.get("工号", ""),
])
log.info(f"CSV 已保存: {csv_path.name}")
def save_markdown(invoices: list[dict], output_path: str | Path = "invoice_summary.md"):
"""将发票列表保存为 Markdown 汇总表"""
md_path = Path(output_path)
lines = [
"# 发票信息汇总表",
"",
"| 序号 | 发票号码 | 开票日期 | 项目名称 | 规格型号 | 价税合计 | 销售方名称 |",
"|------|---------|---------|---------|---------|---------|-----------|",
]
total = 0.0
for idx, inv in enumerate(invoices, 1):
amount = 0.0
try:
amount = float(inv.get("价税合计", "0"))
except (ValueError, TypeError):
pass
total += amount
items = inv.get("_items", [])
first_item = items[0] if items else {}
project = first_item.get("项目名称", inv.get("项目名称", "-"))
spec = first_item.get("规格型号", inv.get("规格型号", "-"))
lines.append(
f"| {idx} "
f"| {inv.get('发票号码', '')} "
f"| {inv.get('开票日期', '')} "
f"| {project} | {spec} "
f"| ¥{amount:,.2f} "
f"| {inv.get('销售方名称', '')} |"
)
lines.append("")
lines.append(f"**总计: ¥{total:,.2f}**")
lines.append("")
with open(md_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
log.info(f"Markdown 已保存: {md_path.name}")
# ------------------------------------------------------------------
# 主入口
# ------------------------------------------------------------------
def extract_invoices(directory: str = ".") -> list[dict]:
"""扫描目录下所有 PDF提取发票信息并返回列表"""
target_dir = Path(directory).absolute()
pdf_files = find_pdf_files(directory)
if not pdf_files:
log.warning("未找到 PDF 文件")
return []
log.info(f"发现 {len(pdf_files)} 个 PDF 文件")
all_invoices = []
for pdf_path in pdf_files:
text = extract_text_from_pdf(pdf_path)
if text:
invoice = parse_invoice(text)
if invoice:
all_invoices.append(invoice)
else:
log.warning(f"未能解析: {pdf_path.name}")
else:
log.warning(f"未能提取文本: {pdf_path.name}")
if all_invoices:
log.info(f"共处理 {len(all_invoices)} 张发票")
else:
log.warning("未成功解析任何发票")
return all_invoices

View File

@@ -1,454 +0,0 @@
"""
OCR 刷卡信息提取
从支付截图中识别刷卡记录(姓名、日期、金额),回填到发票数据中。
匹配策略:
1. 先按文件名匹配PDF 和图片同名)
2. 未匹配的通过金额近邻匹配
对外接口:
enrich_with_ocr(rows, directory) -> list[dict] 用 OCR 识别结果丰富发票数据
"""
import csv
import os
import re
from pathlib import Path
from . import get_logger
log = get_logger("ocr")
# ------------------------------------------------------------------
# 懒加载 OCR
# ------------------------------------------------------------------
_ocr_instance = None
def _get_ocr():
"""懒加载 PaddleOCR 实例(兼容 2.x / 3.x"""
global _ocr_instance
if _ocr_instance is not None:
return _ocr_instance
os.environ.setdefault("FLAGS_use_mkldnn", "0")
os.environ.setdefault("FLAGS_mkldnn_cache_enabled", "0")
from paddleocr import PaddleOCR
try:
_ocr_instance = PaddleOCR(use_textline_orientation=True, lang="ch")
except TypeError:
try:
_ocr_instance = PaddleOCR(lang="ch")
except TypeError:
_ocr_instance = PaddleOCR()
return _ocr_instance
# ------------------------------------------------------------------
# OCR 识别
# ------------------------------------------------------------------
def ocr_image(image_path: Path) -> list[dict]:
"""对单张图片执行 OCR返回 [{"text": str, "confidence": float}, ...]"""
ocr = _get_ocr()
texts = []
try:
results = ocr.ocr(str(image_path), cls=True)
if results and isinstance(results, list):
for page_result in results:
if not page_result:
continue
for line in page_result:
if isinstance(line, (list, tuple)) and len(line) >= 2:
_, text_info = line[0], line[1]
if isinstance(text_info, (list, tuple)) and len(text_info) >= 2:
texts.append({
"text": str(text_info[0]),
"confidence": float(text_info[1]),
})
except Exception:
try:
if hasattr(ocr, "predict"):
results = ocr.predict(str(image_path))
if results:
for result in results:
if hasattr(result, "rec_result_list"):
for line in result.rec_result_list:
t = getattr(line, "text", "") or ""
s = getattr(line, "score", 0.0) or 0.0
texts.append({"text": str(t), "confidence": float(s)})
elif isinstance(result, list):
for line in result:
if isinstance(line, (list, tuple)) and len(line) >= 2:
t = line[1][0] if isinstance(line[1], (list, tuple)) else str(line[1])
s = line[1][1] if isinstance(line[1], (list, tuple)) and len(line[1]) > 1 else 0.0
texts.append({"text": str(t), "confidence": float(s)})
except Exception as e:
log.error(f"OCR 识别失败: {e}")
return texts
def extract_card_info(texts: list[dict]) -> dict:
"""从 OCR 文本中提取刷卡信息(日期 / 金额 / 姓名)"""
info = {"刷卡日期": "", "刷卡金额": "", "人员姓名": ""}
valid = [t for t in texts if t["confidence"] > 0.5]
full_text = " ".join(t["text"] for t in valid)
if not full_text:
return info
# 日期(优先级匹配,避免误抓发票开票日期)
date_candidates = []
for pattern, priority in [
(r"记账时间[:\s]*(\d{4}[-/]\d{1,2}[-/]\d{1,2})", 10),
(r"交易时间[:\s]*(\d{4}[-/]\d{1,2}[-/]\d{1,2})", 9),
(r"刷卡日期[:\s]*(\d{4}[-/]\d{1,2}[-/]\d{1,2})", 9),
(r"日期[:\s]*(\d{4}[-/]\d{1,2}[-/]\d{1,2})", 5),
]:
for m in re.finditer(pattern, full_text):
date_candidates.append((priority, m.start(), m.group(1).replace("-", "/")))
if date_candidates:
date_candidates.sort(key=lambda x: (-x[0], x[1]))
info["刷卡日期"] = date_candidates[0][2]
# 金额
amount_candidates = []
for pattern, priority in [
(r"交易金额[:\s]*([+-]?[\d,]+\.?\d*)", 10),
(r"刷卡金额[:\s]*([+-]?[\d,]+\.?\d*)", 10),
(r"金额[:\s]*([+-]?[\d,]+\.?\d*)", 5),
]:
for m in re.finditer(pattern, full_text):
amt_str = m.group(1).replace(",", "").replace("+", "")
try:
val = float(amt_str)
if 0 < val < 999999:
amount_candidates.append((priority, m.start(), amt_str))
except ValueError:
continue
if amount_candidates:
amount_candidates.sort(key=lambda x: (-x[0], x[1]))
info["刷卡金额"] = amount_candidates[0][2]
# 姓名(排除公司/机构后缀)
EXCLUDE_SUFFIXES = ("公司", "银行", "中心", "支行", "商户", "网点", "有限", "责任")
name_candidates = []
for pattern, priority in [
(r"交易户名[:\s]*([\u4e00-\u9fff]{2,6})", 10),
(r"户名[:\s]*([\u4e00-\u9fff]{2,6})", 8),
(r"持卡人[:\s]*([\u4e00-\u9fff]{2,6})", 8),
(r"姓名[:\s]*([\u4e00-\u9fff]{2,6})", 8),
]:
for m in re.finditer(pattern, full_text):
name = m.group(1)
if not any(name.endswith(s) for s in EXCLUDE_SUFFIXES):
name_candidates.append((priority, m.start(), name))
if name_candidates:
name_candidates.sort(key=lambda x: (-x[0], x[1]))
info["人员姓名"] = name_candidates[0][2]
return info
# ------------------------------------------------------------------
# PDF 发票号提取
# ------------------------------------------------------------------
def extract_invoice_number(pdf_path: Path) -> str:
"""从 PDF 中提取发票号码"""
try:
import pdfplumber
except ImportError:
log.warning("缺少 pdfplumber跳过发票号提取")
return ""
try:
with pdfplumber.open(str(pdf_path)) as pdf_file:
page_text = ""
for page in pdf_file.pages:
page_text += page.extract_text() or ""
for pattern in [
r"发票号码[:\s]*([A-Za-z0-9]{8,20})",
r"发票代码[:\s]*([A-Za-z0-9]{10,12})",
r"号码[:\s]*([A-Za-z0-9]{8,20})",
]:
m = re.search(pattern, page_text)
if m:
return m.group(1)
except Exception as e:
log.warning(f"PDF 读取失败 ({pdf_path.name}): {e}")
return ""
# ------------------------------------------------------------------
# 图片配对
# ------------------------------------------------------------------
def _extract_amount_from_pdf(pdf_path: Path) -> float | None:
"""从 PDF 中提取价税合计金额"""
try:
import pdfplumber
with pdfplumber.open(str(pdf_path)) as pdf:
text = ""
for page in pdf.pages:
t = page.extract_text()
if t:
text += t + "\n"
m = re.search(r"价税合计.*?(小写)[¥¥]?\s*(\d+\.?\d*)", text)
if m:
return float(m.group(1))
except Exception:
pass
return None
def _extract_amount_from_image(img_path: Path) -> float | None:
"""从图片 OCR 中提取刷卡金额"""
texts = ocr_image(img_path)
if not texts:
return None
info = extract_card_info(texts)
amt_str = info.get("刷卡金额", "")
if amt_str:
try:
return float(amt_str)
except ValueError:
pass
return None
def find_image_pairs(directory: str = ".") -> list[tuple[Path, Path]]:
"""查找 PDF 和对应图片的配对
1. 先按文件名匹配PDF 和图片同名)
2. 未匹配的通过金额近邻匹配
"""
base = Path(directory)
pdfs = sorted(base.glob("*.pdf"))
image_exts = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
all_images = sorted(
f for ext in image_exts for f in base.glob(f"*{ext}")
)
# ---- Phase 1: 文件名匹配 ----
pairs: list[tuple[Path, Path]] = []
matched_pdfs: set[Path] = set()
matched_imgs: set[Path] = set()
for pdf in pdfs:
for ext in image_exts:
img = base / f"{pdf.stem}{ext}"
if img.exists():
pairs.append((pdf, img))
matched_pdfs.add(pdf)
matched_imgs.add(img)
break
unmatched_pdfs = [p for p in pdfs if p not in matched_pdfs]
unmatched_imgs = [i for i in all_images if i not in matched_imgs]
if not unmatched_pdfs or not unmatched_imgs:
return pairs
# ---- Phase 2: 金额近邻匹配 ----
if len(unmatched_pdfs) > 0 and len(unmatched_imgs) > 0:
log.info(f"文件名匹配 {len(pairs)} 组,剩余 {len(unmatched_pdfs)} 个 PDF、{len(unmatched_imgs)} 张图片,尝试金额匹配...")
pdf_amounts: dict[Path, float] = {}
for pdf in unmatched_pdfs:
amt = _extract_amount_from_pdf(pdf)
if amt is not None:
pdf_amounts[pdf] = amt
img_amounts: dict[Path, float] = {}
for img in unmatched_imgs:
amt = _extract_amount_from_image(img)
if amt is not None:
img_amounts[img] = amt
# 贪婪匹配:每张图片找金额差最小的 PDF
used_pdfs: set[Path] = set()
for img, img_amt in sorted(img_amounts.items(), key=lambda x: x[0].name):
best_pdf: Path | None = None
best_diff: float = float("inf")
for pdf, pdf_amt in pdf_amounts.items():
if pdf in used_pdfs:
continue
diff = abs(pdf_amt - img_amt)
if diff < best_diff:
best_diff = diff
best_pdf = pdf
if best_pdf is not None:
pairs.append((best_pdf, img))
used_pdfs.add(best_pdf)
log.info(f"金额匹配完成,共 {len(pairs)} 组配对")
return pairs
# ------------------------------------------------------------------
# CSV 读写
# ------------------------------------------------------------------
from .extractor import CSV_COLUMNS
def _load_csv(csv_path: Path) -> list[dict] | None:
"""读取现有 CSV 为 dict 列表,失败返回 None"""
try:
with open(csv_path, encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames or []
missing = [c for c in CSV_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 _save_csv(csv_path: Path, rows: list[dict]):
"""保存 CSV"""
with open(csv_path, "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS)
writer.writeheader()
writer.writerows(rows)
# ------------------------------------------------------------------
# Markdown 同步
# ------------------------------------------------------------------
def save_markdown_from_csv(csv_path: Path, rows: list[dict]):
"""根据最新 CSV 数据生成 Markdown 汇总表"""
md_path = csv_path.with_suffix(".md")
columns = [
("序号", "序号"), ("发票号码", "发票号码"), ("开票日期", "开票日期"),
("项目名称", "项目名称"), ("规格型号", "规格型号"), ("价税合计", "价税合计"),
("销售方名称", "销售方名称"), ("人员姓名", "人员姓名"),
("刷卡日期", "刷卡日期"), ("公务卡号", "公务卡号"),
("刷卡金额", "刷卡金额"), ("备注", "备注"), ("工号", "工号"),
]
lines = ["# 发票信息汇总表", ""]
header = " | ".join(col[1] for col in columns)
separator = "|".join(["------" for _ in columns])
lines.append(f"| {header} |")
lines.append(f"|{separator}|")
total_price = 0.0
total_card = 0.0
for row in rows:
cells = []
for key, _ in columns:
value = row.get(key, "").strip()
if key == "价税合计" and value:
try:
total_price += float(value.replace(",", ""))
cells.append(f"¥{float(value.replace(',', '')):,.2f}")
except (ValueError, TypeError):
cells.append(value)
elif key == "刷卡金额" and value:
try:
total_card += float(value.replace(",", ""))
cells.append(f"¥{float(value.replace(',', '')):,.2f}")
except (ValueError, TypeError):
cells.append(value)
else:
cells.append(value if value else "")
lines.append("| " + " | ".join(cells) + " |")
lines.append("")
lines.append(f"**价税合计总计: ¥{total_price:,.2f}**")
lines.append(f"**刷卡金额总计: ¥{total_card:,.2f}**")
lines.append("")
with open(md_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
log.info(f"Markdown 已同步: {md_path.name}")
# ------------------------------------------------------------------
# 主入口
# ------------------------------------------------------------------
def enrich_with_ocr(rows: list[dict], directory: str = ".") -> list[dict]:
"""用 OCR 识别结果丰富发票数据,返回更新后的行列表
rows 应包含「发票号码」列,已存在的字段不会覆盖。
"""
pairs = find_image_pairs(directory)
if not pairs:
log.warning("未找到 PDF-图片配对文件,跳过 OCR")
return rows
log.info(f"找到 {len(pairs)} 组 PDF-图片配对")
ocr_by_invoice: dict[str, dict] = {}
for idx, (pdf, img) in enumerate(pairs, 1):
inv_num = extract_invoice_number(pdf)
if not inv_num:
inv_num = pdf.stem
texts = ocr_image(img)
if not texts:
log.warning(f"OCR 未识别到文本: {img.name}")
continue
info = extract_card_info(texts)
ocr_by_invoice[inv_num] = info
# 更新行数据
updated = 0
matched = 0
for i, row in enumerate(rows):
inv_num = row.get("发票号码", "").strip()
ocr_info = ocr_by_invoice.get(inv_num)
if not ocr_info:
for key, val in ocr_by_invoice.items():
if inv_num in key or key in inv_num:
ocr_info = val
break
if ocr_info:
matched += 1
if not row.get("人员姓名", "").strip() and ocr_info["人员姓名"]:
row["人员姓名"] = ocr_info["人员姓名"]
updated += 1
if not row.get("刷卡日期", "").strip() and ocr_info["刷卡日期"]:
row["刷卡日期"] = ocr_info["刷卡日期"]
updated += 1
if not row.get("刷卡金额", "").strip() and ocr_info["刷卡金额"]:
row["刷卡金额"] = ocr_info["刷卡金额"]
updated += 1
log.info(f"OCR 完成: 匹配 {matched}/{len(rows)} 行,更新 {updated} 个字段")
return rows

View File

@@ -1,128 +0,0 @@
"""
报销全流程编排
将发票提取 → OCR 识别 → 浏览器填报串联为一条管道,
数据在内存中流转,同时生成 CSV / Markdown 中间产物。
"""
import sys
from pathlib import Path
from . import get_logger
from .config import load_config
from .extractor import extract_invoices, save_csv as save_invoice_csv, save_markdown as save_invoice_md
from .ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv
log = get_logger("pipeline")
def run_pipeline(step: str = "all", username: str = None, password: str = None):
"""执行报销流程
Args:
step: all | invoice | ocr | submit
username: 覆盖 config.json 中的用户名
password: 覆盖 config.json 中的密码
"""
config = load_config()
if username:
config["username"] = username
if password:
config["password"] = password
# 工作目录(项目根目录)
project_dir = Path(__file__).parent.parent
# --------------------------------------------------
# Step 1: 发票提取
# --------------------------------------------------
invoices = None
if step in ("all", "invoice"):
log.info("=" * 60)
log.info("[1/3] 发票提取")
log.info("=" * 60)
invoices = extract_invoices(str(project_dir))
if not invoices:
log.error("未提取到任何发票数据")
return 1
save_invoice_csv(invoices, project_dir / "invoice_summary.csv")
save_invoice_md(invoices, project_dir / "invoice_summary.md")
if step == "invoice":
log.info("[1/3] 发票提取 完成")
return 0
# --------------------------------------------------
# Step 2: OCR 识别
# --------------------------------------------------
if step in ("all", "ocr"):
log.info("=" * 60)
log.info("[2/3] OCR 识别")
log.info("=" * 60)
csv_path = project_dir / "invoice_summary.csv"
if invoices is None:
rows = _load_csv(csv_path)
if rows is None:
return 1
else:
# 将 dict 列表转为 CSV 风格的 dict对齐列名
from .extractor import CSV_COLUMNS
rows = []
for idx, inv in enumerate(invoices, 1):
items = inv.get("_items", [])
first_item = items[0] if items else {}
rows.append({
"序号": str(idx),
"发票号码": inv.get("发票号码", ""),
"开票日期": inv.get("开票日期", ""),
"项目名称": first_item.get("项目名称", inv.get("项目名称", "")),
"规格型号": first_item.get("规格型号", inv.get("规格型号", "")),
"价税合计": inv.get("价税合计", ""),
"销售方名称": inv.get("销售方名称", ""),
"人员姓名": inv.get("人员姓名", ""),
"刷卡日期": inv.get("刷卡日期", ""),
"公务卡号": inv.get("公务卡号", ""),
"刷卡金额": inv.get("刷卡金额", ""),
"备注": inv.get("备注", ""),
"工号": inv.get("工号", ""),
})
rows = enrich_with_ocr(rows, str(project_dir))
save_ocr_csv(csv_path, rows)
save_markdown_from_csv(csv_path, rows)
invoices = rows
if step == "ocr":
log.info("[2/3] OCR 识别 完成")
return 0
# --------------------------------------------------
# Step 3: 浏览器填报
# --------------------------------------------------
if step in ("all", "submit"):
log.info("=" * 60)
log.info("[3/3] 报销提交")
log.info("=" * 60)
from .bot import load_invoice_data, run_bot
csv_path = project_dir / "invoice_summary.csv"
bot_invoices = load_invoice_data(str(csv_path), config)
run_bot(config, bot_invoices)
if step == "submit":
log.info("[3/3] 报销提交 完成")
return 0
# --------------------------------------------------
# 全流程完成
# --------------------------------------------------
log.info("=" * 60)
log.info("全流程执行完毕")
log.info("=" * 60)
return 0

View File

@@ -8,5 +8,10 @@
"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 Normal file
View File

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

View File

@@ -1,7 +1,11 @@
---
last_reviewed: 2026-06-09
---
# 财务报销自动化 — API 文档 # 财务报销自动化 — API 文档
> 基础地址: `http://localhost:5000` > 基础地址: `http://localhost:5000`
> 启动: `python web/app.py` > 启动: `uv run python src/web/app.py`
## 总览 ## 总览
@@ -12,7 +16,7 @@
| 3 | POST | `/api/upload/<session_id>` | 上传文件PDF/图片) | | 3 | POST | `/api/upload/<session_id>` | 上传文件PDF/图片) |
| 4 | POST | `/api/upload-csv/<session_id>` | 上传 CSV 发票数据 | | 4 | POST | `/api/upload-csv/<session_id>` | 上传 CSV 发票数据 |
| 5 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 | | 5 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
| 6 | POST | `/api/process/<session_id>` | 启动处理(提取+OCR+出库单) | | 6 | POST | `/api/process/<session_id>` | 启动处理(提取+LLM识别+出库单) |
| 7 | GET | `/api/logs/<session_id>` | SSE 日志流 | | 7 | GET | `/api/logs/<session_id>` | SSE 日志流 |
| 8 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 | | 8 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
| 9 | GET | `/api/data/<session_id>` | 获取发票数据JSON | | 9 | GET | `/api/data/<session_id>` | 获取发票数据JSON |
@@ -26,8 +30,8 @@
## 会话与目录 ## 会话与目录
- 调用 `POST /api/session` 获得 `session_id` - 调用 `POST /api/session` 获得 `session_id`
- 该会话下所有文件存放在 `web/uploads/<session_id>/` - 该会话下所有文件存放在 `src/web/uploads/<session_id>/`
- 典型产物:`invoice_summary.csv``invoice_summary.md``易耗品、出库单.doc``config.json``session.log``result.json` - 典型产物:`invoice_summary.csv``易耗品、出库单.doc``config.json``session.log``result.json`
--- ---
@@ -91,7 +95,7 @@ Content-Type: multipart/form-data
{ "ok": true, "filename": "invoice_summary.csv" } { "ok": true, "filename": "invoice_summary.csv" }
``` ```
> 上传 CSV 后可跳过 PDF 提取和 OCR,直接进入处理/编辑流程。 > 上传 CSV 后可跳过 PDF 提取和 LLM 识别,直接进入处理/编辑流程。
--- ---
@@ -137,17 +141,17 @@ Content-Type: application/json
| mode | 行为 | | mode | 行为 |
|------|------| |------|------|
| `auto` | 仅有 CSV、无 PDF → CSV 模式;否则 → PDF 提取 + OCR | | `auto` | 仅有 CSV、无 PDF → CSV 模式;否则 → PDF 提取 + LLM 识别 |
| `csv` | 使用已上传 CSV跳过提取与 OCR | | `csv` | 使用已上传 CSV跳过提取与 LLM 识别 |
| `pdf` | 执行 PDF 提取 + OCR | | `pdf` | 执行 PDF 提取 + LLM 识别 |
**处理内容PDF 模式):** **处理内容PDF 模式):**
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv` / `.md` 1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv`
2. 对支付截图 OCR,回填刷卡字段 2. 对支付截图多模态 LLM 识别,回填刷卡字段
3. 从项目根目录复制 `易耗品、出库单.doc` 模板到会话目录并自动填写(需 Windows + Word 3. 根据发票类型自动分类:差旅发票(高铁票/酒店住宿)不生成出库单;普通发票从模板复制并自动填写
配置会写入 `web/uploads/<session_id>/config.json` 配置会写入 `src/web/uploads/<session_id>/config.json`
**响应(立即):** **响应(立即):**
@@ -165,12 +169,28 @@ Content-Type: application/json
"elapsed": "45.2s", "elapsed": "45.2s",
"invoice_count": 4, "invoice_count": 4,
"csv_url": "/api/download/<session_id>/invoice_summary.csv", "csv_url": "/api/download/<session_id>/invoice_summary.csv",
"md_url": "/api/download/<session_id>/invoice_summary.md", "travel_count": 2,
"general_count": 2,
"doc_url": "/api/download/<session_id>/%E6%98%93%E8%80%97%E5%93%81%E3%80%81%E5%87%BA%E5%BA%93%E5%8D%95.doc", "doc_url": "/api/download/<session_id>/%E6%98%93%E8%80%97%E5%93%81%E3%80%81%E5%87%BA%E5%BA%93%E5%8D%95.doc",
"doc_ok": true "doc_ok": true
} }
``` ```
**纯差旅发票(跳过出库单生成):**
```json
{
"ok": true,
"invoice_count": 3,
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
"travel_count": 3,
"general_count": 0,
"doc_ok": null,
"doc_skipped": true,
"doc_message": "差旅发票无需生成易耗品出库单"
}
```
**出库单生成失败时CSV 等仍可能成功):** **出库单生成失败时CSV 等仍可能成功):**
```json ```json
@@ -178,11 +198,23 @@ Content-Type: application/json
"ok": true, "ok": true,
"invoice_count": 4, "invoice_count": 4,
"csv_url": "/api/download/<session_id>/invoice_summary.csv", "csv_url": "/api/download/<session_id>/invoice_summary.csv",
"travel_count": 2,
"general_count": 2,
"doc_ok": false, "doc_ok": false,
"doc_error": "服务器未安装 pywin32无法生成 Word 出库单" "doc_error": "服务器未安装 pywin32无法生成 Word 出库单"
} }
``` ```
**字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `travel_count` | int | 差旅发票数量(高铁票/酒店住宿) |
| `general_count` | int | 普通发票数量 |
| `doc_ok` | bool/null | `true`=成功,`false`=失败,`null`=已跳过(纯差旅发票) |
| `doc_skipped` | bool | 是否因纯差旅发票而跳过出库单生成 |
| `doc_message` | string | 跳过时的提示信息 |
--- ---
### 6. SSE 日志流 ### 6. SSE 日志流
@@ -211,9 +243,11 @@ data: 2026-05-26 12:00:01 [INFO ] extractor: 正在提取发票...
| 来源 | 典型字段 | | 来源 | 典型字段 |
|------|----------| |------|----------|
| `/api/process` | `ok`, `elapsed`, `invoice_count`, `csv_url`, `md_url`, `doc_url`, `doc_ok`, `doc_error` | | `/api/process` | `ok`, `elapsed`, `invoice_count`, `csv_url`, `travel_count`, `general_count`, `doc_url`, `doc_ok`, `doc_skipped`, `doc_error` |
| `/api/submit-financial` | `ok`, `submit_ok`, `submit_error` | | `/api/submit-financial` | `ok`, `submit_ok`, `submit_error` |
> SSE 超时时间为 10 分钟600 秒)。
--- ---
### 7. 下载文件 ### 7. 下载文件
@@ -227,7 +261,6 @@ GET /api/download/<session_id>/<filename>
| 扩展名 | Content-Type | | 扩展名 | Content-Type |
|--------|----------------| |--------|----------------|
| `.csv` | `text/csv; charset=utf-8` | | `.csv` | `text/csv; charset=utf-8` |
| `.md` | `text/markdown; charset=utf-8` |
| `.doc` | `application/msword` | | `.doc` | `application/msword` |
| 其它 | `application/octet-stream` | | 其它 | `application/octet-stream` |
@@ -235,8 +268,7 @@ GET /api/download/<session_id>/<filename>
| 文件名 | 说明 | | 文件名 | 说明 |
|--------|------| |--------|------|
| `invoice_summary.csv` | 发票汇总(含 OCR 结果) | | `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) |
| `invoice_summary.md` | Markdown 摘要 |
| `易耗品、出库单.doc` | 自动填写的出库单 | | `易耗品、出库单.doc` | 自动填写的出库单 |
| 用户上传的 CSV 名 | CSV 快捷模式下的原始文件 | | 用户上传的 CSV 名 | CSV 快捷模式下的原始文件 |
@@ -328,7 +360,17 @@ Content-Type: application/json
保存后会根据最新 CSV **重新生成** 出库单 Word与会话 `config.json` 中的 `consumable_storage` 等配置一致)。 保存后会根据最新 CSV **重新生成** 出库单 Word与会话 `config.json` 中的 `consumable_storage` 等配置一致)。
若出库单生成失败: **纯差旅发票跳过出库单:**
```json
{
"ok": true,
"doc_ok": null,
"doc_skipped": true
}
```
**出库单生成失败:**
```json ```json
{ {
@@ -348,10 +390,14 @@ POST /api/submit-financial/<session_id>
**前置条件:** **前置条件:**
- 会话目录存在 `config.json`(由 `/api/process` 写入) - 会话目录存在 `config.json`(由 `/api/process` 写入),否则返回 `400`
- 存在可用的发票 CSV通常为 `invoice_summary.csv` - 存在可用的发票 CSV通常为 `invoice_summary.csv`
**说明:** 前端一般在提交前调用 `/api/save` 保存表格修改。本接口**不会**自动执行发票提取或 OCR。 **说明:**
- 前端一般在提交前调用 `/api/save` 保存表格修改
- 本接口**不会**自动执行发票提取或 LLM 识别
- 根据发票类型选择填报模式:纯差旅发票走差旅报销流程,含普通发票走普通报销流程
**响应(立即):** **响应(立即):**
@@ -416,6 +462,19 @@ Content-Type: multipart/form-data
--- ---
## 发票类型分类
系统自动将发票分为两类,影响出库单生成和后续报销流程:
| 类型 | 判断依据 | 出库单 | 报销流程 |
|------|----------|--------|----------|
| 差旅发票 | 高铁票、酒店住宿等 | 不生成 | 差旅报销 |
| 普通发票 | 其他(办公用品、耗材等) | 自动生成 | 普通报销 |
`/api/process``/api/save` 的响应中 `travel_count` / `general_count` 即为分类统计。
---
## 端到端流程 ## 端到端流程
```mermaid ```mermaid
@@ -430,8 +489,12 @@ sequenceDiagram
PC->>Server: POST /api/upload/{sid} PC->>Server: POST /api/upload/{sid}
PC->>Server: POST /api/process/{sid} PC->>Server: POST /api/process/{sid}
Note over Server: PDF 提取 + OCR + 写 config.json Note over Server: PDF 提取 + LLM 识别 + 写 config.json
Server->>Word: 复制模板并填写出库单 alt 含普通发票
Server->>Word: 从模板复制并填写出库单
else 纯差旅发票
Note over Server: 跳过出库单生成
end
Server-->>PC: SSE done (csv_url, doc_url, ...) Server-->>PC: SSE done (csv_url, doc_url, ...)
PC->>Server: GET /api/data/{sid} PC->>Server: GET /api/data/{sid}
@@ -457,7 +520,7 @@ sequenceDiagram
不经过 Web、在本地直接填写出库单 不经过 Web、在本地直接填写出库单
```bash ```bash
python -m app.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc" uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
``` ```
详见 [README.md](./README.md)。 详见 [README.md](./README.md)。

481
docs/报销操作指南.md Normal file
View File

@@ -0,0 +1,481 @@
---
last_reviewed: 2026-06-09
---
# 阜阳师范大学财务报销系统 - 自动化操作指南
> 适用场景:日常报销录入(支持 Web 界面和 CLI 两种方式)
> 最后更新2026-06-09
---
## 一、系统概览
```
整体流程:
信息门户(SSO登录) → 财务系统入口 → 单点登录页 → 网络报销 → 日常报销录入
(tyrz.fynu.edu.cn) (点击"财务系统") (新标签页) (a:has(img)) (/expen/common/common)
目标系统: http://210.45.32.214:8081
用户: 张三 (工号: xxxxxxx)
```
### 关键 URL
| 系统 | URL | 说明 |
|------|-----|------|
| SSO 登录 | `https://tyrz.fynu.edu.cn/sso/login` | 统一认证入口 |
| 信息门户 | `https://tyrz.fynu.edu.cn/oshall` | 登录后跳转目标 |
| 报销系统 | `http://210.45.32.214:8081` | 网络报销主系统 |
| 日常报销录入 | `/expen/common/common?v=4.0` | 目标录入页面 |
---
## 二、两种使用方式
### 方式一Web 界面(推荐)
适合日常使用,可视化操作,支持手机端拍照上传。
```bash
uv run python src/web/app.py
# 访问: http://localhost:5000
```
### 方式二CLI 命令行
适合脚本化、批量处理。
```bash
# 全流程(发票提取 → LLM 识别 → 浏览器填报)
uv run python src/main.py
# 分步执行
uv run python src/main.py --step invoice # 仅发票提取
uv run python src/main.py --step submit # 仅浏览器填报
# 覆盖登录凭据
uv run python src/main.py -u 202407021 -p "your_password"
```
---
## 三、数据准备
### 3.1 发票数据 CSV
系统支持两种数据输入方式:
**方式 APDF 发票 + 支付截图(自动提取)**
上传 PDF 发票文件和支付截图,系统自动完成:
1. 从 PDF 提取发票信息
2. 多模态 LLM 识别支付截图中的刷卡信息
3. 生成 `invoice_summary.csv`
**方式 B直接上传 CSV快捷模式**
已有发票数据 CSV 可直接上传,跳过 PDF 提取和 LLM 识别步骤。
CSV 需包含以下列:
| 列名 | 说明 | 示例 |
|------|------|------|
| 序号 | 发票序号 | 1, 2, 3... |
| 发票号码 | 发票编号 | 26442000005432652421 |
| 开票日期 | 发票开具日期 | 2026/5/18 |
| 项目名称 | 采购项目名称 | 电阻一批 |
| 规格型号 | 规格型号 | — |
| 价税合计 | 发票金额 | 2900.00 |
| 销售方名称 | 商户/销售方 | 佛山市泓宇芯科技有限公司 |
| 人员姓名 | 报销人 | 王建锋(默认值) |
| 刷卡日期 | 公务卡消费日期 | 2026/5/18 → 自动转为 2026-05-18 |
| 公务卡号 | 公务卡卡号 | 6282880139161682默认值 |
| 刷卡金额 | 实际刷卡金额 | 2900.00 |
| 备注 | 备注信息 | — |
| 工号 | 人员工号 | 202407021默认值 |
### 3.2 发票类型分类
系统自动将发票分为两类,影响后续处理流程:
| 类型 | 判断依据 | 出库单 | 报销流程 |
|------|----------|--------|----------|
| 差旅发票 | 高铁票、酒店住宿等 | 不生成 | 差旅报销 |
| 普通发票 | 其他(办公用品、耗材等) | 自动生成 | 普通报销 |
### 3.3 附件文件
PDF 发票文件需与支付截图配对上传。系统通过金额匹配自动关联发票和支付记录。
---
## 四、Web 界面操作流程
### 4.1 启动服务
```bash
uv run python src/web/app.py
# 访问: http://localhost:5000
```
### 4.2 上传文件
1. **发票 PDF**:点击或拖拽上传 PDF 文件(支持多选)
2. **支付截图**:点击或拖拽上传图片文件(支持多选)
3. **手机扫码上传**:扫描页面二维码,通过手机拍照上传支付截图
4. **CSV 快捷上传**:已有发票数据 CSV 可直接上传,跳过提取和 LLM 识别
### 4.3 配置信息
填写以下配置项(也可通过上传 `config.json` 快速填充):
| 字段 | 说明 |
|------|------|
| 账号 | 财务系统工号 |
| 密码 | 登录密码 |
| 默认姓名 | 报销人姓名 |
| 公务卡号 | 公务卡卡号 |
| 人员编号 | 人员编号 |
| 存放地点 | 出库单存放地点 |
### 4.4 开始处理
点击"开始处理"按钮,系统自动执行:
1. **发票提取**:从 PDF 提取发票信息
2. **LLM 信息提取**:多模态 LLM 识别支付截图中的刷卡信息
3. **发票分类**:自动区分差旅发票和普通发票
4. **出库单生成**:普通发票自动生成易耗品出库单(差旅发票跳过)
处理过程中可在日志面板实时查看进度。
### 4.5 编辑发票数据
处理完成后,发票数据以可编辑表格形式展示:
- 直接点击单元格即可编辑
- 修改后点击"提交到财务系统"时自动保存
- 保存后会自动重新生成出库单
### 4.6 下载文件
处理完成后下载:
| 文件 | 说明 |
|------|------|
| `invoice_summary.csv` | 发票汇总数据 |
| `易耗品、出库单.doc` | 自动填写的出库单(仅普通发票) |
### 4.7 提交到财务系统
确认数据无误后,点击"🚀 提交到财务系统"按钮,系统自动:
1. 登录信息门户
2. 进入财务系统
3. 创建报销单
4. 填写基本信息
5. 录入报销明细
6. 录入支付方式
7. 上传附件
---
## 五、CLI 执行流程
### 5.1 全流程执行
```bash
uv run python src/main.py
```
执行步骤:
```
Step 1: 发票提取
├── 扫描 PDF 发票文件
├── 提取发票信息
├── 生成 invoice_summary.csv
└── 自动分类(差旅/普通)
Step 2: LLM 信息提取
├── 扫描支付截图
├── 多模态 LLM 识别刷卡信息
├── 回填 CSV 刷卡字段
└── 更新 invoice_summary.csv
Step 3: 报销提交
├── 登录信息门户
├── 进入财务系统
├── 创建报销单
├── 填写基本信息
├── 录入报销明细
├── 录入支付方式
├── 上传附件
└── 提交(需手动确认)
```
### 5.2 分步执行
```bash
# 仅发票提取
uv run python src/main.py --step invoice
# 仅浏览器填报
uv run python src/main.py --step submit
```
### 5.3 上传目录
CLI 模式自动发现 `src/web/uploads/` 下最新的会话文件夹,该文件夹包含上传的 PDF 和图片。
---
## 六、页面元素速查
### 基本信息页
| 字段 | 选择器 | 操作 |
|------|--------|------|
| 报销说明 | `#EXPENEXPLAIN` | fill |
| 项目代码 | `#PROJECTCODE` | click → 弹窗选择 |
| 项目弹窗 | `#promodal .fixed-table-body tbody tr` | 点击第一行 |
| 下一步按钮 | `#saveAndNext` | click |
### 报销明细页
| 字段 | 选择器 | 操作 |
|------|--------|------|
| 增加按钮 | `#insertDetail` | click |
| 经济事项代码 | `#economicscode2` | click → 弹窗选择 |
| 经济科目弹窗 | `#econmodal .fixed-table-body tbody tr` | 点击第 3 行 |
| 单据数 | `input[name="expenPwCommondetail.HOWBILLS"]` | fill |
| 报销总金额 | `#je_zwzcdz` | fill |
| 确定按钮 | `#detailAdd` | click |
### 支付方式页
| 字段 | 选择器 | 操作 |
|------|--------|------|
| 增加按钮 | `#insertPay` | click |
| 人员编号 | `#personid2` | fill |
| 人员姓名 | `#accountname2` | fill |
| 刷卡日期 | `#receiptdate2` | fill |
| 公务卡号 | `#localaccount2` | fill |
| 刷卡金额 | `#receiptmoney2` | fill |
| 实报金额 | `#money2` | fill |
| 商户 | `#merchant2` | fill |
| 备注 | `#smark2` | fill |
| 确定按钮 | `#payAdd` | click |
### 附件清单页
| 字段 | 选择器 | 操作 |
|------|--------|------|
| 增加按钮 | `#insertAcc` | click |
| 附件类型 | `#fjlx` | select_option → '1'(发票) |
| 附件说明 | `#fpsmxx` | fill |
| 文件上传 | `#file` | set_input_files |
| 确定按钮 | `#cjtj` | click |
### 提交
| 操作 | 选择器 |
|------|--------|
| 提交按钮 | `#submit` |
| 提交按钮(备用) | `#submit2` |
---
## 七、数据流向
```
PDF 发票 + 支付截图
▼ extract_invoices()
├── 读取 PDF 发票
├── 提取发票信息
├── 自动分类(差旅/普通)
└── 输出: invoice_summary.csv
▼ enrich_with_llm()
├── 多模态 LLM 识别支付截图
├── 回填刷卡字段
└── 更新 invoice_summary.csv
▼ fill_consumable_from_template()
├── 仅普通发票
├── 从模板复制出库单
└── 自动填写出库单
▼ run_bot()
├── 登录信息门户
├── 进入财务系统
├── 创建报销单
├── 填写基本信息
├── 录入报销明细
├── 录入支付方式
├── 上传附件
└── 提交
```
---
## 八、关键设计说明
### 8.1 浏览器启动
系统使用 Playwright 的 `sync_playwright()` 启动 Chromium 浏览器,支持 `headless` 模式。每次运行均启动新浏览器实例,需重新登录。可通过 `headless` 参数控制是否显示浏览器窗口。
### 8.2 明细录入策略
系统采用"一条总明细"策略:将所有发票合并为一条报销明细,报销总金额为所有发票刷卡金额之和,单据数为发票总张数。支付方式则逐张发票分别录入,每张发票对应一条支付记录。
### 8.3 经济科目选择
系统在经济科目弹窗中固定选择第 3 行。如需更改科目,修改 `rows[2]` 的索引即可。
### 8.4 项目选择
系统在项目选择弹窗中固定选择第 1 行。如需更改项目,修改 `first_row` 的选择逻辑即可。
### 8.5 网络报销链接动态获取
单点登录页的"网络报销"链接参数每次不同,系统通过 `a:has(img[src*="wlbx"])` 精确定位链接,动态提取 `href` 属性后导航,不硬编码 URL。
### 8.6 发票类型分流
- **差旅发票**(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- **普通发票**:生成易耗品出库单,走普通报销流程
### 8.7 移动端同步
PC 端生成二维码指向移动端上传页面,手机端上传的图片通过轮询同步到 PC 端,实现跨设备协作。
---
## 九、配置说明
### config.json
项目根目录 `config.json` 包含默认配置:
```json
{
"username": "202407021",
"password": "your_password",
"sso_login_url": "https://tyrz.fynu.edu.cn/sso/login",
"portal_url": "https://tyrz.fynu.edu.cn/oshall",
"reimburse_url": "http://210.45.32.214:8081",
"reimburse_page": "/expen/common/common?v=4.0",
"default_name": "王建锋",
"default_card_no": "6282880139161682",
"default_person_id": "202407021",
"consumable_storage": "躬行楼 C205",
"llm": {
"model": "qwen3.5-9b",
"api_base": "http://100.123.83.115:1234/v1",
"api_key": "123456"
}
}
```
| 字段 | 说明 |
|------|------|
| `username` | 财务系统工号 |
| `password` | 登录密码 |
| `sso_login_url` | SSO 登录地址 |
| `portal_url` | 信息门户地址 |
| `reimburse_url` | 报销系统地址 |
| `reimburse_page` | 报销录入页面路径 |
| `default_name` | 默认报销人姓名 |
| `default_card_no` | 默认公务卡号 |
| `default_person_id` | 默认人员编号 |
| `consumable_storage` | 出库单存放地点 |
| `llm.model` | LLM 模型名称 |
| `llm.api_base` | LLM API 地址 |
| `llm.api_key` | LLM API 密钥 |
---
## 十、日志与调试
### 日志输出
- 控制台实时输出INFO 级别)
- 文件日志:`logs/<日期>.log`UTF-8 编码)
- Web 界面:实时 SSE 日志流
### 截图保存
每个关键步骤自动截图到 `images/` 目录:
| 截图文件 | 对应步骤 |
|----------|----------|
| `debug_portal_loaded.png` | 登录成功 |
| `debug_step3_project_modal.png` | 项目弹窗打开 |
| `debug_step3_project_selected.png` | 项目选择完成 |
| `debug_step3_done.png` | 基本信息完成 |
| `debug_after_add_click.png` | 点击新增后 |
| `debug_item_total.png` | 总明细录入完成 |
| `debug_step5_done.png` | 支付方式完成 |
| `debug_step6_done.png` | 附件上传完成 |
| `debug_error.png` | 异常状态 |
### 超时设置
- 页面默认超时30 秒
- 登录门户等待:最多 30 秒
- 单点登录页等待:最多 15 秒
- SSE 日志流超时10 分钟600 秒)
---
## 十一、常见问题
| 问题 | 原因 | 解决方案 |
|------|------|----------|
| 登录超时 | SSO 需要手动验证码/微信扫码 | 手动完成验证后脚本继续 |
| 未找到财务系统入口 | 门户页面结构变化 | 检查 `images/debug_*` 截图定位 |
| 经济科目选择失败 | 弹窗加载延迟 | 检查超时设置,增加等待时间 |
| 附件上传失败 | PDF 文件不存在或路径错误 | 确认 PDF 在当前工作目录 |
| 金额不匹配 | 明细合计 ≠ 支付合计 | 检查 CSV 数据中刷卡金额 |
| 提交被拦截 | 必填项为空 | 检查 `logs/<日期>.log` 定位失败步骤 |
| LLM 多模态提取失败 | llama-index 版本不兼容 | 确保使用 llama-index-core >= 0.14.x多模态消息使用 `blocks` 格式 |
| 出库单生成失败 | 缺少 pywin32 或模板文件 | 安装 `pywin32`,确保项目根目录有 `易耗品、出库单.doc` 模板 |
| 差旅发票生成了出库单 | 分类不准确 | 检查发票内容是否包含"高铁票""酒店住宿"等关键词 |
---
## 十二、相关 CLI 命令
### 单独填写出库单
```bash
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
```
### 分步执行管道
```bash
# 仅发票提取
uv run python src/main.py --step invoice
# 仅浏览器填报
uv run python src/main.py --step submit
```
### 启动 Web 服务
```bash
uv run python src/web/app.py
# 访问: http://localhost:5000
```
---
## 附录API 文档
详细的 API 接口文档见 [API.md](./API.md)。

4
invoice_summary.csv Normal file
View File

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

1536
pipeline.log Normal file

File diff suppressed because it is too large Load Diff

49
pyproject.toml Normal file
View File

@@ -0,0 +1,49 @@
[project]
name = "auto-reimbursement-system"
version = "0.1.0"
description = "财务报销自动化系统"
requires-python = ">=3.12"
dependencies = [
"flask>=3.0",
"playwright>=1.40",
"pdfplumber>=0.10",
"pywin32>=306",
"llama-index>=0.12.0",
"llama-index-llms-openai-like==0.7.2",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.9",
"mypy>=1.14",
"deptry>=0.22",
"pre-commit>=4.0",
]
[tool.ruff]
target-version = "py312"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B"]
ignore = ["E501"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
ignore_errors = true
[tool.deptry]
ignore_notebooks = true
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

View File

@@ -1,5 +1,6 @@
"""财务报销自动化工具包""" """财务报销自动化工具包"""
import datetime
import io import io
import logging import logging
import sys import sys
@@ -7,14 +8,20 @@ from pathlib import Path
_LOG_FMT = "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s" _LOG_FMT = "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s"
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S" _LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
_LOG_FILE = Path(__file__).resolve().parent.parent / "pipeline.log" _LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
def _get_log_file() -> Path:
"""返回当日日志文件路径,如 logs/2026-06-09.log"""
_LOG_DIR.mkdir(parents=True, exist_ok=True)
return _LOG_DIR / f"{datetime.date.today():%Y-%m-%d}.log"
def get_logger(name: str) -> logging.Logger: def get_logger(name: str) -> logging.Logger:
"""获取带时间戳的日志记录器 """获取带时间戳的日志记录器
输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ... 输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ...
日志同时输出到终端和项目根目录的 pipeline.log 日志同时输出到终端和 logs/<日期>.log
""" """
logger = logging.getLogger(name) logger = logging.getLogger(name)
if not logger.handlers: if not logger.handlers:
@@ -28,7 +35,7 @@ def get_logger(name: str) -> logging.Logger:
logger.addHandler(stream_handler) logger.addHandler(stream_handler)
# 文件输出 # 文件输出
file_handler = logging.FileHandler(str(_LOG_FILE), encoding="utf-8") file_handler = logging.FileHandler(str(_get_log_file()), encoding="utf-8")
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
logger.addHandler(file_handler) logger.addHandler(file_handler)

View File

@@ -10,6 +10,7 @@
import csv import csv
from pathlib import Path from pathlib import Path
from typing import Any
from . import get_logger from . import get_logger
@@ -20,6 +21,7 @@ log = get_logger("bot")
# 日期格式化 # 日期格式化
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _format_date(date_str: str) -> str: def _format_date(date_str: str) -> str:
"""'2026/5/13''2026-5-13' 转为 '2026-05-13'""" """'2026/5/13''2026-5-13' 转为 '2026-05-13'"""
if not date_str: if not date_str:
@@ -30,31 +32,58 @@ def _format_date(date_str: str) -> str:
return date_str return date_str
def _safe_float(value: str | None, default: float = 0.0) -> float:
"""安全转换为浮点数,空值或转换失败时返回默认值"""
if value is None or str(value).strip() == "":
return default
try:
return float(value)
except (ValueError, TypeError):
return default
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# CSV 数据加载 # CSV 数据加载
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def load_invoice_data(csv_path: str, config: dict) -> list[dict]:
"""从 CSV 加载发票数据,自动补全空白字段的默认值""" def load_invoice_data(csv_path: str, config: dict[str, str | Path]) -> list[dict[str, str | float | Any | Path]]:
"""从 CSV 加载发票数据,自动补全空白字段的默认值
CSV 以支付记录为主键每行包含 _invoices_json 字段JSON 序列化的发票列表
本函数还原为发票级别的数据列表
"""
import json
invoices = [] invoices = []
with open(csv_path, encoding="utf-8") as f: with open(csv_path, encoding="utf-8") as f:
reader = csv.DictReader(f) reader = csv.DictReader(f)
for row in reader: for row in reader:
invoices.append({ 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("序号", ""), "seq": row.get("序号", ""),
"invoice_no": row.get("发票号码", ""), "invoice_no": inv.get("发票号码", ""),
"invoice_date": row.get("开票日期", ""), "invoice_date": inv.get("开票日期", ""),
"item_name": row.get("项目名称", ""), "item_name": inv.get("项目名称", ""),
"spec_model": row.get("规格型号", ""), "spec_model": inv.get("规格型号", ""),
"total_amount": float(row.get("价税合计", 0)), "total_amount": _safe_float(inv.get("价税合计")),
"seller_name": row.get("销售方名称", ""), "seller_name": inv.get("销售方名称", ""),
"person_name": row.get("人员姓名") or config.get("default_name", ""), "person_name": inv.get("人员姓名") or config.get("default_name", ""),
"card_date": _format_date(row.get("刷卡日期") or ""), "card_date": _format_date(row.get("刷卡日期") or ""),
"card_no": row.get("公务卡号") or config.get("default_card_no", ""), "card_no": row.get("公务卡号") or config.get("default_card_no", ""),
"card_amount": float(row.get("刷卡金额") or "0"), "card_amount": _safe_float(row.get("刷卡金额")),
"remark": row.get("备注") or "", "remark": row.get("备注") or "",
"person_id": row.get("工号") or config.get("default_person_id", ""), "person_id": row.get("工号") or config.get("default_person_id", ""),
}) }
)
except json.JSONDecodeError:
log.warning(f"无法解析发票 JSON: {invoices_json[:50]}...")
return invoices return invoices
@@ -62,29 +91,31 @@ def load_invoice_data(csv_path: str, config: dict) -> list[dict]:
# 报销机器人 # 报销机器人
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ReimburseBot: class ReimburseBot:
"""财务报销自动化机器人""" """财务报销自动化机器人"""
def __init__(self, config: dict, headless: bool = False): def __init__(self, config: dict[str, Any], headless: bool = False):
self.config = config self.config = config
self.headless = headless self.headless = headless
self.work_dir: Path | None = None self.work_dir: Path | None = None
self.browser = None self.browser: Any = None
self.context = None self.context: Any = None
self.page = None self.page: Any = None
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
self._pw_ctx = sync_playwright() self._pw_ctx = sync_playwright()
self.pw = self._pw_ctx.__enter__() self.pw = self._pw_ctx.__enter__()
def launch(self): 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": 1920, "height": 1080})
self.page = self.context.new_page() self.page = self.context.new_page()
self.page.set_default_timeout(30000) self.page.set_default_timeout(30000)
def login_portal(self): def login_portal(self) -> None:
"""登录信息门户""" """登录信息门户"""
log.info("登录信息门户...") log.info("登录信息门户...")
@@ -113,7 +144,7 @@ class ReimburseBot:
self._wait_for_portal() self._wait_for_portal()
def _wait_for_portal(self): def _wait_for_portal(self) -> None:
"""等待跳转到统一信息平台""" """等待跳转到统一信息平台"""
for _ in range(30): for _ in range(30):
self.page.wait_for_timeout(1000) self.page.wait_for_timeout(1000)
@@ -125,7 +156,7 @@ class ReimburseBot:
self._screenshot("portal_timeout") self._screenshot("portal_timeout")
raise TimeoutError("登录超时,未跳转到信息门户") raise TimeoutError("登录超时,未跳转到信息门户")
def navigate_to_reimburse(self): def navigate_to_reimburse(self) -> None:
"""从统一信息平台进入报销系统""" """从统一信息平台进入报销系统"""
log.info("进入报销系统...") log.info("进入报销系统...")
self._wait_for('text="快捷入口"', timeout=5000) self._wait_for('text="快捷入口"', timeout=5000)
@@ -170,7 +201,7 @@ class ReimburseBot:
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): def open_reimburse_menu(self) -> None:
"""点击「新增」创建新报销单""" """点击「新增」创建新报销单"""
log.info("创建新报销单...") log.info("创建新报销单...")
self.page.wait_for_timeout(2000) self.page.wait_for_timeout(2000)
@@ -180,14 +211,14 @@ class ReimburseBot:
except Exception: except Exception:
try: try:
self.page.click("text=新增", timeout=3000) self.page.click("text=新增", timeout=3000)
except Exception: except Exception as err:
self._screenshot("no_add_button") self._screenshot("no_add_button")
raise RuntimeError("无法点击新增按钮") raise RuntimeError("无法点击新增按钮") from err
self.page.wait_for_timeout(3000) self.page.wait_for_timeout(3000)
self._screenshot("after_add_click") self._screenshot("after_add_click")
def fill_basic_info(self, description: str = "元器件采购报销"): def fill_basic_info(self, description: str = "元器件采购报销") -> None:
"""填写基本信息""" """填写基本信息"""
log.info("填写基本信息...") log.info("填写基本信息...")
@@ -223,7 +254,7 @@ class ReimburseBot:
self._screenshot("step3_done") self._screenshot("step3_done")
def add_reimburse_items(self, invoices: list[dict]): 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)
log.info(f"录入报销明细 (合计 ¥{card_amount:.2f})...") log.info(f"录入报销明细 (合计 ¥{card_amount:.2f})...")
@@ -255,7 +286,7 @@ class ReimburseBot:
self._screenshot("item_total_error") self._screenshot("item_total_error")
raise raise
def fill_payment(self, invoices: list[dict]): def fill_payment(self, invoices: list[dict[str, Any]]) -> None:
"""录入支付信息""" """录入支付信息"""
log.info("录入支付信息...") log.info("录入支付信息...")
try: try:
@@ -282,7 +313,7 @@ class ReimburseBot:
self._screenshot("step5_done") self._screenshot("step5_done")
def upload_attachments(self, invoices: list[dict]): def upload_attachments(self, invoices: list[dict[str, Any]]) -> None:
"""上传发票附件""" """上传发票附件"""
log.info("上传附件...") log.info("上传附件...")
@@ -299,7 +330,7 @@ class ReimburseBot:
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']}"
@@ -328,7 +359,7 @@ class ReimburseBot:
self._screenshot("step6_done") self._screenshot("step6_done")
def submit(self): def submit(self) -> None:
"""提交报销单""" """提交报销单"""
log.info("提交报销单...") log.info("提交报销单...")
try: try:
@@ -340,7 +371,7 @@ class ReimburseBot:
self._screenshot("submit_error") self._screenshot("submit_error")
raise raise
def close(self): def close(self) -> None:
"""关闭浏览器""" """关闭浏览器"""
if self.context: if self.context:
self.context.close() self.context.close()
@@ -355,10 +386,10 @@ class ReimburseBot:
# 辅助方法 # 辅助方法
# -------------------------------------------------------- # --------------------------------------------------------
def _wait_for(self, selector: str, timeout: int = 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)
def _screenshot(self, name: str): def _screenshot(self, name: str) -> None:
img_dir = Path(__file__).parent.parent / "images" img_dir = Path(__file__).parent.parent / "images"
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"))
@@ -368,7 +399,10 @@ class ReimburseBot:
# 对外入口 # 对外入口
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def run_bot(config: dict, invoices: list[dict], headless: bool = False, work_dir: Path | None = None):
def run_bot(
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("缺少用户名或密码")
@@ -396,7 +430,7 @@ def run_bot(config: dict, invoices: list[dict], headless: bool = False, work_dir
bot.close() bot.close()
def run_bot_web(config: dict, invoices: list[dict], work_dir: Path): def run_bot_web(config: dict[str, Any], invoices: list[dict[str, Any]], work_dir: Path) -> None:
"""Web 模式填报 — headless附件从指定目录读取""" """Web 模式填报 — headless附件从指定目录读取"""
if not config["username"] or not config["password"]: if not config["username"] or not config["password"]:
raise ValueError("缺少用户名或密码") raise ValueError("缺少用户名或密码")

View File

@@ -10,7 +10,7 @@ from pathlib import Path
_CONFIG_PATH = Path(__file__).parent.parent / "config.json" _CONFIG_PATH = Path(__file__).parent.parent / "config.json"
def load_config() -> dict: def load_config() -> dict[str, str | Path]:
"""加载并合并配置,缺失字段使用默认值""" """加载并合并配置,缺失字段使用默认值"""
raw = {} raw = {}
if _CONFIG_PATH.exists(): if _CONFIG_PATH.exists():
@@ -32,3 +32,18 @@ def load_config() -> dict:
"consumable_storage": raw.get("consumable_storage", "躬行楼 C205"), "consumable_storage": raw.get("consumable_storage", "躬行楼 C205"),
"attachment_dir": project_root / "attachments", "attachment_dir": project_root / "attachments",
} }
def get_llm_config() -> dict[str, str]:
"""加载 LLM 配置,缺失字段使用默认值"""
raw = {}
if _CONFIG_PATH.exists():
with open(_CONFIG_PATH, encoding="utf-8") as f:
raw = json.load(f)
llm_raw = raw.get("llm", {})
return {
"model": llm_raw.get("model", "qwen-vl-max"),
"api_base": llm_raw.get("api_base", "http://localhost:8080/v1"),
"api_key": llm_raw.get("api_key", "lm-studio"),
}

49
src/doc/README.md Normal file
View File

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

4
src/doc/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""文档处理模块
包含发票提取、LLM 信息提取、出库单填写等功能。
"""

65
src/doc/extractor.py Normal file
View File

@@ -0,0 +1,65 @@
"""发票提取编排
串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类,生成支付记录列表。
对外接口:
extract_invoices(directory) -> tuple[list[dict], dict]
"""
from .. import get_logger
from .invoice import classify_invoice_batch
from .llm_extractor import extract_invoice_from_text
from .matcher import match_invoices_to_cards
from .pdf import extract_text_from_pdf, find_pdf_files
log = get_logger("extractor")
def extract_invoices(
directory: str = ".",
) -> tuple[list[dict[str, str]], dict[str, list[dict[str, str]]]]:
"""扫描目录下所有 PDF,提取发票信息并匹配支付记录
Returns:
(payment_records, groups): 支付记录列表和按发票类型分组的字典
groups = {'travel': [差旅发票], 'general': [普通发票]}
"""
pdf_files = find_pdf_files(directory)
if not pdf_files:
log.warning("未找到 PDF 文件")
return [], {"travel": [], "general": []}
log.info(f"发现 {len(pdf_files)} 个 PDF 文件")
all_invoices = []
for pdf_path in pdf_files:
text = extract_text_from_pdf(pdf_path)
if not text:
log.warning(f"未能提取文本: {pdf_path.name}")
continue
invoice = extract_invoice_from_text(text, pdf_path.name)
if invoice and invoice.get("发票号码"):
all_invoices.append(invoice)
log.info(f"[{invoice['发票类型']}] 已解析: {pdf_path.name}")
else:
log.warning(f"未能解析: {pdf_path.name}")
if all_invoices:
log.info(f"共处理 {len(all_invoices)} 张发票")
else:
log.warning("未成功解析任何发票")
# 将支付截图与发票进行金额匹配,返回以支付记录为主键的列表
payment_records = match_invoices_to_cards(all_invoices, directory)
# 从支付记录中还原所有发票用于分类
all_invoices_restored: list[dict[str, str]] = []
for record in payment_records:
all_invoices_restored.extend(record.get("_matched_invoices", []))
groups = classify_invoice_batch(all_invoices_restored)
log.info(f"差旅发票: {len(groups['travel'])} 张, 普通发票: {len(groups['general'])}")
return payment_records, groups

View File

@@ -11,10 +11,11 @@ import re
import shutil import shutil
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from typing import Any
from . import get_logger from .. import get_logger
from .bot import load_invoice_data from ..bot import load_invoice_data
from .config import load_config from ..config import load_config
log = get_logger("fill_consumable_doc") log = get_logger("fill_consumable_doc")
@@ -86,7 +87,7 @@ def _today_cn_date() -> str:
return f"{today.year}{today.month}{today.day}" return f"{today.year}{today.month}{today.day}"
def _apply_font(rng) -> None: def _apply_font(rng: Any) -> None:
"""将范围字体设为宋体五号(含数字与英文)。""" """将范围字体设为宋体五号(含数字与英文)。"""
font = rng.Font font = rng.Font
font.Name = TABLE_FONT_NAME font.Name = TABLE_FONT_NAME
@@ -97,7 +98,7 @@ def _apply_font(rng) -> None:
font.Size = TABLE_FONT_SIZE font.Size = TABLE_FONT_SIZE
def _set_cell_value(cell, text: str) -> None: def _set_cell_value(cell: Any, text: str) -> None:
"""写入单元格正文(不含末尾单元格标记)。""" """写入单元格正文(不含末尾单元格标记)。"""
rng = cell.Range rng = cell.Range
rng.MoveEnd(WD_CHARACTER, -1) rng.MoveEnd(WD_CHARACTER, -1)
@@ -105,7 +106,7 @@ def _set_cell_value(cell, text: str) -> None:
_apply_font(rng) _apply_font(rng)
def _normalize_table_font(tbl) -> None: def _normalize_table_font(tbl: Any) -> None:
"""填写完成后统一整张表的字体。""" """填写完成后统一整张表的字体。"""
for row in tbl.Rows: for row in tbl.Rows:
for cell in row.Cells: for cell in row.Cells:
@@ -114,7 +115,7 @@ def _normalize_table_font(tbl) -> None:
_apply_font(rng) _apply_font(rng)
def _replace_date_in_doc(doc, new_date: str) -> None: def _replace_date_in_doc(doc: Any, new_date: str) -> None:
"""仅替换表头段落中的日期文字,不改动段落其余部分。""" """仅替换表头段落中的日期文字,不改动段落其余部分。"""
if not new_date: if not new_date:
return return
@@ -135,7 +136,7 @@ def _replace_date_in_doc(doc, new_date: str) -> None:
def fill_consumable_doc( def fill_consumable_doc(
csv_path: str | Path, csv_path: str | Path,
doc_path: str | Path, doc_path: str | Path,
config: dict | None = None, config: dict[str, Any] | None = None,
backup: bool = True, backup: bool = True,
) -> Path: ) -> Path:
csv_path = Path(csv_path) csv_path = Path(csv_path)
@@ -148,8 +149,11 @@ def fill_consumable_doc(
bak = doc_path.with_suffix(doc_path.suffix + ".bak") bak = doc_path.with_suffix(doc_path.suffix + ".bak")
shutil.copy2(doc_path, bak) shutil.copy2(doc_path, bak)
import pythoncom
import win32com.client import win32com.client
pythoncom.CoInitialize()
try:
word = win32com.client.Dispatch("Word.Application") word = win32com.client.Dispatch("Word.Application")
word.Visible = False word.Visible = False
word.DisplayAlerts = 0 word.DisplayAlerts = 0
@@ -166,8 +170,17 @@ def fill_consumable_doc(
if row_idx > tbl.Rows.Count: if row_idx > tbl.Rows.Count:
break break
parsed = parse_spec_model(inv.get("spec_model", "")) parsed = parse_spec_model(str(inv.get("spec_model", "")))
card_amount = inv.get("card_amount") or 0 # 当规格型号为空时,从项目名称提取产品信息
if not parsed["product_name"]:
item_name = str(inv.get("item_name", ""))
# 去除 "*分类*" 前缀(如 "*电子工业设备*元件盒" -> "元件盒"
if "*" in item_name:
item_name = item_name.split("*")[-1].strip()
parsed["product_name"] = item_name
card_amount_raw = inv.get("card_amount") or 0
card_amount: float = float(str(card_amount_raw).replace(",", ""))
qty_str = parsed["qty"] qty_str = parsed["qty"]
qty_val = int(qty_str) if qty_str and qty_str.isdigit() else 0 qty_val = int(qty_str) if qty_str and qty_str.isdigit() else 0
@@ -175,8 +188,8 @@ def fill_consumable_doc(
amount = _format_money(card_amount) amount = _format_money(card_amount)
unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount) unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount)
# 数量:去掉前导零 # 数量:去掉前导零;若无数量则默认为 1
qty = str(qty_val) if qty_val > 0 else "" qty = str(qty_val) if qty_val > 0 else "1"
values = [ values = [
str(inv.get("seq", i + 1)), str(inv.get("seq", i + 1)),
@@ -193,7 +206,7 @@ def fill_consumable_doc(
] ]
for col_idx, val in enumerate(values, start=1): for col_idx, val in enumerate(values, start=1):
_set_cell_value(tbl.Cell(row_idx, col_idx), val) _set_cell_value(tbl.Cell(row_idx, col_idx), str(val))
_normalize_table_font(tbl) _normalize_table_font(tbl)
@@ -201,6 +214,8 @@ def fill_consumable_doc(
finally: finally:
doc.Close() doc.Close()
word.Quit() word.Quit()
finally:
pythoncom.CoUninitialize()
return doc_path return doc_path
@@ -209,7 +224,7 @@ def fill_consumable_from_template(
csv_path: str | Path, csv_path: str | Path,
template_path: str | Path, template_path: str | Path,
output_path: str | Path, output_path: str | Path,
config: dict | None = None, config: dict[str, Any] | None = None,
) -> Path: ) -> Path:
"""从模板复制并填写出库单Web 会话每次从模板重新生成)。""" """从模板复制并填写出库单Web 会话每次从模板重新生成)。"""
template_path = Path(template_path) template_path = Path(template_path)

273
src/doc/invoice.py Normal file
View File

@@ -0,0 +1,273 @@
"""发票数据模型与 CSV 工具
定义发票类型常量、CSV 列结构,提供发票分类和 CSV 读写功能。
对外接口:
INVOICE_LEVEL_COLUMNS 发票级别 CSV 列定义
PAYMENT_RECORD_COLUMNS 支付记录级别 CSV 列定义
INVOICE_TYPE_* 发票类型常量
is_travel_invoice(type) 判断是否为差旅发票
classify_invoice_batch(invoices) 按类型分组
load_csv(path) 读取支付记录 CSV
save_csv(payment_records, path) 保存支付记录 CSV
save_invoice_csv(payment_records, path) 保存发票级别 CSV
"""
import csv
import json
from pathlib import Path
from .. import get_logger
log = get_logger("invoice")
# ------------------------------------------------------------------
# CSV 列定义
# ------------------------------------------------------------------
# 发票级别 CSV 列(用于 invoice_summary.csv每行一张发票
INVOICE_LEVEL_COLUMNS = [
"序号",
"发票类型",
"发票号码",
"开票日期",
"项目名称",
"规格型号",
"价税合计",
"销售方名称",
"出发站",
"到达站",
"车次",
"乘车日期",
"座位等级",
"人员姓名",
"刷卡日期",
"公务卡号",
"刷卡金额",
"备注",
"工号",
]
# 支付记录级别 CSV 列(用于 payment_records.csv每行一笔支付
PAYMENT_RECORD_COLUMNS = [
"序号",
# 支付信息
"刷卡日期",
"公务卡号",
"刷卡金额",
# 发票聚合信息
"关联发票数",
"发票详情", # 格式: 类型[号码]¥金额 | 类型[号码]¥金额
"备注",
# 内部字段(用于下游解析)
"_invoices_json", # JSON 序列化的发票列表,供 bot/fill_doc 使用
"工号",
]
# ------------------------------------------------------------------
# 发票类型常量
# ------------------------------------------------------------------
INVOICE_TYPE_TRAIN = "高铁票"
INVOICE_TYPE_HOTEL = "酒店住宿"
INVOICE_TYPE_GENERAL = "普通发票"
INVOICE_TYPE_TRAVEL = frozenset([INVOICE_TYPE_TRAIN, INVOICE_TYPE_HOTEL])
def is_travel_invoice(invoice_type: str) -> bool:
"""判断是否为差旅发票(高铁票/酒店住宿)"""
return invoice_type in INVOICE_TYPE_TRAVEL
def classify_invoice_batch(invoices: list[dict[str, str]]) -> dict[str, list[dict[str, str]]]:
"""将发票列表按类型分组:{'travel': [...], 'general': [...]}"""
travel = []
general = []
for inv in invoices:
inv_type = inv.get("发票类型", INVOICE_TYPE_GENERAL)
if is_travel_invoice(inv_type):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general}
# ------------------------------------------------------------------
# CSV 读写工具
# ------------------------------------------------------------------
def _clean_invoice_for_json(inv: dict[str, str]) -> dict[str, str]:
"""清理发票字典中的内部字段,保留可序列化的字段"""
clean = {}
for k, v in inv.items():
if k.startswith("_"):
continue
clean[k] = v
return clean
def load_csv(csv_path: Path) -> list[dict[str, str]] | None:
"""读取支付记录 CSV 为 dict 列表,失败返回 None"""
try:
with open(csv_path, encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames or []
missing = [c for c in PAYMENT_RECORD_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_invoice_csv(csv_path: Path) -> list[dict[str, str]] | None:
"""读取发票级别 CSV 为 dict 列表(每行一张发票),失败返回 None"""
try:
with open(csv_path, encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames or []
missing = [c for c in INVOICE_LEVEL_COLUMNS if c not in fieldnames]
if missing:
log.error(f"发票 CSV 缺少必要列: {missing}")
return None
return [row for row in reader]
except FileNotFoundError:
log.error(f"发票 CSV 文件不存在: {csv_path.name}")
return None
except Exception as e:
log.error(f"发票 CSV 读取失败: {e}")
return None
def load_invoices_from_csv(csv_path: Path) -> list[dict[str, str]] | None:
"""从支付记录 CSV 中还原发票级别的数据(供 bot/fill_doc 使用)
读取 _invoices_json 字段,反序列化后展平为发票列表。
"""
rows = load_csv(csv_path)
if rows is None:
return None
invoices = []
for row in rows:
invoices_json = row.get("_invoices_json", "")
if not invoices_json:
continue
try:
inv_list = json.loads(invoices_json)
for inv in inv_list:
# 从支付记录回填刷卡信息
inv["刷卡日期"] = row.get("刷卡日期", inv.get("刷卡日期", ""))
inv["公务卡号"] = row.get("公务卡号", inv.get("公务卡号", ""))
inv["刷卡金额"] = row.get("刷卡金额", inv.get("刷卡金额", ""))
invoices.append(inv)
except json.JSONDecodeError:
log.warning(f"无法解析发票 JSON: {invoices_json[:50]}...")
return invoices
def save_csv(
payment_records: list[dict[str, str]],
output_path: str | Path = "payment_records.csv",
) -> None:
"""将支付记录列表保存为 CSV以支付记录为主键
每条支付记录包含:
- 刷卡日期、公务卡号、刷卡金额(支付信息)
- 关联发票数、发票详情(发票聚合信息)
- _matched_invoices内部字段序列化为 JSON 存储在 CSV 中)
"""
csv_path = Path(output_path)
with open(csv_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(PAYMENT_RECORD_COLUMNS)
for idx, record in enumerate(payment_records, 1):
# 序列化关联发票为 JSON
matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment]
invoices_json = json.dumps(
[_clean_invoice_for_json(inv) for inv in matched_invoices],
ensure_ascii=False,
)
writer.writerow(
[
idx,
record.get("刷卡日期", ""),
record.get("公务卡号", ""),
record.get("刷卡金额", ""),
record.get("关联发票数", str(len(matched_invoices))),
record.get("发票详情", ""),
record.get("备注", ""),
invoices_json,
record.get("工号", ""),
]
)
log.info(f"支付记录 CSV 已保存: {csv_path.name}")
def save_invoice_csv(
payment_records: list[dict[str, str]],
output_path: str | Path = "invoice_summary.csv",
) -> None:
"""将支付记录展平为发票级别 CSV每行一张发票
从 _matched_invoices 中还原每张发票,回填刷卡信息,
生成以发票为主键的 CSV用于人工填写报销单参考。
"""
csv_path = Path(output_path)
with open(csv_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(INVOICE_LEVEL_COLUMNS)
idx = 1
for record in payment_records:
matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment]
for inv in matched_invoices:
clean_inv = _clean_invoice_for_json(inv)
writer.writerow(
[
idx,
clean_inv.get("发票类型", ""),
clean_inv.get("发票号码", ""),
clean_inv.get("开票日期", ""),
clean_inv.get("项目名称", ""),
clean_inv.get("规格型号", ""),
clean_inv.get("价税合计", ""),
clean_inv.get("销售方名称", ""),
clean_inv.get("出发站", ""),
clean_inv.get("到达站", ""),
clean_inv.get("车次", ""),
clean_inv.get("乘车日期", ""),
clean_inv.get("座位等级", ""),
clean_inv.get("人员姓名", ""),
record.get("刷卡日期", ""),
record.get("公务卡号", ""),
record.get("刷卡金额", ""),
record.get("备注", ""),
record.get("工号", ""),
]
)
idx += 1
log.info(f"发票级别 CSV 已保存: {csv_path.name}")
def save_csv_rows(csv_path: Path, rows: list[dict[str, str]]) -> None:
"""将 dict 列表保存为支付记录 CSV用于更新已有 CSV"""
with open(csv_path, "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=PAYMENT_RECORD_COLUMNS)
writer.writeheader()
writer.writerows(rows)
log.info(f"CSV 已保存: {csv_path.name}")

210
src/doc/llm_extractor.py Normal file
View File

@@ -0,0 +1,210 @@
"""
LLM 信息提取
使用 LLM 从 PDF 文本中提取结构化数据,以及从支付截图中提取刷卡信息。
支持 JSON 格式输出,字段与 CSV_COLUMNS 对齐。
对外接口:
extract_invoice_from_text(text, file_name) -> dict 从 PDF 文本提取发票信息
extract_card_info_from_image(image_path) -> dict 从支付截图提取刷卡信息
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
from typing import Any, cast
from .. import get_logger
from .prompt import build_card_info_system_prompt, build_invoice_system_prompt
log = get_logger("llm_extractor")
def _create_llm() -> Any:
"""根据配置文件创建 LLM 实例。"""
try:
from llama_index.llms.openai_like import OpenAILike
except ImportError:
log.error("缺少 llama-index-llms-openai-like请执行: uv pip install llama-index-llms-openai-like")
raise
from ..config import get_llm_config
llm_config = get_llm_config()
return OpenAILike(
model=llm_config["model"],
api_base=llm_config["api_base"],
api_key=llm_config.get("api_key", "lm-studio"),
temperature=0.1,
max_tokens=8192,
request_timeout=600.0,
is_chat_model=True,
)
def _llm_query(system_prompt: str, user_content: str, max_tokens: int = 4096) -> str:
"""发送请求到 LLM 并返回完整响应文本。"""
from llama_index.core.llms import ChatMessage
from ..config import get_llm_config
messages = [
ChatMessage(role="system", content=system_prompt),
ChatMessage(role="user", content=user_content),
]
llm_config = get_llm_config()
llm = _create_llm()
log.info("开始请求 LLM (model=%s, base=%s)", llm_config["model"], llm_config["api_base"])
try:
parts = []
for resp in llm.stream_chat(
messages,
temperature=0.1,
max_tokens=max_tokens,
extra_body={"reasoning_effort": "none"},
):
delta = resp.delta
if delta:
parts.append(delta)
text = "".join(parts)
log.info("LLM 请求完成,响应总长度: %d 字符", len(text))
log.info("LLM 响应: %s", text)
return text
except Exception as e:
log.error("LLM 请求失败: %s", e)
raise
def _parse_json_response(text: str) -> dict[str, Any]:
"""从 LLM 响应中提取 JSON处理可能的 Markdown 包裹。"""
text = text.strip()
# 处理 ```json ... ``` 包裹
if "```" in text:
# 提取第一个代码块
start = text.find("```") + 3
end = text.find("```", start)
if end > start:
text = text[start:end].strip()
# 去掉可能的前缀 (如 "json")
if text.lower().startswith("json"):
text = text[4:].strip()
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
# ------------------------------------------------------------------
# 支付截图信息提取(多模态)
# ------------------------------------------------------------------
def _image_to_base64(image_path: Path) -> str:
"""将图片文件读取为 base64 字符串。"""
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def _llm_query_multimodal(
system_prompt: str,
text: str,
image_b64: str,
max_tokens: int = 4096,
) -> str:
"""发送多模态请求(文本 + 图片)到 LLM。"""
from llama_index.core.base.llms.types import ImageBlock, TextBlock
from llama_index.core.llms import ChatMessage
from ..config import get_llm_config
messages = [
ChatMessage(role="system", content=system_prompt),
ChatMessage(
role="user",
blocks=[
TextBlock(text=text),
ImageBlock(
url=f"data:image/jpeg;base64,{image_b64}",
detail="high",
),
],
),
]
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 extract_card_info_from_image(image_path: Path) -> dict[str, Any]:
"""从支付截图中提取刷卡信息。
Args:
image_path: 支付截图图片路径。
Returns:
包含刷卡日期、刷卡金额、公务卡号的字典。
"""
system_prompt = build_card_info_system_prompt()
user_text = f"请分析以下支付截图并提取信息:\n\n文件名: {image_path.name}"
image_b64 = _image_to_base64(image_path)
try:
response = _llm_query_multimodal(system_prompt, user_text, image_b64, max_tokens=4096)
result = _parse_json_response(response)
log.info("LLM 支付截图提取成功: %s", image_path.name)
return result
except Exception as e:
log.error("LLM 支付截图提取失败: %s (%s)", image_path.name, e)
raise

395
src/doc/matcher.py Normal file
View File

@@ -0,0 +1,395 @@
"""发票与支付截图匹配
将提取到的发票数据与支付截图中的刷卡记录进行金额匹配,
输出以支付记录为主键的结果列表。
## 业务约束
- 发票数 >= 付款记录数(最少一张发票对应一张付款记录)
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
- 若发票数 == 付款数,走一对一匹配,无需一对多
## 匹配流程
1. 扫描目录下图片文件,调用 LLM 提取刷卡信息(日期/金额/卡号)
2. 解析发票和刷卡记录的金额,进行总额校验
- 发票总额 < 刷卡总额时发出 warning
3. 按金额降序排序
4. 根据数量关系选择匹配策略:
- 数量相等 → 一对一匹配:按金额从大到小依次配对,相对容差内即匹配
- 发票更多 → 一对多匹配:对每张刷卡记录贪心凑金额,相对容差内结束
5. 构建以支付记录为主键的结果列表
6. 未匹配的发票单独作为一条记录(无刷卡信息)
7. 清理内部字段,输出支付记录列表
## 容差计算
使用相对容差(默认 3%),以刷卡金额为基准:
- ¥2900 发票 vs ¥2850 刷卡 → 差 ¥50容差 ¥85.5 → 匹配成功
- ¥100 发票 vs ¥95 刷卡 → 差 ¥5容差 ¥3.0 → 不匹配(需精确匹配或调整)
## 一对多匹配细节
- 对每张刷卡记录,维护 remaining剩余待匹配金额
- 遍历未分配的发票(按金额降序):
- 若发票金额 + 容差 >= remaining视为最后一张匹配后退出
- 否则发票金额不超过 remaining + 容差即可匹配
- 匹配后 remaining 为负且超出容差时回滚最后一张发票
- 每张发票只会被分配一次
## 对外接口
match_invoices_to_cards(invoices, directory, tolerance) -> list[dict]
"""
from pathlib import Path
from typing import Any
from .. import get_logger
from .llm_extractor import extract_card_info_from_image
log = get_logger("matcher")
# 支持的图片扩展名
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
def _find_images(directory: str) -> list[Path]:
"""在目录下查找支付截图图片文件"""
dir_path = Path(directory)
images = [f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in IMAGE_EXTENSIONS]
return sorted(images)
def _safe_float(value: str | None, default: float = 0.0) -> float:
"""安全转换为浮点数"""
if value is None or str(value).strip() == "":
return default
try:
return float(str(value).replace(",", ""))
except (ValueError, TypeError):
return default
def _extract_all_cards(directory: str) -> list[dict[str, Any]]:
"""提取目录下所有支付截图的刷卡信息"""
images = _find_images(directory)
if not images:
log.warning("未找到支付截图图片")
return []
log.info(f"发现 {len(images)} 张支付截图")
cards = []
for img_path in images:
try:
card_info = extract_card_info_from_image(img_path)
card_info["_source_file"] = img_path.name
cards.append(card_info)
log.info(f"[支付截图] 已解析: {img_path.name}")
except Exception as e:
log.warning(f"支付截图解析失败 {img_path.name}: {e}")
log.info(f"共提取 {len(cards)} 条刷卡记录")
return cards
def _build_invoice_summary(invoices: list[dict[str, Any]]) -> str:
"""将多张发票信息汇总为备注字符串"""
parts = []
for inv in invoices:
person_name = inv.get("人员姓名") or inv.get("发票号码", "未知")
inv_type = inv.get("发票类型", "未知")
amount = inv.get("价税合计", "未知")
parts.append(f"{inv_type}[{person_name}{amount}")
return " | ".join(parts)
def _relative_tolerance(base: float, rate: float = 0.05) -> float:
"""根据基准金额计算相对容差(默认 5%"""
return abs(base) * rate
def match_invoices_to_cards(
invoices: list[dict[str, Any]],
directory: str,
tolerance: float = 0.03,
) -> list[dict[str, Any]]:
"""将发票与支付截图按金额匹配,输出以支付记录为主键的结果列表
业务约束:
- 发票数 >= 付款记录数
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
- 若发票数 == 付款数,一对一匹配,无需一对多
Args:
invoices: 发票列表,需包含 "价税合计" 字段
directory: 支付截图所在目录
tolerance: 金额匹配容差比例(默认 0.05 = 5%
Returns:
以支付记录为主键的结果列表,每条记录包含:
- 刷卡日期、公务卡号、刷卡金额(支付信息)
- 关联发票列表_matched_invoices
- 发票详情备注
- 未匹配发票单独作为一条无刷卡信息的记录
"""
cards = _extract_all_cards(directory)
if not cards:
log.warning("无刷卡记录可供匹配,发票将保持原状")
# 无刷卡记录时,每张发票作为独立记录返回
return _invoices_to_records(invoices)
# 解析金额
for card in cards:
card["_amount"] = _safe_float(card.get("刷卡金额"))
for inv in invoices:
inv["_amount"] = _safe_float(inv.get("价税合计"))
# 数据校验
total_invoices = sum(inv["_amount"] for inv in invoices)
total_cards = sum(card["_amount"] for card in cards)
log.info(
f"金额校验: 发票总额 ¥{total_invoices:.2f}, 刷卡总额 ¥{total_cards:.2f}, "
f"发票数 {len(invoices)}, 刷卡数 {len(cards)}"
)
total_tolerance = _relative_tolerance(max(total_invoices, total_cards), tolerance)
if total_invoices < total_cards - total_tolerance:
log.warning(
f"发票总额 (¥{total_invoices:.2f}) 小于刷卡总额 (¥{total_cards:.2f})"
f"超出容差 {tolerance * 100:.0f}%,匹配结果可能有偏差"
)
# 按金额降序排序
cards.sort(key=lambda c: c["_amount"], reverse=True)
invoices.sort(key=lambda i: i["_amount"], reverse=True)
# 执行匹配,返回 {card_index: [invoice_indices]} 的映射
card_to_invoices = _match(cards, invoices, tolerance)
# 构建支付记录列表
records = _build_payment_records(cards, invoices, card_to_invoices)
# 清理内部字段
for inv in invoices:
inv.pop("_amount", None)
for card in cards:
card.pop("_amount", None)
# 统计
matched_invoices = sum(len(inv_list) for inv_list in card_to_invoices.values())
unmatched_count = len(invoices) - matched_invoices
log.info(f"匹配完成: {len(records)} 条支付记录, {matched_invoices}/{len(invoices)} 张发票已关联")
if unmatched_count:
log.info(f"未匹配发票: {unmatched_count} 张(已单独列为记录)")
return records
def _match(
cards: list[dict[str, Any]],
invoices: list[dict[str, Any]],
tolerance: float,
) -> dict[int, list[int]]:
"""执行匹配,返回 {card_index: [invoice_indices]} 的映射
tolerance 为相对容差比例(如 0.05 表示 5%
"""
result: dict[int, list[int]] = {}
assigned: set[int] = set()
if len(invoices) == len(cards):
_match_one_to_one(invoices, cards, tolerance, assigned, result)
else:
_match_one_to_many(invoices, cards, tolerance, assigned, result)
return result
def _match_one_to_one(
invoices: list[dict[str, Any]],
cards: list[dict[str, Any]],
tolerance: float,
assigned: set[int],
result: dict[int, list[int]],
) -> None:
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对
tolerance 为相对容差比例,以刷卡金额为基准计算
"""
for card_idx, card in enumerate(cards):
if card_idx >= len(invoices):
break
inv = invoices[card_idx]
diff = abs(inv["_amount"] - card["_amount"])
card_tol = _relative_tolerance(card["_amount"], tolerance)
if diff <= card_tol:
assigned.add(card_idx)
result[card_idx] = [card_idx]
log.info(
f"[一对一] {inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
f"{card.get('_source_file', '未知')} ¥{card['_amount']:.2f}"
)
else:
log.warning(
f"[一对一] 金额偏差超出容差: "
f"{inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
f"vs ¥{card['_amount']:.2f} (差 ¥{diff:.2f}, 容差 ¥{card_tol:.2f})"
)
def _match_one_to_many(
invoices: list[dict[str, Any]],
cards: list[dict[str, Any]],
tolerance: float,
assigned: set[int],
result: dict[int, list[int]],
) -> None:
"""一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配
tolerance 为相对容差比例(如 0.05 表示 5%),以刷卡金额为基准计算
匹配分两阶段:
1. 精确匹配:先扫描金额完全相等(差值 <= 0.01 元)的发票-刷卡对,直接锁定
2. 贪心匹配:剩余未分配的发票和刷卡记录走贪心凑金额
"""
# ---- 阶段 1精确匹配金额差 <= 0.01 元视为相等)----
exact_tolerance = 0.01
for card_idx, card in enumerate(cards):
card_amount = card["_amount"]
if card_amount <= 0:
continue
for idx, inv in enumerate(invoices):
if idx in assigned:
continue
inv_amount = inv["_amount"]
if inv_amount <= 0:
continue
if abs(inv_amount - card_amount) <= exact_tolerance:
assigned.add(idx)
result[card_idx] = [idx]
log.info(
f"[一对多-精确] {inv.get('发票号码', '未知')} ¥{inv_amount:.2f} "
f"{card.get('_source_file', '未知')} ¥{card_amount:.2f}"
)
break # 每张刷卡只精确匹配一张发票
# ---- 阶段 2贪心匹配仅处理未精确匹配的刷卡记录----
for card_idx, card in enumerate(cards):
if card_idx in result: # 已在阶段 1 精确匹配
continue
card_amount = card["_amount"]
if card_amount <= 0:
continue
# 以刷卡金额为基准计算相对容差
card_tol = _relative_tolerance(card_amount, tolerance)
remaining = card_amount
matched_indices: list[int] = []
for idx, inv in enumerate(invoices):
if idx in assigned:
continue
if remaining <= card_tol:
break
inv_amount = inv["_amount"]
if inv_amount <= 0:
continue
# 最后一张发票:金额 + 容差 >= remaining 即可
# 中间发票:金额不超过 remaining + 容差
if inv_amount + card_tol >= remaining:
is_match = True
else:
is_match = inv_amount <= remaining + card_tol
if is_match:
assigned.add(idx)
matched_indices.append(idx)
remaining -= inv_amount
if remaining <= card_tol:
break
# 回滚:如果匹配后 remaining 为负且超出容差
if remaining < -card_tol and matched_indices:
last_idx = matched_indices.pop()
assigned.discard(last_idx)
remaining += invoices[last_idx]["_amount"]
# 记录匹配结果
if matched_indices:
result[card_idx] = matched_indices
for idx in matched_indices:
inv = invoices[idx]
log.info(
f"[一对多-贪心] {inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
f"{card.get('_source_file', '未知')} ¥{card['_amount']:.2f}"
)
def _build_payment_records(
cards: list[dict[str, Any]],
invoices: list[dict[str, Any]],
card_to_invoices: dict[int, list[int]],
) -> list[dict[str, Any]]:
"""构建以支付记录为主键的结果列表"""
records: list[dict[str, Any]] = []
# 已有匹配记录的支付
for card_idx, inv_indices in card_to_invoices.items():
card = cards[card_idx]
matched_invs = [invoices[idx] for idx in inv_indices]
record = {
"刷卡日期": card.get("刷卡日期", ""),
"公务卡号": card.get("公务卡号", ""),
"刷卡金额": str(card["_amount"]),
"关联发票数": str(len(matched_invs)),
"发票详情": _build_invoice_summary(matched_invs),
"备注": "",
"_matched_invoices": matched_invs,
}
records.append(record)
# 未匹配的发票,单独作为记录
matched_indices = set()
for inv_indices in card_to_invoices.values():
matched_indices.update(inv_indices)
unmatched = [inv for idx, inv in enumerate(invoices) if idx not in matched_indices]
for inv in unmatched:
record = {
"刷卡日期": "",
"公务卡号": "",
"刷卡金额": "",
"关联发票数": "1",
"发票详情": _build_invoice_summary([inv]),
"备注": "未匹配到支付记录",
"_matched_invoices": [inv],
}
records.append(record)
return records
def _invoices_to_records(invoices: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""无刷卡记录时,将每张发票转为独立记录"""
records = []
for inv in invoices:
record = {
"刷卡日期": "",
"公务卡号": "",
"刷卡金额": "",
"关联发票数": "1",
"发票详情": _build_invoice_summary([inv]),
"备注": "",
"_matched_invoices": [inv],
}
records.append(record)
return records

42
src/doc/pdf.py Normal file
View File

@@ -0,0 +1,42 @@
"""PDF 文件发现与文本提取
从 PDF 发票文件中提取原始文本内容。
对外接口:
find_pdf_files(directory) -> list[Path] 查找目录下所有 PDF
extract_text_from_pdf(filepath) -> str 提取 PDF 文本
"""
from pathlib import Path
from .. import get_logger
log = get_logger("pdf")
def find_pdf_files(directory: str = ".") -> list[Path]:
"""查找目录下所有 PDF 文件(非递归)"""
pdf_dir = Path(directory)
if not pdf_dir.exists():
return []
return sorted(pdf_dir.glob("*.pdf"))
def extract_text_from_pdf(filepath: Path) -> str:
"""从单个 PDF 中提取全部文本"""
try:
import pdfplumber
except ImportError as err:
raise ImportError("缺少 pdfplumber请执行: uv pip install pdfplumber") from err
try:
parts = []
with pdfplumber.open(filepath) as pdf:
for page in pdf.pages:
text = page.extract_text()
if text:
parts.append(text)
return "\n".join(parts)
except Exception as e:
log.error(f"无法读取 {filepath.name}: {e}")
return ""

26
src/doc/prompt.py Normal file
View File

@@ -0,0 +1,26 @@
"""
LLM 提示词模板
从 src/prompts/ 目录加载 .md 文件作为提示词模板。
"""
import os
_PROMPTS_DIR = os.path.join(os.path.dirname(__file__), "prompts")
def _load_prompt(filename: str) -> str:
"""从 prompts 目录加载提示词文件内容。"""
path = os.path.join(_PROMPTS_DIR, filename)
with open(path, encoding="utf-8") as f:
return f.read()
def build_invoice_system_prompt() -> str:
"""构建发票提取系统提示词。"""
return _load_prompt("invoice_system.md")
def build_card_info_system_prompt() -> str:
"""构建支付截图信息提取系统提示词。"""
return _load_prompt("card_info_system.md")

View File

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

View File

@@ -0,0 +1,33 @@
# 发票提取系统提示词
你是财务文档信息提取助手。你的任务是从发票文本中提取结构化信息,并以 JSON 格式返回。
需要提取的字段(全部必填,无法识别时返回空字符串):
先判断发票类型,如果是高铁票/或者火车票,返回如下字段:
1. 发票类型: "高铁票"
2. 发票号码: 发票的唯一编号
3. 开票日期: 格式为 YYYY/M/D
4. 乘车日期: 格式为 YYYY/M/D
5. 出发站: 没有留空
6. 到达站: 没有留空
7. 座位等级: 没有留空
8. 车次: 没有留空
9. 人员姓名: 没有留空
10. 价税合计:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息
如果是酒店住宿(酒店住宿通产包含关键字:住宿服务,酒店,生产生活服务等,请仔细分析,这种发票和普通发票类似),返回如下字段:
1. 发票类型: "酒店住宿"
2. 发票号码: 发票的唯一编号
3. 开票日期: 格式为 YYYY/M/D
4. 价税合计: 金额数字
如果是普通发票,返回如下字段:
1. 发票类型: "普通发票"
2. 发票号码: 发票的唯一编号
3. 开票日期: 格式为 YYYY/M/D
4. 项目名称: 商品或服务名称,总结的人能看懂
5. 规格型号: 规格描述
6. 价税合计: 金额数字
7. 销售方名称: 卖方全称
严格只输出 JSON不要输出任何其他文字、Markdown 标记或解释。

View File

@@ -1,17 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" """
财务报销自动化 财务报销自动化
依次执行: 依次执行:
1. 发票提取 PDF 发票提取信息生成 invoice_summary.csv 1. 发票提取 PDF 发票提取信息生成 invoice_summary.csv
2. OCR 识别 从支付截图识别刷卡信息回填 CSV 2. 报销提交 打开浏览器登录财务系统并自动填报
3. 报销提交 打开浏览器登录财务系统并自动填报
用法: 用法:
python run.py # 全流程 python run.py # 全流程
python run.py --step invoice # 仅发票提取 python run.py --step invoice # 仅发票提取
python run.py --step ocr # 仅 OCR 识别
python run.py --step submit # 仅浏览器填报 python run.py --step submit # 仅浏览器填报
python run.py -u 工号 -p 密码 # 覆盖登录凭据 python run.py -u 工号 -p 密码 # 覆盖登录凭据
""" """
@@ -21,28 +18,30 @@ import sys
from pathlib import Path from pathlib import Path
# 确保项目根目录在 sys.path 中 # 确保项目根目录在 sys.path 中
sys.path.insert(0, str(Path(__file__).parent.resolve())) sys.path.insert(0, str(Path(__file__).parent.resolve().parent))
from app.pipeline import run_pipeline from src.pipeline import run_pipeline
def main(): def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="财务报销自动化 - 发票提取 → OCR 识别 → 浏览器填报", description="财务报销自动化 - 发票提取 → 浏览器填报",
) )
parser.add_argument( parser.add_argument(
"--step", "--step",
choices=["all", "invoice", "ocr", "submit"], choices=["all", "invoice", "submit"],
default="all", default="all",
help="执行步骤 (默认: all)", help="执行步骤 (默认: all)",
) )
parser.add_argument( parser.add_argument(
"-u", "--username", "-u",
"--username",
default=None, default=None,
help="信息门户登录账号(覆盖 config.json", help="信息门户登录账号(覆盖 config.json",
) )
parser.add_argument( parser.add_argument(
"-p", "--password", "-p",
"--password",
default=None, default=None,
help="信息门户登录密码(覆盖 config.json", help="信息门户登录密码(覆盖 config.json",
) )

136
src/pipeline.py Normal file
View File

@@ -0,0 +1,136 @@
"""
报销全流程编排
将发票提取 → 浏览器填报串联为一条管道,
数据在内存中流转,同时生成 CSV 中间产物。
发票类型区分:
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- 普通发票:生成易耗品出库单,走普通报销流程
"""
from pathlib import Path
from typing import Any
from . import get_logger
from .config import load_config
from .doc.extractor import extract_invoices
from .doc.invoice import (
classify_invoice_batch,
load_invoices_from_csv,
save_invoice_csv,
)
from .doc.invoice import (
save_csv as save_payment_csv,
)
log = get_logger("pipeline")
def _find_upload_directory(project_dir: Path) -> Path | None:
"""自动发现 uploads 目录下最新且包含 PDF 的会话文件夹"""
uploads_base = project_dir / "src" / "web" / "uploads"
if not uploads_base.is_dir():
return None
# 只选包含 PDF 的目录
valid_dirs = [d for d in uploads_base.iterdir() if d.is_dir() and list(d.glob("*.pdf"))]
if not valid_dirs:
return None
# 按修改时间排序,取最新
session_dirs = sorted(
valid_dirs,
key=lambda d: d.stat().st_mtime,
reverse=True,
)
return session_dirs[0]
def _classify_from_csv(csv_path: Path) -> dict[str, list[dict[str, Any]]]:
"""从已生成的 CSV 中读取发票数据并按类型分组"""
rows = load_invoices_from_csv(csv_path)
if rows is None:
return {"travel": [], "general": []}
return classify_invoice_batch(rows)
def run_pipeline(step: str = "all", username: str | None = None, password: str | None = None) -> int:
"""执行报销流程
Args:
step: all | invoice | submit
username: 覆盖 config.json 中的用户名
password: 覆盖 config.json 中的密码
"""
config = load_config()
if username:
config["username"] = username
if password:
config["password"] = password
# 工作目录(项目根目录)
project_dir = Path(__file__).parent.parent
# --------------------------------------------------
# Step 1: 发票提取
# --------------------------------------------------
payment_records: list[dict[str, str]] | None = None
groups: dict[str, list[dict[str, str]]] | None = None
if step in ("all", "invoice"):
log.info("=" * 60)
log.info("[1/2] 发票提取")
log.info("=" * 60)
payment_records, groups = extract_invoices(str(project_dir))
if not payment_records:
log.error("未提取到任何发票数据")
return 1
save_payment_csv(payment_records, project_dir / "payment_records.csv")
save_invoice_csv(payment_records, project_dir / "invoice_summary.csv")
# 打印分类结果
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])}")
if step == "invoice":
log.info("[1/2] 发票提取 完成")
return 0
# --------------------------------------------------
# Step 2: 浏览器填报
# --------------------------------------------------
if step in ("all", "submit"):
log.info("=" * 60)
log.info("[2/2] 报销提交")
log.info("=" * 60)
from .bot import load_invoice_data, run_bot
csv_path = project_dir / "payment_records.csv"
bot_invoices = load_invoice_data(str(csv_path), config)
# 根据发票类型选择填报模式
if groups is None:
groups = _classify_from_csv(csv_path)
if groups["travel"] and not groups["general"]:
log.info("检测到纯差旅发票,使用差旅报销模式")
# TODO: 差旅报销填报流程
run_bot(config, bot_invoices)
else:
log.info("检测到普通发票,使用普通报销模式")
run_bot(config, bot_invoices)
if step == "submit":
log.info("[2/2] 报销提交 完成")
return 0
# --------------------------------------------------
# 全流程完成
# --------------------------------------------------
log.info("=" * 60)
log.info("全流程执行完毕")
log.info("=" * 60)
return 0

83
src/web/README.md Normal file
View File

@@ -0,0 +1,83 @@
---
last_reviewed: 2026-06-09
---
# src/web 模块设计说明
## 设计思路
`src/web` 是一个基于 Flask 的轻量级 Web 界面,为财务报销自动化管道提供可视化操作入口。核心设计原则:
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5不引入构建工具保持单页应用轻量可维护。
- **双模式支持**PDF 发票提取模式和 CSV 快捷上传模式,后者跳过 LLM 识别和 PDF 解析,直接处理已有发票数据。
## 文件结构
```
src/web/
├── app.py # Flask 应用入口,路由、管道编排、日志收集
├── templates/
│ ├── index.html # PC 端主界面(上传、配置、处理、编辑、提交)
│ └── mobile_upload.html # 移动端上传页面(拍照/相册选择)
└── static/
├── css/
│ └── index.css # 全局样式(上传区、日志面板、可编辑表格)
└── js/
└── index.js # 前端逻辑上传、SSE 日志、表格编辑、二维码同步)
```
## 数据流
```
用户上传文件 → 创建 session → 文件写入 uploads/<sid>/
后台线程执行管道: extract_invoices() → enrich_with_llm() → save_csv()
结果写入 session 目录: invoice_summary.csv / result.json / session.log
前端 SSE 轮询 result.json 变化 → 显示完成状态
前端加载 CSV 数据 → 可编辑表格展示 → 用户修改后保存
用户点击提交 → run_financial_submit() → bot 自动填报财务系统
```
## API 路由
| 方法 | 路径 | 功能 |
|------|------|------|
| GET | `/` | 主界面 |
| POST | `/api/session` | 创建会话,返回 session_id |
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
| POST | `/api/upload-csv/<sid>` | 上传 CSV 发票数据 |
| GET | `/api/files/<sid>` | 列出会话文件 |
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
| GET | `/api/logs/<sid>` | SSE 日志流 |
| GET | `/api/data/<sid>` | 获取发票数据 JSON |
| POST | `/api/save/<sid>` | 保存前端编辑的发票数据 |
| GET | `/api/download/<sid>/<filename>` | 下载生成文件 |
| POST | `/api/submit-financial/<sid>` | 手动触发财务系统填报 |
| GET | `/mobile/<sid>` | 移动端上传页面 |
## 关键机制
### 日志收集
`_SSELogHandler` 将管道日志写入 `session.log`SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
### 发票类型分流
- **差旅发票**(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- **普通发票**生成易耗品出库单Word 文档),走普通报销流程
`classify_invoice_batch()` 根据发票内容自动分类,`_try_fill_consumable_doc()` 仅对普通发票生成出库单。
### 移动端同步
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的图片通过 `syncFiles()` 轮询同步到 PC 端内存中的 `imgFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
### 配置管理
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端支持通过上传 `config.json` 快速填充配置表单。

View File

@@ -1,14 +1,17 @@
""" """
财务报销自动化 Web 界面 财务报销自动化 Web 界面
用户上传 PDF 发票和支付截图配置账号信息自动完成 用户上传 PDF 发票配置账号信息自动完成
1. 发票提取 2. OCR 识别 3. 浏览器填报可选 1. 发票提取 2. 浏览器填报可选
启动: python web/app.py 发票类型区分
- 差旅发票高铁票/酒店住宿不生成易耗品出库单走差旅报销流程
- 普通发票生成易耗品出库单走普通报销流程
启动: uv run python src/web/app.py
访问: http://localhost:5000 访问: http://localhost:5000
""" """
import io
import json import json
import logging import logging
import sys import sys
@@ -16,29 +19,38 @@ import threading
import time import time
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any
from urllib.parse import quote from urllib.parse import quote
from flask import Flask, Response, jsonify, render_template, request, stream_with_context from flask import Flask, Response, jsonify, render_template, request, stream_with_context
# 确保项目根目录在 sys.path # 确保项目根目录在 sys.path
PROJECT_ROOT = Path(__file__).parent.parent.resolve() PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from app.config import load_config as load_project_config from src import get_logger # noqa: E402, I001
from app.extractor import extract_invoices, save_csv, save_markdown from src.config import load_config as load_project_config # noqa: E402, I001
from app.fill_consumable_doc import ( from src.doc.extractor import ( # noqa: E402, I001
extract_invoices,
)
from src.doc.fill_consumable_doc import ( # noqa: E402, I001
CONSUMABLE_DOC_FILENAME, CONSUMABLE_DOC_FILENAME,
fill_consumable_from_template, fill_consumable_from_template,
) )
from app import get_logger from src.doc.invoice import ( # noqa: E402, I001
from app.ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv as load_ocr_csv classify_invoice_batch,
load_csv,
load_invoice_csv,
save_csv as save_payment_csv,
save_invoice_csv,
)
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
app = Flask(__name__, template_folder="templates") app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "web" / "uploads" UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
SESSION_LOG_FILE = "session.log" SESSION_LOG_FILE = "session.log"
SESSION_RESULT_FILE = "result.json" SESSION_RESULT_FILE = "result.json"
@@ -47,6 +59,7 @@ SESSION_RESULT_FILE = "result.json"
# 日志收集器 — 捕获管道日志到文件SSE 端点通过 tail -f 读取 # 日志收集器 — 捕获管道日志到文件SSE 端点通过 tail -f 读取
# ================================================================ # ================================================================
class _SSELogHandler(logging.Handler): class _SSELogHandler(logging.Handler):
"""将日志写入指定文件(线程安全)""" """将日志写入指定文件(线程安全)"""
@@ -55,7 +68,7 @@ class _SSELogHandler(logging.Handler):
self._lock = threading.Lock() self._lock = threading.Lock()
self._file = open(log_path, "w", encoding="utf-8") self._file = open(log_path, "w", encoding="utf-8")
def emit(self, record: logging.LogRecord): def emit(self, record: logging.LogRecord) -> None:
try: try:
msg = self.format(record) + "\n" msg = self.format(record) + "\n"
with self._lock: with self._lock:
@@ -64,7 +77,7 @@ class _SSELogHandler(logging.Handler):
except Exception: except Exception:
pass pass
def close_file(self): def close_file(self) -> None:
try: try:
self._file.close() self._file.close()
except Exception: except Exception:
@@ -82,7 +95,7 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
handler.setFormatter(fmt) handler.setFormatter(fmt)
handler.setLevel(logging.INFO) handler.setLevel(logging.INFO)
for name in ["extractor", "ocr", "pipeline", "bot", "fill_consumable_doc"]: for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
logger = logging.getLogger(name) logger = logging.getLogger(name)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.addHandler(handler) logger.addHandler(handler)
@@ -90,8 +103,8 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
return handler return handler
def _remove_log_collector(handler: _SSELogHandler): def _remove_log_collector(handler: _SSELogHandler) -> None:
for name in ["extractor", "ocr", "pipeline", "bot", "fill_consumable_doc"]: for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
logging.getLogger(name).removeHandler(handler) logging.getLogger(name).removeHandler(handler)
handler.close_file() handler.close_file()
@@ -101,7 +114,7 @@ def _remove_log_collector(handler: _SSELogHandler):
# ================================================================ # ================================================================
def _load_session_config(session_dir: Path) -> dict: def _load_session_config(session_dir: Path) -> dict[str, Any]:
config = load_project_config() config = load_project_config()
cfg_path = session_dir / "config.json" cfg_path = session_dir / "config.json"
if cfg_path.exists(): if cfg_path.exists():
@@ -110,31 +123,95 @@ def _load_session_config(session_dir: Path) -> dict:
return config return config
def _resolve_invoice_csv(session_dir: Path) -> Path | None: def _resolve_payment_csv(session_dir: Path) -> Path | None:
csv_path = session_dir / "invoice_summary.csv" """查找支付记录 CSVpayment_records.csv"""
csv_path = session_dir / "payment_records.csv"
if csv_path.exists(): if csv_path.exists():
return csv_path return csv_path
for f in session_dir.glob("*.csv"): for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f return f
return None return None
def _try_fill_consumable_doc(session_dir: Path, config: dict) -> dict: def _resolve_invoice_csv(session_dir: Path) -> Path | None:
"""根据 CSV 填写易耗品出库单,供会话目录下载。""" """查找发票级别 CSVinvoice_summary.csv"""
csv_path = session_dir / "invoice_summary.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f
return None
def _has_general_invoices(rows: list[dict[str, str]]) -> bool:
"""检查发票列表中是否包含普通发票(需要生成易耗品出库单)"""
invoices = []
for row in rows:
# 支付记录格式:从 _invoices_json 还原
invoices_json = row.get("_invoices_json", "")
if invoices_json:
try:
invoices.extend(json.loads(invoices_json))
except json.JSONDecodeError:
pass
# 发票级别格式:直接使用
elif "发票类型" in row:
invoices.append(row)
if not invoices:
return False
groups = classify_invoice_batch(invoices)
return len(groups["general"]) > 0
def _get_invoice_groups(rows: list[dict[str, str]]) -> dict[str, int]:
"""统计发票类型分布"""
invoices = []
for row in rows:
# 支付记录格式:从 _invoices_json 还原
invoices_json = row.get("_invoices_json", "")
if invoices_json:
try:
invoices.extend(json.loads(invoices_json))
except json.JSONDecodeError:
pass
# 发票级别格式:直接使用
elif "发票类型" in row:
invoices.append(row)
groups = classify_invoice_batch(invoices)
return {
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
}
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""根据 CSV 填写易耗品出库单,供会话目录下载。
仅当存在普通发票时才生成出库单纯差旅发票跳过
"""
if not CONSUMABLE_TEMPLATE.exists(): if not CONSUMABLE_TEMPLATE.exists():
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE) fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"} return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
csv_path = _resolve_invoice_csv(session_dir) csv_path = _resolve_payment_csv(session_dir)
if csv_path is None: if csv_path is None:
return {"ok": False, "error": "未找到发票 CSV"} return {"ok": False, "error": "未找到发票 CSV"}
# 检查是否有普通发票
rows = load_csv(csv_path)
if rows is None:
return {"ok": False, "error": "CSV 读取失败"}
if not _has_general_invoices(rows):
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
out_doc = session_dir / CONSUMABLE_DOC_FILENAME out_doc = session_dir / CONSUMABLE_DOC_FILENAME
try: try:
fill_log.info("开始填写出库单: %s", out_doc.name) fill_log.info("开始填写出库单: %s", out_doc.name)
fill_consumable_from_template( fill_consumable_from_template(csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config)
csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config
)
fill_log.info("出库单填写完成") fill_log.info("出库单填写完成")
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME} return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
except ImportError: except ImportError:
@@ -145,11 +222,16 @@ def _try_fill_consumable_doc(session_dir: Path, config: dict) -> dict:
return {"ok": False, "error": str(e)} return {"ok": False, "error": str(e)}
def _append_doc_download(result: dict, session_id: str, doc_fill: dict) -> None: def _append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
if doc_fill.get("ok"): if doc_fill.get("ok"):
fn = doc_fill["doc_filename"] fn = doc_fill["doc_filename"]
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}" result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
result["doc_ok"] = True result["doc_ok"] = True
elif doc_fill.get("skipped"):
# 差旅发票,跳过出库单生成(不是错误)
result["doc_ok"] = None
result["doc_skipped"] = True
result["doc_message"] = doc_fill.get("error", "")
else: else:
result["doc_ok"] = False result["doc_ok"] = False
result["doc_error"] = doc_fill.get("error", "未知错误") result["doc_error"] = doc_fill.get("error", "未知错误")
@@ -159,78 +241,110 @@ def _append_doc_download(result: dict, session_id: str, doc_fill: dict) -> None:
# 管道入口 # 管道入口
# ================================================================ # ================================================================
def run_pipeline_web(session_dir: Path, config: dict):
"""在 Web 会话目录中执行提取+OCR结果写入 session 目录下的文件 def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
注意不再自动提交财务系统提交通由 /api/submit-financial/<session_id> 触发 注意不再自动提交财务系统提交通由 /api/submit-financial/<session_id> 触发
""" """
start = time.time() start = time.time()
# ---- Step 1: 发票提取 ---- # ---- Step 1: 发票提取 ----
invoices = extract_invoices(str(session_dir)) invoices, groups = extract_invoices(str(session_dir))
if not invoices: if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"} return {"ok": False, "error": "未提取到任何发票数据"}
save_csv(invoices, session_dir / "invoice_summary.csv") # 保存两个 CSV支付记录级别供 bot/出库单使用)和发票级别(供人工参考)
save_markdown(invoices, session_dir / "invoice_summary.md") save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
# ---- Step 2: OCR 识别 ---- # 统计发票总数
csv_path = session_dir / "invoice_summary.csv" invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
rows = load_ocr_csv(csv_path)
if rows is None:
return {"ok": False, "error": "CSV 读取失败"}
rows = enrich_with_ocr(rows, str(session_dir))
save_ocr_csv(csv_path, rows)
save_markdown_from_csv(csv_path, rows)
elapsed = time.time() - start elapsed = time.time() - start
result = { result = {
"ok": True, "ok": True,
"elapsed": f"{elapsed:.1f}s", "elapsed": f"{elapsed:.1f}s",
"invoice_count": len(rows), "invoice_count": invoice_count,
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv", "csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
"md_url": f"/api/download/{session_dir.name}/invoice_summary.md", # 发票类型统计
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
} }
doc_fill = _try_fill_consumable_doc(session_dir, config) doc_fill = _try_fill_consumable_doc(session_dir, config)
_append_doc_download(result, session_dir.name, doc_fill) _append_doc_download(result, session_dir.name, doc_fill)
return result return result
def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str): def run_csv_pipeline_web(session_dir: Path, config: dict[str, Any], csv_filename: str) -> dict[str, Any]:
"""直接使用上传的 CSV 文件,跳过 PDF 提取和 OCR""" """直接使用上传的 CSV 文件,跳过 PDF 提取"""
start = time.time() start = time.time()
csv_path = session_dir / csv_filename csv_path = session_dir / csv_filename
if not csv_path.exists(): if not csv_path.exists():
return {"ok": False, "error": "CSV 文件不存在"} return {"ok": False, "error": "CSV 文件不存在"}
# 读取 CSV 行数 # 尝试读取支付记录格式
rows = load_ocr_csv(csv_path) rows = load_csv(csv_path)
if rows is None: if rows is None:
# 尝试读取发票级别格式
invoice_rows = load_invoice_csv(csv_path)
if invoice_rows is None:
return {"ok": False, "error": "CSV 读取失败"} 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 elapsed = time.time() - start
result = { result = {
"ok": True, "ok": True,
"elapsed": f"{elapsed:.1f}s", "elapsed": f"{elapsed:.1f}s",
"invoice_count": len(rows), "invoice_count": type_stats["travel_count"] + type_stats["general_count"],
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}", "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) doc_fill = _try_fill_consumable_doc(session_dir, config)
_append_doc_download(result, session_dir.name, doc_fill) _append_doc_download(result, session_dir.name, doc_fill)
return result return result
def run_financial_submit(session_dir: Path, config: dict) -> dict: def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)""" """执行财务系统填报(从前端确认后调用)
csv_path = session_dir / "invoice_summary.csv"
根据发票类型选择填报模式
- 纯差旅发票差旅报销模式TODO
- 含普通发票普通报销模式
"""
csv_path = session_dir / "payment_records.csv"
if not csv_path.exists(): if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"} return {"ok": False, "error": "未找到发票数据,请先处理"}
from app.bot import load_invoice_data, run_bot_web from src.bot import load_invoice_data, run_bot_web
bot_invoices = load_invoice_data(str(csv_path), config) bot_invoices = load_invoice_data(str(csv_path), config)
# 判断发票类型
rows = load_csv(csv_path)
if rows:
invoice_groups = _get_invoice_groups(rows)
if invoice_groups["travel_count"] and not invoice_groups["general_count"]:
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
# TODO: 差旅报销填报流程
else:
fill_log.info("检测到普通发票,使用普通报销模式")
run_bot_web(config, bot_invoices, session_dir) run_bot_web(config, bot_invoices, session_dir)
return {"ok": True} return {"ok": True}
@@ -239,13 +353,14 @@ def run_financial_submit(session_dir: Path, config: dict) -> dict:
# Flask 路由 # Flask 路由
# ================================================================ # ================================================================
@app.route("/") @app.route("/")
def index(): def index() -> Any:
return render_template("index.html") return render_template("index.html")
@app.route("/api/session", methods=["POST"]) @app.route("/api/session", methods=["POST"])
def create_session(): def create_session() -> Any:
"""创建上传会话,返回 session_id""" """创建上传会话,返回 session_id"""
sid = uuid.uuid4().hex[:12] sid = uuid.uuid4().hex[:12]
session_dir = UPLOAD_BASE / sid session_dir = UPLOAD_BASE / sid
@@ -254,7 +369,7 @@ def create_session():
@app.route("/api/upload/<session_id>", methods=["POST"]) @app.route("/api/upload/<session_id>", methods=["POST"])
def upload_file(session_id: str): def upload_file(session_id: str) -> Any:
"""上传 PDF 或图片""" """上传 PDF 或图片"""
session_dir = _validate_session(session_id) session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple): if isinstance(session_dir, tuple):
@@ -270,8 +385,8 @@ def upload_file(session_id: str):
@app.route("/api/upload-csv/<session_id>", methods=["POST"]) @app.route("/api/upload-csv/<session_id>", methods=["POST"])
def upload_csv(session_id: str): def upload_csv(session_id: str) -> Any:
"""上传 CSV 发票数据文件(跳过 PDF 提取和 OCR""" """上传 CSV 发票数据文件(跳过 PDF 提取)"""
session_dir = _validate_session(session_id) session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple): if isinstance(session_dir, tuple):
return session_dir return session_dir
@@ -286,23 +401,20 @@ def upload_csv(session_id: str):
@app.route("/api/files/<session_id>", methods=["GET"]) @app.route("/api/files/<session_id>", methods=["GET"])
def list_files(session_id: str): def list_files(session_id: str) -> Any:
"""列出会话目录中的文件""" """列出会话目录中的文件"""
session_dir = _validate_session(session_id) session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple): if isinstance(session_dir, tuple):
return session_dir return session_dir
pdfs = sorted(f.name for f in session_dir.glob("*.pdf")) pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
imgs = sorted( imgs = sorted(f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"} for f in session_dir.glob(f"*{ext}"))
f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
for f in session_dir.glob(f"*{ext}")
)
return jsonify({"pdfs": pdfs, "images": imgs}) return jsonify({"pdfs": pdfs, "images": imgs})
@app.route("/api/process/<session_id>", methods=["POST"]) @app.route("/api/process/<session_id>", methods=["POST"])
def start_process(session_id: str): def start_process(session_id: str) -> Any:
"""启动管道处理(仅提取+OCR,不自动提交财务系统)""" """启动管道处理(仅发票提取,不自动提交财务系统)"""
session_dir = _validate_session(session_id) session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple): if isinstance(session_dir, tuple):
return session_dir return session_dir
@@ -320,7 +432,7 @@ def start_process(session_id: str):
# 在后台线程执行 # 在后台线程执行
handler = _install_log_collector(session_dir) handler = _install_log_collector(session_dir)
def _run(): def _run() -> None:
result = {"ok": False, "error": "未知错误"} result = {"ok": False, "error": "未知错误"}
try: try:
if mode == "csv": if mode == "csv":
@@ -338,7 +450,7 @@ def start_process(session_id: str):
result = run_pipeline_web(session_dir, config) 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):
raise raise
finally: finally:
try: try:
@@ -356,13 +468,13 @@ def start_process(session_id: str):
@app.route("/api/logs/<session_id>") @app.route("/api/logs/<session_id>")
def stream_logs(session_id: str): def stream_logs(session_id: str) -> Any:
"""SSE 日志流""" """SSE 日志流"""
session_dir = _validate_session(session_id) session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple): if isinstance(session_dir, tuple):
return session_dir return session_dir
def generate(): def generate() -> Any:
# 先发送已有日志 # 先发送已有日志
log_file = session_dir / SESSION_LOG_FILE log_file = session_dir / SESSION_LOG_FILE
last_size = 0 last_size = 0
@@ -399,7 +511,7 @@ def stream_logs(session_id: str):
@app.route("/api/download/<session_id>/<filename>") @app.route("/api/download/<session_id>/<filename>")
def download_file(session_id: str, filename: str): def download_file(session_id: str, filename: str) -> Any:
"""下载生成的文件""" """下载生成的文件"""
session_dir = _validate_session(session_id) session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple): if isinstance(session_dir, tuple):
@@ -415,8 +527,6 @@ def download_file(session_id: str, filename: str):
mimetype = "application/msword" mimetype = "application/msword"
elif safe_name.endswith(".csv"): elif safe_name.endswith(".csv"):
mimetype = "text/csv; charset=utf-8" mimetype = "text/csv; charset=utf-8"
elif safe_name.endswith(".md"):
mimetype = "text/markdown; charset=utf-8"
else: else:
mimetype = "application/octet-stream" mimetype = "application/octet-stream"
@@ -428,42 +538,86 @@ def download_file(session_id: str, filename: str):
) )
@app.route("/api/config/<session_id>", methods=["GET"])
def get_session_config(session_id: str) -> Any:
"""获取当前会话的配置(供前端回填表单)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
config = load_project_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
config.update(json.load(f))
# 只返回前端需要的字段
return jsonify(
{
"username": config.get("username", ""),
"password": "", # 不返回密码
"default_name": config.get("default_name", ""),
"default_card_no": config.get("default_card_no", ""),
"default_person_id": config.get("default_person_id", ""),
"consumable_storage": config.get("consumable_storage", ""),
}
)
@app.route("/api/data/<session_id>", methods=["GET"]) @app.route("/api/data/<session_id>", methods=["GET"])
def get_invoice_data(session_id: str): def get_invoice_data(session_id: str) -> Any:
"""读取发票数据并返回 JSON供前端表格编辑""" """读取发票数据并返回 JSON供前端表格编辑"""
session_dir = _validate_session(session_id) session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple): if isinstance(session_dir, tuple):
return session_dir return session_dir
csv_path = session_dir / "invoice_summary.csv" # 优先读取支付记录 CSV
if not csv_path.exists(): payment_csv = session_dir / "payment_records.csv"
# CSV 模式下可能是其他文件名 if payment_csv.exists():
rows = load_csv(payment_csv)
if rows is not None:
data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry: dict[str, Any] = dict(row)
entry["__row"] = i
data.append(entry)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
# 回退到发票级别 CSV
invoice_csv = session_dir / "invoice_summary.csv"
if invoice_csv.exists():
rows = load_invoice_csv(invoice_csv)
if rows is not None:
invoice_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry2: dict[str, Any] = dict(row)
entry2["__row"] = i
invoice_data.append(entry2)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data})
# 最后尝试任意 CSV
csv_files = list(session_dir.glob("*.csv")) csv_files = list(session_dir.glob("*.csv"))
csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE] csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
if csv_files: if csv_files:
csv_path = csv_files[0] csv_path = csv_files[0]
else: rows = load_csv(csv_path)
return jsonify({"error": "未找到发票数据,请先处理"}), 404
rows = load_ocr_csv(csv_path)
if rows is None: if rows is None:
return jsonify({"error": "CSV 读取失败"}), 500 rows = load_invoice_csv(csv_path)
if rows is not None:
# 添加行号用于编辑追踪 fallback_data: list[dict[str, Any]] = []
data = []
for i, row in enumerate(rows): for i, row in enumerate(rows):
entry = dict(row) entry3: dict[str, Any] = dict(row)
entry["__row"] = i entry3["__row"] = i
data.append(entry) fallback_data.append(entry3)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
# 返回原始字段顺序(去掉内部字段) return jsonify({"error": "未找到发票数据,请先处理"}), 404
fields = [k for k in rows[0].keys() if not k.startswith('__')] if rows else []
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": data})
@app.route("/api/save/<session_id>", methods=["POST"]) @app.route("/api/save/<session_id>", methods=["POST"])
def save_invoice_data(session_id: str): def save_invoice_data(session_id: str) -> Any:
"""保存前端编辑后的发票数据到 CSV""" """保存前端编辑后的发票数据到 CSV"""
session_dir = _validate_session(session_id) session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple): if isinstance(session_dir, tuple):
@@ -478,7 +632,7 @@ def save_invoice_data(session_id: str):
return jsonify({"error": "CSV 文件不存在"}), 404 return jsonify({"error": "CSV 文件不存在"}), 404
# 读取原 CSV 获取字段顺序(使用第一个数据的 keys # 读取原 CSV 获取字段顺序(使用第一个数据的 keys
original_rows = load_ocr_csv(csv_path) original_rows = load_csv(csv_path)
if original_rows is None or len(original_rows) == 0: if original_rows is None or len(original_rows) == 0:
return jsonify({"error": "无法读取原始 CSV 结构"}), 500 return jsonify({"error": "无法读取原始 CSV 结构"}), 500
@@ -494,21 +648,24 @@ def save_invoice_data(session_id: str):
row = {k: entry.get(k, "") for k in fieldnames} row = {k: entry.get(k, "") for k in fieldnames}
writer.writerow(row) writer.writerow(row)
resp = {"ok": True} resp: dict[str, str | bool | None] = {"ok": True}
config = _load_session_config(session_dir) config = _load_session_config(session_dir)
doc_fill = _try_fill_consumable_doc(session_dir, config) doc_fill = _try_fill_consumable_doc(session_dir, config)
if doc_fill.get("ok"): if doc_fill.get("ok"):
fn = doc_fill["doc_filename"] fn = doc_fill["doc_filename"]
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}" resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
resp["doc_ok"] = True resp["doc_ok"] = True
elif doc_fill.get("skipped"):
resp["doc_ok"] = None
resp["doc_skipped"] = True
else: else:
resp["doc_ok"] = False resp["doc_ok"] = False
resp["doc_error"] = doc_fill.get("error") resp["doc_error"] = doc_fill.get("error") or ""
return jsonify(resp) return jsonify(resp)
@app.route("/api/submit-financial/<session_id>", methods=["POST"]) @app.route("/api/submit-financial/<session_id>", methods=["POST"])
def submit_financial(session_id: str): def submit_financial(session_id: str) -> Any:
"""手动触发财务系统填报""" """手动触发财务系统填报"""
session_dir = _validate_session(session_id) session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple): if isinstance(session_dir, tuple):
@@ -530,7 +687,7 @@ def submit_financial(session_id: str):
# 在后台线程执行提交 # 在后台线程执行提交
handler = _install_log_collector(session_dir) handler = _install_log_collector(session_dir)
def _run(): def _run() -> None:
result = {"ok": False, "error": "未知错误"} result = {"ok": False, "error": "未知错误"}
try: try:
submit_result = run_financial_submit(session_dir, config) submit_result = run_financial_submit(session_dir, config)
@@ -540,13 +697,17 @@ def submit_financial(session_id: str):
result = submit_result result = submit_result
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):
raise raise
finally: finally:
try: try:
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp") tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f: with open(tmp_path, "w", encoding="utf-8") as f:
json.dump({"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")}, f, ensure_ascii=False) json.dump(
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
f,
ensure_ascii=False,
)
tmp_path.replace(session_dir / SESSION_RESULT_FILE) tmp_path.replace(session_dir / SESSION_RESULT_FILE)
except Exception: except Exception:
pass pass
@@ -561,14 +722,15 @@ def submit_financial(session_id: str):
# 辅助函数 # 辅助函数
# ================================================================ # ================================================================
def _validate_session(session_id: str):
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
session_dir = UPLOAD_BASE / session_id session_dir = UPLOAD_BASE / session_id
if not session_dir.exists(): if not session_dir.exists():
return jsonify({"error": "会话不存在"}), 404 return jsonify({"error": "会话不存在"}), 404
return session_dir return session_dir
def _build_web_config(body: dict) -> dict: def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
"""从请求体构建配置""" """从请求体构建配置"""
config = load_project_config() config = load_project_config()
for key in ( for key in (
@@ -590,7 +752,7 @@ def _escape_sse(text: str) -> str:
@app.route("/mobile/<session_id>") @app.route("/mobile/<session_id>")
def mobile_upload(session_id: str): def mobile_upload(session_id: str) -> Any:
"""移动端上传页面""" """移动端上传页面"""
session_dir = UPLOAD_BASE / session_id session_dir = UPLOAD_BASE / session_id
if not session_dir.exists(): if not session_dir.exists():
@@ -599,12 +761,12 @@ def mobile_upload(session_id: str):
@app.route("/api/mobile-upload/<session_id>", methods=["POST"]) @app.route("/api/mobile-upload/<session_id>", methods=["POST"])
def mobile_upload_file(session_id: str): def mobile_upload_file(session_id: str) -> Any:
"""移动端上传图片(复用 PC 上传逻辑)""" """移动端上传图片(复用 PC 上传逻辑)"""
return upload_file(session_id) return upload_file(session_id)
if __name__ == "__main__": if __name__ == "__main__":
UPLOAD_BASE.mkdir(parents=True, exist_ok=True) UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
print(f"启动 Web 服务: http://localhost:5000") print("启动 Web 服务: http://localhost:5000")
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False) app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)

View File

@@ -238,7 +238,6 @@ function showDownloadLinks(result) {
const items = []; const items = [];
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url }); if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
if (result.md_url) items.push({ label: 'invoice_summary.md', url: result.md_url });
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url }); if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
lastDownloadUrls = {}; lastDownloadUrls = {};
@@ -369,7 +368,6 @@ async function saveInvoiceData() {
if (d.doc_url || d.doc_ok === false) { if (d.doc_url || d.doc_ok === false) {
showDownloadLinks({ showDownloadLinks({
csv_url: lastDownloadUrls['invoice_summary.csv'], csv_url: lastDownloadUrls['invoice_summary.csv'],
md_url: lastDownloadUrls['invoice_summary.md'],
doc_url: d.doc_url, doc_url: d.doc_url,
doc_ok: d.doc_ok, doc_ok: d.doc_ok,
doc_error: d.doc_error, doc_error: d.doc_error,

View File

@@ -11,7 +11,7 @@
<div class="header text-center mb-4"> <div class="header text-center mb-4">
<h3>财务报销自动化</h3> <h3>财务报销自动化</h3>
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、OCR 识别并填报</p> <p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、LLM 识别并填报</p>
</div> </div>
<div class="container" style="max-width:960px"> <div class="container" style="max-width:960px">
@@ -45,7 +45,7 @@
<!-- CSV 快捷上传 --> <!-- CSV 快捷上传 -->
<div class="mb-4"> <div class="mb-4">
<div class="section-title">📊 CSV 快捷上传 <span class="text-muted fw-normal" style="font-size:12px">(已有发票数据 CSV 可直接上传,跳过提取和 OCR</span></div> <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="upload-zone" id="csv-zone" onclick="document.getElementById('csv-input').click()">
<div class="icon">📊</div> <div class="icon">📊</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传 CSV 文件</div> <div class="text-muted" style="font-size:13px">点击或拖拽上传 CSV 文件</div>

25
tasks.py Normal file
View File

@@ -0,0 +1,25 @@
import os
def run_task(task_name):
print(f"Running {task_name}...")
os.system(
"uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=term-missing"
if task_name == "test"
else "uv run ruff check . && uv run ruff format --check . && uv run mypy src/main.py && uv run deptry ."
if task_name == "check"
else "uv run python src/main.py"
if task_name == "run"
else "uv sync && uv run pre-commit install"
if task_name == "install"
else "rm -rf .venv __pycache__ .pytest_cache .mypy_cache .ruff_cache"
if task_name == "clean"
else ""
)
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
run_task(sys.argv[1])

0
tests/__init__.py Normal file
View File

19
tests/test_config.py Normal file
View File

@@ -0,0 +1,19 @@
"""配置模块单元测试"""
from src.config import load_config
class TestLoadConfig:
"""配置加载"""
def test_returns_dict(self) -> None:
config = load_config()
assert isinstance(config, dict)
def test_has_username(self) -> None:
config = load_config()
assert "username" in config
def test_has_password(self) -> None:
config = load_config()
assert "password" in config

89
tests/test_invoice.py Normal file
View File

@@ -0,0 +1,89 @@
"""发票模块单元测试
覆盖发票分类、CSV 列定义、常量校验。
"""
from src.doc.invoice import (
INVOICE_LEVEL_COLUMNS,
INVOICE_TYPE_GENERAL,
INVOICE_TYPE_HOTEL,
INVOICE_TYPE_TRAIN,
INVOICE_TYPE_TRAVEL,
PAYMENT_RECORD_COLUMNS,
classify_invoice_batch,
is_travel_invoice,
)
class TestInvoiceConstants:
"""发票常量校验"""
def test_travel_types_contain_train(self) -> None:
assert INVOICE_TYPE_TRAIN in INVOICE_TYPE_TRAVEL
def test_travel_types_contain_hotel(self) -> None:
assert INVOICE_TYPE_HOTEL in INVOICE_TYPE_TRAVEL
def test_general_not_in_travel(self) -> None:
assert INVOICE_TYPE_GENERAL not in INVOICE_TYPE_TRAVEL
def test_invoice_columns_not_empty(self) -> None:
assert len(INVOICE_LEVEL_COLUMNS) > 0
def test_payment_columns_not_empty(self) -> None:
assert len(PAYMENT_RECORD_COLUMNS) > 0
class TestIsTravelInvoice:
"""差旅发票判断"""
def test_train_is_travel(self) -> None:
assert is_travel_invoice(INVOICE_TYPE_TRAIN) is True
def test_hotel_is_travel(self) -> None:
assert is_travel_invoice(INVOICE_TYPE_HOTEL) is True
def test_general_not_travel(self) -> None:
assert is_travel_invoice(INVOICE_TYPE_GENERAL) is False
def test_unknown_not_travel(self) -> None:
assert is_travel_invoice("未知类型") is False
class TestClassifyInvoiceBatch:
"""发票分类"""
def test_empty_list(self) -> None:
result = classify_invoice_batch([])
assert result == {"travel": [], "general": []}
def test_all_travel(self) -> None:
invoices = [
{"发票类型": INVOICE_TYPE_TRAIN, "发票号码": "001"},
{"发票类型": INVOICE_TYPE_HOTEL, "发票号码": "002"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 2
assert len(result["general"]) == 0
def test_all_general(self) -> None:
invoices = [
{"发票类型": INVOICE_TYPE_GENERAL, "发票号码": "001"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 0
assert len(result["general"]) == 1
def test_mixed(self) -> None:
invoices = [
{"发票类型": INVOICE_TYPE_TRAIN, "发票号码": "001"},
{"发票类型": INVOICE_TYPE_GENERAL, "发票号码": "002"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 1
assert len(result["general"]) == 1
def test_missing_type_defaults_to_general(self) -> None:
invoices = [{"发票号码": "001"}]
result = classify_invoice_batch(invoices)
assert len(result["general"]) == 1

2644
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,302 +0,0 @@
# 阜阳师范大学财务报销系统 - 自动化脚本操作指南
> 适用场景:日常报销录入(基于 `reimburse.py` 脚本)
> 最后更新2026-05-23
---
## 一、系统概览
```
整体流程:
信息门户(SSO登录) → 财务系统入口 → 单点登录页 → 网络报销 → 日常报销录入
(tyrz.fynu.edu.cn) (点击"财务系统") (新标签页) (a:has(img)) (/expen/common/common)
目标系统: http://210.45.32.214:8081
用户: 王建锋 (工号: 202407021)
```
### 关键 URL
| 系统 | URL | 说明 |
|------|-----|------|
| SSO 登录 | `https://tyrz.fynu.edu.cn/sso/login` | 统一认证入口 |
| 信息门户 | `https://tyrz.fynu.edu.cn/oshall` | 登录后跳转目标 |
| 报销系统 | `http://210.45.32.214:8081` | 网络报销主系统 |
| 日常报销录入 | `/expen/common/common?v=4.0` | 目标录入页面 |
---
## 二、数据准备
### 2.1 发票数据 CSV
脚本从 `invoice_summary.csv`GBK 编码读取发票数据CSV 需包含以下列:
| 列名 | 说明 | 示例 |
|------|------|------|
| 序号 | 发票序号 | 1, 2, 3... |
| 发票号码 | 发票编号 | 26442000005432652421 |
| 开票日期 | 发票开具日期 | 2026/5/18 |
| 项目名称 | 采购项目名称 | 电阻一批 |
| 规格型号 | 规格型号 | — |
| 价税合计 | 发票金额 | 2900.00 |
| 销售方名称 | 商户/销售方 | 佛山市泓宇芯科技有限公司 |
| 人员姓名 | 报销人 | 王建锋(默认值) |
| 刷卡日期 | 公务卡消费日期 | 2026/5/18 → 自动转为 2026-05-18 |
| 公务卡号 | 公务卡卡号 | 6282880139161682默认值 |
| 刷卡金额 | 实际刷卡金额 | 2900.00 |
| 备注 | 备注信息 | — |
| 工号 | 人员工号 | 202407021默认值 |
### 2.2 附件文件
脚本自动扫描当前工作目录下所有 `.pdf` 文件,按文件名排序后与发票一一对应上传。确保 PDF 文件名与发票顺序一致。
---
## 三、脚本执行流程
### 运行方式
```bash
python reimburse.py --data invoice_summary.csv
```
支持命令行覆盖配置:
```bash
python reimburse.py \
--data invoice_summary.csv \
--username 202407021 \
--password "your_password" \
--user-data-dir browser_profile
```
### 执行步骤
脚本按以下顺序自动执行,截图保存在 `images/` 目录:
```
Step 0: 登录信息门户
├── 访问 SSO 登录页
├── 填写工号 + 密码
├── 勾选用户协议复选框
├── 点击"登录"按钮
└── 等待跳转到信息门户 (zs-uip/oshall/portal)
Step 1: 进入报销系统
├── 点击"财务系统"快捷入口
├── 等待新标签页打开 (含 dddl 或 210.45.32.214)
├── 切换到新标签页
├── 关闭旧标签页
├── 通过 a:has(img[src*="wlbx"]) 定位"网络报销"链接
├── 提取链接 URL 并导航
├── 等待"报销录入"文本出现
└── 导航到 /expen/common/common?v=4.0
Step 2: 创建新报销单
├── 等待 2 秒
├── 点击 button:has-text("新增")
└── 等待表单加载 3 秒
Step 3: 填写基本信息
├── 填写 #EXPENEXPLAIN → "元器件采购报销"
├── 点击 #PROJECTCODE 打开项目选择弹窗
├── 在 #promodal .fixed-table-body tbody tr 中点击第一行
└── 点击 #saveAndNext 进入下一步
Step 4: 录入报销明细(一条总明细)
├── 计算所有发票的刷卡金额合计
├── 点击 #insertDetail 打开增加明细弹窗
├── 点击 #economicscode2 打开经济科目选择
├── 在 #econmodal 中选择第 3 行经济科目
├── 填写单据数 = 发票张数
├── 填写报销总金额 = 刷卡金额合计
└── 点击 #detailAdd 确认
Step 5: 录入支付方式(逐张发票)
├── 点击 "下一步(支付方式)"
├── 对每张发票循环:
│ ├── 点击 #insertPay
│ ├── 填写 #personid2 (工号)
│ ├── 填写 #accountname2 (姓名)
│ ├── 填写 #receiptdate2 (刷卡日期)
│ ├── 填写 #localaccount2 (固定卡号: 6282880139161682)
│ ├── 填写 #receiptmoney2 (刷卡金额)
│ ├── 填写 #money2 (实报金额 = 刷卡金额)
│ ├── 填写 #merchant2 (销售方名称)
│ ├── 填写 #smark2 (备注)
│ └── 点击 #payAdd 确认
└── 所有发票录入完成
Step 6: 上传附件(逐张发票)
├── 点击 #next3 切换到附件清单页面
├── 对每张发票循环:
│ ├── 点击 #insertAcc 打开附件弹窗
│ ├── select_option #fjlx → '1' (发票类型)
│ ├── 填写 #fpsmxx (项目名称 - 发票号码)
│ ├── set_input_files #file (对应 PDF 文件)
│ └── 点击 #cjtj 确认
└── 所有附件上传完成
提交阶段:
└── 点击 #submit (当前已注释,需手动取消注释)
```
---
## 四、页面元素速查
### 基本信息页 (Step 3)
| 字段 | 选择器 | 操作 |
|------|--------|------|
| 报销说明 | `#EXPENEXPLAIN` | fill |
| 项目代码 | `#PROJECTCODE` | click → 弹窗选择 |
| 项目弹窗 | `#promodal .fixed-table-body tbody tr` | 点击第一行 |
| 下一步按钮 | `#saveAndNext` | click |
### 报销明细页 (Step 4)
| 字段 | 选择器 | 操作 |
|------|--------|------|
| 增加按钮 | `#insertDetail` | click |
| 经济事项代码 | `#economicscode2` | click → 弹窗选择 |
| 经济科目弹窗 | `#econmodal .fixed-table-body tbody tr` | 点击第 3 行 |
| 单据数 | `input[name="expenPwCommondetail.HOWBILLS"]` | fill |
| 报销总金额 | `#je_zwzcdz` | fill |
| 确定按钮 | `#detailAdd` | click |
### 支付方式页 (Step 5)
| 字段 | 选择器 | 操作 |
|------|--------|------|
| 增加按钮 | `#insertPay` | click |
| 人员编号 | `#personid2` | fill |
| 人员姓名 | `#accountname2` | fill |
| 刷卡日期 | `#receiptdate2` | fill |
| 公务卡号 | `#localaccount2` | fill (固定值) |
| 刷卡金额 | `#receiptmoney2` | fill |
| 实报金额 | `#money2` | fill |
| 商户 | `#merchant2` | fill |
| 备注 | `#smark2` | fill |
| 确定按钮 | `#payAdd` | click |
### 附件清单页 (Step 6)
| 字段 | 选择器 | 操作 |
|------|--------|------|
| 增加按钮 | `#insertAcc` | click |
| 附件类型 | `#fjlx` | select_option → '1'(发票) |
| 附件说明 | `#fpsmxx` | fill |
| 文件上传 | `#file` | set_input_files |
| 确定按钮 | `#cjtj` | click |
### 提交
| 操作 | 选择器 |
|------|--------|
| 提交按钮 | `#submit` |
| 提交按钮(备用) | `#submit2` |
---
## 五、数据流向
```
invoice_summary.csv (GBK)
▼ load_invoice_data()
├── 读取 CSV 行
├── 日期格式转换 (2026/5/18 → 2026-05-18)
├── 填充默认值 (姓名/卡号/工号)
└── 输出: list[dict]
▼ add_reimburse_items()
├── 计算: card_amount = sum(所有发票刷卡金额)
├── 单据数 = len(invoices)
└── 录入 1 条总明细
▼ fill_payment()
└── 对每张发票录入 1 条支付记录
▼ upload_attachments()
├── 扫描 *.pdf 文件
└── 按索引匹配发票 → PDF逐张上传
```
---
## 六、关键设计说明
### 6.1 浏览器复用
脚本使用 `launch_persistent_context` 持久化浏览器上下文,登录状态保存在 `browser_profile/` 目录。再次运行时复用已有会话,无需重复登录。
### 6.2 明细录入策略
脚本采用"一条总明细"策略:将所有发票合并为一条报销明细,报销总金额为所有发票刷卡金额之和,单据数为发票总张数。支付方式则逐张发票分别录入,每张发票对应一条支付记录。
### 6.3 经济科目选择
脚本在经济科目弹窗中固定选择第 3 行。如需更改科目,修改 `rows[2]` 的索引即可。
### 6.4 项目选择
脚本在项目选择弹窗中固定选择第 1 行。如需更改项目,修改 `first_row` 的选择逻辑即可。
### 6.5 网络报销链接动态获取
单点登录页的"网络报销"链接参数每次不同,脚本通过 `a:has(img[src*="wlbx"])` 精确定位链接,动态提取 `href` 属性后导航,不硬编码 URL。
### 6.6 提交控制
脚本默认注释了 `bot.submit()` 调用。完成所有录入后停留在附件清单页面,需人工确认数据无误后,取消注释 `bot.submit()` 再运行,或手动点击提交按钮。
---
## 七、日志与调试
### 日志输出
- 控制台实时输出DEBUG 级别)
- 文件日志:`reimburse.log`UTF-8 编码)
### 截图保存
每个关键步骤自动截图到 `images/` 目录:
| 截图文件 | 对应步骤 |
|----------|----------|
| `debug_portal_loaded.png` | 登录成功 |
| `debug_step3_project_modal.png` | 项目弹窗打开 |
| `debug_step3_project_selected.png` | 项目选择完成 |
| `debug_step3_done.png` | 基本信息完成 |
| `debug_after_add_click.png` | 点击新增后 |
| `debug_item_total.png` | 总明细录入完成 |
| `debug_step5_done.png` | 支付方式完成 |
| `debug_step6_done.png` | 附件上传完成 |
| `debug_submitted.png` | 提交完成 |
| `debug_error.png` | 异常状态 |
### 超时设置
- 页面默认超时30 秒
- 登录门户等待:最多 30 秒
- 单点登录页等待:最多 15 秒
---
## 八、常见问题
| 问题 | 原因 | 解决方案 |
|------|------|----------|
| 登录超时 | SSO 需要手动验证码/微信扫码 | 手动完成验证后脚本继续 |
| 未找到财务系统入口 | 门户页面结构变化 | 检查 `images/debug_*` 截图定位 |
| 经济科目选择失败 | 弹窗加载延迟 | 检查超时设置,增加等待时间 |
| 附件上传失败 | PDF 文件不存在或路径错误 | 确认 PDF 在当前工作目录 |
| 金额不匹配 | 明细合计 ≠ 支付合计 | 检查 CSV 数据中刷卡金额 |
| 提交被拦截 | 必填项为空 | 检查 `reimburse.log` 定位失败步骤 |