Compare commits
5 Commits
agent
...
5c35bd06d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c35bd06d6 | ||
|
|
7aa8ca86f2 | ||
|
|
87c7b2d5be | ||
|
|
263d542903 | ||
|
|
1a8e06f228 |
19
.agents/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# .agents — 项目内部维护目录
|
||||||
|
|
||||||
|
本目录存放维护人员与自动化代理相关资料,不属于对外公开的用户文档。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
| 子目录 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `docs/` | 维护文档:规范、经验总结、实施方案、操作指南 |
|
||||||
|
| `skills/` | Cursor Agent 技能:定义自动化工作流和代码检查流程 |
|
||||||
|
|
||||||
|
## 文档边界
|
||||||
|
|
||||||
|
- 面向开源用户、外部贡献者的公开文档统一放置在项目根目录 `docs/` 下。
|
||||||
|
- 维护规范、实施方案、经验总结、拉取请求佐证材料与各类内部记录资料,均统一放置在本目录下。
|
||||||
12
.agents/docs/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 项目维护人员文档
|
||||||
|
|
||||||
|
本目录存放维护人员与自动化代理相关资料,主要用于项目运维,不属于对外公开的用户文档。
|
||||||
|
|
||||||
|
- standards/:存放维护人员需遵守的规范制度与校验规则
|
||||||
|
- plans/:存放实施方案与工作交接说明
|
||||||
|
- error-experience/、good-experience/:存放内部经验总结文档
|
||||||
|
- guides/:面向维护人员的工作流程及集成实操手册
|
||||||
|
- architecture/manifest.yaml:记录可读性检查所覆盖的文件路径
|
||||||
|
|
||||||
|
对外用户文档及说明文件请统一放置在 docs/ 目录下。
|
||||||
|
|
||||||
@@ -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 discriminator,OpenAI 格式的 `{"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
|
||||||
36
.agents/docs/good-experience/2026-06-11-出现问题好的排查流程.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# 出现问题好的排查流程
|
||||||
|
|
||||||
|
## 出现问题后的排查流程
|
||||||
|
|
||||||
|
```
|
||||||
|
日志报错
|
||||||
|
│
|
||||||
|
├─ 1. 定位错误源码
|
||||||
|
│ 根据日志标签 + 错误信息 → 找到报错函数和校验条件
|
||||||
|
│
|
||||||
|
├─ 2. 分析日志时间线
|
||||||
|
│ 对比错误时间与前后事件 → 判断是主流程还是试探性调用
|
||||||
|
│
|
||||||
|
├─ 3. 编写诊断脚本(隔离测试)
|
||||||
|
│ │
|
||||||
|
│ ├─ 正常 save/load 循环 → 确认基础路径是否健康
|
||||||
|
│ ├─ 编码异常检测 → BOM、GBK 混入等
|
||||||
|
│ ├─ 数据流变更检测 → 前端编辑/外部写入后列结构变化
|
||||||
|
│ └─ 回退链检测 → glob 扫描到非预期文件
|
||||||
|
│
|
||||||
|
├─ 4. 最小化复现
|
||||||
|
│ 针对失败的测试用例,提取最简输入证明根因
|
||||||
|
│
|
||||||
|
└─ 5. 修复 + 验证
|
||||||
|
最小改动修复 → 确认不影响正常输入
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键判断点
|
||||||
|
|
||||||
|
- 错误不影响主流程 → 优先排查试探性调用和回退链
|
||||||
|
- 列名校验失败但文件肉眼正常 → 优先检查 BOM 和不可见字符
|
||||||
|
- 错误出现在外部数据入口 → 优先检查编码兼容性和数据清洗
|
||||||
261
.agents/docs/guides/工程实践指南.md
Normal 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 Ruff(Lint + 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 Makefile(Unix)
|
||||||
|
|
||||||
|
```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 操作
|
||||||
9
.agents/docs/standards/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-09
|
||||||
|
---
|
||||||
|
|
||||||
|
# 标准元数据
|
||||||
|
|
||||||
|
`.agents/docs/standards/*.md` 下所有文件都必须包含 frontmatter,字段包括:
|
||||||
|
|
||||||
|
- `last_reviewed`:最近一次策略审查的 ISO 日期 `YYYY-MM-DD`。
|
||||||
13
.agents/docs/standards/复利式工程实践.md
Normal 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 失败或发现有价值模式后,创建一条条目并记录根因与经验。
|
||||||
34
.agents/docs/standards/调试规范.md
Normal 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
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# .agents/skills — Cursor Agent 技能目录
|
||||||
|
|
||||||
|
存放 Cursor Agent 可调用的自动化技能定义。
|
||||||
|
|
||||||
|
## 技能清单
|
||||||
|
|
||||||
|
| 技能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `pre-commit-check/` | 提交前代码质量检查:运行 ruff lint/format、mypy 类型检查、deptry 依赖审计,自动修复可修复问题 |
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
Agent 在用户请求提交代码或检查代码质量时自动触发对应技能,无需手动调用。
|
||||||
68
.agents/skills/pre-commit-check/SKILL.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
name: pre-commit-check
|
||||||
|
description: >-
|
||||||
|
Run pre-commit code quality checks (ruff lint/format, mypy type check, deptry dependency audit)
|
||||||
|
and fix any issues before committing. Use when the user wants to commit code, asks to check code
|
||||||
|
quality, or mentions pre-commit validation. Also use when preparing code for submission or
|
||||||
|
when the user says "check before commit" or "run checks".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pre-Commit Code Quality Check
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
Run all checks in parallel first, then fix any issues iteratively:
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: Run checks (parallel)
|
||||||
|
- uv run ruff check .
|
||||||
|
- uv run ruff format --check .
|
||||||
|
- uv run mypy src/
|
||||||
|
- uv run deptry .
|
||||||
|
|
||||||
|
Step 2: If any check fails, fix issues
|
||||||
|
- ruff check --fix . (auto-fix lint issues)
|
||||||
|
- ruff format . (auto-format)
|
||||||
|
- Fix type annotation errors manually
|
||||||
|
|
||||||
|
Step 3: Re-run all checks to confirm
|
||||||
|
Step 4: Report results with table
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Fixes
|
||||||
|
|
||||||
|
| Error | Fix |
|
||||||
|
|-------|-----|
|
||||||
|
| `UP038` | Replace `isinstance(e, (X, Y))` with `isinstance(e, X \| Y)` |
|
||||||
|
| `no-untyped-def` | Add return type annotation (`-> None` for void functions) |
|
||||||
|
| `I001` | Run `uv run ruff check --fix .` |
|
||||||
|
| `no-any-return` | Use `cast(Type, expression)` from `typing` |
|
||||||
|
| `type-arg` missing | Add type arguments: `dict[str, Any]` instead of `dict` |
|
||||||
|
| `unused-ignore` | Remove stale `# type: ignore` comments |
|
||||||
|
|
||||||
|
## Type Annotation Rules
|
||||||
|
|
||||||
|
- Functions that modify data in-place and return nothing: `-> None`
|
||||||
|
- Functions that call untyped external APIs: add `-> Any` return type
|
||||||
|
- Dicts that hold mixed types (str + float): use `dict[str, Any]`
|
||||||
|
- Import `Any` from `typing` when needed
|
||||||
|
- Import `cast` from `typing` when `no-any-return` triggers
|
||||||
|
|
||||||
|
## Verification Report
|
||||||
|
|
||||||
|
After all checks pass, report:
|
||||||
|
|
||||||
|
| 检查项 | 状态 |
|
||||||
|
|--------|------|
|
||||||
|
| `ruff check .` | ✅ |
|
||||||
|
| `ruff format --check .` | ✅ |
|
||||||
|
| `mypy src/` | ✅ |
|
||||||
|
| `deptry .` | ✅ |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All commands use `uv run` prefix (project virtual environment)
|
||||||
|
- `mypy` has `strict = true` in `pyproject.toml` - expect strict type checking
|
||||||
|
- `ignore_missing_imports = true` is set, so missing third-party stubs are OK
|
||||||
|
- If `tests.*` override shows "unused section" note, it's normal (no tests dir yet)
|
||||||
|
- Never skip a check - all four must pass before committing
|
||||||
7
.cursorignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.playwright-mcp/
|
||||||
|
*.pyc
|
||||||
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 系统 URL 配置(服务端专用,Web 用户无需配置)
|
||||||
|
# 复制此文件为 .env 并填入实际值
|
||||||
|
|
||||||
|
SSO_LOGIN_URL=https://your-sso-url/login
|
||||||
|
PORTAL_URL=https://your-portal-url/oshall
|
||||||
|
REIMBURSE_URL=http://your-reimburse-host:8081
|
||||||
|
REIMBURSE_PAGE=/expen/common/common?v=4.0
|
||||||
|
TRAVEL_PAGE=/expen/travel/travel?v=4.0
|
||||||
|
|
||||||
|
# LLM 配置
|
||||||
|
LLM_MODEL=your-model-name
|
||||||
|
LLM_API_BASE=http://your-llm-host:port/v1
|
||||||
|
LLM_API_KEY=your-api-key
|
||||||
46
.gitignore
vendored
@@ -1,34 +1,14 @@
|
|||||||
# Python
|
.venv/
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.pyo
|
|
||||||
*.egg-info/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
.eggs/
|
|
||||||
|
|
||||||
# Playwright MCP snapshots
|
|
||||||
.playwright-mcp/
|
|
||||||
|
|
||||||
# Local secrets & backups
|
|
||||||
config.json
|
|
||||||
*.doc.bak
|
|
||||||
|
|
||||||
# Uploads (user data)
|
|
||||||
web/uploads/
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
pipeline.log
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Debug images
|
|
||||||
images/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
.cursor/
|
.cursor/
|
||||||
|
__pycache__/
|
||||||
# OS
|
.pytest_cache/
|
||||||
.DS_Store
|
.mypy_cache/
|
||||||
Thumbs.db
|
.ruff_cache/
|
||||||
|
.playwright-mcp/
|
||||||
|
*.pyc
|
||||||
|
logs/
|
||||||
|
.vscode/
|
||||||
|
uploads/
|
||||||
|
.env
|
||||||
|
images/
|
||||||
|
scripts/data/
|
||||||
7
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.9.6
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
19
AGENTS.md
Normal file
@@ -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
@@ -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
|
||||||
307
README.md
@@ -1,85 +1,151 @@
|
|||||||
# 财务报销自动化
|
# 财务报销自动化
|
||||||
|
|
||||||
自动从 PDF 发票提取信息,OCR 识别支付记录截图,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
|
自动从 PDF 发票或图片中提取信息,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
|
||||||
|
|
||||||
|
**支持发票类型区分**:系统自动识别高铁票、酒店住宿等差旅发票与普通发票。差旅发票不生成易耗品出库单,走差旅报销流程;普通发票生成出库单,走普通报销流程。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
├── run.py # CLI 入口(发票提取 → OCR → 浏览器填报)
|
├── pyproject.toml # 项目配置(依赖、工具链)
|
||||||
├── config.json # 配置文件(登录凭据、默认值、存放地点等)
|
├── uv.lock # 依赖锁定文件
|
||||||
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板(Web/CLI 填写用)
|
├── Makefile # 任务脚本(跨平台)
|
||||||
├── app/
|
├── tasks.py # 任务脚本(Windows 兼容)
|
||||||
|
├── .pre-commit-config.yaml # pre-commit 钩子配置
|
||||||
|
├── .env.example # 环境变量示例(SSO 地址、LLM 配置等)
|
||||||
|
├── config.example.json # 用户配置示例
|
||||||
|
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板
|
||||||
|
├── src/
|
||||||
|
│ ├── __init__.py # 包初始化 / 日志器
|
||||||
│ ├── config.py # 配置加载
|
│ ├── config.py # 配置加载
|
||||||
│ ├── extractor.py # PDF 发票信息提取
|
|
||||||
│ ├── ocr.py # OCR 刷卡信息识别
|
|
||||||
│ ├── bot.py # 浏览器自动填报
|
│ ├── bot.py # 浏览器自动填报
|
||||||
│ ├── pipeline.py # CLI 流程编排
|
│ ├── pipeline.py # CLI 流程编排
|
||||||
│ └── fill_consumable_doc.py # 将 CSV 填入易耗品出库单(Word COM)
|
│ ├── main.py # CLI 入口
|
||||||
├── web/
|
│ ├── doc/ # 文档处理模块
|
||||||
|
│ │ ├── extractor.py # 编排入口:串联 PDF 读取 → LLM 提取 → 分类
|
||||||
|
│ │ ├── pdf.py # PDF 图片渲染(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 服务入口
|
│ ├── app.py # Web 服务入口
|
||||||
│ ├── templates/
|
│ ├── templates/
|
||||||
│ │ ├── index.html # PC 端主页
|
│ │ ├── index.html # PC 端主页
|
||||||
│ │ └── mobile_upload.html # 移动端扫码上传
|
│ │ └── mobile_upload.html # 移动端扫码上传
|
||||||
│ ├── static/ # 前端 CSS / JS
|
│ ├── static/
|
||||||
|
│ │ ├── css/ # 样式文件
|
||||||
|
│ │ └── js/ # 前端脚本
|
||||||
│ └── uploads/ # 按会话隔离的上传与产物目录
|
│ └── uploads/ # 按会话隔离的上传与产物目录
|
||||||
├── *.pdf # 发票 PDF(CLI 模式,放在项目根目录)
|
├── scripts/ # CLI 数据目录
|
||||||
├── *.png / *.jpg # 与 PDF 配对的支付截图
|
│ ├── data/ # 发票源文件、config.json 与 .invoice_cache 缓存
|
||||||
├── invoice_summary.csv # 发票汇总表(CLI 产物)
|
│ └── test_*.py # 测试脚本
|
||||||
└── images/ # 浏览器调试截图
|
├── tests/ # 测试目录
|
||||||
|
├── docs/ # 用户文档(API 说明、操作指南等)
|
||||||
|
├── images/ # 浏览器调试截图
|
||||||
|
└── *.pdf / *.jpg / *.png # 发票 PDF 或图片(CLI 模式,放在 scripts/data/)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 数据流
|
## 数据流
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
PDF[PDF 发票] --> Extract[extractor 提取]
|
PDF[PDF 发票 / 图片] --> Extract[extractor 多模态提取]
|
||||||
Extract --> List[发票列表]
|
Extract --> Cache[(.invoice_cache/*.json)]
|
||||||
Img[支付截图] --> OCR[OCR 识别]
|
|
||||||
List --> OCR
|
Cache --> Classify{发票类型分类}
|
||||||
OCR --> CSV[(invoice_summary.csv)]
|
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]
|
CSV --> Fill[fill_consumable_doc]
|
||||||
Fill --> Doc[易耗品、出库单.doc]
|
Fill --> Doc[易耗品、出库单.doc]
|
||||||
CSV --> Bot[bot 浏览器自动化]
|
|
||||||
Bot --> Submit[财务系统填报<br/>可选]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 关键中间产物
|
||||||
|
|
||||||
|
| 文件 | 生成阶段 | 作用 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `.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 可用)
|
- Windows(易耗品出库单填写依赖 Microsoft Word + COM,仅 Windows 可用)
|
||||||
- 主要依赖见下方安装步骤
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 安装依赖
|
### 1. 安装依赖
|
||||||
|
|
||||||
> OCR 相关依赖建议在已有 PaddleOCR 的环境中安装(如 MinerU 虚拟环境)。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install pdfplumber==0.11.9 paddleocr==2.8.1 playwright==1.60.0 flask==3.0.3 pywin32
|
# 同步所有依赖(运行时 + 开发工具)
|
||||||
playwright install chromium
|
make install
|
||||||
|
# Windows 上等效命令:
|
||||||
|
python tasks.py install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
项目使用 `uv` 管理依赖,所有包版本锁定在 `uv.lock` 中,确保可复现。
|
||||||
|
|
||||||
| 依赖 | 用途 |
|
| 依赖 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| pdfplumber | PDF 发票文本提取 |
|
| PyMuPDF | PDF 图片渲染(供多模态 LLM 使用) |
|
||||||
| paddleocr | 支付截图 OCR |
|
| llama-index | LLM 信息提取(发票识别、差旅信息提取) |
|
||||||
| playwright | 财务系统浏览器自动化 |
|
| playwright | 财务系统浏览器自动化 |
|
||||||
| flask | Web 服务 |
|
| flask | Web 服务 |
|
||||||
| pywin32 | 填写 Word 出库单(`fill_consumable_doc`) |
|
| pywin32 | 填写 Word 出库单(`fill_consumable_doc`) |
|
||||||
|
|
||||||
### 2. 准备数据(CLI 模式)
|
### 2. 准备数据(CLI 模式)
|
||||||
|
|
||||||
将发票 PDF 和对应的支付截图放在项目根目录下。脚本会自动匹配 PDF 与截图:
|
将发票 PDF 或图片(`.jpg`、`.png`、`.webp`、`.bmp`)放在 `scripts/data/` 目录下。
|
||||||
|
|
||||||
1. **优先文件名匹配** — PDF 与截图同名(如 `发票.pdf` ↔ `发票.png`)
|
|
||||||
2. **金额近邻匹配** — 文件名不同时,按价税合计与刷卡金额就近配对
|
|
||||||
|
|
||||||
截图支持格式:`.png`、`.jpg`、`.jpeg`、`.bmp`、`.webp`。
|
|
||||||
|
|
||||||
### 3. 配置
|
### 3. 配置
|
||||||
|
|
||||||
编辑 `config.json`:
|
编辑 `scripts/config.json`(参考 `config.example.json`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -88,7 +154,7 @@ playwright install chromium
|
|||||||
"default_name": "默认报销人姓名",
|
"default_name": "默认报销人姓名",
|
||||||
"default_card_no": "默认公务卡号",
|
"default_card_no": "默认公务卡号",
|
||||||
"default_person_id": "默认人员编号",
|
"default_person_id": "默认人员编号",
|
||||||
"consumable_storage": "躬行楼 C205"
|
"consumable_storage": "物料存储地"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -98,22 +164,35 @@ playwright install chromium
|
|||||||
| `default_name` | 默认报销人姓名 |
|
| `default_name` | 默认报销人姓名 |
|
||||||
| `default_card_no` | 默认公务卡号 |
|
| `default_card_no` | 默认公务卡号 |
|
||||||
| `default_person_id` | 默认人员编号(工号) |
|
| `default_person_id` | 默认人员编号(工号) |
|
||||||
| `consumable_storage` | 出库单「存放地点」列默认值 |
|
| `consumable_storage` | 出库单「存放地点」列默认值(默认: `躬行楼 C205`) |
|
||||||
| `sso_login_url` 等 | 系统 URL,一般无需修改 |
|
|
||||||
|
**服务端配置**(SSO 地址、报销系统 URL、LLM 参数)通过环境变量提供,有默认值,一般无需修改:
|
||||||
|
|
||||||
|
| 环境变量 | 默认值 | 说明 |
|
||||||
|
|----------|--------|------|
|
||||||
|
| `SSO_LOGIN_URL` | `https://tyrz.fynu.edu.cn/sso/login` | SSO 登录地址 |
|
||||||
|
| `PORTAL_URL` | `https://tyrz.fynu.edu.cn/oshall` | 统一信息平台地址 |
|
||||||
|
| `REIMBURSE_URL` | `http://210.45.32.214:8081` | 报销系统地址 |
|
||||||
|
| `REIMBURSE_PAGE` | `/expen/common/common?v=4.0` | 普通报销页面路径 |
|
||||||
|
| `TRAVEL_PAGE` | `/expen/travel/travel?v=4.0` | 差旅报销页面路径 |
|
||||||
|
| `LLM_MODEL` | `qwen-vl-max` | LLM 模型名称 |
|
||||||
|
| `LLM_API_BASE` | `http://localhost:8080/v1` | LLM API 地址 |
|
||||||
|
| `LLM_API_KEY` | `lm-studio` | LLM API 密钥 |
|
||||||
|
|
||||||
### 4. 运行(CLI)
|
### 4. 运行(CLI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 全流程(发票提取 → OCR 识别 → 浏览器填报)
|
# 全流程(发票提取 → 浏览器填报)
|
||||||
python run.py
|
make run
|
||||||
|
# Windows 等效:
|
||||||
|
python tasks.py run
|
||||||
|
|
||||||
# 仅执行某一步
|
# 仅执行某一步
|
||||||
python run.py --step invoice # 仅发票提取
|
uv run python src/main.py --step invoice # 仅发票提取
|
||||||
python run.py --step ocr # 仅 OCR 识别
|
uv run python src/main.py --step submit # 仅浏览器填报
|
||||||
python run.py --step submit # 仅浏览器填报
|
|
||||||
|
|
||||||
# 覆盖配置中的登录凭据
|
# 覆盖配置中的登录凭据
|
||||||
python run.py -u 工号 -p 密码
|
uv run python src/main.py -u 工号 -p 密码
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 填写易耗品出库单(CLI)
|
### 5. 填写易耗品出库单(CLI)
|
||||||
@@ -121,88 +200,160 @@ python run.py -u 工号 -p 密码
|
|||||||
需已生成 `invoice_summary.csv`,且本机已安装 **Microsoft Word**:
|
需已生成 `invoice_summary.csv`,且本机已安装 **Microsoft Word**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m app.fill_consumable_doc
|
uv run python -m src.doc.fill_consumable_doc
|
||||||
python -m app.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||||
python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
|
uv run python -m src.doc.fill_consumable_doc --config scripts/config.json # 指定配置文件
|
||||||
|
uv run python -m src.doc.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
|
||||||
```
|
```
|
||||||
|
|
||||||
填写规则概要:
|
填写规则概要:
|
||||||
|
|
||||||
- 表头「日期」使用**填写当天**的日期(非发票开票日期)
|
- 表头「日期」使用**填写当天**的日期(非发票开票日期)
|
||||||
- 从 `规格型号` 解析品名、规格、单位、数量、单价;`价税合计` 写入金额列
|
- 从 `spec_model` 解析品名、规格、单位、数量、单价;`card_amount` 写入金额列
|
||||||
- 单价/金额保留两位小数;表格内统一为 **宋体五号(10.5 磅)**
|
- 单价/金额保留两位小数;表格内统一为 **宋体五号(10.5 磅)**
|
||||||
- 存放地点取自 `consumable_storage`;购货人/领用人签字、备注保持空白
|
- 存放地点取自 `consumable_storage`(默认: `躬行楼 C205`);购货人/领用人签字、备注保持空白
|
||||||
|
|
||||||
## 执行步骤说明
|
## 执行步骤说明
|
||||||
|
|
||||||
| 步骤 | 命令 | 说明 |
|
| 步骤 | 命令 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 发票提取 | `--step invoice` | 扫描根目录 PDF,生成 `invoice_summary.csv` / `.md` |
|
| 发票提取 | `--step invoice` | 扫描 `scripts/data/` 目录的 PDF 和图片,生成 `invoice_summary.csv` |
|
||||||
| OCR 识别 | `--step ocr` | 识别支付截图,回填刷卡日期、金额、持卡人等到 CSV |
|
|
||||||
| 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 |
|
| 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 |
|
||||||
|
|
||||||
> 分步执行时,上一步的 CSV 会自动成为下一步的输入。
|
> 全流程执行时数据在内存中流转,CSV 为参考产物。分步执行时,缓存数据会自动成为下一步的输入。
|
||||||
|
|
||||||
|
### 缓存机制
|
||||||
|
|
||||||
|
系统使用 JSON 缓存作为数据中转站,串联整个处理流程:
|
||||||
|
|
||||||
|
```
|
||||||
|
源文件 (PDF/图片) → LLM 多模态提取 → JSON 缓存 → 匹配/分类/填报
|
||||||
|
```
|
||||||
|
|
||||||
|
| 缓存文件 | 说明 |
|
||||||
|
|----------|------|
|
||||||
|
| `<文件名>.json` | 每个源文件的 LLM 提取结果(发票信息、支付记录等) |
|
||||||
|
| `match_result.json` | 发票与支付记录的匹配结果 |
|
||||||
|
| `travel_info.json` | 差旅信息(事由、地点、时间等)提取结果 |
|
||||||
|
|
||||||
|
缓存位置:CLI 模式为 `scripts/data/.invoice_cache/`,Web 模式为 `src/web/uploads/<session_id>/.invoice_cache/`。缓存文件与源文件同名(如 `发票1.pdf` 对应 `.invoice_cache/发票1.json`),后续步骤(金额匹配、发票分类、浏览器填报)均从缓存读取结构化数据。删除缓存后下次处理会重新提取。
|
||||||
|
|
||||||
### CSV 字段说明
|
### CSV 字段说明
|
||||||
|
|
||||||
|
发票级别 CSV(`invoice_summary.csv`)使用英文列名:
|
||||||
|
|
||||||
| 列名 | 说明 |
|
| 列名 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 序号 | 行号 |
|
| `index` | 行号 |
|
||||||
| 发票号码 | 电子发票号码 |
|
| `invoice_type` | 发票类型:`train` / `hotel` / `general` |
|
||||||
| 开票日期 | 开票日期 |
|
| `invoice_number` | 电子发票号码 |
|
||||||
| 项目名称 / 规格型号 | 货物或应税劳务信息 |
|
| `invoice_date` | 开票日期 |
|
||||||
| 价税合计 | 发票含税金额 |
|
| `item_name` | 货物或应税劳务名称 |
|
||||||
| 销售方名称 | 销方名称 |
|
| `spec_model` | 规格型号(差旅发票为出发站→到达站) |
|
||||||
| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | OCR 自支付截图回填 |
|
| `total_amount` | 发票含税金额 |
|
||||||
| 备注 / 工号 | 可手工或 Web 端编辑补充 |
|
| `seller_name` | 销方名称 |
|
||||||
|
| `departure` / `arrival` | 出发站 / 到达站(高铁票专用) |
|
||||||
|
| `train_no` / `ride_date` / `seat_class` | 车次 / 乘车日期 / 座位等级(高铁票专用) |
|
||||||
|
| `person_name` | 人员姓名 |
|
||||||
|
| `card_date` / `card_no` / `card_amount` | 刷卡日期 / 公务卡号 / 刷卡金额 |
|
||||||
|
| `remark` | 备注 |
|
||||||
|
| `person_id` | 工号 |
|
||||||
|
|
||||||
## Web 服务
|
## Web 服务
|
||||||
|
|
||||||
提供浏览器界面:上传文件 → 自动处理 → 在线编辑 → 下载产物 → 可选提交财务系统。
|
提供浏览器界面:上传文件 → 自动处理 → 在线编辑 → 下载产物 → 可选提交财务系统。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python web/app.py
|
uv run python src/web/app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 `http://localhost:5000`。
|
访问 `http://localhost:5000`。
|
||||||
|
|
||||||
### 推荐使用流程
|
### 推荐使用流程
|
||||||
|
|
||||||
1. 上传 PDF + 支付截图(或上传已有 CSV)
|
1. 上传 PDF 或图片(或上传已有 CSV)
|
||||||
2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充
|
2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充
|
||||||
3. 点击 **开始处理** — 完成发票提取、OCR、生成 CSV,并自动填写 **易耗品、出库单.doc**
|
3. 点击 **开始处理** — 完成发票提取、生成 CSV,系统自动识别发票类型并分类统计
|
||||||
4. 在 **下载文件** 区域下载 CSV / Markdown / 出库单 Word
|
4. **普通发票**:自动生成 **易耗品、出库单.doc**,可下载
|
||||||
5. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
|
5. **差旅发票**(高铁票/酒店住宿):跳过出库单生成,直接进入差旅报销流程
|
||||||
6. 确认无误后点击 **提交到财务系统**
|
6. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
|
||||||
|
7. 确认无误后点击 **提交到财务系统**
|
||||||
|
|
||||||
### 处理模式
|
### 处理流程
|
||||||
|
|
||||||
| 模式 | 入口 | 说明 |
|
上传 PDF 或图片 → LLM 识别文档类型 → 结构化提取 → 分类处理
|
||||||
|------|------|------|
|
|
||||||
| **PDF 模式** | 上传 PDF + 截图 | 提取发票 → OCR → 生成 CSV + 出库单 |
|
系统通过 LLM 多模态识别自动判断每张文档的类型,无需手动指定:
|
||||||
| **CSV 快捷模式** | 仅上传 CSV | 跳过提取与 OCR,直接生成出库单并进入编辑/提交 |
|
|
||||||
|
| 文档类型 | 处理方式 | 状态 |
|
||||||
|
|----------|----------|------|
|
||||||
|
| 发票(高铁票/酒店住宿/普通发票) | 提取发票信息 → 金额匹配 → 分类 | 已实现 |
|
||||||
|
| 支付记录(刷卡截图) | 提取刷卡信息 → 与发票匹配 | 已实现 |
|
||||||
|
| 出差事前申请单 | 提取出差事由、地点、时间 | 已实现 |
|
||||||
|
| 飞机票 | 同高铁票处理流程 | 计划中 |
|
||||||
|
|
||||||
### 功能一览
|
### 功能一览
|
||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 发票提取 + OCR | 上传 PDF 后自动完成 |
|
| 发票提取 | 上传 PDF 或图片后自动完成 |
|
||||||
| 易耗品出库单 | 处理完成后自动生成 Word,可下载 |
|
| 发票类型自动分类 | 高铁票/酒店住宿/普通发票,自动分流处理 |
|
||||||
|
| 易耗品出库单 | 仅普通发票自动生成 Word,差旅发票跳过 |
|
||||||
| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 |
|
| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 |
|
||||||
| 财务系统填报 | 单独按钮触发,处理阶段不会自动提交 |
|
| 财务系统填报 | 单独按钮触发,处理阶段不会自动提交 |
|
||||||
| 实时日志 | SSE 推送处理进度 |
|
| 实时日志 | SSE 推送处理进度 |
|
||||||
| 配置上传 | 支持上传 `config.json` 填充表单 |
|
| 配置上传 | 支持上传 `config.json` 填充表单 |
|
||||||
| 手机扫码上传 | 二维码打开移动端页面,拍照上传支付截图,PC 端轮询同步 |
|
| 手机扫码上传 | 二维码打开移动端页面,拍照上传,PC 端轮询同步 |
|
||||||
|
|
||||||
> Web 端浏览器填报以无头模式运行。未上传 PDF 时,填报阶段会跳过附件上传。
|
> Web 端浏览器填报以无头模式运行。未上传 PDF 或图片时,填报阶段会跳过附件上传。
|
||||||
> 出库单生成需要 **Windows + Word + pywin32**;若失败,页面会显示具体原因,CSV 等其它产物仍可正常使用。
|
> 出库单生成需要 **Windows + Word + pywin32**;若失败,页面会显示具体原因,CSV 等其它产物仍可正常使用。
|
||||||
|
|
||||||
|
### 会话产物
|
||||||
|
|
||||||
|
每次上传生成独立会话,产物存放在 `src/web/uploads/<session_id>/`:
|
||||||
|
|
||||||
|
| 产物 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `invoice_summary.csv` | 发票汇总数据(含 LLM 识别结果) |
|
||||||
|
| `payment_records.csv` | 支付记录级别数据(含匹配结果) |
|
||||||
|
| `travel_applications.json` | 出差事前申请单数据(JSON 格式) |
|
||||||
|
| `易耗品、出库单.doc` | 自动填写的出库单(仅普通发票) |
|
||||||
|
| `config.json` | 当次会话配置 |
|
||||||
|
| `session.log` | 处理日志 |
|
||||||
|
| `result.json` | 处理结果 |
|
||||||
|
| `.invoice_cache/` | LLM 提取结果缓存(JSON 格式,避免重复处理) |
|
||||||
|
|
||||||
|
会话缓存机制与 CLI 模式相同,缓存目录中的 JSON 数据是后续匹配、分类和填报的唯一数据来源。
|
||||||
|
|
||||||
接口说明见 [API.md](./API.md)。
|
接口说明见 [API.md](./API.md)。
|
||||||
|
|
||||||
|
## 开发任务
|
||||||
|
|
||||||
|
项目提供统一的任务脚本,支持跨平台使用:
|
||||||
|
|
||||||
|
| 任务 | Makefile | tasks.py | 说明 |
|
||||||
|
|------|----------|----------|------|
|
||||||
|
| 安装依赖 | `make install` | `python tasks.py install` | 同步依赖 + 安装 pre-commit |
|
||||||
|
| 代码检查 | `make check` | `python tasks.py check` | Ruff lint + 格式化 + MyPy 类型检查 + deptry 依赖检查 |
|
||||||
|
| 运行测试 | `make test` | `python tasks.py test` | pytest + 覆盖率报告 |
|
||||||
|
| 运行 CLI | `make run` | `python tasks.py run` | 执行全流程 |
|
||||||
|
| 清理缓存 | `make clean` | `python tasks.py clean` | 删除虚拟环境和缓存 |
|
||||||
|
|
||||||
|
## 代码质量
|
||||||
|
|
||||||
|
项目配置了完整的代码质量工具链:
|
||||||
|
|
||||||
|
- **Ruff** — 快速 lint 检查和代码格式化(替代 flake8 + isort + black)
|
||||||
|
- **MyPy** — 严格模式类型检查(`strict = true`)
|
||||||
|
- **deptry** — 检测未声明、未使用、过时依赖
|
||||||
|
- **pre-commit** — 提交前自动运行 Ruff 检查和格式化
|
||||||
|
|
||||||
|
所有检查通过后方可提交代码。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 浏览器填报时会打开或使用 Chromium,请勿手动干扰自动化流程
|
- 浏览器填报时会打开或使用 Chromium,请勿手动干扰自动化流程
|
||||||
- 调试截图保存在 `images/` 目录
|
- 调试截图保存在 `images/` 目录
|
||||||
- OCR 不会覆盖 CSV 中已有非空字段
|
|
||||||
- 项目根目录需保留 `易耗品、出库单.doc` 模板;Web 每次从模板复制到会话目录再填写,不修改原模板
|
- 项目根目录需保留 `易耗品、出库单.doc` 模板;Web 每次从模板复制到会话目录再填写,不修改原模板
|
||||||
- `config.json` 含敏感信息,请勿提交到公开仓库
|
- `scripts/config.json` 含敏感信息,请勿提交到公开仓库
|
||||||
|
- **发票类型区分**:差旅发票(高铁票/酒店住宿)不会生成易耗品出库单,差旅报销填报流程已完整实现(含差旅信息提取、明细录入、支付方式、补助清单、附件上传)
|
||||||
@@ -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
|
|
||||||
423
app/bot.py
@@ -1,423 +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
|
|
||||||
try:
|
|
||||||
self._wait_for("#insertAcc", timeout=20000) # 等待增加按钮出现
|
|
||||||
self.page.click("#insertAcc", timeout=5000) #点击增加按钮出现
|
|
||||||
self._wait_for("#fjlx", timeout=5000)
|
|
||||||
self.page.select_option("#fjlx", "1")
|
|
||||||
explanation = f"{inv['item_name']} - {inv['invoice_no']}"
|
|
||||||
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)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
self.page.press("body", "Escape")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
log.info("上传附件完成")
|
|
||||||
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()
|
|
||||||
@@ -1,34 +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", ""),
|
|
||||||
"consumable_storage": raw.get("consumable_storage", "躬行楼 C205"),
|
|
||||||
"attachment_dir": project_root / "attachments",
|
|
||||||
}
|
|
||||||
256
app/extractor.py
@@ -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
|
|
||||||
454
app/ocr.py
@@ -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
|
|
||||||
128
app/pipeline.py
@@ -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
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
{
|
{
|
||||||
"username": "你的工号",
|
"username": "你的工号",
|
||||||
"password": "你的密码",
|
"password": "你的密码",
|
||||||
"sso_login_url": "https://tyrz.fynu.edu.cn/sso/login",
|
|
||||||
"portal_url": "https://tyrz.fynu.edu.cn/oshall",
|
|
||||||
"reimburse_url": "http://210.45.32.214:8081",
|
|
||||||
"reimburse_page": "/expen/common/common?v=4.0",
|
|
||||||
"default_name": "默认报销人姓名",
|
"default_name": "默认报销人姓名",
|
||||||
"default_card_no": "默认公务卡号",
|
"default_card_no": "默认公务卡号",
|
||||||
"default_person_id": "默认人员编号",
|
"default_person_id": "默认人员编号",
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-09
|
||||||
|
---
|
||||||
|
|
||||||
# 财务报销自动化 — API 文档
|
# 财务报销自动化 — API 文档
|
||||||
|
|
||||||
> 基础地址: `http://localhost:5000`
|
> 基础地址: `http://localhost:5000`
|
||||||
> 启动: `python web/app.py`
|
> 启动: `uv run python src/web/app.py`
|
||||||
|
|
||||||
## 总览
|
## 总览
|
||||||
|
|
||||||
@@ -10,24 +14,23 @@
|
|||||||
| 1 | GET | `/` | PC 端主页 |
|
| 1 | GET | `/` | PC 端主页 |
|
||||||
| 2 | POST | `/api/session` | 创建会话 |
|
| 2 | POST | `/api/session` | 创建会话 |
|
||||||
| 3 | POST | `/api/upload/<session_id>` | 上传文件(PDF/图片) |
|
| 3 | POST | `/api/upload/<session_id>` | 上传文件(PDF/图片) |
|
||||||
| 4 | POST | `/api/upload-csv/<session_id>` | 上传 CSV 发票数据 |
|
| 4 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
|
||||||
| 5 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
|
| 5 | POST | `/api/process/<session_id>` | 启动处理(提取+LLM识别+出库单) |
|
||||||
| 6 | POST | `/api/process/<session_id>` | 启动处理(提取+OCR+出库单) |
|
| 6 | GET | `/api/logs/<session_id>` | SSE 日志流 |
|
||||||
| 7 | GET | `/api/logs/<session_id>` | SSE 日志流 |
|
| 7 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
|
||||||
| 8 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
|
| 8 | GET | `/api/data/<session_id>` | 获取发票数据(JSON) |
|
||||||
| 9 | GET | `/api/data/<session_id>` | 获取发票数据(JSON) |
|
| 9 | POST | `/api/save/<session_id>` | 保存编辑后的发票数据 |
|
||||||
| 10 | POST | `/api/save/<session_id>` | 保存编辑后的发票数据 |
|
| 10 | POST | `/api/submit-financial/<session_id>` | 提交到财务系统 |
|
||||||
| 11 | POST | `/api/submit-financial/<session_id>` | 提交到财务系统 |
|
| 11 | GET | `/mobile/<session_id>` | 移动端上传页面 |
|
||||||
| 12 | GET | `/mobile/<session_id>` | 移动端上传页面 |
|
| 12 | POST | `/api/mobile-upload/<session_id>` | 移动端上传图片 |
|
||||||
| 13 | POST | `/api/mobile-upload/<session_id>` | 移动端上传图片 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 会话与目录
|
## 会话与目录
|
||||||
|
|
||||||
- 调用 `POST /api/session` 获得 `session_id`
|
- 调用 `POST /api/session` 获得 `session_id`
|
||||||
- 该会话下所有文件存放在 `web/uploads/<session_id>/`
|
- 该会话下所有文件存放在 `src/web/uploads/<session_id>/`
|
||||||
- 典型产物:`invoice_summary.csv`、`invoice_summary.md`、`易耗品、出库单.doc`、`config.json`、`session.log`、`result.json`
|
- 典型产物:`invoice_summary.csv`、`易耗品、出库单.doc`、`config.json`、`session.log`、`result.json`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -74,28 +77,7 @@ HTTP `400`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. 上传 CSV 发票数据
|
### 3. 列出会话文件
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/upload-csv/<session_id>
|
|
||||||
Content-Type: multipart/form-data
|
|
||||||
```
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| file | File | 发票汇总 CSV |
|
|
||||||
|
|
||||||
**响应(成功):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "ok": true, "filename": "invoice_summary.csv" }
|
|
||||||
```
|
|
||||||
|
|
||||||
> 上传 CSV 后可跳过 PDF 提取和 OCR,直接进入处理/编辑流程。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 列出会话文件
|
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/files/<session_id>
|
GET /api/files/<session_id>
|
||||||
@@ -125,7 +107,6 @@ Content-Type: application/json
|
|||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| mode | string | 否 | `"pdf"` / `"csv"` / `"auto"`(默认 `auto`) |
|
|
||||||
| username | string | 否 | 财务系统工号 |
|
| username | string | 否 | 财务系统工号 |
|
||||||
| password | string | 否 | 登录密码 |
|
| password | string | 否 | 登录密码 |
|
||||||
| default_name | string | 否 | 默认报销人姓名 |
|
| default_name | string | 否 | 默认报销人姓名 |
|
||||||
@@ -133,21 +114,13 @@ Content-Type: application/json
|
|||||||
| default_person_id | string | 否 | 默认人员编号 |
|
| default_person_id | string | 否 | 默认人员编号 |
|
||||||
| consumable_storage | string | 否 | 出库单存放地点;未填则用 `config.json` 中的值 |
|
| consumable_storage | string | 否 | 出库单存放地点;未填则用 `config.json` 中的值 |
|
||||||
|
|
||||||
**mode 行为:**
|
**处理内容:**
|
||||||
|
|
||||||
| mode | 行为 |
|
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv`
|
||||||
|------|------|
|
2. 对支付截图多模态 LLM 识别,回填刷卡字段
|
||||||
| `auto` | 仅有 CSV、无 PDF → CSV 模式;否则 → PDF 提取 + OCR |
|
3. 根据发票类型自动分类:差旅发票(高铁票/酒店住宿)不生成出库单;普通发票从模板复制并自动填写
|
||||||
| `csv` | 使用已上传 CSV,跳过提取与 OCR |
|
|
||||||
| `pdf` | 执行 PDF 提取 + OCR |
|
|
||||||
|
|
||||||
**处理内容(PDF 模式):**
|
配置会写入 `src/web/uploads/<session_id>/config.json`。
|
||||||
|
|
||||||
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv` / `.md`
|
|
||||||
2. 对支付截图 OCR,回填刷卡字段
|
|
||||||
3. 从项目根目录复制 `易耗品、出库单.doc` 模板到会话目录并自动填写(需 Windows + Word)
|
|
||||||
|
|
||||||
配置会写入 `web/uploads/<session_id>/config.json`。
|
|
||||||
|
|
||||||
**响应(立即):**
|
**响应(立即):**
|
||||||
|
|
||||||
@@ -165,12 +138,28 @@ Content-Type: application/json
|
|||||||
"elapsed": "45.2s",
|
"elapsed": "45.2s",
|
||||||
"invoice_count": 4,
|
"invoice_count": 4,
|
||||||
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
||||||
"md_url": "/api/download/<session_id>/invoice_summary.md",
|
"travel_count": 2,
|
||||||
|
"general_count": 2,
|
||||||
"doc_url": "/api/download/<session_id>/%E6%98%93%E8%80%97%E5%93%81%E3%80%81%E5%87%BA%E5%BA%93%E5%8D%95.doc",
|
"doc_url": "/api/download/<session_id>/%E6%98%93%E8%80%97%E5%93%81%E3%80%81%E5%87%BA%E5%BA%93%E5%8D%95.doc",
|
||||||
"doc_ok": true
|
"doc_ok": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**纯差旅发票(跳过出库单生成):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"invoice_count": 3,
|
||||||
|
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
||||||
|
"travel_count": 3,
|
||||||
|
"general_count": 0,
|
||||||
|
"doc_ok": null,
|
||||||
|
"doc_skipped": true,
|
||||||
|
"doc_message": "差旅发票无需生成易耗品出库单"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**出库单生成失败时(CSV 等仍可能成功):**
|
**出库单生成失败时(CSV 等仍可能成功):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -178,11 +167,23 @@ Content-Type: application/json
|
|||||||
"ok": true,
|
"ok": true,
|
||||||
"invoice_count": 4,
|
"invoice_count": 4,
|
||||||
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
||||||
|
"travel_count": 2,
|
||||||
|
"general_count": 2,
|
||||||
"doc_ok": false,
|
"doc_ok": false,
|
||||||
"doc_error": "服务器未安装 pywin32,无法生成 Word 出库单"
|
"doc_error": "服务器未安装 pywin32,无法生成 Word 出库单"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**字段说明:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `travel_count` | int | 差旅发票数量(高铁票/酒店住宿) |
|
||||||
|
| `general_count` | int | 普通发票数量 |
|
||||||
|
| `doc_ok` | bool/null | `true`=成功,`false`=失败,`null`=已跳过(纯差旅发票) |
|
||||||
|
| `doc_skipped` | bool | 是否因纯差旅发票而跳过出库单生成 |
|
||||||
|
| `doc_message` | string | 跳过时的提示信息 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. SSE 日志流
|
### 6. SSE 日志流
|
||||||
@@ -211,9 +212,11 @@ data: 2026-05-26 12:00:01 [INFO ] extractor: 正在提取发票...
|
|||||||
|
|
||||||
| 来源 | 典型字段 |
|
| 来源 | 典型字段 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `/api/process` | `ok`, `elapsed`, `invoice_count`, `csv_url`, `md_url`, `doc_url`, `doc_ok`, `doc_error` |
|
| `/api/process` | `ok`, `elapsed`, `invoice_count`, `csv_url`, `travel_count`, `general_count`, `doc_url`, `doc_ok`, `doc_skipped`, `doc_error` |
|
||||||
| `/api/submit-financial` | `ok`, `submit_ok`, `submit_error` |
|
| `/api/submit-financial` | `ok`, `submit_ok`, `submit_error` |
|
||||||
|
|
||||||
|
> SSE 超时时间为 10 分钟(600 秒)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 7. 下载文件
|
### 7. 下载文件
|
||||||
@@ -227,7 +230,6 @@ GET /api/download/<session_id>/<filename>
|
|||||||
| 扩展名 | Content-Type |
|
| 扩展名 | Content-Type |
|
||||||
|--------|----------------|
|
|--------|----------------|
|
||||||
| `.csv` | `text/csv; charset=utf-8` |
|
| `.csv` | `text/csv; charset=utf-8` |
|
||||||
| `.md` | `text/markdown; charset=utf-8` |
|
|
||||||
| `.doc` | `application/msword` |
|
| `.doc` | `application/msword` |
|
||||||
| 其它 | `application/octet-stream` |
|
| 其它 | `application/octet-stream` |
|
||||||
|
|
||||||
@@ -235,10 +237,8 @@ GET /api/download/<session_id>/<filename>
|
|||||||
|
|
||||||
| 文件名 | 说明 |
|
| 文件名 | 说明 |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| `invoice_summary.csv` | 发票汇总(含 OCR 结果) |
|
| `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) |
|
||||||
| `invoice_summary.md` | Markdown 摘要 |
|
|
||||||
| `易耗品、出库单.doc` | 自动填写的出库单 |
|
| `易耗品、出库单.doc` | 自动填写的出库单 |
|
||||||
| 用户上传的 CSV 名 | CSV 快捷模式下的原始文件 |
|
|
||||||
|
|
||||||
**错误:**
|
**错误:**
|
||||||
|
|
||||||
@@ -328,7 +328,17 @@ Content-Type: application/json
|
|||||||
|
|
||||||
保存后会根据最新 CSV **重新生成** 出库单 Word(与会话 `config.json` 中的 `consumable_storage` 等配置一致)。
|
保存后会根据最新 CSV **重新生成** 出库单 Word(与会话 `config.json` 中的 `consumable_storage` 等配置一致)。
|
||||||
|
|
||||||
若出库单生成失败:
|
**纯差旅发票跳过出库单:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"doc_ok": null,
|
||||||
|
"doc_skipped": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**出库单生成失败:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -348,10 +358,14 @@ POST /api/submit-financial/<session_id>
|
|||||||
|
|
||||||
**前置条件:**
|
**前置条件:**
|
||||||
|
|
||||||
- 会话目录存在 `config.json`(由 `/api/process` 写入)
|
- 会话目录存在 `config.json`(由 `/api/process` 写入),否则返回 `400`
|
||||||
- 存在可用的发票 CSV(通常为 `invoice_summary.csv`)
|
- 存在可用的发票 CSV(通常为 `invoice_summary.csv`)
|
||||||
|
|
||||||
**说明:** 前端一般在提交前调用 `/api/save` 保存表格修改。本接口**不会**自动执行发票提取或 OCR。
|
**说明:**
|
||||||
|
|
||||||
|
- 前端一般在提交前调用 `/api/save` 保存表格修改
|
||||||
|
- 本接口**不会**自动执行发票提取或 LLM 识别
|
||||||
|
- 根据发票类型选择填报模式:纯差旅发票走差旅报销流程,含普通发票走普通报销流程
|
||||||
|
|
||||||
**响应(立即):**
|
**响应(立即):**
|
||||||
|
|
||||||
@@ -416,6 +430,19 @@ Content-Type: multipart/form-data
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 发票类型分类
|
||||||
|
|
||||||
|
系统自动将发票分为两类,影响出库单生成和后续报销流程:
|
||||||
|
|
||||||
|
| 类型 | 判断依据 | 出库单 | 报销流程 |
|
||||||
|
|------|----------|--------|----------|
|
||||||
|
| 差旅发票 | 高铁票、酒店住宿等 | 不生成 | 差旅报销 |
|
||||||
|
| 普通发票 | 其他(办公用品、耗材等) | 自动生成 | 普通报销 |
|
||||||
|
|
||||||
|
`/api/process` 和 `/api/save` 的响应中 `travel_count` / `general_count` 即为分类统计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 端到端流程
|
## 端到端流程
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -430,8 +457,12 @@ sequenceDiagram
|
|||||||
|
|
||||||
PC->>Server: POST /api/upload/{sid}
|
PC->>Server: POST /api/upload/{sid}
|
||||||
PC->>Server: POST /api/process/{sid}
|
PC->>Server: POST /api/process/{sid}
|
||||||
Note over Server: PDF 提取 + OCR + 写 config.json
|
Note over Server: PDF 提取 + LLM 识别 + 写 config.json
|
||||||
Server->>Word: 复制模板并填写出库单
|
alt 含普通发票
|
||||||
|
Server->>Word: 从模板复制并填写出库单
|
||||||
|
else 纯差旅发票
|
||||||
|
Note over Server: 跳过出库单生成
|
||||||
|
end
|
||||||
Server-->>PC: SSE done (csv_url, doc_url, ...)
|
Server-->>PC: SSE done (csv_url, doc_url, ...)
|
||||||
|
|
||||||
PC->>Server: GET /api/data/{sid}
|
PC->>Server: GET /api/data/{sid}
|
||||||
@@ -457,7 +488,7 @@ sequenceDiagram
|
|||||||
不经过 Web、在本地直接填写出库单:
|
不经过 Web、在本地直接填写出库单:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m app.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||||
```
|
```
|
||||||
|
|
||||||
详见 [README.md](./README.md)。
|
详见 [README.md](./README.md)。
|
||||||
18
docs/README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# docs — 公开文档目录
|
||||||
|
|
||||||
|
本目录存放面向项目用户和外部贡献者的公开文档及说明文件。
|
||||||
|
|
||||||
|
## 文档清单
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `API.md` | Web API 完整接口文档:路由、请求/响应格式、SSE 日志流、错误码、端到端流程 |
|
||||||
|
| `报销操作指南.md` | 面向最终用户的操作步骤说明 |
|
||||||
|
|
||||||
|
## 文档边界
|
||||||
|
|
||||||
|
维护规范、实施方案、经验总结等内部资料统一放置在 `.agents/` 目录下,不混入本目录。
|
||||||
454
docs/报销操作指南.md
Normal 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)。
|
||||||
BIN
images/debug_after_add_click.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
images/debug_error.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
images/debug_item_total.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
images/debug_item_total_error.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
images/debug_normal_attachment_done.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
images/debug_normal_attachment_error.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
images/debug_normal_item_done.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
images/debug_normal_payment_done.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
images/debug_portal_loaded.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
images/debug_step3_done.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
images/debug_step3_project_modal.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
images/debug_step3_project_selected.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
images/debug_step5_done.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
images/debug_step5_error.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
images/debug_step6_done.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
images/debug_step6_error.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
images/debug_subsidy_done.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
images/debug_subsidy_error.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/debug_travel_attachment_done.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
images/debug_travel_basic_done.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
49
pyproject.toml
Normal 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 = ["."]
|
||||||
21
scripts/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# scripts — 测试脚本目录
|
||||||
|
|
||||||
|
存放用于测试各模块功能的独立脚本,可直接运行。
|
||||||
|
|
||||||
|
## 脚本清单
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `test_multimodal.py` | 测试 PDF 多模态提取完整链路(PDF 渲染 + LLM 提取) |
|
||||||
|
| `test_travel_info.py` | 测试差旅信息提取函数(数据从 `data/.invoice_cache` 缓存加载) |
|
||||||
|
|
||||||
|
## 运行方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python scripts/test_multimodal.py
|
||||||
|
uv run python scripts/test_travel_info.py
|
||||||
|
```
|
||||||
81
scripts/test_multimodal.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""测试 PDF 多模态提取完整链路
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/test_multimodal.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Windows 终端强制 UTF-8
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT)) # noqa: E402
|
||||||
|
|
||||||
|
from src.doc.llm_extractor import extract_document # noqa: E402
|
||||||
|
from src.doc.pdf import render_pdf_to_images # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_render() -> None:
|
||||||
|
"""测试 PDF 渲染"""
|
||||||
|
pdf_path = ROOT / "事前申请单.pdf"
|
||||||
|
if not pdf_path.exists():
|
||||||
|
print(f"跳过: {pdf_path.name} 不存在")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("测试 PDF 渲染")
|
||||||
|
print("=" * 60)
|
||||||
|
try:
|
||||||
|
images = render_pdf_to_images(pdf_path, dpi=150)
|
||||||
|
if images:
|
||||||
|
print(f"成功渲染 {len(images)} 页")
|
||||||
|
for i, img_b64 in enumerate(images):
|
||||||
|
print(f" 第 {i + 1} 页: base64 长度 {len(img_b64)} 字符")
|
||||||
|
else:
|
||||||
|
print("渲染返回空列表!")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"导入失败: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"渲染异常: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_multimodal_extract() -> None:
|
||||||
|
"""测试完整的多模态提取链路"""
|
||||||
|
pdf_path = ROOT / "事前申请单.pdf"
|
||||||
|
if not pdf_path.exists():
|
||||||
|
print(f"跳过: {pdf_path.name} 不存在")
|
||||||
|
return
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("测试多模态提取")
|
||||||
|
print("=" * 60)
|
||||||
|
try:
|
||||||
|
result = extract_document(pdf_path)
|
||||||
|
if result:
|
||||||
|
print("提取成功:")
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
print("提取返回空字典!")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"提取异常: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
test_render()
|
||||||
|
test_multimodal_extract()
|
||||||
|
print()
|
||||||
|
print("测试完成!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
54
scripts/test_travel_info.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""直接测试 extract_travel_info 函数
|
||||||
|
|
||||||
|
所有数据从 .invoice_cache 缓存中自动加载,无需手动构造样本数据。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/test_travel_info.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 项目根目录
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT)) # noqa: E402
|
||||||
|
|
||||||
|
from src.doc.llm_extractor import extract_travel_info # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
source_dir = ROOT / "scripts" / "data"
|
||||||
|
|
||||||
|
if not source_dir.exists():
|
||||||
|
print(f"源文件目录不存在: {source_dir}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("测试 extract_travel_info(数据来自缓存)")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"源文件目录: {source_dir}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = extract_travel_info(
|
||||||
|
source_dir=source_dir,
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("提取结果:")
|
||||||
|
print("=" * 60)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
print()
|
||||||
|
print("测试通过!")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"测试失败: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
94
src/README.md
Normal 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
@@ -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 上添加标准 handler(stream + 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,4 @@
|
|||||||
|
"""文档处理模块
|
||||||
|
|
||||||
|
包含发票提取、LLM 信息提取、出库单填写等功能。
|
||||||
|
"""
|
||||||
241
src/doc/extractor.py
Normal 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
|
||||||
@@ -11,10 +11,11 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from . import get_logger
|
from .. import get_logger
|
||||||
from .bot import load_invoice_data
|
from ..config import load_config
|
||||||
from .config import load_config
|
from ..doc.invoice import load_invoice_csv
|
||||||
|
|
||||||
log = get_logger("fill_consumable_doc")
|
log = get_logger("fill_consumable_doc")
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ def _today_cn_date() -> str:
|
|||||||
return f"{today.year}年{today.month}月{today.day}日"
|
return f"{today.year}年{today.month}月{today.day}日"
|
||||||
|
|
||||||
|
|
||||||
def _apply_font(rng) -> None:
|
def _apply_font(rng: Any) -> None:
|
||||||
"""将范围字体设为宋体五号(含数字与英文)。"""
|
"""将范围字体设为宋体五号(含数字与英文)。"""
|
||||||
font = rng.Font
|
font = rng.Font
|
||||||
font.Name = TABLE_FONT_NAME
|
font.Name = TABLE_FONT_NAME
|
||||||
@@ -97,7 +98,7 @@ def _apply_font(rng) -> None:
|
|||||||
font.Size = TABLE_FONT_SIZE
|
font.Size = TABLE_FONT_SIZE
|
||||||
|
|
||||||
|
|
||||||
def _set_cell_value(cell, text: str) -> None:
|
def _set_cell_value(cell: Any, text: str) -> None:
|
||||||
"""写入单元格正文(不含末尾单元格标记)。"""
|
"""写入单元格正文(不含末尾单元格标记)。"""
|
||||||
rng = cell.Range
|
rng = cell.Range
|
||||||
rng.MoveEnd(WD_CHARACTER, -1)
|
rng.MoveEnd(WD_CHARACTER, -1)
|
||||||
@@ -105,7 +106,7 @@ def _set_cell_value(cell, text: str) -> None:
|
|||||||
_apply_font(rng)
|
_apply_font(rng)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_table_font(tbl) -> None:
|
def _normalize_table_font(tbl: Any) -> None:
|
||||||
"""填写完成后统一整张表的字体。"""
|
"""填写完成后统一整张表的字体。"""
|
||||||
for row in tbl.Rows:
|
for row in tbl.Rows:
|
||||||
for cell in row.Cells:
|
for cell in row.Cells:
|
||||||
@@ -114,7 +115,7 @@ def _normalize_table_font(tbl) -> None:
|
|||||||
_apply_font(rng)
|
_apply_font(rng)
|
||||||
|
|
||||||
|
|
||||||
def _replace_date_in_doc(doc, new_date: str) -> None:
|
def _replace_date_in_doc(doc: Any, new_date: str) -> None:
|
||||||
"""仅替换表头段落中的日期文字,不改动段落其余部分。"""
|
"""仅替换表头段落中的日期文字,不改动段落其余部分。"""
|
||||||
if not new_date:
|
if not new_date:
|
||||||
return
|
return
|
||||||
@@ -135,21 +136,25 @@ def _replace_date_in_doc(doc, new_date: str) -> None:
|
|||||||
def fill_consumable_doc(
|
def fill_consumable_doc(
|
||||||
csv_path: str | Path,
|
csv_path: str | Path,
|
||||||
doc_path: str | Path,
|
doc_path: str | Path,
|
||||||
config: dict | None = None,
|
config: dict[str, Any] | None = None,
|
||||||
backup: bool = True,
|
backup: bool = True,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
csv_path = Path(csv_path)
|
csv_path = Path(csv_path)
|
||||||
doc_path = Path(doc_path)
|
doc_path = Path(doc_path)
|
||||||
if config is None:
|
if config is None:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
invoices = load_invoice_data(str(csv_path), config)
|
|
||||||
|
invoices = load_invoice_csv(csv_path.parent / "invoice_summary.csv") or []
|
||||||
|
|
||||||
if backup:
|
if backup:
|
||||||
bak = doc_path.with_suffix(doc_path.suffix + ".bak")
|
bak = doc_path.with_suffix(doc_path.suffix + ".bak")
|
||||||
shutil.copy2(doc_path, bak)
|
shutil.copy2(doc_path, bak)
|
||||||
|
|
||||||
|
import pythoncom
|
||||||
import win32com.client
|
import win32com.client
|
||||||
|
|
||||||
|
pythoncom.CoInitialize()
|
||||||
|
try:
|
||||||
word = win32com.client.Dispatch("Word.Application")
|
word = win32com.client.Dispatch("Word.Application")
|
||||||
word.Visible = False
|
word.Visible = False
|
||||||
word.DisplayAlerts = 0
|
word.DisplayAlerts = 0
|
||||||
@@ -166,8 +171,17 @@ def fill_consumable_doc(
|
|||||||
if row_idx > tbl.Rows.Count:
|
if row_idx > tbl.Rows.Count:
|
||||||
break
|
break
|
||||||
|
|
||||||
parsed = parse_spec_model(inv.get("spec_model", ""))
|
parsed = parse_spec_model(str(inv.get("spec_model", "")))
|
||||||
card_amount = inv.get("card_amount") or 0
|
# 当规格型号为空时,从项目名称提取产品信息
|
||||||
|
if not parsed["product_name"]:
|
||||||
|
item_name = str(inv.get("item_name", ""))
|
||||||
|
# 去除 "*分类*" 前缀(如 "*电子工业设备*元件盒" -> "元件盒")
|
||||||
|
if "*" in item_name:
|
||||||
|
item_name = item_name.split("*")[-1].strip()
|
||||||
|
parsed["product_name"] = item_name
|
||||||
|
|
||||||
|
card_amount_raw = inv.get("card_amount") or 0
|
||||||
|
card_amount: float = float(str(card_amount_raw).replace(",", ""))
|
||||||
qty_str = parsed["qty"]
|
qty_str = parsed["qty"]
|
||||||
qty_val = int(qty_str) if qty_str and qty_str.isdigit() else 0
|
qty_val = int(qty_str) if qty_str and qty_str.isdigit() else 0
|
||||||
|
|
||||||
@@ -175,11 +189,11 @@ def fill_consumable_doc(
|
|||||||
amount = _format_money(card_amount)
|
amount = _format_money(card_amount)
|
||||||
unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount)
|
unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount)
|
||||||
|
|
||||||
# 数量:去掉前导零
|
# 数量:去掉前导零;若无数量则默认为 1
|
||||||
qty = str(qty_val) if qty_val > 0 else ""
|
qty = str(qty_val) if qty_val > 0 else "1"
|
||||||
|
|
||||||
values = [
|
values = [
|
||||||
str(inv.get("seq", i + 1)),
|
str(inv.get("index", i + 1)),
|
||||||
parsed["product_name"],
|
parsed["product_name"],
|
||||||
parsed["spec"],
|
parsed["spec"],
|
||||||
parsed["unit"],
|
parsed["unit"],
|
||||||
@@ -193,7 +207,7 @@ def fill_consumable_doc(
|
|||||||
]
|
]
|
||||||
|
|
||||||
for col_idx, val in enumerate(values, start=1):
|
for col_idx, val in enumerate(values, start=1):
|
||||||
_set_cell_value(tbl.Cell(row_idx, col_idx), val)
|
_set_cell_value(tbl.Cell(row_idx, col_idx), str(val))
|
||||||
|
|
||||||
_normalize_table_font(tbl)
|
_normalize_table_font(tbl)
|
||||||
|
|
||||||
@@ -201,6 +215,8 @@ def fill_consumable_doc(
|
|||||||
finally:
|
finally:
|
||||||
doc.Close()
|
doc.Close()
|
||||||
word.Quit()
|
word.Quit()
|
||||||
|
finally:
|
||||||
|
pythoncom.CoUninitialize()
|
||||||
|
|
||||||
return doc_path
|
return doc_path
|
||||||
|
|
||||||
@@ -209,7 +225,7 @@ def fill_consumable_from_template(
|
|||||||
csv_path: str | Path,
|
csv_path: str | Path,
|
||||||
template_path: str | Path,
|
template_path: str | Path,
|
||||||
output_path: str | Path,
|
output_path: str | Path,
|
||||||
config: dict | None = None,
|
config: dict[str, Any] | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""从模板复制并填写出库单(Web 会话每次从模板重新生成)。"""
|
"""从模板复制并填写出库单(Web 会话每次从模板重新生成)。"""
|
||||||
template_path = Path(template_path)
|
template_path = Path(template_path)
|
||||||
@@ -225,7 +241,7 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser(description="将发票 CSV 填入易耗品出库单")
|
parser = argparse.ArgumentParser(description="将发票 CSV 填入易耗品出库单")
|
||||||
parser.add_argument("--csv", default=str(root / "invoice_summary.csv"))
|
parser.add_argument("--csv", default=str(root / "invoice_summary.csv"))
|
||||||
parser.add_argument("--doc", default=str(root / "易耗品、出库单.doc"))
|
parser.add_argument("--doc", default=str(root / "易耗品、出库单.doc"))
|
||||||
parser.add_argument("--config", default=str(root / "config.json"))
|
parser.add_argument("--config", default=str(root / "scripts" / "data" / "config.json"))
|
||||||
parser.add_argument("--no-backup", action="store_true")
|
parser.add_argument("--no-backup", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
233
src/doc/invoice.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
```
|
||||||
96
src/doc/prompts/invoice_system.md
Normal 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 标记或解释。
|
||||||
78
src/doc/prompts/normal_info_system.md
Normal 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 语法必须正确,无多余逗号、引号等错误
|
||||||
|
* 严格遵守所有强制性类型约束,违反类型要求的输出视为无效
|
||||||
123
src/doc/prompts/travel_info_system.md
Normal 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 必须语法正确,无多余逗号、引号等语法错误
|
||||||
|
* 必须严格遵守所有强制性类型约束,任何违反类型要求的输出均视为无效
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
"""
|
||||||
财务报销自动化
|
财务报销自动化
|
||||||
|
|
||||||
依次执行:
|
依次执行:
|
||||||
1. 发票提取 — 从 PDF 发票提取信息,生成 invoice_summary.csv
|
1. 发票提取 — 从 PDF 发票提取信息,生成 invoice_summary.csv
|
||||||
2. OCR 识别 — 从支付截图识别刷卡信息,回填 CSV
|
2. 报销提交 — 打开浏览器登录财务系统并自动填报
|
||||||
3. 报销提交 — 打开浏览器登录财务系统并自动填报
|
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python run.py # 全流程
|
python run.py # 全流程
|
||||||
python run.py --step invoice # 仅发票提取
|
python run.py --step invoice # 仅发票提取
|
||||||
python run.py --step ocr # 仅 OCR 识别
|
|
||||||
python run.py --step submit # 仅浏览器填报
|
python run.py --step submit # 仅浏览器填报
|
||||||
python run.py -u 工号 -p 密码 # 覆盖登录凭据
|
python run.py -u 工号 -p 密码 # 覆盖登录凭据
|
||||||
"""
|
"""
|
||||||
@@ -21,30 +18,37 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# 确保项目根目录在 sys.path 中
|
# 确保项目根目录在 sys.path 中
|
||||||
sys.path.insert(0, str(Path(__file__).parent.resolve()))
|
sys.path.insert(0, str(Path(__file__).parent.resolve().parent))
|
||||||
|
|
||||||
from app.pipeline import run_pipeline
|
from src.pipeline import run_pipeline
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="财务报销自动化 - 发票提取 → OCR 识别 → 浏览器填报",
|
description="财务报销自动化 - 发票提取 → 浏览器填报",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--step",
|
"--step",
|
||||||
choices=["all", "invoice", "ocr", "submit"],
|
choices=["all", "invoice", "submit"],
|
||||||
default="all",
|
default="all",
|
||||||
help="执行步骤 (默认: all)",
|
help="执行步骤 (默认: all)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-u", "--username",
|
"-u",
|
||||||
|
"--username",
|
||||||
default=None,
|
default=None,
|
||||||
help="信息门户登录账号(覆盖 config.json)",
|
help="信息门户登录账号(覆盖 scripts/config.json)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-p", "--password",
|
"-p",
|
||||||
|
"--password",
|
||||||
default=None,
|
default=None,
|
||||||
help="信息门户登录密码(覆盖 config.json)",
|
help="信息门户登录密码(覆盖 scripts/config.json)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cache-dir",
|
||||||
|
default=None,
|
||||||
|
help="发票缓存目录(包含 .invoice_cache 子目录,默认: scripts/data)",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -52,6 +56,7 @@ def main():
|
|||||||
step=args.step,
|
step=args.step,
|
||||||
username=args.username,
|
username=args.username,
|
||||||
password=args.password,
|
password=args.password,
|
||||||
|
cache_dir=args.cache_dir,
|
||||||
)
|
)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
219
src/pipeline.py
Normal 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
@@ -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` 快速填充配置表单。
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
财务报销自动化 — Web 界面
|
财务报销自动化 — Web 界面
|
||||||
|
|
||||||
用户上传 PDF 发票和支付截图,配置账号信息,自动完成:
|
用户上传 PDF 发票,配置账号信息,自动完成:
|
||||||
1. 发票提取 2. OCR 识别 3. 浏览器填报(可选)
|
1. 发票提取 2. 浏览器填报(可选)
|
||||||
|
|
||||||
启动: python web/app.py
|
发票类型区分:
|
||||||
|
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
||||||
|
- 普通发票:生成易耗品出库单,走普通报销流程
|
||||||
|
|
||||||
|
启动: uv run python src/web/app.py
|
||||||
访问: http://localhost:5000
|
访问: http://localhost:5000
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
@@ -16,36 +19,68 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
|
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
|
||||||
|
|
||||||
# 确保项目根目录在 sys.path
|
# 确保项目根目录在 sys.path
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
from app.config import load_config as load_project_config
|
from src import get_logger # noqa: E402, I001
|
||||||
from app.extractor import extract_invoices, save_csv, save_markdown
|
from src.config import load_config as load_project_config # noqa: E402, I001
|
||||||
from app.fill_consumable_doc import (
|
from src.doc.extractor import ( # noqa: E402, I001
|
||||||
|
extract_invoices,
|
||||||
|
)
|
||||||
|
from src.doc.fill_consumable_doc import ( # noqa: E402, I001
|
||||||
CONSUMABLE_DOC_FILENAME,
|
CONSUMABLE_DOC_FILENAME,
|
||||||
fill_consumable_from_template,
|
fill_consumable_from_template,
|
||||||
)
|
)
|
||||||
from app import get_logger
|
from src.doc.invoice import ( # noqa: E402, I001
|
||||||
from app.ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv as load_ocr_csv
|
load_csv,
|
||||||
|
load_invoice_csv,
|
||||||
|
save_csv as save_payment_csv,
|
||||||
|
save_invoice_csv,
|
||||||
|
save_application_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
fill_log = get_logger("fill_consumable_doc")
|
fill_log = get_logger("fill_consumable_doc")
|
||||||
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
|
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates")
|
app = Flask(__name__, template_folder="templates")
|
||||||
|
|
||||||
UPLOAD_BASE = PROJECT_ROOT / "web" / "uploads"
|
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
|
||||||
SESSION_LOG_FILE = "session.log"
|
SESSION_LOG_FILE = "session.log"
|
||||||
SESSION_RESULT_FILE = "result.json"
|
SESSION_RESULT_FILE = "result.json"
|
||||||
|
INVOICE_GROUPS_FILE = "invoice_groups.json"
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
def _save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
|
||||||
# 日志收集器 — 捕获管道日志到文件,SSE 端点通过 tail -f 读取
|
"""保存发票分类结果到 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):
|
class _SSELogHandler(logging.Handler):
|
||||||
"""将日志写入指定文件(线程安全)"""
|
"""将日志写入指定文件(线程安全)"""
|
||||||
@@ -55,7 +90,7 @@ class _SSELogHandler(logging.Handler):
|
|||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._file = open(log_path, "w", encoding="utf-8")
|
self._file = open(log_path, "w", encoding="utf-8")
|
||||||
|
|
||||||
def emit(self, record: logging.LogRecord):
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
try:
|
try:
|
||||||
msg = self.format(record) + "\n"
|
msg = self.format(record) + "\n"
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -64,7 +99,7 @@ class _SSELogHandler(logging.Handler):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def close_file(self):
|
def close_file(self) -> None:
|
||||||
try:
|
try:
|
||||||
self._file.close()
|
self._file.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -82,7 +117,7 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
|
|||||||
handler.setFormatter(fmt)
|
handler.setFormatter(fmt)
|
||||||
handler.setLevel(logging.INFO)
|
handler.setLevel(logging.INFO)
|
||||||
|
|
||||||
for name in ["extractor", "ocr", "pipeline", "bot", "fill_consumable_doc"]:
|
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
|
||||||
logger = logging.getLogger(name)
|
logger = logging.getLogger(name)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
@@ -90,8 +125,8 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
|
|||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
|
||||||
def _remove_log_collector(handler: _SSELogHandler):
|
def _remove_log_collector(handler: _SSELogHandler) -> None:
|
||||||
for name in ["extractor", "ocr", "pipeline", "bot", "fill_consumable_doc"]:
|
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
|
||||||
logging.getLogger(name).removeHandler(handler)
|
logging.getLogger(name).removeHandler(handler)
|
||||||
handler.close_file()
|
handler.close_file()
|
||||||
|
|
||||||
@@ -101,7 +136,7 @@ def _remove_log_collector(handler: _SSELogHandler):
|
|||||||
# ================================================================
|
# ================================================================
|
||||||
|
|
||||||
|
|
||||||
def _load_session_config(session_dir: Path) -> dict:
|
def _load_session_config(session_dir: Path) -> dict[str, Any]:
|
||||||
config = load_project_config()
|
config = load_project_config()
|
||||||
cfg_path = session_dir / "config.json"
|
cfg_path = session_dir / "config.json"
|
||||||
if cfg_path.exists():
|
if cfg_path.exists():
|
||||||
@@ -110,31 +145,59 @@ def _load_session_config(session_dir: Path) -> dict:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _resolve_invoice_csv(session_dir: Path) -> Path | None:
|
def _resolve_payment_csv(session_dir: Path) -> Path | None:
|
||||||
csv_path = session_dir / "invoice_summary.csv"
|
"""查找支付记录 CSV(payment_records.csv)"""
|
||||||
|
csv_path = session_dir / "payment_records.csv"
|
||||||
if csv_path.exists():
|
if csv_path.exists():
|
||||||
return csv_path
|
return csv_path
|
||||||
for f in session_dir.glob("*.csv"):
|
for f in session_dir.glob("*.csv"):
|
||||||
|
if f.name != SESSION_RESULT_FILE:
|
||||||
return f
|
return f
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _try_fill_consumable_doc(session_dir: Path, config: dict) -> dict:
|
def _resolve_invoice_csv(session_dir: Path) -> Path | None:
|
||||||
"""根据 CSV 填写易耗品出库单,供会话目录下载。"""
|
"""查找发票级别 CSV(invoice_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():
|
if not CONSUMABLE_TEMPLATE.exists():
|
||||||
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
|
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
|
||||||
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
|
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
|
||||||
|
|
||||||
csv_path = _resolve_invoice_csv(session_dir)
|
# 从统一的分类结果读取,避免重复解析 CSV/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:
|
if csv_path is None:
|
||||||
return {"ok": False, "error": "未找到发票 CSV"}
|
return {"ok": False, "error": "未找到发票 CSV"}
|
||||||
|
|
||||||
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
|
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
|
||||||
try:
|
try:
|
||||||
fill_log.info("开始填写出库单: %s", out_doc.name)
|
fill_log.info("开始填写出库单: %s", out_doc.name)
|
||||||
fill_consumable_from_template(
|
fill_consumable_from_template(csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config)
|
||||||
csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config
|
|
||||||
)
|
|
||||||
fill_log.info("出库单填写完成")
|
fill_log.info("出库单填写完成")
|
||||||
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
|
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -145,11 +208,16 @@ def _try_fill_consumable_doc(session_dir: Path, config: dict) -> dict:
|
|||||||
return {"ok": False, "error": str(e)}
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def _append_doc_download(result: dict, session_id: str, doc_fill: dict) -> None:
|
def _append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
|
||||||
if doc_fill.get("ok"):
|
if doc_fill.get("ok"):
|
||||||
fn = doc_fill["doc_filename"]
|
fn = doc_fill["doc_filename"]
|
||||||
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||||
result["doc_ok"] = True
|
result["doc_ok"] = True
|
||||||
|
elif doc_fill.get("skipped"):
|
||||||
|
# 差旅发票,跳过出库单生成(不是错误)
|
||||||
|
result["doc_ok"] = None
|
||||||
|
result["doc_skipped"] = True
|
||||||
|
result["doc_message"] = doc_fill.get("error", "")
|
||||||
else:
|
else:
|
||||||
result["doc_ok"] = False
|
result["doc_ok"] = False
|
||||||
result["doc_error"] = doc_fill.get("error", "未知错误")
|
result["doc_error"] = doc_fill.get("error", "未知错误")
|
||||||
@@ -159,79 +227,104 @@ def _append_doc_download(result: dict, session_id: str, doc_fill: dict) -> None:
|
|||||||
# 管道入口
|
# 管道入口
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|
||||||
def run_pipeline_web(session_dir: Path, config: dict):
|
|
||||||
"""在 Web 会话目录中执行提取+OCR,结果写入 session 目录下的文件
|
def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
|
||||||
|
|
||||||
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
|
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
|
||||||
|
|
||||||
|
在发票提取和匹配完成后立即判断报销类型:
|
||||||
|
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
|
||||||
|
- 普通发票:无需额外提取(normal_info.json 待实现)
|
||||||
"""
|
"""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|
||||||
# ---- Step 1: 发票提取 ----
|
# ---- Step 1: 发票提取 ----
|
||||||
invoices = extract_invoices(str(session_dir))
|
invoices, applications, groups = extract_invoices(str(session_dir))
|
||||||
if not invoices:
|
if not invoices:
|
||||||
return {"ok": False, "error": "未提取到任何发票数据"}
|
return {"ok": False, "error": "未提取到任何发票数据"}
|
||||||
|
|
||||||
save_csv(invoices, session_dir / "invoice_summary.csv")
|
# 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
|
||||||
save_markdown(invoices, session_dir / "invoice_summary.md")
|
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
||||||
|
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
|
||||||
|
|
||||||
# ---- Step 2: OCR 识别 ----
|
# 出差申请单单独保存
|
||||||
csv_path = session_dir / "invoice_summary.csv"
|
if applications:
|
||||||
rows = load_ocr_csv(csv_path)
|
save_application_json(applications, session_dir / "travel_applications.json")
|
||||||
if rows is None:
|
|
||||||
return {"ok": False, "error": "CSV 读取失败"}
|
|
||||||
|
|
||||||
rows = enrich_with_ocr(rows, str(session_dir))
|
# 保存分类结果(供后续步骤统一读取)
|
||||||
save_ocr_csv(csv_path, rows)
|
_save_invoice_groups(session_dir, groups)
|
||||||
save_markdown_from_csv(csv_path, rows)
|
|
||||||
|
# ---- 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
|
elapsed = time.time() - start
|
||||||
result = {
|
result = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"elapsed": f"{elapsed:.1f}s",
|
"elapsed": f"{elapsed:.1f}s",
|
||||||
"invoice_count": len(rows),
|
"invoice_count": invoice_count,
|
||||||
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
|
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
|
||||||
"md_url": f"/api/download/{session_dir.name}/invoice_summary.md",
|
# 发票类型统计
|
||||||
|
"travel_count": len(groups["travel"]),
|
||||||
|
"general_count": len(groups["general"]),
|
||||||
}
|
}
|
||||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||||
_append_doc_download(result, session_dir.name, doc_fill)
|
_append_doc_download(result, session_dir.name, doc_fill)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str):
|
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""直接使用上传的 CSV 文件,跳过 PDF 提取和 OCR"""
|
"""执行财务系统填报(从前端确认后调用)
|
||||||
start = time.time()
|
|
||||||
|
|
||||||
csv_path = session_dir / csv_filename
|
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
|
||||||
if not csv_path.exists():
|
- 纯差旅发票:差旅报销模式(TODO)
|
||||||
return {"ok": False, "error": "CSV 文件不存在"}
|
- 含普通发票:普通报销模式
|
||||||
|
"""
|
||||||
# 读取 CSV 行数
|
csv_path = session_dir / "payment_records.csv"
|
||||||
rows = load_ocr_csv(csv_path)
|
|
||||||
if rows is None:
|
|
||||||
return {"ok": False, "error": "CSV 读取失败"}
|
|
||||||
|
|
||||||
elapsed = time.time() - start
|
|
||||||
result = {
|
|
||||||
"ok": True,
|
|
||||||
"elapsed": f"{elapsed:.1f}s",
|
|
||||||
"invoice_count": len(rows),
|
|
||||||
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
|
|
||||||
}
|
|
||||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
|
||||||
_append_doc_download(result, session_dir.name, doc_fill)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def run_financial_submit(session_dir: Path, config: dict) -> dict:
|
|
||||||
"""执行财务系统填报(从前端确认后调用)"""
|
|
||||||
csv_path = session_dir / "invoice_summary.csv"
|
|
||||||
if not csv_path.exists():
|
if not csv_path.exists():
|
||||||
return {"ok": False, "error": "未找到发票数据,请先处理"}
|
return {"ok": False, "error": "未找到发票数据,请先处理"}
|
||||||
|
|
||||||
from app.bot import load_invoice_data, run_bot_web
|
from src.bot import run_bot_web
|
||||||
|
|
||||||
bot_invoices = load_invoice_data(str(csv_path), config)
|
# 从统一的分类结果读取,避免重复解析
|
||||||
run_bot_web(config, bot_invoices, session_dir)
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -239,13 +332,14 @@ def run_financial_submit(session_dir: Path, config: dict) -> dict:
|
|||||||
# Flask 路由
|
# Flask 路由
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index() -> Any:
|
||||||
return render_template("index.html")
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/session", methods=["POST"])
|
@app.route("/api/session", methods=["POST"])
|
||||||
def create_session():
|
def create_session() -> Any:
|
||||||
"""创建上传会话,返回 session_id"""
|
"""创建上传会话,返回 session_id"""
|
||||||
sid = uuid.uuid4().hex[:12]
|
sid = uuid.uuid4().hex[:12]
|
||||||
session_dir = UPLOAD_BASE / sid
|
session_dir = UPLOAD_BASE / sid
|
||||||
@@ -254,7 +348,7 @@ def create_session():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/upload/<session_id>", methods=["POST"])
|
@app.route("/api/upload/<session_id>", methods=["POST"])
|
||||||
def upload_file(session_id: str):
|
def upload_file(session_id: str) -> Any:
|
||||||
"""上传 PDF 或图片"""
|
"""上传 PDF 或图片"""
|
||||||
session_dir = _validate_session(session_id)
|
session_dir = _validate_session(session_id)
|
||||||
if isinstance(session_dir, tuple):
|
if isinstance(session_dir, tuple):
|
||||||
@@ -269,46 +363,26 @@ def upload_file(session_id: str):
|
|||||||
return jsonify({"ok": True, "filename": safe_name})
|
return jsonify({"ok": True, "filename": safe_name})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/upload-csv/<session_id>", methods=["POST"])
|
|
||||||
def upload_csv(session_id: str):
|
|
||||||
"""上传 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"])
|
@app.route("/api/files/<session_id>", methods=["GET"])
|
||||||
def list_files(session_id: str):
|
def list_files(session_id: str) -> Any:
|
||||||
"""列出会话目录中的文件"""
|
"""列出会话目录中的文件"""
|
||||||
session_dir = _validate_session(session_id)
|
session_dir = _validate_session(session_id)
|
||||||
if isinstance(session_dir, tuple):
|
if isinstance(session_dir, tuple):
|
||||||
return session_dir
|
return session_dir
|
||||||
|
|
||||||
pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
|
pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
|
||||||
imgs = sorted(
|
imgs = sorted(f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"} for f in session_dir.glob(f"*{ext}"))
|
||||||
f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
|
||||||
for f in session_dir.glob(f"*{ext}")
|
|
||||||
)
|
|
||||||
return jsonify({"pdfs": pdfs, "images": imgs})
|
return jsonify({"pdfs": pdfs, "images": imgs})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/process/<session_id>", methods=["POST"])
|
@app.route("/api/process/<session_id>", methods=["POST"])
|
||||||
def start_process(session_id: str):
|
def start_process(session_id: str) -> Any:
|
||||||
"""启动管道处理(仅提取+OCR,不自动提交财务系统)"""
|
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
|
||||||
session_dir = _validate_session(session_id)
|
session_dir = _validate_session(session_id)
|
||||||
if isinstance(session_dir, tuple):
|
if isinstance(session_dir, tuple):
|
||||||
return session_dir
|
return session_dir
|
||||||
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
mode = body.get("mode", "auto") # "pdf", "csv", or "auto"
|
|
||||||
|
|
||||||
# 读取配置
|
# 读取配置
|
||||||
config = _build_web_config(body)
|
config = _build_web_config(body)
|
||||||
@@ -320,25 +394,13 @@ def start_process(session_id: str):
|
|||||||
# 在后台线程执行
|
# 在后台线程执行
|
||||||
handler = _install_log_collector(session_dir)
|
handler = _install_log_collector(session_dir)
|
||||||
|
|
||||||
def _run():
|
def _run() -> None:
|
||||||
result = {"ok": False, "error": "未知错误"}
|
result = {"ok": False, "error": "未知错误"}
|
||||||
try:
|
try:
|
||||||
if mode == "csv":
|
|
||||||
csv_files = list(session_dir.glob("*.csv"))
|
|
||||||
if not csv_files:
|
|
||||||
result = {"ok": False, "error": "未找到 CSV 文件"}
|
|
||||||
else:
|
|
||||||
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name)
|
|
||||||
else:
|
|
||||||
csv_files = list(session_dir.glob("*.csv"))
|
|
||||||
pdf_files = list(session_dir.glob("*.pdf"))
|
|
||||||
if csv_files and not pdf_files:
|
|
||||||
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name)
|
|
||||||
else:
|
|
||||||
result = run_pipeline_web(session_dir, config)
|
result = run_pipeline_web(session_dir, config)
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
result = {"ok": False, "error": str(e)}
|
result = {"ok": False, "error": str(e)}
|
||||||
if isinstance(e, (KeyboardInterrupt, SystemExit)):
|
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -356,13 +418,13 @@ def start_process(session_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/logs/<session_id>")
|
@app.route("/api/logs/<session_id>")
|
||||||
def stream_logs(session_id: str):
|
def stream_logs(session_id: str) -> Any:
|
||||||
"""SSE 日志流"""
|
"""SSE 日志流"""
|
||||||
session_dir = _validate_session(session_id)
|
session_dir = _validate_session(session_id)
|
||||||
if isinstance(session_dir, tuple):
|
if isinstance(session_dir, tuple):
|
||||||
return session_dir
|
return session_dir
|
||||||
|
|
||||||
def generate():
|
def generate() -> Any:
|
||||||
# 先发送已有日志
|
# 先发送已有日志
|
||||||
log_file = session_dir / SESSION_LOG_FILE
|
log_file = session_dir / SESSION_LOG_FILE
|
||||||
last_size = 0
|
last_size = 0
|
||||||
@@ -399,7 +461,7 @@ def stream_logs(session_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/download/<session_id>/<filename>")
|
@app.route("/api/download/<session_id>/<filename>")
|
||||||
def download_file(session_id: str, filename: str):
|
def download_file(session_id: str, filename: str) -> Any:
|
||||||
"""下载生成的文件"""
|
"""下载生成的文件"""
|
||||||
session_dir = _validate_session(session_id)
|
session_dir = _validate_session(session_id)
|
||||||
if isinstance(session_dir, tuple):
|
if isinstance(session_dir, tuple):
|
||||||
@@ -415,8 +477,6 @@ def download_file(session_id: str, filename: str):
|
|||||||
mimetype = "application/msword"
|
mimetype = "application/msword"
|
||||||
elif safe_name.endswith(".csv"):
|
elif safe_name.endswith(".csv"):
|
||||||
mimetype = "text/csv; charset=utf-8"
|
mimetype = "text/csv; charset=utf-8"
|
||||||
elif safe_name.endswith(".md"):
|
|
||||||
mimetype = "text/markdown; charset=utf-8"
|
|
||||||
else:
|
else:
|
||||||
mimetype = "application/octet-stream"
|
mimetype = "application/octet-stream"
|
||||||
|
|
||||||
@@ -428,42 +488,86 @@ def download_file(session_id: str, filename: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/config/<session_id>", methods=["GET"])
|
||||||
|
def get_session_config(session_id: str) -> Any:
|
||||||
|
"""获取当前会话的配置(供前端回填表单)"""
|
||||||
|
session_dir = _validate_session(session_id)
|
||||||
|
if isinstance(session_dir, tuple):
|
||||||
|
return session_dir
|
||||||
|
|
||||||
|
config = load_project_config()
|
||||||
|
cfg_path = session_dir / "config.json"
|
||||||
|
if cfg_path.exists():
|
||||||
|
with open(cfg_path, encoding="utf-8") as f:
|
||||||
|
config.update(json.load(f))
|
||||||
|
# 只返回前端需要的字段
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"username": config.get("username", ""),
|
||||||
|
"password": "", # 不返回密码
|
||||||
|
"default_name": config.get("default_name", ""),
|
||||||
|
"default_card_no": config.get("default_card_no", ""),
|
||||||
|
"default_person_id": config.get("default_person_id", ""),
|
||||||
|
"consumable_storage": config.get("consumable_storage", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/data/<session_id>", methods=["GET"])
|
@app.route("/api/data/<session_id>", methods=["GET"])
|
||||||
def get_invoice_data(session_id: str):
|
def get_invoice_data(session_id: str) -> Any:
|
||||||
"""读取发票数据并返回 JSON(供前端表格编辑)"""
|
"""读取发票数据并返回 JSON(供前端表格编辑)"""
|
||||||
session_dir = _validate_session(session_id)
|
session_dir = _validate_session(session_id)
|
||||||
if isinstance(session_dir, tuple):
|
if isinstance(session_dir, tuple):
|
||||||
return session_dir
|
return session_dir
|
||||||
|
|
||||||
csv_path = session_dir / "invoice_summary.csv"
|
# 优先读取支付记录 CSV
|
||||||
if not csv_path.exists():
|
payment_csv = session_dir / "payment_records.csv"
|
||||||
# CSV 模式下可能是其他文件名
|
if payment_csv.exists():
|
||||||
|
rows = load_csv(payment_csv)
|
||||||
|
if rows is not None:
|
||||||
|
data: list[dict[str, Any]] = []
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
entry: dict[str, Any] = dict(row)
|
||||||
|
entry["__row"] = i
|
||||||
|
data.append(entry)
|
||||||
|
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||||
|
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
|
||||||
|
|
||||||
|
# 回退到发票级别 CSV
|
||||||
|
invoice_csv = session_dir / "invoice_summary.csv"
|
||||||
|
if invoice_csv.exists():
|
||||||
|
rows = load_invoice_csv(invoice_csv)
|
||||||
|
if rows is not None:
|
||||||
|
invoice_data: list[dict[str, Any]] = []
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
entry2: dict[str, Any] = dict(row)
|
||||||
|
entry2["__row"] = i
|
||||||
|
invoice_data.append(entry2)
|
||||||
|
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||||
|
return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data})
|
||||||
|
|
||||||
|
# 最后尝试任意 CSV
|
||||||
csv_files = list(session_dir.glob("*.csv"))
|
csv_files = list(session_dir.glob("*.csv"))
|
||||||
csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
|
csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
|
||||||
if csv_files:
|
if csv_files:
|
||||||
csv_path = csv_files[0]
|
csv_path = csv_files[0]
|
||||||
else:
|
rows = load_csv(csv_path)
|
||||||
return jsonify({"error": "未找到发票数据,请先处理"}), 404
|
|
||||||
|
|
||||||
rows = load_ocr_csv(csv_path)
|
|
||||||
if rows is None:
|
if rows is None:
|
||||||
return jsonify({"error": "CSV 读取失败"}), 500
|
rows = load_invoice_csv(csv_path)
|
||||||
|
if rows is not None:
|
||||||
# 添加行号用于编辑追踪
|
fallback_data: list[dict[str, Any]] = []
|
||||||
data = []
|
|
||||||
for i, row in enumerate(rows):
|
for i, row in enumerate(rows):
|
||||||
entry = dict(row)
|
entry3: dict[str, Any] = dict(row)
|
||||||
entry["__row"] = i
|
entry3["__row"] = i
|
||||||
data.append(entry)
|
fallback_data.append(entry3)
|
||||||
|
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||||
|
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
|
||||||
|
|
||||||
# 返回原始字段顺序(去掉内部字段)
|
return jsonify({"error": "未找到发票数据,请先处理"}), 404
|
||||||
fields = [k for k in rows[0].keys() if not k.startswith('__')] if rows else []
|
|
||||||
|
|
||||||
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": data})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/save/<session_id>", methods=["POST"])
|
@app.route("/api/save/<session_id>", methods=["POST"])
|
||||||
def save_invoice_data(session_id: str):
|
def save_invoice_data(session_id: str) -> Any:
|
||||||
"""保存前端编辑后的发票数据到 CSV"""
|
"""保存前端编辑后的发票数据到 CSV"""
|
||||||
session_dir = _validate_session(session_id)
|
session_dir = _validate_session(session_id)
|
||||||
if isinstance(session_dir, tuple):
|
if isinstance(session_dir, tuple):
|
||||||
@@ -478,7 +582,7 @@ def save_invoice_data(session_id: str):
|
|||||||
return jsonify({"error": "CSV 文件不存在"}), 404
|
return jsonify({"error": "CSV 文件不存在"}), 404
|
||||||
|
|
||||||
# 读取原 CSV 获取字段顺序(使用第一个数据的 keys)
|
# 读取原 CSV 获取字段顺序(使用第一个数据的 keys)
|
||||||
original_rows = load_ocr_csv(csv_path)
|
original_rows = load_csv(csv_path)
|
||||||
if original_rows is None or len(original_rows) == 0:
|
if original_rows is None or len(original_rows) == 0:
|
||||||
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
|
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
|
||||||
|
|
||||||
@@ -494,21 +598,24 @@ def save_invoice_data(session_id: str):
|
|||||||
row = {k: entry.get(k, "") for k in fieldnames}
|
row = {k: entry.get(k, "") for k in fieldnames}
|
||||||
writer.writerow(row)
|
writer.writerow(row)
|
||||||
|
|
||||||
resp = {"ok": True}
|
resp: dict[str, str | bool | None] = {"ok": True}
|
||||||
config = _load_session_config(session_dir)
|
config = _load_session_config(session_dir)
|
||||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||||
if doc_fill.get("ok"):
|
if doc_fill.get("ok"):
|
||||||
fn = doc_fill["doc_filename"]
|
fn = doc_fill["doc_filename"]
|
||||||
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||||
resp["doc_ok"] = True
|
resp["doc_ok"] = True
|
||||||
|
elif doc_fill.get("skipped"):
|
||||||
|
resp["doc_ok"] = None
|
||||||
|
resp["doc_skipped"] = True
|
||||||
else:
|
else:
|
||||||
resp["doc_ok"] = False
|
resp["doc_ok"] = False
|
||||||
resp["doc_error"] = doc_fill.get("error")
|
resp["doc_error"] = doc_fill.get("error") or ""
|
||||||
return jsonify(resp)
|
return jsonify(resp)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/submit-financial/<session_id>", methods=["POST"])
|
@app.route("/api/submit-financial/<session_id>", methods=["POST"])
|
||||||
def submit_financial(session_id: str):
|
def submit_financial(session_id: str) -> Any:
|
||||||
"""手动触发财务系统填报"""
|
"""手动触发财务系统填报"""
|
||||||
session_dir = _validate_session(session_id)
|
session_dir = _validate_session(session_id)
|
||||||
if isinstance(session_dir, tuple):
|
if isinstance(session_dir, tuple):
|
||||||
@@ -530,7 +637,7 @@ def submit_financial(session_id: str):
|
|||||||
# 在后台线程执行提交
|
# 在后台线程执行提交
|
||||||
handler = _install_log_collector(session_dir)
|
handler = _install_log_collector(session_dir)
|
||||||
|
|
||||||
def _run():
|
def _run() -> None:
|
||||||
result = {"ok": False, "error": "未知错误"}
|
result = {"ok": False, "error": "未知错误"}
|
||||||
try:
|
try:
|
||||||
submit_result = run_financial_submit(session_dir, config)
|
submit_result = run_financial_submit(session_dir, config)
|
||||||
@@ -540,13 +647,17 @@ def submit_financial(session_id: str):
|
|||||||
result = submit_result
|
result = submit_result
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
result = {"ok": False, "error": str(e)}
|
result = {"ok": False, "error": str(e)}
|
||||||
if isinstance(e, (KeyboardInterrupt, SystemExit)):
|
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
||||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
json.dump({"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")}, f, ensure_ascii=False)
|
json.dump(
|
||||||
|
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
|
||||||
|
f,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
|
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -561,14 +672,15 @@ def submit_financial(session_id: str):
|
|||||||
# 辅助函数
|
# 辅助函数
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|
||||||
def _validate_session(session_id: str):
|
|
||||||
|
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
|
||||||
session_dir = UPLOAD_BASE / session_id
|
session_dir = UPLOAD_BASE / session_id
|
||||||
if not session_dir.exists():
|
if not session_dir.exists():
|
||||||
return jsonify({"error": "会话不存在"}), 404
|
return jsonify({"error": "会话不存在"}), 404
|
||||||
return session_dir
|
return session_dir
|
||||||
|
|
||||||
|
|
||||||
def _build_web_config(body: dict) -> dict:
|
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""从请求体构建配置"""
|
"""从请求体构建配置"""
|
||||||
config = load_project_config()
|
config = load_project_config()
|
||||||
for key in (
|
for key in (
|
||||||
@@ -590,7 +702,7 @@ def _escape_sse(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/mobile/<session_id>")
|
@app.route("/mobile/<session_id>")
|
||||||
def mobile_upload(session_id: str):
|
def mobile_upload(session_id: str) -> Any:
|
||||||
"""移动端上传页面"""
|
"""移动端上传页面"""
|
||||||
session_dir = UPLOAD_BASE / session_id
|
session_dir = UPLOAD_BASE / session_id
|
||||||
if not session_dir.exists():
|
if not session_dir.exists():
|
||||||
@@ -599,12 +711,12 @@ def mobile_upload(session_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/mobile-upload/<session_id>", methods=["POST"])
|
@app.route("/api/mobile-upload/<session_id>", methods=["POST"])
|
||||||
def mobile_upload_file(session_id: str):
|
def mobile_upload_file(session_id: str) -> Any:
|
||||||
"""移动端上传图片(复用 PC 上传逻辑)"""
|
"""移动端上传图片(复用 PC 上传逻辑)"""
|
||||||
return upload_file(session_id)
|
return upload_file(session_id)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
|
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
|
||||||
print(f"启动 Web 服务: http://localhost:5000")
|
print("启动 Web 服务: http://localhost:5000")
|
||||||
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)
|
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)
|
||||||
18
src/web/static/README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# src/web/static — 静态资源目录
|
||||||
|
|
||||||
|
存放 Web 界面的 CSS 样式表和 JavaScript 前端逻辑。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `css/index.css` | 全局样式:上传区域、日志面板、可编辑表格、状态徽章 |
|
||||||
|
| `js/index.js` | 前端逻辑:文件上传、SSE 日志监听、发票数据编辑、配置同步、移动端扫码上传、财务提交 |
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
原生 JavaScript + Bootstrap 5,无构建工具,保持单页应用轻量可维护。
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
let sessionId = null;
|
let sessionId = null;
|
||||||
const pdfFiles = [], imgFiles = [];
|
const pdfFiles = [], imgFiles = [];
|
||||||
let csvFile = null;
|
|
||||||
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
|
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
|
||||||
let csvFilename = ''; // 当前 CSV 文件名
|
let csvFilename = ''; // 当前 CSV 文件名
|
||||||
let lastDownloadUrls = {}; // 最近一次可下载文件链接
|
let lastDownloadUrls = {}; // 最近一次可下载文件链接
|
||||||
@@ -50,23 +49,6 @@ function renderFileList(type) {
|
|||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- CSV 上传 ----
|
|
||||||
function handleCsvFile(input) {
|
|
||||||
const file = input.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
csvFile = file;
|
|
||||||
document.getElementById('csv-zone').classList.add('active');
|
|
||||||
document.getElementById('csv-list').innerHTML =
|
|
||||||
`<span class="file-tag">${file.name}<span class="remove" onclick="event.stopPropagation();removeCsvFile()">×</span></span>`;
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeCsvFile() {
|
|
||||||
csvFile = null;
|
|
||||||
document.getElementById('csv-zone').classList.remove('active');
|
|
||||||
document.getElementById('csv-list').innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 配置上传 ----
|
// ---- 配置上传 ----
|
||||||
function handleConfigUpload(input) {
|
function handleConfigUpload(input) {
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
@@ -122,22 +104,10 @@ function handleConfigUpload(input) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// CSV 拖拽
|
|
||||||
const csvZone = document.getElementById('csv-zone');
|
|
||||||
csvZone.addEventListener('dragover', e => { e.preventDefault(); csvZone.classList.add('dragover'); });
|
|
||||||
csvZone.addEventListener('dragleave', () => csvZone.classList.remove('dragover'));
|
|
||||||
csvZone.addEventListener('drop', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
csvZone.classList.remove('dragover');
|
|
||||||
const file = Array.from(e.dataTransfer.files).find(f => f.name.toLowerCase().endsWith('.csv'));
|
|
||||||
if (file) handleCsvFile({ files: [file] });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- 处理 ----
|
// ---- 处理 ----
|
||||||
async function startProcess() {
|
async function startProcess() {
|
||||||
const isCsvMode = !!csvFile;
|
if (!pdfFiles.length && !imgFiles.length) {
|
||||||
if (!isCsvMode && !pdfFiles.length && !imgFiles.length) {
|
alert('请先上传文件或图片');
|
||||||
alert('请先上传文件或 CSV');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,18 +122,12 @@ async function startProcess() {
|
|||||||
try {
|
try {
|
||||||
await ensureSession();
|
await ensureSession();
|
||||||
|
|
||||||
if (isCsvMode) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', csvFile);
|
|
||||||
await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd });
|
|
||||||
} else {
|
|
||||||
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
|
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
|
||||||
for (const {f} of allFiles) {
|
for (const {f} of allFiles) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', f);
|
fd.append('file', f);
|
||||||
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
|
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
|
||||||
|
|
||||||
@@ -174,7 +138,6 @@ async function startProcess() {
|
|||||||
default_card_no: document.getElementById('cfg-card').value,
|
default_card_no: document.getElementById('cfg-card').value,
|
||||||
default_person_id: document.getElementById('cfg-person-id').value,
|
default_person_id: document.getElementById('cfg-person-id').value,
|
||||||
consumable_storage: document.getElementById('cfg-storage').value,
|
consumable_storage: document.getElementById('cfg-storage').value,
|
||||||
mode: isCsvMode ? 'csv' : 'auto',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await fetch(`/api/process/${sessionId}`, {
|
await fetch(`/api/process/${sessionId}`, {
|
||||||
@@ -238,7 +201,6 @@ function showDownloadLinks(result) {
|
|||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
|
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
|
||||||
if (result.md_url) items.push({ label: 'invoice_summary.md', url: result.md_url });
|
|
||||||
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
|
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
|
||||||
|
|
||||||
lastDownloadUrls = {};
|
lastDownloadUrls = {};
|
||||||
@@ -249,8 +211,13 @@ function showDownloadLinks(result) {
|
|||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
if (warn) {
|
if (warn) {
|
||||||
if (result.doc_ok === false && result.doc_error) {
|
if (result.doc_skipped) {
|
||||||
warn.style.display = 'block';
|
warn.style.display = 'block';
|
||||||
|
warn.style.color = '#0d6efd';
|
||||||
|
warn.textContent = result.doc_message || '差旅报销无需生成易耗品出库单';
|
||||||
|
} else if (result.doc_ok === false && result.doc_error) {
|
||||||
|
warn.style.display = 'block';
|
||||||
|
warn.style.color = '';
|
||||||
warn.textContent = '出库单未生成:' + result.doc_error;
|
warn.textContent = '出库单未生成:' + result.doc_error;
|
||||||
} else {
|
} else {
|
||||||
warn.style.display = 'none';
|
warn.style.display = 'none';
|
||||||
@@ -258,7 +225,7 @@ function showDownloadLinks(result) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
section.style.display = items.length || (result.doc_ok === false) ? 'block' : 'none';
|
section.style.display = items.length || (result.doc_ok === false) || result.doc_skipped ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 发票数据编辑 ----
|
// ---- 发票数据编辑 ----
|
||||||
@@ -369,7 +336,6 @@ async function saveInvoiceData() {
|
|||||||
if (d.doc_url || d.doc_ok === false) {
|
if (d.doc_url || d.doc_ok === false) {
|
||||||
showDownloadLinks({
|
showDownloadLinks({
|
||||||
csv_url: lastDownloadUrls['invoice_summary.csv'],
|
csv_url: lastDownloadUrls['invoice_summary.csv'],
|
||||||
md_url: lastDownloadUrls['invoice_summary.md'],
|
|
||||||
doc_url: d.doc_url,
|
doc_url: d.doc_url,
|
||||||
doc_ok: d.doc_ok,
|
doc_ok: d.doc_ok,
|
||||||
doc_error: d.doc_error,
|
doc_error: d.doc_error,
|
||||||
14
src/web/templates/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# src/web/templates — HTML 模板目录
|
||||||
|
|
||||||
|
存放 Flask 渲染的 HTML 模板文件。
|
||||||
|
|
||||||
|
## 模板清单
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `index.html` | PC 端主界面:包含文件上传区、配置表单、处理按钮、SSE 日志面板、可编辑发票表格、下载链接、财务提交按钮、移动端二维码 |
|
||||||
|
| `mobile_upload.html` | 移动端上传页面:支持拍照/相册选择,上传至当前会话 |
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<div class="header text-center mb-4">
|
<div class="header text-center mb-4">
|
||||||
<h3>财务报销自动化</h3>
|
<h3>财务报销自动化</h3>
|
||||||
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、OCR 识别并填报</p>
|
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、LLM 识别并填报</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container" style="max-width:960px">
|
<div class="container" style="max-width:960px">
|
||||||
@@ -43,17 +43,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CSV 快捷上传 -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="section-title">📊 CSV 快捷上传 <span class="text-muted fw-normal" style="font-size:12px">(已有发票数据 CSV 可直接上传,跳过提取和 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 mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -111,7 +100,7 @@
|
|||||||
<div class="card mb-4" id="edit-section" style="display:none">
|
<div class="card mb-4" id="edit-section" style="display:none">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="section-title d-flex justify-content-between align-items-center">
|
<div class="section-title d-flex justify-content-between align-items-center">
|
||||||
<span>📝 发票数据(可编辑)</span>
|
<span>📝 付款记录(可编辑)</span>
|
||||||
<div class="d-flex justify-content-end align-items-center">
|
<div class="d-flex justify-content-end align-items-center">
|
||||||
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
|
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
|
||||||
</div>
|
</div>
|
||||||
25
tasks.py
Normal 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])
|
||||||
23
tests/README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
last_reviewed: 2026-06-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# tests — 单元测试目录
|
||||||
|
|
||||||
|
存放项目单元测试,使用 pytest 运行。
|
||||||
|
|
||||||
|
## 测试清单
|
||||||
|
|
||||||
|
| 文件 | 覆盖范围 |
|
||||||
|
|------|---------|
|
||||||
|
| `test_config.py` | 配置加载模块 |
|
||||||
|
| `test_extractor.py` | 发票提取编排:空目录、提取失败、正常流程、分类结果、申请单分离、支付匹配 |
|
||||||
|
| `test_invoice.py` | 发票分类、CSV 读写 |
|
||||||
|
| `test_llm_extractor.py` | LLM 信息提取 |
|
||||||
|
| `test_matcher.py` | 发票与支付记录匹配 |
|
||||||
|
|
||||||
|
## 运行方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/
|
||||||
|
```
|
||||||
0
tests/__init__.py
Normal file
19
tests/test_config.py
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,202 @@
|
|||||||
|
"""LLM 信息提取模块单元测试
|
||||||
|
|
||||||
|
覆盖范围:
|
||||||
|
- _parse_json_response:纯 JSON、Markdown 包裹、带前缀、解析失败
|
||||||
|
- _image_to_base64:图片转 base64
|
||||||
|
- extract_document:成功提取、LLM 失败
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.doc.llm_extractor import (
|
||||||
|
_image_to_base64,
|
||||||
|
_parse_json_response,
|
||||||
|
extract_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 字段键名(与源码中的字符串字面量保持一致)
|
||||||
|
K_INVOICE_NUMBER = "invoice_number"
|
||||||
|
K_TOTAL_AMOUNT = "total_amount"
|
||||||
|
K_CARD_DATE = "card_date"
|
||||||
|
K_CARD_NO = "card_no"
|
||||||
|
K_CARD_AMOUNT = "card_amount"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# _parse_json_response
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseJsonResponse:
|
||||||
|
"""JSON 响应解析"""
|
||||||
|
|
||||||
|
def test_pure_json(self):
|
||||||
|
raw = json.dumps({K_INVOICE_NUMBER: "123456", K_TOTAL_AMOUNT: "100.00"})
|
||||||
|
result = _parse_json_response(raw)
|
||||||
|
assert result[K_INVOICE_NUMBER] == "123456"
|
||||||
|
assert result[K_TOTAL_AMOUNT] == "100.00"
|
||||||
|
|
||||||
|
def test_markdown_json_block(self):
|
||||||
|
raw = f'```json\n{{"{K_INVOICE_NUMBER}": "789"}}\n```'
|
||||||
|
result = _parse_json_response(raw)
|
||||||
|
assert result[K_INVOICE_NUMBER] == "789"
|
||||||
|
|
||||||
|
def test_markdown_block_without_lang(self):
|
||||||
|
raw = '```\n{"key": "value"}\n```'
|
||||||
|
result = _parse_json_response(raw)
|
||||||
|
assert result["key"] == "value"
|
||||||
|
|
||||||
|
def test_json_prefix(self):
|
||||||
|
raw = f'json\n{{"{K_INVOICE_NUMBER}": "001"}}'
|
||||||
|
result = _parse_json_response(raw)
|
||||||
|
assert result[K_INVOICE_NUMBER] == "001"
|
||||||
|
|
||||||
|
def test_json_prefix_with_whitespace(self):
|
||||||
|
raw = ' json \n{"a": 1}'
|
||||||
|
result = _parse_json_response(raw)
|
||||||
|
assert result["a"] == 1
|
||||||
|
|
||||||
|
def test_nested_json(self):
|
||||||
|
raw = json.dumps({"outer": {"inner": [1, 2, 3]}})
|
||||||
|
result = _parse_json_response(raw)
|
||||||
|
assert result["outer"]["inner"] == [1, 2, 3]
|
||||||
|
|
||||||
|
def test_whitespace_around_json(self):
|
||||||
|
raw = ' \n {"x": 42} \n '
|
||||||
|
result = _parse_json_response(raw)
|
||||||
|
assert result["x"] == 42
|
||||||
|
|
||||||
|
def test_invalid_json_raises(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_parse_json_response("not json at all")
|
||||||
|
|
||||||
|
def test_empty_string_raises(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_parse_json_response("")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# _image_to_base64
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageToBase64:
|
||||||
|
"""图片转 base64"""
|
||||||
|
|
||||||
|
def test_png_to_base64(self, tmp_path: Path):
|
||||||
|
content = b"\x89PNG\r\n\x1a\nfake_png_data"
|
||||||
|
img_path = tmp_path / "test.png"
|
||||||
|
img_path.write_bytes(content)
|
||||||
|
|
||||||
|
result = _image_to_base64(img_path)
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert base64.b64decode(result) == content
|
||||||
|
|
||||||
|
def test_jpg_to_base64(self, tmp_path: Path):
|
||||||
|
content = b"\xff\xd8\xff\xe0fake_jpg_data"
|
||||||
|
img_path = tmp_path / "test.jpg"
|
||||||
|
img_path.write_bytes(content)
|
||||||
|
|
||||||
|
result = _image_to_base64(img_path)
|
||||||
|
assert base64.b64decode(result) == content
|
||||||
|
|
||||||
|
def test_file_not_found_raises(self, tmp_path: Path):
|
||||||
|
img_path = tmp_path / "nonexistent.png"
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
_image_to_base64(img_path)
|
||||||
|
|
||||||
|
def test_returns_utf8_string(self, tmp_path: Path):
|
||||||
|
content = b"test_image_content"
|
||||||
|
img_path = tmp_path / "test.png"
|
||||||
|
img_path.write_bytes(content)
|
||||||
|
|
||||||
|
result = _image_to_base64(img_path)
|
||||||
|
assert type(result) is str
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# extract_document (mock LLM)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractDocument:
|
||||||
|
"""图片提取:通过 mock _llm_query_multimodal 避免真实 LLM 调用"""
|
||||||
|
|
||||||
|
def _mock_multimodal(self, monkeypatch, response_text: str):
|
||||||
|
def fake_query(system_prompt, text, image_b64, max_tokens=4096):
|
||||||
|
return response_text
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", fake_query)
|
||||||
|
|
||||||
|
def test_success(self, tmp_path: Path, monkeypatch):
|
||||||
|
img_path = tmp_path / "card.png"
|
||||||
|
img_path.write_bytes(b"fake_image")
|
||||||
|
|
||||||
|
mock_result = {
|
||||||
|
K_CARD_DATE: "2026-01-10",
|
||||||
|
K_CARD_NO: "6228480000000000",
|
||||||
|
K_CARD_AMOUNT: "500.00",
|
||||||
|
}
|
||||||
|
self._mock_multimodal(monkeypatch, json.dumps(mock_result))
|
||||||
|
|
||||||
|
result = extract_document(img_path)
|
||||||
|
assert result[K_CARD_DATE] == "2026-01-10"
|
||||||
|
assert result[K_CARD_NO] == "6228480000000000"
|
||||||
|
assert result[K_CARD_AMOUNT] == "500.00"
|
||||||
|
|
||||||
|
def test_llm_failure_propagates(self, tmp_path: Path, monkeypatch):
|
||||||
|
img_path = tmp_path / "card.png"
|
||||||
|
img_path.write_bytes(b"fake_image")
|
||||||
|
|
||||||
|
def fake_query(system_prompt, text, image_b64, max_tokens=4096):
|
||||||
|
raise RuntimeError("模型不可用")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", fake_query)
|
||||||
|
with pytest.raises(RuntimeError, match="模型不可用"):
|
||||||
|
extract_document(img_path)
|
||||||
|
|
||||||
|
def test_file_not_found_propagates(self, tmp_path: Path, monkeypatch):
|
||||||
|
img_path = tmp_path / "missing.png"
|
||||||
|
self._mock_multimodal(monkeypatch, "{}")
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
extract_document(img_path)
|
||||||
|
|
||||||
|
def test_markdown_wrapped_json(self, tmp_path: Path, monkeypatch):
|
||||||
|
img_path = tmp_path / "card.png"
|
||||||
|
img_path.write_bytes(b"fake_image")
|
||||||
|
|
||||||
|
mock_result = {
|
||||||
|
K_CARD_DATE: "2026-02-01",
|
||||||
|
K_CARD_NO: "6228481111111111",
|
||||||
|
K_CARD_AMOUNT: "300.00",
|
||||||
|
}
|
||||||
|
wrapped = f"```json\n{json.dumps(mock_result)}\n```"
|
||||||
|
self._mock_multimodal(monkeypatch, wrapped)
|
||||||
|
|
||||||
|
result = extract_document(img_path)
|
||||||
|
assert result[K_CARD_DATE] == "2026-02-01"
|
||||||
|
|
||||||
|
def test_image_encoded_as_base64(self, tmp_path: Path, monkeypatch):
|
||||||
|
img_path = tmp_path / "card.png"
|
||||||
|
expected_content = b"test_image_data"
|
||||||
|
img_path.write_bytes(expected_content)
|
||||||
|
|
||||||
|
received_b64 = None
|
||||||
|
|
||||||
|
def capture_b64(system_prompt, text, image_b64s, max_tokens=4096):
|
||||||
|
nonlocal received_b64
|
||||||
|
received_b64 = image_b64s
|
||||||
|
return json.dumps({K_CARD_DATE: "2026-01-01", K_CARD_NO: "0000", K_CARD_AMOUNT: "100"})
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", capture_b64)
|
||||||
|
extract_document(img_path)
|
||||||
|
|
||||||
|
assert received_b64 is not None
|
||||||
|
assert isinstance(received_b64, list)
|
||||||
|
assert len(received_b64) >= 1
|
||||||
|
assert base64.b64decode(received_b64[0]) == expected_content
|
||||||
564
tests/test_matcher.py
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
"""发票与支付记录匹配模块单元测试
|
||||||
|
|
||||||
|
覆盖范围:
|
||||||
|
- 辅助函数:_safe_float, _relative_tolerance, _build_invoice_summary
|
||||||
|
- 一对一匹配:精确匹配、容差内匹配、容差外不匹配
|
||||||
|
- 一对多匹配:精确匹配阶段、贪心匹配阶段、回滚逻辑
|
||||||
|
- 记录构建:_build_payment_records, _invoices_to_records
|
||||||
|
- 端到端:match_invoices_to_cards(直接传入分类好的支付记录)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.doc.matcher import (
|
||||||
|
_build_invoice_summary,
|
||||||
|
_build_payment_records,
|
||||||
|
_invoices_to_records,
|
||||||
|
_match,
|
||||||
|
_match_one_to_many,
|
||||||
|
_match_one_to_one,
|
||||||
|
_relative_tolerance,
|
||||||
|
_safe_float,
|
||||||
|
match_invoices_to_cards,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 字段键名(与源码中的字符串字面量保持一致)
|
||||||
|
K_INVOICE_TYPE = "invoice_type"
|
||||||
|
K_INVOICE_NUMBER = "invoice_number"
|
||||||
|
K_TOTAL_AMOUNT = "total_amount"
|
||||||
|
K_ITEM_NAME = "item_name"
|
||||||
|
K_PERSON_NAME = "person_name"
|
||||||
|
K_CARD_DATE = "card_date"
|
||||||
|
K_CARD_NO = "card_no"
|
||||||
|
K_CARD_AMOUNT = "card_amount"
|
||||||
|
K_MATCHED_INVOICES = "_matched_invoices"
|
||||||
|
K_RELATIVE_INVOICE_COUNT = "relative_invoice_count"
|
||||||
|
K_INVOICE_DETAIL = "invoice_detail"
|
||||||
|
K_REMARK = "remark"
|
||||||
|
K_SOURCE_FILE = "source_file"
|
||||||
|
|
||||||
|
INVOICE_TYPE_TRAIN = "train"
|
||||||
|
INVOICE_TYPE_HOTEL = "hotel"
|
||||||
|
INVOICE_TYPE_GENERAL = "general"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Fixture helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_invoice(number: str, amount: float, inv_type: str = INVOICE_TYPE_GENERAL) -> dict[str, Any]:
|
||||||
|
inv: dict[str, Any] = {
|
||||||
|
K_INVOICE_NUMBER: number,
|
||||||
|
K_INVOICE_TYPE: inv_type,
|
||||||
|
K_TOTAL_AMOUNT: str(amount),
|
||||||
|
}
|
||||||
|
if inv_type == INVOICE_TYPE_TRAIN:
|
||||||
|
inv[K_PERSON_NAME] = f"person{number}"
|
||||||
|
elif inv_type == INVOICE_TYPE_GENERAL:
|
||||||
|
inv[K_ITEM_NAME] = f"item{number}"
|
||||||
|
return inv
|
||||||
|
|
||||||
|
|
||||||
|
def _make_card(date: str, amount: float, card_no: str = "6228480000000000") -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
K_CARD_DATE: date,
|
||||||
|
K_CARD_NO: card_no,
|
||||||
|
K_CARD_AMOUNT: str(amount),
|
||||||
|
K_SOURCE_FILE: "card.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 辅助函数
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafeFloat:
|
||||||
|
"""安全浮点转换"""
|
||||||
|
|
||||||
|
def test_normal_string(self):
|
||||||
|
assert _safe_float("123.45") == 123.45
|
||||||
|
|
||||||
|
def test_with_comma(self):
|
||||||
|
assert _safe_float("1,234.56") == 1234.56
|
||||||
|
|
||||||
|
def test_none_returns_default(self):
|
||||||
|
assert _safe_float(None) == 0.0
|
||||||
|
|
||||||
|
def test_empty_string_returns_default(self):
|
||||||
|
assert _safe_float("") == 0.0
|
||||||
|
|
||||||
|
def test_whitespace_returns_default(self):
|
||||||
|
assert _safe_float(" ") == 0.0
|
||||||
|
|
||||||
|
def test_invalid_string_returns_default(self):
|
||||||
|
assert _safe_float("abc") == 0.0
|
||||||
|
|
||||||
|
def test_custom_default(self):
|
||||||
|
assert _safe_float(None, default=-1.0) == -1.0
|
||||||
|
|
||||||
|
def test_int_input(self):
|
||||||
|
assert _safe_float(42) == 42.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRelativeTolerance:
|
||||||
|
"""相对容差计算"""
|
||||||
|
|
||||||
|
def test_default_rate(self):
|
||||||
|
assert _relative_tolerance(1000) == 30.0
|
||||||
|
|
||||||
|
def test_custom_rate(self):
|
||||||
|
assert _relative_tolerance(1000, 0.03) == 30.0
|
||||||
|
|
||||||
|
def test_negative_base(self):
|
||||||
|
assert _relative_tolerance(-200, 0.03) == 6.0
|
||||||
|
|
||||||
|
def test_zero_base(self):
|
||||||
|
assert _relative_tolerance(0, 0.03) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildInvoiceSummary:
|
||||||
|
"""发票汇总字符串"""
|
||||||
|
|
||||||
|
def test_single_invoice(self):
|
||||||
|
invoices = [_make_invoice("INV001", 100.0)]
|
||||||
|
result = _build_invoice_summary(invoices)
|
||||||
|
assert "INV001" in result
|
||||||
|
assert "100.0" in result
|
||||||
|
|
||||||
|
def test_multiple_invoices(self):
|
||||||
|
invoices = [
|
||||||
|
_make_invoice("INV001", 100.0),
|
||||||
|
_make_invoice("INV002", 200.0),
|
||||||
|
]
|
||||||
|
result = _build_invoice_summary(invoices)
|
||||||
|
assert " | " in result
|
||||||
|
assert "INV001" in result
|
||||||
|
assert "INV002" in result
|
||||||
|
|
||||||
|
def test_train_uses_person_name(self):
|
||||||
|
invoices = [_make_invoice("TRAIN001", 500.0, INVOICE_TYPE_TRAIN)]
|
||||||
|
result = _build_invoice_summary(invoices)
|
||||||
|
assert "personTRAIN001" in result
|
||||||
|
assert INVOICE_TYPE_TRAIN in result
|
||||||
|
|
||||||
|
def test_hotel_uses_fixed_label(self):
|
||||||
|
invoices = [_make_invoice("HOTEL001", 800.0, INVOICE_TYPE_HOTEL)]
|
||||||
|
result = _build_invoice_summary(invoices)
|
||||||
|
assert "hotel[hotel]" in result
|
||||||
|
|
||||||
|
def test_general_uses_project_name(self):
|
||||||
|
invoices = [_make_invoice("INV001", 100.0, INVOICE_TYPE_GENERAL)]
|
||||||
|
result = _build_invoice_summary(invoices)
|
||||||
|
assert "itemINV001" in result
|
||||||
|
|
||||||
|
def test_missing_fields_falls_back_to_number(self):
|
||||||
|
inv = {K_INVOICE_NUMBER: "INV001", K_TOTAL_AMOUNT: "50.0"}
|
||||||
|
result = _build_invoice_summary([inv])
|
||||||
|
assert "INV001" in result
|
||||||
|
|
||||||
|
def test_empty_invoices_returns_empty(self):
|
||||||
|
result = _build_invoice_summary([])
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 一对一匹配
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchOneToOne:
|
||||||
|
"""一对一匹配"""
|
||||||
|
|
||||||
|
def test_exact_match(self):
|
||||||
|
invoices = [_make_invoice("A", 500), _make_invoice("B", 300)]
|
||||||
|
cards = [_make_card("2026-01-01", 500), _make_card("2026-01-02", 300)]
|
||||||
|
for inv in invoices:
|
||||||
|
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||||
|
for card in cards:
|
||||||
|
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||||
|
|
||||||
|
assigned: set[int] = set()
|
||||||
|
result: dict[int, list[int]] = {}
|
||||||
|
_match_one_to_one(invoices, cards, 0.03, assigned, result)
|
||||||
|
|
||||||
|
assert 0 in result
|
||||||
|
assert 1 in result
|
||||||
|
assert result[0] == [0]
|
||||||
|
assert result[1] == [1]
|
||||||
|
|
||||||
|
def test_within_tolerance(self):
|
||||||
|
invoices = [_make_invoice("A", 500)]
|
||||||
|
cards = [_make_card("2026-01-01", 490)]
|
||||||
|
for inv in invoices:
|
||||||
|
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||||
|
for card in cards:
|
||||||
|
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||||
|
|
||||||
|
assigned: set[int] = set()
|
||||||
|
result: dict[int, list[int]] = {}
|
||||||
|
_match_one_to_one(invoices, cards, 0.03, assigned, result)
|
||||||
|
|
||||||
|
assert 0 in result
|
||||||
|
|
||||||
|
def test_outside_tolerance_no_match(self):
|
||||||
|
invoices = [_make_invoice("A", 500)]
|
||||||
|
cards = [_make_card("2026-01-01", 400)]
|
||||||
|
for inv in invoices:
|
||||||
|
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||||
|
for card in cards:
|
||||||
|
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||||
|
|
||||||
|
assigned: set[int] = set()
|
||||||
|
result: dict[int, list[int]] = {}
|
||||||
|
_match_one_to_one(invoices, cards, 0.03, assigned, result)
|
||||||
|
|
||||||
|
assert 0 not in result
|
||||||
|
|
||||||
|
def test_sorted_by_amount_desc(self):
|
||||||
|
invoices = [_make_invoice("A", 100), _make_invoice("B", 500)]
|
||||||
|
cards = [_make_card("2026-01-01", 100), _make_card("2026-01-02", 500)]
|
||||||
|
for inv in invoices:
|
||||||
|
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||||
|
for card in cards:
|
||||||
|
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||||
|
|
||||||
|
invoices.sort(key=lambda i: i["_amount"], reverse=True)
|
||||||
|
cards.sort(key=lambda c: c["_amount"], reverse=True)
|
||||||
|
|
||||||
|
assigned: set[int] = set()
|
||||||
|
result: dict[int, list[int]] = {}
|
||||||
|
_match_one_to_one(invoices, cards, 0.03, assigned, result)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 一对多匹配
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchOneToMany:
|
||||||
|
"""一对多匹配"""
|
||||||
|
|
||||||
|
def _prepare(self, invoices, cards):
|
||||||
|
for inv in invoices:
|
||||||
|
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||||
|
for card in cards:
|
||||||
|
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||||
|
invoices.sort(key=lambda i: i["_amount"], reverse=True)
|
||||||
|
cards.sort(key=lambda c: c["_amount"], reverse=True)
|
||||||
|
|
||||||
|
def test_exact_match_phase(self):
|
||||||
|
invoices = [
|
||||||
|
_make_invoice("A", 500),
|
||||||
|
_make_invoice("B", 300),
|
||||||
|
_make_invoice("C", 200),
|
||||||
|
]
|
||||||
|
cards = [_make_card("2026-01-01", 500)]
|
||||||
|
self._prepare(invoices, cards)
|
||||||
|
|
||||||
|
assigned: set[int] = set()
|
||||||
|
result: dict[int, list[int]] = {}
|
||||||
|
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||||
|
|
||||||
|
assert 0 in result
|
||||||
|
matched_inv = invoices[result[0][0]]
|
||||||
|
assert matched_inv["_amount"] == 500.0
|
||||||
|
|
||||||
|
def test_greedy_match_multiple_invoices(self):
|
||||||
|
invoices = [
|
||||||
|
_make_invoice("A", 300),
|
||||||
|
_make_invoice("B", 200),
|
||||||
|
_make_invoice("C", 100),
|
||||||
|
]
|
||||||
|
cards = [_make_card("2026-01-01", 500)]
|
||||||
|
self._prepare(invoices, cards)
|
||||||
|
|
||||||
|
assigned: set[int] = set()
|
||||||
|
result: dict[int, list[int]] = {}
|
||||||
|
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||||
|
|
||||||
|
assert 0 in result
|
||||||
|
assert len(result[0]) == 2
|
||||||
|
|
||||||
|
def test_greedy_with_remaining_invoice(self):
|
||||||
|
invoices = [
|
||||||
|
_make_invoice("A", 300),
|
||||||
|
_make_invoice("B", 200),
|
||||||
|
_make_invoice("C", 100),
|
||||||
|
]
|
||||||
|
cards = [_make_card("2026-01-01", 500)]
|
||||||
|
self._prepare(invoices, cards)
|
||||||
|
|
||||||
|
assigned: set[int] = set()
|
||||||
|
result: dict[int, list[int]] = {}
|
||||||
|
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||||
|
|
||||||
|
assert 0 in result
|
||||||
|
assert len(assigned) == 2
|
||||||
|
|
||||||
|
def test_rollback_when_over_shooting(self):
|
||||||
|
invoices = [
|
||||||
|
_make_invoice("A", 600),
|
||||||
|
_make_invoice("B", 500),
|
||||||
|
]
|
||||||
|
cards = [_make_card("2026-01-01", 1000)]
|
||||||
|
self._prepare(invoices, cards)
|
||||||
|
|
||||||
|
assigned: set[int] = set()
|
||||||
|
result: dict[int, list[int]] = {}
|
||||||
|
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||||
|
|
||||||
|
assert 0 in result
|
||||||
|
assert len(result[0]) == 1
|
||||||
|
|
||||||
|
def test_zero_amount_card_skipped(self):
|
||||||
|
invoices = [_make_invoice("A", 100)]
|
||||||
|
cards = [_make_card("2026-01-01", 0)]
|
||||||
|
self._prepare(invoices, cards)
|
||||||
|
|
||||||
|
assigned: set[int] = set()
|
||||||
|
result: dict[int, list[int]] = {}
|
||||||
|
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||||
|
|
||||||
|
assert 0 not in result
|
||||||
|
|
||||||
|
def test_zero_amount_invoice_skipped(self):
|
||||||
|
invoices = [
|
||||||
|
_make_invoice("A", 100),
|
||||||
|
_make_invoice("B", 0),
|
||||||
|
]
|
||||||
|
cards = [_make_card("2026-01-01", 100)]
|
||||||
|
self._prepare(invoices, cards)
|
||||||
|
|
||||||
|
assigned: set[int] = set()
|
||||||
|
result: dict[int, list[int]] = {}
|
||||||
|
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||||
|
|
||||||
|
assert 0 in result
|
||||||
|
matched_inv = invoices[result[0][0]]
|
||||||
|
assert matched_inv["_amount"] == 100.0
|
||||||
|
|
||||||
|
def test_multiple_cards_greedy(self):
|
||||||
|
invoices = [
|
||||||
|
_make_invoice("A", 300),
|
||||||
|
_make_invoice("B", 200),
|
||||||
|
_make_invoice("C", 150),
|
||||||
|
_make_invoice("D", 100),
|
||||||
|
]
|
||||||
|
cards = [
|
||||||
|
_make_card("2026-01-01", 500),
|
||||||
|
_make_card("2026-01-02", 150),
|
||||||
|
]
|
||||||
|
self._prepare(invoices, cards)
|
||||||
|
|
||||||
|
assigned: set[int] = set()
|
||||||
|
result: dict[int, list[int]] = {}
|
||||||
|
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||||
|
|
||||||
|
assert 0 in result
|
||||||
|
assert 1 in result
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# _match 路由
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchRouter:
|
||||||
|
"""_match 根据数量选择策略"""
|
||||||
|
|
||||||
|
def _prepare(self, invoices, cards):
|
||||||
|
for inv in invoices:
|
||||||
|
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||||
|
for card in cards:
|
||||||
|
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||||
|
invoices.sort(key=lambda i: i["_amount"], reverse=True)
|
||||||
|
cards.sort(key=lambda c: c["_amount"], reverse=True)
|
||||||
|
|
||||||
|
def test_equal_count_routes_to_one_to_one(self):
|
||||||
|
invoices = [_make_invoice("A", 500)]
|
||||||
|
cards = [_make_card("2026-01-01", 500)]
|
||||||
|
self._prepare(invoices, cards)
|
||||||
|
|
||||||
|
result = _match(cards, invoices, 0.03)
|
||||||
|
assert 0 in result
|
||||||
|
|
||||||
|
def test_more_invoices_routes_to_one_to_many(self):
|
||||||
|
invoices = [_make_invoice("A", 300), _make_invoice("B", 200)]
|
||||||
|
cards = [_make_card("2026-01-01", 500)]
|
||||||
|
self._prepare(invoices, cards)
|
||||||
|
|
||||||
|
result = _match(cards, invoices, 0.03)
|
||||||
|
assert 0 in result
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 记录构建
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildPaymentRecords:
|
||||||
|
"""构建支付记录列表"""
|
||||||
|
|
||||||
|
def _prepare(self, invoices, cards):
|
||||||
|
for inv in invoices:
|
||||||
|
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||||
|
for card in cards:
|
||||||
|
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||||
|
|
||||||
|
def test_matched_records_have_card_info(self):
|
||||||
|
invoices = [_make_invoice("A", 500)]
|
||||||
|
cards = [_make_card("2026-01-01", 500)]
|
||||||
|
self._prepare(invoices, cards)
|
||||||
|
|
||||||
|
card_to_invoices = {0: [0]}
|
||||||
|
records = _build_payment_records(cards, invoices, card_to_invoices)
|
||||||
|
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0][K_CARD_DATE] == "2026-01-01"
|
||||||
|
assert records[0][K_RELATIVE_INVOICE_COUNT] == "1"
|
||||||
|
assert "unmatched" not in records[0][K_REMARK]
|
||||||
|
|
||||||
|
def test_unmatched_invoices_become_separate_records(self):
|
||||||
|
invoices = [_make_invoice("A", 500), _make_invoice("B", 300)]
|
||||||
|
cards = [_make_card("2026-01-01", 500)]
|
||||||
|
self._prepare(invoices, cards)
|
||||||
|
|
||||||
|
card_to_invoices = {0: [0]}
|
||||||
|
records = _build_payment_records(cards, invoices, card_to_invoices)
|
||||||
|
|
||||||
|
assert len(records) == 2
|
||||||
|
unmatched = [r for r in records if r[K_REMARK] == "unmatched"]
|
||||||
|
assert len(unmatched) == 1
|
||||||
|
|
||||||
|
def test_empty_mapping_returns_no_records(self):
|
||||||
|
invoices = []
|
||||||
|
cards = []
|
||||||
|
records = _build_payment_records(cards, invoices, {})
|
||||||
|
assert records == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvoicesToRecords:
|
||||||
|
"""无刷卡记录时将发票转为独立记录"""
|
||||||
|
|
||||||
|
def test_single_invoice(self):
|
||||||
|
invoices = [_make_invoice("A", 100)]
|
||||||
|
records = _invoices_to_records(invoices)
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0][K_RELATIVE_INVOICE_COUNT] == "1"
|
||||||
|
|
||||||
|
def test_multiple_invoices(self):
|
||||||
|
invoices = [_make_invoice("A", 100), _make_invoice("B", 200)]
|
||||||
|
records = _invoices_to_records(invoices)
|
||||||
|
assert len(records) == 2
|
||||||
|
|
||||||
|
def test_empty_invoices(self):
|
||||||
|
records = _invoices_to_records([])
|
||||||
|
assert records == []
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 端到端集成
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchInvoicesToCards:
|
||||||
|
"""match_invoices_to_cards 端到端测试"""
|
||||||
|
|
||||||
|
def test_no_cards_returns_invoice_records(self):
|
||||||
|
invoices = [_make_invoice("A", 100), _make_invoice("B", 200)]
|
||||||
|
result = match_invoices_to_cards(invoices, cards=None)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
for rec in result:
|
||||||
|
assert rec[K_CARD_DATE] == ""
|
||||||
|
assert rec[K_CARD_NO] == ""
|
||||||
|
|
||||||
|
def test_one_to_one_end_to_end(self):
|
||||||
|
cards = [
|
||||||
|
{
|
||||||
|
K_CARD_DATE: "2026-01-01",
|
||||||
|
K_CARD_NO: "6228480000000000",
|
||||||
|
K_CARD_AMOUNT: "500.00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
invoices = [_make_invoice("A", 500)]
|
||||||
|
result = match_invoices_to_cards(invoices, cards=cards)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0][K_CARD_DATE] == "2026-01-01"
|
||||||
|
assert result[0][K_RELATIVE_INVOICE_COUNT] == "1"
|
||||||
|
assert "_amount" not in result[0]
|
||||||
|
|
||||||
|
def test_one_to_many_end_to_end(self):
|
||||||
|
cards = [
|
||||||
|
{
|
||||||
|
K_CARD_DATE: "2026-01-01",
|
||||||
|
K_CARD_NO: "6228480000000000",
|
||||||
|
K_CARD_AMOUNT: "500.00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
invoices = [
|
||||||
|
_make_invoice("A", 300),
|
||||||
|
_make_invoice("B", 200),
|
||||||
|
]
|
||||||
|
result = match_invoices_to_cards(invoices, cards=cards)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0][K_RELATIVE_INVOICE_COUNT] == "2"
|
||||||
|
|
||||||
|
def test_unmatched_invoices_included(self):
|
||||||
|
cards = [
|
||||||
|
{
|
||||||
|
K_CARD_DATE: "2026-01-01",
|
||||||
|
K_CARD_NO: "6228480000000000",
|
||||||
|
K_CARD_AMOUNT: "500.00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
invoices = [
|
||||||
|
_make_invoice("A", 500),
|
||||||
|
_make_invoice("B", 100),
|
||||||
|
]
|
||||||
|
result = match_invoices_to_cards(invoices, cards=cards)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
unmatched = [r for r in result if r[K_REMARK] == "unmatched"]
|
||||||
|
assert len(unmatched) == 1
|
||||||
|
|
||||||
|
def test_internal_fields_cleaned(self):
|
||||||
|
cards = [
|
||||||
|
{
|
||||||
|
K_CARD_DATE: "2026-01-01",
|
||||||
|
K_CARD_NO: "6228480000000000",
|
||||||
|
K_CARD_AMOUNT: "500.00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
invoices = [_make_invoice("A", 500)]
|
||||||
|
result = match_invoices_to_cards(invoices, cards=cards)
|
||||||
|
|
||||||
|
for inv in result[0][K_MATCHED_INVOICES]:
|
||||||
|
assert "_amount" not in inv
|
||||||
|
|
||||||
|
def test_empty_cards_list(self):
|
||||||
|
invoices = [_make_invoice("A", 100)]
|
||||||
|
result = match_invoices_to_cards(invoices, cards=[])
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0][K_CARD_DATE] == ""
|
||||||
|
|
||||||
|
def test_multiple_cards(self):
|
||||||
|
cards = [
|
||||||
|
{K_CARD_DATE: "2026-01-01", K_CARD_NO: "6228480000000001", K_CARD_AMOUNT: "500.00"},
|
||||||
|
{K_CARD_DATE: "2026-01-02", K_CARD_NO: "6228480000000002", K_CARD_AMOUNT: "300.00"},
|
||||||
|
]
|
||||||
|
invoices = [
|
||||||
|
_make_invoice("A", 500),
|
||||||
|
_make_invoice("B", 300),
|
||||||
|
]
|
||||||
|
result = match_invoices_to_cards(invoices, cards=cards)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
302
报销操作指南.md
@@ -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` 定位失败步骤 |
|
|
||||||