重构项目为LLM 驱动
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
|
||||
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/` 目录下新建记录文档,内容包含:
|
||||
* 故障现象
|
||||
* 历次排查操作及失败原因
|
||||
* 最终解决方案
|
||||
* 后续可借鉴的调试经验
|
||||
7
.cursorignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.playwright-mcp/
|
||||
*.pyc
|
||||
42
.gitignore
vendored
@@ -1,34 +1,10 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Playwright MCP snapshots
|
||||
.playwright-mcp/
|
||||
|
||||
# Local secrets & backups
|
||||
config.json
|
||||
*.doc.bak
|
||||
|
||||
# Uploads (user data)
|
||||
web/uploads/
|
||||
|
||||
# Logs
|
||||
pipeline.log
|
||||
*.log
|
||||
|
||||
# Debug images
|
||||
images/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.venv/
|
||||
.cursor/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.playwright-mcp/
|
||||
*.pyc
|
||||
logs/
|
||||
.vscode/
|
||||
7
.pre-commit-config.yaml
Normal file
@@ -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
|
||||
103
PowerShell注意事项.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# PowerShell 使用注意事项
|
||||
|
||||
> 适用于 Windows 环境下的 Shell 命令执行。以下坑点均来自实际踩坑记录。
|
||||
|
||||
---
|
||||
|
||||
## 1. 没有 `head` / `tail` / `sed` / `awk`
|
||||
|
||||
PowerShell 不是 bash,这些 Unix 工具不存在。
|
||||
|
||||
| bash | PowerShell 替代 |
|
||||
|------|-----------------|
|
||||
| `cmd \| head -n 5` | `cmd \| Select-Object -First 5` |
|
||||
| `cmd \| tail -n 3` | `cmd \| Select-Object -Last 3` |
|
||||
| `grep pattern file` | `Select-String pattern file`(或直接调用 `rg`) |
|
||||
|
||||
**优先用 ripgrep (`rg`)**:搜索文件内容时直接用 `rg`,不依赖 PowerShell 内置工具。
|
||||
|
||||
---
|
||||
|
||||
## 2. 引号嵌套规则严格
|
||||
|
||||
PowerShell 对外部命令的引号处理不同于 bash:
|
||||
|
||||
- `"` 是双引号字符串,内部变量会展开
|
||||
- `'` 是单引号字符串,不展开变量
|
||||
- `-c "..."` 里再嵌套 Python 的 `"..."` 或 `r'...'` 时,层数超过两层极易出错
|
||||
|
||||
**示例:**
|
||||
|
||||
```powershell
|
||||
# 危险:$e 会被 PowerShell 当作变量展开
|
||||
python -c "print(f'error: {$e}')"
|
||||
|
||||
# 安全:用单引号包裹整个 -c 参数(PowerShell 不展开单引号)
|
||||
python -c 'print("hello")'
|
||||
```
|
||||
|
||||
**最佳实践**:复杂命令写进临时 `.py` 脚本文件执行,而不是用 `-c` 一行塞完。
|
||||
|
||||
---
|
||||
|
||||
## 3. `$` 变量展开干扰
|
||||
|
||||
在双引号字符串中,PowerShell 会尝试解析 `$var`、`${expr}`。如果 Python 代码里包含 f-string 的 `{...}` 或正则里的 `$`,会被 PowerShell 提前处理。
|
||||
|
||||
**应对**:用单引号包裹整个命令参数,或将逻辑写进脚本文件。
|
||||
|
||||
---
|
||||
|
||||
## 4. Unicode / GBK 编码冲突
|
||||
|
||||
Windows PowerShell 默认控制台编码是 GBK。当 Python 输出包含非 GBK 字符(如全角符号、emoji、CJK 扩展字符)时:
|
||||
|
||||
```
|
||||
UnicodeEncodeError: 'gbk' codec can't encode character '\uff08' in position X
|
||||
```
|
||||
|
||||
**解决:**
|
||||
|
||||
- Python 脚本开头加 `sys.stdout.reconfigure(encoding='utf-8')`
|
||||
- 或设置环境变量 `$env:PYTHONIOENCODING = 'utf-8'`
|
||||
- 或将输出写入文件而非直接打印到控制台
|
||||
|
||||
---
|
||||
|
||||
## 5. stderr 重定向行为不一致
|
||||
|
||||
bash 的 `2>&1` 在 PowerShell 中对外部程序(如 Python)有时能工作,但格式可能错乱。PowerShell 默认只捕获 stdout(流编号 6),stderr(流编号 2)需要显式合并。
|
||||
|
||||
| 写法 | 说明 |
|
||||
|------|------|
|
||||
| `cmd 2>&1` | stderr 合并到 stdout,对外部程序可用但不稳定 |
|
||||
| `cmd *>&1` | PowerShell 特有语法,捕获所有输出流,更可靠 |
|
||||
|
||||
**实际表现**:Python Traceback 走 stderr,用 `2>&1` 有时被 PowerShell 拦截后格式错乱。建议优先写脚本文件执行。
|
||||
|
||||
---
|
||||
|
||||
## 6. 中文路径在错误回显中乱码
|
||||
|
||||
命令失败时,PowerShell 的 stderr 回显中包含中文的路径会显示为 `<60><><EFBFBD><EFBFBD><EFBFBD>`。不影响命令执行本身,但会让错误信息难以阅读。
|
||||
|
||||
**应对**:优先通过 Python 脚本自身打印结构化错误(写入文件或返回 JSON),而非依赖 PowerShell 的错误捕获来诊断问题。
|
||||
|
||||
---
|
||||
|
||||
## 7. 管道传递的是对象而非文本
|
||||
|
||||
PowerShell 的管道传递的是 .NET 对象而非文本字节流。当把 PowerShell 管道接到外部程序时,对象的 `ToString()` 可能不是预期的格式。
|
||||
|
||||
---
|
||||
|
||||
## 核心原则速查
|
||||
|
||||
1. **复杂命令写脚本**:超过一行的操作,写成 `.py` 或 `.ps1` 文件执行
|
||||
2. **优先用 ripgrep (`rg`)**:搜索文件内容时直接用 `rg`
|
||||
3. **编码问题提前处理**:Python 入口设 `sys.stdout.reconfigure(encoding='utf-8')`
|
||||
4. **避免 `-c` 嵌套引号超过两层**
|
||||
|
||||
---
|
||||
|
||||
*创建于 2026-05-27 | Windows PowerShell + Python 环境*
|
||||
184
README.md
@@ -1,31 +1,44 @@
|
||||
# 财务报销自动化
|
||||
|
||||
自动从 PDF 发票提取信息,OCR 识别支付记录截图,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
|
||||
自动从 PDF 发票提取信息,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
|
||||
|
||||
**支持发票类型区分**:系统自动识别高铁票、酒店住宿等差旅发票与普通发票。差旅发票不生成易耗品出库单,走差旅报销流程;普通发票生成出库单,走普通报销流程。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── run.py # CLI 入口(发票提取 → OCR → 浏览器填报)
|
||||
├── config.json # 配置文件(登录凭据、默认值、存放地点等)
|
||||
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板(Web/CLI 填写用)
|
||||
├── app/
|
||||
│ ├── config.py # 配置加载
|
||||
│ ├── extractor.py # PDF 发票信息提取
|
||||
│ ├── ocr.py # OCR 刷卡信息识别
|
||||
│ ├── bot.py # 浏览器自动填报
|
||||
│ ├── pipeline.py # CLI 流程编排
|
||||
│ └── fill_consumable_doc.py # 将 CSV 填入易耗品出库单(Word COM)
|
||||
├── web/
|
||||
│ ├── app.py # Web 服务入口
|
||||
│ ├── templates/
|
||||
│ │ ├── index.html # PC 端主页
|
||||
│ │ └── mobile_upload.html # 移动端扫码上传
|
||||
│ ├── static/ # 前端 CSS / JS
|
||||
│ └── uploads/ # 按会话隔离的上传与产物目录
|
||||
├── *.pdf # 发票 PDF(CLI 模式,放在项目根目录)
|
||||
├── *.png / *.jpg # 与 PDF 配对的支付截图
|
||||
├── invoice_summary.csv # 发票汇总表(CLI 产物)
|
||||
└── images/ # 浏览器调试截图
|
||||
├── pyproject.toml # 项目配置(依赖、工具链)
|
||||
├── uv.lock # 依赖锁定文件
|
||||
├── Makefile # 任务脚本(跨平台)
|
||||
├── tasks.py # 任务脚本(Windows 兼容)
|
||||
├── config.json # 配置文件(登录凭据、默认值等)
|
||||
├── config.example.json # 配置示例
|
||||
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板
|
||||
├── src/
|
||||
│ ├── __init__.py # 包初始化 / 日志器
|
||||
│ ├── config.py # 配置加载
|
||||
│ ├── bot.py # 浏览器自动填报
|
||||
│ ├── pipeline.py # CLI 流程编排
|
||||
│ ├── main.py # CLI 入口
|
||||
│ ├── doc/ # 文档处理模块
|
||||
│ │ ├── extractor.py # 编排入口:串联 PDF 读取 → LLM 提取 → 分类
|
||||
│ │ ├── pdf.py # PDF 文件发现与文本提取
|
||||
│ │ ├── llm_extractor.py # LLM 信息提取
|
||||
│ │ ├── invoice.py # 发票类型常量、分类逻辑、CSV 读写工具
|
||||
│ │ ├── fill_consumable_doc.py # 将 CSV 填入易耗品出库单(Word COM)
|
||||
│ │ ├── prompt.py # LLM 提示词模板
|
||||
│ │ └── prompts/ # 提示词模板文件
|
||||
│ └── web/
|
||||
│ ├── app.py # Web 服务入口
|
||||
│ ├── templates/
|
||||
│ │ ├── index.html # PC 端主页
|
||||
│ │ └── mobile_upload.html # 移动端扫码上传
|
||||
│ ├── static/ # 前端 CSS / JS
|
||||
│ └── uploads/ # 按会话隔离的上传与产物目录
|
||||
├── tests/ # 测试目录
|
||||
├── *.pdf # 发票 PDF(CLI 模式,放在项目根目录)
|
||||
├── invoice_summary.csv # 发票汇总表(CLI 产物)
|
||||
└── images/ # 浏览器调试截图
|
||||
```
|
||||
|
||||
## 数据流
|
||||
@@ -33,49 +46,47 @@
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PDF[PDF 发票] --> Extract[extractor 提取]
|
||||
Extract --> List[发票列表]
|
||||
Img[支付截图] --> OCR[OCR 识别]
|
||||
List --> OCR
|
||||
OCR --> CSV[(invoice_summary.csv)]
|
||||
CSV --> Fill[fill_consumable_doc]
|
||||
Extract --> Classify{发票类型分类}
|
||||
Classify -->|差旅发票| Travel[高铁票 / 酒店住宿]
|
||||
Classify -->|普通发票| General[普通发票]
|
||||
Travel --> CSV[(invoice_summary.csv)]
|
||||
General --> CSV
|
||||
CSV -->|仅普通发票| Fill[fill_consumable_doc]
|
||||
Fill --> Doc[易耗品、出库单.doc]
|
||||
CSV --> Bot[bot 浏览器自动化]
|
||||
Bot --> Submit[财务系统填报<br/>可选]
|
||||
Bot -->|差旅模式| Submit_T[差旅报销填报<br/>TODO]
|
||||
Bot -->|普通模式| Submit_G[普通报销填报]
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3.10+
|
||||
- **Python 3.12+**
|
||||
- **uv** 包管理器([安装指南](https://docs.astral.sh/uv/getting-started/installation/))
|
||||
- Windows(易耗品出库单填写依赖 Microsoft Word + COM,仅 Windows 可用)
|
||||
- 主要依赖见下方安装步骤
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
> OCR 相关依赖建议在已有 PaddleOCR 的环境中安装(如 MinerU 虚拟环境)。
|
||||
|
||||
```bash
|
||||
pip install pdfplumber==0.11.9 paddleocr==2.8.1 playwright==1.60.0 flask==3.0.3 pywin32
|
||||
playwright install chromium
|
||||
# 同步所有依赖(运行时 + 开发工具)
|
||||
make install
|
||||
# Windows 上等效命令:
|
||||
python tasks.py install
|
||||
```
|
||||
|
||||
项目使用 `uv` 管理依赖,所有包版本锁定在 `uv.lock` 中,确保可复现。
|
||||
|
||||
| 依赖 | 用途 |
|
||||
|------|------|
|
||||
| pdfplumber | PDF 发票文本提取 |
|
||||
| paddleocr | 支付截图 OCR |
|
||||
| playwright | 财务系统浏览器自动化 |
|
||||
| flask | Web 服务 |
|
||||
| pywin32 | 填写 Word 出库单(`fill_consumable_doc`) |
|
||||
|
||||
### 2. 准备数据(CLI 模式)
|
||||
|
||||
将发票 PDF 和对应的支付截图放在项目根目录下。脚本会自动匹配 PDF 与截图:
|
||||
|
||||
1. **优先文件名匹配** — PDF 与截图同名(如 `发票.pdf` ↔ `发票.png`)
|
||||
2. **金额近邻匹配** — 文件名不同时,按价税合计与刷卡金额就近配对
|
||||
|
||||
截图支持格式:`.png`、`.jpg`、`.jpeg`、`.bmp`、`.webp`。
|
||||
将发票 PDF 放在项目根目录下。
|
||||
|
||||
### 3. 配置
|
||||
|
||||
@@ -104,16 +115,17 @@ playwright install chromium
|
||||
### 4. 运行(CLI)
|
||||
|
||||
```bash
|
||||
# 全流程(发票提取 → OCR 识别 → 浏览器填报)
|
||||
python run.py
|
||||
# 全流程(发票提取 → 浏览器填报)
|
||||
make run
|
||||
# Windows 等效:
|
||||
python tasks.py run
|
||||
|
||||
# 仅执行某一步
|
||||
python run.py --step invoice # 仅发票提取
|
||||
python run.py --step ocr # 仅 OCR 识别
|
||||
python run.py --step submit # 仅浏览器填报
|
||||
uv run python src/main.py --step invoice # 仅发票提取
|
||||
uv run python src/main.py --step submit # 仅浏览器填报
|
||||
|
||||
# 覆盖配置中的登录凭据
|
||||
python run.py -u 工号 -p 密码
|
||||
uv run python src/main.py -u 工号 -p 密码
|
||||
```
|
||||
|
||||
### 5. 填写易耗品出库单(CLI)
|
||||
@@ -121,9 +133,9 @@ python run.py -u 工号 -p 密码
|
||||
需已生成 `invoice_summary.csv`,且本机已安装 **Microsoft Word**:
|
||||
|
||||
```bash
|
||||
python -m app.fill_consumable_doc
|
||||
python -m app.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
|
||||
uv run python -m src.doc.fill_consumable_doc
|
||||
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
uv run python -m src.doc.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
|
||||
```
|
||||
|
||||
填写规则概要:
|
||||
@@ -137,8 +149,7 @@ python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 发票提取 | `--step invoice` | 扫描根目录 PDF,生成 `invoice_summary.csv` / `.md` |
|
||||
| OCR 识别 | `--step ocr` | 识别支付截图,回填刷卡日期、金额、持卡人等到 CSV |
|
||||
| 发票提取 | `--step invoice` | 扫描根目录 PDF,生成 `invoice_summary.csv` |
|
||||
| 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 |
|
||||
|
||||
> 分步执行时,上一步的 CSV 会自动成为下一步的输入。
|
||||
@@ -148,12 +159,14 @@ python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
|
||||
| 列名 | 说明 |
|
||||
|------|------|
|
||||
| 序号 | 行号 |
|
||||
| 发票类型 | 自动识别:`高铁票` / `酒店住宿` / `普通发票` |
|
||||
| 发票号码 | 电子发票号码 |
|
||||
| 开票日期 | 开票日期 |
|
||||
| 项目名称 / 规格型号 | 货物或应税劳务信息 |
|
||||
| 项目名称 / 规格型号 | 货物或应税劳务信息(差旅发票为出发站→到达站) |
|
||||
| 价税合计 | 发票含税金额 |
|
||||
| 销售方名称 | 销方名称 |
|
||||
| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | OCR 自支付截图回填 |
|
||||
| 出发站 / 到达站 / 车次 / 乘车日期 / 座位等级 | 高铁票专用字段 |
|
||||
| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | 可手工或 Web 端编辑补充 |
|
||||
| 备注 / 工号 | 可手工或 Web 端编辑补充 |
|
||||
|
||||
## Web 服务
|
||||
@@ -161,48 +174,85 @@ python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
|
||||
提供浏览器界面:上传文件 → 自动处理 → 在线编辑 → 下载产物 → 可选提交财务系统。
|
||||
|
||||
```bash
|
||||
python web/app.py
|
||||
uv run python src/web/app.py
|
||||
```
|
||||
|
||||
访问 `http://localhost:5000`。
|
||||
|
||||
### 推荐使用流程
|
||||
|
||||
1. 上传 PDF + 支付截图(或上传已有 CSV)
|
||||
1. 上传 PDF(或上传已有 CSV)
|
||||
2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充
|
||||
3. 点击 **开始处理** — 完成发票提取、OCR、生成 CSV,并自动填写 **易耗品、出库单.doc**
|
||||
4. 在 **下载文件** 区域下载 CSV / Markdown / 出库单 Word
|
||||
5. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
|
||||
6. 确认无误后点击 **提交到财务系统**
|
||||
3. 点击 **开始处理** — 完成发票提取、生成 CSV,系统自动识别发票类型并分类统计
|
||||
4. **普通发票**:自动生成 **易耗品、出库单.doc**,可下载
|
||||
5. **差旅发票**(高铁票/酒店住宿):跳过出库单生成,直接进入差旅报销流程
|
||||
6. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
|
||||
7. 确认无误后点击 **提交到财务系统**
|
||||
|
||||
### 处理模式
|
||||
|
||||
| 模式 | 入口 | 说明 |
|
||||
|------|------|------|
|
||||
| **PDF 模式** | 上传 PDF + 截图 | 提取发票 → OCR → 生成 CSV + 出库单 |
|
||||
| **CSV 快捷模式** | 仅上传 CSV | 跳过提取与 OCR,直接生成出库单并进入编辑/提交 |
|
||||
| **PDF 模式** | 上传 PDF | 提取发票 → 生成 CSV + 自动分类 → 普通发票生成出库单 |
|
||||
| **CSV 快捷模式** | 仅上传 CSV | 跳过提取,读取 CSV 中的发票类型,按需生成出库单 |
|
||||
|
||||
### 发票类型区分
|
||||
|
||||
系统自动识别以下发票类型并按类型分流:
|
||||
|
||||
| 发票类型 | 识别依据 | 出库单 | 填报模式 |
|
||||
|----------|----------|--------|----------|
|
||||
| **高铁票** | 含"电子客票"、"中国铁路"、"12306"等关键词 | 不生成 | 差旅报销(TODO) |
|
||||
| **酒店住宿** | 含"住宿费"、"餐饮服务"、"租赁服务"等关键词 | 不生成 | 差旅报销(TODO) |
|
||||
| **普通发票** | 其他所有发票 | 自动生成 | 普通报销(已实现) |
|
||||
|
||||
当同一批次同时包含差旅发票和普通发票时,系统会为普通发票生成出库单,并在填报时分别处理。
|
||||
|
||||
### 功能一览
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 发票提取 + OCR | 上传 PDF 后自动完成 |
|
||||
| 易耗品出库单 | 处理完成后自动生成 Word,可下载 |
|
||||
| 发票提取 | 上传 PDF 后自动完成 |
|
||||
| 发票类型自动分类 | 高铁票/酒店住宿/普通发票,自动分流处理 |
|
||||
| 易耗品出库单 | 仅普通发票自动生成 Word,差旅发票跳过 |
|
||||
| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 |
|
||||
| 财务系统填报 | 单独按钮触发,处理阶段不会自动提交 |
|
||||
| 实时日志 | SSE 推送处理进度 |
|
||||
| 配置上传 | 支持上传 `config.json` 填充表单 |
|
||||
| 手机扫码上传 | 二维码打开移动端页面,拍照上传支付截图,PC 端轮询同步 |
|
||||
| 手机扫码上传 | 二维码打开移动端页面,拍照上传,PC 端轮询同步 |
|
||||
|
||||
> Web 端浏览器填报以无头模式运行。未上传 PDF 时,填报阶段会跳过附件上传。
|
||||
> Web 端浏览器填报以无头模式运行。未上传 PDF 时,填报阶段会跳过附件上传。
|
||||
> 出库单生成需要 **Windows + Word + pywin32**;若失败,页面会显示具体原因,CSV 等其它产物仍可正常使用。
|
||||
|
||||
接口说明见 [API.md](./API.md)。
|
||||
|
||||
## 开发任务
|
||||
|
||||
项目提供统一的任务脚本,支持跨平台使用:
|
||||
|
||||
| 任务 | Makefile | tasks.py | 说明 |
|
||||
|------|----------|----------|------|
|
||||
| 安装依赖 | `make install` | `python tasks.py install` | 同步依赖 + 安装 pre-commit |
|
||||
| 代码检查 | `make check` | `python tasks.py check` | Ruff lint + 格式化 + MyPy 类型检查 + deptry 依赖检查 |
|
||||
| 运行测试 | `make test` | `python tasks.py test` | pytest + 覆盖率报告 |
|
||||
| 运行 CLI | `make run` | `python tasks.py run` | 执行全流程 |
|
||||
| 清理缓存 | `make clean` | `python tasks.py clean` | 删除虚拟环境和缓存 |
|
||||
|
||||
## 代码质量
|
||||
|
||||
项目配置了完整的代码质量工具链:
|
||||
|
||||
- **Ruff** — 快速 lint 检查和代码格式化(替代 flake8 + isort + black)
|
||||
- **MyPy** — 严格模式类型检查(`strict = true`)
|
||||
- **deptry** — 检测未声明、未使用、过时依赖
|
||||
- **pre-commit** — 提交前自动运行 Ruff 检查和格式化
|
||||
|
||||
所有检查通过后方可提交代码。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 浏览器填报时会打开或使用 Chromium,请勿手动干扰自动化流程
|
||||
- 调试截图保存在 `images/` 目录
|
||||
- OCR 不会覆盖 CSV 中已有非空字段
|
||||
- 项目根目录需保留 `易耗品、出库单.doc` 模板;Web 每次从模板复制到会话目录再填写,不修改原模板
|
||||
- `config.json` 含敏感信息,请勿提交到公开仓库
|
||||
- **发票类型区分**:差旅发票(高铁票/酒店住宿)不会生成易耗品出库单,当前差旅报销填报流程仍在开发中(TODO)
|
||||
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
|
||||
@@ -8,5 +8,10 @@
|
||||
"default_name": "默认报销人姓名",
|
||||
"default_card_no": "默认公务卡号",
|
||||
"default_person_id": "默认人员编号",
|
||||
"consumable_storage": "躬行楼 C205"
|
||||
"consumable_storage": "躬行楼 C205",
|
||||
"llm": {
|
||||
"model": "你的模型名称",
|
||||
"api_base": "你的API地址",
|
||||
"api_key": "你的API密钥"
|
||||
}
|
||||
}
|
||||
|
||||
17
config.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"username": "202407021",
|
||||
"password": "wang!1624155937",
|
||||
"sso_login_url": "https://tyrz.fynu.edu.cn/sso/login",
|
||||
"portal_url": "https://tyrz.fynu.edu.cn/oshall",
|
||||
"reimburse_url": "http://210.45.32.214:8081",
|
||||
"reimburse_page": "/expen/common/common?v=4.0",
|
||||
"default_name": "王建锋",
|
||||
"default_card_no": "6282880139161682",
|
||||
"default_person_id": "202407021",
|
||||
"consumable_storage": "新工科 D605",
|
||||
"llm": {
|
||||
"model": "qwen/qwen3.5-9b",
|
||||
"api_base": "http://100.123.83.115:1234/v1",
|
||||
"api_key": "123456"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
---
|
||||
|
||||
# 财务报销自动化 — API 文档
|
||||
|
||||
> 基础地址: `http://localhost:5000`
|
||||
> 启动: `python web/app.py`
|
||||
> 启动: `uv run python src/web/app.py`
|
||||
|
||||
## 总览
|
||||
|
||||
@@ -12,7 +16,7 @@
|
||||
| 3 | POST | `/api/upload/<session_id>` | 上传文件(PDF/图片) |
|
||||
| 4 | POST | `/api/upload-csv/<session_id>` | 上传 CSV 发票数据 |
|
||||
| 5 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
|
||||
| 6 | POST | `/api/process/<session_id>` | 启动处理(提取+OCR+出库单) |
|
||||
| 6 | POST | `/api/process/<session_id>` | 启动处理(提取+LLM识别+出库单) |
|
||||
| 7 | GET | `/api/logs/<session_id>` | SSE 日志流 |
|
||||
| 8 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
|
||||
| 9 | GET | `/api/data/<session_id>` | 获取发票数据(JSON) |
|
||||
@@ -26,8 +30,8 @@
|
||||
## 会话与目录
|
||||
|
||||
- 调用 `POST /api/session` 获得 `session_id`
|
||||
- 该会话下所有文件存放在 `web/uploads/<session_id>/`
|
||||
- 典型产物:`invoice_summary.csv`、`invoice_summary.md`、`易耗品、出库单.doc`、`config.json`、`session.log`、`result.json`
|
||||
- 该会话下所有文件存放在 `src/web/uploads/<session_id>/`
|
||||
- 典型产物:`invoice_summary.csv`、`易耗品、出库单.doc`、`config.json`、`session.log`、`result.json`
|
||||
|
||||
---
|
||||
|
||||
@@ -91,7 +95,7 @@ Content-Type: multipart/form-data
|
||||
{ "ok": true, "filename": "invoice_summary.csv" }
|
||||
```
|
||||
|
||||
> 上传 CSV 后可跳过 PDF 提取和 OCR,直接进入处理/编辑流程。
|
||||
> 上传 CSV 后可跳过 PDF 提取和 LLM 识别,直接进入处理/编辑流程。
|
||||
|
||||
---
|
||||
|
||||
@@ -137,17 +141,17 @@ Content-Type: application/json
|
||||
|
||||
| mode | 行为 |
|
||||
|------|------|
|
||||
| `auto` | 仅有 CSV、无 PDF → CSV 模式;否则 → PDF 提取 + OCR |
|
||||
| `csv` | 使用已上传 CSV,跳过提取与 OCR |
|
||||
| `pdf` | 执行 PDF 提取 + OCR |
|
||||
| `auto` | 仅有 CSV、无 PDF → CSV 模式;否则 → PDF 提取 + LLM 识别 |
|
||||
| `csv` | 使用已上传 CSV,跳过提取与 LLM 识别 |
|
||||
| `pdf` | 执行 PDF 提取 + LLM 识别 |
|
||||
|
||||
**处理内容(PDF 模式):**
|
||||
|
||||
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv` / `.md`
|
||||
2. 对支付截图 OCR,回填刷卡字段
|
||||
3. 从项目根目录复制 `易耗品、出库单.doc` 模板到会话目录并自动填写(需 Windows + Word)
|
||||
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv`
|
||||
2. 对支付截图多模态 LLM 识别,回填刷卡字段
|
||||
3. 根据发票类型自动分类:差旅发票(高铁票/酒店住宿)不生成出库单;普通发票从模板复制并自动填写
|
||||
|
||||
配置会写入 `web/uploads/<session_id>/config.json`。
|
||||
配置会写入 `src/web/uploads/<session_id>/config.json`。
|
||||
|
||||
**响应(立即):**
|
||||
|
||||
@@ -165,12 +169,28 @@ Content-Type: application/json
|
||||
"elapsed": "45.2s",
|
||||
"invoice_count": 4,
|
||||
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
||||
"md_url": "/api/download/<session_id>/invoice_summary.md",
|
||||
"travel_count": 2,
|
||||
"general_count": 2,
|
||||
"doc_url": "/api/download/<session_id>/%E6%98%93%E8%80%97%E5%93%81%E3%80%81%E5%87%BA%E5%BA%93%E5%8D%95.doc",
|
||||
"doc_ok": true
|
||||
}
|
||||
```
|
||||
|
||||
**纯差旅发票(跳过出库单生成):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"invoice_count": 3,
|
||||
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
||||
"travel_count": 3,
|
||||
"general_count": 0,
|
||||
"doc_ok": null,
|
||||
"doc_skipped": true,
|
||||
"doc_message": "差旅发票无需生成易耗品出库单"
|
||||
}
|
||||
```
|
||||
|
||||
**出库单生成失败时(CSV 等仍可能成功):**
|
||||
|
||||
```json
|
||||
@@ -178,11 +198,23 @@ Content-Type: application/json
|
||||
"ok": true,
|
||||
"invoice_count": 4,
|
||||
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
||||
"travel_count": 2,
|
||||
"general_count": 2,
|
||||
"doc_ok": false,
|
||||
"doc_error": "服务器未安装 pywin32,无法生成 Word 出库单"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `travel_count` | int | 差旅发票数量(高铁票/酒店住宿) |
|
||||
| `general_count` | int | 普通发票数量 |
|
||||
| `doc_ok` | bool/null | `true`=成功,`false`=失败,`null`=已跳过(纯差旅发票) |
|
||||
| `doc_skipped` | bool | 是否因纯差旅发票而跳过出库单生成 |
|
||||
| `doc_message` | string | 跳过时的提示信息 |
|
||||
|
||||
---
|
||||
|
||||
### 6. SSE 日志流
|
||||
@@ -211,9 +243,11 @@ data: 2026-05-26 12:00:01 [INFO ] extractor: 正在提取发票...
|
||||
|
||||
| 来源 | 典型字段 |
|
||||
|------|----------|
|
||||
| `/api/process` | `ok`, `elapsed`, `invoice_count`, `csv_url`, `md_url`, `doc_url`, `doc_ok`, `doc_error` |
|
||||
| `/api/process` | `ok`, `elapsed`, `invoice_count`, `csv_url`, `travel_count`, `general_count`, `doc_url`, `doc_ok`, `doc_skipped`, `doc_error` |
|
||||
| `/api/submit-financial` | `ok`, `submit_ok`, `submit_error` |
|
||||
|
||||
> SSE 超时时间为 10 分钟(600 秒)。
|
||||
|
||||
---
|
||||
|
||||
### 7. 下载文件
|
||||
@@ -227,7 +261,6 @@ GET /api/download/<session_id>/<filename>
|
||||
| 扩展名 | Content-Type |
|
||||
|--------|----------------|
|
||||
| `.csv` | `text/csv; charset=utf-8` |
|
||||
| `.md` | `text/markdown; charset=utf-8` |
|
||||
| `.doc` | `application/msword` |
|
||||
| 其它 | `application/octet-stream` |
|
||||
|
||||
@@ -235,8 +268,7 @@ GET /api/download/<session_id>/<filename>
|
||||
|
||||
| 文件名 | 说明 |
|
||||
|--------|------|
|
||||
| `invoice_summary.csv` | 发票汇总(含 OCR 结果) |
|
||||
| `invoice_summary.md` | Markdown 摘要 |
|
||||
| `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) |
|
||||
| `易耗品、出库单.doc` | 自动填写的出库单 |
|
||||
| 用户上传的 CSV 名 | CSV 快捷模式下的原始文件 |
|
||||
|
||||
@@ -328,7 +360,17 @@ Content-Type: application/json
|
||||
|
||||
保存后会根据最新 CSV **重新生成** 出库单 Word(与会话 `config.json` 中的 `consumable_storage` 等配置一致)。
|
||||
|
||||
若出库单生成失败:
|
||||
**纯差旅发票跳过出库单:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"doc_ok": null,
|
||||
"doc_skipped": true
|
||||
}
|
||||
```
|
||||
|
||||
**出库单生成失败:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -348,10 +390,14 @@ POST /api/submit-financial/<session_id>
|
||||
|
||||
**前置条件:**
|
||||
|
||||
- 会话目录存在 `config.json`(由 `/api/process` 写入)
|
||||
- 会话目录存在 `config.json`(由 `/api/process` 写入),否则返回 `400`
|
||||
- 存在可用的发票 CSV(通常为 `invoice_summary.csv`)
|
||||
|
||||
**说明:** 前端一般在提交前调用 `/api/save` 保存表格修改。本接口**不会**自动执行发票提取或 OCR。
|
||||
**说明:**
|
||||
|
||||
- 前端一般在提交前调用 `/api/save` 保存表格修改
|
||||
- 本接口**不会**自动执行发票提取或 LLM 识别
|
||||
- 根据发票类型选择填报模式:纯差旅发票走差旅报销流程,含普通发票走普通报销流程
|
||||
|
||||
**响应(立即):**
|
||||
|
||||
@@ -416,6 +462,19 @@ Content-Type: multipart/form-data
|
||||
|
||||
---
|
||||
|
||||
## 发票类型分类
|
||||
|
||||
系统自动将发票分为两类,影响出库单生成和后续报销流程:
|
||||
|
||||
| 类型 | 判断依据 | 出库单 | 报销流程 |
|
||||
|------|----------|--------|----------|
|
||||
| 差旅发票 | 高铁票、酒店住宿等 | 不生成 | 差旅报销 |
|
||||
| 普通发票 | 其他(办公用品、耗材等) | 自动生成 | 普通报销 |
|
||||
|
||||
`/api/process` 和 `/api/save` 的响应中 `travel_count` / `general_count` 即为分类统计。
|
||||
|
||||
---
|
||||
|
||||
## 端到端流程
|
||||
|
||||
```mermaid
|
||||
@@ -430,8 +489,12 @@ sequenceDiagram
|
||||
|
||||
PC->>Server: POST /api/upload/{sid}
|
||||
PC->>Server: POST /api/process/{sid}
|
||||
Note over Server: PDF 提取 + OCR + 写 config.json
|
||||
Server->>Word: 复制模板并填写出库单
|
||||
Note over Server: PDF 提取 + LLM 识别 + 写 config.json
|
||||
alt 含普通发票
|
||||
Server->>Word: 从模板复制并填写出库单
|
||||
else 纯差旅发票
|
||||
Note over Server: 跳过出库单生成
|
||||
end
|
||||
Server-->>PC: SSE done (csv_url, doc_url, ...)
|
||||
|
||||
PC->>Server: GET /api/data/{sid}
|
||||
@@ -457,7 +520,7 @@ sequenceDiagram
|
||||
不经过 Web、在本地直接填写出库单:
|
||||
|
||||
```bash
|
||||
python -m app.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
```
|
||||
|
||||
详见 [README.md](./README.md)。
|
||||
详见 [README.md](./README.md)。
|
||||
481
docs/报销操作指南.md
Normal file
@@ -0,0 +1,481 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
---
|
||||
|
||||
# 阜阳师范大学财务报销系统 - 自动化操作指南
|
||||
|
||||
> 适用场景:日常报销录入(支持 Web 界面和 CLI 两种方式)
|
||||
> 最后更新:2026-06-09
|
||||
|
||||
---
|
||||
|
||||
## 一、系统概览
|
||||
|
||||
```
|
||||
整体流程:
|
||||
|
||||
信息门户(SSO登录) → 财务系统入口 → 单点登录页 → 网络报销 → 日常报销录入
|
||||
(tyrz.fynu.edu.cn) (点击"财务系统") (新标签页) (a:has(img)) (/expen/common/common)
|
||||
|
||||
目标系统: http://210.45.32.214:8081
|
||||
用户: 张三 (工号: xxxxxxx)
|
||||
```
|
||||
|
||||
### 关键 URL
|
||||
|
||||
| 系统 | URL | 说明 |
|
||||
|------|-----|------|
|
||||
| SSO 登录 | `https://tyrz.fynu.edu.cn/sso/login` | 统一认证入口 |
|
||||
| 信息门户 | `https://tyrz.fynu.edu.cn/oshall` | 登录后跳转目标 |
|
||||
| 报销系统 | `http://210.45.32.214:8081` | 网络报销主系统 |
|
||||
| 日常报销录入 | `/expen/common/common?v=4.0` | 目标录入页面 |
|
||||
|
||||
---
|
||||
|
||||
## 二、两种使用方式
|
||||
|
||||
### 方式一:Web 界面(推荐)
|
||||
|
||||
适合日常使用,可视化操作,支持手机端拍照上传。
|
||||
|
||||
```bash
|
||||
uv run python src/web/app.py
|
||||
# 访问: http://localhost:5000
|
||||
```
|
||||
|
||||
### 方式二:CLI 命令行
|
||||
|
||||
适合脚本化、批量处理。
|
||||
|
||||
```bash
|
||||
# 全流程(发票提取 → LLM 识别 → 浏览器填报)
|
||||
uv run python src/main.py
|
||||
|
||||
# 分步执行
|
||||
uv run python src/main.py --step invoice # 仅发票提取
|
||||
uv run python src/main.py --step submit # 仅浏览器填报
|
||||
|
||||
# 覆盖登录凭据
|
||||
uv run python src/main.py -u 202407021 -p "your_password"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、数据准备
|
||||
|
||||
### 3.1 发票数据 CSV
|
||||
|
||||
系统支持两种数据输入方式:
|
||||
|
||||
**方式 A:PDF 发票 + 支付截图(自动提取)**
|
||||
|
||||
上传 PDF 发票文件和支付截图,系统自动完成:
|
||||
1. 从 PDF 提取发票信息
|
||||
2. 多模态 LLM 识别支付截图中的刷卡信息
|
||||
3. 生成 `invoice_summary.csv`
|
||||
|
||||
**方式 B:直接上传 CSV(快捷模式)**
|
||||
|
||||
已有发票数据 CSV 可直接上传,跳过 PDF 提取和 LLM 识别步骤。
|
||||
|
||||
CSV 需包含以下列:
|
||||
|
||||
| 列名 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| 序号 | 发票序号 | 1, 2, 3... |
|
||||
| 发票号码 | 发票编号 | 26442000005432652421 |
|
||||
| 开票日期 | 发票开具日期 | 2026/5/18 |
|
||||
| 项目名称 | 采购项目名称 | 电阻一批 |
|
||||
| 规格型号 | 规格型号 | — |
|
||||
| 价税合计 | 发票金额 | 2900.00 |
|
||||
| 销售方名称 | 商户/销售方 | 佛山市泓宇芯科技有限公司 |
|
||||
| 人员姓名 | 报销人 | 王建锋(默认值) |
|
||||
| 刷卡日期 | 公务卡消费日期 | 2026/5/18 → 自动转为 2026-05-18 |
|
||||
| 公务卡号 | 公务卡卡号 | 6282880139161682(默认值) |
|
||||
| 刷卡金额 | 实际刷卡金额 | 2900.00 |
|
||||
| 备注 | 备注信息 | — |
|
||||
| 工号 | 人员工号 | 202407021(默认值) |
|
||||
|
||||
### 3.2 发票类型分类
|
||||
|
||||
系统自动将发票分为两类,影响后续处理流程:
|
||||
|
||||
| 类型 | 判断依据 | 出库单 | 报销流程 |
|
||||
|------|----------|--------|----------|
|
||||
| 差旅发票 | 高铁票、酒店住宿等 | 不生成 | 差旅报销 |
|
||||
| 普通发票 | 其他(办公用品、耗材等) | 自动生成 | 普通报销 |
|
||||
|
||||
### 3.3 附件文件
|
||||
|
||||
PDF 发票文件需与支付截图配对上传。系统通过金额匹配自动关联发票和支付记录。
|
||||
|
||||
---
|
||||
|
||||
## 四、Web 界面操作流程
|
||||
|
||||
### 4.1 启动服务
|
||||
|
||||
```bash
|
||||
uv run python src/web/app.py
|
||||
# 访问: http://localhost:5000
|
||||
```
|
||||
|
||||
### 4.2 上传文件
|
||||
|
||||
1. **发票 PDF**:点击或拖拽上传 PDF 文件(支持多选)
|
||||
2. **支付截图**:点击或拖拽上传图片文件(支持多选)
|
||||
3. **手机扫码上传**:扫描页面二维码,通过手机拍照上传支付截图
|
||||
4. **CSV 快捷上传**:已有发票数据 CSV 可直接上传,跳过提取和 LLM 识别
|
||||
|
||||
### 4.3 配置信息
|
||||
|
||||
填写以下配置项(也可通过上传 `config.json` 快速填充):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 账号 | 财务系统工号 |
|
||||
| 密码 | 登录密码 |
|
||||
| 默认姓名 | 报销人姓名 |
|
||||
| 公务卡号 | 公务卡卡号 |
|
||||
| 人员编号 | 人员编号 |
|
||||
| 存放地点 | 出库单存放地点 |
|
||||
|
||||
### 4.4 开始处理
|
||||
|
||||
点击"开始处理"按钮,系统自动执行:
|
||||
|
||||
1. **发票提取**:从 PDF 提取发票信息
|
||||
2. **LLM 信息提取**:多模态 LLM 识别支付截图中的刷卡信息
|
||||
3. **发票分类**:自动区分差旅发票和普通发票
|
||||
4. **出库单生成**:普通发票自动生成易耗品出库单(差旅发票跳过)
|
||||
|
||||
处理过程中可在日志面板实时查看进度。
|
||||
|
||||
### 4.5 编辑发票数据
|
||||
|
||||
处理完成后,发票数据以可编辑表格形式展示:
|
||||
|
||||
- 直接点击单元格即可编辑
|
||||
- 修改后点击"提交到财务系统"时自动保存
|
||||
- 保存后会自动重新生成出库单
|
||||
|
||||
### 4.6 下载文件
|
||||
|
||||
处理完成后下载:
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `invoice_summary.csv` | 发票汇总数据 |
|
||||
| `易耗品、出库单.doc` | 自动填写的出库单(仅普通发票) |
|
||||
|
||||
### 4.7 提交到财务系统
|
||||
|
||||
确认数据无误后,点击"🚀 提交到财务系统"按钮,系统自动:
|
||||
|
||||
1. 登录信息门户
|
||||
2. 进入财务系统
|
||||
3. 创建报销单
|
||||
4. 填写基本信息
|
||||
5. 录入报销明细
|
||||
6. 录入支付方式
|
||||
7. 上传附件
|
||||
|
||||
---
|
||||
|
||||
## 五、CLI 执行流程
|
||||
|
||||
### 5.1 全流程执行
|
||||
|
||||
```bash
|
||||
uv run python src/main.py
|
||||
```
|
||||
|
||||
执行步骤:
|
||||
|
||||
```
|
||||
Step 1: 发票提取
|
||||
├── 扫描 PDF 发票文件
|
||||
├── 提取发票信息
|
||||
├── 生成 invoice_summary.csv
|
||||
└── 自动分类(差旅/普通)
|
||||
|
||||
Step 2: LLM 信息提取
|
||||
├── 扫描支付截图
|
||||
├── 多模态 LLM 识别刷卡信息
|
||||
├── 回填 CSV 刷卡字段
|
||||
└── 更新 invoice_summary.csv
|
||||
|
||||
Step 3: 报销提交
|
||||
├── 登录信息门户
|
||||
├── 进入财务系统
|
||||
├── 创建报销单
|
||||
├── 填写基本信息
|
||||
├── 录入报销明细
|
||||
├── 录入支付方式
|
||||
├── 上传附件
|
||||
└── 提交(需手动确认)
|
||||
```
|
||||
|
||||
### 5.2 分步执行
|
||||
|
||||
```bash
|
||||
# 仅发票提取
|
||||
uv run python src/main.py --step invoice
|
||||
|
||||
# 仅浏览器填报
|
||||
uv run python src/main.py --step submit
|
||||
```
|
||||
|
||||
### 5.3 上传目录
|
||||
|
||||
CLI 模式自动发现 `src/web/uploads/` 下最新的会话文件夹,该文件夹包含上传的 PDF 和图片。
|
||||
|
||||
---
|
||||
|
||||
## 六、页面元素速查
|
||||
|
||||
### 基本信息页
|
||||
|
||||
| 字段 | 选择器 | 操作 |
|
||||
|------|--------|------|
|
||||
| 报销说明 | `#EXPENEXPLAIN` | fill |
|
||||
| 项目代码 | `#PROJECTCODE` | click → 弹窗选择 |
|
||||
| 项目弹窗 | `#promodal .fixed-table-body tbody tr` | 点击第一行 |
|
||||
| 下一步按钮 | `#saveAndNext` | click |
|
||||
|
||||
### 报销明细页
|
||||
|
||||
| 字段 | 选择器 | 操作 |
|
||||
|------|--------|------|
|
||||
| 增加按钮 | `#insertDetail` | click |
|
||||
| 经济事项代码 | `#economicscode2` | click → 弹窗选择 |
|
||||
| 经济科目弹窗 | `#econmodal .fixed-table-body tbody tr` | 点击第 3 行 |
|
||||
| 单据数 | `input[name="expenPwCommondetail.HOWBILLS"]` | fill |
|
||||
| 报销总金额 | `#je_zwzcdz` | fill |
|
||||
| 确定按钮 | `#detailAdd` | click |
|
||||
|
||||
### 支付方式页
|
||||
|
||||
| 字段 | 选择器 | 操作 |
|
||||
|------|--------|------|
|
||||
| 增加按钮 | `#insertPay` | click |
|
||||
| 人员编号 | `#personid2` | fill |
|
||||
| 人员姓名 | `#accountname2` | fill |
|
||||
| 刷卡日期 | `#receiptdate2` | fill |
|
||||
| 公务卡号 | `#localaccount2` | fill |
|
||||
| 刷卡金额 | `#receiptmoney2` | fill |
|
||||
| 实报金额 | `#money2` | fill |
|
||||
| 商户 | `#merchant2` | fill |
|
||||
| 备注 | `#smark2` | fill |
|
||||
| 确定按钮 | `#payAdd` | click |
|
||||
|
||||
### 附件清单页
|
||||
|
||||
| 字段 | 选择器 | 操作 |
|
||||
|------|--------|------|
|
||||
| 增加按钮 | `#insertAcc` | click |
|
||||
| 附件类型 | `#fjlx` | select_option → '1'(发票) |
|
||||
| 附件说明 | `#fpsmxx` | fill |
|
||||
| 文件上传 | `#file` | set_input_files |
|
||||
| 确定按钮 | `#cjtj` | click |
|
||||
|
||||
### 提交
|
||||
|
||||
| 操作 | 选择器 |
|
||||
|------|--------|
|
||||
| 提交按钮 | `#submit` |
|
||||
| 提交按钮(备用) | `#submit2` |
|
||||
|
||||
---
|
||||
|
||||
## 七、数据流向
|
||||
|
||||
```
|
||||
PDF 发票 + 支付截图
|
||||
│
|
||||
▼ extract_invoices()
|
||||
│
|
||||
├── 读取 PDF 发票
|
||||
├── 提取发票信息
|
||||
├── 自动分类(差旅/普通)
|
||||
└── 输出: invoice_summary.csv
|
||||
│
|
||||
▼ enrich_with_llm()
|
||||
├── 多模态 LLM 识别支付截图
|
||||
├── 回填刷卡字段
|
||||
└── 更新 invoice_summary.csv
|
||||
│
|
||||
▼ fill_consumable_from_template()
|
||||
├── 仅普通发票
|
||||
├── 从模板复制出库单
|
||||
└── 自动填写出库单
|
||||
│
|
||||
▼ run_bot()
|
||||
├── 登录信息门户
|
||||
├── 进入财务系统
|
||||
├── 创建报销单
|
||||
├── 填写基本信息
|
||||
├── 录入报销明细
|
||||
├── 录入支付方式
|
||||
├── 上传附件
|
||||
└── 提交
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、关键设计说明
|
||||
|
||||
### 8.1 浏览器启动
|
||||
|
||||
系统使用 Playwright 的 `sync_playwright()` 启动 Chromium 浏览器,支持 `headless` 模式。每次运行均启动新浏览器实例,需重新登录。可通过 `headless` 参数控制是否显示浏览器窗口。
|
||||
|
||||
### 8.2 明细录入策略
|
||||
|
||||
系统采用"一条总明细"策略:将所有发票合并为一条报销明细,报销总金额为所有发票刷卡金额之和,单据数为发票总张数。支付方式则逐张发票分别录入,每张发票对应一条支付记录。
|
||||
|
||||
### 8.3 经济科目选择
|
||||
|
||||
系统在经济科目弹窗中固定选择第 3 行。如需更改科目,修改 `rows[2]` 的索引即可。
|
||||
|
||||
### 8.4 项目选择
|
||||
|
||||
系统在项目选择弹窗中固定选择第 1 行。如需更改项目,修改 `first_row` 的选择逻辑即可。
|
||||
|
||||
### 8.5 网络报销链接动态获取
|
||||
|
||||
单点登录页的"网络报销"链接参数每次不同,系统通过 `a:has(img[src*="wlbx"])` 精确定位链接,动态提取 `href` 属性后导航,不硬编码 URL。
|
||||
|
||||
### 8.6 发票类型分流
|
||||
|
||||
- **差旅发票**(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
||||
- **普通发票**:生成易耗品出库单,走普通报销流程
|
||||
|
||||
### 8.7 移动端同步
|
||||
|
||||
PC 端生成二维码指向移动端上传页面,手机端上传的图片通过轮询同步到 PC 端,实现跨设备协作。
|
||||
|
||||
---
|
||||
|
||||
## 九、配置说明
|
||||
|
||||
### config.json
|
||||
|
||||
项目根目录 `config.json` 包含默认配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "202407021",
|
||||
"password": "your_password",
|
||||
"sso_login_url": "https://tyrz.fynu.edu.cn/sso/login",
|
||||
"portal_url": "https://tyrz.fynu.edu.cn/oshall",
|
||||
"reimburse_url": "http://210.45.32.214:8081",
|
||||
"reimburse_page": "/expen/common/common?v=4.0",
|
||||
"default_name": "王建锋",
|
||||
"default_card_no": "6282880139161682",
|
||||
"default_person_id": "202407021",
|
||||
"consumable_storage": "躬行楼 C205",
|
||||
"llm": {
|
||||
"model": "qwen3.5-9b",
|
||||
"api_base": "http://100.123.83.115:1234/v1",
|
||||
"api_key": "123456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `username` | 财务系统工号 |
|
||||
| `password` | 登录密码 |
|
||||
| `sso_login_url` | SSO 登录地址 |
|
||||
| `portal_url` | 信息门户地址 |
|
||||
| `reimburse_url` | 报销系统地址 |
|
||||
| `reimburse_page` | 报销录入页面路径 |
|
||||
| `default_name` | 默认报销人姓名 |
|
||||
| `default_card_no` | 默认公务卡号 |
|
||||
| `default_person_id` | 默认人员编号 |
|
||||
| `consumable_storage` | 出库单存放地点 |
|
||||
| `llm.model` | LLM 模型名称 |
|
||||
| `llm.api_base` | LLM API 地址 |
|
||||
| `llm.api_key` | LLM API 密钥 |
|
||||
|
||||
---
|
||||
|
||||
## 十、日志与调试
|
||||
|
||||
### 日志输出
|
||||
|
||||
- 控制台实时输出(INFO 级别)
|
||||
- 文件日志:`logs/<日期>.log`(UTF-8 编码)
|
||||
- Web 界面:实时 SSE 日志流
|
||||
|
||||
### 截图保存
|
||||
|
||||
每个关键步骤自动截图到 `images/` 目录:
|
||||
|
||||
| 截图文件 | 对应步骤 |
|
||||
|----------|----------|
|
||||
| `debug_portal_loaded.png` | 登录成功 |
|
||||
| `debug_step3_project_modal.png` | 项目弹窗打开 |
|
||||
| `debug_step3_project_selected.png` | 项目选择完成 |
|
||||
| `debug_step3_done.png` | 基本信息完成 |
|
||||
| `debug_after_add_click.png` | 点击新增后 |
|
||||
| `debug_item_total.png` | 总明细录入完成 |
|
||||
| `debug_step5_done.png` | 支付方式完成 |
|
||||
| `debug_step6_done.png` | 附件上传完成 |
|
||||
| `debug_error.png` | 异常状态 |
|
||||
|
||||
### 超时设置
|
||||
|
||||
- 页面默认超时:30 秒
|
||||
- 登录门户等待:最多 30 秒
|
||||
- 单点登录页等待:最多 15 秒
|
||||
- SSE 日志流超时:10 分钟(600 秒)
|
||||
|
||||
---
|
||||
|
||||
## 十一、常见问题
|
||||
|
||||
| 问题 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| 登录超时 | SSO 需要手动验证码/微信扫码 | 手动完成验证后脚本继续 |
|
||||
| 未找到财务系统入口 | 门户页面结构变化 | 检查 `images/debug_*` 截图定位 |
|
||||
| 经济科目选择失败 | 弹窗加载延迟 | 检查超时设置,增加等待时间 |
|
||||
| 附件上传失败 | PDF 文件不存在或路径错误 | 确认 PDF 在当前工作目录 |
|
||||
| 金额不匹配 | 明细合计 ≠ 支付合计 | 检查 CSV 数据中刷卡金额 |
|
||||
| 提交被拦截 | 必填项为空 | 检查 `logs/<日期>.log` 定位失败步骤 |
|
||||
| LLM 多模态提取失败 | llama-index 版本不兼容 | 确保使用 llama-index-core >= 0.14.x,多模态消息使用 `blocks` 格式 |
|
||||
| 出库单生成失败 | 缺少 pywin32 或模板文件 | 安装 `pywin32`,确保项目根目录有 `易耗品、出库单.doc` 模板 |
|
||||
| 差旅发票生成了出库单 | 分类不准确 | 检查发票内容是否包含"高铁票""酒店住宿"等关键词 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、相关 CLI 命令
|
||||
|
||||
### 单独填写出库单
|
||||
|
||||
```bash
|
||||
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
```
|
||||
|
||||
### 分步执行管道
|
||||
|
||||
```bash
|
||||
# 仅发票提取
|
||||
uv run python src/main.py --step invoice
|
||||
|
||||
# 仅浏览器填报
|
||||
uv run python src/main.py --step submit
|
||||
```
|
||||
|
||||
### 启动 Web 服务
|
||||
|
||||
```bash
|
||||
uv run python src/web/app.py
|
||||
# 访问: http://localhost:5000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:API 文档
|
||||
|
||||
详细的 API 接口文档见 [API.md](./API.md)。
|
||||
BIN
images/debug_after_add_click.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
images/debug_error.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
images/debug_item_total.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
images/debug_portal_loaded.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
images/debug_step3_done.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
images/debug_step3_project_modal.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
images/debug_step3_project_selected.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
images/debug_step5_done.png
Normal file
|
After Width: | Height: | Size: 77 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 |
4
invoice_summary.csv
Normal file
@@ -0,0 +1,4 @@
|
||||
序号,发票类型,发票号码,开票日期,项目名称,规格型号,价税合计,销售方名称,出发站,到达站,车次,乘车日期,座位等级,人员姓名,刷卡日期,公务卡号,刷卡金额,备注,工号
|
||||
1,高铁票,26349119343000154520,2026/3/23,,,,,阜阳西,无锡东,G7221,2026/3/21,二等座,王建锋,,,,,
|
||||
2,高铁票,26329166851000278168,2026/3/23,,,,,无锡东,阜阳西,G1826,2026/3/22,二等座,王建锋,,,,,
|
||||
3,酒店住宿,26322000002199439186,2026/3/23,*住宿服务*住宿费 间 天 1 347.735849056604 347.74 6% 20.86,住宿费 间 天 1 347.735849056604 347.74 6% 20.86,368.6,,,,,,,,,,,,
|
||||
|
1536
pipeline.log
Normal file
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",
|
||||
"pdfplumber>=0.10",
|
||||
"pywin32>=306",
|
||||
"llama-index>=0.12.0",
|
||||
"llama-index-llms-openai-like==0.7.2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=5.0",
|
||||
"ruff>=0.9",
|
||||
"mypy>=1.14",
|
||||
"deptry>=0.22",
|
||||
"pre-commit>=4.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tests.*"
|
||||
ignore_errors = true
|
||||
|
||||
[tool.deptry]
|
||||
ignore_notebooks = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
@@ -1,5 +1,6 @@
|
||||
"""财务报销自动化工具包"""
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
@@ -7,14 +8,20 @@ from pathlib import Path
|
||||
|
||||
_LOG_FMT = "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s"
|
||||
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_FILE = Path(__file__).resolve().parent.parent / "pipeline.log"
|
||||
_LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||
|
||||
|
||||
def _get_log_file() -> Path:
|
||||
"""返回当日日志文件路径,如 logs/2026-06-09.log"""
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return _LOG_DIR / f"{datetime.date.today():%Y-%m-%d}.log"
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""获取带时间戳的日志记录器
|
||||
|
||||
输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ...
|
||||
日志同时输出到终端和项目根目录的 pipeline.log
|
||||
日志同时输出到终端和 logs/<日期>.log
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
if not logger.handlers:
|
||||
@@ -28,8 +35,8 @@ def get_logger(name: str) -> logging.Logger:
|
||||
logger.addHandler(stream_handler)
|
||||
|
||||
# 文件输出
|
||||
file_handler = logging.FileHandler(str(_LOG_FILE), encoding="utf-8")
|
||||
file_handler = logging.FileHandler(str(_get_log_file()), encoding="utf-8")
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
return logger
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import get_logger
|
||||
|
||||
@@ -20,6 +21,7 @@ log = get_logger("bot")
|
||||
# 日期格式化
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_date(date_str: str) -> str:
|
||||
"""将 '2026/5/13' 或 '2026-5-13' 转为 '2026-05-13'"""
|
||||
if not date_str:
|
||||
@@ -30,31 +32,58 @@ def _format_date(date_str: str) -> str:
|
||||
return date_str
|
||||
|
||||
|
||||
def _safe_float(value: str | None, default: float = 0.0) -> float:
|
||||
"""安全转换为浮点数,空值或转换失败时返回默认值"""
|
||||
if value is None or str(value).strip() == "":
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CSV 数据加载
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load_invoice_data(csv_path: str, config: dict) -> list[dict]:
|
||||
"""从 CSV 加载发票数据,自动补全空白字段的默认值"""
|
||||
|
||||
def load_invoice_data(csv_path: str, config: dict[str, str | Path]) -> list[dict[str, str | float | Any | Path]]:
|
||||
"""从 CSV 加载发票数据,自动补全空白字段的默认值
|
||||
|
||||
CSV 以支付记录为主键,每行包含 _invoices_json 字段(JSON 序列化的发票列表)。
|
||||
本函数还原为发票级别的数据列表。
|
||||
"""
|
||||
import json
|
||||
|
||||
invoices = []
|
||||
with open(csv_path, encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
invoices.append({
|
||||
"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", ""),
|
||||
})
|
||||
invoices_json = row.get("_invoices_json", "")
|
||||
if not invoices_json:
|
||||
continue
|
||||
try:
|
||||
inv_list = json.loads(invoices_json)
|
||||
for inv in inv_list:
|
||||
invoices.append(
|
||||
{
|
||||
"seq": row.get("序号", ""),
|
||||
"invoice_no": inv.get("发票号码", ""),
|
||||
"invoice_date": inv.get("开票日期", ""),
|
||||
"item_name": inv.get("项目名称", ""),
|
||||
"spec_model": inv.get("规格型号", ""),
|
||||
"total_amount": _safe_float(inv.get("价税合计")),
|
||||
"seller_name": inv.get("销售方名称", ""),
|
||||
"person_name": inv.get("人员姓名") or config.get("default_name", ""),
|
||||
"card_date": _format_date(row.get("刷卡日期") or ""),
|
||||
"card_no": row.get("公务卡号") or config.get("default_card_no", ""),
|
||||
"card_amount": _safe_float(row.get("刷卡金额")),
|
||||
"remark": row.get("备注") or "",
|
||||
"person_id": row.get("工号") or config.get("default_person_id", ""),
|
||||
}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
log.warning(f"无法解析发票 JSON: {invoices_json[:50]}...")
|
||||
return invoices
|
||||
|
||||
|
||||
@@ -62,29 +91,31 @@ def load_invoice_data(csv_path: str, config: dict) -> list[dict]:
|
||||
# 报销机器人
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class ReimburseBot:
|
||||
"""财务报销自动化机器人"""
|
||||
|
||||
def __init__(self, config: dict, headless: bool = False):
|
||||
def __init__(self, config: dict[str, Any], headless: bool = False):
|
||||
self.config = config
|
||||
self.headless = headless
|
||||
self.work_dir: Path | None = None
|
||||
self.browser = None
|
||||
self.context = None
|
||||
self.page = None
|
||||
self.browser: Any = None
|
||||
self.context: Any = None
|
||||
self.page: Any = None
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
self._pw_ctx = sync_playwright()
|
||||
self.pw = self._pw_ctx.__enter__()
|
||||
|
||||
def launch(self):
|
||||
def launch(self) -> None:
|
||||
"""启动浏览器"""
|
||||
self.browser = self.pw.chromium.launch(headless=self.headless)
|
||||
self.context = self.browser.new_context(viewport={"width": 1920, "height": 1080})
|
||||
self.page = self.context.new_page()
|
||||
self.page.set_default_timeout(30000)
|
||||
|
||||
def login_portal(self):
|
||||
def login_portal(self) -> None:
|
||||
"""登录信息门户"""
|
||||
log.info("登录信息门户...")
|
||||
|
||||
@@ -113,7 +144,7 @@ class ReimburseBot:
|
||||
|
||||
self._wait_for_portal()
|
||||
|
||||
def _wait_for_portal(self):
|
||||
def _wait_for_portal(self) -> None:
|
||||
"""等待跳转到统一信息平台"""
|
||||
for _ in range(30):
|
||||
self.page.wait_for_timeout(1000)
|
||||
@@ -125,7 +156,7 @@ class ReimburseBot:
|
||||
self._screenshot("portal_timeout")
|
||||
raise TimeoutError("登录超时,未跳转到信息门户")
|
||||
|
||||
def navigate_to_reimburse(self):
|
||||
def navigate_to_reimburse(self) -> None:
|
||||
"""从统一信息平台进入报销系统"""
|
||||
log.info("进入报销系统...")
|
||||
self._wait_for('text="快捷入口"', timeout=5000)
|
||||
@@ -170,7 +201,7 @@ class ReimburseBot:
|
||||
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
|
||||
self._wait_for('text="单据状态:"', timeout=5000)
|
||||
|
||||
def open_reimburse_menu(self):
|
||||
def open_reimburse_menu(self) -> None:
|
||||
"""点击「新增」创建新报销单"""
|
||||
log.info("创建新报销单...")
|
||||
self.page.wait_for_timeout(2000)
|
||||
@@ -180,14 +211,14 @@ class ReimburseBot:
|
||||
except Exception:
|
||||
try:
|
||||
self.page.click("text=新增", timeout=3000)
|
||||
except Exception:
|
||||
except Exception as err:
|
||||
self._screenshot("no_add_button")
|
||||
raise RuntimeError("无法点击新增按钮")
|
||||
raise RuntimeError("无法点击新增按钮") from err
|
||||
|
||||
self.page.wait_for_timeout(3000)
|
||||
self._screenshot("after_add_click")
|
||||
|
||||
def fill_basic_info(self, description: str = "元器件采购报销"):
|
||||
def fill_basic_info(self, description: str = "元器件采购报销") -> None:
|
||||
"""填写基本信息"""
|
||||
log.info("填写基本信息...")
|
||||
|
||||
@@ -223,7 +254,7 @@ class ReimburseBot:
|
||||
|
||||
self._screenshot("step3_done")
|
||||
|
||||
def add_reimburse_items(self, invoices: list[dict]):
|
||||
def add_reimburse_items(self, invoices: list[dict[str, Any]]) -> None:
|
||||
"""录入报销明细(一条总明细)"""
|
||||
card_amount = sum(inv["card_amount"] for inv in invoices)
|
||||
log.info(f"录入报销明细 (合计 ¥{card_amount:.2f})...")
|
||||
@@ -255,7 +286,7 @@ class ReimburseBot:
|
||||
self._screenshot("item_total_error")
|
||||
raise
|
||||
|
||||
def fill_payment(self, invoices: list[dict]):
|
||||
def fill_payment(self, invoices: list[dict[str, Any]]) -> None:
|
||||
"""录入支付信息"""
|
||||
log.info("录入支付信息...")
|
||||
try:
|
||||
@@ -282,7 +313,7 @@ class ReimburseBot:
|
||||
|
||||
self._screenshot("step5_done")
|
||||
|
||||
def upload_attachments(self, invoices: list[dict]):
|
||||
def upload_attachments(self, invoices: list[dict[str, Any]]) -> None:
|
||||
"""上传发票附件"""
|
||||
log.info("上传附件...")
|
||||
|
||||
@@ -299,11 +330,11 @@ class ReimburseBot:
|
||||
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.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)
|
||||
self.page.fill("#fpsmxx", explanation)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -328,7 +359,7 @@ class ReimburseBot:
|
||||
|
||||
self._screenshot("step6_done")
|
||||
|
||||
def submit(self):
|
||||
def submit(self) -> None:
|
||||
"""提交报销单"""
|
||||
log.info("提交报销单...")
|
||||
try:
|
||||
@@ -340,7 +371,7 @@ class ReimburseBot:
|
||||
self._screenshot("submit_error")
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
def close(self) -> None:
|
||||
"""关闭浏览器"""
|
||||
if self.context:
|
||||
self.context.close()
|
||||
@@ -355,10 +386,10 @@ class ReimburseBot:
|
||||
# 辅助方法
|
||||
# --------------------------------------------------------
|
||||
|
||||
def _wait_for(self, selector: str, timeout: int = None):
|
||||
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
|
||||
self.page.wait_for_selector(selector, timeout=timeout)
|
||||
|
||||
def _screenshot(self, name: str):
|
||||
def _screenshot(self, name: str) -> None:
|
||||
img_dir = Path(__file__).parent.parent / "images"
|
||||
img_dir.mkdir(exist_ok=True)
|
||||
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
|
||||
@@ -368,7 +399,10 @@ class ReimburseBot:
|
||||
# 对外入口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run_bot(config: dict, invoices: list[dict], headless: bool = False, work_dir: Path | None = None):
|
||||
|
||||
def run_bot(
|
||||
config: dict[str, Any], invoices: list[dict[str, Any]], headless: bool = False, work_dir: Path | None = None
|
||||
) -> None:
|
||||
"""执行完整的浏览器填报流程"""
|
||||
if not config["username"] or not config["password"]:
|
||||
raise ValueError("缺少用户名或密码")
|
||||
@@ -396,7 +430,7 @@ def run_bot(config: dict, invoices: list[dict], headless: bool = False, work_dir
|
||||
bot.close()
|
||||
|
||||
|
||||
def run_bot_web(config: dict, invoices: list[dict], work_dir: Path):
|
||||
def run_bot_web(config: dict[str, Any], invoices: list[dict[str, Any]], work_dir: Path) -> None:
|
||||
"""Web 模式填报 — headless,附件从指定目录读取"""
|
||||
if not config["username"] or not config["password"]:
|
||||
raise ValueError("缺少用户名或密码")
|
||||
@@ -420,4 +454,4 @@ def run_bot_web(config: dict, invoices: list[dict], work_dir: Path):
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
bot.close()
|
||||
bot.close()
|
||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
_CONFIG_PATH = Path(__file__).parent.parent / "config.json"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
def load_config() -> dict[str, str | Path]:
|
||||
"""加载并合并配置,缺失字段使用默认值"""
|
||||
raw = {}
|
||||
if _CONFIG_PATH.exists():
|
||||
@@ -31,4 +31,19 @@ def load_config() -> dict:
|
||||
"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 配置,缺失字段使用默认值"""
|
||||
raw = {}
|
||||
if _CONFIG_PATH.exists():
|
||||
with open(_CONFIG_PATH, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
llm_raw = raw.get("llm", {})
|
||||
return {
|
||||
"model": llm_raw.get("model", "qwen-vl-max"),
|
||||
"api_base": llm_raw.get("api_base", "http://localhost:8080/v1"),
|
||||
"api_key": llm_raw.get("api_key", "lm-studio"),
|
||||
}
|
||||
49
src/doc/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
|
||||
## last_reviewed: 2026-06-09
|
||||
|
||||
# src/doc — 文档处理模块
|
||||
|
||||
负责发票信息提取、基于 LLM 的支付截图信息识别、以及将数据填入 Word 出库单模板。
|
||||
|
||||
## 模块清单
|
||||
|
||||
|
||||
| 文件 | 作用 |
|
||||
| ------------------------ | -------------------------------------------------- |
|
||||
| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 |
|
||||
| `pdf.py` | PDF 文件发现与文本提取(pdfplumber) |
|
||||
| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态) |
|
||||
| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 |
|
||||
| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 |
|
||||
| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板(pywin32 COM) |
|
||||
| `prompt.py` | LLM 提示词模板加载 |
|
||||
| `prompts/` | 提示词模板文件(`invoice_system.md`、`card_info_system.md`) |
|
||||
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
|
||||
支付截图 → llm_extractor.py → [刷卡记录]
|
||||
↓
|
||||
matcher.py(按金额贪心匹配,容差 10 元)
|
||||
↓
|
||||
invoice.py 分类 → CSV(已回填刷卡日期/卡号/金额)
|
||||
↓
|
||||
fill_consumable_doc → 易耗品出库单.doc
|
||||
```
|
||||
|
||||
## 依赖说明
|
||||
|
||||
- **pdfplumber** — PDF 文本提取
|
||||
- **pywin32** — Word COM 自动化(仅 Windows)
|
||||
- **llama-index** — LLM 信息提取
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `fill_consumable_doc.py` 依赖 Microsoft Word + COM,仅 Windows 可用
|
||||
- LLM 提取不会覆盖 CSV 中已有非空字段
|
||||
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
|
||||
- LLM 提取失败时直接报错,无正则回退
|
||||
|
||||
4
src/doc/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""文档处理模块
|
||||
|
||||
包含发票提取、LLM 信息提取、出库单填写等功能。
|
||||
"""
|
||||
65
src/doc/extractor.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""发票提取编排
|
||||
|
||||
串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类,生成支付记录列表。
|
||||
|
||||
对外接口:
|
||||
extract_invoices(directory) -> tuple[list[dict], dict]
|
||||
"""
|
||||
|
||||
from .. import get_logger
|
||||
from .invoice import classify_invoice_batch
|
||||
from .llm_extractor import extract_invoice_from_text
|
||||
from .matcher import match_invoices_to_cards
|
||||
from .pdf import extract_text_from_pdf, find_pdf_files
|
||||
|
||||
log = get_logger("extractor")
|
||||
|
||||
|
||||
def extract_invoices(
|
||||
directory: str = ".",
|
||||
) -> tuple[list[dict[str, str]], dict[str, list[dict[str, str]]]]:
|
||||
"""扫描目录下所有 PDF,提取发票信息并匹配支付记录
|
||||
|
||||
Returns:
|
||||
(payment_records, groups): 支付记录列表和按发票类型分组的字典
|
||||
groups = {'travel': [差旅发票], 'general': [普通发票]}
|
||||
"""
|
||||
pdf_files = find_pdf_files(directory)
|
||||
if not pdf_files:
|
||||
log.warning("未找到 PDF 文件")
|
||||
return [], {"travel": [], "general": []}
|
||||
|
||||
log.info(f"发现 {len(pdf_files)} 个 PDF 文件")
|
||||
|
||||
all_invoices = []
|
||||
for pdf_path in pdf_files:
|
||||
text = extract_text_from_pdf(pdf_path)
|
||||
if not text:
|
||||
log.warning(f"未能提取文本: {pdf_path.name}")
|
||||
continue
|
||||
|
||||
invoice = extract_invoice_from_text(text, pdf_path.name)
|
||||
|
||||
if invoice and invoice.get("发票号码"):
|
||||
all_invoices.append(invoice)
|
||||
log.info(f"[{invoice['发票类型']}] 已解析: {pdf_path.name}")
|
||||
else:
|
||||
log.warning(f"未能解析: {pdf_path.name}")
|
||||
|
||||
if all_invoices:
|
||||
log.info(f"共处理 {len(all_invoices)} 张发票")
|
||||
else:
|
||||
log.warning("未成功解析任何发票")
|
||||
|
||||
# 将支付截图与发票进行金额匹配,返回以支付记录为主键的列表
|
||||
payment_records = match_invoices_to_cards(all_invoices, directory)
|
||||
|
||||
# 从支付记录中还原所有发票用于分类
|
||||
all_invoices_restored: list[dict[str, str]] = []
|
||||
for record in payment_records:
|
||||
all_invoices_restored.extend(record.get("_matched_invoices", []))
|
||||
|
||||
groups = classify_invoice_batch(all_invoices_restored)
|
||||
log.info(f"差旅发票: {len(groups['travel'])} 张, 普通发票: {len(groups['general'])} 张")
|
||||
|
||||
return payment_records, groups
|
||||
@@ -11,10 +11,11 @@ import re
|
||||
import shutil
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import get_logger
|
||||
from .bot import load_invoice_data
|
||||
from .config import load_config
|
||||
from .. import get_logger
|
||||
from ..bot import load_invoice_data
|
||||
from ..config import load_config
|
||||
|
||||
log = get_logger("fill_consumable_doc")
|
||||
|
||||
@@ -86,7 +87,7 @@ def _today_cn_date() -> str:
|
||||
return f"{today.year}年{today.month}月{today.day}日"
|
||||
|
||||
|
||||
def _apply_font(rng) -> None:
|
||||
def _apply_font(rng: Any) -> None:
|
||||
"""将范围字体设为宋体五号(含数字与英文)。"""
|
||||
font = rng.Font
|
||||
font.Name = TABLE_FONT_NAME
|
||||
@@ -97,7 +98,7 @@ def _apply_font(rng) -> None:
|
||||
font.Size = TABLE_FONT_SIZE
|
||||
|
||||
|
||||
def _set_cell_value(cell, text: str) -> None:
|
||||
def _set_cell_value(cell: Any, text: str) -> None:
|
||||
"""写入单元格正文(不含末尾单元格标记)。"""
|
||||
rng = cell.Range
|
||||
rng.MoveEnd(WD_CHARACTER, -1)
|
||||
@@ -105,7 +106,7 @@ def _set_cell_value(cell, text: str) -> None:
|
||||
_apply_font(rng)
|
||||
|
||||
|
||||
def _normalize_table_font(tbl) -> None:
|
||||
def _normalize_table_font(tbl: Any) -> None:
|
||||
"""填写完成后统一整张表的字体。"""
|
||||
for row in tbl.Rows:
|
||||
for cell in row.Cells:
|
||||
@@ -114,7 +115,7 @@ def _normalize_table_font(tbl) -> None:
|
||||
_apply_font(rng)
|
||||
|
||||
|
||||
def _replace_date_in_doc(doc, new_date: str) -> None:
|
||||
def _replace_date_in_doc(doc: Any, new_date: str) -> None:
|
||||
"""仅替换表头段落中的日期文字,不改动段落其余部分。"""
|
||||
if not new_date:
|
||||
return
|
||||
@@ -135,7 +136,7 @@ def _replace_date_in_doc(doc, new_date: str) -> None:
|
||||
def fill_consumable_doc(
|
||||
csv_path: str | Path,
|
||||
doc_path: str | Path,
|
||||
config: dict | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
backup: bool = True,
|
||||
) -> Path:
|
||||
csv_path = Path(csv_path)
|
||||
@@ -148,59 +149,73 @@ def fill_consumable_doc(
|
||||
bak = doc_path.with_suffix(doc_path.suffix + ".bak")
|
||||
shutil.copy2(doc_path, bak)
|
||||
|
||||
import pythoncom
|
||||
import win32com.client
|
||||
|
||||
word = win32com.client.Dispatch("Word.Application")
|
||||
word.Visible = False
|
||||
word.DisplayAlerts = 0
|
||||
doc = word.Documents.Open(str(doc_path.resolve()))
|
||||
|
||||
pythoncom.CoInitialize()
|
||||
try:
|
||||
_replace_date_in_doc(doc, _today_cn_date())
|
||||
word = win32com.client.Dispatch("Word.Application")
|
||||
word.Visible = False
|
||||
word.DisplayAlerts = 0
|
||||
doc = word.Documents.Open(str(doc_path.resolve()))
|
||||
|
||||
tbl = doc.Tables(1)
|
||||
storage = config.get("consumable_storage", "躬行楼 C205")
|
||||
try:
|
||||
_replace_date_in_doc(doc, _today_cn_date())
|
||||
|
||||
for i, inv in enumerate(invoices):
|
||||
row_idx = i + 2
|
||||
if row_idx > tbl.Rows.Count:
|
||||
break
|
||||
tbl = doc.Tables(1)
|
||||
storage = config.get("consumable_storage", "躬行楼 C205")
|
||||
|
||||
parsed = parse_spec_model(inv.get("spec_model", ""))
|
||||
card_amount = inv.get("card_amount") or 0
|
||||
qty_str = parsed["qty"]
|
||||
qty_val = int(qty_str) if qty_str and qty_str.isdigit() else 0
|
||||
for i, inv in enumerate(invoices):
|
||||
row_idx = i + 2
|
||||
if row_idx > tbl.Rows.Count:
|
||||
break
|
||||
|
||||
# 金额填写刷卡金额,单价由刷卡金额反算
|
||||
amount = _format_money(card_amount)
|
||||
unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount)
|
||||
parsed = parse_spec_model(str(inv.get("spec_model", "")))
|
||||
# 当规格型号为空时,从项目名称提取产品信息
|
||||
if not parsed["product_name"]:
|
||||
item_name = str(inv.get("item_name", ""))
|
||||
# 去除 "*分类*" 前缀(如 "*电子工业设备*元件盒" -> "元件盒")
|
||||
if "*" in item_name:
|
||||
item_name = item_name.split("*")[-1].strip()
|
||||
parsed["product_name"] = item_name
|
||||
|
||||
# 数量:去掉前导零
|
||||
qty = str(qty_val) if qty_val > 0 else ""
|
||||
card_amount_raw = inv.get("card_amount") or 0
|
||||
card_amount: float = float(str(card_amount_raw).replace(",", ""))
|
||||
qty_str = parsed["qty"]
|
||||
qty_val = int(qty_str) if qty_str and qty_str.isdigit() else 0
|
||||
|
||||
values = [
|
||||
str(inv.get("seq", i + 1)),
|
||||
parsed["product_name"],
|
||||
parsed["spec"],
|
||||
parsed["unit"],
|
||||
qty,
|
||||
unit_price,
|
||||
amount,
|
||||
"", # 购货人签字 — 保持空白
|
||||
storage,
|
||||
"", # 领用人签字 — 保持空白
|
||||
"", # 备注 — 保持空白,避免撑破版式
|
||||
]
|
||||
# 金额填写刷卡金额,单价由刷卡金额反算
|
||||
amount = _format_money(card_amount)
|
||||
unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount)
|
||||
|
||||
for col_idx, val in enumerate(values, start=1):
|
||||
_set_cell_value(tbl.Cell(row_idx, col_idx), val)
|
||||
# 数量:去掉前导零;若无数量则默认为 1
|
||||
qty = str(qty_val) if qty_val > 0 else "1"
|
||||
|
||||
_normalize_table_font(tbl)
|
||||
values = [
|
||||
str(inv.get("seq", i + 1)),
|
||||
parsed["product_name"],
|
||||
parsed["spec"],
|
||||
parsed["unit"],
|
||||
qty,
|
||||
unit_price,
|
||||
amount,
|
||||
"", # 购货人签字 — 保持空白
|
||||
storage,
|
||||
"", # 领用人签字 — 保持空白
|
||||
"", # 备注 — 保持空白,避免撑破版式
|
||||
]
|
||||
|
||||
doc.Save()
|
||||
for col_idx, val in enumerate(values, start=1):
|
||||
_set_cell_value(tbl.Cell(row_idx, col_idx), str(val))
|
||||
|
||||
_normalize_table_font(tbl)
|
||||
|
||||
doc.Save()
|
||||
finally:
|
||||
doc.Close()
|
||||
word.Quit()
|
||||
finally:
|
||||
doc.Close()
|
||||
word.Quit()
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
return doc_path
|
||||
|
||||
@@ -209,7 +224,7 @@ def fill_consumable_from_template(
|
||||
csv_path: str | Path,
|
||||
template_path: str | Path,
|
||||
output_path: str | Path,
|
||||
config: dict | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> Path:
|
||||
"""从模板复制并填写出库单(Web 会话每次从模板重新生成)。"""
|
||||
template_path = Path(template_path)
|
||||
273
src/doc/invoice.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""发票数据模型与 CSV 工具
|
||||
|
||||
定义发票类型常量、CSV 列结构,提供发票分类和 CSV 读写功能。
|
||||
|
||||
对外接口:
|
||||
INVOICE_LEVEL_COLUMNS 发票级别 CSV 列定义
|
||||
PAYMENT_RECORD_COLUMNS 支付记录级别 CSV 列定义
|
||||
INVOICE_TYPE_* 发票类型常量
|
||||
is_travel_invoice(type) 判断是否为差旅发票
|
||||
classify_invoice_batch(invoices) 按类型分组
|
||||
load_csv(path) 读取支付记录 CSV
|
||||
save_csv(payment_records, path) 保存支付记录 CSV
|
||||
save_invoice_csv(payment_records, path) 保存发票级别 CSV
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .. import get_logger
|
||||
|
||||
log = get_logger("invoice")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CSV 列定义
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# 发票级别 CSV 列(用于 invoice_summary.csv,每行一张发票)
|
||||
INVOICE_LEVEL_COLUMNS = [
|
||||
"序号",
|
||||
"发票类型",
|
||||
"发票号码",
|
||||
"开票日期",
|
||||
"项目名称",
|
||||
"规格型号",
|
||||
"价税合计",
|
||||
"销售方名称",
|
||||
"出发站",
|
||||
"到达站",
|
||||
"车次",
|
||||
"乘车日期",
|
||||
"座位等级",
|
||||
"人员姓名",
|
||||
"刷卡日期",
|
||||
"公务卡号",
|
||||
"刷卡金额",
|
||||
"备注",
|
||||
"工号",
|
||||
]
|
||||
|
||||
# 支付记录级别 CSV 列(用于 payment_records.csv,每行一笔支付)
|
||||
PAYMENT_RECORD_COLUMNS = [
|
||||
"序号",
|
||||
# 支付信息
|
||||
"刷卡日期",
|
||||
"公务卡号",
|
||||
"刷卡金额",
|
||||
# 发票聚合信息
|
||||
"关联发票数",
|
||||
"发票详情", # 格式: 类型[号码]¥金额 | 类型[号码]¥金额
|
||||
"备注",
|
||||
# 内部字段(用于下游解析)
|
||||
"_invoices_json", # JSON 序列化的发票列表,供 bot/fill_doc 使用
|
||||
"工号",
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 发票类型常量
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
INVOICE_TYPE_TRAIN = "高铁票"
|
||||
INVOICE_TYPE_HOTEL = "酒店住宿"
|
||||
INVOICE_TYPE_GENERAL = "普通发票"
|
||||
|
||||
INVOICE_TYPE_TRAVEL = frozenset([INVOICE_TYPE_TRAIN, INVOICE_TYPE_HOTEL])
|
||||
|
||||
|
||||
def is_travel_invoice(invoice_type: str) -> bool:
|
||||
"""判断是否为差旅发票(高铁票/酒店住宿)"""
|
||||
return invoice_type in INVOICE_TYPE_TRAVEL
|
||||
|
||||
|
||||
def classify_invoice_batch(invoices: list[dict[str, str]]) -> dict[str, list[dict[str, str]]]:
|
||||
"""将发票列表按类型分组:{'travel': [...], 'general': [...]}"""
|
||||
travel = []
|
||||
general = []
|
||||
for inv in invoices:
|
||||
inv_type = inv.get("发票类型", INVOICE_TYPE_GENERAL)
|
||||
if is_travel_invoice(inv_type):
|
||||
travel.append(inv)
|
||||
else:
|
||||
general.append(inv)
|
||||
return {"travel": travel, "general": general}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CSV 读写工具
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clean_invoice_for_json(inv: dict[str, str]) -> dict[str, str]:
|
||||
"""清理发票字典中的内部字段,保留可序列化的字段"""
|
||||
clean = {}
|
||||
for k, v in inv.items():
|
||||
if k.startswith("_"):
|
||||
continue
|
||||
clean[k] = v
|
||||
return clean
|
||||
|
||||
|
||||
def load_csv(csv_path: Path) -> list[dict[str, str]] | None:
|
||||
"""读取支付记录 CSV 为 dict 列表,失败返回 None"""
|
||||
try:
|
||||
with open(csv_path, encoding="utf-8", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
fieldnames = reader.fieldnames or []
|
||||
missing = [c for c in PAYMENT_RECORD_COLUMNS if c not in fieldnames]
|
||||
if missing:
|
||||
log.error(f"CSV 缺少必要列: {missing}")
|
||||
return None
|
||||
return [row for row in reader]
|
||||
except FileNotFoundError:
|
||||
log.error(f"CSV 文件不存在: {csv_path.name}")
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"CSV 读取失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def load_invoice_csv(csv_path: Path) -> list[dict[str, str]] | None:
|
||||
"""读取发票级别 CSV 为 dict 列表(每行一张发票),失败返回 None"""
|
||||
try:
|
||||
with open(csv_path, encoding="utf-8", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
fieldnames = reader.fieldnames or []
|
||||
missing = [c for c in INVOICE_LEVEL_COLUMNS if c not in fieldnames]
|
||||
if missing:
|
||||
log.error(f"发票 CSV 缺少必要列: {missing}")
|
||||
return None
|
||||
return [row for row in reader]
|
||||
except FileNotFoundError:
|
||||
log.error(f"发票 CSV 文件不存在: {csv_path.name}")
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"发票 CSV 读取失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def load_invoices_from_csv(csv_path: Path) -> list[dict[str, str]] | None:
|
||||
"""从支付记录 CSV 中还原发票级别的数据(供 bot/fill_doc 使用)
|
||||
|
||||
读取 _invoices_json 字段,反序列化后展平为发票列表。
|
||||
"""
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
return None
|
||||
|
||||
invoices = []
|
||||
for row in rows:
|
||||
invoices_json = row.get("_invoices_json", "")
|
||||
if not invoices_json:
|
||||
continue
|
||||
try:
|
||||
inv_list = json.loads(invoices_json)
|
||||
for inv in inv_list:
|
||||
# 从支付记录回填刷卡信息
|
||||
inv["刷卡日期"] = row.get("刷卡日期", inv.get("刷卡日期", ""))
|
||||
inv["公务卡号"] = row.get("公务卡号", inv.get("公务卡号", ""))
|
||||
inv["刷卡金额"] = row.get("刷卡金额", inv.get("刷卡金额", ""))
|
||||
invoices.append(inv)
|
||||
except json.JSONDecodeError:
|
||||
log.warning(f"无法解析发票 JSON: {invoices_json[:50]}...")
|
||||
return invoices
|
||||
|
||||
|
||||
def save_csv(
|
||||
payment_records: list[dict[str, str]],
|
||||
output_path: str | Path = "payment_records.csv",
|
||||
) -> None:
|
||||
"""将支付记录列表保存为 CSV(以支付记录为主键)
|
||||
|
||||
每条支付记录包含:
|
||||
- 刷卡日期、公务卡号、刷卡金额(支付信息)
|
||||
- 关联发票数、发票详情(发票聚合信息)
|
||||
- _matched_invoices(内部字段,序列化为 JSON 存储在 CSV 中)
|
||||
"""
|
||||
csv_path = Path(output_path)
|
||||
|
||||
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(PAYMENT_RECORD_COLUMNS)
|
||||
|
||||
for idx, record in enumerate(payment_records, 1):
|
||||
# 序列化关联发票为 JSON
|
||||
matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment]
|
||||
invoices_json = json.dumps(
|
||||
[_clean_invoice_for_json(inv) for inv in matched_invoices],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
idx,
|
||||
record.get("刷卡日期", ""),
|
||||
record.get("公务卡号", ""),
|
||||
record.get("刷卡金额", ""),
|
||||
record.get("关联发票数", str(len(matched_invoices))),
|
||||
record.get("发票详情", ""),
|
||||
record.get("备注", ""),
|
||||
invoices_json,
|
||||
record.get("工号", ""),
|
||||
]
|
||||
)
|
||||
|
||||
log.info(f"支付记录 CSV 已保存: {csv_path.name}")
|
||||
|
||||
|
||||
def save_invoice_csv(
|
||||
payment_records: list[dict[str, str]],
|
||||
output_path: str | Path = "invoice_summary.csv",
|
||||
) -> None:
|
||||
"""将支付记录展平为发票级别 CSV(每行一张发票)
|
||||
|
||||
从 _matched_invoices 中还原每张发票,回填刷卡信息,
|
||||
生成以发票为主键的 CSV,用于人工填写报销单参考。
|
||||
"""
|
||||
csv_path = Path(output_path)
|
||||
|
||||
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(INVOICE_LEVEL_COLUMNS)
|
||||
|
||||
idx = 1
|
||||
for record in payment_records:
|
||||
matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment]
|
||||
for inv in matched_invoices:
|
||||
clean_inv = _clean_invoice_for_json(inv)
|
||||
writer.writerow(
|
||||
[
|
||||
idx,
|
||||
clean_inv.get("发票类型", ""),
|
||||
clean_inv.get("发票号码", ""),
|
||||
clean_inv.get("开票日期", ""),
|
||||
clean_inv.get("项目名称", ""),
|
||||
clean_inv.get("规格型号", ""),
|
||||
clean_inv.get("价税合计", ""),
|
||||
clean_inv.get("销售方名称", ""),
|
||||
clean_inv.get("出发站", ""),
|
||||
clean_inv.get("到达站", ""),
|
||||
clean_inv.get("车次", ""),
|
||||
clean_inv.get("乘车日期", ""),
|
||||
clean_inv.get("座位等级", ""),
|
||||
clean_inv.get("人员姓名", ""),
|
||||
record.get("刷卡日期", ""),
|
||||
record.get("公务卡号", ""),
|
||||
record.get("刷卡金额", ""),
|
||||
record.get("备注", ""),
|
||||
record.get("工号", ""),
|
||||
]
|
||||
)
|
||||
idx += 1
|
||||
|
||||
log.info(f"发票级别 CSV 已保存: {csv_path.name}")
|
||||
|
||||
|
||||
def save_csv_rows(csv_path: Path, rows: list[dict[str, str]]) -> None:
|
||||
"""将 dict 列表保存为支付记录 CSV(用于更新已有 CSV)"""
|
||||
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=PAYMENT_RECORD_COLUMNS)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
log.info(f"CSV 已保存: {csv_path.name}")
|
||||
210
src/doc/llm_extractor.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
LLM 信息提取
|
||||
|
||||
使用 LLM 从 PDF 文本中提取结构化数据,以及从支付截图中提取刷卡信息。
|
||||
支持 JSON 格式输出,字段与 CSV_COLUMNS 对齐。
|
||||
|
||||
对外接口:
|
||||
extract_invoice_from_text(text, file_name) -> dict 从 PDF 文本提取发票信息
|
||||
extract_card_info_from_image(image_path) -> dict 从支付截图提取刷卡信息
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from .. import get_logger
|
||||
from .prompt import build_card_info_system_prompt, build_invoice_system_prompt
|
||||
|
||||
log = get_logger("llm_extractor")
|
||||
|
||||
|
||||
def _create_llm() -> Any:
|
||||
"""根据配置文件创建 LLM 实例。"""
|
||||
try:
|
||||
from llama_index.llms.openai_like import OpenAILike
|
||||
except ImportError:
|
||||
log.error("缺少 llama-index-llms-openai-like,请执行: uv pip install llama-index-llms-openai-like")
|
||||
raise
|
||||
|
||||
from ..config import get_llm_config
|
||||
|
||||
llm_config = get_llm_config()
|
||||
return OpenAILike(
|
||||
model=llm_config["model"],
|
||||
api_base=llm_config["api_base"],
|
||||
api_key=llm_config.get("api_key", "lm-studio"),
|
||||
temperature=0.1,
|
||||
max_tokens=8192,
|
||||
request_timeout=600.0,
|
||||
is_chat_model=True,
|
||||
)
|
||||
|
||||
|
||||
def _llm_query(system_prompt: str, user_content: str, max_tokens: int = 4096) -> str:
|
||||
"""发送请求到 LLM 并返回完整响应文本。"""
|
||||
from llama_index.core.llms import ChatMessage
|
||||
|
||||
from ..config import get_llm_config
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="system", content=system_prompt),
|
||||
ChatMessage(role="user", content=user_content),
|
||||
]
|
||||
|
||||
llm_config = get_llm_config()
|
||||
llm = _create_llm()
|
||||
log.info("开始请求 LLM (model=%s, base=%s)", llm_config["model"], llm_config["api_base"])
|
||||
|
||||
try:
|
||||
parts = []
|
||||
for resp in llm.stream_chat(
|
||||
messages,
|
||||
temperature=0.1,
|
||||
max_tokens=max_tokens,
|
||||
extra_body={"reasoning_effort": "none"},
|
||||
):
|
||||
delta = resp.delta
|
||||
if delta:
|
||||
parts.append(delta)
|
||||
text = "".join(parts)
|
||||
log.info("LLM 请求完成,响应总长度: %d 字符", len(text))
|
||||
log.info("LLM 响应: %s", text)
|
||||
return text
|
||||
except Exception as e:
|
||||
log.error("LLM 请求失败: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def _parse_json_response(text: str) -> dict[str, Any]:
|
||||
"""从 LLM 响应中提取 JSON,处理可能的 Markdown 包裹。"""
|
||||
text = text.strip()
|
||||
|
||||
# 处理 ```json ... ``` 包裹
|
||||
if "```" in text:
|
||||
# 提取第一个代码块
|
||||
start = text.find("```") + 3
|
||||
end = text.find("```", start)
|
||||
if end > start:
|
||||
text = text[start:end].strip()
|
||||
|
||||
# 去掉可能的前缀 (如 "json")
|
||||
if text.lower().startswith("json"):
|
||||
text = text[4:].strip()
|
||||
|
||||
return cast(dict[str, Any], json.loads(text))
|
||||
|
||||
|
||||
def extract_invoice_from_text(text: str, file_name: str = "") -> dict[str, Any]:
|
||||
"""从 PDF 发票文本中提取结构化数据。
|
||||
|
||||
Args:
|
||||
text: PDF 提取的文本内容。
|
||||
file_name: 原始文件名(用于日志)。
|
||||
|
||||
Returns:
|
||||
包含所有 CSV_COLUMNS 字段的字典。
|
||||
"""
|
||||
system_prompt = build_invoice_system_prompt()
|
||||
user_content = f"请分析以下发票文本并提取信息:\n\n文件名: {file_name}\n\n---\n\n{text}\n\n---"
|
||||
|
||||
try:
|
||||
response = _llm_query(system_prompt, user_content, max_tokens=4096)
|
||||
result = _parse_json_response(response)
|
||||
log.info("LLM 发票提取成功: %s", file_name)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.error("LLM 发票提取失败: %s (%s)", file_name, e)
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 支付截图信息提取(多模态)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _image_to_base64(image_path: Path) -> str:
|
||||
"""将图片文件读取为 base64 字符串。"""
|
||||
with open(image_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
|
||||
def _llm_query_multimodal(
|
||||
system_prompt: str,
|
||||
text: str,
|
||||
image_b64: str,
|
||||
max_tokens: int = 4096,
|
||||
) -> str:
|
||||
"""发送多模态请求(文本 + 图片)到 LLM。"""
|
||||
from llama_index.core.base.llms.types import ImageBlock, TextBlock
|
||||
from llama_index.core.llms import ChatMessage
|
||||
|
||||
from ..config import get_llm_config
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="system", content=system_prompt),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
blocks=[
|
||||
TextBlock(text=text),
|
||||
ImageBlock(
|
||||
url=f"data:image/jpeg;base64,{image_b64}",
|
||||
detail="high",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
llm_config = get_llm_config()
|
||||
llm = _create_llm()
|
||||
log.info(
|
||||
"开始请求 LLM 多模态 (model=%s, base=%s)",
|
||||
llm_config["model"],
|
||||
llm_config["api_base"],
|
||||
)
|
||||
|
||||
try:
|
||||
parts = []
|
||||
for resp in llm.stream_chat(
|
||||
messages,
|
||||
temperature=0.1,
|
||||
max_tokens=max_tokens,
|
||||
extra_body={"reasoning_effort": "none"},
|
||||
):
|
||||
delta = resp.delta
|
||||
if delta:
|
||||
parts.append(delta)
|
||||
text = "".join(parts)
|
||||
log.info("LLM 多模态请求完成,响应总长度: %d 字符", len(text))
|
||||
log.info("LLM 多模态响应: %s", text)
|
||||
return text
|
||||
except Exception as e:
|
||||
log.error("LLM 多模态请求失败: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def extract_card_info_from_image(image_path: Path) -> dict[str, Any]:
|
||||
"""从支付截图中提取刷卡信息。
|
||||
|
||||
Args:
|
||||
image_path: 支付截图图片路径。
|
||||
|
||||
Returns:
|
||||
包含刷卡日期、刷卡金额、公务卡号的字典。
|
||||
"""
|
||||
system_prompt = build_card_info_system_prompt()
|
||||
user_text = f"请分析以下支付截图并提取信息:\n\n文件名: {image_path.name}"
|
||||
|
||||
image_b64 = _image_to_base64(image_path)
|
||||
|
||||
try:
|
||||
response = _llm_query_multimodal(system_prompt, user_text, image_b64, max_tokens=4096)
|
||||
result = _parse_json_response(response)
|
||||
log.info("LLM 支付截图提取成功: %s", image_path.name)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.error("LLM 支付截图提取失败: %s (%s)", image_path.name, e)
|
||||
raise
|
||||
395
src/doc/matcher.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""发票与支付截图匹配
|
||||
|
||||
将提取到的发票数据与支付截图中的刷卡记录进行金额匹配,
|
||||
输出以支付记录为主键的结果列表。
|
||||
|
||||
## 业务约束
|
||||
|
||||
- 发票数 >= 付款记录数(最少一张发票对应一张付款记录)
|
||||
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
|
||||
- 若发票数 == 付款数,走一对一匹配,无需一对多
|
||||
|
||||
## 匹配流程
|
||||
|
||||
1. 扫描目录下图片文件,调用 LLM 提取刷卡信息(日期/金额/卡号)
|
||||
2. 解析发票和刷卡记录的金额,进行总额校验
|
||||
- 发票总额 < 刷卡总额时发出 warning
|
||||
3. 按金额降序排序
|
||||
4. 根据数量关系选择匹配策略:
|
||||
- 数量相等 → 一对一匹配:按金额从大到小依次配对,相对容差内即匹配
|
||||
- 发票更多 → 一对多匹配:对每张刷卡记录贪心凑金额,相对容差内结束
|
||||
5. 构建以支付记录为主键的结果列表
|
||||
6. 未匹配的发票单独作为一条记录(无刷卡信息)
|
||||
7. 清理内部字段,输出支付记录列表
|
||||
|
||||
## 容差计算
|
||||
|
||||
使用相对容差(默认 3%),以刷卡金额为基准:
|
||||
- ¥2900 发票 vs ¥2850 刷卡 → 差 ¥50,容差 ¥85.5 → 匹配成功
|
||||
- ¥100 发票 vs ¥95 刷卡 → 差 ¥5,容差 ¥3.0 → 不匹配(需精确匹配或调整)
|
||||
|
||||
## 一对多匹配细节
|
||||
|
||||
- 对每张刷卡记录,维护 remaining(剩余待匹配金额)
|
||||
- 遍历未分配的发票(按金额降序):
|
||||
- 若发票金额 + 容差 >= remaining,视为最后一张,匹配后退出
|
||||
- 否则发票金额不超过 remaining + 容差即可匹配
|
||||
- 匹配后 remaining 为负且超出容差时回滚最后一张发票
|
||||
- 每张发票只会被分配一次
|
||||
|
||||
## 对外接口
|
||||
|
||||
match_invoices_to_cards(invoices, directory, tolerance) -> list[dict]
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from .llm_extractor import extract_card_info_from_image
|
||||
|
||||
log = get_logger("matcher")
|
||||
|
||||
# 支持的图片扩展名
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
||||
|
||||
|
||||
def _find_images(directory: str) -> list[Path]:
|
||||
"""在目录下查找支付截图图片文件"""
|
||||
dir_path = Path(directory)
|
||||
images = [f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in IMAGE_EXTENSIONS]
|
||||
return sorted(images)
|
||||
|
||||
|
||||
def _safe_float(value: str | None, default: float = 0.0) -> float:
|
||||
"""安全转换为浮点数"""
|
||||
if value is None or str(value).strip() == "":
|
||||
return default
|
||||
try:
|
||||
return float(str(value).replace(",", ""))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _extract_all_cards(directory: str) -> list[dict[str, Any]]:
|
||||
"""提取目录下所有支付截图的刷卡信息"""
|
||||
images = _find_images(directory)
|
||||
if not images:
|
||||
log.warning("未找到支付截图图片")
|
||||
return []
|
||||
|
||||
log.info(f"发现 {len(images)} 张支付截图")
|
||||
cards = []
|
||||
for img_path in images:
|
||||
try:
|
||||
card_info = extract_card_info_from_image(img_path)
|
||||
card_info["_source_file"] = img_path.name
|
||||
cards.append(card_info)
|
||||
log.info(f"[支付截图] 已解析: {img_path.name}")
|
||||
except Exception as e:
|
||||
log.warning(f"支付截图解析失败 {img_path.name}: {e}")
|
||||
|
||||
log.info(f"共提取 {len(cards)} 条刷卡记录")
|
||||
return cards
|
||||
|
||||
|
||||
def _build_invoice_summary(invoices: list[dict[str, Any]]) -> str:
|
||||
"""将多张发票信息汇总为备注字符串"""
|
||||
parts = []
|
||||
for inv in invoices:
|
||||
person_name = inv.get("人员姓名") or inv.get("发票号码", "未知")
|
||||
inv_type = inv.get("发票类型", "未知")
|
||||
amount = inv.get("价税合计", "未知")
|
||||
parts.append(f"{inv_type}[{person_name}]¥{amount}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _relative_tolerance(base: float, rate: float = 0.05) -> float:
|
||||
"""根据基准金额计算相对容差(默认 5%)"""
|
||||
return abs(base) * rate
|
||||
|
||||
|
||||
def match_invoices_to_cards(
|
||||
invoices: list[dict[str, Any]],
|
||||
directory: str,
|
||||
tolerance: float = 0.03,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""将发票与支付截图按金额匹配,输出以支付记录为主键的结果列表
|
||||
|
||||
业务约束:
|
||||
- 发票数 >= 付款记录数
|
||||
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
|
||||
- 若发票数 == 付款数,一对一匹配,无需一对多
|
||||
|
||||
Args:
|
||||
invoices: 发票列表,需包含 "价税合计" 字段
|
||||
directory: 支付截图所在目录
|
||||
tolerance: 金额匹配容差比例(默认 0.05 = 5%)
|
||||
|
||||
Returns:
|
||||
以支付记录为主键的结果列表,每条记录包含:
|
||||
- 刷卡日期、公务卡号、刷卡金额(支付信息)
|
||||
- 关联发票列表(_matched_invoices)
|
||||
- 发票详情备注
|
||||
- 未匹配发票单独作为一条无刷卡信息的记录
|
||||
"""
|
||||
cards = _extract_all_cards(directory)
|
||||
if not cards:
|
||||
log.warning("无刷卡记录可供匹配,发票将保持原状")
|
||||
# 无刷卡记录时,每张发票作为独立记录返回
|
||||
return _invoices_to_records(invoices)
|
||||
|
||||
# 解析金额
|
||||
for card in cards:
|
||||
card["_amount"] = _safe_float(card.get("刷卡金额"))
|
||||
for inv in invoices:
|
||||
inv["_amount"] = _safe_float(inv.get("价税合计"))
|
||||
|
||||
# 数据校验
|
||||
total_invoices = sum(inv["_amount"] for inv in invoices)
|
||||
total_cards = sum(card["_amount"] for card in cards)
|
||||
log.info(
|
||||
f"金额校验: 发票总额 ¥{total_invoices:.2f}, 刷卡总额 ¥{total_cards:.2f}, "
|
||||
f"发票数 {len(invoices)}, 刷卡数 {len(cards)}"
|
||||
)
|
||||
|
||||
total_tolerance = _relative_tolerance(max(total_invoices, total_cards), tolerance)
|
||||
if total_invoices < total_cards - total_tolerance:
|
||||
log.warning(
|
||||
f"发票总额 (¥{total_invoices:.2f}) 小于刷卡总额 (¥{total_cards:.2f}),"
|
||||
f"超出容差 {tolerance * 100:.0f}%,匹配结果可能有偏差"
|
||||
)
|
||||
|
||||
# 按金额降序排序
|
||||
cards.sort(key=lambda c: c["_amount"], reverse=True)
|
||||
invoices.sort(key=lambda i: i["_amount"], reverse=True)
|
||||
|
||||
# 执行匹配,返回 {card_index: [invoice_indices]} 的映射
|
||||
card_to_invoices = _match(cards, invoices, tolerance)
|
||||
|
||||
# 构建支付记录列表
|
||||
records = _build_payment_records(cards, invoices, card_to_invoices)
|
||||
|
||||
# 清理内部字段
|
||||
for inv in invoices:
|
||||
inv.pop("_amount", None)
|
||||
for card in cards:
|
||||
card.pop("_amount", None)
|
||||
|
||||
# 统计
|
||||
matched_invoices = sum(len(inv_list) for inv_list in card_to_invoices.values())
|
||||
unmatched_count = len(invoices) - matched_invoices
|
||||
log.info(f"匹配完成: {len(records)} 条支付记录, {matched_invoices}/{len(invoices)} 张发票已关联")
|
||||
if unmatched_count:
|
||||
log.info(f"未匹配发票: {unmatched_count} 张(已单独列为记录)")
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _match(
|
||||
cards: list[dict[str, Any]],
|
||||
invoices: list[dict[str, Any]],
|
||||
tolerance: float,
|
||||
) -> dict[int, list[int]]:
|
||||
"""执行匹配,返回 {card_index: [invoice_indices]} 的映射
|
||||
|
||||
tolerance 为相对容差比例(如 0.05 表示 5%)
|
||||
"""
|
||||
result: dict[int, list[int]] = {}
|
||||
assigned: set[int] = set()
|
||||
|
||||
if len(invoices) == len(cards):
|
||||
_match_one_to_one(invoices, cards, tolerance, assigned, result)
|
||||
else:
|
||||
_match_one_to_many(invoices, cards, tolerance, assigned, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _match_one_to_one(
|
||||
invoices: list[dict[str, Any]],
|
||||
cards: list[dict[str, Any]],
|
||||
tolerance: float,
|
||||
assigned: set[int],
|
||||
result: dict[int, list[int]],
|
||||
) -> None:
|
||||
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对
|
||||
|
||||
tolerance 为相对容差比例,以刷卡金额为基准计算
|
||||
"""
|
||||
for card_idx, card in enumerate(cards):
|
||||
if card_idx >= len(invoices):
|
||||
break
|
||||
inv = invoices[card_idx]
|
||||
diff = abs(inv["_amount"] - card["_amount"])
|
||||
card_tol = _relative_tolerance(card["_amount"], tolerance)
|
||||
if diff <= card_tol:
|
||||
assigned.add(card_idx)
|
||||
result[card_idx] = [card_idx]
|
||||
log.info(
|
||||
f"[一对一] {inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
|
||||
f"↔ {card.get('_source_file', '未知')} ¥{card['_amount']:.2f}"
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
f"[一对一] 金额偏差超出容差: "
|
||||
f"{inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
|
||||
f"vs ¥{card['_amount']:.2f} (差 ¥{diff:.2f}, 容差 ¥{card_tol:.2f})"
|
||||
)
|
||||
|
||||
|
||||
def _match_one_to_many(
|
||||
invoices: list[dict[str, Any]],
|
||||
cards: list[dict[str, Any]],
|
||||
tolerance: float,
|
||||
assigned: set[int],
|
||||
result: dict[int, list[int]],
|
||||
) -> None:
|
||||
"""一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配
|
||||
|
||||
tolerance 为相对容差比例(如 0.05 表示 5%),以刷卡金额为基准计算
|
||||
|
||||
匹配分两阶段:
|
||||
1. 精确匹配:先扫描金额完全相等(差值 <= 0.01 元)的发票-刷卡对,直接锁定
|
||||
2. 贪心匹配:剩余未分配的发票和刷卡记录走贪心凑金额
|
||||
"""
|
||||
|
||||
# ---- 阶段 1:精确匹配(金额差 <= 0.01 元视为相等)----
|
||||
exact_tolerance = 0.01
|
||||
for card_idx, card in enumerate(cards):
|
||||
card_amount = card["_amount"]
|
||||
if card_amount <= 0:
|
||||
continue
|
||||
|
||||
for idx, inv in enumerate(invoices):
|
||||
if idx in assigned:
|
||||
continue
|
||||
inv_amount = inv["_amount"]
|
||||
if inv_amount <= 0:
|
||||
continue
|
||||
|
||||
if abs(inv_amount - card_amount) <= exact_tolerance:
|
||||
assigned.add(idx)
|
||||
result[card_idx] = [idx]
|
||||
log.info(
|
||||
f"[一对多-精确] {inv.get('发票号码', '未知')} ¥{inv_amount:.2f} "
|
||||
f"↔ {card.get('_source_file', '未知')} ¥{card_amount:.2f}"
|
||||
)
|
||||
break # 每张刷卡只精确匹配一张发票
|
||||
|
||||
# ---- 阶段 2:贪心匹配(仅处理未精确匹配的刷卡记录)----
|
||||
for card_idx, card in enumerate(cards):
|
||||
if card_idx in result: # 已在阶段 1 精确匹配
|
||||
continue
|
||||
|
||||
card_amount = card["_amount"]
|
||||
if card_amount <= 0:
|
||||
continue
|
||||
|
||||
# 以刷卡金额为基准计算相对容差
|
||||
card_tol = _relative_tolerance(card_amount, tolerance)
|
||||
|
||||
remaining = card_amount
|
||||
matched_indices: list[int] = []
|
||||
|
||||
for idx, inv in enumerate(invoices):
|
||||
if idx in assigned:
|
||||
continue
|
||||
if remaining <= card_tol:
|
||||
break
|
||||
|
||||
inv_amount = inv["_amount"]
|
||||
if inv_amount <= 0:
|
||||
continue
|
||||
|
||||
# 最后一张发票:金额 + 容差 >= remaining 即可
|
||||
# 中间发票:金额不超过 remaining + 容差
|
||||
if inv_amount + card_tol >= remaining:
|
||||
is_match = True
|
||||
else:
|
||||
is_match = inv_amount <= remaining + card_tol
|
||||
|
||||
if is_match:
|
||||
assigned.add(idx)
|
||||
matched_indices.append(idx)
|
||||
remaining -= inv_amount
|
||||
if remaining <= card_tol:
|
||||
break
|
||||
|
||||
# 回滚:如果匹配后 remaining 为负且超出容差
|
||||
if remaining < -card_tol and matched_indices:
|
||||
last_idx = matched_indices.pop()
|
||||
assigned.discard(last_idx)
|
||||
remaining += invoices[last_idx]["_amount"]
|
||||
|
||||
# 记录匹配结果
|
||||
if matched_indices:
|
||||
result[card_idx] = matched_indices
|
||||
for idx in matched_indices:
|
||||
inv = invoices[idx]
|
||||
log.info(
|
||||
f"[一对多-贪心] {inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} "
|
||||
f"→ {card.get('_source_file', '未知')} ¥{card['_amount']:.2f}"
|
||||
)
|
||||
|
||||
|
||||
def _build_payment_records(
|
||||
cards: list[dict[str, Any]],
|
||||
invoices: list[dict[str, Any]],
|
||||
card_to_invoices: dict[int, list[int]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""构建以支付记录为主键的结果列表"""
|
||||
records: list[dict[str, Any]] = []
|
||||
|
||||
# 已有匹配记录的支付
|
||||
for card_idx, inv_indices in card_to_invoices.items():
|
||||
card = cards[card_idx]
|
||||
matched_invs = [invoices[idx] for idx in inv_indices]
|
||||
|
||||
record = {
|
||||
"刷卡日期": card.get("刷卡日期", ""),
|
||||
"公务卡号": card.get("公务卡号", ""),
|
||||
"刷卡金额": str(card["_amount"]),
|
||||
"关联发票数": str(len(matched_invs)),
|
||||
"发票详情": _build_invoice_summary(matched_invs),
|
||||
"备注": "",
|
||||
"_matched_invoices": matched_invs,
|
||||
}
|
||||
records.append(record)
|
||||
|
||||
# 未匹配的发票,单独作为记录
|
||||
matched_indices = set()
|
||||
for inv_indices in card_to_invoices.values():
|
||||
matched_indices.update(inv_indices)
|
||||
|
||||
unmatched = [inv for idx, inv in enumerate(invoices) if idx not in matched_indices]
|
||||
for inv in unmatched:
|
||||
record = {
|
||||
"刷卡日期": "",
|
||||
"公务卡号": "",
|
||||
"刷卡金额": "",
|
||||
"关联发票数": "1",
|
||||
"发票详情": _build_invoice_summary([inv]),
|
||||
"备注": "未匹配到支付记录",
|
||||
"_matched_invoices": [inv],
|
||||
}
|
||||
records.append(record)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _invoices_to_records(invoices: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""无刷卡记录时,将每张发票转为独立记录"""
|
||||
records = []
|
||||
for inv in invoices:
|
||||
record = {
|
||||
"刷卡日期": "",
|
||||
"公务卡号": "",
|
||||
"刷卡金额": "",
|
||||
"关联发票数": "1",
|
||||
"发票详情": _build_invoice_summary([inv]),
|
||||
"备注": "",
|
||||
"_matched_invoices": [inv],
|
||||
}
|
||||
records.append(record)
|
||||
return records
|
||||
42
src/doc/pdf.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""PDF 文件发现与文本提取
|
||||
|
||||
从 PDF 发票文件中提取原始文本内容。
|
||||
|
||||
对外接口:
|
||||
find_pdf_files(directory) -> list[Path] 查找目录下所有 PDF
|
||||
extract_text_from_pdf(filepath) -> str 提取 PDF 文本
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .. import get_logger
|
||||
|
||||
log = get_logger("pdf")
|
||||
|
||||
|
||||
def find_pdf_files(directory: str = ".") -> list[Path]:
|
||||
"""查找目录下所有 PDF 文件(非递归)"""
|
||||
pdf_dir = Path(directory)
|
||||
if not pdf_dir.exists():
|
||||
return []
|
||||
return sorted(pdf_dir.glob("*.pdf"))
|
||||
|
||||
|
||||
def extract_text_from_pdf(filepath: Path) -> str:
|
||||
"""从单个 PDF 中提取全部文本"""
|
||||
try:
|
||||
import pdfplumber
|
||||
except ImportError as err:
|
||||
raise ImportError("缺少 pdfplumber,请执行: uv pip install pdfplumber") from err
|
||||
|
||||
try:
|
||||
parts = []
|
||||
with pdfplumber.open(filepath) as pdf:
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
except Exception as e:
|
||||
log.error(f"无法读取 {filepath.name}: {e}")
|
||||
return ""
|
||||
26
src/doc/prompt.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
LLM 提示词模板
|
||||
|
||||
从 src/prompts/ 目录加载 .md 文件作为提示词模板。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
_PROMPTS_DIR = os.path.join(os.path.dirname(__file__), "prompts")
|
||||
|
||||
|
||||
def _load_prompt(filename: str) -> str:
|
||||
"""从 prompts 目录加载提示词文件内容。"""
|
||||
path = os.path.join(_PROMPTS_DIR, filename)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def build_invoice_system_prompt() -> str:
|
||||
"""构建发票提取系统提示词。"""
|
||||
return _load_prompt("invoice_system.md")
|
||||
|
||||
|
||||
def build_card_info_system_prompt() -> str:
|
||||
"""构建支付截图信息提取系统提示词。"""
|
||||
return _load_prompt("card_info_system.md")
|
||||
11
src/doc/prompts/card_info_system.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 支付截图信息提取系统提示词
|
||||
|
||||
你是财务支付截图信息提取助手。你的任务是从支付截图(银行转账记录、微信/支付宝付款凭证等)中提取结构化信息,并以 JSON 格式返回。
|
||||
|
||||
需要提取的字段(全部必填,无法识别时返回空字符串):
|
||||
|
||||
1. 刷卡日期: 支付发生的日期,格式为 YYYY/M/D
|
||||
2. 刷卡金额: 实际支付金额,只保留数字(如 123.45)
|
||||
3. 公务卡号: 付款银行卡号,如果截图中有显示则提取,没有则返回空字符串
|
||||
|
||||
严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。
|
||||
33
src/doc/prompts/invoice_system.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 发票提取系统提示词
|
||||
|
||||
你是财务文档信息提取助手。你的任务是从发票文本中提取结构化信息,并以 JSON 格式返回。
|
||||
|
||||
需要提取的字段(全部必填,无法识别时返回空字符串):
|
||||
先判断发票类型,如果是高铁票/或者火车票,返回如下字段:
|
||||
1. 发票类型: "高铁票"
|
||||
2. 发票号码: 发票的唯一编号
|
||||
3. 开票日期: 格式为 YYYY/M/D
|
||||
4. 乘车日期: 格式为 YYYY/M/D
|
||||
5. 出发站: 没有留空
|
||||
6. 到达站: 没有留空
|
||||
7. 座位等级: 没有留空
|
||||
8. 车次: 没有留空
|
||||
9. 人员姓名: 没有留空
|
||||
10. 价税合计:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息
|
||||
|
||||
如果是酒店住宿(酒店住宿通产包含关键字:住宿服务,酒店,生产生活服务等,请仔细分析,这种发票和普通发票类似),返回如下字段:
|
||||
1. 发票类型: "酒店住宿"
|
||||
2. 发票号码: 发票的唯一编号
|
||||
3. 开票日期: 格式为 YYYY/M/D
|
||||
4. 价税合计: 金额数字
|
||||
|
||||
如果是普通发票,返回如下字段:
|
||||
1. 发票类型: "普通发票"
|
||||
2. 发票号码: 发票的唯一编号
|
||||
3. 开票日期: 格式为 YYYY/M/D
|
||||
4. 项目名称: 商品或服务名称,总结的人能看懂
|
||||
5. 规格型号: 规格描述
|
||||
6. 价税合计: 金额数字
|
||||
7. 销售方名称: 卖方全称
|
||||
|
||||
严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。
|
||||
@@ -1,17 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
财务报销自动化
|
||||
|
||||
依次执行:
|
||||
1. 发票提取 — 从 PDF 发票提取信息,生成 invoice_summary.csv
|
||||
2. OCR 识别 — 从支付截图识别刷卡信息,回填 CSV
|
||||
3. 报销提交 — 打开浏览器登录财务系统并自动填报
|
||||
2. 报销提交 — 打开浏览器登录财务系统并自动填报
|
||||
|
||||
用法:
|
||||
python run.py # 全流程
|
||||
python run.py --step invoice # 仅发票提取
|
||||
python run.py --step ocr # 仅 OCR 识别
|
||||
python run.py --step submit # 仅浏览器填报
|
||||
python run.py -u 工号 -p 密码 # 覆盖登录凭据
|
||||
"""
|
||||
@@ -21,28 +18,30 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 确保项目根目录在 sys.path 中
|
||||
sys.path.insert(0, str(Path(__file__).parent.resolve()))
|
||||
sys.path.insert(0, str(Path(__file__).parent.resolve().parent))
|
||||
|
||||
from app.pipeline import run_pipeline
|
||||
from src.pipeline import run_pipeline
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="财务报销自动化 - 发票提取 → OCR 识别 → 浏览器填报",
|
||||
description="财务报销自动化 - 发票提取 → 浏览器填报",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--step",
|
||||
choices=["all", "invoice", "ocr", "submit"],
|
||||
choices=["all", "invoice", "submit"],
|
||||
default="all",
|
||||
help="执行步骤 (默认: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-u", "--username",
|
||||
"-u",
|
||||
"--username",
|
||||
default=None,
|
||||
help="信息门户登录账号(覆盖 config.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--password",
|
||||
"-p",
|
||||
"--password",
|
||||
default=None,
|
||||
help="信息门户登录密码(覆盖 config.json)",
|
||||
)
|
||||
@@ -57,4 +56,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
136
src/pipeline.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
报销全流程编排
|
||||
|
||||
将发票提取 → 浏览器填报串联为一条管道,
|
||||
数据在内存中流转,同时生成 CSV 中间产物。
|
||||
|
||||
发票类型区分:
|
||||
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
||||
- 普通发票:生成易耗品出库单,走普通报销流程
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import get_logger
|
||||
from .config import load_config
|
||||
from .doc.extractor import extract_invoices
|
||||
from .doc.invoice import (
|
||||
classify_invoice_batch,
|
||||
load_invoices_from_csv,
|
||||
save_invoice_csv,
|
||||
)
|
||||
from .doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
|
||||
log = get_logger("pipeline")
|
||||
|
||||
|
||||
def _find_upload_directory(project_dir: Path) -> Path | None:
|
||||
"""自动发现 uploads 目录下最新且包含 PDF 的会话文件夹"""
|
||||
uploads_base = project_dir / "src" / "web" / "uploads"
|
||||
if not uploads_base.is_dir():
|
||||
return None
|
||||
|
||||
# 只选包含 PDF 的目录
|
||||
valid_dirs = [d for d in uploads_base.iterdir() if d.is_dir() and list(d.glob("*.pdf"))]
|
||||
if not valid_dirs:
|
||||
return None
|
||||
|
||||
# 按修改时间排序,取最新
|
||||
session_dirs = sorted(
|
||||
valid_dirs,
|
||||
key=lambda d: d.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
return session_dirs[0]
|
||||
|
||||
|
||||
def _classify_from_csv(csv_path: Path) -> dict[str, list[dict[str, Any]]]:
|
||||
"""从已生成的 CSV 中读取发票数据并按类型分组"""
|
||||
rows = load_invoices_from_csv(csv_path)
|
||||
if rows is None:
|
||||
return {"travel": [], "general": []}
|
||||
return classify_invoice_batch(rows)
|
||||
|
||||
|
||||
def run_pipeline(step: str = "all", username: str | None = None, password: str | None = None) -> int:
|
||||
"""执行报销流程
|
||||
|
||||
Args:
|
||||
step: all | invoice | submit
|
||||
username: 覆盖 config.json 中的用户名
|
||||
password: 覆盖 config.json 中的密码
|
||||
"""
|
||||
config = load_config()
|
||||
if username:
|
||||
config["username"] = username
|
||||
if password:
|
||||
config["password"] = password
|
||||
|
||||
# 工作目录(项目根目录)
|
||||
project_dir = Path(__file__).parent.parent
|
||||
|
||||
# --------------------------------------------------
|
||||
# Step 1: 发票提取
|
||||
# --------------------------------------------------
|
||||
payment_records: list[dict[str, str]] | None = None
|
||||
groups: dict[str, list[dict[str, str]]] | None = None
|
||||
|
||||
if step in ("all", "invoice"):
|
||||
log.info("=" * 60)
|
||||
log.info("[1/2] 发票提取")
|
||||
log.info("=" * 60)
|
||||
|
||||
payment_records, groups = extract_invoices(str(project_dir))
|
||||
if not payment_records:
|
||||
log.error("未提取到任何发票数据")
|
||||
return 1
|
||||
|
||||
save_payment_csv(payment_records, project_dir / "payment_records.csv")
|
||||
save_invoice_csv(payment_records, project_dir / "invoice_summary.csv")
|
||||
|
||||
# 打印分类结果
|
||||
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张")
|
||||
|
||||
if step == "invoice":
|
||||
log.info("[1/2] 发票提取 完成")
|
||||
return 0
|
||||
|
||||
# --------------------------------------------------
|
||||
# Step 2: 浏览器填报
|
||||
# --------------------------------------------------
|
||||
if step in ("all", "submit"):
|
||||
log.info("=" * 60)
|
||||
log.info("[2/2] 报销提交")
|
||||
log.info("=" * 60)
|
||||
|
||||
from .bot import load_invoice_data, run_bot
|
||||
|
||||
csv_path = project_dir / "payment_records.csv"
|
||||
bot_invoices = load_invoice_data(str(csv_path), config)
|
||||
|
||||
# 根据发票类型选择填报模式
|
||||
if groups is None:
|
||||
groups = _classify_from_csv(csv_path)
|
||||
|
||||
if groups["travel"] and not groups["general"]:
|
||||
log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||
# TODO: 差旅报销填报流程
|
||||
run_bot(config, bot_invoices)
|
||||
else:
|
||||
log.info("检测到普通发票,使用普通报销模式")
|
||||
run_bot(config, bot_invoices)
|
||||
|
||||
if step == "submit":
|
||||
log.info("[2/2] 报销提交 完成")
|
||||
return 0
|
||||
|
||||
# --------------------------------------------------
|
||||
# 全流程完成
|
||||
# --------------------------------------------------
|
||||
log.info("=" * 60)
|
||||
log.info("全流程执行完毕")
|
||||
log.info("=" * 60)
|
||||
return 0
|
||||
83
src/web/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
---
|
||||
|
||||
# src/web 模块设计说明
|
||||
|
||||
## 设计思路
|
||||
|
||||
`src/web` 是一个基于 Flask 的轻量级 Web 界面,为财务报销自动化管道提供可视化操作入口。核心设计原则:
|
||||
|
||||
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
|
||||
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
|
||||
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5,不引入构建工具,保持单页应用轻量可维护。
|
||||
- **双模式支持**:PDF 发票提取模式和 CSV 快捷上传模式,后者跳过 LLM 识别和 PDF 解析,直接处理已有发票数据。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/web/
|
||||
├── app.py # Flask 应用入口,路由、管道编排、日志收集
|
||||
├── templates/
|
||||
│ ├── index.html # PC 端主界面(上传、配置、处理、编辑、提交)
|
||||
│ └── mobile_upload.html # 移动端上传页面(拍照/相册选择)
|
||||
└── static/
|
||||
├── css/
|
||||
│ └── index.css # 全局样式(上传区、日志面板、可编辑表格)
|
||||
└── js/
|
||||
└── index.js # 前端逻辑(上传、SSE 日志、表格编辑、二维码同步)
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
用户上传文件 → 创建 session → 文件写入 uploads/<sid>/
|
||||
↓
|
||||
后台线程执行管道: extract_invoices() → enrich_with_llm() → save_csv()
|
||||
↓
|
||||
结果写入 session 目录: invoice_summary.csv / result.json / session.log
|
||||
↓
|
||||
前端 SSE 轮询 result.json 变化 → 显示完成状态
|
||||
↓
|
||||
前端加载 CSV 数据 → 可编辑表格展示 → 用户修改后保存
|
||||
↓
|
||||
用户点击提交 → run_financial_submit() → bot 自动填报财务系统
|
||||
```
|
||||
|
||||
## API 路由
|
||||
|
||||
| 方法 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 主界面 |
|
||||
| POST | `/api/session` | 创建会话,返回 session_id |
|
||||
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
|
||||
| POST | `/api/upload-csv/<sid>` | 上传 CSV 发票数据 |
|
||||
| GET | `/api/files/<sid>` | 列出会话文件 |
|
||||
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
|
||||
| GET | `/api/logs/<sid>` | SSE 日志流 |
|
||||
| GET | `/api/data/<sid>` | 获取发票数据 JSON |
|
||||
| POST | `/api/save/<sid>` | 保存前端编辑的发票数据 |
|
||||
| GET | `/api/download/<sid>/<filename>` | 下载生成文件 |
|
||||
| POST | `/api/submit-financial/<sid>` | 手动触发财务系统填报 |
|
||||
| GET | `/mobile/<sid>` | 移动端上传页面 |
|
||||
|
||||
## 关键机制
|
||||
|
||||
### 日志收集
|
||||
|
||||
`_SSELogHandler` 将管道日志写入 `session.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
|
||||
|
||||
### 发票类型分流
|
||||
|
||||
- **差旅发票**(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
||||
- **普通发票**:生成易耗品出库单(Word 文档),走普通报销流程
|
||||
|
||||
`classify_invoice_batch()` 根据发票内容自动分类,`_try_fill_consumable_doc()` 仅对普通发票生成出库单。
|
||||
|
||||
### 移动端同步
|
||||
|
||||
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的图片通过 `syncFiles()` 轮询同步到 PC 端内存中的 `imgFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
|
||||
|
||||
### 配置管理
|
||||
|
||||
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端支持通过上传 `config.json` 快速填充配置表单。
|
||||
@@ -1,14 +1,17 @@
|
||||
"""
|
||||
财务报销自动化 — Web 界面
|
||||
|
||||
用户上传 PDF 发票和支付截图,配置账号信息,自动完成:
|
||||
1. 发票提取 2. OCR 识别 3. 浏览器填报(可选)
|
||||
用户上传 PDF 发票,配置账号信息,自动完成:
|
||||
1. 发票提取 2. 浏览器填报(可选)
|
||||
|
||||
启动: python web/app.py
|
||||
发票类型区分:
|
||||
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
||||
- 普通发票:生成易耗品出库单,走普通报销流程
|
||||
|
||||
启动: uv run python src/web/app.py
|
||||
访问: http://localhost:5000
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
@@ -16,29 +19,38 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
|
||||
|
||||
# 确保项目根目录在 sys.path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from app.config import load_config as load_project_config
|
||||
from app.extractor import extract_invoices, save_csv, save_markdown
|
||||
from app.fill_consumable_doc import (
|
||||
from src import get_logger # noqa: E402, I001
|
||||
from src.config import load_config as load_project_config # noqa: E402, I001
|
||||
from src.doc.extractor import ( # noqa: E402, I001
|
||||
extract_invoices,
|
||||
)
|
||||
from src.doc.fill_consumable_doc import ( # noqa: E402, I001
|
||||
CONSUMABLE_DOC_FILENAME,
|
||||
fill_consumable_from_template,
|
||||
)
|
||||
from app import get_logger
|
||||
from app.ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv as load_ocr_csv
|
||||
from src.doc.invoice import ( # noqa: E402, I001
|
||||
classify_invoice_batch,
|
||||
load_csv,
|
||||
load_invoice_csv,
|
||||
save_csv as save_payment_csv,
|
||||
save_invoice_csv,
|
||||
)
|
||||
|
||||
fill_log = get_logger("fill_consumable_doc")
|
||||
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
|
||||
|
||||
app = Flask(__name__, template_folder="templates")
|
||||
|
||||
UPLOAD_BASE = PROJECT_ROOT / "web" / "uploads"
|
||||
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
|
||||
SESSION_LOG_FILE = "session.log"
|
||||
SESSION_RESULT_FILE = "result.json"
|
||||
|
||||
@@ -47,6 +59,7 @@ SESSION_RESULT_FILE = "result.json"
|
||||
# 日志收集器 — 捕获管道日志到文件,SSE 端点通过 tail -f 读取
|
||||
# ================================================================
|
||||
|
||||
|
||||
class _SSELogHandler(logging.Handler):
|
||||
"""将日志写入指定文件(线程安全)"""
|
||||
|
||||
@@ -55,7 +68,7 @@ class _SSELogHandler(logging.Handler):
|
||||
self._lock = threading.Lock()
|
||||
self._file = open(log_path, "w", encoding="utf-8")
|
||||
|
||||
def emit(self, record: logging.LogRecord):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
msg = self.format(record) + "\n"
|
||||
with self._lock:
|
||||
@@ -64,7 +77,7 @@ class _SSELogHandler(logging.Handler):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def close_file(self):
|
||||
def close_file(self) -> None:
|
||||
try:
|
||||
self._file.close()
|
||||
except Exception:
|
||||
@@ -82,7 +95,7 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
|
||||
handler.setFormatter(fmt)
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
for name in ["extractor", "ocr", "pipeline", "bot", "fill_consumable_doc"]:
|
||||
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(handler)
|
||||
@@ -90,8 +103,8 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
|
||||
return handler
|
||||
|
||||
|
||||
def _remove_log_collector(handler: _SSELogHandler):
|
||||
for name in ["extractor", "ocr", "pipeline", "bot", "fill_consumable_doc"]:
|
||||
def _remove_log_collector(handler: _SSELogHandler) -> None:
|
||||
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
|
||||
logging.getLogger(name).removeHandler(handler)
|
||||
handler.close_file()
|
||||
|
||||
@@ -101,7 +114,7 @@ def _remove_log_collector(handler: _SSELogHandler):
|
||||
# ================================================================
|
||||
|
||||
|
||||
def _load_session_config(session_dir: Path) -> dict:
|
||||
def _load_session_config(session_dir: Path) -> dict[str, Any]:
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
@@ -110,31 +123,95 @@ def _load_session_config(session_dir: Path) -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def _resolve_payment_csv(session_dir: Path) -> Path | None:
|
||||
"""查找支付记录 CSV(payment_records.csv)"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
if csv_path.exists():
|
||||
return csv_path
|
||||
for f in session_dir.glob("*.csv"):
|
||||
if f.name != SESSION_RESULT_FILE:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_invoice_csv(session_dir: Path) -> Path | None:
|
||||
"""查找发票级别 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"):
|
||||
return f
|
||||
if f.name != SESSION_RESULT_FILE:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _try_fill_consumable_doc(session_dir: Path, config: dict) -> dict:
|
||||
"""根据 CSV 填写易耗品出库单,供会话目录下载。"""
|
||||
def _has_general_invoices(rows: list[dict[str, str]]) -> bool:
|
||||
"""检查发票列表中是否包含普通发票(需要生成易耗品出库单)"""
|
||||
invoices = []
|
||||
for row in rows:
|
||||
# 支付记录格式:从 _invoices_json 还原
|
||||
invoices_json = row.get("_invoices_json", "")
|
||||
if invoices_json:
|
||||
try:
|
||||
invoices.extend(json.loads(invoices_json))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 发票级别格式:直接使用
|
||||
elif "发票类型" in row:
|
||||
invoices.append(row)
|
||||
if not invoices:
|
||||
return False
|
||||
groups = classify_invoice_batch(invoices)
|
||||
return len(groups["general"]) > 0
|
||||
|
||||
|
||||
def _get_invoice_groups(rows: list[dict[str, str]]) -> dict[str, int]:
|
||||
"""统计发票类型分布"""
|
||||
invoices = []
|
||||
for row in rows:
|
||||
# 支付记录格式:从 _invoices_json 还原
|
||||
invoices_json = row.get("_invoices_json", "")
|
||||
if invoices_json:
|
||||
try:
|
||||
invoices.extend(json.loads(invoices_json))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 发票级别格式:直接使用
|
||||
elif "发票类型" in row:
|
||||
invoices.append(row)
|
||||
groups = classify_invoice_batch(invoices)
|
||||
return {
|
||||
"travel_count": len(groups["travel"]),
|
||||
"general_count": len(groups["general"]),
|
||||
}
|
||||
|
||||
|
||||
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""根据 CSV 填写易耗品出库单,供会话目录下载。
|
||||
|
||||
仅当存在普通发票时才生成出库单。纯差旅发票跳过。
|
||||
"""
|
||||
if not CONSUMABLE_TEMPLATE.exists():
|
||||
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
|
||||
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
|
||||
|
||||
csv_path = _resolve_invoice_csv(session_dir)
|
||||
csv_path = _resolve_payment_csv(session_dir)
|
||||
if csv_path is None:
|
||||
return {"ok": False, "error": "未找到发票 CSV"}
|
||||
|
||||
# 检查是否有普通发票
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
return {"ok": False, "error": "CSV 读取失败"}
|
||||
|
||||
if not _has_general_invoices(rows):
|
||||
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
|
||||
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
|
||||
|
||||
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
|
||||
try:
|
||||
fill_log.info("开始填写出库单: %s", out_doc.name)
|
||||
fill_consumable_from_template(
|
||||
csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config
|
||||
)
|
||||
fill_consumable_from_template(csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config)
|
||||
fill_log.info("出库单填写完成")
|
||||
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
|
||||
except ImportError:
|
||||
@@ -145,11 +222,16 @@ def _try_fill_consumable_doc(session_dir: Path, config: dict) -> dict:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def _append_doc_download(result: dict, session_id: str, doc_fill: dict) -> None:
|
||||
def _append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
|
||||
if doc_fill.get("ok"):
|
||||
fn = doc_fill["doc_filename"]
|
||||
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||
result["doc_ok"] = True
|
||||
elif doc_fill.get("skipped"):
|
||||
# 差旅发票,跳过出库单生成(不是错误)
|
||||
result["doc_ok"] = None
|
||||
result["doc_skipped"] = True
|
||||
result["doc_message"] = doc_fill.get("error", "")
|
||||
else:
|
||||
result["doc_ok"] = False
|
||||
result["doc_error"] = doc_fill.get("error", "未知错误")
|
||||
@@ -159,78 +241,110 @@ def _append_doc_download(result: dict, session_id: str, doc_fill: dict) -> None:
|
||||
# 管道入口
|
||||
# ================================================================
|
||||
|
||||
def run_pipeline_web(session_dir: Path, config: dict):
|
||||
"""在 Web 会话目录中执行提取+OCR,结果写入 session 目录下的文件
|
||||
|
||||
|
||||
def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
|
||||
|
||||
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
# ---- Step 1: 发票提取 ----
|
||||
invoices = extract_invoices(str(session_dir))
|
||||
invoices, groups = extract_invoices(str(session_dir))
|
||||
if not invoices:
|
||||
return {"ok": False, "error": "未提取到任何发票数据"}
|
||||
|
||||
save_csv(invoices, session_dir / "invoice_summary.csv")
|
||||
save_markdown(invoices, session_dir / "invoice_summary.md")
|
||||
# 保存两个 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
|
||||
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
||||
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
|
||||
|
||||
# ---- Step 2: OCR 识别 ----
|
||||
csv_path = session_dir / "invoice_summary.csv"
|
||||
rows = load_ocr_csv(csv_path)
|
||||
if rows is None:
|
||||
return {"ok": False, "error": "CSV 读取失败"}
|
||||
|
||||
rows = enrich_with_ocr(rows, str(session_dir))
|
||||
save_ocr_csv(csv_path, rows)
|
||||
save_markdown_from_csv(csv_path, rows)
|
||||
# 统计发票总数
|
||||
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
|
||||
|
||||
elapsed = time.time() - start
|
||||
result = {
|
||||
"ok": True,
|
||||
"elapsed": f"{elapsed:.1f}s",
|
||||
"invoice_count": len(rows),
|
||||
"invoice_count": invoice_count,
|
||||
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
|
||||
"md_url": f"/api/download/{session_dir.name}/invoice_summary.md",
|
||||
# 发票类型统计
|
||||
"travel_count": len(groups["travel"]),
|
||||
"general_count": len(groups["general"]),
|
||||
}
|
||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||
_append_doc_download(result, session_dir.name, doc_fill)
|
||||
return result
|
||||
|
||||
|
||||
def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str):
|
||||
"""直接使用上传的 CSV 文件,跳过 PDF 提取和 OCR"""
|
||||
def run_csv_pipeline_web(session_dir: Path, config: dict[str, Any], csv_filename: str) -> dict[str, Any]:
|
||||
"""直接使用上传的 CSV 文件,跳过 PDF 提取"""
|
||||
start = time.time()
|
||||
|
||||
csv_path = session_dir / csv_filename
|
||||
if not csv_path.exists():
|
||||
return {"ok": False, "error": "CSV 文件不存在"}
|
||||
|
||||
# 读取 CSV 行数
|
||||
rows = load_ocr_csv(csv_path)
|
||||
# 尝试读取支付记录格式
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
return {"ok": False, "error": "CSV 读取失败"}
|
||||
# 尝试读取发票级别格式
|
||||
invoice_rows = load_invoice_csv(csv_path)
|
||||
if invoice_rows is None:
|
||||
return {"ok": False, "error": "CSV 读取失败"}
|
||||
# 发票级别格式:直接统计
|
||||
type_stats = _get_invoice_groups(invoice_rows)
|
||||
elapsed = time.time() - start
|
||||
return {
|
||||
"ok": True,
|
||||
"elapsed": f"{elapsed:.1f}s",
|
||||
"invoice_count": len(invoice_rows),
|
||||
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
|
||||
"travel_count": type_stats["travel_count"],
|
||||
"general_count": type_stats["general_count"],
|
||||
}
|
||||
|
||||
# 支付记录格式:统计发票类型
|
||||
type_stats = _get_invoice_groups(rows)
|
||||
|
||||
elapsed = time.time() - start
|
||||
result = {
|
||||
"ok": True,
|
||||
"elapsed": f"{elapsed:.1f}s",
|
||||
"invoice_count": len(rows),
|
||||
"invoice_count": type_stats["travel_count"] + type_stats["general_count"],
|
||||
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
|
||||
"travel_count": type_stats["travel_count"],
|
||||
"general_count": type_stats["general_count"],
|
||||
}
|
||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||
_append_doc_download(result, session_dir.name, doc_fill)
|
||||
return result
|
||||
|
||||
|
||||
def run_financial_submit(session_dir: Path, config: dict) -> dict:
|
||||
"""执行财务系统填报(从前端确认后调用)"""
|
||||
csv_path = session_dir / "invoice_summary.csv"
|
||||
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""执行财务系统填报(从前端确认后调用)
|
||||
|
||||
根据发票类型选择填报模式:
|
||||
- 纯差旅发票:差旅报销模式(TODO)
|
||||
- 含普通发票:普通报销模式
|
||||
"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
if not csv_path.exists():
|
||||
return {"ok": False, "error": "未找到发票数据,请先处理"}
|
||||
|
||||
from app.bot import load_invoice_data, run_bot_web
|
||||
from src.bot import load_invoice_data, run_bot_web
|
||||
|
||||
bot_invoices = load_invoice_data(str(csv_path), config)
|
||||
|
||||
# 判断发票类型
|
||||
rows = load_csv(csv_path)
|
||||
if rows:
|
||||
invoice_groups = _get_invoice_groups(rows)
|
||||
if invoice_groups["travel_count"] and not invoice_groups["general_count"]:
|
||||
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||
# TODO: 差旅报销填报流程
|
||||
else:
|
||||
fill_log.info("检测到普通发票,使用普通报销模式")
|
||||
|
||||
run_bot_web(config, bot_invoices, session_dir)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -239,13 +353,14 @@ def run_financial_submit(session_dir: Path, config: dict) -> dict:
|
||||
# Flask 路由
|
||||
# ================================================================
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
def index() -> Any:
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/session", methods=["POST"])
|
||||
def create_session():
|
||||
def create_session() -> Any:
|
||||
"""创建上传会话,返回 session_id"""
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
session_dir = UPLOAD_BASE / sid
|
||||
@@ -254,7 +369,7 @@ def create_session():
|
||||
|
||||
|
||||
@app.route("/api/upload/<session_id>", methods=["POST"])
|
||||
def upload_file(session_id: str):
|
||||
def upload_file(session_id: str) -> Any:
|
||||
"""上传 PDF 或图片"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
@@ -270,8 +385,8 @@ def upload_file(session_id: str):
|
||||
|
||||
|
||||
@app.route("/api/upload-csv/<session_id>", methods=["POST"])
|
||||
def upload_csv(session_id: str):
|
||||
"""上传 CSV 发票数据文件(跳过 PDF 提取和 OCR)"""
|
||||
def upload_csv(session_id: str) -> Any:
|
||||
"""上传 CSV 发票数据文件(跳过 PDF 提取)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
@@ -286,23 +401,20 @@ def upload_csv(session_id: str):
|
||||
|
||||
|
||||
@app.route("/api/files/<session_id>", methods=["GET"])
|
||||
def list_files(session_id: str):
|
||||
def list_files(session_id: str) -> Any:
|
||||
"""列出会话目录中的文件"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
|
||||
imgs = sorted(
|
||||
f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
||||
for f in session_dir.glob(f"*{ext}")
|
||||
)
|
||||
imgs = sorted(f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"} for f in session_dir.glob(f"*{ext}"))
|
||||
return jsonify({"pdfs": pdfs, "images": imgs})
|
||||
|
||||
|
||||
@app.route("/api/process/<session_id>", methods=["POST"])
|
||||
def start_process(session_id: str):
|
||||
"""启动管道处理(仅提取+OCR,不自动提交财务系统)"""
|
||||
def start_process(session_id: str) -> Any:
|
||||
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
@@ -320,7 +432,7 @@ def start_process(session_id: str):
|
||||
# 在后台线程执行
|
||||
handler = _install_log_collector(session_dir)
|
||||
|
||||
def _run():
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
if mode == "csv":
|
||||
@@ -338,7 +450,7 @@ def start_process(session_id: str):
|
||||
result = run_pipeline_web(session_dir, config)
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, (KeyboardInterrupt, SystemExit)):
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
@@ -356,13 +468,13 @@ def start_process(session_id: str):
|
||||
|
||||
|
||||
@app.route("/api/logs/<session_id>")
|
||||
def stream_logs(session_id: str):
|
||||
def stream_logs(session_id: str) -> Any:
|
||||
"""SSE 日志流"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
def generate():
|
||||
def generate() -> Any:
|
||||
# 先发送已有日志
|
||||
log_file = session_dir / SESSION_LOG_FILE
|
||||
last_size = 0
|
||||
@@ -399,7 +511,7 @@ def stream_logs(session_id: str):
|
||||
|
||||
|
||||
@app.route("/api/download/<session_id>/<filename>")
|
||||
def download_file(session_id: str, filename: str):
|
||||
def download_file(session_id: str, filename: str) -> Any:
|
||||
"""下载生成的文件"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
@@ -415,8 +527,6 @@ def download_file(session_id: str, filename: str):
|
||||
mimetype = "application/msword"
|
||||
elif safe_name.endswith(".csv"):
|
||||
mimetype = "text/csv; charset=utf-8"
|
||||
elif safe_name.endswith(".md"):
|
||||
mimetype = "text/markdown; charset=utf-8"
|
||||
else:
|
||||
mimetype = "application/octet-stream"
|
||||
|
||||
@@ -428,42 +538,86 @@ def download_file(session_id: str, filename: str):
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/config/<session_id>", methods=["GET"])
|
||||
def get_session_config(session_id: str) -> Any:
|
||||
"""获取当前会话的配置(供前端回填表单)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
config.update(json.load(f))
|
||||
# 只返回前端需要的字段
|
||||
return jsonify(
|
||||
{
|
||||
"username": config.get("username", ""),
|
||||
"password": "", # 不返回密码
|
||||
"default_name": config.get("default_name", ""),
|
||||
"default_card_no": config.get("default_card_no", ""),
|
||||
"default_person_id": config.get("default_person_id", ""),
|
||||
"consumable_storage": config.get("consumable_storage", ""),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/data/<session_id>", methods=["GET"])
|
||||
def get_invoice_data(session_id: str):
|
||||
def get_invoice_data(session_id: str) -> Any:
|
||||
"""读取发票数据并返回 JSON(供前端表格编辑)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
csv_path = session_dir / "invoice_summary.csv"
|
||||
if not csv_path.exists():
|
||||
# CSV 模式下可能是其他文件名
|
||||
csv_files = list(session_dir.glob("*.csv"))
|
||||
csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
|
||||
if csv_files:
|
||||
csv_path = csv_files[0]
|
||||
else:
|
||||
return jsonify({"error": "未找到发票数据,请先处理"}), 404
|
||||
# 优先读取支付记录 CSV
|
||||
payment_csv = session_dir / "payment_records.csv"
|
||||
if payment_csv.exists():
|
||||
rows = load_csv(payment_csv)
|
||||
if rows is not None:
|
||||
data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry: dict[str, Any] = dict(row)
|
||||
entry["__row"] = i
|
||||
data.append(entry)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
|
||||
|
||||
rows = load_ocr_csv(csv_path)
|
||||
if rows is None:
|
||||
return jsonify({"error": "CSV 读取失败"}), 500
|
||||
# 回退到发票级别 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})
|
||||
|
||||
# 添加行号用于编辑追踪
|
||||
data = []
|
||||
for i, row in enumerate(rows):
|
||||
entry = dict(row)
|
||||
entry["__row"] = i
|
||||
data.append(entry)
|
||||
# 最后尝试任意 CSV
|
||||
csv_files = list(session_dir.glob("*.csv"))
|
||||
csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
|
||||
if csv_files:
|
||||
csv_path = csv_files[0]
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
rows = load_invoice_csv(csv_path)
|
||||
if rows is not None:
|
||||
fallback_data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry3: dict[str, Any] = dict(row)
|
||||
entry3["__row"] = i
|
||||
fallback_data.append(entry3)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
|
||||
|
||||
# 返回原始字段顺序(去掉内部字段)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith('__')] if rows else []
|
||||
|
||||
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": data})
|
||||
return jsonify({"error": "未找到发票数据,请先处理"}), 404
|
||||
|
||||
|
||||
@app.route("/api/save/<session_id>", methods=["POST"])
|
||||
def save_invoice_data(session_id: str):
|
||||
def save_invoice_data(session_id: str) -> Any:
|
||||
"""保存前端编辑后的发票数据到 CSV"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
@@ -478,7 +632,7 @@ def save_invoice_data(session_id: str):
|
||||
return jsonify({"error": "CSV 文件不存在"}), 404
|
||||
|
||||
# 读取原 CSV 获取字段顺序(使用第一个数据的 keys)
|
||||
original_rows = load_ocr_csv(csv_path)
|
||||
original_rows = load_csv(csv_path)
|
||||
if original_rows is None or len(original_rows) == 0:
|
||||
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
|
||||
|
||||
@@ -494,21 +648,24 @@ def save_invoice_data(session_id: str):
|
||||
row = {k: entry.get(k, "") for k in fieldnames}
|
||||
writer.writerow(row)
|
||||
|
||||
resp = {"ok": True}
|
||||
resp: dict[str, str | bool | None] = {"ok": True}
|
||||
config = _load_session_config(session_dir)
|
||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||
if doc_fill.get("ok"):
|
||||
fn = doc_fill["doc_filename"]
|
||||
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||
resp["doc_ok"] = True
|
||||
elif doc_fill.get("skipped"):
|
||||
resp["doc_ok"] = None
|
||||
resp["doc_skipped"] = True
|
||||
else:
|
||||
resp["doc_ok"] = False
|
||||
resp["doc_error"] = doc_fill.get("error")
|
||||
resp["doc_error"] = doc_fill.get("error") or ""
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@app.route("/api/submit-financial/<session_id>", methods=["POST"])
|
||||
def submit_financial(session_id: str):
|
||||
def submit_financial(session_id: str) -> Any:
|
||||
"""手动触发财务系统填报"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
@@ -530,7 +687,7 @@ def submit_financial(session_id: str):
|
||||
# 在后台线程执行提交
|
||||
handler = _install_log_collector(session_dir)
|
||||
|
||||
def _run():
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
submit_result = run_financial_submit(session_dir, config)
|
||||
@@ -540,13 +697,17 @@ def submit_financial(session_id: str):
|
||||
result = submit_result
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, (KeyboardInterrupt, SystemExit)):
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")}, f, ensure_ascii=False)
|
||||
json.dump(
|
||||
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -561,14 +722,15 @@ def submit_financial(session_id: str):
|
||||
# 辅助函数
|
||||
# ================================================================
|
||||
|
||||
def _validate_session(session_id: str):
|
||||
|
||||
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
|
||||
session_dir = UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
return jsonify({"error": "会话不存在"}), 404
|
||||
return session_dir
|
||||
|
||||
|
||||
def _build_web_config(body: dict) -> dict:
|
||||
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""从请求体构建配置"""
|
||||
config = load_project_config()
|
||||
for key in (
|
||||
@@ -590,7 +752,7 @@ def _escape_sse(text: str) -> str:
|
||||
|
||||
|
||||
@app.route("/mobile/<session_id>")
|
||||
def mobile_upload(session_id: str):
|
||||
def mobile_upload(session_id: str) -> Any:
|
||||
"""移动端上传页面"""
|
||||
session_dir = UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
@@ -599,12 +761,12 @@ def mobile_upload(session_id: str):
|
||||
|
||||
|
||||
@app.route("/api/mobile-upload/<session_id>", methods=["POST"])
|
||||
def mobile_upload_file(session_id: str):
|
||||
def mobile_upload_file(session_id: str) -> Any:
|
||||
"""移动端上传图片(复用 PC 上传逻辑)"""
|
||||
return upload_file(session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
|
||||
print(f"启动 Web 服务: http://localhost:5000")
|
||||
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)
|
||||
print("启动 Web 服务: http://localhost:5000")
|
||||
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)
|
||||
@@ -238,7 +238,6 @@ function showDownloadLinks(result) {
|
||||
|
||||
const items = [];
|
||||
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
|
||||
if (result.md_url) items.push({ label: 'invoice_summary.md', url: result.md_url });
|
||||
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
|
||||
|
||||
lastDownloadUrls = {};
|
||||
@@ -369,7 +368,6 @@ async function saveInvoiceData() {
|
||||
if (d.doc_url || d.doc_ok === false) {
|
||||
showDownloadLinks({
|
||||
csv_url: lastDownloadUrls['invoice_summary.csv'],
|
||||
md_url: lastDownloadUrls['invoice_summary.md'],
|
||||
doc_url: d.doc_url,
|
||||
doc_ok: d.doc_ok,
|
||||
doc_error: d.doc_error,
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class="header text-center mb-4">
|
||||
<h3>财务报销自动化</h3>
|
||||
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、OCR 识别并填报</p>
|
||||
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、LLM 识别并填报</p>
|
||||
</div>
|
||||
|
||||
<div class="container" style="max-width:960px">
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<!-- CSV 快捷上传 -->
|
||||
<div class="mb-4">
|
||||
<div class="section-title">📊 CSV 快捷上传 <span class="text-muted fw-normal" style="font-size:12px">(已有发票数据 CSV 可直接上传,跳过提取和 OCR)</span></div>
|
||||
<div class="section-title">📊 CSV 快捷上传 <span class="text-muted fw-normal" style="font-size:12px">(已有发票数据 CSV 可直接上传,跳过提取和 LLM 识别)</span></div>
|
||||
<div class="upload-zone" id="csv-zone" onclick="document.getElementById('csv-input').click()">
|
||||
<div class="icon">📊</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传 CSV 文件</div>
|
||||
BIN
src/web/uploads/af9065343bc3/26349119343000331138-电子发票.pdf
Normal file
BIN
src/web/uploads/af9065343bc3/26349119343000331314-电子发票.pdf
Normal file
BIN
src/web/uploads/af9065343bc3/26349119343000335414-电子发票.pdf
Normal file
BIN
src/web/uploads/af9065343bc3/26349119423003550275-电子发票.pdf
Normal file
BIN
src/web/uploads/af9065343bc3/26349119423003552366-电子发票.pdf
Normal file
BIN
src/web/uploads/af9065343bc3/26349119423003595208-电子发票.pdf
Normal file
13
src/web/uploads/af9065343bc3/config.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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",
|
||||
"username": "202407021",
|
||||
"password": "wang!1624155937",
|
||||
"default_name": "王建锋",
|
||||
"default_card_no": "6282880139161682",
|
||||
"default_person_id": "202407021",
|
||||
"consumable_storage": "新工科 D605",
|
||||
"attachment_dir": "D:\\阜阳师范大学\\财务报销\\自动报销系统\\attachments"
|
||||
}
|
||||
8
src/web/uploads/af9065343bc3/invoice_summary.csv
Normal file
@@ -0,0 +1,8 @@
|
||||
序号,发票类型,发票号码,开票日期,项目名称,规格型号,价税合计,销售方名称,出发站,到达站,车次,乘车日期,座位等级,人员姓名,刷卡日期,公务卡号,刷卡金额,备注,工号
|
||||
1,高铁票,26349119423003552366,2026/6/3,,,189.50,,合肥南,阜阳西,G1318,2026/6/3,一等座,陈曙光,2026/6/3,6282****1682,189.5,,
|
||||
2,高铁票,26349119343000331314,2026/6/3,,,167.00,,阜阳西,合肥南,G1967,2026/6/2,一等座,陈曙光,2026/6/1,6282****1682,167.0,,
|
||||
3,酒店住宿,26342000001715702281,2026/6/3,,,536.00,,,,,,,,2026/6/2,6282****1682,535.86,,
|
||||
4,高铁票,26349119423003550275,2026/6/3,,,117.50,,合肥南,阜阳西,G1318,2026/6/3,二等座,张国庆,2026/6/3,6282****1682,235.0,,
|
||||
5,高铁票,26349119423003595208,2026/6/5,,,117.50,,合肥南,阜阳西,G1318,2026/6/3,二等座,王建锋,2026/6/3,6282****1682,235.0,,
|
||||
6,高铁票,26349119343000331138,2026/6/3,,,115.50,,阜阳西,合肥南,G1967,2026/6/2,二等座,张国庆,2026/6/1,6282****1682,231.0,,
|
||||
7,高铁票,26349119343000335414,2026/6/5,,,115.50,,阜阳西,合肥南,G1967,2026/6/2,二等座,王建锋,2026/6/1,6282****1682,231.0,,
|
||||
|
6
src/web/uploads/af9065343bc3/payment_records.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
序号,刷卡日期,公务卡号,刷卡金额,关联发票数,发票详情,备注,_invoices_json,工号
|
||||
1,2026/6/3,6282****1682,189.5,1,高铁票[陈曙光]¥189.50,,"[{""发票类型"": ""高铁票"", ""发票号码"": ""26349119423003552366"", ""开票日期"": ""2026/6/3"", ""乘车日期"": ""2026/6/3"", ""出发站"": ""合肥南"", ""到达站"": ""阜阳西"", ""座位等级"": ""一等座"", ""车次"": ""G1318"", ""人员姓名"": ""陈曙光"", ""价税合计"": ""189.50""}]",
|
||||
2,2026/6/1,6282****1682,167.0,1,高铁票[陈曙光]¥167.00,,"[{""发票类型"": ""高铁票"", ""发票号码"": ""26349119343000331314"", ""开票日期"": ""2026/6/3"", ""乘车日期"": ""2026/6/2"", ""出发站"": ""阜阳西"", ""到达站"": ""合肥南"", ""座位等级"": ""一等座"", ""车次"": ""G1967"", ""人员姓名"": ""陈曙光"", ""价税合计"": ""167.00""}]",
|
||||
3,2026/6/2,6282****1682,535.86,1,酒店住宿[26342000001715702281]¥536.00,,"[{""发票类型"": ""酒店住宿"", ""发票号码"": ""26342000001715702281"", ""开票日期"": ""2026/6/3"", ""价税合计"": ""536.00""}]",
|
||||
4,2026/6/3,6282****1682,235.0,2,高铁票[张国庆]¥117.50 | 高铁票[王建锋]¥117.50,,"[{""发票类型"": ""高铁票"", ""发票号码"": ""26349119423003550275"", ""开票日期"": ""2026/6/3"", ""乘车日期"": ""2026/6/3"", ""出发站"": ""合肥南"", ""到达站"": ""阜阳西"", ""座位等级"": ""二等座"", ""车次"": ""G1318"", ""人员姓名"": ""张国庆"", ""价税合计"": ""117.50""}, {""发票类型"": ""高铁票"", ""发票号码"": ""26349119423003595208"", ""开票日期"": ""2026/6/5"", ""乘车日期"": ""2026/6/3"", ""出发站"": ""合肥南"", ""到达站"": ""阜阳西"", ""座位等级"": ""二等座"", ""车次"": ""G1318"", ""人员姓名"": ""王建锋"", ""价税合计"": ""117.50""}]",
|
||||
5,2026/6/1,6282****1682,231.0,2,高铁票[张国庆]¥115.50 | 高铁票[王建锋]¥115.50,,"[{""发票类型"": ""高铁票"", ""发票号码"": ""26349119343000331138"", ""开票日期"": ""2026/6/3"", ""乘车日期"": ""2026/6/2"", ""出发站"": ""阜阳西"", ""到达站"": ""合肥南"", ""座位等级"": ""二等座"", ""车次"": ""G1967"", ""人员姓名"": ""张国庆"", ""价税合计"": ""115.50""}, {""发票类型"": ""高铁票"", ""发票号码"": ""26349119343000335414"", ""开票日期"": ""2026/6/5"", ""乘车日期"": ""2026/6/2"", ""出发站"": ""阜阳西"", ""到达站"": ""合肥南"", ""座位等级"": ""二等座"", ""车次"": ""G1967"", ""人员姓名"": ""王建锋"", ""价税合计"": ""115.50""}]",
|
||||
|
1
src/web/uploads/af9065343bc3/result.json
Normal file
@@ -0,0 +1 @@
|
||||
{"ok": true, "elapsed": "30.3s", "invoice_count": 7, "csv_url": "/api/download/af9065343bc3/invoice_summary.csv", "travel_count": 7, "general_count": 0, "doc_ok": null, "doc_skipped": true, "doc_message": "差旅发票无需生成易耗品出库单"}
|
||||
166
src/web/uploads/af9065343bc3/session.log
Normal file
@@ -0,0 +1,166 @@
|
||||
2026-06-09 16:19:41 [INFO ] extractor: 发现 7 个 PDF 文件
|
||||
2026-06-09 16:19:44 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:19:47 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符
|
||||
2026-06-09 16:19:47 [INFO ] llm_extractor: LLM 响应: {
|
||||
"发票类型": "高铁票",
|
||||
"发票号码": "26349119343000331138",
|
||||
"开票日期": "2026/6/3",
|
||||
"乘车日期": "2026/6/2",
|
||||
"出发站": "阜阳西",
|
||||
"到达站": "合肥南",
|
||||
"座位等级": "二等座",
|
||||
"车次": "G1967",
|
||||
"人员姓名": "张国庆",
|
||||
"价税合计": "115.50"
|
||||
}
|
||||
2026-06-09 16:19:47 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331138-电子发票.pdf
|
||||
2026-06-09 16:19:47 [INFO ] extractor: [高铁票] 已解析: 26349119343000331138-电子发票.pdf
|
||||
2026-06-09 16:19:47 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:19:49 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符
|
||||
2026-06-09 16:19:49 [INFO ] llm_extractor: LLM 响应: {
|
||||
"发票类型": "高铁票",
|
||||
"发票号码": "26349119343000331314",
|
||||
"开票日期": "2026/6/3",
|
||||
"乘车日期": "2026/6/2",
|
||||
"出发站": "阜阳西",
|
||||
"到达站": "合肥南",
|
||||
"座位等级": "一等座",
|
||||
"车次": "G1967",
|
||||
"人员姓名": "陈曙光",
|
||||
"价税合计": "167.00"
|
||||
}
|
||||
2026-06-09 16:19:49 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331314-电子发票.pdf
|
||||
2026-06-09 16:19:49 [INFO ] extractor: [高铁票] 已解析: 26349119343000331314-电子发票.pdf
|
||||
2026-06-09 16:19:49 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:19:52 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符
|
||||
2026-06-09 16:19:52 [INFO ] llm_extractor: LLM 响应: {
|
||||
"发票类型": "高铁票",
|
||||
"发票号码": "26349119343000335414",
|
||||
"开票日期": "2026/6/5",
|
||||
"乘车日期": "2026/6/2",
|
||||
"出发站": "阜阳西",
|
||||
"到达站": "合肥南",
|
||||
"座位等级": "二等座",
|
||||
"车次": "G1967",
|
||||
"人员姓名": "王建锋",
|
||||
"价税合计": "115.50"
|
||||
}
|
||||
2026-06-09 16:19:52 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000335414-电子发票.pdf
|
||||
2026-06-09 16:19:52 [INFO ] extractor: [高铁票] 已解析: 26349119343000335414-电子发票.pdf
|
||||
2026-06-09 16:19:52 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:19:54 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符
|
||||
2026-06-09 16:19:54 [INFO ] llm_extractor: LLM 响应: {
|
||||
"发票类型": "高铁票",
|
||||
"发票号码": "26349119423003550275",
|
||||
"开票日期": "2026/6/3",
|
||||
"乘车日期": "2026/6/3",
|
||||
"出发站": "合肥南",
|
||||
"到达站": "阜阳西",
|
||||
"座位等级": "二等座",
|
||||
"车次": "G1318",
|
||||
"人员姓名": "张国庆",
|
||||
"价税合计": "117.50"
|
||||
}
|
||||
2026-06-09 16:19:54 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003550275-电子发票.pdf
|
||||
2026-06-09 16:19:54 [INFO ] extractor: [高铁票] 已解析: 26349119423003550275-电子发票.pdf
|
||||
2026-06-09 16:19:54 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:19:56 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符
|
||||
2026-06-09 16:19:56 [INFO ] llm_extractor: LLM 响应: {
|
||||
"发票类型": "高铁票",
|
||||
"发票号码": "26349119423003552366",
|
||||
"开票日期": "2026/6/3",
|
||||
"乘车日期": "2026/6/3",
|
||||
"出发站": "合肥南",
|
||||
"到达站": "阜阳西",
|
||||
"座位等级": "一等座",
|
||||
"车次": "G1318",
|
||||
"人员姓名": "陈曙光",
|
||||
"价税合计": "189.50"
|
||||
}
|
||||
2026-06-09 16:19:56 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003552366-电子发票.pdf
|
||||
2026-06-09 16:19:56 [INFO ] extractor: [高铁票] 已解析: 26349119423003552366-电子发票.pdf
|
||||
2026-06-09 16:19:56 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:19:59 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符
|
||||
2026-06-09 16:19:59 [INFO ] llm_extractor: LLM 响应: {
|
||||
"发票类型": "高铁票",
|
||||
"发票号码": "26349119423003595208",
|
||||
"开票日期": "2026/6/5",
|
||||
"乘车日期": "2026/6/3",
|
||||
"出发站": "合肥南",
|
||||
"到达站": "阜阳西",
|
||||
"座位等级": "二等座",
|
||||
"车次": "G1318",
|
||||
"人员姓名": "王建锋",
|
||||
"价税合计": "117.50"
|
||||
}
|
||||
2026-06-09 16:19:59 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003595208-电子发票.pdf
|
||||
2026-06-09 16:19:59 [INFO ] extractor: [高铁票] 已解析: 26349119423003595208-电子发票.pdf
|
||||
2026-06-09 16:19:59 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:20:00 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 96 字符
|
||||
2026-06-09 16:20:00 [INFO ] llm_extractor: LLM 响应: {
|
||||
"发票类型": "酒店住宿",
|
||||
"发票号码": "26342000001715702281",
|
||||
"开票日期": "2026/6/3",
|
||||
"价税合计": "536.00"
|
||||
}
|
||||
2026-06-09 16:20:00 [INFO ] llm_extractor: LLM 发票提取成功: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf
|
||||
2026-06-09 16:20:00 [INFO ] extractor: [酒店住宿] 已解析: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf
|
||||
2026-06-09 16:20:00 [INFO ] extractor: 共处理 7 张发票
|
||||
2026-06-09 16:20:00 [INFO ] matcher: 发现 5 张支付截图
|
||||
2026-06-09 16:20:01 [INFO ] llm_extractor: 开始请求 LLM 多模态 (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:20:03 [INFO ] llm_extractor: LLM 多模态请求完成,响应总长度: 70 字符
|
||||
2026-06-09 16:20:03 [INFO ] llm_extractor: LLM 多模态响应: {
|
||||
"刷卡日期": "2026/6/1",
|
||||
"刷卡金额": "231.00",
|
||||
"公务卡号": "6282****1682"
|
||||
}
|
||||
2026-06-09 16:20:03 [INFO ] llm_extractor: LLM 支付截图提取成功: 微信图片_20260608145318_279_42.jpg
|
||||
2026-06-09 16:20:03 [INFO ] matcher: [支付截图] 已解析: 微信图片_20260608145318_279_42.jpg
|
||||
2026-06-09 16:20:03 [INFO ] llm_extractor: 开始请求 LLM 多模态 (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:20:05 [INFO ] llm_extractor: LLM 多模态请求完成,响应总长度: 70 字符
|
||||
2026-06-09 16:20:05 [INFO ] llm_extractor: LLM 多模态响应: {
|
||||
"刷卡日期": "2026/6/1",
|
||||
"刷卡金额": "167.00",
|
||||
"公务卡号": "6282****1682"
|
||||
}
|
||||
2026-06-09 16:20:05 [INFO ] llm_extractor: LLM 支付截图提取成功: 微信图片_20260608145319_280_42.jpg
|
||||
2026-06-09 16:20:05 [INFO ] matcher: [支付截图] 已解析: 微信图片_20260608145319_280_42.jpg
|
||||
2026-06-09 16:20:05 [INFO ] llm_extractor: 开始请求 LLM 多模态 (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:20:07 [INFO ] llm_extractor: LLM 多模态请求完成,响应总长度: 70 字符
|
||||
2026-06-09 16:20:07 [INFO ] llm_extractor: LLM 多模态响应: {
|
||||
"刷卡日期": "2026/6/2",
|
||||
"刷卡金额": "535.86",
|
||||
"公务卡号": "6282****1682"
|
||||
}
|
||||
2026-06-09 16:20:07 [INFO ] llm_extractor: LLM 支付截图提取成功: 微信图片_20260608145320_281_42.jpg
|
||||
2026-06-09 16:20:07 [INFO ] matcher: [支付截图] 已解析: 微信图片_20260608145320_281_42.jpg
|
||||
2026-06-09 16:20:07 [INFO ] llm_extractor: 开始请求 LLM 多模态 (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:20:09 [INFO ] llm_extractor: LLM 多模态请求完成,响应总长度: 70 字符
|
||||
2026-06-09 16:20:09 [INFO ] llm_extractor: LLM 多模态响应: {
|
||||
"刷卡日期": "2026/6/3",
|
||||
"刷卡金额": "189.50",
|
||||
"公务卡号": "6282****1682"
|
||||
}
|
||||
2026-06-09 16:20:09 [INFO ] llm_extractor: LLM 支付截图提取成功: 微信图片_20260608145321_282_42.jpg
|
||||
2026-06-09 16:20:09 [INFO ] matcher: [支付截图] 已解析: 微信图片_20260608145321_282_42.jpg
|
||||
2026-06-09 16:20:09 [INFO ] llm_extractor: 开始请求 LLM 多模态 (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:20:11 [INFO ] llm_extractor: LLM 多模态请求完成,响应总长度: 70 字符
|
||||
2026-06-09 16:20:11 [INFO ] llm_extractor: LLM 多模态响应: {
|
||||
"刷卡日期": "2026/6/3",
|
||||
"刷卡金额": "235.00",
|
||||
"公务卡号": "6282****1682"
|
||||
}
|
||||
2026-06-09 16:20:11 [INFO ] llm_extractor: LLM 支付截图提取成功: 微信图片_20260608145323_283_42.jpg
|
||||
2026-06-09 16:20:11 [INFO ] matcher: [支付截图] 已解析: 微信图片_20260608145323_283_42.jpg
|
||||
2026-06-09 16:20:11 [INFO ] matcher: 共提取 5 条刷卡记录
|
||||
2026-06-09 16:20:11 [INFO ] matcher: 金额校验: 发票总额 ¥1358.50, 刷卡总额 ¥1358.36, 发票数 7, 刷卡数 5
|
||||
2026-06-09 16:20:11 [INFO ] matcher: [一对多-精确] 26349119423003552366 ¥189.50 ↔ 微信图片_20260608145321_282_42.jpg ¥189.50
|
||||
2026-06-09 16:20:11 [INFO ] matcher: [一对多-精确] 26349119343000331314 ¥167.00 ↔ 微信图片_20260608145319_280_42.jpg ¥167.00
|
||||
2026-06-09 16:20:11 [INFO ] matcher: [一对多-贪心] 26342000001715702281 ¥536.00 → 微信图片_20260608145320_281_42.jpg ¥535.86
|
||||
2026-06-09 16:20:11 [INFO ] matcher: [一对多-贪心] 26349119423003550275 ¥117.50 → 微信图片_20260608145323_283_42.jpg ¥235.00
|
||||
2026-06-09 16:20:11 [INFO ] matcher: [一对多-贪心] 26349119423003595208 ¥117.50 → 微信图片_20260608145323_283_42.jpg ¥235.00
|
||||
2026-06-09 16:20:11 [INFO ] matcher: [一对多-贪心] 26349119343000331138 ¥115.50 → 微信图片_20260608145318_279_42.jpg ¥231.00
|
||||
2026-06-09 16:20:11 [INFO ] matcher: [一对多-贪心] 26349119343000335414 ¥115.50 → 微信图片_20260608145318_279_42.jpg ¥231.00
|
||||
2026-06-09 16:20:11 [INFO ] matcher: 匹配完成: 5 条支付记录, 7/7 张发票已关联
|
||||
2026-06-09 16:20:11 [INFO ] extractor: 差旅发票: 7 张, 普通发票: 0 张
|
||||
2026-06-09 16:20:11 [INFO ] fill_consumable_doc: 纯差旅发票,跳过易耗品出库单生成
|
||||
BIN
src/web/uploads/af9065343bc3/微信图片_20260608145318_279_42.jpg
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
src/web/uploads/af9065343bc3/微信图片_20260608145319_280_42.jpg
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
src/web/uploads/af9065343bc3/微信图片_20260608145320_281_42.jpg
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
src/web/uploads/af9065343bc3/微信图片_20260608145321_282_42.jpg
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
src/web/uploads/af9065343bc3/微信图片_20260608145323_283_42.jpg
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
src/web/uploads/c913da2afca0/26349119343000331138-电子发票.pdf
Normal file
BIN
src/web/uploads/c913da2afca0/26349119343000331314-电子发票.pdf
Normal file
BIN
src/web/uploads/c913da2afca0/26349119343000335414-电子发票.pdf
Normal file
BIN
src/web/uploads/c913da2afca0/26349119423003550275-电子发票.pdf
Normal file
BIN
src/web/uploads/c913da2afca0/26349119423003552366-电子发票.pdf
Normal file
BIN
src/web/uploads/c913da2afca0/26349119423003595208-电子发票.pdf
Normal file
13
src/web/uploads/c913da2afca0/config.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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",
|
||||
"username": "202407021",
|
||||
"password": "wang!1624155937",
|
||||
"default_name": "王建锋",
|
||||
"default_card_no": "6282880139161682",
|
||||
"default_person_id": "202407021",
|
||||
"consumable_storage": "新工科 D605",
|
||||
"attachment_dir": "D:\\阜阳师范大学\\财务报销\\自动报销系统\\attachments"
|
||||
}
|
||||
1
src/web/uploads/c913da2afca0/result.json
Normal file
@@ -0,0 +1 @@
|
||||
{"ok": false, "error": "Error code: 502"}
|
||||
4
src/web/uploads/c913da2afca0/session.log
Normal file
@@ -0,0 +1,4 @@
|
||||
2026-06-09 16:09:29 [INFO ] extractor: 发现 7 个 PDF 文件
|
||||
2026-06-09 16:09:31 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.5-9b, base=http://100.123.83.115:1234/v1)
|
||||
2026-06-09 16:09:50 [ERROR] llm_extractor: LLM 请求失败: Error code: 502
|
||||
2026-06-09 16:09:50 [ERROR] llm_extractor: LLM 发票提取失败: 26349119343000331138-电子发票.pdf (Error code: 502)
|
||||
BIN
src/web/uploads/c913da2afca0/微信图片_20260608145318_279_42.jpg
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
src/web/uploads/c913da2afca0/微信图片_20260608145319_280_42.jpg
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
src/web/uploads/c913da2afca0/微信图片_20260608145320_281_42.jpg
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
src/web/uploads/c913da2afca0/微信图片_20260608145321_282_42.jpg
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
src/web/uploads/c913da2afca0/微信图片_20260608145323_283_42.jpg
Normal file
|
After Width: | Height: | Size: 282 KiB |
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])
|
||||
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
|
||||
89
tests/test_invoice.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""发票模块单元测试
|
||||
|
||||
覆盖发票分类、CSV 列定义、常量校验。
|
||||
"""
|
||||
|
||||
from src.doc.invoice import (
|
||||
INVOICE_LEVEL_COLUMNS,
|
||||
INVOICE_TYPE_GENERAL,
|
||||
INVOICE_TYPE_HOTEL,
|
||||
INVOICE_TYPE_TRAIN,
|
||||
INVOICE_TYPE_TRAVEL,
|
||||
PAYMENT_RECORD_COLUMNS,
|
||||
classify_invoice_batch,
|
||||
is_travel_invoice,
|
||||
)
|
||||
|
||||
|
||||
class TestInvoiceConstants:
|
||||
"""发票常量校验"""
|
||||
|
||||
def test_travel_types_contain_train(self) -> None:
|
||||
assert INVOICE_TYPE_TRAIN in INVOICE_TYPE_TRAVEL
|
||||
|
||||
def test_travel_types_contain_hotel(self) -> None:
|
||||
assert INVOICE_TYPE_HOTEL in INVOICE_TYPE_TRAVEL
|
||||
|
||||
def test_general_not_in_travel(self) -> None:
|
||||
assert INVOICE_TYPE_GENERAL not in INVOICE_TYPE_TRAVEL
|
||||
|
||||
def test_invoice_columns_not_empty(self) -> None:
|
||||
assert len(INVOICE_LEVEL_COLUMNS) > 0
|
||||
|
||||
def test_payment_columns_not_empty(self) -> None:
|
||||
assert len(PAYMENT_RECORD_COLUMNS) > 0
|
||||
|
||||
|
||||
class TestIsTravelInvoice:
|
||||
"""差旅发票判断"""
|
||||
|
||||
def test_train_is_travel(self) -> None:
|
||||
assert is_travel_invoice(INVOICE_TYPE_TRAIN) is True
|
||||
|
||||
def test_hotel_is_travel(self) -> None:
|
||||
assert is_travel_invoice(INVOICE_TYPE_HOTEL) is True
|
||||
|
||||
def test_general_not_travel(self) -> None:
|
||||
assert is_travel_invoice(INVOICE_TYPE_GENERAL) is False
|
||||
|
||||
def test_unknown_not_travel(self) -> None:
|
||||
assert is_travel_invoice("未知类型") is False
|
||||
|
||||
|
||||
class TestClassifyInvoiceBatch:
|
||||
"""发票分类"""
|
||||
|
||||
def test_empty_list(self) -> None:
|
||||
result = classify_invoice_batch([])
|
||||
assert result == {"travel": [], "general": []}
|
||||
|
||||
def test_all_travel(self) -> None:
|
||||
invoices = [
|
||||
{"发票类型": INVOICE_TYPE_TRAIN, "发票号码": "001"},
|
||||
{"发票类型": INVOICE_TYPE_HOTEL, "发票号码": "002"},
|
||||
]
|
||||
result = classify_invoice_batch(invoices)
|
||||
assert len(result["travel"]) == 2
|
||||
assert len(result["general"]) == 0
|
||||
|
||||
def test_all_general(self) -> None:
|
||||
invoices = [
|
||||
{"发票类型": INVOICE_TYPE_GENERAL, "发票号码": "001"},
|
||||
]
|
||||
result = classify_invoice_batch(invoices)
|
||||
assert len(result["travel"]) == 0
|
||||
assert len(result["general"]) == 1
|
||||
|
||||
def test_mixed(self) -> None:
|
||||
invoices = [
|
||||
{"发票类型": INVOICE_TYPE_TRAIN, "发票号码": "001"},
|
||||
{"发票类型": INVOICE_TYPE_GENERAL, "发票号码": "002"},
|
||||
]
|
||||
result = classify_invoice_batch(invoices)
|
||||
assert len(result["travel"]) == 1
|
||||
assert len(result["general"]) == 1
|
||||
|
||||
def test_missing_type_defaults_to_general(self) -> None:
|
||||
invoices = [{"发票号码": "001"}]
|
||||
result = classify_invoice_batch(invoices)
|
||||
assert len(result["general"]) == 1
|
||||
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` 定位失败步骤 |
|
||||