Compare commits

9 Commits

Author SHA1 Message Date
wandering
8dd91df3b9 差旅报销和普通报销都已经完成 2026-06-12 12:08:27 +08:00
wandering
6d66a27aab 日常报销和差旅报销都可以走通 2026-06-12 12:06:06 +08:00
wandering
10115214aa 完成差旅发票录入流程 2026-06-11 19:22:34 +08:00
wandering
cf567c22f2 chore: 从版本控制中移除 uploads 上传文件 2026-06-09 16:30:03 +08:00
wandering
98e3d21c83 重构项目为LLM 驱动 2026-06-09 16:25:20 +08:00
wandering
0074975591 fix: 出库单金额使用刷卡金额,单价由刷卡金额反算 2026-05-27 10:35:41 +08:00
wandering
a72c4ffaab feat: Web 表格编辑、易耗品出库单自动生成与文档完善
- 新增 fill_consumable_doc,根据 CSV 填写 Word 出库单(宋体五号)
- Web 处理完成后自动生成出库单并提供下载
- 前端拆分为 static 资源,支持在线编辑 CSV 与分步提交财务系统
- 补充 API.md、README(含 Mermaid 数据流)及 config.example.json

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 14:17:26 +08:00
wandering
70f61be3aa 清理示例文件和敏感数据 2026-05-25 13:51:57 +08:00
wandering
02e2ca90a7 增加手机上传图片的功能 2026-05-25 12:00:06 +08:00
89 changed files with 10678 additions and 2922 deletions

