重构项目为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
__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/
.venv/
.cursor/
# OS
.DS_Store
Thumbs.db
__pycache__/
.pytest_cache/
.mypy_cache/
.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 → 浏览器填报
├── config.json # 配置文件(登录凭据、默认值、存放地点等)
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板Web/CLI 填写用
├── app/
├── pyproject.toml # 项目配置(依赖、工具链
├── uv.lock # 依赖锁定文件
├── Makefile # 任务脚本(跨平台
├── tasks.py # 任务脚本Windows 兼容)
├── config.json # 配置文件(登录凭据、默认值等)
├── config.example.json # 配置示例
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板
├── src/
│ ├── __init__.py # 包初始化 / 日志器
│ ├── config.py # 配置加载
│ ├── extractor.py # PDF 发票信息提取
│ ├── ocr.py # OCR 刷卡信息识别
│ ├── bot.py # 浏览器自动填报
│ ├── pipeline.py # CLI 流程编排
── fill_consumable_doc.py # 将 CSV 填入易耗品出库单Word COM
├── web/
── main.py # CLI 入口
│ ├── 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 服务入口
│ ├── templates/
│ │ ├── index.html # PC 端主页
│ │ └── mobile_upload.html # 移动端扫码上传
│ ├── static/ # 前端 CSS / JS
│ └── uploads/ # 按会话隔离的上传与产物目录
├── tests/ # 测试目录
├── *.pdf # 发票 PDFCLI 模式,放在项目根目录)
├── *.png / *.jpg # 与 PDF 配对的支付截图
├── invoice_summary.csv # 发票汇总表CLI 产物)
└── images/ # 浏览器调试截图
```
@@ -33,49 +46,47 @@
```mermaid
flowchart TB
PDF[PDF 发票] --> Extract[extractor 提取]
Extract --> List[发票列表]
Img[支付截图] --> OCR[OCR 识别]
List --> OCR
OCR --> CSV[(invoice_summary.csv)]
CSV --> Fill[fill_consumable_doc]
Extract --> Classify{发票类型分类}
Classify -->|差旅发票| Travel[高铁票 / 酒店住宿]
Classify -->|普通发票| General[普通发票]
Travel --> CSV[(invoice_summary.csv)]
General --> CSV
CSV -->|仅普通发票| Fill[fill_consumable_doc]
Fill --> Doc[易耗品、出库单.doc]
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 可用)
- 主要依赖见下方安装步骤
## 快速开始
### 1. 安装依赖
> OCR 相关依赖建议在已有 PaddleOCR 的环境中安装(如 MinerU 虚拟环境)。
```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 发票文本提取 |
| paddleocr | 支付截图 OCR |
| playwright | 财务系统浏览器自动化 |
| flask | Web 服务 |
| pywin32 | 填写 Word 出库单(`fill_consumable_doc` |
### 2. 准备数据CLI 模式)
将发票 PDF 和对应的支付截图放在项目根目录下。脚本会自动匹配 PDF 与截图:
1. **优先文件名匹配** — PDF 与截图同名(如 `发票.pdf``发票.png`
2. **金额近邻匹配** — 文件名不同时,按价税合计与刷卡金额就近配对
截图支持格式:`.png``.jpg``.jpeg``.bmp``.webp`
将发票 PDF 放在项目根目录下。
### 3. 配置
@@ -104,16 +115,17 @@ playwright install chromium
### 4. 运行CLI
```bash
# 全流程(发票提取 → OCR 识别 → 浏览器填报)
python run.py
# 全流程(发票提取 → 浏览器填报)
make run
# Windows 等效:
python tasks.py run
# 仅执行某一步
python run.py --step invoice # 仅发票提取
python run.py --step ocr # 仅 OCR 识别
python run.py --step submit # 仅浏览器填报
uv run python src/main.py --step invoice # 仅发票提取
uv run python src/main.py --step submit # 仅浏览器填报
# 覆盖配置中的登录凭据
python run.py -u 工号 -p 密码
uv run python src/main.py -u 工号 -p 密码
```
### 5. 填写易耗品出库单CLI
@@ -121,9 +133,9 @@ python run.py -u 工号 -p 密码
需已生成 `invoice_summary.csv`,且本机已安装 **Microsoft Word**
```bash
python -m app.fill_consumable_doc
python -m app.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
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
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` |
| OCR 识别 | `--step ocr` | 识别支付截图,回填刷卡日期、金额、持卡人等到 CSV |
| 发票提取 | `--step invoice` | 扫描根目录 PDF生成 `invoice_summary.csv` |
| 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 |
> 分步执行时,上一步的 CSV 会自动成为下一步的输入。
@@ -148,12 +159,14 @@ python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
| 列名 | 说明 |
|------|------|
| 序号 | 行号 |
| 发票类型 | 自动识别:`高铁票` / `酒店住宿` / `普通发票` |
| 发票号码 | 电子发票号码 |
| 开票日期 | 开票日期 |
| 项目名称 / 规格型号 | 货物或应税劳务信息 |
| 项目名称 / 规格型号 | 货物或应税劳务信息(差旅发票为出发站→到达站) |
| 价税合计 | 发票含税金额 |
| 销售方名称 | 销方名称 |
| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | OCR 自支付截图回填 |
| 出发站 / 到达站 / 车次 / 乘车日期 / 座位等级 | 高铁票专用字段 |
| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | 可手工或 Web 端编辑补充 |
| 备注 / 工号 | 可手工或 Web 端编辑补充 |
## Web 服务
@@ -161,48 +174,85 @@ python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
提供浏览器界面:上传文件 → 自动处理 → 在线编辑 → 下载产物 → 可选提交财务系统。
```bash
python web/app.py
uv run python src/web/app.py
```
访问 `http://localhost:5000`
### 推荐使用流程
1. 上传 PDF + 支付截图(或上传已有 CSV
1. 上传 PDF或上传已有 CSV
2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充
3. 点击 **开始处理** — 完成发票提取、OCR、生成 CSV并自动填写 **易耗品、出库单.doc**
4. **下载文件** 区域下载 CSV / Markdown / 出库单 Word
5. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
6. 确认无误后点击 **提交财务系统**
3. 点击 **开始处理** — 完成发票提取、生成 CSV系统自动识别发票类型并分类统计
4. **普通发票**:自动生成 **易耗品、出库单.doc**,可下载
5. **差旅发票**(高铁票/酒店住宿):跳过出库单生成,直接进入差旅报销流程
6. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
7. 确认无误后点击 **提交到财务系统**
### 处理模式
| 模式 | 入口 | 说明 |
|------|------|------|
| **PDF 模式** | 上传 PDF + 截图 | 提取发票 → OCR → 生成 CSV + 出库单 |
| **CSV 快捷模式** | 仅上传 CSV | 跳过提取与 OCR直接生成出库单并进入编辑/提交 |
| **PDF 模式** | 上传 PDF | 提取发票 → 生成 CSV + 自动分类 → 普通发票生成出库单 |
| **CSV 快捷模式** | 仅上传 CSV | 跳过提取,读取 CSV 中的发票类型,按需生成出库单 |
### 发票类型区分
系统自动识别以下发票类型并按类型分流:
| 发票类型 | 识别依据 | 出库单 | 填报模式 |
|----------|----------|--------|----------|
| **高铁票** | 含"电子客票"、"中国铁路"、"12306"等关键词 | 不生成 | 差旅报销TODO |
| **酒店住宿** | 含"住宿费"、"餐饮服务"、"租赁服务"等关键词 | 不生成 | 差旅报销TODO |
| **普通发票** | 其他所有发票 | 自动生成 | 普通报销(已实现) |
当同一批次同时包含差旅发票和普通发票时,系统会为普通发票生成出库单,并在填报时分别处理。
### 功能一览
| 功能 | 说明 |
|------|------|
| 发票提取 + OCR | 上传 PDF 后自动完成 |
| 易耗品出库单 | 处理完成后自动生成 Word可下载 |
| 发票提取 | 上传 PDF 后自动完成 |
| 发票类型自动分类 | 高铁票/酒店住宿/普通发票,自动分流处理 |
| 易耗品出库单 | 仅普通发票自动生成 Word差旅发票跳过 |
| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 |
| 财务系统填报 | 单独按钮触发,处理阶段不会自动提交 |
| 实时日志 | SSE 推送处理进度 |
| 配置上传 | 支持上传 `config.json` 填充表单 |
| 手机扫码上传 | 二维码打开移动端页面,拍照上传支付截图PC 端轮询同步 |
| 手机扫码上传 | 二维码打开移动端页面拍照上传PC 端轮询同步 |
> Web 端浏览器填报以无头模式运行。未上传 PDF 时,填报阶段会跳过附件上传。
> 出库单生成需要 **Windows + Word + pywin32**若失败页面会显示具体原因CSV 等其它产物仍可正常使用。
接口说明见 [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请勿手动干扰自动化流程
- 调试截图保存在 `images/` 目录
- OCR 不会覆盖 CSV 中已有非空字段
- 项目根目录需保留 `易耗品、出库单.doc` 模板Web 每次从模板复制到会话目录再填写,不修改原模板
- `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_card_no": "默认公务卡号",
"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 文档
> 基础地址: `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/图片) |
| 4 | POST | `/api/upload-csv/<session_id>` | 上传 CSV 发票数据 |
| 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 日志流 |
| 8 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
| 9 | GET | `/api/data/<session_id>` | 获取发票数据JSON |
@@ -26,8 +30,8 @@
## 会话与目录
- 调用 `POST /api/session` 获得 `session_id`
- 该会话下所有文件存放在 `web/uploads/<session_id>/`
- 典型产物:`invoice_summary.csv``invoice_summary.md``易耗品、出库单.doc``config.json``session.log``result.json`
- 该会话下所有文件存放在 `src/web/uploads/<session_id>/`
- 典型产物:`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" }
```
> 上传 CSV 后可跳过 PDF 提取和 OCR,直接进入处理/编辑流程。
> 上传 CSV 后可跳过 PDF 提取和 LLM 识别,直接进入处理/编辑流程。
---
@@ -137,17 +141,17 @@ Content-Type: application/json
| mode | 行为 |
|------|------|
| `auto` | 仅有 CSV、无 PDF → CSV 模式;否则 → PDF 提取 + OCR |
| `csv` | 使用已上传 CSV跳过提取与 OCR |
| `pdf` | 执行 PDF 提取 + OCR |
| `auto` | 仅有 CSV、无 PDF → CSV 模式;否则 → PDF 提取 + LLM 识别 |
| `csv` | 使用已上传 CSV跳过提取与 LLM 识别 |
| `pdf` | 执行 PDF 提取 + LLM 识别 |
**处理内容PDF 模式):**
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv` / `.md`
2. 对支付截图 OCR,回填刷卡字段
3. 从项目根目录复制 `易耗品、出库单.doc` 模板到会话目录并自动填写(需 Windows + Word
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv`
2. 对支付截图多模态 LLM 识别,回填刷卡字段
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",
"invoice_count": 4,
"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_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 等仍可能成功):**
```json
@@ -178,11 +198,23 @@ Content-Type: application/json
"ok": true,
"invoice_count": 4,
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
"travel_count": 2,
"general_count": 2,
"doc_ok": false,
"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 日志流
@@ -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` |
> SSE 超时时间为 10 分钟600 秒)。
---
### 7. 下载文件
@@ -227,7 +261,6 @@ GET /api/download/<session_id>/<filename>
| 扩展名 | Content-Type |
|--------|----------------|
| `.csv` | `text/csv; charset=utf-8` |
| `.md` | `text/markdown; charset=utf-8` |
| `.doc` | `application/msword` |
| 其它 | `application/octet-stream` |
@@ -235,8 +268,7 @@ GET /api/download/<session_id>/<filename>
| 文件名 | 说明 |
|--------|------|
| `invoice_summary.csv` | 发票汇总(含 OCR 结果) |
| `invoice_summary.md` | Markdown 摘要 |
| `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) |
| `易耗品、出库单.doc` | 自动填写的出库单 |
| 用户上传的 CSV 名 | CSV 快捷模式下的原始文件 |
@@ -328,7 +360,17 @@ Content-Type: application/json
保存后会根据最新 CSV **重新生成** 出库单 Word与会话 `config.json` 中的 `consumable_storage` 等配置一致)。
若出库单生成失败:
**纯差旅发票跳过出库单:**
```json
{
"ok": true,
"doc_ok": null,
"doc_skipped": true
}
```
**出库单生成失败:**
```json
{
@@ -348,10 +390,14 @@ POST /api/submit-financial/<session_id>
**前置条件:**
- 会话目录存在 `config.json`(由 `/api/process` 写入)
- 会话目录存在 `config.json`(由 `/api/process` 写入),否则返回 `400`
- 存在可用的发票 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
@@ -430,8 +489,12 @@ sequenceDiagram
PC->>Server: POST /api/upload/{sid}
PC->>Server: POST /api/process/{sid}
Note over Server: PDF 提取 + OCR + 写 config.json
Server->>Word: 复制模板并填写出库单
Note over Server: PDF 提取 + LLM 识别 + 写 config.json
alt 含普通发票
Server->>Word: 从模板复制并填写出库单
else 纯差旅发票
Note over Server: 跳过出库单生成
end
Server-->>PC: SSE done (csv_url, doc_url, ...)
PC->>Server: GET /api/data/{sid}
@@ -457,7 +520,7 @@ sequenceDiagram
不经过 Web、在本地直接填写出库单
```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)。

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 logging
import sys
@@ -7,14 +8,20 @@ from pathlib import Path
_LOG_FMT = "%(asctime)s [%(levelname)-5s] %(name)s: %(message)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:
"""获取带时间戳的日志记录器
输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ...
日志同时输出到终端和项目根目录的 pipeline.log
日志同时输出到终端和 logs/<日期>.log
"""
logger = logging.getLogger(name)
if not logger.handlers:
@@ -28,7 +35,7 @@ def get_logger(name: str) -> logging.Logger:
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)
logger.addHandler(file_handler)

View File

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

View File

@@ -10,7 +10,7 @@ from pathlib import Path
_CONFIG_PATH = Path(__file__).parent.parent / "config.json"
def load_config() -> dict:
def load_config() -> dict[str, str | Path]:
"""加载并合并配置,缺失字段使用默认值"""
raw = {}
if _CONFIG_PATH.exists():
@@ -32,3 +32,18 @@ def load_config() -> dict:
"consumable_storage": raw.get("consumable_storage", "躬行楼 C205"),
"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
from datetime import date
from pathlib import Path
from typing import Any
from . import get_logger
from .bot import load_invoice_data
from .config import load_config
from .. import get_logger
from ..bot import load_invoice_data
from ..config import load_config
log = get_logger("fill_consumable_doc")
@@ -86,7 +87,7 @@ def _today_cn_date() -> str:
return f"{today.year}{today.month}{today.day}"
def _apply_font(rng) -> None:
def _apply_font(rng: Any) -> None:
"""将范围字体设为宋体五号(含数字与英文)。"""
font = rng.Font
font.Name = TABLE_FONT_NAME
@@ -97,7 +98,7 @@ def _apply_font(rng) -> None:
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.MoveEnd(WD_CHARACTER, -1)
@@ -105,7 +106,7 @@ def _set_cell_value(cell, text: str) -> None:
_apply_font(rng)
def _normalize_table_font(tbl) -> None:
def _normalize_table_font(tbl: Any) -> None:
"""填写完成后统一整张表的字体。"""
for row in tbl.Rows:
for cell in row.Cells:
@@ -114,7 +115,7 @@ def _normalize_table_font(tbl) -> None:
_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:
return
@@ -135,7 +136,7 @@ def _replace_date_in_doc(doc, new_date: str) -> None:
def fill_consumable_doc(
csv_path: str | Path,
doc_path: str | Path,
config: dict | None = None,
config: dict[str, Any] | None = None,
backup: bool = True,
) -> Path:
csv_path = Path(csv_path)
@@ -148,8 +149,11 @@ def fill_consumable_doc(
bak = doc_path.with_suffix(doc_path.suffix + ".bak")
shutil.copy2(doc_path, bak)
import pythoncom
import win32com.client
pythoncom.CoInitialize()
try:
word = win32com.client.Dispatch("Word.Application")
word.Visible = False
word.DisplayAlerts = 0
@@ -166,8 +170,17 @@ def fill_consumable_doc(
if row_idx > tbl.Rows.Count:
break
parsed = parse_spec_model(inv.get("spec_model", ""))
card_amount = inv.get("card_amount") or 0
parsed = parse_spec_model(str(inv.get("spec_model", "")))
# 当规格型号为空时,从项目名称提取产品信息
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_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)
unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount)
# 数量:去掉前导零
qty = str(qty_val) if qty_val > 0 else ""
# 数量:去掉前导零;若无数量则默认为 1
qty = str(qty_val) if qty_val > 0 else "1"
values = [
str(inv.get("seq", i + 1)),
@@ -193,7 +206,7 @@ def fill_consumable_doc(
]
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)
@@ -201,6 +214,8 @@ def fill_consumable_doc(
finally:
doc.Close()
word.Quit()
finally:
pythoncom.CoUninitialize()
return doc_path
@@ -209,7 +224,7 @@ def fill_consumable_from_template(
csv_path: str | Path,
template_path: str | Path,
output_path: str | Path,
config: dict | None = None,
config: dict[str, Any] | None = None,
) -> Path:
"""从模板复制并填写出库单Web 会话每次从模板重新生成)。"""
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
# -*- coding: utf-8 -*-
"""
财务报销自动化
依次执行:
1. 发票提取 PDF 发票提取信息生成 invoice_summary.csv
2. OCR 识别 从支付截图识别刷卡信息回填 CSV
3. 报销提交 打开浏览器登录财务系统并自动填报
2. 报销提交 打开浏览器登录财务系统并自动填报
用法:
python run.py # 全流程
python run.py --step invoice # 仅发票提取
python run.py --step ocr # 仅 OCR 识别
python run.py --step submit # 仅浏览器填报
python run.py -u 工号 -p 密码 # 覆盖登录凭据
"""
@@ -21,28 +18,30 @@ import sys
from pathlib import 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(
description="财务报销自动化 - 发票提取 → OCR 识别 → 浏览器填报",
description="财务报销自动化 - 发票提取 → 浏览器填报",
)
parser.add_argument(
"--step",
choices=["all", "invoice", "ocr", "submit"],
choices=["all", "invoice", "submit"],
default="all",
help="执行步骤 (默认: all)",
)
parser.add_argument(
"-u", "--username",
"-u",
"--username",
default=None,
help="信息门户登录账号(覆盖 config.json",
)
parser.add_argument(
"-p", "--password",
"-p",
"--password",
default=None,
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 界面
用户上传 PDF 发票和支付截图配置账号信息自动完成
1. 发票提取 2. OCR 识别 3. 浏览器填报可选
用户上传 PDF 发票配置账号信息自动完成
1. 发票提取 2. 浏览器填报可选
启动: python web/app.py
发票类型区分
- 差旅发票高铁票/酒店住宿不生成易耗品出库单走差旅报销流程
- 普通发票生成易耗品出库单走普通报销流程
启动: uv run python src/web/app.py
访问: http://localhost:5000
"""
import io
import json
import logging
import sys
@@ -16,29 +19,38 @@ import threading
import time
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import quote
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
# 确保项目根目录在 sys.path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from app.config import load_config as load_project_config
from app.extractor import extract_invoices, save_csv, save_markdown
from app.fill_consumable_doc import (
from src import get_logger # noqa: E402, I001
from src.config import load_config as load_project_config # noqa: E402, I001
from src.doc.extractor import ( # noqa: E402, I001
extract_invoices,
)
from src.doc.fill_consumable_doc import ( # noqa: E402, I001
CONSUMABLE_DOC_FILENAME,
fill_consumable_from_template,
)
from app import get_logger
from app.ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv as load_ocr_csv
from src.doc.invoice import ( # noqa: E402, I001
classify_invoice_batch,
load_csv,
load_invoice_csv,
save_csv as save_payment_csv,
save_invoice_csv,
)
fill_log = get_logger("fill_consumable_doc")
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
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_RESULT_FILE = "result.json"
@@ -47,6 +59,7 @@ SESSION_RESULT_FILE = "result.json"
# 日志收集器 — 捕获管道日志到文件SSE 端点通过 tail -f 读取
# ================================================================
class _SSELogHandler(logging.Handler):
"""将日志写入指定文件(线程安全)"""
@@ -55,7 +68,7 @@ class _SSELogHandler(logging.Handler):
self._lock = threading.Lock()
self._file = open(log_path, "w", encoding="utf-8")
def emit(self, record: logging.LogRecord):
def emit(self, record: logging.LogRecord) -> None:
try:
msg = self.format(record) + "\n"
with self._lock:
@@ -64,7 +77,7 @@ class _SSELogHandler(logging.Handler):
except Exception:
pass
def close_file(self):
def close_file(self) -> None:
try:
self._file.close()
except Exception:
@@ -82,7 +95,7 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
handler.setFormatter(fmt)
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.setLevel(logging.INFO)
logger.addHandler(handler)
@@ -90,8 +103,8 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
return handler
def _remove_log_collector(handler: _SSELogHandler):
for name in ["extractor", "ocr", "pipeline", "bot", "fill_consumable_doc"]:
def _remove_log_collector(handler: _SSELogHandler) -> None:
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
logging.getLogger(name).removeHandler(handler)
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()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
@@ -110,31 +123,95 @@ def _load_session_config(session_dir: Path) -> dict:
return config
def _resolve_invoice_csv(session_dir: Path) -> Path | None:
csv_path = session_dir / "invoice_summary.csv"
def _resolve_payment_csv(session_dir: Path) -> Path | None:
"""查找支付记录 CSVpayment_records.csv"""
csv_path = session_dir / "payment_records.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 _try_fill_consumable_doc(session_dir: Path, config: dict) -> dict:
"""根据 CSV 填写易耗品出库单,供会话目录下载。"""
def _resolve_invoice_csv(session_dir: Path) -> Path | None:
"""查找发票级别 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():
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
csv_path = _resolve_invoice_csv(session_dir)
csv_path = _resolve_payment_csv(session_dir)
if csv_path is None:
return {"ok": False, "error": "未找到发票 CSV"}
# 检查是否有普通发票
rows = load_csv(csv_path)
if rows is None:
return {"ok": False, "error": "CSV 读取失败"}
if not _has_general_invoices(rows):
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
try:
fill_log.info("开始填写出库单: %s", out_doc.name)
fill_consumable_from_template(
csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config
)
fill_consumable_from_template(csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config)
fill_log.info("出库单填写完成")
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
except ImportError:
@@ -145,11 +222,16 @@ def _try_fill_consumable_doc(session_dir: Path, config: dict) -> dict:
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"):
fn = doc_fill["doc_filename"]
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
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:
result["doc_ok"] = False
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> 触发
"""
start = time.time()
# ---- Step 1: 发票提取 ----
invoices = extract_invoices(str(session_dir))
invoices, groups = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
save_csv(invoices, session_dir / "invoice_summary.csv")
save_markdown(invoices, session_dir / "invoice_summary.md")
# 保存两个 CSV支付记录级别供 bot/出库单使用)和发票级别(供人工参考)
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"
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)
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": len(rows),
"invoice_count": invoice_count,
"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)
_append_doc_download(result, session_dir.name, doc_fill)
return result
def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str):
"""直接使用上传的 CSV 文件,跳过 PDF 提取和 OCR"""
def run_csv_pipeline_web(session_dir: Path, config: dict[str, Any], csv_filename: str) -> dict[str, Any]:
"""直接使用上传的 CSV 文件,跳过 PDF 提取"""
start = time.time()
csv_path = session_dir / csv_filename
if not csv_path.exists():
return {"ok": False, "error": "CSV 文件不存在"}
# 读取 CSV 行数
rows = load_ocr_csv(csv_path)
# 尝试读取支付记录格式
rows = load_csv(csv_path)
if rows is None:
# 尝试读取发票级别格式
invoice_rows = load_invoice_csv(csv_path)
if invoice_rows is None:
return {"ok": False, "error": "CSV 读取失败"}
# 发票级别格式:直接统计
type_stats = _get_invoice_groups(invoice_rows)
elapsed = time.time() - start
return {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": len(invoice_rows),
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
"travel_count": type_stats["travel_count"],
"general_count": type_stats["general_count"],
}
# 支付记录格式:统计发票类型
type_stats = _get_invoice_groups(rows)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": len(rows),
"invoice_count": type_stats["travel_count"] + type_stats["general_count"],
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
"travel_count": type_stats["travel_count"],
"general_count": type_stats["general_count"],
}
doc_fill = _try_fill_consumable_doc(session_dir, config)
_append_doc_download(result, session_dir.name, doc_fill)
return result
def run_financial_submit(session_dir: Path, config: dict) -> dict:
"""执行财务系统填报(从前端确认后调用)"""
csv_path = session_dir / "invoice_summary.csv"
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
根据发票类型选择填报模式
- 纯差旅发票差旅报销模式TODO
- 含普通发票普通报销模式
"""
csv_path = session_dir / "payment_records.csv"
if not csv_path.exists():
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)
# 判断发票类型
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)
return {"ok": True}
@@ -239,13 +353,14 @@ def run_financial_submit(session_dir: Path, config: dict) -> dict:
# Flask 路由
# ================================================================
@app.route("/")
def index():
def index() -> Any:
return render_template("index.html")
@app.route("/api/session", methods=["POST"])
def create_session():
def create_session() -> Any:
"""创建上传会话,返回 session_id"""
sid = uuid.uuid4().hex[:12]
session_dir = UPLOAD_BASE / sid
@@ -254,7 +369,7 @@ def create_session():
@app.route("/api/upload/<session_id>", methods=["POST"])
def upload_file(session_id: str):
def upload_file(session_id: str) -> Any:
"""上传 PDF 或图片"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
@@ -270,8 +385,8 @@ def upload_file(session_id: str):
@app.route("/api/upload-csv/<session_id>", methods=["POST"])
def upload_csv(session_id: str):
"""上传 CSV 发票数据文件(跳过 PDF 提取和 OCR"""
def upload_csv(session_id: str) -> Any:
"""上传 CSV 发票数据文件(跳过 PDF 提取)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
@@ -286,23 +401,20 @@ def upload_csv(session_id: str):
@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)
if isinstance(session_dir, tuple):
return session_dir
pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
imgs = sorted(
f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
for f in session_dir.glob(f"*{ext}")
)
imgs = sorted(f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"} for f in session_dir.glob(f"*{ext}"))
return jsonify({"pdfs": pdfs, "images": imgs})
@app.route("/api/process/<session_id>", methods=["POST"])
def start_process(session_id: str):
"""启动管道处理(仅提取+OCR,不自动提交财务系统)"""
def start_process(session_id: str) -> Any:
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
@@ -320,7 +432,7 @@ def start_process(session_id: str):
# 在后台线程执行
handler = _install_log_collector(session_dir)
def _run():
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
if mode == "csv":
@@ -338,7 +450,7 @@ def start_process(session_id: str):
result = run_pipeline_web(session_dir, config)
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, (KeyboardInterrupt, SystemExit)):
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
@@ -356,13 +468,13 @@ def start_process(session_id: str):
@app.route("/api/logs/<session_id>")
def stream_logs(session_id: str):
def stream_logs(session_id: str) -> Any:
"""SSE 日志流"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
def generate():
def generate() -> Any:
# 先发送已有日志
log_file = session_dir / SESSION_LOG_FILE
last_size = 0
@@ -399,7 +511,7 @@ def stream_logs(session_id: str):
@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)
if isinstance(session_dir, tuple):
@@ -415,8 +527,6 @@ def download_file(session_id: str, filename: str):
mimetype = "application/msword"
elif safe_name.endswith(".csv"):
mimetype = "text/csv; charset=utf-8"
elif safe_name.endswith(".md"):
mimetype = "text/markdown; charset=utf-8"
else:
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"])
def get_invoice_data(session_id: str):
def get_invoice_data(session_id: str) -> Any:
"""读取发票数据并返回 JSON供前端表格编辑"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
csv_path = session_dir / "invoice_summary.csv"
if not csv_path.exists():
# CSV 模式下可能是其他文件名
# 优先读取支付记录 CSV
payment_csv = session_dir / "payment_records.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 = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
if csv_files:
csv_path = csv_files[0]
else:
return jsonify({"error": "未找到发票数据,请先处理"}), 404
rows = load_ocr_csv(csv_path)
rows = load_csv(csv_path)
if rows is None:
return jsonify({"error": "CSV 读取失败"}), 500
# 添加行号用于编辑追踪
data = []
rows = load_invoice_csv(csv_path)
if rows is not None:
fallback_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry = dict(row)
entry["__row"] = i
data.append(entry)
entry3: dict[str, Any] = dict(row)
entry3["__row"] = i
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})
# 返回原始字段顺序(去掉内部字段)
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})
return jsonify({"error": "未找到发票数据,请先处理"}), 404
@app.route("/api/save/<session_id>", methods=["POST"])
def save_invoice_data(session_id: str):
def save_invoice_data(session_id: str) -> Any:
"""保存前端编辑后的发票数据到 CSV"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
@@ -478,7 +632,7 @@ def save_invoice_data(session_id: str):
return jsonify({"error": "CSV 文件不存在"}), 404
# 读取原 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:
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}
writer.writerow(row)
resp = {"ok": True}
resp: dict[str, str | bool | None] = {"ok": True}
config = _load_session_config(session_dir)
doc_fill = _try_fill_consumable_doc(session_dir, config)
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
resp["doc_ok"] = True
elif doc_fill.get("skipped"):
resp["doc_ok"] = None
resp["doc_skipped"] = True
else:
resp["doc_ok"] = False
resp["doc_error"] = doc_fill.get("error")
resp["doc_error"] = doc_fill.get("error") or ""
return jsonify(resp)
@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)
if isinstance(session_dir, tuple):
@@ -530,7 +687,7 @@ def submit_financial(session_id: str):
# 在后台线程执行提交
handler = _install_log_collector(session_dir)
def _run():
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
submit_result = run_financial_submit(session_dir, config)
@@ -540,13 +697,17 @@ def submit_financial(session_id: str):
result = submit_result
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, (KeyboardInterrupt, SystemExit)):
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
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)
except Exception:
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
if not session_dir.exists():
return jsonify({"error": "会话不存在"}), 404
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()
for key in (
@@ -590,7 +752,7 @@ def _escape_sse(text: str) -> str:
@app.route("/mobile/<session_id>")
def mobile_upload(session_id: str):
def mobile_upload(session_id: str) -> Any:
"""移动端上传页面"""
session_dir = UPLOAD_BASE / session_id
if not session_dir.exists():
@@ -599,12 +761,12 @@ def mobile_upload(session_id: str):
@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 上传逻辑)"""
return upload_file(session_id)
if __name__ == "__main__":
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)

View File

@@ -238,7 +238,6 @@ function showDownloadLinks(result) {
const items = [];
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 });
lastDownloadUrls = {};
@@ -369,7 +368,6 @@ async function saveInvoiceData() {
if (d.doc_url || d.doc_ok === false) {
showDownloadLinks({
csv_url: lastDownloadUrls['invoice_summary.csv'],
md_url: lastDownloadUrls['invoice_summary.md'],
doc_url: d.doc_url,
doc_ok: d.doc_ok,
doc_error: d.doc_error,

View File

@@ -11,7 +11,7 @@
<div class="header text-center mb-4">
<h3>财务报销自动化</h3>
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、OCR 识别并填报</p>
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、LLM 识别并填报</p>
</div>
<div class="container" style="max-width:960px">
@@ -45,7 +45,7 @@
<!-- CSV 快捷上传 -->
<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="icon">📊</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` 定位失败步骤 |