19
.agents/README.md Normal file
View File

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

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

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/` 目录下新建记录文档,内容包含:
* 故障现象
* 历次排查操作及失败原因
* 最终解决方案
* 后续可借鉴的调试经验

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

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

View File

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

BIN
.coverage 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

13
.env.example Normal file
View File

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

37
.gitignore vendored
View File

@@ -1,29 +1,14 @@
# Python
.venv/
.cursor/
__pycache__/
*.py[cod]
*.pyo
*.egg-info/
dist/
build/
.eggs/
# Playwright MCP snapshots
.pytest_cache/
.mypy_cache/
.ruff_cache/
.playwright-mcp/
# Uploads (user data)
web/uploads/
# Logs
pipeline.log
*.log
# Debug images
images/
# IDE
*.pyc
logs/
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
uploads/
.env
images/
scripts/data/

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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

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

367
README.md
View File

@@ -1,73 +1,151 @@
# 财务报销自动化
自动从 PDF 发票提取信息,OCR 识别支付记录截图,然后在财务系统中自动填报报销单。
自动从 PDF 发票或图片中提取信息,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
**支持发票类型区分**:系统自动识别高铁票、酒店住宿等差旅发票与普通发票。差旅发票不生成易耗品出库单,走差旅报销流程;普通发票生成出库单,走普通报销流程。
## 项目结构
```
├── run.py # CLI 入口
├── config.json # 配置文件(登录凭据、系统 URL 等)
├── app/
│ ├── config.py # 配置加载
│ ├── extractor.py # PDF 发票信息提取
│ ├── ocr.py # OCR 刷卡信息识别
│ ├── bot.py # 浏览器自动填报
│ └── pipeline.py # 流程编排(数据在内存中流转)
├── web/
│ ├── app.py # Web 服务入口
│ ├── templates/
│ └── index.html # Web 前端页面
── uploads/ # 用户上传文件目录
├── *.pdf # 发票 PDF按需放置
├── *.png / *.jpg # 与 PDF 同名的支付截图
├── invoice_summary.csv # 中间产物 — 发票汇总表
└── images/ # 调试截图
├── pyproject.toml # 项目配置(依赖、工具链)
├── uv.lock # 依赖锁定文件
├── Makefile # 任务脚本(跨平台)
├── tasks.py # 任务脚本Windows 兼容)
├── .pre-commit-config.yaml # pre-commit 钩子配置
├── .env.example # 环境变量示例SSO 地址、LLM 配置等)
├── config.example.json # 用户配置示例
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板
├── src/
│ ├── __init__.py # 包初始化 / 日志器
│ ├── config.py # 配置加载
├── bot.py # 浏览器自动填报
── pipeline.py # CLI 流程编排
│ ├── main.py # CLI 入口
│ ├── doc/ # 文档处理模块
│ │ ├── extractor.py # 编排入口:串联 PDF 读取 → LLM 提取 → 分类
│ │ ├── pdf.py # PDF 图片渲染PyMuPDF供多模态 LLM 使用)
│ │ ├── llm_extractor.py # LLM 信息提取
│ │ ├── matcher.py # 数据匹配与校验
│ │ ├── 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/ # 按会话隔离的上传与产物目录
├── scripts/ # CLI 数据目录
│ ├── data/ # 发票源文件、config.json 与 .invoice_cache 缓存
│ └── test_*.py # 测试脚本
├── tests/ # 测试目录
├── docs/ # 用户文档API 说明、操作指南等)
├── images/ # 浏览器调试截图
└── *.pdf / *.jpg / *.png # 发票 PDF 或图片CLI 模式,放在 scripts/data/
```
## 数据流
```mermaid
flowchart TB
PDF[PDF 发票 / 图片] --> Extract[extractor 多模态提取]
Extract --> Cache[(.invoice_cache/*.json)]
Cache --> Classify{发票类型分类}
Classify -->|差旅发票| Travel[高铁票 / 酒店住宿]
Classify -->|普通发票| General[普通发票]
Classify -->|支付记录| Payment[支付截图]
Classify -->|申请单| Application[出差事前申请单]
Travel --> Matcher[matcher 金额匹配]
Payment --> Matcher
Matcher --> MatchResult[(match_result.json)]
Cache --> TravelLLM[LLM 差旅信息提取]
MatchResult --> TravelLLM
TravelLLM --> TravelInfo[(travel_info.json)]
Cache --> NormalLLM[LLM 普通发票信息提取]
MatchResult --> NormalLLM
NormalLLM --> NormalInfo[(normal_info.json)]
TravelInfo -->|差旅基本信息| Bot_T[bot/travel.py<br/>差旅填报流程]
TravelInfo -->|报销明细| Bot_T
TravelInfo -->|支付方式| Bot_T
TravelInfo -->|补助清单| Bot_T
TravelInfo -->|附件清单| Bot_T
Bot_T --> Submit_T[差旅报销提交]
NormalInfo -->|报销说明| Bot_G[bot/normal.py<br/>普通填报流程]
NormalInfo -->|发票总数/金额| Bot_G
NormalInfo -->|支付方式| Bot_G
NormalInfo -->|附件清单| Bot_G
Bot_G --> Submit_G[普通报销提交]
General --> CSV[(invoice_summary.csv)]
CSV --> Fill[fill_consumable_doc]
Fill --> Doc[易耗品、出库单.doc]
```
PDF 文件 ──► extractor 提取 ──► 发票列表
支付截图 ──► OCR 识别 ────────────► 回填刷卡信息
invoice_summary.csv
bot 打开浏览器 ──► 自动填报
```
### 关键中间产物
| 文件 | 生成阶段 | 作用 |
|------|---------|------|
| `.invoice_cache/*.json` | extractor 提取 | 单张发票/支付记录/申请单的结构化数据 |
| `match_result.json` | matcher 匹配 | 支付截图与发票的关联关系(按金额匹配) |
| `travel_info.json` | LLM 差旅信息提取 | 综合发票缓存 + 匹配结果,生成差旅报销所需的全部结构化数据 |
| `normal_info.json` | LLM 普通发票信息提取 | 综合普通发票 + 匹配结果,生成普通报销所需的全部结构化数据 |
| `invoice_summary.csv` | extractor 提取 | 普通发票汇总(用于生成易耗品出库单) |
### bot 模块架构
`bot/` 包负责浏览器自动化填报,仅接收已提取的信息并执行填报操作,不承担信息提取职责:
| 模块 | 职责 |
|------|------|
| `bot/base.py` | `BaseBot` 基类:浏览器生命周期、登录、导航、截图 |
| `bot/travel.py` | 差旅填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
| `bot/normal.py` | 普通填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
| `bot/__init__.py` | 入口函数:`run_bot()` / `run_bot_web()`,负责类型判断和流程路由 |
## 环境要求
- Python 3.10+
- 依赖见下方安装步骤
- **Python 3.12+**
- **uv** 包管理器([安装指南](https://docs.astral.sh/uv/getting-started/installation/)
- Windows易耗品出库单填写依赖 Microsoft Word + COM仅 Windows 可用)
## 快速开始
### 1. 安装依赖
> 以下依赖需在 MinerU 虚拟环境中安装:
> ```bash
> conda activate MinerU
> ```
```bash
pip install pdfplumber==0.11.9 paddleocr==2.8.1 playwright==1.60.0 flask==3.0.3
playwright install chromium
# 同步所有依赖(运行时 + 开发工具)
make install
# Windows 上等效命令:
python tasks.py install
```
### 2. 准备数据
项目使用 `uv` 管理依赖,所有包版本锁定在 `uv.lock` 中,确保可复现。
将发票 PDF 和对应的支付截图放在项目根目录下。脚本会自动匹配 PDF 与截图:
| 依赖 | 用途 |
|------|------|
| PyMuPDF | PDF 图片渲染(供多模态 LLM 使用) |
| llama-index | LLM 信息提取(发票识别、差旅信息提取) |
| playwright | 财务系统浏览器自动化 |
| flask | Web 服务 |
| pywin32 | 填写 Word 出库单(`fill_consumable_doc` |
1. **优先文件名匹配** — PDF 与截图同名(如 `发票.pdf``发票.png`
2. **金额近邻匹配** — 文件名不同时,自动提取 PDF 的价税合计和截图的刷卡金额进行配对
### 2. 准备数据CLI 模式
截图支持格式:`.png``.jpg``.jpeg``.bmp``.webp`
```
将发票 PDF 或图片(`.jpg``.png``.webp``.bmp`)放在 `scripts/data/` 目录下
### 3. 配置
编辑 `config.json`,填写登录凭据和默认值
编辑 `scripts/config.json`(参考 `config.example.json`
```json
{
@@ -75,72 +153,207 @@ playwright install chromium
"password": "你的密码",
"default_name": "默认报销人姓名",
"default_card_no": "默认公务卡号",
"default_person_id": "默认人员编号"
"default_person_id": "默认人员编号",
"consumable_storage": "物料存储地"
}
```
未填写的字段将使用默认值URL 类配置一般无需修改。
| 字段 | 说明 |
|------|------|
| `username` / `password` | 信息门户登录凭据 |
| `default_name` | 默认报销人姓名 |
| `default_card_no` | 默认公务卡号 |
| `default_person_id` | 默认人员编号(工号) |
| `consumable_storage` | 出库单「存放地点」列默认值(默认: `躬行楼 C205` |
### 4. 运行
**服务端配置**SSO 地址、报销系统 URL、LLM 参数)通过环境变量提供,有默认值,一般无需修改:
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `SSO_LOGIN_URL` | `https://tyrz.fynu.edu.cn/sso/login` | SSO 登录地址 |
| `PORTAL_URL` | `https://tyrz.fynu.edu.cn/oshall` | 统一信息平台地址 |
| `REIMBURSE_URL` | `http://210.45.32.214:8081` | 报销系统地址 |
| `REIMBURSE_PAGE` | `/expen/common/common?v=4.0` | 普通报销页面路径 |
| `TRAVEL_PAGE` | `/expen/travel/travel?v=4.0` | 差旅报销页面路径 |
| `LLM_MODEL` | `qwen-vl-max` | LLM 模型名称 |
| `LLM_API_BASE` | `http://localhost:8080/v1` | LLM API 地址 |
| `LLM_API_KEY` | `lm-studio` | LLM API 密钥 |
### 4. 运行CLI
```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
需已生成 `invoice_summary.csv`,且本机已安装 **Microsoft Word**
```bash
uv run python -m src.doc.fill_consumable_doc
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
uv run python -m src.doc.fill_consumable_doc --config scripts/config.json # 指定配置文件
uv run python -m src.doc.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
```
填写规则概要:
- 表头「日期」使用**填写当天**的日期(非发票开票日期)
-`spec_model` 解析品名、规格、单位、数量、单价;`card_amount` 写入金额列
- 单价/金额保留两位小数;表格内统一为 **宋体五号10.5 磅)**
- 存放地点取自 `consumable_storage`(默认: `躬行楼 C205`);购货人/领用人签字、备注保持空白
## 执行步骤说明
| 步骤 | 命令 | 说明 |
|------|------|------|
| 发票提取 | `--step invoice` | 扫描目录 PDF,提取发票号码、金额、销售方等信息,生成 `invoice_summary.csv``.md` |
| OCR 识别 | `--step ocr` | 对支付截图执行 OCR识别刷卡日期、刷卡金额、持卡人姓名回填到 CSV |
| 浏览器填报 | `--step submit` | 打开浏览器,登录信息门户 → 进入报销系统 → 自动填单、录入明细、上传附件 |
| 发票提取 | `--step invoice` | 扫描 `scripts/data/` 目录 PDF 和图片,生成 `invoice_summary.csv` |
| 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 |
> 分步执行时,上一步的 CSV 产物会自动成为下一步的输入。
> 全流程执行时数据在内存中流转CSV 为参考产物。分步执行时,缓存数据会自动成为下一步的输入。
### 缓存机制
系统使用 JSON 缓存作为数据中转站,串联整个处理流程:
```
源文件 (PDF/图片) → LLM 多模态提取 → JSON 缓存 → 匹配/分类/填报
```
| 缓存文件 | 说明 |
|----------|------|
| `<文件名>.json` | 每个源文件的 LLM 提取结果(发票信息、支付记录等) |
| `match_result.json` | 发票与支付记录的匹配结果 |
| `travel_info.json` | 差旅信息(事由、地点、时间等)提取结果 |
缓存位置CLI 模式为 `scripts/data/.invoice_cache/`Web 模式为 `src/web/uploads/<session_id>/.invoice_cache/`。缓存文件与源文件同名(如 `发票1.pdf` 对应 `.invoice_cache/发票1.json`),后续步骤(金额匹配、发票分类、浏览器填报)均从缓存读取结构化数据。删除缓存后下次处理会重新提取。
### CSV 字段说明
发票级别 CSV`invoice_summary.csv`)使用英文列名:
| 列名 | 说明 |
|------|------|
| `index` | 行号 |
| `invoice_type` | 发票类型:`train` / `hotel` / `general` |
| `invoice_number` | 电子发票号码 |
| `invoice_date` | 开票日期 |
| `item_name` | 货物或应税劳务名称 |
| `spec_model` | 规格型号(差旅发票为出发站→到达站) |
| `total_amount` | 发票含税金额 |
| `seller_name` | 销方名称 |
| `departure` / `arrival` | 出发站 / 到达站(高铁票专用) |
| `train_no` / `ride_date` / `seat_class` | 车次 / 乘车日期 / 座位等级(高铁票专用) |
| `person_name` | 人员姓名 |
| `card_date` / `card_no` / `card_amount` | 刷卡日期 / 公务卡号 / 刷卡金额 |
| `remark` | 备注 |
| `person_id` | 工号 |
## Web 服务
提供浏览器界面上传文件即可自动处理
提供浏览器界面上传文件自动处理 → 在线编辑 → 下载产物 → 可选提交财务系统。
```bash
python web/app.py
uv run python src/web/app.py
```
访问 `http://localhost:5000`,上传文件并填写配置后点击「开始处理」
访问 `http://localhost:5000`
### 两种处理模式
### 推荐使用流程
| 模式 | 入口 | 说明 |
|------|------|------|
| **PDF 模式** | 上传 PDF + 截图 | 自动提取发票信息 → OCR 识别刷卡记录 → 生成 CSV → 可选浏览器填报 |
| **CSV 快捷模式** | 上传已有 CSV 文件 | 跳过提取和 OCR直接使用 CSV 数据进行浏览器填报 |
1. 上传 PDF 或图片(或上传已有 CSV
2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充
3. 点击 **开始处理** — 完成发票提取、生成 CSV系统自动识别发票类型并分类统计
4. **普通发票**:自动生成 **易耗品、出库单.doc**,可下载
5. **差旅发票**(高铁票/酒店住宿):跳过出库单生成,直接进入差旅报销流程
6. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
7. 确认无误后点击 **提交到财务系统**
### 功能说明
### 处理流程
上传 PDF 或图片 → LLM 识别文档类型 → 结构化提取 → 分类处理
系统通过 LLM 多模态识别自动判断每张文档的类型,无需手动指定:
| 文档类型 | 处理方式 | 状态 |
|----------|----------|------|
| 发票(高铁票/酒店住宿/普通发票) | 提取发票信息 → 金额匹配 → 分类 | 已实现 |
| 支付记录(刷卡截图) | 提取刷卡信息 → 与发票匹配 | 已实现 |
| 出差事前申请单 | 提取出差事由、地点、时间 | 已实现 |
| 飞机票 | 同高铁票处理流程 | 计划中 |
### 功能一览
| 功能 | 说明 |
|------|------|
| 发票提取 + OCR | 上传 PDF 后自动完成,无需手动操作 |
| CSV 快捷上传 | 已有发票数据 CSV 可直接上传,跳过前面的步骤 |
| 浏览器填报 | 勾选「同时提交到财务系统」后自动运行 |
| 实时日志 | 处理进度通过 SSE 实时推送 |
| 下载 CSV | 处理后下载发票汇总表 |
| 配置上传 | 可上传 `config.json` 自动填充表单 |
| 发票提取 | 上传 PDF 或图片后自动完成 |
| 发票类型自动分类 | 高铁票/酒店住宿/普通发票,自动分流处理 |
| 易耗品出库单 | 仅普通发票自动生成 Word差旅发票跳过 |
| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 |
| 财务系统填报 | 单独按钮触发,处理阶段不会自动提交 |
| 实时日志 | SSE 推送处理进度 |
| 配置上传 | 支持上传 `config.json` 填充表单 |
| 手机扫码上传 | 二维码打开移动端页面拍照上传PC 端轮询同步 |
> Web 模式下浏览器以无头模式运行,不会弹出窗口
> 若未上传 PDF 附件,浏览器填报阶段将自动跳过附件上传步骤
> Web 浏览器填报以无头模式运行。未上传 PDF 或图片时,填报阶段会跳过附件上传
> 出库单生成需要 **Windows + Word + pywin32**若失败页面会显示具体原因CSV 等其它产物仍可正常使用
### 会话产物
每次上传生成独立会话,产物存放在 `src/web/uploads/<session_id>/`
| 产物 | 说明 |
|------|------|
| `invoice_summary.csv` | 发票汇总数据(含 LLM 识别结果) |
| `payment_records.csv` | 支付记录级别数据(含匹配结果) |
| `travel_applications.json` | 出差事前申请单数据JSON 格式) |
| `易耗品、出库单.doc` | 自动填写的出库单(仅普通发票) |
| `config.json` | 当次会话配置 |
| `session.log` | 处理日志 |
| `result.json` | 处理结果 |
| `.invoice_cache/` | LLM 提取结果缓存JSON 格式,避免重复处理) |
会话缓存机制与 CLI 模式相同,缓存目录中的 JSON 数据是后续匹配、分类和填报的唯一数据来源。
接口说明见 [API.md](./API.md)。
## 开发任务
项目提供统一的任务脚本,支持跨平台使用:
| 任务 | 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 检查和格式化
所有检查通过后方可提交代码。
## 注意事项
- 第三步会打开浏览器窗口,请勿关闭或切换标签页
- 首次运行可能需要手动处理 SSO 登录(如已保存会话则跳过)
- 调试截图保存在 `images/` 目录,出错时可查看
- `invoice_summary.csv` 中空白的字段会在 OCR 步骤自动回填,不会覆盖已有数据
- 提交按钮默认未启用,确认数据无误后可在 `app/bot.py` 中取消注释 `bot.submit()`
- 浏览器填报时会打开或使用 Chromium请勿手动干扰自动化流程
- 调试截图保存在 `images/` 目录
- 项目根目录需保留 `易耗品、出库单.doc` 模板Web 每次从模板复制到会话目录再填写,不修改原模板
- `scripts/config.json` 含敏感信息,请勿提交到公开仓库
- **发票类型区分**:差旅发票(高铁票/酒店住宿)不会生成易耗品出库单,差旅报销填报流程已完整实现(含差旅信息提取、明细录入、支付方式、补助清单、附件上传)

View File

@@ -1,35 +0,0 @@
"""财务报销自动化工具包"""
import io
import logging
import sys
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"
def get_logger(name: str) -> logging.Logger:
"""获取带时间戳的日志记录器
输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ...
日志同时输出到终端和项目根目录的 pipeline.log
"""
logger = logging.getLogger(name)
if not logger.handlers:
logger.setLevel(logging.INFO)
formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT)
# 终端输出
utf8_stream = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
stream_handler = logging.StreamHandler(utf8_stream)
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
# 文件输出
file_handler = logging.FileHandler(str(_LOG_FILE), encoding="utf-8")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger

View File

@@ -1,435 +0,0 @@
"""
浏览器自动化填报
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
对外接口:
load_invoice_data(csv_path, config) -> list[dict] 从 CSV 加载并补全默认值
run_bot(config, invoices) 启动浏览器并执行填报流程
"""
import csv
from pathlib import Path
from . import get_logger
log = get_logger("bot")
# ------------------------------------------------------------------
# 日期格式化
# ------------------------------------------------------------------
def _format_date(date_str: str) -> str:
"""'2026/5/13''2026-5-13' 转为 '2026-05-13'"""
if not date_str:
return ""
parts = date_str.replace("-", "/").split("/")
if len(parts) == 3:
return f"{parts[0].zfill(4)}-{parts[1].zfill(2)}-{parts[2].zfill(2)}"
return date_str
# ------------------------------------------------------------------
# CSV 数据加载
# ------------------------------------------------------------------
def load_invoice_data(csv_path: str, config: dict) -> list[dict]:
"""从 CSV 加载发票数据,自动补全空白字段的默认值"""
invoices = []
with open(csv_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
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", ""),
"card_date": _format_date(row.get("刷卡日期") or ""),
"card_no": row.get("公务卡号") or config.get("default_card_no", ""),
"card_amount": float(row.get("刷卡金额") or "0"),
"remark": row.get("备注") or "",
"person_id": row.get("工号") or config.get("default_person_id", ""),
})
return invoices
# ------------------------------------------------------------------
# 报销机器人
# ------------------------------------------------------------------
class ReimburseBot:
"""财务报销自动化机器人"""
def __init__(self, config: dict, headless: bool = False):
self.config = config
self.headless = headless
self.work_dir: Path | None = None
self.browser = None
self.context = None
self.page = None
from playwright.sync_api import sync_playwright
self._pw_ctx = sync_playwright()
self.pw = self._pw_ctx.__enter__()
def launch(self):
"""启动浏览器"""
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):
"""登录信息门户"""
log.info("登录信息门户...")
self.page.goto(self.config["sso_login_url"], wait_until="domcontentloaded")
self._wait_for('text="微信扫码登录"', timeout=5000)
try:
self.page.fill('input[placeholder*="工号"], input[placeholder*="学号"]', self.config["username"])
self.page.fill('input[placeholder*="密码"]', self.config["password"])
except Exception:
log.warning("未找到登录输入框,可能已登录")
try:
checkbox = self.page.query_selector('input[type="checkbox"]')
if checkbox and not checkbox.is_checked():
checkbox.click()
except Exception:
pass
for selector in ['button:has-text("登录")', 'input[value="登录"]', 'text="登录"']:
try:
self.page.click(selector, timeout=3000)
break
except Exception:
continue
self._wait_for_portal()
def _wait_for_portal(self):
"""等待跳转到统一信息平台"""
for _ in range(30):
self.page.wait_for_timeout(1000)
url = self.page.url
if any(kw in url for kw in ("tyrz.fynu.edu.cn/zs-uip", "tyrz.fynu.edu.cn/oshall", "portal")):
self._screenshot("portal_loaded")
return
log.error("等待门户跳转超时")
self._screenshot("portal_timeout")
raise TimeoutError("登录超时,未跳转到信息门户")
def navigate_to_reimburse(self):
"""从统一信息平台进入报销系统"""
log.info("进入报销系统...")
self._wait_for('text="快捷入口"', timeout=5000)
try:
self.page.click('text="财务系统"', timeout=5000)
except Exception:
log.warning("未找到财务系统入口")
new_tab = None
for _ in range(15):
self.page.wait_for_timeout(1000)
for p in self.context.pages:
if "dddl" in p.url or "210.45.32.214" in p.url:
new_tab = p
break
if new_tab:
break
if new_tab:
self.page = new_tab
self._wait_for('text="网络报销"', timeout=5000)
else:
log.warning(f"未找到单点登录页面,当前 URL: {self.page.url}")
for p in self.context.pages[:-1]:
try:
p.close()
except Exception:
pass
try:
link = self.page.query_selector('a:has(img[src*="wlbx"])')
if link:
reimburse_url = link.get_attribute("href")
self.page.goto(reimburse_url, wait_until="domcontentloaded", timeout=15000)
except Exception:
pass
self._wait_for('text="报销录入"', timeout=5000)
common_url = self.config["reimburse_url"] + self.config["reimburse_page"]
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
self._wait_for('text="单据状态:"', timeout=5000)
def open_reimburse_menu(self):
"""点击「新增」创建新报销单"""
log.info("创建新报销单...")
self.page.wait_for_timeout(2000)
try:
self.page.click('button:has-text("新增")', timeout=5000)
except Exception:
try:
self.page.click("text=新增", timeout=3000)
except Exception:
self._screenshot("no_add_button")
raise RuntimeError("无法点击新增按钮")
self.page.wait_for_timeout(3000)
self._screenshot("after_add_click")
def fill_basic_info(self, description: str = "元器件采购报销"):
"""填写基本信息"""
log.info("填写基本信息...")
try:
self.page.fill("#EXPENEXPLAIN", description)
except Exception:
pass
try:
self.page.click("#PROJECTCODE", timeout=10000)
self.page.wait_for_timeout(1000)
except Exception:
pass
self._screenshot("step3_project_modal")
try:
self.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
first_row = self.page.query_selector("#promodal .fixed-table-body tbody tr")
if first_row:
first_row.click()
self.page.wait_for_timeout(1000)
except Exception:
pass
self._screenshot("step3_project_selected")
try:
self.page.click("#saveAndNext", timeout=5000)
self.page.wait_for_timeout(2000)
except Exception:
pass
self._screenshot("step3_done")
def add_reimburse_items(self, invoices: list[dict]):
"""录入报销明细(一条总明细)"""
card_amount = sum(inv["card_amount"] for inv in invoices)
log.info(f"录入报销明细 (合计 ¥{card_amount:.2f})...")
try:
self.page.click("#insertDetail", timeout=5000)
self.page.wait_for_timeout(1000)
self._wait_for('text="经济事项名称"', timeout=5000)
self.page.click("#economicscode2")
self.page.wait_for_timeout(1000)
try:
self.page.wait_for_selector("#econmodal .fixed-table-body tbody tr", timeout=10000)
rows = self.page.query_selector_all("#econmodal .fixed-table-body tbody tr")
if len(rows) >= 3:
rows[2].click()
self.page.wait_for_timeout(1000)
except Exception:
pass
self.page.fill('input[name="expenPwCommondetail.HOWBILLS"]', f"{len(invoices)}")
self.page.fill("#je_zwzcdz", f"{card_amount:.2f}")
self.page.click("#detailAdd", timeout=3000)
self.page.wait_for_timeout(1000)
self._screenshot("item_total")
except Exception as e:
log.error(f"录入总明细失败: {e}")
self._screenshot("item_total_error")
raise
def fill_payment(self, invoices: list[dict]):
"""录入支付信息"""
log.info("录入支付信息...")
try:
self.page.click('text="下一步(支付方式)"', timeout=5000)
self._wait_for('text="下一步(附件清单)"', timeout=5000)
for inv in invoices:
self.page.click("#insertPay", timeout=5000)
self.page.wait_for_timeout(1000)
self.page.fill("#personid2", inv["person_id"])
self.page.fill("#accountname2", inv["person_name"])
self.page.fill("#receiptdate2", inv["card_date"])
self.page.fill("#localaccount2", inv["card_no"])
self.page.fill("#receiptmoney2", str(inv["card_amount"]))
self.page.fill("#money2", str(inv["card_amount"]))
self.page.fill("#merchant2", inv["seller_name"])
self.page.fill("#smark2", inv["remark"])
self.page.click("#payAdd", timeout=3000)
self.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"支付方式录入失败: {e}")
self._screenshot("step5_error")
raise
self._screenshot("step5_done")
def upload_attachments(self, invoices: list[dict]):
"""上传发票附件"""
log.info("上传附件...")
try:
self.page.click("#next3", timeout=5000)
self._wait_for("#submit2", timeout=5000)
attachment_files = sorted((self.work_dir or Path(__file__).parent.parent).glob("*.pdf"))
if not attachment_files:
log.warning("未找到附件 PDF跳过附件上传")
return
for i, inv in enumerate(invoices):
file_path = attachment_files[i] if i < len(attachment_files) else None
self.page.click("#insertAcc", timeout=5000)
self.page.wait_for_timeout(1000)
try:
self._wait_for("#fjlx", timeout=5000)
except Exception:
pass
try:
self.page.select_option("#fjlx", "1")
except Exception:
pass
try:
explanation = f"{inv['item_name']} - {inv['invoice_no']}"
self.page.fill("#fpsmxx", explanation)
except Exception:
pass
if file_path and file_path.exists():
try:
self.page.set_input_files("#file", str(file_path))
self.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"文件上传失败: {e}")
try:
self.page.click("#cjtj", timeout=5000)
self.page.wait_for_timeout(1500)
except Exception:
try:
self.page.press("body", "Escape")
except Exception:
pass
except Exception as e:
log.error(f"附件上传失败: {e}")
self._screenshot("step6_error")
raise
self._screenshot("step6_done")
def submit(self):
"""提交报销单"""
log.info("提交报销单...")
try:
self.page.click("#submit", timeout=5000)
self.page.wait_for_timeout(1000)
self._screenshot("submitted")
except Exception as e:
log.error(f"提交失败: {e}")
self._screenshot("submit_error")
raise
def close(self):
"""关闭浏览器"""
if self.context:
self.context.close()
if self.browser:
self.browser.close()
try:
self._pw_ctx.__exit__(None, None, None)
except Exception:
pass
# --------------------------------------------------------
# 辅助方法
# --------------------------------------------------------
def _wait_for(self, selector: str, timeout: int = None):
self.page.wait_for_selector(selector, timeout=timeout)
def _screenshot(self, name: str):
img_dir = Path(__file__).parent.parent / "images"
img_dir.mkdir(exist_ok=True)
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
# ------------------------------------------------------------------
# 对外入口
# ------------------------------------------------------------------
def run_bot(config: dict, invoices: list[dict], headless: bool = False, work_dir: Path | None = None):
"""执行完整的浏览器填报流程"""
if not config["username"] or not config["password"]:
raise ValueError("缺少用户名或密码")
bot = ReimburseBot(config, headless=headless)
bot.work_dir = work_dir
try:
bot.launch()
bot.login_portal()
bot.navigate_to_reimburse()
bot.open_reimburse_menu()
bot.fill_basic_info()
bot.add_reimburse_items(invoices)
bot.fill_payment(invoices)
bot.upload_attachments(invoices)
# bot.submit() # 确认无误后再取消注释
except Exception as e:
log.error(f"操作失败: {e}")
try:
bot._screenshot("error")
except Exception:
pass
raise
finally:
bot.close()
def run_bot_web(config: dict, invoices: list[dict], work_dir: Path):
"""Web 模式填报 — headless附件从指定目录读取"""
if not config["username"] or not config["password"]:
raise ValueError("缺少用户名或密码")
bot = ReimburseBot(config, headless=True)
bot.work_dir = work_dir
try:
bot.launch()
bot.login_portal()
bot.navigate_to_reimburse()
bot.open_reimburse_menu()
bot.fill_basic_info()
bot.add_reimburse_items(invoices)
bot.fill_payment(invoices)
bot.upload_attachments(invoices)
except Exception as e:
log.error(f"操作失败: {e}")
try:
bot._screenshot("error")
except Exception:
pass
raise
finally:
bot.close()

View File

@@ -1,33 +0,0 @@
"""
配置加载
从项目根目录的 config.json 读取配置,返回结构化的配置字典。
"""
import json
from pathlib import Path
_CONFIG_PATH = Path(__file__).parent.parent / "config.json"
def load_config() -> dict:
"""加载并合并配置,缺失字段使用默认值"""
raw = {}
if _CONFIG_PATH.exists():
with open(_CONFIG_PATH, encoding="utf-8") as f:
raw = json.load(f)
project_root = _CONFIG_PATH.parent
return {
"sso_login_url": raw.get("sso_login_url", "https://tyrz.fynu.edu.cn/sso/login"),
"portal_url": raw.get("portal_url", "https://tyrz.fynu.edu.cn/oshall"),
"reimburse_url": raw.get("reimburse_url", "http://210.45.32.214:8081"),
"reimburse_page": raw.get("reimburse_page", "/expen/common/common?v=4.0"),
"username": raw.get("username", ""),
"password": raw.get("password", ""),
"default_name": raw.get("default_name", ""),
"default_card_no": raw.get("default_card_no", ""),
"default_person_id": raw.get("default_person_id", ""),
"attachment_dir": project_root / "attachments",
}

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

8
config.example.json Normal file
View File

@@ -0,0 +1,8 @@
{
"username": "你的工号",
"password": "你的密码",
"default_name": "默认报销人姓名",
"default_card_no": "默认公务卡号",
"default_person_id": "默认人员编号",
"consumable_storage": "躬行楼 C205"
}

View File

@@ -1,11 +0,0 @@
{
"username": "202407021",
"password": "wang!1624155937",
"sso_login_url": "https://tyrz.fynu.edu.cn/sso/login",
"portal_url": "https://tyrz.fynu.edu.cn/oshall",
"reimburse_url": "http://210.45.32.214:8081",
"reimburse_page": "/expen/common/common?v=4.0",
"default_name": "王建锋",
"default_card_no": "6282880139161682",
"default_person_id": "202407021"
}

494
docs/API.md Normal file
View File

@@ -0,0 +1,494 @@
---
last_reviewed: 2026-06-09
---
# 财务报销自动化 — API 文档
> 基础地址: `http://localhost:5000`
> 启动: `uv run python src/web/app.py`
## 总览
| # | 方法 | 路径 | 说明 |
|---|------|------|------|
| 1 | GET | `/` | PC 端主页 |
| 2 | POST | `/api/session` | 创建会话 |
| 3 | POST | `/api/upload/<session_id>` | 上传文件PDF/图片) |
| 4 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
| 5 | POST | `/api/process/<session_id>` | 启动处理(提取+LLM识别+出库单) |
| 6 | GET | `/api/logs/<session_id>` | SSE 日志流 |
| 7 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
| 8 | GET | `/api/data/<session_id>` | 获取发票数据JSON |
| 9 | POST | `/api/save/<session_id>` | 保存编辑后的发票数据 |
| 10 | POST | `/api/submit-financial/<session_id>` | 提交到财务系统 |
| 11 | GET | `/mobile/<session_id>` | 移动端上传页面 |
| 12 | POST | `/api/mobile-upload/<session_id>` | 移动端上传图片 |
---
## 会话与目录
- 调用 `POST /api/session` 获得 `session_id`
- 该会话下所有文件存放在 `src/web/uploads/<session_id>/`
- 典型产物:`invoice_summary.csv``易耗品、出库单.doc``config.json``session.log``result.json`
---
## 接口详情
### 1. 创建会话
```
POST /api/session
```
**响应:**
```json
{ "session_id": "a1b2c3d4e5f6" }
```
---
### 2. 上传文件PDF/图片)
```
POST /api/upload/<session_id>
Content-Type: multipart/form-data
```
| 字段 | 类型 | 说明 |
|------|------|------|
| file | File | PDF 发票或支付截图 |
**响应(成功):**
```json
{ "ok": true, "filename": "1. 电容一批.pdf" }
```
**响应(失败):**
```json
{ "error": "未选择文件" }
```
HTTP `400`
---
### 3. 列出会话文件
```
GET /api/files/<session_id>
```
**响应:**
```json
{
"pdfs": ["1. 电容一批.pdf"],
"images": ["payment_01.jpg"]
}
```
`images` 包含扩展名:`.png``.jpg``.jpeg``.bmp``.webp`
---
### 5. 启动管道处理
```
POST /api/process/<session_id>
Content-Type: application/json
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| username | string | 否 | 财务系统工号 |
| password | string | 否 | 登录密码 |
| default_name | string | 否 | 默认报销人姓名 |
| default_card_no | string | 否 | 默认公务卡号 |
| default_person_id | string | 否 | 默认人员编号 |
| consumable_storage | string | 否 | 出库单存放地点;未填则用 `config.json` 中的值 |
**处理内容:**
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv`
2. 对支付截图多模态 LLM 识别,回填刷卡字段
3. 根据发票类型自动分类:差旅发票(高铁票/酒店住宿)不生成出库单;普通发票从模板复制并自动填写
配置会写入 `src/web/uploads/<session_id>/config.json`
**响应(立即):**
```json
{ "status": "started" }
```
处理在后台线程执行,进度与结果通过 `GET /api/logs/<session_id>`SSE获取。
**SSE 完成时 `result` 示例(成功):**
```json
{
"ok": true,
"elapsed": "45.2s",
"invoice_count": 4,
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
"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
{
"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 日志流
```
GET /api/logs/<session_id>
Accept: text/event-stream
```
**日志行格式:**
```
data: 2026-05-26 12:00:01 [INFO ] extractor: 正在提取发票...
```
**结束消息:**
```json
{
"type": "done",
"result": { }
}
```
`result` 结构取决于触发来源:
| 来源 | 典型字段 |
|------|----------|
| `/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. 下载文件
```
GET /api/download/<session_id>/<filename>
```
**响应:** 文件二进制流,带 `Content-Disposition: attachment` 与 UTF-8 文件名。
| 扩展名 | Content-Type |
|--------|----------------|
| `.csv` | `text/csv; charset=utf-8` |
| `.doc` | `application/msword` |
| 其它 | `application/octet-stream` |
**常见文件名:**
| 文件名 | 说明 |
|--------|------|
| `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) |
| `易耗品、出库单.doc` | 自动填写的出库单 |
**错误:**
```json
{ "error": "文件不存在" }
```
HTTP `404``filename` 仅允许会话目录内的文件名(防止路径穿越)。
---
### 8. 获取发票数据
```
GET /api/data/<session_id>
```
**响应:**
```json
{
"csv_filename": "invoice_summary.csv",
"fields": [
"序号", "发票号码", "开票日期", "项目名称", "规格型号",
"价税合计", "销售方名称", "人员姓名", "刷卡日期",
"公务卡号", "刷卡金额", "备注", "工号"
],
"data": [
{
"__row": 0,
"序号": "1",
"发票号码": "26442000005432755951",
"价税合计": "2900.00"
}
]
}
```
- `fields`:列顺序
- `data[].__row`:内部行索引(保存时不需要提交,服务端按数组顺序写回)
**错误:** `404` 未找到 CSV`500` 读取失败。
---
### 9. 保存编辑后的发票数据
```
POST /api/save/<session_id>
Content-Type: application/json
```
**请求体:**
```json
{
"csv_filename": "invoice_summary.csv",
"data": [
{
"序号": "1",
"发票号码": "26442000005432755951",
"开票日期": "2026/05/18",
"项目名称": "...",
"规格型号": "...",
"价税合计": "2900.00",
"销售方名称": "...",
"人员姓名": "",
"刷卡日期": "2026/04/28",
"公务卡号": "",
"刷卡金额": "2850.00",
"备注": "",
"工号": "202407021"
}
]
}
```
**响应(成功):**
```json
{
"ok": true,
"doc_ok": true,
"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"
}
```
保存后会根据最新 CSV **重新生成** 出库单 Word与会话 `config.json` 中的 `consumable_storage` 等配置一致)。
**纯差旅发票跳过出库单:**
```json
{
"ok": true,
"doc_ok": null,
"doc_skipped": true
}
```
**出库单生成失败:**
```json
{
"ok": true,
"doc_ok": false,
"doc_error": "出库单模板不存在,请将模板放在项目根目录"
}
```
---
### 10. 提交到财务系统
```
POST /api/submit-financial/<session_id>
```
**前置条件:**
- 会话目录存在 `config.json`(由 `/api/process` 写入),否则返回 `400`
- 存在可用的发票 CSV通常为 `invoice_summary.csv`
**说明:**
- 前端一般在提交前调用 `/api/save` 保存表格修改
- 本接口**不会**自动执行发票提取或 LLM 识别
- 根据发票类型选择填报模式:纯差旅发票走差旅报销流程,含普通发票走普通报销流程
**响应(立即):**
```json
{ "status": "started" }
```
**SSE 完成示例:**
```json
{
"type": "done",
"result": {
"ok": true,
"submit_ok": true
}
}
```
失败时 `submit_ok: false``submit_error` 为错误描述。
---
### 11. 移动端上传页面
```
GET /mobile/<session_id>
```
返回移动端 HTML 页面。扫码上传的图片与 PC 端共用同一会话目录PC 通过轮询 `GET /api/files/<session_id>` 同步文件列表。
---
### 12. 移动端上传图片
```
POST /api/mobile-upload/<session_id>
Content-Type: multipart/form-data
```
| 字段 | 类型 | 说明 |
|------|------|------|
| file | File | 图片文件 |
逻辑与 `POST /api/upload/<session_id>` 相同。
---
## 错误码汇总
| HTTP | 场景 |
|------|------|
| 400 | 参数缺失、未找到配置等 |
| 404 | `session_id` 不存在、文件不存在 |
| 500 | CSV 读取失败等内部错误 |
统一错误体:
```json
{ "error": "错误描述" }
```
---
## 发票类型分类
系统自动将发票分为两类,影响出库单生成和后续报销流程:
| 类型 | 判断依据 | 出库单 | 报销流程 |
|------|----------|--------|----------|
| 差旅发票 | 高铁票、酒店住宿等 | 不生成 | 差旅报销 |
| 普通发票 | 其他(办公用品、耗材等) | 自动生成 | 普通报销 |
`/api/process``/api/save` 的响应中 `travel_count` / `general_count` 即为分类统计。
---
## 端到端流程
```mermaid
sequenceDiagram
participant PC as PC 端
participant Server as Server
participant Mobile as 移动端
participant Word as Word COM
PC->>Server: POST /api/session
Server-->>PC: session_id
PC->>Server: POST /api/upload/{sid}
PC->>Server: POST /api/process/{sid}
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}
Server-->>PC: fields + data
PC->>Server: POST /api/save/{sid}
Note over Server: 更新 CSV重新生成出库单
Server-->>PC: doc_url
PC->>Server: GET /api/download/{sid}/易耗品、出库单.doc
PC->>Server: POST /api/submit-financial/{sid}
Note over Server: Playwright 浏览器填报
Server-->>PC: SSE done (submit_ok)
Mobile->>Server: POST /api/mobile-upload/{sid}
PC->>Server: GET /api/files/{sid} (轮询)
```
---
## 相关 CLI
不经过 Web、在本地直接填写出库单
```bash
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
```
详见 [README.md](./README.md)。

18
docs/README.md Normal file
View File

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

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

@@ -0,0 +1,454 @@
---
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
上传 PDF 发票文件和支付截图,系统自动完成:
1. 从 PDF 提取发票信息
2. 多模态 LLM 识别支付截图中的刷卡信息
3. 生成 `invoice_summary.csv`
### 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.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)。

View File

@@ -1,5 +0,0 @@
序号,发票号码,开票日期,项目名称,规格型号,价税合计,销售方名称,人员姓名,刷卡日期,公务卡号,刷卡金额,备注,工号
1,26442000005432755951,2026/05/18,*电子元件*电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71,电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71,2900.00,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2850.00,,
2,26442000005432652421,2026/05/18,*电子元件*电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71,电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71,2900.00,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2880.00,,
3,26442000005432937661,2026/05/18,*集成电路*集成电路 LED一批 个 2638.92 1% 26.39,集成电路 LED一批 个 2638.92 1% 26.39,2665.31,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2661.61,,
4,26442000005468940571,2026/05/18,*电子工业设备*元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48,元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48,96.00,东莞市长安顺淘电子工具经营部,陈陈,2026/05/07,,96.00,,
1 序号 发票号码 开票日期 项目名称 规格型号 价税合计 销售方名称 人员姓名 刷卡日期 公务卡号 刷卡金额 备注 工号
2 1 26442000005432755951 2026/05/18 *电子元件*电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71 电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71 2900.00 佛山市泓宇芯科技有限公司 陈陈 2026/04/28 2850.00
3 2 26442000005432652421 2026/05/18 *电子元件*电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71 电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71 2900.00 佛山市泓宇芯科技有限公司 陈陈 2026/04/28 2880.00
4 3 26442000005432937661 2026/05/18 *集成电路*集成电路 LED一批 个 2638.92 1% 26.39 集成电路 LED一批 个 2638.92 1% 26.39 2665.31 佛山市泓宇芯科技有限公司 陈陈 2026/04/28 2661.61
5 4 26442000005468940571 2026/05/18 *电子工业设备*元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48 元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48 96.00 东莞市长安顺淘电子工具经营部 陈陈 2026/05/07 96.00

View File

@@ -1,11 +0,0 @@
# 发票信息汇总表
| 序号 | 发票号码 | 开票日期 | 项目名称 | 规格型号 | 价税合计 | 销售方名称 | 人员姓名 | 刷卡日期 | 公务卡号 | 刷卡金额 | 备注 | 工号 |
|------|------|------|------|------|------|------|------|------|------|------|------|------|
| 1 | 26442000005432755951 | 2026/05/18 | *电子元件*电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71 | 电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71 | ¥2,900.00 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,850.00 | | |
| 2 | 26442000005432652421 | 2026/05/18 | *电子元件*电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71 | 电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71 | ¥2,900.00 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,880.00 | | |
| 3 | 26442000005432937661 | 2026/05/18 | *集成电路*集成电路 LED一批 个 2638.92 1% 26.39 | 集成电路 LED一批 个 2638.92 1% 26.39 | ¥2,665.31 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,661.61 | | |
| 4 | 26442000005468940571 | 2026/05/18 | *电子工业设备*元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48 | 元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48 | ¥96.00 | 东莞市长安顺淘电子工具经营部 | 陈陈 | 2026/05/07 | | ¥96.00 | | |
**价税合计总计: ¥8,561.31**
**刷卡金额总计: ¥8,487.61**

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",
"PyMuPDF>=1.24",
"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,84 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
财务报销全流程编排脚本
依次执行:
1. extract_invoice.py — 从 PDF 发票提取信息,生成 invoice_summary.csv
2. extract_image_ocr.py — 从支付截图 OCR 识别刷卡信息,更新 CSV
3. reimburse.py — 打开浏览器登录财务系统并自动填报
用法:
python run_all.py # 默认执行全部三步
python run_all.py --step invoice # 仅执行第 1 步
python run_all.py --step ocr # 仅执行第 2 步
python run_all.py --step submit # 仅执行第 3 步
"""
import subprocess
import sys
from pathlib import Path
PROJECT_DIR = Path(__file__).parent.resolve()
PYTHON = sys.executable
def step(name: str, module: str, args: list[str]) -> bool:
"""运行单个步骤,返回是否成功"""
cmd = [PYTHON, str(PROJECT_DIR / module), *args]
print(f"\n{'=' * 60}")
print(f" [{name}] {module}")
print(f"{'=' * 60}")
result = subprocess.run(cmd, cwd=str(PROJECT_DIR))
ok = result.returncode == 0
if ok:
print(f"\n[{name}] 完成")
else:
print(f"\n[错误] {name} 失败 (exit code: {result.returncode})")
return ok
def run_all() -> int:
print("=" * 60)
print(" 财务报销自动化流程")
print(f" 工作目录: {PROJECT_DIR}")
print(f" Python: {PYTHON}")
print("=" * 60)
if not step("发票提取", "extract_invoice.py", []):
return 1
if not step("OCR 识别", "extract_image_ocr.py", []):
return 1
if not step("报销提交", "reimburse.py", ["--data", str(PROJECT_DIR / "invoice_summary.csv")]):
return 1
print("\n" + "=" * 60)
print(" 全流程执行完毕")
print("=" * 60)
return 0
def main() -> int:
if len(sys.argv) >= 3 and sys.argv[1] == "--step":
name = sys.argv[2]
steps = {
"invoice": ("发票提取", "extract_invoice.py", []),
"ocr": ("OCR 识别", "extract_image_ocr.py", []),
"submit": ("报销提交", "reimburse.py", ["--data", str(PROJECT_DIR / "invoice_summary.csv")]),
}
if name not in steps:
print(f"未知步骤: {name},可选: {', '.join(steps)}")
return 1
label, module, args = steps[name]
return 0 if step(label, module, args) else 1
return run_all()
if __name__ == "__main__":
sys.exit(main())

21
scripts/README.md Normal file
View File

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

View File

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

View File

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

94
src/README.md Normal file
View File

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

58
src/__init__.py Normal file
View File

@@ -0,0 +1,58 @@
"""财务报销自动化工具包"""
import datetime
import io
import logging
import sys
from pathlib import Path
_LOG_FMT = "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s"
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
_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: 扫描目录: ...
日志同时输出到终端和 logs/<日期>.log
只在最顶层的 logger 上添加标准 handlerstream + file
子 logger 通过 propagate 将日志传递给父 logger 统一处理。
这样 _SSELogHandler 只需挂在父 logger 上即可捕获所有子日志。
"""
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
# 检查是否有父 logger 已初始化标准 handler
# 如果有,子 logger 不重复添加,靠 propagate 传递即可
parent_name = name.rsplit(".", 1)[0] if "." in name else None
parent_has_handlers = False
if parent_name:
parent = logging.getLogger(parent_name)
parent_has_handlers = getattr(parent, "_standard_handlers_initialized", False)
if not getattr(logger, "_standard_handlers_initialized", False) and not parent_has_handlers:
formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT)
# 只有没有已初始化的父 logger 时,才添加标准 handler
# 终端输出
utf8_stream = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
stream_handler = logging.StreamHandler(utf8_stream)
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
# 文件输出
file_handler = logging.FileHandler(str(_get_log_file()), encoding="utf-8")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger._standard_handlers_initialized = True # type: ignore[attr-defined]
return logger

33
src/bot/README.md Normal file
View File

@@ -0,0 +1,33 @@
---
last_reviewed: 2026-06-12
---
# bot — 浏览器自动化填报模块
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
## 模块清单
| 文件 | 说明 |
|------|------|
| `__init__.py` | 对外入口:`run_bot()``run_bot_web()`,负责类型判断和流程路由 |
| `base.py` | `BaseBot` 基类:浏览器生命周期、登录、导航、截图、日期格式化 |
| `travel.py` | 差旅报销填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
| `normal.py` | 普通发票报销填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
## 架构设计
```
run_bot(config, travel_info, normal_info)
├── 创建 BaseBot启动浏览器登录门户
├── travel_info 存在 → travel.run(bot, travel_info)
└── normal_info 存在 → normal.run(bot, normal_info)
```
- **`BaseBot`** 只保留公共操作launch、login、navigate、create_new_form、close、screenshot
- **差旅/普通流程** 作为独立函数接受 `bot: BaseBot` 参数,符合函数式编程偏好
- **`__init__.py`** 仅做路由分发,不包含具体填报逻辑
## 变更历史
- **2026-06-12**:从 `bot.py` 单文件重构为 `bot/` 包,分离差旅和普通报销逻辑

93
src/bot/__init__.py Normal file
View File

@@ -0,0 +1,93 @@
"""
浏览器自动化填报
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
对外接口:
run_bot(config, travel_info, normal_info) 启动浏览器并执行填报流程
run_bot_web(config, work_dir) Web 模式填报(从缓存加载信息)
"""
from pathlib import Path
from typing import Any
from .. import get_logger
from .base import BaseBot
log = get_logger("bot")
def run_bot(
config: dict[str, Any],
headless: bool = False,
work_dir: Path | None = None,
travel_info: dict[str, Any] | None = None,
normal_info: dict[str, Any] | None = None,
) -> None:
"""执行完整的浏览器填报流程,根据发票类型自动路由
Args:
config: 配置字典。
headless: 是否无头模式。
work_dir: 工作目录。
travel_info: 差旅信息(由 pipeline 层提前提取并传入,非差旅时传 None
normal_info: 普通发票信息(由 pipeline 层提前提取并传入,非普通时传 None
"""
if not config["username"] or not config["password"]:
raise ValueError("缺少用户名或密码")
if not work_dir:
raise ValueError("缺少工作目录")
bot = BaseBot(config, headless=headless)
bot.work_dir = work_dir
try:
bot.launch()
bot.login_portal()
if travel_info is not None:
log.info("处理差旅发票...")
bot.navigate_to_reimburse(page_key="travel_page")
bot.create_new_form()
from . import travel
travel.run(bot, travel_info)
elif normal_info is not None:
log.info("处理普通发票...")
bot.navigate_to_reimburse(page_key="reimburse_page")
bot.create_new_form()
from . import normal
normal.run(bot, normal_info)
else:
raise ValueError("缺少差旅信息travel_info和普通发票信息normal_info无法继续填报")
except Exception as e:
log.error(f"操作失败: {e}")
try:
bot._screenshot("error")
except Exception:
pass
raise
finally:
bot.close()
def run_bot_web(config: dict[str, Any], work_dir: Path) -> None:
"""Web 模式填报 — headless附件从指定目录读取
Web 端的信息提取由 app.py 的管道负责,此处从缓存加载。
"""
from ..doc.llm_extractor import load_cache
cache_map = load_cache(work_dir)
travel_info = cache_map.get("travel_info")
normal_info = cache_map.get("normal_info")
run_bot(
config,
headless=True,
work_dir=work_dir,
travel_info=travel_info,
normal_info=normal_info,
)

204
src/bot/base.py Normal file
View File

@@ -0,0 +1,204 @@
"""
浏览器自动化填报 — 公共基类
提供浏览器生命周期管理、登录、导航、截图等公共操作。
"""
from pathlib import Path
from typing import Any
from .. import get_logger
log = get_logger("bot")
# ------------------------------------------------------------------
# 工具函数
# ------------------------------------------------------------------
def format_date(date_str: str) -> str:
"""'2026/5/13''2026-5-13' 转为 '2026-05-13'"""
if not date_str:
return ""
parts = date_str.replace("-", "/").split("/")
if len(parts) == 3:
return f"{parts[0].zfill(4)}-{parts[1].zfill(2)}-{parts[2].zfill(2)}"
return date_str
# ------------------------------------------------------------------
# 基类
# ------------------------------------------------------------------
class BaseBot:
"""浏览器自动化基类 — 管理浏览器生命周期与公共操作"""
def __init__(self, config: dict[str, Any], headless: bool = False) -> None:
self.config = config
self.headless = headless
self.work_dir: Path | None = None
self.browser: Any = None
self.context: Any = None
self.page: Any = None
from playwright.sync_api import sync_playwright
self._pw_ctx = sync_playwright()
self.pw = self._pw_ctx.__enter__()
# ---------------------------------------------------------------
# 浏览器生命周期
# ---------------------------------------------------------------
def launch(self) -> None:
"""启动浏览器"""
self.browser = self.pw.chromium.launch(headless=self.headless)
self.context = self.browser.new_context(viewport={"width": 1360, "height": 768})
self.page = self.context.new_page()
self.page.set_default_timeout(30000)
def close(self) -> None:
"""关闭浏览器"""
if self.context:
self.context.close()
if self.browser:
self.browser.close()
try:
self._pw_ctx.__exit__(None, None, None)
except Exception:
pass
# ---------------------------------------------------------------
# 登录
# ---------------------------------------------------------------
def login_portal(self) -> None:
"""登录信息门户"""
log.info("登录信息门户...")
self.page.goto(self.config["sso_login_url"], wait_until="domcontentloaded")
self._wait_for('text="微信扫码登录"', timeout=5000)
try:
self.page.fill(
'input[placeholder*="工号"], input[placeholder*="学号"]',
self.config["username"],
)
self.page.fill('input[placeholder*="密码"]', self.config["password"])
except Exception:
log.warning("未找到登录输入框,可能已登录")
try:
checkbox = self.page.query_selector('input[type="checkbox"]')
if checkbox and not checkbox.is_checked():
checkbox.click()
except Exception:
pass
for selector in ['button:has-text("登录")', 'input[value="登录"]', 'text="登录"']:
try:
self.page.click(selector, timeout=3000)
break
except Exception:
continue
self._wait_for_portal()
def _wait_for_portal(self) -> None:
"""等待跳转到统一信息平台"""
for _ in range(30):
self.page.wait_for_timeout(1000)
url = self.page.url
if any(
kw in url
for kw in (
"tyrz.fynu.edu.cn/zs-uip",
"tyrz.fynu.edu.cn/oshall",
"portal",
)
):
self._screenshot("portal_loaded")
return
log.error("等待门户跳转超时")
self._screenshot("portal_timeout")
raise TimeoutError("登录超时,未跳转到信息门户")
# ---------------------------------------------------------------
# 导航
# ---------------------------------------------------------------
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
"""从统一信息平台进入报销系统"""
log.info("进入报销系统...")
self._wait_for('text="快捷入口"', timeout=5000)
try:
self.page.click('text="财务系统"', timeout=5000)
except Exception:
log.warning("未找到财务系统入口")
new_tab = None
for _ in range(15):
self.page.wait_for_timeout(1000)
for p in self.context.pages:
if "dddl" in p.url or "210.45.32.214" in p.url:
new_tab = p
break
if new_tab:
break
if new_tab:
self.page = new_tab
self._wait_for('text="网络报销"', timeout=5000)
else:
log.warning(f"未找到单点登录页面,当前 URL: {self.page.url}")
for p in self.context.pages[:-1]:
try:
p.close()
except Exception:
pass
try:
link = self.page.query_selector('a:has(img[src*="wlbx"])')
if link:
reimburse_url = link.get_attribute("href")
self.page.goto(reimburse_url, wait_until="domcontentloaded", timeout=15000)
except Exception:
pass
self._wait_for('text="报销录入"', timeout=5000)
common_url = self.config["reimburse_url"] + self.config[page_key]
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
self._wait_for('text="单据状态:"', timeout=5000)
def create_new_form(self) -> None:
"""点击「新增」创建新单据"""
log.info("创建新单据...")
self.page.wait_for_timeout(2000)
try:
self.page.click("#insert", timeout=5000)
except Exception:
try:
self.page.click("text=新增", timeout=3000)
except Exception as err:
self._screenshot("no_add_button")
raise RuntimeError("无法点击新增按钮") from err
self.page.wait_for_timeout(3000)
self._screenshot("after_add_click")
# ---------------------------------------------------------------
# 辅助方法
# ---------------------------------------------------------------
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
self.page.wait_for_selector(selector, timeout=timeout)
def _screenshot(self, name: str) -> None:
img_dir = Path(__file__).parent.parent.parent / "images"
img_dir.mkdir(exist_ok=True)
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))

176
src/bot/normal.py Normal file
View File

@@ -0,0 +1,176 @@
"""
普通报销填报流程
负责普通发票报销的完整填报步骤:
基本信息 → 总明细 → 支付方式 → 附件上传
"""
from typing import Any
from .. import get_logger
from .base import BaseBot, format_date
log = get_logger("bot.normal")
# ------------------------------------------------------------------
# 普通报销流程
# ------------------------------------------------------------------
def run(
bot: BaseBot,
normal_info: dict[str, Any],
) -> None:
"""执行普通发票报销填报流程
Args:
bot: 已启动并登录的 BaseBot 实例。
normal_info: 普通发票信息字典。
"""
log.info("开始普通发票报销填报...")
log.info("填写基本信息...")
description = normal_info.get("basic_info", {}).get("reimbursement_description", "元器件采购报销")
fill_basic_info(bot, description)
total_invoices = normal_info.get("reimbursement_details", {}).get("total_invoices", 0)
total_amount = normal_info.get("reimbursement_details", {}).get("total_amount", 0)
log.info(f"录入普通发票总明细 (共 {total_invoices} 张, 合计 ¥{total_amount:.2f})...")
add_normal_item(bot, total_invoices, total_amount)
payment_info = normal_info.get("payment_methods", [])
log.info(f"录入普通发票支付信息 (共 {len(payment_info)} 笔)...")
fill_normal_payment(bot, payment_info)
log.info("上传普通发票附件...")
attachment_info = normal_info.get("attachments", [])
upload_normal_attachments(bot, attachment_info)
log.info("普通发票报销填报完成")
# ------------------------------------------------------------------
# 基本信息
# ------------------------------------------------------------------
def fill_basic_info(bot: BaseBot, description: str = "元器件采购报销") -> None:
"""填写基本信息"""
bot.page.fill("#EXPENEXPLAIN", description)
bot.page.click("#PROJECTCODE", timeout=10000)
bot.page.wait_for_timeout(1000)
bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr")
if first_row:
first_row.click()
bot.page.wait_for_timeout(1000)
bot.page.click("#saveAndNext", timeout=5000)
bot.page.wait_for_timeout(2000)
bot._screenshot("step3_done")
# ------------------------------------------------------------------
# 总明细
# ------------------------------------------------------------------
def add_normal_item(bot: BaseBot, total_invoices: int, total_amount: float) -> None:
"""录入普通发票总明细"""
try:
bot.page.click("#insertDetail", timeout=5000)
bot._wait_for('text="经济事项名称"', timeout=5000)
bot.page.click("#economicscode2")
bot.page.wait_for_timeout(1000)
try:
bot.page.wait_for_selector("#econmodal .fixed-table-body tbody tr", timeout=10000)
rows = bot.page.query_selector_all("#econmodal .fixed-table-body tbody tr")
if len(rows) >= 3:
rows[2].click()
bot.page.wait_for_timeout(1000)
except Exception:
pass
bot.page.fill('input[name="expenPwCommondetail.HOWBILLS"]', str(total_invoices))
bot.page.fill("#je_zwzcdz", f"{total_amount:.2f}")
bot.page.click("#detailAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
bot._screenshot("normal_item_done")
except Exception as e:
log.error(f"录入总明细失败: {e}")
bot._screenshot("normal_item_error")
raise
# ------------------------------------------------------------------
# 支付方式
# ------------------------------------------------------------------
def fill_normal_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> None:
"""录入普通发票支付信息"""
try:
bot.page.click('text="下一步(支付方式)"', timeout=5000)
bot._wait_for('text="下一步(附件清单)"', timeout=5000)
for info in payment_info:
bot.page.click("#insertPay", timeout=5000)
bot.page.wait_for_timeout(1000)
bot.page.fill("#personid2", bot.config["default_name"])
bot.page.fill("#accountname2", bot.config["default_person_id"])
bot.page.fill("#receiptdate2", format_date(str(info.get("card_date", ""))))
bot.page.fill("#localaccount2", bot.config["default_card_no"])
bot.page.fill("#receiptmoney2", str(info.get("card_amount", 0)))
bot.page.fill("#money2", str(info.get("card_amount", 0)))
bot.page.fill("#merchant2", str(info.get("merchant", "")))
bot.page.fill("#smark2", str(info.get("remark", "")))
bot.page.click("#payAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"支付方式录入失败: {e}")
bot._screenshot("normal_payment_error")
raise
bot._screenshot("normal_payment_done")
# ------------------------------------------------------------------
# 附件上传
# ------------------------------------------------------------------
def upload_normal_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]]) -> None:
"""上传普通发票附件"""
log.info(f"上传普通发票附件 (共 {len(attachment_info)} 个)...")
try:
bot.page.click("#next3", timeout=5000)
bot._wait_for("#submit2", timeout=5000)
for info in attachment_info:
attachment_file = bot.work_dir / info["filename"]
bot._wait_for("#insertAcc", timeout=20000)
bot.page.click("#insertAcc", timeout=5000)
bot._wait_for("#fjlx", timeout=5000)
if info.get("attachment_type") == "invoice":
bot.page.select_option("#fjlx", "1")
else:
bot.page.select_option("#fjlx", "2")
bot.page.fill("#fpsmxx", info.get("attachment_desc", ""))
if attachment_file and attachment_file.exists():
bot.page.set_input_files("#file", str(attachment_file))
bot.page.wait_for_timeout(1000)
bot.page.click("#cjtj", timeout=5000)
log.info("上传普通发票附件完成")
except Exception as e:
log.error(f"附件上传失败: {e}")
bot._screenshot("normal_attachment_error")
raise
bot._screenshot("normal_attachment_done")

295
src/bot/travel.py Normal file
View File

@@ -0,0 +1,295 @@
"""
差旅报销填报流程
负责差旅报销的完整填报步骤:
基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传
"""
from typing import Any
from .. import get_logger
from .base import BaseBot, format_date
log = get_logger("bot.travel")
# ------------------------------------------------------------------
# 差旅填报流程
# ------------------------------------------------------------------
def run(bot: BaseBot, travel_info: dict[str, Any]) -> None:
"""执行差旅报销填报流程
Args:
bot: 已启动并登录的 BaseBot 实例。
travel_info: 差旅信息字典(包含 basic_info、reimbursement_details 等)。
"""
log.info("开始差旅报销填报...")
log.info("填写差旅报销信息...")
basic_info = travel_info["basic_info"]
fill_travel_info(bot, basic_info)
log.info("填写差旅报销明细...")
travel_items = travel_info["reimbursement_details"]
add_travel_items(bot, travel_items)
log.info("填写差旅报销支付方式...")
payment_info = travel_info["payment_methods"]
fill_travel_payment(bot, payment_info)
log.info("填写差旅报销补助清单...")
subsidy_info = travel_info["subsidy_list"]
fill_travel_subsidy(bot, subsidy_info)
log.info("上传差旅报销附件...")
attachment_info = travel_info["attachments"]
upload_travel_attachments(bot, attachment_info)
log.info("差旅报销填报完成")
# ------------------------------------------------------------------
# 基本信息
# ------------------------------------------------------------------
def fill_travel_info(bot: BaseBot, basic_info: dict[str, Any]) -> None:
"""填写差旅报销基本信息"""
try:
bot.page.fill("#CAUSE", basic_info.get("travel_purpose", ""))
bot.page.fill("#SITE", basic_info.get("travel_location", ""))
bot.page.click("#PROJECTCODE", timeout=10000)
bot.page.wait_for_timeout(1000)
bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr")
if first_row:
first_row.click()
bot.page.wait_for_timeout(1000)
bot.page.fill("#THEKSRQ", format_date(basic_info.get("start_date", "")))
bot.page.fill("#THEJSRQ", format_date(basic_info.get("end_date", "")))
bot.page.click("#saveAndNext", timeout=5000)
bot.page.wait_for_timeout(2000)
bot._screenshot("travel_basic_done")
except Exception:
log.error("填写基本信息失败")
bot._screenshot("travel_basic_error")
# ------------------------------------------------------------------
# 差旅明细
# ------------------------------------------------------------------
def add_travel_items(bot: BaseBot, travel_items: dict[str, Any]) -> None:
"""录入差旅报销明细"""
vehicle_map = {
"火车": "01",
"汽车": "02",
"轮船": "03",
"自带车": "04",
"公务车": "05",
"飞机": "06",
"租车": "07",
"自驾车": "08",
}
try:
traffic_info = travel_items.get("transport_fee") or []
for item in traffic_info:
bot.page.click("#insertDetail", timeout=5000)
bot._wait_for('text="增加明细"', timeout=5000)
bot.page.select_option("#cost", "1")
bot.page.wait_for_timeout(500)
vehicle = item.get("vehicle_type", "")
if vehicle in vehicle_map:
bot.page.select_option("#jtgj", vehicle_map[vehicle])
bot.page.fill("#ksdd", item.get("departure_place", ""))
bot.page.fill("#jsdd", item.get("arrival_place", ""))
bot.page.fill(
'#t1 input[name="expenPwTraveldetail.MONEY"]',
str(item.get("amount", "")),
)
bot.page.fill(
'#t1 input[name="expenPwTraveldetail.HOWBILL"]',
str(item.get("bill_count", "")),
)
bot.page.fill(
'#t1 input[name="expenPwTraveldetail.SMARK"]',
str(item.get("remark", "")),
)
bot.page.click("#detailAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
hotel_info = travel_items.get("hotel_fee") or []
for item in hotel_info:
bot.page.click("#insertDetail", timeout=5000)
bot._wait_for('text="增加明细"', timeout=5000)
bot.page.select_option("#cost", "2")
bot.page.wait_for_timeout(500)
bot.page.fill("#ksrq2", format_date(str(item.get("checkin_date", ""))))
bot.page.fill("#jsrq2", format_date(str(item.get("checkout_date", ""))))
bot.page.fill("#ts2", str(item.get("days", "")))
bot.page.fill("#rs2", str(item.get("person_count", "")))
bot.page.fill(
'#t2 input[name="expenPwTraveldetail.FPMONEY"]',
str(item.get("invoice_amount", "")),
)
bot.page.fill(
'#t2 input[name="expenPwTraveldetail.MONEY"]',
str(item.get("reimburse_amount", "")),
)
bot.page.fill(
'#t2 input[name="expenPwTraveldetail.SMARK"]',
str(item.get("remark", "")),
)
bot.page.click("#detailAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
conference_info = travel_items.get("conference_fee") or []
for item in conference_info:
bot.page.click("#insertDetail", timeout=5000)
bot._wait_for('text="增加明细"', timeout=5000)
bot.page.select_option("#cost", "3")
bot.page.wait_for_timeout(500)
bot.page.fill(
'#t3 input[name="expenPwTraveldetail.HOWBILL"]',
str(item.get("bill_count", "")),
)
bot.page.fill(
'#t3 input[name="expenPwTraveldetail.MONEY"]',
str(item.get("amount", "")),
)
bot.page.fill(
'#t3 input[name="expenPwTraveldetail.SMARK"]',
str(item.get("remark", "")),
)
bot.page.click("#detailAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"录入总明细失败: {e}")
bot._screenshot("item_total_error")
raise
# ------------------------------------------------------------------
# 支付方式
# ------------------------------------------------------------------
def fill_travel_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> None:
"""录入差旅支付信息"""
try:
bot.page.click('text="下一步(支付方式)"', timeout=5000)
bot._wait_for('text="下一步(补助清单)"', timeout=5000)
for info in payment_info:
bot.page.click("#insertPay", timeout=5000)
bot.page.wait_for_timeout(1000)
bot.page.fill("#personid2", bot.config["default_name"])
bot.page.fill("#accountname2", bot.config["default_person_id"])
bot.page.fill("#receiptdate2", format_date(info["card_date"]))
bot.page.fill("#localaccount2", bot.config["default_card_no"])
bot.page.fill("#receiptmoney2", str(info["card_amount"]))
bot.page.fill("#money2", str(info["card_amount"]))
bot.page.fill("#merchant2", info.get("merchant", ""))
bot.page.fill("#smark2", info.get("remark", ""))
bot.page.click("#payAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"支付方式录入失败: {e}")
bot._screenshot("step5_error")
raise
bot._screenshot("step5_done")
# ------------------------------------------------------------------
# 补助清单
# ------------------------------------------------------------------
def fill_travel_subsidy(bot: BaseBot, subsidy_info: list[dict[str, Any]]) -> None:
"""录入差旅补助清单"""
try:
bot.page.click("#next3", timeout=5000)
bot._wait_for("#next4", timeout=5000)
for info in subsidy_info:
bot.page.click("#insertSubsidy", timeout=5000)
bot._wait_for('text="增加补助清单"', timeout=5000)
bot.page.click("#jzg3", timeout=5000)
bot.page.wait_for_timeout(500)
if info["person_name"] and info["person_name"] != "":
bot.page.fill("#seacher", info["person_name"])
elif info["person_id"] and info["person_id"] != "":
bot.page.fill("#seacher", info["person_id"])
else:
raise ValueError(f"人员编号和人员姓名不能同时为空: {info}")
bot.page.click("#cx", timeout=5000)
bot.page.wait_for_selector("div.fixed-table-loading", state="hidden", timeout=10000)
bot.page.click("#tableEmp tbody tr", timeout=10000)
bot.page.wait_for_timeout(1000)
open_bank = bot.page.input_value("#openbank1")
if not open_bank:
log.info("员工开户行未填写,默认填写中国工商银行")
bot.page.fill("#openbank1", "中国工商银行")
bot.page.fill("#startdate1", format_date(info["start_date"]))
bot.page.fill("#enddate1", format_date(info["end_date"]))
bot.page.fill("#trafficdays1", str(info["days"]))
bot.page.fill("#fooddays1", str(info["days"]))
bot.page.fill("#trafficnorm1", str(80))
bot.page.fill("#foodnorm1", str(100))
trafficmoney = int(info["days"]) * 80
foodmoney = int(info["days"]) * 100
subsidymoney = trafficmoney + foodmoney
bot.page.fill("#trafficmoney1", str(trafficmoney))
bot.page.fill("#foodmoney1", str(foodmoney))
bot.page.fill("#subsidymoney1", str(subsidymoney))
bot.page.click("#add", timeout=3000)
bot.page.wait_for_timeout(1000)
except Exception as e:
log.error(f"差旅补助清单录入失败: {e}")
bot._screenshot("subsidy_error")
raise
bot._screenshot("subsidy_done")
# ------------------------------------------------------------------
# 附件上传
# ------------------------------------------------------------------
def upload_travel_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]]) -> None:
"""上传差旅附件"""
try:
bot.page.click("#next4", timeout=5000)
bot._wait_for("#submit2", timeout=5000)
for info in attachment_info:
attachment_file = bot.work_dir / info["filename"]
bot._wait_for("#insertAcc", timeout=20000)
bot.page.click("#insertAcc", timeout=5000)
bot._wait_for("#fjlx", timeout=5000)
if info["attachment_type"] == "invoice":
bot.page.select_option("#fjlx", "1")
else:
bot.page.select_option("#fjlx", "2")
bot.page.fill("#fpsmxx", info["attachment_desc"])
if attachment_file and attachment_file.exists():
bot.page.set_input_files("#file", str(attachment_file))
bot.page.wait_for_timeout(1000)
bot.page.click("#cjtj", timeout=5000)
log.info("上传差旅附件完成")
except Exception as e:
log.error(f"差旅附件上传失败: {e}")
bot._screenshot("travel_attachment_error")
raise
bot._screenshot("travel_attachment_done")

45
src/config.py Normal file
View File

@@ -0,0 +1,45 @@
"""
配置加载
从 scripts/data/config.json 读取用户配置从环境变量读取服务端配置LLM + 系统 URL
"""
import json
import os
from pathlib import Path
_CONFIG_PATH = Path(__file__).parent.parent.parent / "scripts" / "data" / "config.json"
def load_config() -> dict[str, str | Path]:
"""加载并合并配置,缺失字段使用默认值"""
raw = {}
if _CONFIG_PATH.exists():
with open(_CONFIG_PATH, encoding="utf-8") as f:
raw = json.load(f)
project_root = _CONFIG_PATH.parent
return {
"sso_login_url": os.environ.get("SSO_LOGIN_URL", "https://tyrz.fynu.edu.cn/sso/login"),
"portal_url": os.environ.get("PORTAL_URL", "https://tyrz.fynu.edu.cn/oshall"),
"reimburse_url": os.environ.get("REIMBURSE_URL", "http://210.45.32.214:8081"),
"reimburse_page": os.environ.get("REIMBURSE_PAGE", "/expen/common/common?v=4.0"),
"travel_page": os.environ.get("TRAVEL_PAGE", "/expen/travel/travel?v=4.0"),
"username": raw.get("username", ""),
"password": raw.get("password", ""),
"default_name": raw.get("default_name", ""),
"default_card_no": raw.get("default_card_no", ""),
"default_person_id": raw.get("default_person_id", ""),
"consumable_storage": raw.get("consumable_storage", "躬行楼 C205"),
"attachment_dir": project_root / "attachments",
}
def get_llm_config() -> dict[str, str]:
"""加载 LLM 配置,优先从环境变量读取,缺失字段使用默认值"""
return {
"model": os.environ.get("LLM_MODEL", "qwen-vl-max"),
"api_base": os.environ.get("LLM_API_BASE", "http://localhost:8080/v1"),
"api_key": os.environ.get("LLM_API_KEY", "lm-studio"),
}

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

@@ -0,0 +1,60 @@
---
last_reviewed: 2026-06-11
---
# src/doc — 文档处理模块
负责发票信息提取、基于 LLM 的支付截图信息识别、差旅/普通报销信息提取、以及将数据填入 Word 出库单模板。
## 模块清单
| 文件 | 作用 |
| ------------------------ | -------------------------------------------------- |
| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 |
| `pdf.py` | PDF 图片渲染PyMuPDF |
| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态 + 差旅/普通报销信息综合提取) |
| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 |
| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 |
| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板pywin32 COM |
| `prompt.py` | LLM 提示词模板加载 |
| `prompts/` | 提示词模板文件(`invoice_system.md``travel_info_system.md``normal_info_system.md` |
## 数据流
```
PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
支付截图 → llm_extractor.py → [刷卡记录]
matcher.py按金额贪心匹配相对容差 3%
invoice.py 分类 → CSV已回填刷卡日期/卡号/金额)
fill_consumable_doc → 易耗品出库单.doc
[发票列表 + 匹配结果] → llm_extractor.py
差旅发票 → extract_travel_info() → travel_info.json
普通发票 → extract_normal_info() → normal_info.json
```
## 依赖说明
- **PyMuPDF (pymupdf)** — PDF 图片渲染
- **pywin32** — Word COM 自动化(仅 Windows
- **llama-index** — LLM 信息提取
## 注意事项
- `fill_consumable_doc.py` 依赖 Microsoft Word + COM仅 Windows 可用
- LLM 提取不会覆盖 CSV 中已有非空字段
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
- LLM 提取失败时直接报错,无正则回退
## 变更说明2026-06-11
- `llm_extractor.py` 新增 `extract_normal_info()`:综合普通发票、支付记录和匹配结果,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json`
- `llm_extractor.py``load_cache()` 扩展支持加载 `normal_info.json`
- `prompt.py` 新增 `build_normal_info_system_prompt()`:加载 `normal_info_system.md`
- `prompts/` 新增 `normal_info_system.md`:普通发票信息提取的系统提示词

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

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

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

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

View File

@@ -0,0 +1,259 @@
"""
将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。
仅写入表格数据单元格,保留原模板字体、边框与版式。
"""
from __future__ import annotations
import argparse
import re
import shutil
from datetime import date
from pathlib import Path
from typing import Any
from .. import get_logger
from ..config import load_config
from ..doc.invoice import load_invoice_csv
log = get_logger("fill_consumable_doc")
CONSUMABLE_DOC_FILENAME = "易耗品、出库单.doc"
# Word COM 常量
WD_CHARACTER = 1
# 表格统一字体宋体、五号10.5 磅)
TABLE_FONT_NAME = "宋体"
TABLE_FONT_SIZE = 10.5
def _split_name_spec(left: str) -> tuple[str, str]:
m = re.search(r"(\S+一批)\s*$", left)
if m:
return m.group(1), left[: m.start()].strip()
parts = left.split(" ", 1)
if len(parts) == 2:
return parts[0], parts[1]
return left, ""
def parse_spec_model(spec: str) -> dict[str, str]:
spec = (spec or "").strip()
if "" not in spec:
return {
"product_name": spec,
"spec": "",
"unit": "",
"qty": "",
"unit_price": "",
}
left, right = spec.split("", 1)
product_name, model_spec = _split_name_spec(left.strip())
tokens = right.split()
qty = ""
unit_price = ""
if len(tokens) >= 4 and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]):
qty, unit_price = tokens[0], tokens[1]
elif tokens and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]):
qty, unit_price = "1", tokens[0]
return {
"product_name": product_name,
"spec": model_spec,
"unit": "",
"qty": qty,
"unit_price": unit_price,
}
def _format_money(value: str | float) -> str:
"""单价、金额:固定保留两位小数。"""
if value is None or value == "":
return ""
try:
num = float(value)
except (TypeError, ValueError):
return str(value)
return f"{num:.2f}"
def _today_cn_date() -> str:
"""当前日期格式2026年5月26日"""
today = date.today()
return f"{today.year}{today.month}{today.day}"
def _apply_font(rng: Any) -> None:
"""将范围字体设为宋体五号(含数字与英文)。"""
font = rng.Font
font.Name = TABLE_FONT_NAME
font.NameFarEast = TABLE_FONT_NAME
font.NameAscii = TABLE_FONT_NAME
font.NameOther = TABLE_FONT_NAME
font.NameBi = TABLE_FONT_NAME
font.Size = TABLE_FONT_SIZE
def _set_cell_value(cell: Any, text: str) -> None:
"""写入单元格正文(不含末尾单元格标记)。"""
rng = cell.Range
rng.MoveEnd(WD_CHARACTER, -1)
rng.Text = "" if text is None else str(text)
_apply_font(rng)
def _normalize_table_font(tbl: Any) -> None:
"""填写完成后统一整张表的字体。"""
for row in tbl.Rows:
for cell in row.Cells:
rng = cell.Range
rng.MoveEnd(WD_CHARACTER, -1)
_apply_font(rng)
def _replace_date_in_doc(doc: Any, new_date: str) -> None:
"""仅替换表头段落中的日期文字,不改动段落其余部分。"""
if not new_date:
return
try:
para = doc.Paragraphs(3)
except Exception:
return
rng = para.Range
text = rng.Text.replace("\r", "").replace("\x07", "")
m = re.search(r"\d{4}\d{1,2}月\d{1,2}日", text)
if not m:
return
start = rng.Start + m.start()
end = rng.Start + m.end()
doc.Range(Start=start, End=end).Text = new_date
def fill_consumable_doc(
csv_path: str | Path,
doc_path: str | Path,
config: dict[str, Any] | None = None,
backup: bool = True,
) -> Path:
csv_path = Path(csv_path)
doc_path = Path(doc_path)
if config is None:
config = load_config()
invoices = load_invoice_csv(csv_path.parent / "invoice_summary.csv") or []
if backup:
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
doc = word.Documents.Open(str(doc_path.resolve()))
try:
_replace_date_in_doc(doc, _today_cn_date())
tbl = doc.Tables(1)
storage = config.get("consumable_storage", "躬行楼 C205")
for i, inv in enumerate(invoices):
row_idx = i + 2
if row_idx > tbl.Rows.Count:
break
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
# 金额填写刷卡金额,单价由刷卡金额反算
amount = _format_money(card_amount)
unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount)
# 数量:去掉前导零;若无数量则默认为 1
qty = str(qty_val) if qty_val > 0 else "1"
values = [
str(inv.get("index", i + 1)),
parsed["product_name"],
parsed["spec"],
parsed["unit"],
qty,
unit_price,
amount,
"", # 购货人签字 — 保持空白
storage,
"", # 领用人签字 — 保持空白
"", # 备注 — 保持空白,避免撑破版式
]
for col_idx, val in enumerate(values, start=1):
_set_cell_value(tbl.Cell(row_idx, col_idx), str(val))
_normalize_table_font(tbl)
doc.Save()
finally:
doc.Close()
word.Quit()
finally:
pythoncom.CoUninitialize()
return doc_path
def fill_consumable_from_template(
csv_path: str | Path,
template_path: str | Path,
output_path: str | Path,
config: dict[str, Any] | None = None,
) -> Path:
"""从模板复制并填写出库单Web 会话每次从模板重新生成)。"""
template_path = Path(template_path)
output_path = Path(output_path)
if not template_path.exists():
raise FileNotFoundError(f"出库单模板不存在: {template_path}")
shutil.copy2(template_path, output_path)
return fill_consumable_doc(csv_path, output_path, config=config, backup=False)
def main() -> None:
root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description="将发票 CSV 填入易耗品出库单")
parser.add_argument("--csv", default=str(root / "invoice_summary.csv"))
parser.add_argument("--doc", default=str(root / "易耗品、出库单.doc"))
parser.add_argument("--config", default=str(root / "scripts" / "data" / "config.json"))
parser.add_argument("--no-backup", action="store_true")
args = parser.parse_args()
cfg = None
if Path(args.config).exists():
import json
with open(args.config, encoding="utf-8") as f:
cfg = {**load_config(), **json.load(f)}
out = fill_consumable_doc(args.csv, args.doc, config=cfg, backup=not args.no_backup)
print(f"已填写并保存: {out}")
if __name__ == "__main__":
main()

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

@@ -0,0 +1,233 @@
"""发票数据模型与 CSV 工具
定义 CSV 列结构,提供发票分类和 CSV 读写功能。
对外接口:
load_csv(path) 读取支付记录 CSV
save_csv(payment_records, path) 保存支付记录 CSV
save_invoice_csv(payment_records, path) 保存发票级别 CSV
save_application_json(applications, path) 保存出差申请单 JSON
"""
import csv
import json
from pathlib import Path
from .. import get_logger
log = get_logger("invoice")
# CSV 列名
INVOICE_LEVEL_COLUMNS = [
"index",
"invoice_type",
"invoice_number",
"invoice_date",
"item_name",
"spec_model",
"total_amount",
"seller_name",
"departure",
"arrival",
"train_no",
"ride_date",
"seat_class",
"person_name",
"card_date",
"card_no",
"card_amount",
"remark",
"person_id",
]
PAYMENT_RECORD_COLUMNS = [
"index",
"card_date",
"card_no",
"card_amount",
"relative_invoice_count",
"invoice_detail",
"remark",
"_matched_invoices",
"person_id",
]
def _is_application_document(invoice_type: str) -> bool:
"""判断是否为出差事前申请单"""
return invoice_type == "application"
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("invoice_type", "general")
if _is_application_document(inv_type):
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general, "application": application}
# ------------------------------------------------------------------
# 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,
required_columns: list[str],
label: str = "CSV",
) -> list[dict[str, str]] | None:
"""通用 CSV 读取器:按 required_columns 校验列,失败返回 None"""
try:
with open(csv_path, encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames or []
missing = [c for c in required_columns if c not in fieldnames]
if missing:
log.error(f"{label} 缺少必要列: {missing}")
return None
return [row for row in reader]
except FileNotFoundError:
log.error(f"{label} 文件不存在: {csv_path.name}")
return None
except Exception as e:
log.error(f"{label} 读取失败: {e}")
return None
def load_csv(csv_path: Path) -> list[dict[str, str]] | None:
"""读取支付记录 CSV 为 dict 列表,失败返回 None"""
return _load_csv(csv_path, PAYMENT_RECORD_COLUMNS, "CSV")
def load_invoice_csv(csv_path: Path) -> list[dict[str, str]] | None:
"""读取发票级别 CSV 为 dict 列表(每行一张发票),失败返回 None"""
return _load_csv(csv_path, INVOICE_LEVEL_COLUMNS, "发票 CSV")
def save_csv(
payment_records: list[dict[str, str]],
output_path: str | Path = "payment_records.csv",
) -> None:
"""将支付记录列表保存为 CSV以支付记录为主键
每条支付记录包含:
- card_date, card_no, card_amount支付信息
- relative_invoice_count, invoice_detail发票聚合信息
- _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):
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("card_date", ""),
record.get("card_no", ""),
record.get("card_amount", ""),
record.get("relative_invoice_count", str(len(matched_invoices))),
record.get("invoice_detail", ""),
record.get("remark", ""),
invoices_json,
record.get("person_id", ""),
]
)
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
"""
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)
if _is_application_document(clean_inv.get("invoice_type", "")):
continue
writer.writerow(
[
idx,
clean_inv.get("invoice_type", ""),
clean_inv.get("invoice_number", ""),
clean_inv.get("invoice_date", ""),
clean_inv.get("item_name", ""),
clean_inv.get("spec_model", ""),
clean_inv.get("total_amount", ""),
clean_inv.get("seller_name", ""),
clean_inv.get("departure", ""),
clean_inv.get("arrival", ""),
clean_inv.get("train_no", ""),
clean_inv.get("ride_date", ""),
clean_inv.get("seat_class", ""),
clean_inv.get("person_name", ""),
record.get("card_date", ""),
record.get("card_no", ""),
record.get("card_amount", ""),
record.get("remark", ""),
record.get("person_id", ""),
]
)
idx += 1
log.info(f"发票级别 CSV 已保存: {csv_path.name}")
def save_application_json(
applications: list[dict[str, str]],
output_path: str | Path = "travel_applications.json",
) -> None:
"""将出差事前申请单列表保存为独立 JSON 文件
使用 JSON 保留完整嵌套结构(如出差人员信息的列表形式),
避免 CSV 扁平化导致的字段丢失。
"""
json_path = Path(output_path)
with open(json_path, "w", encoding="utf-8") as f:
json.dump(applications, f, ensure_ascii=False, indent=2)
log.info(f"出差申请单 JSON 已保存: {json_path.name}")

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

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

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

@@ -0,0 +1,396 @@
"""发票与支付记录匹配
将提取到的发票数据与支付记录进行金额匹配,
输出以支付记录为主键的结果列表。
支付记录由统一提取模块extractor根据 LLM 返回的「invoice_type」字段分类而来
不再按文件类型假设文档类型。
## 业务约束
- 发票数 >= 付款记录数(最少一张发票对应一张付款记录)
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
- 若发票数 == 付款数,走一对一匹配,无需一对多
## 匹配流程
1. 接收分类好的发票和支付记录列表
2. 解析发票和刷卡记录的金额,进行总额校验
- 发票总额 < 刷卡总额时发出 warning
3. 按金额降序排序
4. 文件名匹配(最高优先级):发票和刷卡记录的文件名(不含后缀)一致时直接匹配
5. 根据数量关系选择匹配策略:
- 数量相等 → 一对一匹配:按金额从大到小依次配对,相对容差内即匹配
- 发票更多 → 一对多匹配:对每张刷卡记录贪心凑金额,相对容差内结束
6. 构建以支付记录为主键的结果列表
7. 未匹配的发票单独作为一条记录(无刷卡信息)
8. 清理内部字段,输出支付记录列表
## 容差计算
使用相对容差(默认 3%),以刷卡金额为基准:
- ¥2900 发票 vs ¥2850 刷卡 → 差 ¥50容差 ¥85.5 → 匹配成功
- ¥100 发票 vs ¥95 刷卡 → 差 ¥5容差 ¥3.0 → 不匹配(需精确匹配或调整)
## 一对多匹配细节
- 对每张刷卡记录,维护 remaining剩余待匹配金额
- 遍历未分配的发票(按金额降序):
- 若发票金额 + 容差 >= remaining视为最后一张匹配后退出
- 否则发票金额不超过 remaining + 容差即可匹配
- 匹配后 remaining 为负且超出容差时回滚最后一张发票
- 每张发票只会被分配一次
## 对外接口
match_invoices_to_cards(invoices, cards, tolerance) -> list[dict]
"""
from pathlib import Path
from typing import Any
from .. import get_logger
log = get_logger("matcher")
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 _build_invoice_summary(invoices: list[dict[str, Any]]) -> str:
"""将多张发票信息汇总为备注字符串"""
parts = []
for inv in invoices:
inv_type = inv.get("invoice_type", "unknown")
amount = inv.get("total_amount", "unknown")
if inv_type == "train":
label = inv.get("person_name") or inv.get("invoice_number", "unknown")
elif inv_type == "hotel":
label = "hotel"
else:
label = inv.get("item_name") or inv.get("invoice_number", "unknown")
parts.append(f"{inv_type}[{label}{amount}")
return " | ".join(parts)
def _relative_tolerance(base: float, rate: float = 0.03) -> float:
"""根据基准金额计算相对容差(默认 3%"""
return abs(base) * rate
def match_invoices_to_cards(
invoices: list[dict[str, Any]],
cards: list[dict[str, Any]] | None = None,
tolerance: float = 0.03,
) -> list[dict[str, Any]]:
"""将发票与支付记录按金额匹配,输出以支付记录为主键的结果列表
支付记录由统一提取模块extractor根据 LLM 返回的「invoice_type」字段分类而来。
业务约束:
- 发票数 >= 付款记录数
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
- 若发票数 == 付款数,一对一匹配,无需一对多
Args:
invoices: 发票列表,需包含 "total_amount" 字段
cards: 支付记录列表,需包含 "card_amount" 字段(由 extractor 分类提供)
tolerance: 金额匹配容差比例(默认 0.03 = 3%
Returns:
以支付记录为主键的结果列表,每条记录包含:
- card_date, card_no, card_amount支付信息
- 关联发票列表_matched_invoices
- 发票详情备注
- 未匹配发票单独作为一条无刷卡信息的记录
"""
if not cards:
log.warning("无支付记录可供匹配,发票将保持原状")
return _invoices_to_records(invoices)
# 解析金额
for card in cards:
card["_amount"] = _safe_float(card.get("card_amount"))
for inv in invoices:
inv["_amount"] = _safe_float(inv.get("total_amount"))
# 数据校验
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.03 表示 3%
匹配优先级(从高到低):
1. 文件名匹配:发票和刷卡记录的文件名(不含后缀)一致时直接匹配
2. 精确匹配:金额差 <= 0.01 元
3. 一对一 / 一对多贪心匹配:按金额容差匹配
"""
result: dict[int, list[int]] = {}
assigned: set[int] = set()
# ---- 阶段 0文件名匹配最高优先级----
_match_by_filename(invoices, cards, assigned, result)
if len(invoices) == len(cards):
_match_one_to_one(invoices, cards, tolerance, assigned, result)
else:
_match_one_to_many(invoices, cards, tolerance, assigned, result)
return result
def _match_by_filename(
invoices: list[dict[str, Any]],
cards: list[dict[str, Any]],
assigned: set[int],
result: dict[int, list[int]],
) -> None:
"""文件名匹配:发票和刷卡记录的文件名(不含后缀)一致时直接匹配"""
for card_idx, card in enumerate(cards):
card_name = card.get("_source_file", "")
if not card_name:
continue
card_stem = Path(card_name).stem
for idx, inv in enumerate(invoices):
if idx in assigned:
continue
inv_name = inv.get("_source_file", "")
if not inv_name:
continue
inv_stem = Path(inv_name).stem
if inv_stem == card_stem:
assigned.add(idx)
result[card_idx] = [idx]
log.info(f"[文件名匹配] {inv_name}{card_name}")
break
def _match_one_to_one(
invoices: list[dict[str, Any]],
cards: list[dict[str, Any]],
tolerance: float,
assigned: set[int],
result: dict[int, list[int]],
) -> None:
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对"""
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('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
f"{card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}"
)
else:
log.warning(
f"[一对一] 金额偏差超出容差: "
f"{inv.get('invoice_number', 'unknown')} ¥{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:
"""一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配"""
# ---- 阶段 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('invoice_number', 'unknown')} ¥{inv_amount:.2f} "
f"{card.get('_source_file', 'unknown')} ¥{card_amount:.2f}"
)
break
# ---- 阶段 2贪心匹配仅处理未精确匹配的刷卡记录----
for card_idx, card in enumerate(cards):
if card_idx in result:
continue
card_amount = card["_amount"]
if card_amount <= 0:
continue
card_tol = _relative_tolerance(card_amount, tolerance)
remaining = card_amount
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
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('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
f"{card.get('_source_file', 'unknown')} ¥{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_date": card.get("card_date", ""),
"card_no": card.get("card_no", ""),
"card_amount": str(card["_amount"]),
"relative_invoice_count": str(len(matched_invs)),
"invoice_detail": _build_invoice_summary(matched_invs),
"remark": "",
"_source_file": card.get("_source_file", ""),
"_matched_invoices": matched_invs,
}
records.append(record)
# 未匹配的发票,单独作为记录
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 = {
"card_date": "",
"card_no": "",
"card_amount": "",
"relative_invoice_count": "1",
"invoice_detail": _build_invoice_summary([inv]),
"remark": "unmatched",
"_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 = {
"card_date": "",
"card_no": "",
"card_amount": "",
"relative_invoice_count": "1",
"invoice_detail": _build_invoice_summary([inv]),
"remark": "",
"_matched_invoices": [inv],
}
records.append(record)
return records

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

@@ -0,0 +1,46 @@
"""PDF 图片渲染
从 PDF 发票文件中渲染为图片供多模态 LLM 使用。
对外接口:
render_pdf_to_images(filepath, dpi) -list[str] 渲染 PDF 为图片字节
"""
import base64
from pathlib import Path
import fitz
from .. import get_logger
log = get_logger("pdf")
def render_pdf_to_images(filepath: Path, dpi: int = 300) -> list[str]:
"""将 PDF 渲染为图片,返回 base64 编码的 JPEG 字符串列表。
Args:
filepath: PDF 文件路径。
dpi: 渲染分辨率(默认 150平衡质量与速度
Returns:
base64 编码的 JPEG 图片字符串列表(每页一个)。
"""
images = []
try:
doc = fitz.open(filepath)
zoom = dpi / 72.0 # 72 DPI 是 fitz 默认
matrix = fitz.Matrix(zoom, zoom)
for page in doc:
pix = page.get_pixmap(matrix=matrix)
jpg_bytes = pix.tobytes("jpg")
b64 = base64.b64encode(jpg_bytes).decode("utf-8")
images.append(b64)
doc.close()
log.info(f"PDF 渲染成功: {filepath.name} ({len(images)} 页, {dpi} DPI)")
except Exception as e:
log.error(f"PDF 渲染失败 {filepath.name}: {e}")
return images

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

@@ -0,0 +1,31 @@
"""
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_travel_info_system_prompt() -> str:
"""构建差旅信息提取系统提示词。"""
return _load_prompt("travel_info_system.md")
def build_normal_info_system_prompt() -> str:
"""构建普通发票信息提取系统提示词。"""
return _load_prompt("normal_info_system.md")

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

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

View File

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

View File

@@ -0,0 +1,78 @@
# 普通报销信息提取系统提示词
你是财务报销信息提取助手。根据发票信息和付款记录,提取报销相关的结构化数据,并以严格符合类型要求的 JSON 格式返回。
> **核心原则**:类型约束为最高优先级规则,任何情况下不得违反。
## 强制类型约束
以下类型规则为最高优先级,任何情况下不得违反。
### 1. 根节点字段(共 4 个,类型不可变更)
| 字段名 | 强制类型 | 空值处理 |
| --- | --- | --- |
| `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 |
| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含下述 2 个子字段 |
| `payment_methods` | 数组 (list) | 必填,无数据时赋值为 `[]` |
| `attachments` | 数组 (list) | 必填,无数据时赋值为 `[]` |
### 2. `reimbursement_details` 子字段(共 2 个)
| 字段名 | 强制类型 | 空值处理 |
| --- | --- | --- |
| `total_invoices` | 数字 (int) | 必填 |
| `total_amount` | 数字 (float) | 必填 |
### 3. 禁止行为
* 省略任何根节点字段或 `reimbursement_details` 的子字段
*`payment_methods``attachments` 赋值为 `null`、字符串、数字或对象
*`reimbursement_details` 中添加未定义的子字段
* 合并不同模块的数组数据
**正确示例**
```json
{
"basic_info": {...},
"reimbursement_details": {...},
"payment_methods": [],
"attachments": [{"filename":"发票.pdf", ...}]
}
```
**错误示例**
```json
{
"basic_info": {...},
"reimbursement_details": {...},
"payment_methods": null,
}
```
## 输入数据说明
你会收到以下数据:
1. **发票信息**:包含购买物品的发票信息
2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息
需要提取的信息:
1. `basic_info`:(必填,每一项都必须填,给出合理的猜测)
1. `reimbursement_description`根据所有信息写一句20字以内的报销说明
2. `reimbursement_details`:(至少有一项)
1. `total_invoices`: 发票的总份数
2. `total_amount`:填写付款记录总金额
3. `payment_methods`:(多少笔支付记录就有多少条;多项请采用上述通用 JSON 数组格式)
1. `card_date`:根据付款记录,格式 YYYY-M-D
2. `card_amount`:根据付款记录填写,单位为元,数字
3. `merchant`:根据发票信息推测商户信息
4. `remark`:说明该笔付款关联的发票信息
4. `attachments`:(必填,用户已经告诉你所有文件了`【源文件: {filename}】`"invoice_type": "payment"的不作为附件)
1. `filename`: 严格使用用户提供的原始文件名,不得修改任何字符
2. `attachment_type`从以下两个选项中选择invoice、other
3. `attachment_desc`:简要描述该文件的基本信息
## 最终输出要求
* 仅输出纯 JSON 字符串,不包含任何思考过程、解释文字或 Markdown 标记
* JSON 语法必须正确,无多余逗号、引号等错误
* 严格遵守所有强制性类型约束,违反类型要求的输出视为无效

View File

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

View File

@@ -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,30 +18,37 @@ 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",
help="信息门户登录账号(覆盖 scripts/config.json",
)
parser.add_argument(
"-p", "--password",
"-p",
"--password",
default=None,
help="信息门户登录密码(覆盖 config.json",
help="信息门户登录密码(覆盖 scripts/config.json",
)
parser.add_argument(
"--cache-dir",
default=None,
help="发票缓存目录(包含 .invoice_cache 子目录,默认: scripts/data",
)
args = parser.parse_args()
@@ -52,9 +56,10 @@ def main():
step=args.step,
username=args.username,
password=args.password,
cache_dir=args.cache_dir,
)
sys.exit(exit_code)
if __name__ == "__main__":
main()
main()

219
src/pipeline.py Normal file
View File

@@ -0,0 +1,219 @@
"""
报销全流程编排
将发票提取 → 差旅/普通信息提取 → 浏览器填报串联为一条管道,
数据在内存中流转,同时生成 CSV 中间产物。
发票类型区分:
- 差旅发票train/hotel不生成易耗品出库单走差旅报销流程
- 普通发票general生成易耗品出库单走普通报销流程
数据流变更2026-06-11
在发票提取和匹配完成后立即判断报销类型(差旅/普通),
差旅调用 LLM 提取 travel_info.json普通预留 normal_info.json。
Bot 仅负责接收信息并填报,不再承担信息提取职责。
"""
import json
from pathlib import Path
from typing import Any, cast
from . import get_logger
from .config import load_config
from .doc.extractor import extract_invoices
from .doc.invoice import (
save_application_json,
save_invoice_csv,
)
from .doc.invoice import (
save_csv as save_payment_csv,
)
from .doc.llm_extractor import (
CACHE_DIR_NAME,
extract_normal_info,
extract_travel_info,
load_cache,
)
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("invoice_type", "general")
if inv_type == "application":
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general, "application": application}
log = get_logger("pipeline")
def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
"""从缓存目录读取发票数据并按类型分组"""
from .doc.llm_extractor import load_cache
cache_map = load_cache(cache_path)
invoices = [data for data in cache_map.values() if data.get("invoice_type") not in ("application", "payment")]
return _classify_invoice_batch(invoices)
def _extract_travel_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None:
"""当存在差旅发票时,调用 LLM 提取差旅信息并缓存。
Returns:
差旅信息字典,非差旅时返回 None。
"""
if not groups.get("travel"):
return None
from .doc.llm_extractor import CACHE_DIR_NAME, load_cache
# 检查缓存是否已有
cache_map = load_cache(cache_path)
if cache_map.get("travel_info"):
log.info("使用已有差旅信息缓存")
return cast(dict[str, Any] | None, cache_map["travel_info"])
log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=cache_path)
# 保存到缓存
cache_dir = cache_path / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
json.dump(travel_info, f, ensure_ascii=False, indent=2)
log.info("差旅信息已保存到缓存")
return travel_info
def _extract_normal_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None:
"""当存在普通发票时,调用 LLM 提取普通报销信息并缓存。
Returns:
普通报销信息字典,非普通时返回 None。
"""
if not groups.get("general"):
return None
# 检查缓存是否已有
cache_map = load_cache(cache_path)
if cache_map.get("normal_info"):
log.info("使用已有普通发票信息缓存")
return cast(dict[str, Any] | None, cache_map["normal_info"])
log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=cache_path)
# 保存到缓存
cache_dir = cache_path / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
json.dump(normal_info, f, ensure_ascii=False, indent=2)
log.info("普通发票信息已保存到缓存")
return normal_info
def run_pipeline(
step: str = "all",
username: str | None = None,
password: str | None = None,
cache_dir: str | None = None,
) -> int:
"""执行报销流程
Args:
step: all | invoice | submit
username: 覆盖 config.json 中的用户名
password: 覆盖 config.json 中的密码
cache_dir: 发票缓存目录(包含 .invoice_cache 子目录)
"""
config = load_config()
if username:
config["username"] = username
if password:
config["password"] = password
project_dir = Path(__file__).parent.parent
cache_path = Path(cache_dir) if cache_dir else project_dir / "scripts" / "data"
# --------------------------------------------------
# Step 1: 发票提取 + 类型判断 + 信息提取
# --------------------------------------------------
payment_records: list[dict[str, str]] | None = None
groups: dict[str, list[dict[str, str]]] | None = None
travel_info: dict[str, Any] | None = None
normal_info: dict[str, Any] | None = None
if step in ("all", "invoice"):
log.info("=" * 60)
log.info("[1/2] 发票提取")
log.info("=" * 60)
payment_records, applications, groups = extract_invoices(str(cache_path))
if not payment_records:
log.error("未提取到任何发票数据")
return 1
save_payment_csv(payment_records, cache_path / "payment_records.csv")
save_invoice_csv(payment_records, cache_path / "invoice_summary.csv")
if applications:
save_application_json(applications, cache_path / "travel_applications.json")
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])}")
# 发票提取完成后立即判断类型
is_travel = bool(groups["travel"]) and not bool(groups["general"])
if is_travel:
travel_info = _extract_travel_info_if_needed(groups, cache_path)
else:
normal_info = _extract_normal_info_if_needed(groups, cache_path)
if step == "invoice":
log.info("[1/2] 发票提取 完成")
return 0
# --------------------------------------------------
# Step 2: 浏览器填报
# --------------------------------------------------
if step in ("all", "submit"):
log.info("=" * 60)
log.info("[2/2] 报销提交")
log.info("=" * 60)
from .bot import run_bot
if groups is None:
groups = _classify_from_cache(cache_path)
is_travel = bool(groups["travel"]) and not bool(groups["general"])
if is_travel:
if travel_info is None:
travel_info = _extract_travel_info_if_needed(groups, cache_path)
log.info("检测到纯差旅发票,使用差旅报销模式")
run_bot(config, work_dir=cache_path, travel_info=travel_info)
else:
if normal_info is None:
normal_info = _extract_normal_info_if_needed(groups, cache_path)
log.info("检测到普通发票,使用普通报销模式")
run_bot(config, work_dir=cache_path, normal_info=normal_info)
if step == "submit":
log.info("[2/2] 报销提交 完成")
return 0
log.info("=" * 60)
log.info("全流程执行完毕")
log.info("=" * 60)
return 0

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

@@ -0,0 +1,104 @@
---
last_reviewed: 2026-06-11
---
# src/web 模块设计说明
## 设计思路
`src/web` 是一个基于 Flask 的轻量级 Web 界面,为财务报销自动化管道提供可视化操作入口。核心设计原则:
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5不引入构建工具保持单页应用轻量可维护。
## 文件结构
```
src/web/
├── app.py # Flask 应用入口,路由、管道编排、日志收集
├── templates/
│ ├── index.html # PC 端主界面(上传、配置、处理、编辑、提交)
│ └── mobile_upload.html # 移动端上传页面(拍照/相册选择)
└── static/
├── css/
│ └── index.css # 全局样式(上传区、日志面板、可编辑表格)
└── js/
└── index.js # 前端逻辑上传、SSE 日志、表格编辑、二维码同步)
```
## 数据流
```mermaid
graph TD
A[用户上传文件] --> B[创建 session]
B --> C[文件写入 uploads/<sid>/]
C --> D[后台线程执行管道]
D --> E["extract_invoices()"]
E --> F["enrich_with_llm()"]
F --> G["save_csv()"]
G --> H["结果写入 session 目录"]
H --> I["invoice_summary.csv"]
H --> J["invoice_groups.json"]
H --> K["result.json"]
H --> L["session.log"]
E --> M{"判断报销类型"}
M -->|差旅| N["extract_travel_info()"]
M -->|普通| O["extract_normal_info()"]
N --> P["travel_info.json"]
O --> Q["normal_info.json"]
K --> R["前端 SSE 轮询 → 显示完成状态"]
R --> S["前端加载 CSV → 可编辑表格"]
S --> T["用户修改后保存"]
T --> U["用户点击提交"]
U --> V["run_financial_submit()"]
V --> W["bot 自动填报财务系统"]
W --> X["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
W --> Y["普通模式: normal_info.json → 基本信息 → 录入明细 → 支付信息 → 上传附件"]
```
## API 路由
| 方法 | 路径 | 功能 |
|------|------|------|
| GET | `/` | 主界面 |
| POST | `/api/session` | 创建会话,返回 session_id |
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
| 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 文档),走普通报销流程
`extract_invoices()` 在提取阶段完成分类,结果保存为 `invoice_groups.json`(包含 `travel_count``general_count``application_count`)。后续步骤(出库单生成、财务填报)统一从该文件读取分类结果,避免重复解析 CSV 和 JSON 字段。
### LLM 信息提取2026-06-11
发票提取和匹配完成后,根据类型分流调用 LLM 提取结构化报销信息:
- **差旅发票**:调用 `extract_travel_info()`,提取出差事由、地点、时间、交通/住宿明细、补助清单、支付方式、附件清单,缓存为 `travel_info.json`
- **普通发票**:调用 `extract_normal_info()`,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json`
Bot 填报时优先使用 LLM 提取的信息(`travel_info`/`normal_info`),降级时从缓存加载原始发票数据。
### 移动端同步
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的图片通过 `syncFiles()` 轮询同步到 PC 端内存中的 `imgFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
### 配置管理
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端支持通过上传 `config.json` 快速填充配置表单。

722
src/web/app.py Normal file
View File

@@ -0,0 +1,722 @@
"""
财务报销自动化 — Web 界面
用户上传 PDF 发票,配置账号信息,自动完成:
1. 发票提取 2. 浏览器填报(可选)
发票类型区分:
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- 普通发票:生成易耗品出库单,走普通报销流程
启动: uv run python src/web/app.py
访问: http://localhost:5000
"""
import json
import logging
import sys
import threading
import time
import uuid
from pathlib import Path
from typing import Any, cast
from urllib.parse import quote
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
# 确保项目根目录在 sys.path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
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 src.doc.invoice import ( # noqa: E402, I001
load_csv,
load_invoice_csv,
save_csv as save_payment_csv,
save_invoice_csv,
save_application_json,
)
fill_log = get_logger("fill_consumable_doc")
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
SESSION_LOG_FILE = "session.log"
SESSION_RESULT_FILE = "result.json"
INVOICE_GROUPS_FILE = "invoice_groups.json"
def _save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
"""保存发票分类结果到 session 目录的 JSON 文件"""
groups_path = session_dir / INVOICE_GROUPS_FILE
# 只保存各组的数量统计,避免重复存储完整发票数据
data = {
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
}
with open(groups_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _load_invoice_groups(session_dir: Path) -> dict[str, int] | None:
"""从 session 目录加载发票分类统计"""
groups_path = session_dir / INVOICE_GROUPS_FILE
if not groups_path.exists():
return None
try:
with open(groups_path, encoding="utf-8") as f:
return cast(dict[str, int] | None, json.load(f))
except Exception:
return None
class _SSELogHandler(logging.Handler):
"""将日志写入指定文件(线程安全)"""
def __init__(self, log_path: Path):
super().__init__()
self._lock = threading.Lock()
self._file = open(log_path, "w", encoding="utf-8")
def emit(self, record: logging.LogRecord) -> None:
try:
msg = self.format(record) + "\n"
with self._lock:
self._file.write(msg)
self._file.flush()
except Exception:
pass
def close_file(self) -> None:
try:
self._file.close()
except Exception:
pass
def _install_log_collector(session_dir: Path) -> _SSELogHandler:
"""安装日志收集器到 app.* 模块"""
log_path = session_dir / SESSION_LOG_FILE
fmt = logging.Formatter(
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
"%Y-%m-%d %H:%M:%S",
)
handler = _SSELogHandler(log_path)
handler.setFormatter(fmt)
handler.setLevel(logging.INFO)
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.addHandler(handler)
return handler
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()
# ================================================================
# 出库单填写
# ================================================================
def _load_session_config(session_dir: Path) -> dict[str, Any]:
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 config
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 _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 _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""根据 CSV 填写易耗品出库单,供会话目录下载。
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
"""
if not CONSUMABLE_TEMPLATE.exists():
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
# 从统一的分类结果读取,避免重复解析 CSV/JSON
groups = _load_invoice_groups(session_dir)
if groups is None:
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
if not groups.get("general_count", 0):
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
csv_path = _resolve_payment_csv(session_dir)
if csv_path is None:
return {"ok": False, "error": "未找到发票 CSV"}
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_log.info("出库单填写完成")
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
except ImportError:
fill_log.error("填写出库单需要 pywin32请执行: pip install pywin32")
return {"ok": False, "error": "服务器未安装 pywin32无法生成 Word 出库单"}
except Exception as e:
fill_log.exception("填写出库单失败: %s", e)
return {"ok": False, "error": str(e)}
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", "未知错误")
# ================================================================
# 管道入口
# ================================================================
def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
在发票提取和匹配完成后立即判断报销类型:
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
- 普通发票无需额外提取normal_info.json 待实现)
"""
start = time.time()
# ---- Step 1: 发票提取 ----
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
# 保存 CSV支付记录级别供 bot/出库单使用)和发票级别(供人工参考)
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
# 出差申请单单独保存
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
# 保存分类结果(供后续步骤统一读取)
_save_invoice_groups(session_dir, groups)
# ---- Step 2: 差旅/普通信息提取 ----
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
if is_travel:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache
# 检查缓存是否已有
cache_map = load_cache(session_dir)
if not cache_map.get("travel_info"):
fill_log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
json.dump(travel_info, f, ensure_ascii=False, indent=2)
fill_log.info("差旅信息已保存到缓存")
else:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache
# 检查缓存是否已有
cache_map = load_cache(session_dir)
if not cache_map.get("normal_info"):
fill_log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
json.dump(normal_info, f, ensure_ascii=False, indent=2)
fill_log.info("普通发票信息已保存到缓存")
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": invoice_count,
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
# 发票类型统计
"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_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
- 纯差旅发票差旅报销模式TODO
- 含普通发票:普通报销模式
"""
csv_path = session_dir / "payment_records.csv"
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from src.bot import run_bot_web
# 从统一的分类结果读取,避免重复解析
groups = _load_invoice_groups(session_dir)
if groups:
if groups.get("travel_count", 0) and not groups.get("general_count", 0):
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
# TODO: 差旅报销填报流程
else:
fill_log.info("检测到普通发票,使用普通报销模式")
run_bot_web(config, session_dir)
return {"ok": True}
# ================================================================
# Flask 路由
# ================================================================
@app.route("/")
def index() -> Any:
return render_template("index.html")
@app.route("/api/session", methods=["POST"])
def create_session() -> Any:
"""创建上传会话,返回 session_id"""
sid = uuid.uuid4().hex[:12]
session_dir = UPLOAD_BASE / sid
session_dir.mkdir(parents=True, exist_ok=True)
return jsonify({"session_id": sid})
@app.route("/api/upload/<session_id>", methods=["POST"])
def upload_file(session_id: str) -> Any:
"""上传 PDF 或图片"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
safe_name = Path(f.filename).name
f.save(str(session_dir / safe_name))
return jsonify({"ok": True, "filename": safe_name})
@app.route("/api/files/<session_id>", methods=["GET"])
def list_files(session_id: str) -> Any:
"""列出会话目录中的文件"""
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}"))
return jsonify({"pdfs": pdfs, "images": imgs})
@app.route("/api/process/<session_id>", methods=["POST"])
def start_process(session_id: str) -> Any:
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
# 读取配置
config = _build_web_config(body)
# 写入配置到会话目录
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
# 在后台线程执行
handler = _install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
result = run_pipeline_web(session_dir, config)
except BaseException as e:
result = {"ok": False, "error": str(e)}
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(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
except Exception:
pass
_remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@app.route("/api/logs/<session_id>")
def stream_logs(session_id: str) -> Any:
"""SSE 日志流"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
def generate() -> Any:
# 先发送已有日志
log_file = session_dir / SESSION_LOG_FILE
last_size = 0
start_time = time.time()
timeout = 600 # 10 分钟超时
while time.time() - start_time < timeout:
if log_file.exists():
current_size = log_file.stat().st_size
if current_size > last_size:
with open(log_file, encoding="utf-8", errors="replace") as f:
f.seek(last_size)
chunk = f.read()
if chunk:
yield f"data: {_escape_sse(chunk)}\n\n"
last_size = current_size
# 也通过队列发送实时日志
# 检查是否完成
result_file = session_dir / SESSION_RESULT_FILE
if result_file.exists():
with open(result_file, encoding="utf-8") as f:
result = json.load(f)
yield f"data: {_escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
break
time.sleep(0.5)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.route("/api/download/<session_id>/<filename>")
def download_file(session_id: str, filename: str) -> Any:
"""下载生成的文件"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 防止路径穿越
safe_name = Path(filename).name
filepath = session_dir / safe_name
if not filepath.exists():
return jsonify({"error": "文件不存在"}), 404
if safe_name.endswith(".doc"):
mimetype = "application/msword"
elif safe_name.endswith(".csv"):
mimetype = "text/csv; charset=utf-8"
else:
mimetype = "application/octet-stream"
disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}"
return Response(
filepath.read_bytes(),
mimetype=mimetype,
headers={"Content-Disposition": disposition},
)
@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) -> Any:
"""读取发票数据并返回 JSON供前端表格编辑"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 优先读取支付记录 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]
rows = load_csv(csv_path)
if rows is None:
rows = load_invoice_csv(csv_path)
if rows is not None:
fallback_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
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})
return jsonify({"error": "未找到发票数据,请先处理"}), 404
@app.route("/api/save/<session_id>", methods=["POST"])
def save_invoice_data(session_id: str) -> Any:
"""保存前端编辑后的发票数据到 CSV"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
data = body.get("data", [])
csv_filename = body.get("csv_filename", "invoice_summary.csv")
csv_path = session_dir / csv_filename
if not csv_path.exists():
return jsonify({"error": "CSV 文件不存在"}), 404
# 读取原 CSV 获取字段顺序(使用第一个数据的 keys
original_rows = load_csv(csv_path)
if original_rows is None or len(original_rows) == 0:
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
# 从原数据中获取字段顺序(去掉内部字段)
fieldnames = list(original_rows[0].keys())
import csv as csv_module
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for entry in data:
row = {k: entry.get(k, "") for k in fieldnames}
writer.writerow(row)
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") or ""
return jsonify(resp)
@app.route("/api/submit-financial/<session_id>", methods=["POST"])
def submit_financial(session_id: str) -> Any:
"""手动触发财务系统填报"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 读取配置
config_path = session_dir / "config.json"
if not config_path.exists():
return jsonify({"error": "未找到配置,请先配置后处理"}), 400
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
# 清除上次处理留下的结果文件,避免 SSE 误判为已完成
result_file = session_dir / SESSION_RESULT_FILE
if result_file.exists():
result_file.unlink()
# 在后台线程执行提交
handler = _install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
submit_result = run_financial_submit(session_dir, config)
if submit_result.get("ok"):
result = {"ok": True}
else:
result = submit_result
except BaseException as e:
result = {"ok": False, "error": str(e)}
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,
)
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
except Exception:
pass
_remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
# ================================================================
# 辅助函数
# ================================================================
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[str, Any]) -> dict[str, Any]:
"""从请求体构建配置"""
config = load_project_config()
for key in (
"username",
"password",
"default_name",
"default_card_no",
"default_person_id",
"consumable_storage",
):
if body.get(key):
config[key] = body[key]
return config
def _escape_sse(text: str) -> str:
"""SSE 数据转义,同时处理 Windows 行尾 \\r\\n"""
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
@app.route("/mobile/<session_id>")
def mobile_upload(session_id: str) -> Any:
"""移动端上传页面"""
session_dir = UPLOAD_BASE / session_id
if not session_dir.exists():
return render_template("mobile_upload.html", error="会话不存在"), 404
return render_template("mobile_upload.html", session_id=session_id)
@app.route("/api/mobile-upload/<session_id>", methods=["POST"])
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("启动 Web 服务: http://localhost:5000")
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)

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

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

View File

@@ -0,0 +1,24 @@
body { background: #f5f7fa; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
.upload-zone {
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
}
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.log-container .empty { color: #666; font-style: italic; }
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
.btn-process { font-size: 17px; padding: 10px 40px; }
.status-badge { font-size: 13px; }
/* 可编辑表格 */
.table-editable { font-size: 13px; }
.table-editable th { position: sticky; top: 0; background: #f8f9fa; z-index: 1; font-weight: 600; white-space: nowrap; }
.table-editable td { vertical-align: middle; }
.table-editable input.form-control { font-size: 13px; padding: 4px 8px; min-width: 80px; }
.table-wrapper { max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 8px; }
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }

483
src/web/static/js/index.js Normal file
View File

@@ -0,0 +1,483 @@
let sessionId = null;
const pdfFiles = [], imgFiles = [];
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
let csvFilename = ''; // 当前 CSV 文件名
let lastDownloadUrls = {}; // 最近一次可下载文件链接
// ---- Session ----
async function ensureSession() {
if (sessionId) return sessionId;
const r = await fetch('/api/session', { method: 'POST' });
const d = await r.json();
sessionId = d.session_id;
return sessionId;
}
// ---- 上传 ----
function handleFiles(input, type) {
const files = Array.from(input.files);
const list = type === 'pdf' ? pdfFiles : imgFiles;
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
files.forEach(f => {
if (!list.find(x => x.name === f.name)) {
f.__source = 'local'; // 标记为本地手动选择
list.push(f);
}
});
renderFileList(type);
document.getElementById(zoneId).classList.add('active');
input.value = '';
}
function removeFile(type, index) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
list.splice(index, 1);
renderFileList(type);
if (list.length === 0) {
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
}
}
function renderFileList(type) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
box.innerHTML = list.map((f, i) =>
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">&times;</span></span>`
).join('');
}
// ---- 配置上传 ----
function handleConfigUpload(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const cfg = JSON.parse(e.target.result);
const map = {
'cfg-username': cfg.username,
'cfg-password': cfg.password,
'cfg-name': cfg.default_name,
'cfg-card': cfg.default_card_no,
'cfg-person-id': cfg.default_person_id,
'cfg-storage': cfg.consumable_storage,
};
for (const [id, val] of Object.entries(map)) {
if (val) document.getElementById(id).value = val;
}
// 同步 config.json 中的工号和公务卡号到表格
if (cfg.username) syncConfigToTable('工号');
if (cfg.default_card_no) syncConfigToTable('公务卡号');
alert('配置已加载');
} catch (err) {
alert('config.json 解析失败: ' + err.message);
}
};
reader.readAsText(file);
input.value = '';
}
// ---- 拖拽 ----
['pdf','img'].forEach(type => {
const zone = document.getElementById(type + '-zone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files).filter(f => {
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
});
if (files.length) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
files.forEach(f => {
f.__source = 'local'; // 标记为本地拖拽
if (!list.find(x => x.name === f.name)) list.push(f);
});
renderFileList(type);
zone.classList.add('active');
}
});
});
// ---- 处理 ----
async function startProcess() {
if (!pdfFiles.length && !imgFiles.length) {
alert('请先上传文件或图片');
return;
}
const btn = document.getElementById('btn-start');
btn.disabled = true;
btn.textContent = '处理中...';
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
document.getElementById('log-box').innerHTML = '';
document.getElementById('edit-section').style.display = 'none';
document.getElementById('download-section').style.display = 'none';
try {
await ensureSession();
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
for (const {f} of allFiles) {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
}
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
const cfg = {
username: document.getElementById('cfg-username').value,
password: document.getElementById('cfg-password').value,
default_name: document.getElementById('cfg-name').value,
default_card_no: document.getElementById('cfg-card').value,
default_person_id: document.getElementById('cfg-person-id').value,
consumable_storage: document.getElementById('cfg-storage').value,
};
await fetch(`/api/process/${sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
// 监听 SSE 日志
const es = new EventSource(`/api/logs/${sessionId}`);
const logBox = document.getElementById('log-box');
let firstLine = true;
es.addEventListener('message', e => {
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
const result = msg.result;
document.getElementById('status').innerHTML = result.ok
? '<span class="badge bg-success status-badge">完成</span>'
: '<span class="badge bg-danger status-badge">失败</span>';
btn.disabled = false;
btn.textContent = '开始处理';
if (result.ok) {
showDownloadLinks(result);
loadInvoiceData(); // 加载可编辑数据
} else {
alert('处理失败: ' + (result.error || '未知错误'));
}
return;
}
} catch (err) {}
logBox.innerHTML += e.data;
logBox.scrollTop = logBox.scrollHeight;
});
es.onerror = () => {
es.close();
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
btn.disabled = false;
btn.textContent = '开始处理';
};
} catch (e) {
alert('请求失败: ' + (e.message || '未知错误'));
btn.disabled = false;
btn.textContent = '开始处理';
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
}
}
// ---- 下载链接 ----
function showDownloadLinks(result) {
const section = document.getElementById('download-section');
const box = document.getElementById('download-links');
const warn = document.getElementById('doc-fill-warning');
if (!section || !box) return;
const items = [];
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
lastDownloadUrls = {};
items.forEach(it => { lastDownloadUrls[it.label] = it.url; });
box.innerHTML = items.map(it =>
`<a class="btn btn-outline-primary btn-sm" href="${it.url}" download>${it.label}</a>`
).join('');
if (warn) {
if (result.doc_skipped) {
warn.style.display = 'block';
warn.style.color = '#0d6efd';
warn.textContent = result.doc_message || '差旅报销无需生成易耗品出库单';
} else if (result.doc_ok === false && result.doc_error) {
warn.style.display = 'block';
warn.style.color = '';
warn.textContent = '出库单未生成:' + result.doc_error;
} else {
warn.style.display = 'none';
warn.textContent = '';
}
}
section.style.display = items.length || (result.doc_ok === false) || result.doc_skipped ? 'block' : 'none';
}
// ---- 发票数据编辑 ----
async function loadInvoiceData() {
try {
const r = await fetch(`/api/data/${sessionId}`);
const d = await r.json();
if (d.error) return;
csvFilename = d.csv_filename || 'invoice_summary.csv';
invoiceData = d.data || [];
const fields = d.fields || Object.keys(invoiceData[0] || {}).filter(k => !k.startsWith('__'));
renderTable(invoiceData, fields);
// 渲染后自动将配置区的工号/公务卡号同步到表格
syncConfigToTable('工号');
syncConfigToTable('公务卡号');
} catch (e) { /* ignore */ }
}
// 配置区 → 表格的同步映射:工号 ↔ cfg-username公务卡号 ↔ cfg-card
const CONFIG_SYNC = {
'工号': 'cfg-username',
'公务卡号': 'cfg-card',
};
// 从配置区输入框的值更新到所有表格行
function syncConfigToTable(field) {
const inputId = CONFIG_SYNC[field];
if (!inputId) return;
const val = document.getElementById(inputId).value || '';
invoiceData.forEach((row, i) => {
row[field] = val;
});
// 更新表格中对应单元格的显示
const tbody = document.getElementById('invoice-tbody');
if (!tbody) return;
const rows = tbody.querySelectorAll('tr');
rows.forEach((tr, i) => {
const inputs = tr.querySelectorAll('input');
fieldsCache.forEach((f, colIdx) => {
if (f === field && inputs[colIdx]) {
inputs[colIdx].value = val;
}
});
});
}
let fieldsCache = []; // renderTable 渲染后的字段列表,用于定位列索引
function renderTable(data, fields) {
const section = document.getElementById('edit-section');
if (!data.length) { section.style.display = 'none'; return; }
section.style.display = 'block';
// 使用后端返回的字段顺序, fallback 到 Object.keys
if (!fields || !fields.length) {
fields = Object.keys(data[0]).filter(k => !k.startsWith('__'));
}
fieldsCache = fields;
// 表头
document.getElementById('invoice-thead').innerHTML = `
<tr>
<th style="width:40px">#</th>
${fields.map(f => `<th>${f}</th>`).join('')}
</tr>
`;
// 表体:每行首列为序号,其后为各字段 input
const rows = data.map((row, i) => {
const cells = fields.map(f => {
let onChangeStr = `invoiceData[${i}]['${f.replace(/'/g, "\\'")}']=this.value`;
// 如果该字段参与配置区同步,额外调用 syncTableToConfig
if (CONFIG_SYNC[f]) {
onChangeStr += `;syncTableToConfig('${f.replace(/'/g, "\\'")}', this.value)`;
}
return `<td><input class="form-control form-control-sm" value="${escapeHtml(String(row[f] || ''))}"
onchange="${onChangeStr}"></td>`;
}).join('');
return `<tr><td style="width:40px;text-align:center">${i + 1}</td>${cells}</tr>`;
}).join('');
document.getElementById('invoice-tbody').innerHTML = rows;
}
// 从表格修改同步回配置区
function syncTableToConfig(field, value) {
const inputId = CONFIG_SYNC[field];
if (inputId) {
document.getElementById(inputId).value = value;
}
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 将当前表格编辑内容写回服务器(内部调用)
async function saveInvoiceData() {
try {
const r = await fetch(`/api/save/${sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: invoiceData, csv_filename: csvFilename }),
});
const d = await r.json();
if (d.doc_url || d.doc_ok === false) {
showDownloadLinks({
csv_url: lastDownloadUrls['invoice_summary.csv'],
doc_url: d.doc_url,
doc_ok: d.doc_ok,
doc_error: d.doc_error,
});
}
} catch (e) {
console.warn('自动保存失败,继续提交:', e);
}
}
// ---- 提交到财务系统 ----
async function submitFinancial() {
const btn = document.getElementById('btn-submit');
btn.disabled = true;
btn.textContent = '提交中...';
document.getElementById('log-box').innerHTML = '';
try {
// 提交前先自动保存当前表格编辑内容
await saveInvoiceData();
await fetch(`/api/submit-financial/${sessionId}`, { method: 'POST' });
// 监听日志流(复用 SSE
const es = new EventSource(`/api/logs/${sessionId}`);
const logBox = document.getElementById('log-box');
let firstLine = true;
es.addEventListener('message', e => {
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
btn.disabled = false;
const result = msg.result;
if (result && result.submit_ok) {
btn.textContent = '✅ 提交完成';
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 3000);
} else {
const errorMsg = result?.submit_error || result?.error || '未知错误';
btn.textContent = '❌ 提交失败';
logBox.innerHTML += `<div style="color: #e74c3c; font-weight: bold;">❌ 提交失败:${errorMsg}</div>`;
logBox.scrollTop = logBox.scrollHeight;
console.warn('提交失败:', errorMsg);
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 5000);
}
return;
}
} catch (err) {}
logBox.innerHTML += e.data;
logBox.scrollTop = logBox.scrollHeight;
});
es.onerror = () => {
es.close();
btn.disabled = false;
btn.textContent = '🚀 提交到财务系统';
};
} catch (e) {
alert('提交失败: ' + e.message);
btn.disabled = false;
btn.textContent = '🚀 提交到财务系统';
}
}
// ---- 二维码 / 手机扫码上传 ----
let qrGenerated = false;
let syncTimer = null;
async function generateQr() {
await ensureSession();
const mobileUrl = window.location.origin + '/mobile/' + sessionId;
const box = document.getElementById('qrcode');
box.innerHTML = '';
new QRCode(box, {
text: mobileUrl,
width: 120,
height: 120,
colorDark: '#333',
colorLight: '#fff',
});
qrGenerated = true;
}
// ---- 实时同步:轮询服务器文件列表,检测手机端上传的新图片 ----
async function startSync() {
if (syncTimer) return;
await syncFiles();
syncTimer = setInterval(syncFiles, 3000);
}
function stopSync() {
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
}
async function syncFiles() {
if (!sessionId) return;
try {
const r = await fetch(`/api/files/${sessionId}`);
const d = await r.json();
const serverNames = new Set(d.images || []);
const localNames = new Set(imgFiles.map(f => f.name));
for (const name of serverNames) {
if (!localNames.has(name)) {
const resp = await fetch(`/api/download/${sessionId}/${encodeURIComponent(name)}`);
const blob = await resp.blob();
const file = new File([blob], name, { type: blob.type });
file.__source = 'server'; // 标记为扫码上传
imgFiles.push(file);
}
}
// 只清理来自服务器但已不存在的文件,保留本地手动选择的文件
for (const f of [...imgFiles]) {
if (!serverNames.has(f.name) && f.__source !== 'local') {
const idx = imgFiles.indexOf(f);
if (idx > -1) imgFiles.splice(idx, 1);
}
}
renderFileList('img');
if (imgFiles.length) {
document.getElementById('img-zone').classList.add('active');
} else {
document.getElementById('img-zone').classList.remove('active');
}
} catch (e) { /* ignore */ }
}
generateQr();
startSync();
// ---- 配置区 → 表格的双向同步绑定 ----
Object.values(CONFIG_SYNC).forEach(inputId => {
const el = document.getElementById(inputId);
if (el) {
el.addEventListener('input', () => {
// 根据 inputId 反查对应的字段名
const field = Object.entries(CONFIG_SYNC).find(([, id]) => id === inputId)?.[0];
if (field) syncConfigToTable(field);
});
}
});

View File

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

View File

@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>财务报销自动化</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/index.css" rel="stylesheet">
</head>
<body>
<div class="header text-center mb-4">
<h3>财务报销自动化</h3>
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图自动提取、LLM 识别并填报</p>
</div>
<div class="container" style="max-width:960px">
<!-- 上传区域 -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="section-title">📄 发票 PDF <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
<div class="upload-zone" id="pdf-zone" onclick="document.getElementById('pdf-input').click()">
<div class="icon">📁</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传 PDF 文件</div>
<div id="pdf-list" class="mt-2"></div>
</div>
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
</div>
<div class="col-md-4">
<div class="section-title">🖼️ 支付截图 <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
<div class="upload-zone" id="img-zone" onclick="document.getElementById('img-input').click()">
<div class="icon">🖼️</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传图片文件</div>
<div id="img-list" class="mt-2"></div>
</div>
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
</div>
<div class="col-md-4 text-center">
<div class="section-title">📱 手机扫码上传</div>
<div id="qrcode" style="display:inline-block"></div>
<p class="text-muted mt-2 mb-0" style="font-size:12px">扫码即可拍照上传</p>
</div>
</div>
<!-- 配置表单 -->
<div class="card mb-4">
<div class="card-body">
<div class="section-title">⚙️ 配置
<span style="font-size:12px;font-weight:normal;cursor:pointer;color:#667eea;margin-left:8px" onclick="document.getElementById('config-upload').click()">
📤 上传 config.json
</span>
<input type="file" id="config-upload" accept=".json" hidden onchange="handleConfigUpload(this)">
</div>
<div class="row g-2">
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">账号 (工号)</label>
<input type="text" class="form-control form-control-sm" id="cfg-username" placeholder="202xxxx">
</div>
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">密码</label>
<input type="password" class="form-control form-control-sm" id="cfg-password" placeholder="登录密码">
</div>
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">默认姓名</label>
<input type="text" class="form-control form-control-sm" id="cfg-name" placeholder="张三">
</div>
<div class="col-sm-3">
<label class="form-label" style="font-size:12px;margin-bottom:2px">公务卡号</label>
<input type="text" class="form-control form-control-sm" id="cfg-card" placeholder="628288...">
</div>
<div class="col-sm-3">
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
</div>
<div class="col-sm-6">
<label class="form-label" style="font-size:12px;margin-bottom:2px">存放地点(出库单)</label>
<input type="text" class="form-control form-control-sm" id="cfg-storage" placeholder="新工科楼">
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="text-center mb-4">
<button class="btn btn-primary btn-process" id="btn-start" onclick="startProcess()">开始处理</button>
<span id="status" class="ms-3"></span>
</div>
<!-- 下载区 -->
<div class="card mb-4" id="download-section" style="display:none">
<div class="card-body py-3">
<div class="section-title mb-2">📥 下载文件</div>
<div id="download-links" class="d-flex flex-wrap gap-2"></div>
<div id="doc-fill-warning" class="text-warning mt-2" style="font-size:13px;display:none"></div>
</div>
</div>
<!-- 发票数据编辑区 -->
<div class="card mb-4" id="edit-section" style="display:none">
<div class="card-body">
<div class="section-title d-flex justify-content-between align-items-center">
<span>📝 付款记录(可编辑)</span>
<div class="d-flex justify-content-end align-items-center">
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
</div>
</div>
<p class="edit-note">直接点击单元格即可编辑,提交时自动保存当前修改。</p>
<div class="table-wrapper">
<table class="table table-sm table-bordered table-editable mb-0" id="invoice-table">
<thead id="invoice-thead"></thead>
<tbody id="invoice-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- 日志 -->
<div class="section-title">📋 处理日志</div>
<div class="log-container mb-4" id="log-box"><div class="empty">等待开始...</div></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script src="/static/js/index.js"></script>
</body>
</html>

View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>支付截图上传</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f5f7fa; padding-bottom: 40px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 20px 16px; text-align: center; }
.upload-btn { width: 100%; padding: 18px; font-size: 16px; border-radius: 12px; margin-top: 16px; }
.img-preview { width: 100px; height: 100px; object-fit: cover; border-radius: 8px; margin: 4px; }
.file-card { background: #fff; border-radius: 8px; padding: 8px; margin-bottom: 8px; display: flex; align-items: center; gap: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.file-card img { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; }
.file-card .info { flex: 1; min-width: 0; }
.file-card .name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-card .remove { color: #c00; cursor: pointer; padding: 4px 8px; }
.status-msg { text-align: center; padding: 12px; font-size: 14px; }
</style>
</head>
<body>
{% if error %}
<div class="header"><h5>错误</h5><p>{{ error }}</p></div>
{% else %}
<div class="header">
<h5>📸 支付截图上传</h5>
<p class="mb-0 opacity-75" style="font-size:13px">拍照或从相册选择图片</p>
</div>
<div class="container" style="max-width:480px">
<!-- 上传按钮 -->
<input type="file" id="file-input" accept="image/*" multiple hidden onchange="handleSelect(this)">
<button class="btn btn-primary upload-btn" onclick="document.getElementById('file-input').click()">
📷 选择图片 / 拍照
</button>
<!-- 上传进度 -->
<div id="upload-status" class="status-msg mt-3"></div>
<!-- 已上传图片列表 -->
<h6 class="mt-3 mb-2">已上传 ({{ server_count | default(0) }})</h6>
<div id="file-list"></div>
</div>
{% endif %}
<script>
const sessionId = "{{ session_id }}";
// ---- 加载已有文件 ----
async function loadFiles() {
try {
const r = await fetch(`/api/files/${sessionId}`);
const d = await r.json();
renderList(d.images || []);
} catch (e) {
console.error(e);
}
}
function renderList(images) {
const box = document.getElementById('file-list');
if (!images.length) {
box.innerHTML = '<p class="text-muted text-center" style="font-size:13px">暂无图片</p>';
return;
}
box.innerHTML = images.map(name => `
<div class="file-card">
<img src="/api/download/${sessionId}/${encodeURIComponent(name)}#t=image/png" alt="">
<div class="info"><div class="name">${name}</div></div>
<span class="remove" onclick="removeFile('${name}')">&times;</span>
</div>
`).join('');
}
// ---- 上传 ----
async function handleSelect(input) {
const files = Array.from(input.files);
if (!files.length) return;
const status = document.getElementById('upload-status');
let done = 0;
status.innerHTML = `<div class="text-primary">上传中... 0/${files.length}</div>`;
for (const f of files) {
try {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
done++;
status.innerHTML = `<div class="text-primary">上传中... ${done}/${files.length}</div>`;
} catch (e) {
status.innerHTML = `<div class="text-danger">${f.name} 上传失败</div>`;
}
}
if (done === files.length) {
status.innerHTML = `<div class="text-success">✅ ${done} 张图片上传成功</div>`;
} else {
status.innerHTML = `<div class="text-warning">${done}/${files.length} 张上传成功</div>`;
}
input.value = '';
loadFiles();
}
// ---- 删除(前端视觉移除,实际保留在服务器)----
function removeFile(name) {
// 移动端暂不支持删除,提示用户回到 PC 端管理
if (confirm('文件已上传到服务器。如需删除请回到电脑端操作。')) {
loadFiles();
}
}
// ---- 定时刷新列表(同步 PC 端新增的文件)----
setInterval(loadFiles, 5000);
loadFiles();
</script>
</body>
</html>

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])

View File

@@ -1,314 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>财务报销自动化</title>
<style>
:root {
--bg: #f5f5f5;
--card: #fff;
--text: #333;
--muted: #888;
--primary: #4f46e5;
--primary-hover: #4338ca;
--success: #16a34a;
--danger: #dc2626;
--warning: #d97706;
--border: #e5e7eb;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,.08);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
.container { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
.card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); padding: 24px; margin-bottom: 16px; }
.card h2 { font-size: 18px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.card h2 .badge { font-size: 12px; padding: 2px 8px; border-radius: 12px; background: var(--primary); color: #fff; }
.upload-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 40px 20px; text-align: center; cursor: pointer; transition: border-color .2s; }
.upload-zone:hover, .upload-zone.dragover { border-color: var(--primary); background: #f8f8ff; }
.upload-zone p { color: var(--muted); margin-top: 8px; }
.file-list { margin-top: 12px; }
.file-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #f9fafb; border-radius: 6px; margin-bottom: 4px; font-size: 14px; }
.file-item .remove { margin-left: auto; cursor: pointer; color: var(--danger); font-size: 16px; }
label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; }
input, textarea { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; outline: none; transition: border-color .2s; }
input:focus, textarea:focus { border-color: var(--primary); }
textarea { font-family: "SF Mono", "Fira Code", monospace; font-size: 13px; height: 160px; resize: vertical; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: background .2s; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.btn-success { background: var(--success); color: #fff; }
.btn-success:hover { background: #15803d; }
.btn-success:disabled { opacity: .5; cursor: not-allowed; }
.actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.log-panel { background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius); padding: 16px; max-height: 380px; overflow-y: auto; font-family: "SF Mono", "Fira Code", monospace; font-size: 13px; }
.log-panel .entry { margin-bottom: 2px; white-space: pre-wrap; word-break: break-all; }
.log-panel .INFO { color: #6a9955; }
.log-panel .WARNING { color: #d97706; }
.log-panel .ERROR { color: #f87171; }
.log-panel .DEBUG { color: #8b8b8b; }
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.status-badge.uploaded { background: #e5e7eb; color: #6b7280; }
.status-badge.extracting { background: #dbeafe; color: #2563eb; }
.status-badge.done { background: #dcfce7; color: #16a34a; }
.status-badge.submitting { background: #fef3c7; color: #d97706; }
.status-badge.submitted { background: #dcfce7; color: #16a34a; }
.status-badge.error { background: #fee2e2; color: #dc2626; }
.result-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
.result-table th, .result-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; }
.result-table th { background: #f9fafb; font-weight: 500; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<h2>财务报销自动化</h2>
<p style="color:var(--muted);font-size:14px;">上传 PDF 发票和支付截图,自动提取信息并填报报销系统</p>
</div>
<!-- 文件上传 -->
<div class="card">
<h2>1. 上传文件</h2>
<div class="upload-zone" id="uploadZone">
<div style="font-size:36px;margin-bottom:4px;">&#128194;</div>
<p>拖拽 PDF 和图片到此处,或点击选择文件</p>
<input type="file" id="fileInput" multiple accept=".pdf,.png,.jpg,.jpeg,.bmp,.webp" style="display:none">
</div>
<div class="file-list" id="fileList"></div>
</div>
<!-- 配置 -->
<div class="card">
<h2>2. 配置</h2>
<div class="form-row">
<div><label>用户名</label><input id="cfgUser" placeholder="工号/学号"></div>
<div><label>密码</label><input id="cfgPass" type="password" placeholder="登录密码"></div>
</div>
<div class="form-row">
<div><label>默认姓名</label><input id="cfgName" placeholder="报销人姓名"></div>
<div><label>默认公务卡号</label><input id="cfgCard" placeholder="公务卡号后四位或完整号"></div>
</div>
<div><label>默认人员编号</label><input id="cfgPid" placeholder="人员编号"></div>
<details style="margin-top:8px;">
<summary style="font-size:14px;color:var(--muted);cursor:pointer;">高级配置 (JSON)</summary>
<textarea id="cfgJson" style="margin-top:8px;">{}</textarea>
</details>
</div>
<!-- 进度 -->
<div class="card">
<h2>3. 处理 <span class="status-badge" id="statusBadge" style="display:none"></span></h2>
<div class="log-panel" id="logPanel">
<div class="entry" style="color:var(--muted)">等待上传文件...</div>
</div>
<div class="actions">
<button class="btn btn-primary" id="btnStart" disabled>开始处理</button>
<button class="btn btn-success hidden" id="btnSubmit">提交报销</button>
<button class="btn hidden" id="btnDownload" style="background:#6b7280;color:#fff;">下载 CSV</button>
</div>
<div id="resultArea" class="hidden" style="margin-top:12px;"></div>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
let sessionId = null;
let files = [];
let evtSource = null;
// ---- 文件选择 ----
const uploadZone = $('#uploadZone');
const fileInput = $('#fileInput');
const fileList = $('#fileList');
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
uploadZone.addEventListener('drop', e => {
e.preventDefault();
uploadZone.classList.remove('dragover');
addFiles(Array.from(e.dataTransfer.files));
});
fileInput.addEventListener('change', () => addFiles(Array.from(fileInput.files)));
function addFiles(newFiles) {
for (const f of newFiles) {
if (!files.find(x => x.name === f.name && x.size === f.size)) {
files.push(f);
}
}
renderFiles();
checkReady();
}
function removeFile(idx) {
files.splice(idx, 1);
renderFiles();
checkReady();
}
function renderFiles() {
fileList.innerHTML = files.length === 0 ? '' : files.map((f, i) =>
`<div class="file-item">
<span>${['.pdf','.PDF'].some(e => f.name.endsWith(e)) ? '&#128196;' : '&#128444;'}</span>
<span>${f.name}</span>
<span style="color:var(--muted);font-size:12px;">${(f.size/1024).toFixed(1)} KB</span>
<span class="remove" onclick="removeFile(${i})">&times;</span>
</div>`
).join('');
}
// ---- 配置 ----
const cfgUser = $('#cfgUser');
const cfgPass = $('#cfgPass');
const cfgName = $('#cfgName');
const cfgCard = $('#cfgCard');
const cfgPid = $('#cfgPid');
const cfgJson = $('#cfgJson');
[cfgUser, cfgPass, cfgName, cfgCard, cfgPid, cfgJson].forEach(el =>
el.addEventListener('input', checkReady)
);
function getConfig() {
let base = {};
try { base = JSON.parse(cfgJson.value || '{}'); } catch (e) {}
if (cfgUser.value) base.username = cfgUser.value;
if (cfgPass.value) base.password = cfgPass.value;
if (cfgName.value) base.default_name = cfgName.value;
if (cfgCard.value) base.default_card_no = cfgCard.value;
if (cfgPid.value) base.default_person_id = cfgPid.value;
return base;
}
function checkReady() {
const hasFiles = files.length > 0;
const hasUser = !!(cfgUser.value && cfgPass.value);
$('#btnStart').disabled = !(hasFiles && hasUser);
}
// ---- 开始处理 ----
const btnStart = $('#btnStart');
const btnSubmit = $('#btnSubmit');
const btnDownload = $('#btnDownload');
const statusBadge = $('#statusBadge');
const logPanel = $('#logPanel');
const resultArea = $('#resultArea');
btnStart.addEventListener('click', async () => {
btnStart.disabled = true;
statusBadge.style.display = 'inline-block';
statusBadge.textContent = '上传中...';
statusBadge.className = 'status-badge extracting';
const formData = new FormData();
for (const f of files) formData.append('files', f);
const config = getConfig();
formData.append('config', JSON.stringify(config));
try {
const resp = await fetch('/api/start', { method: 'POST', body: formData });
const data = await resp.json();
if (data.error) { alert(data.error); btnStart.disabled = false; return; }
sessionId = data.session_id;
startStream();
btnSubmit.classList.remove('hidden');
btnDownload.classList.remove('hidden');
} catch (e) {
alert('请求失败: ' + e.message);
btnStart.disabled = false;
}
});
function startStream() {
logPanel.innerHTML = '';
if (evtSource) evtSource.close();
evtSource = new EventSource(`/api/stream/${sessionId}`);
evtSource.addEventListener('message', e => {
const data = JSON.parse(e.data);
if (data.type === 'log') {
const lvl = (data.msg.match(/\[(\w+)\]/) || [])[1] || '';
logPanel.innerHTML += `<div class="entry ${lvl}">${escHtml(data.msg)}</div>`;
logPanel.scrollTop = logPanel.scrollHeight;
}
if (data.type === 'heartbeat' || data.type === 'status') {
statusBadge.textContent = data.status;
statusBadge.className = `status-badge ${data.status}`;
}
if (data.type === 'complete') {
statusBadge.textContent = data.status;
statusBadge.className = `status-badge ${data.status}`;
evtSource.close();
if (data.status === 'done') {
btnSubmit.classList.remove('hidden');
loadResults();
}
btnStart.disabled = data.status !== 'done';
}
});
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
async function loadResults() {
try {
const resp = await fetch(`/api/status/${sessionId}`);
const data = await resp.json();
if (!data.preview) return;
let html = '<table class="result-table"><thead><tr>';
const keys = Object.keys(data.preview[0]);
for (const k of keys) html += `<th>${k}</th>`;
html += '</tr></thead><tbody>';
for (const row of data.preview) {
html += '<tr>';
for (const k of keys) html += `<td>${row[k] || ''}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
html += `<p style="margin-top:8px;color:var(--muted);">共 ${data.total} 条记录</p>`;
resultArea.innerHTML = html;
resultArea.classList.remove('hidden');
} catch (e) {}
}
// ---- 浏览器填报 ----
btnSubmit.addEventListener('click', async () => {
if (!sessionId) return;
btnSubmit.disabled = true;
statusBadge.textContent = 'submitting';
statusBadge.className = 'status-badge submitting';
try {
await fetch(`/api/submit/${sessionId}`, { method: 'POST' });
startStream();
} catch (e) {
alert('提交失败: ' + e.message);
btnSubmit.disabled = false;
}
});
// ---- 下载 CSV ----
btnDownload.addEventListener('click', () => {
if (sessionId) window.open(`/api/download/${sessionId}/csv`, '_blank');
});
</script>
</body>
</html>

23
tests/README.md Normal file
View File

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

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

340
tests/test_extractor.py Normal file
View File

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

111
tests/test_invoice.py Normal file
View File

@@ -0,0 +1,111 @@
"""发票模块单元测试
覆盖发票分类、CSV 列定义、常量校验。
"""
from src.doc.invoice import (
INVOICE_LEVEL_COLUMNS,
PAYMENT_RECORD_COLUMNS,
)
from src.doc.invoice import (
_classify_invoice_batch as classify_invoice_batch,
)
# 字段键名与发票类型(与源码中的字符串字面量保持一致)
K_INVOICE_TYPE = "invoice_type"
K_INVOICE_NUMBER = "invoice_number"
K_TOTAL_AMOUNT = "total_amount"
K_ITEM_NAME = "item_name"
K_PERSON_NAME = "person_name"
K_CARD_DATE = "card_date"
K_CARD_NO = "card_no"
K_CARD_AMOUNT = "card_amount"
K_MATCHED_INVOICES = "_matched_invoices"
K_RELATIVE_INVOICE_COUNT = "relative_invoice_count"
K_INVOICE_DETAIL = "invoice_detail"
K_REMARK = "remark"
K_SOURCE_FILE = "source_file"
INVOICE_TYPE_TRAIN = "train"
INVOICE_TYPE_HOTEL = "hotel"
INVOICE_TYPE_GENERAL = "general"
DOCUMENT_TYPE_APPLICATION = "application"
INVOICE_TYPE_TRAVEL = [INVOICE_TYPE_TRAIN, INVOICE_TYPE_HOTEL]
def is_travel_invoice(invoice_type: str) -> bool:
return invoice_type in INVOICE_TYPE_TRAVEL
class TestInvoiceConstants:
"""发票常量校验"""
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("unknown") is False
class TestClassifyInvoiceBatch:
"""发票分类"""
def test_empty_list(self) -> None:
result = classify_invoice_batch([])
assert result == {"travel": [], "general": [], "application": []}
def test_all_travel(self) -> None:
invoices = [
{K_INVOICE_TYPE: INVOICE_TYPE_TRAIN, K_INVOICE_NUMBER: "001"},
{K_INVOICE_TYPE: INVOICE_TYPE_HOTEL, K_INVOICE_NUMBER: "002"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 2
assert len(result["general"]) == 0
def test_all_general(self) -> None:
invoices = [
{K_INVOICE_TYPE: INVOICE_TYPE_GENERAL, K_INVOICE_NUMBER: "001"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 0
assert len(result["general"]) == 1
def test_mixed(self) -> None:
invoices = [
{K_INVOICE_TYPE: INVOICE_TYPE_TRAIN, K_INVOICE_NUMBER: "001"},
{K_INVOICE_TYPE: INVOICE_TYPE_GENERAL, K_INVOICE_NUMBER: "002"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 1
assert len(result["general"]) == 1
def test_missing_type_defaults_to_general(self) -> None:
invoices = [{K_INVOICE_NUMBER: "001"}]
result = classify_invoice_batch(invoices)
assert len(result["general"]) == 1

202
tests/test_llm_extractor.py Normal file
View File

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

564
tests/test_matcher.py Normal file
View File

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

2485
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,375 +0,0 @@
"""
财务报销自动化 — Web 界面
用户上传 PDF 发票和支付截图,配置账号信息,自动完成:
1. 发票提取 2. OCR 识别 3. 浏览器填报(可选)
启动: python web/app.py
访问: http://localhost:5000
"""
import io
import json
import logging
import sys
import threading
import time
import uuid
from pathlib import Path
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
# 确保项目根目录在 sys.path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
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.ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv as load_ocr_csv
app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "web" / "uploads"
SESSION_LOG_FILE = "session.log"
SESSION_RESULT_FILE = "result.json"
# ================================================================
# 日志收集器 — 捕获管道日志到文件SSE 端点通过 tail -f 读取
# ================================================================
class _SSELogHandler(logging.Handler):
"""将日志写入指定文件(线程安全)"""
def __init__(self, log_path: Path):
super().__init__()
self._lock = threading.Lock()
self._file = open(log_path, "w", encoding="utf-8")
def emit(self, record: logging.LogRecord):
try:
msg = self.format(record) + "\n"
with self._lock:
self._file.write(msg)
self._file.flush()
except Exception:
pass
def close_file(self):
try:
self._file.close()
except Exception:
pass
def _install_log_collector(session_dir: Path) -> _SSELogHandler:
"""安装日志收集器到 app.* 模块"""
log_path = session_dir / SESSION_LOG_FILE
fmt = logging.Formatter(
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
"%Y-%m-%d %H:%M:%S",
)
handler = _SSELogHandler(log_path)
handler.setFormatter(fmt)
handler.setLevel(logging.INFO)
for name in ["extractor", "ocr", "pipeline", "bot"]:
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.addHandler(handler)
return handler
def _remove_log_collector(handler: _SSELogHandler):
for name in ["extractor", "ocr", "pipeline", "bot"]:
logging.getLogger(name).removeHandler(handler)
handler.close_file()
# ================================================================
# 管道入口
# ================================================================
# ================================================================
# 管道入口
# ================================================================
def run_pipeline_web(session_dir: Path, config: dict, run_bot: bool = False):
"""在 Web 会话目录中执行管道,结果写入 session 目录下的文件"""
start = time.time()
# ---- Step 1: 发票提取 ----
invoices = 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")
# ---- 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)
# ---- Step 3: 浏览器填报(可选)----
if run_bot:
from app.bot import load_invoice_data, run_bot_web
bot_invoices = load_invoice_data(str(csv_path), config)
run_bot_web(config, bot_invoices, session_dir)
elapsed = time.time() - start
return {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": len(rows),
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
"md_url": f"/api/download/{session_dir.name}/invoice_summary.md",
}
def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str, run_bot: bool = False):
"""直接使用上传的 CSV 文件进行填报,跳过 PDF 提取和 OCR"""
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)
if rows is None:
return {"ok": False, "error": "CSV 读取失败"}
# 浏览器填报
if run_bot:
from app.bot import load_invoice_data, run_bot_web
bot_invoices = load_invoice_data(str(csv_path), config)
run_bot_web(config, bot_invoices, session_dir)
elapsed = time.time() - start
return {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": len(rows),
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
}
# ================================================================
# Flask 路由
# ================================================================
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/session", methods=["POST"])
def create_session():
"""创建上传会话,返回 session_id"""
sid = uuid.uuid4().hex[:12]
session_dir = UPLOAD_BASE / sid
session_dir.mkdir(parents=True, exist_ok=True)
return jsonify({"session_id": sid})
@app.route("/api/upload/<session_id>", methods=["POST"])
def upload_file(session_id: str):
"""上传 PDF 或图片"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
safe_name = Path(f.filename).name
f.save(str(session_dir / safe_name))
return jsonify({"ok": True, "filename": safe_name})
@app.route("/api/upload-csv/<session_id>", methods=["POST"])
def upload_csv(session_id: str):
"""上传 CSV 发票数据文件(跳过 PDF 提取和 OCR"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
safe_name = Path(f.filename).name
f.save(str(session_dir / safe_name))
return jsonify({"ok": True, "filename": safe_name})
@app.route("/api/files/<session_id>", methods=["GET"])
def list_files(session_id: str):
"""列出会话目录中的文件"""
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}")
)
return jsonify({"pdfs": pdfs, "images": imgs})
@app.route("/api/process/<session_id>", methods=["POST"])
def start_process(session_id: str):
"""启动管道处理"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
run_bot_flag = body.get("submit", False)
mode = body.get("mode", "auto") # "pdf", "csv", or "auto"
# 读取配置
config = _build_web_config(body)
# 写入配置到会话目录
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
# 在后台线程执行
handler = _install_log_collector(session_dir)
def _run():
result = {"ok": False, "error": "未知错误"}
try:
if mode == "csv":
# CSV 模式:直接使用上传的 CSV跳过 PDF 提取和 OCR
csv_files = list(session_dir.glob("*.csv"))
if not csv_files:
result = {"ok": False, "error": "未找到 CSV 文件"}
else:
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name, run_bot_flag)
else:
# 自动检测:如果有 CSV 则走 csv 管道,否则走 pdf 管道
csv_files = list(session_dir.glob("*.csv"))
pdf_files = list(session_dir.glob("*.pdf"))
if csv_files and not pdf_files:
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name, run_bot_flag)
else:
result = run_pipeline_web(session_dir, config, run_bot_flag)
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, (KeyboardInterrupt, SystemExit)):
raise
finally:
try:
# 原子写入:先写临时文件,再重命名,避免 SSE 读到截断的空文件
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
except Exception:
pass
_remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@app.route("/api/logs/<session_id>")
def stream_logs(session_id: str):
"""SSE 日志流"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
def generate():
# 先发送已有日志
log_file = session_dir / SESSION_LOG_FILE
last_size = 0
start_time = time.time()
timeout = 600 # 10 分钟超时
while time.time() - start_time < timeout:
if log_file.exists():
current_size = log_file.stat().st_size
if current_size > last_size:
with open(log_file, encoding="utf-8", errors="replace") as f:
f.seek(last_size)
chunk = f.read()
if chunk:
yield f"data: {_escape_sse(chunk)}\n\n"
last_size = current_size
# 也通过队列发送实时日志
# 检查是否完成
result_file = session_dir / SESSION_RESULT_FILE
if result_file.exists():
with open(result_file, encoding="utf-8") as f:
result = json.load(f)
yield f"data: {_escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
break
time.sleep(0.5)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.route("/api/download/<session_id>/<filename>")
def download_file(session_id: str, filename: str):
"""下载生成的文件"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
filepath = session_dir / filename
if not filepath.exists():
return jsonify({"error": "文件不存在"}), 404
return Response(filepath.read_bytes(), mimetype="application/octet-stream")
# ================================================================
# 辅助函数
# ================================================================
def _validate_session(session_id: str):
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:
"""从请求体构建配置"""
config = load_project_config()
for key in ("username", "password", "default_name", "default_card_no", "default_person_id"):
if body.get(key):
config[key] = body[key]
return config
def _escape_sse(text: str) -> str:
"""SSE 数据转义,同时处理 Windows 行尾 \\r\\n"""
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
if __name__ == "__main__":
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
print(f"启动 Web 服务: http://localhost:5000")
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)

View File

@@ -1,362 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>财务报销自动化</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f5f7fa; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
.upload-zone {
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
}
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.log-container .empty { color: #666; font-style: italic; }
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
.btn-process { font-size: 17px; padding: 10px 40px; }
.status-badge { font-size: 13px; }
.result-box { background: #fff; border-radius: 8px; padding: 16px; display: none; }
.result-box a { text-decoration: none; }
</style>
</head>
<body>
<div class="header text-center mb-4">
<h3>财务报销自动化</h3>
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图自动提取、OCR 识别并填报</p>
</div>
<div class="container" style="max-width:960px">
<!-- 上传区域 -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="section-title">📄 发票 PDF <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
<div class="upload-zone" id="pdf-zone" onclick="document.getElementById('pdf-input').click()">
<div class="icon">📁</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传 PDF 文件</div>
<div id="pdf-list" class="mt-2"></div>
</div>
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
</div>
<div class="col-md-6">
<div class="section-title">🖼️ 支付截图 <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
<div class="upload-zone" id="img-zone" onclick="document.getElementById('img-input').click()">
<div class="icon">🖼️</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传图片文件</div>
<div id="img-list" class="mt-2"></div>
</div>
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
</div>
</div>
<!-- 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="upload-zone" id="csv-zone" onclick="document.getElementById('csv-input').click()">
<div class="icon">📊</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传 CSV 文件</div>
<div id="csv-list" class="mt-2"></div>
</div>
<input type="file" id="csv-input" accept=".csv" hidden onchange="handleCsvFile(this)">
</div>
<!-- 配置表单 -->
<div class="card mb-4">
<div class="card-body">
<div class="section-title">⚙️ 配置
<span style="font-size:12px;font-weight:normal;cursor:pointer;color:#667eea;margin-left:8px" onclick="document.getElementById('config-upload').click()">
📤 上传 config.json
</span>
<input type="file" id="config-upload" accept=".json" hidden onchange="handleConfigUpload(this)">
</div>
<div class="row g-2">
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">账号 (工号)</label>
<input type="text" class="form-control form-control-sm" id="cfg-username" placeholder="202xxxx">
</div>
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">密码</label>
<input type="password" class="form-control form-control-sm" id="cfg-password" placeholder="登录密码">
</div>
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">默认姓名</label>
<input type="text" class="form-control form-control-sm" id="cfg-name" placeholder="张三">
</div>
<div class="col-sm-3">
<label class="form-label" style="font-size:12px;margin-bottom:2px">公务卡号</label>
<input type="text" class="form-control form-control-sm" id="cfg-card" placeholder="628288...">
</div>
<div class="col-sm-3">
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
</div>
<div class="col-sm-6 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="cfg-submit">
<label class="form-check-label" for="cfg-submit" style="font-size:13px">同时提交到财务系统(需浏览器自动化)</label>
</div>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="text-center mb-4">
<button class="btn btn-primary btn-process" id="btn-start" onclick="startProcess()">开始处理</button>
<span id="status" class="ms-3"></span>
</div>
<!-- 结果 -->
<div class="result-box mb-3" id="result-box">
<div class="section-title">📊 处理结果</div>
<div id="result-content"></div>
</div>
<!-- 日志 -->
<div class="section-title">📋 处理日志</div>
<div class="log-container mb-4" id="log-box"><div class="empty">等待开始...</div></div>
</div>
<script>
let sessionId = null;
const pdfFiles = [], imgFiles = [];
let csvFile = null;
// ---- Session ----
async function ensureSession() {
if (sessionId) return sessionId;
const r = await fetch('/api/session', { method: 'POST' });
const d = await r.json();
sessionId = d.session_id;
return sessionId;
}
// ---- 上传 ----
function handleFiles(input, type) {
const files = Array.from(input.files);
const list = type === 'pdf' ? pdfFiles : imgFiles;
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
files.forEach(f => {
if (!list.find(x => x.name === f.name)) list.push(f);
});
renderFileList(type);
document.getElementById(zoneId).classList.add('active');
input.value = '';
}
function removeFile(type, index) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
list.splice(index, 1);
renderFileList(type);
if (list.length === 0) {
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
}
}
function renderFileList(type) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
box.innerHTML = list.map((f, i) =>
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">&times;</span></span>`
).join('');
}
// ---- CSV 上传 ----
function handleCsvFile(input) {
const file = input.files[0];
if (!file) return;
csvFile = file;
document.getElementById('csv-zone').classList.add('active');
document.getElementById('csv-list').innerHTML =
`<span class="file-tag">${file.name}<span class="remove" onclick="event.stopPropagation();removeCsvFile()">&times;</span></span>`;
input.value = '';
}
function removeCsvFile() {
csvFile = null;
document.getElementById('csv-zone').classList.remove('active');
document.getElementById('csv-list').innerHTML = '';
}
// ---- 配置上传 ----
function handleConfigUpload(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const cfg = JSON.parse(e.target.result);
const map = {
'cfg-username': cfg.username,
'cfg-password': cfg.password,
'cfg-name': cfg.default_name,
'cfg-card': cfg.default_card_no,
'cfg-person-id': cfg.default_person_id,
};
for (const [id, val] of Object.entries(map)) {
if (val) document.getElementById(id).value = val;
}
alert('配置已加载');
} catch (err) {
alert('config.json 解析失败: ' + err.message);
}
};
reader.readAsText(file);
input.value = '';
}
// ---- 拖拽 ----
['pdf','img'].forEach(type => {
const zone = document.getElementById(type + '-zone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files).filter(f => {
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
});
if (files.length) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
files.forEach(f => { if (!list.find(x => x.name === f.name)) list.push(f); });
renderFileList(type);
zone.classList.add('active');
}
});
});
// CSV 拖拽
const csvZone = document.getElementById('csv-zone');
csvZone.addEventListener('dragover', e => { e.preventDefault(); csvZone.classList.add('dragover'); });
csvZone.addEventListener('dragleave', () => csvZone.classList.remove('dragover'));
csvZone.addEventListener('drop', e => {
e.preventDefault();
csvZone.classList.remove('dragover');
const file = Array.from(e.dataTransfer.files).find(f => f.name.toLowerCase().endsWith('.csv'));
if (file) handleCsvFile({ files: [file] });
});
// ---- 处理 ----
async function startProcess() {
const isCsvMode = !!csvFile;
if (!isCsvMode && !pdfFiles.length && !imgFiles.length) {
alert('请先上传文件或 CSV');
return;
}
const btn = document.getElementById('btn-start');
btn.disabled = true;
btn.textContent = '处理中...';
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
document.getElementById('log-box').innerHTML = '';
document.getElementById('result-box').style.display = 'none';
try {
await ensureSession();
if (isCsvMode) {
// CSV 模式:只上传 CSV 文件
const fd = new FormData();
fd.append('file', csvFile);
await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd });
} else {
// 传统模式:上传 PDF 和图片
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
for (const {f} of allFiles) {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
}
}
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
// 启动管道
const cfg = {
username: document.getElementById('cfg-username').value,
password: document.getElementById('cfg-password').value,
default_name: document.getElementById('cfg-name').value,
default_card_no: document.getElementById('cfg-card').value,
default_person_id: document.getElementById('cfg-person-id').value,
submit: document.getElementById('cfg-submit').checked,
mode: isCsvMode ? 'csv' : 'auto',
};
await fetch(`/api/process/${sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
// 监听 SSE 日志
const es = new EventSource(`/api/logs/${sessionId}`);
const logBox = document.getElementById('log-box');
let firstLine = true;
es.addEventListener('message', e => {
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
// 尝试解析 JSON完成信号否则按日志文本处理
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
const result = msg.result;
document.getElementById('status').innerHTML = result.ok
? '<span class="badge bg-success status-badge">完成</span>'
: '<span class="badge bg-danger status-badge">失败</span>';
btn.disabled = false;
btn.textContent = '开始处理';
if (result.ok) {
showResult(result);
} else {
document.getElementById('result-box').style.display = 'block';
document.getElementById('result-content').innerHTML = '<span class="text-danger">' + (result.error || '处理失败') + '</span>';
}
return;
}
} catch (err) {}
logBox.innerHTML += e.data;
logBox.scrollTop = logBox.scrollHeight;
});
es.onerror = () => {
es.close();
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
btn.disabled = false;
btn.textContent = '开始处理';
};
} catch (e) {
alert('请求失败: ' + (e.message || '未知错误'));
btn.disabled = false;
btn.textContent = '开始处理';
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
}
}
function showResult(r) {
const box = document.getElementById('result-box');
box.style.display = 'block';
const mdLink = r.md_url ? `<a class="btn btn-sm btn-outline-secondary" href="${r.md_url}" download>下载 Markdown</a>` : '';
document.getElementById('result-content').innerHTML = `
<p>处理 <b>${r.invoice_count}</b> 张发票,耗时 ${r.elapsed}</p>
<a class="btn btn-sm btn-outline-primary me-2" href="${r.csv_url}" download>下载 CSV</a>
${mdLink}
`;
}
</script>
</body>
</html>

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` 定位失败步骤 |

BIN
易耗品、出库单.doc Normal file

Binary file not shown.