Compare commits
9 Commits
f754386695
...
feature/ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd91df3b9 | ||
|
|
6d66a27aab | ||
|
|
10115214aa | ||
|
|
cf567c22f2 | ||
|
|
98e3d21c83 | ||
|
|
0074975591 | ||
|
|
a72c4ffaab | ||
|
|
70f61be3aa | ||
|
|
02e2ca90a7 |
19
.agents/README.md
Normal file
19
.agents/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# .agents — 项目内部维护目录
|
||||
|
||||
本目录存放维护人员与自动化代理相关资料,不属于对外公开的用户文档。
|
||||
|
||||
## 目录结构
|
||||
|
||||
| 子目录 | 说明 |
|
||||
|--------|------|
|
||||
| `docs/` | 维护文档:规范、经验总结、实施方案、操作指南 |
|
||||
| `skills/` | Cursor Agent 技能:定义自动化工作流和代码检查流程 |
|
||||
|
||||
## 文档边界
|
||||
|
||||
- 面向开源用户、外部贡献者的公开文档统一放置在项目根目录 `docs/` 下。
|
||||
- 维护规范、实施方案、经验总结、拉取请求佐证材料与各类内部记录资料,均统一放置在本目录下。
|
||||
12
.agents/docs/README.md
Normal file
12
.agents/docs/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 项目维护人员文档
|
||||
|
||||
本目录存放维护人员与自动化代理相关资料,主要用于项目运维,不属于对外公开的用户文档。
|
||||
|
||||
- standards/:存放维护人员需遵守的规范制度与校验规则
|
||||
- plans/:存放实施方案与工作交接说明
|
||||
- error-experience/、good-experience/:存放内部经验总结文档
|
||||
- guides/:面向维护人员的工作流程及集成实操手册
|
||||
- architecture/manifest.yaml:记录可读性检查所覆盖的文件路径
|
||||
|
||||
对外用户文档及说明文件请统一放置在 docs/ 目录下。
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
---
|
||||
|
||||
# LlamaIndex 多模态消息格式错误
|
||||
|
||||
## 错误现象
|
||||
|
||||
```
|
||||
pydantic_core._pydantic_core.ValidationError: 2 validation errors for ChatMessage
|
||||
blocks.0
|
||||
Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, ...
|
||||
blocks.1
|
||||
Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, ...
|
||||
```
|
||||
|
||||
## 触发条件
|
||||
|
||||
- llama-index-core >= 0.14.x
|
||||
- 使用 `ChatMessage` 构造多模态消息(文本 + 图片)
|
||||
- 传入 OpenAI 格式的 `content` 列表:`[{"type": "text", ...}, {"type": "image_url", ...}]`
|
||||
|
||||
## 原因
|
||||
|
||||
`llama-index-core 0.14.x` 重构了 `ChatMessage` 的内部结构:
|
||||
|
||||
| 版本 | 字段 | 多模态 content 格式 |
|
||||
|------|------|-------------------|
|
||||
| 0.14.x | `role`, `blocks`, `additional_kwargs` | `TextBlock` / `ImageBlock` 实例 |
|
||||
| 旧版本 | `role`, `content` | OpenAI 风格字典列表 |
|
||||
|
||||
底层 Pydantic 模型使用 `block_type` 作为 union discriminator,OpenAI 格式的 `{"type": "text", ...}` 字典不包含该字段,导致验证失败。
|
||||
|
||||
## 修复方法
|
||||
|
||||
**正确写法(llama-index 原生 blocks 格式):**
|
||||
|
||||
```python
|
||||
from llama_index.core.llms import ChatMessage
|
||||
from llama_index.core.base.llms.types import ImageBlock, TextBlock
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="system", content=system_prompt),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
blocks=[
|
||||
TextBlock(text="请分析这张图片"),
|
||||
ImageBlock(
|
||||
url=f"data:image/jpeg;base64,{image_b64}",
|
||||
detail="high",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
```
|
||||
## 适用版本
|
||||
|
||||
- llama-index-core: 0.14.22
|
||||
- llama-index-llms-openai-like: 0.7.2
|
||||
36
.agents/docs/good-experience/2026-06-11-出现问题好的排查流程.md
Normal file
36
.agents/docs/good-experience/2026-06-11-出现问题好的排查流程.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# 出现问题好的排查流程
|
||||
|
||||
## 出现问题后的排查流程
|
||||
|
||||
```
|
||||
日志报错
|
||||
│
|
||||
├─ 1. 定位错误源码
|
||||
│ 根据日志标签 + 错误信息 → 找到报错函数和校验条件
|
||||
│
|
||||
├─ 2. 分析日志时间线
|
||||
│ 对比错误时间与前后事件 → 判断是主流程还是试探性调用
|
||||
│
|
||||
├─ 3. 编写诊断脚本(隔离测试)
|
||||
│ │
|
||||
│ ├─ 正常 save/load 循环 → 确认基础路径是否健康
|
||||
│ ├─ 编码异常检测 → BOM、GBK 混入等
|
||||
│ ├─ 数据流变更检测 → 前端编辑/外部写入后列结构变化
|
||||
│ └─ 回退链检测 → glob 扫描到非预期文件
|
||||
│
|
||||
├─ 4. 最小化复现
|
||||
│ 针对失败的测试用例,提取最简输入证明根因
|
||||
│
|
||||
└─ 5. 修复 + 验证
|
||||
最小改动修复 → 确认不影响正常输入
|
||||
```
|
||||
|
||||
## 关键判断点
|
||||
|
||||
- 错误不影响主流程 → 优先排查试探性调用和回退链
|
||||
- 列名校验失败但文件肉眼正常 → 优先检查 BOM 和不可见字符
|
||||
- 错误出现在外部数据入口 → 优先检查编码兼容性和数据清洗
|
||||
261
.agents/docs/guides/工程实践指南.md
Normal file
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
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
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
34
.agents/docs/standards/调试规范.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
---
|
||||
# 调试规范
|
||||
|
||||
## 调试前检查清单
|
||||
|
||||
在循环调试任务前,需完成以下检查:
|
||||
|
||||
1. 梳理代码链路(最长耗时 5 分钟)。从入口函数追踪至异常执行环节,排查硬编码值、参数缺失或分支逻辑异常等问题。
|
||||
2. 对比正常与异常场景。若功能 A 运行正常、功能 B 出现故障,梳理二者代码链路的差异,问题通常就出在差异部分。
|
||||
3. 排查基础配置项。检查代理设置、环境变量、端口号、功能开关等。多数故障由配置问题导致,而非代码逻辑错误。
|
||||
|
||||
## 调试过程要求
|
||||
|
||||
1. 两次尝试原则。若同一排查方式(重跑测试、调整参数等)连续失败两次,立即停止,更换排查思路:
|
||||
* 增加针对性日志或打印语句
|
||||
* 阅读异常依赖库的源码
|
||||
* 精简代码,复现最小故障案例
|
||||
* 反思:自身哪些预设判断可能存在偏差
|
||||
2. 禁止无限循环调试。定时任务仅用于监控正常运行的进程,不可作为调试工具。若定时循环连续两轮无进展,关闭循环,转为人工调试。
|
||||
3. 记录排查思路。每开始一次尝试前,做好记录:
|
||||
* 初步判断的问题原因
|
||||
* 用于验证猜想的依据
|
||||
* 本次准备执行的操作
|
||||
* 避免重复无效尝试与逻辑死循环
|
||||
|
||||
## 调试收尾工作
|
||||
|
||||
1. 编写经验文档。所有非简单故障的调试工作,均需在`.agents/docs/error-experience/` 目录下新建记录文档,内容包含:
|
||||
* 故障现象
|
||||
* 历次排查操作及失败原因
|
||||
* 最终解决方案
|
||||
* 后续可借鉴的调试经验
|
||||
17
.agents/skills/README.md
Normal file
17
.agents/skills/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# .agents/skills — Cursor Agent 技能目录
|
||||
|
||||
存放 Cursor Agent 可调用的自动化技能定义。
|
||||
|
||||
## 技能清单
|
||||
|
||||
| 技能 | 说明 |
|
||||
|------|------|
|
||||
| `pre-commit-check/` | 提交前代码质量检查:运行 ruff lint/format、mypy 类型检查、deptry 依赖审计,自动修复可修复问题 |
|
||||
|
||||
## 使用方式
|
||||
|
||||
Agent 在用户请求提交代码或检查代码质量时自动触发对应技能,无需手动调用。
|
||||
68
.agents/skills/pre-commit-check/SKILL.md
Normal file
68
.agents/skills/pre-commit-check/SKILL.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: pre-commit-check
|
||||
description: >-
|
||||
Run pre-commit code quality checks (ruff lint/format, mypy type check, deptry dependency audit)
|
||||
and fix any issues before committing. Use when the user wants to commit code, asks to check code
|
||||
quality, or mentions pre-commit validation. Also use when preparing code for submission or
|
||||
when the user says "check before commit" or "run checks".
|
||||
---
|
||||
|
||||
# Pre-Commit Code Quality Check
|
||||
|
||||
## Workflow
|
||||
|
||||
Run all checks in parallel first, then fix any issues iteratively:
|
||||
|
||||
```
|
||||
Step 1: Run checks (parallel)
|
||||
- uv run ruff check .
|
||||
- uv run ruff format --check .
|
||||
- uv run mypy src/
|
||||
- uv run deptry .
|
||||
|
||||
Step 2: If any check fails, fix issues
|
||||
- ruff check --fix . (auto-fix lint issues)
|
||||
- ruff format . (auto-format)
|
||||
- Fix type annotation errors manually
|
||||
|
||||
Step 3: Re-run all checks to confirm
|
||||
Step 4: Report results with table
|
||||
```
|
||||
|
||||
## Common Fixes
|
||||
|
||||
| Error | Fix |
|
||||
|-------|-----|
|
||||
| `UP038` | Replace `isinstance(e, (X, Y))` with `isinstance(e, X \| Y)` |
|
||||
| `no-untyped-def` | Add return type annotation (`-> None` for void functions) |
|
||||
| `I001` | Run `uv run ruff check --fix .` |
|
||||
| `no-any-return` | Use `cast(Type, expression)` from `typing` |
|
||||
| `type-arg` missing | Add type arguments: `dict[str, Any]` instead of `dict` |
|
||||
| `unused-ignore` | Remove stale `# type: ignore` comments |
|
||||
|
||||
## Type Annotation Rules
|
||||
|
||||
- Functions that modify data in-place and return nothing: `-> None`
|
||||
- Functions that call untyped external APIs: add `-> Any` return type
|
||||
- Dicts that hold mixed types (str + float): use `dict[str, Any]`
|
||||
- Import `Any` from `typing` when needed
|
||||
- Import `cast` from `typing` when `no-any-return` triggers
|
||||
|
||||
## Verification Report
|
||||
|
||||
After all checks pass, report:
|
||||
|
||||
| 检查项 | 状态 |
|
||||
|--------|------|
|
||||
| `ruff check .` | ✅ |
|
||||
| `ruff format --check .` | ✅ |
|
||||
| `mypy src/` | ✅ |
|
||||
| `deptry .` | ✅ |
|
||||
|
||||
## Notes
|
||||
|
||||
- All commands use `uv run` prefix (project virtual environment)
|
||||
- `mypy` has `strict = true` in `pyproject.toml` - expect strict type checking
|
||||
- `ignore_missing_imports = true` is set, so missing third-party stubs are OK
|
||||
- If `tests.*` override shows "unused section" note, it's normal (no tests dir yet)
|
||||
- Never skip a check - all four must pass before committing
|
||||
7
.cursorignore
Normal file
7
.cursorignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.playwright-mcp/
|
||||
*.pyc
|
||||
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# 系统 URL 配置(服务端专用,Web 用户无需配置)
|
||||
# 复制此文件为 .env 并填入实际值
|
||||
|
||||
SSO_LOGIN_URL=https://your-sso-url/login
|
||||
PORTAL_URL=https://your-portal-url/oshall
|
||||
REIMBURSE_URL=http://your-reimburse-host:8081
|
||||
REIMBURSE_PAGE=/expen/common/common?v=4.0
|
||||
TRAVEL_PAGE=/expen/travel/travel?v=4.0
|
||||
|
||||
# LLM 配置
|
||||
LLM_MODEL=your-model-name
|
||||
LLM_API_BASE=http://your-llm-host:port/v1
|
||||
LLM_API_KEY=your-api-key
|
||||
37
.gitignore
vendored
37
.gitignore
vendored
@@ -1,29 +1,14 @@
|
||||
# Python
|
||||
.venv/
|
||||
.cursor/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Playwright MCP snapshots
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.playwright-mcp/
|
||||
|
||||
# Uploads (user data)
|
||||
web/uploads/
|
||||
|
||||
# Logs
|
||||
pipeline.log
|
||||
*.log
|
||||
|
||||
# Debug images
|
||||
images/
|
||||
|
||||
# IDE
|
||||
*.pyc
|
||||
logs/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
uploads/
|
||||
.env
|
||||
images/
|
||||
scripts/data/
|
||||
7
.pre-commit-config.yaml
Normal file
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
|
||||
BIN
1. 电容一批.pdf
BIN
1. 电容一批.pdf
Binary file not shown.
BIN
1. 电容一批.png
BIN
1. 电容一批.png
Binary file not shown.
|
Before Width: | Height: | Size: 204 KiB |
BIN
2. 电阻一批.pdf
BIN
2. 电阻一批.pdf
Binary file not shown.
BIN
2. 电阻一批.png
BIN
2. 电阻一批.png
Binary file not shown.
|
Before Width: | Height: | Size: 201 KiB |
BIN
3. LED一批.pdf
BIN
3. LED一批.pdf
Binary file not shown.
BIN
3. LED一批.png
BIN
3. LED一批.png
Binary file not shown.
|
Before Width: | Height: | Size: 197 KiB |
BIN
4. 元器件盒.pdf
BIN
4. 元器件盒.pdf
Binary file not shown.
BIN
4. 元器件盒.png
BIN
4. 元器件盒.png
Binary file not shown.
|
Before Width: | Height: | Size: 200 KiB |
19
AGENTS.md
Normal file
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
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
|
||||
367
README.md
367
README.md
@@ -1,73 +1,151 @@
|
||||
# 财务报销自动化
|
||||
|
||||
自动从 PDF 发票提取信息,OCR 识别支付记录截图,然后在财务系统中自动填报报销单。
|
||||
自动从 PDF 发票或图片中提取信息,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
|
||||
|
||||
**支持发票类型区分**:系统自动识别高铁票、酒店住宿等差旅发票与普通发票。差旅发票不生成易耗品出库单,走差旅报销流程;普通发票生成出库单,走普通报销流程。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── run.py # CLI 入口
|
||||
├── config.json # 配置文件(登录凭据、系统 URL 等)
|
||||
├── app/
|
||||
│ ├── config.py # 配置加载
|
||||
│ ├── extractor.py # PDF 发票信息提取
|
||||
│ ├── ocr.py # OCR 刷卡信息识别
|
||||
│ ├── bot.py # 浏览器自动填报
|
||||
│ └── pipeline.py # 流程编排(数据在内存中流转)
|
||||
├── web/
|
||||
│ ├── app.py # Web 服务入口
|
||||
│ ├── templates/
|
||||
│ │ └── index.html # Web 前端页面
|
||||
│ └── uploads/ # 用户上传文件目录
|
||||
├── *.pdf # 发票 PDF(按需放置)
|
||||
├── *.png / *.jpg # 与 PDF 同名的支付截图
|
||||
├── invoice_summary.csv # 中间产物 — 发票汇总表
|
||||
└── images/ # 调试截图
|
||||
├── pyproject.toml # 项目配置(依赖、工具链)
|
||||
├── uv.lock # 依赖锁定文件
|
||||
├── Makefile # 任务脚本(跨平台)
|
||||
├── tasks.py # 任务脚本(Windows 兼容)
|
||||
├── .pre-commit-config.yaml # pre-commit 钩子配置
|
||||
├── .env.example # 环境变量示例(SSO 地址、LLM 配置等)
|
||||
├── config.example.json # 用户配置示例
|
||||
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板
|
||||
├── src/
|
||||
│ ├── __init__.py # 包初始化 / 日志器
|
||||
│ ├── config.py # 配置加载
|
||||
│ ├── bot.py # 浏览器自动填报
|
||||
│ ├── pipeline.py # CLI 流程编排
|
||||
│ ├── main.py # CLI 入口
|
||||
│ ├── doc/ # 文档处理模块
|
||||
│ │ ├── extractor.py # 编排入口:串联 PDF 读取 → LLM 提取 → 分类
|
||||
│ │ ├── pdf.py # PDF 图片渲染(PyMuPDF,供多模态 LLM 使用)
|
||||
│ │ ├── llm_extractor.py # LLM 信息提取
|
||||
│ │ ├── matcher.py # 数据匹配与校验
|
||||
│ │ ├── invoice.py # 发票类型常量、分类逻辑、CSV 读写工具
|
||||
│ │ ├── fill_consumable_doc.py # 将 CSV 填入易耗品出库单(Word COM)
|
||||
│ │ ├── prompt.py # LLM 提示词模板
|
||||
│ │ └── prompts/ # 提示词模板文件
|
||||
│ └── web/
|
||||
│ ├── app.py # Web 服务入口
|
||||
│ ├── templates/
|
||||
│ │ ├── index.html # PC 端主页
|
||||
│ │ └── mobile_upload.html # 移动端扫码上传
|
||||
│ ├── static/
|
||||
│ │ ├── css/ # 样式文件
|
||||
│ │ └── js/ # 前端脚本
|
||||
│ └── uploads/ # 按会话隔离的上传与产物目录
|
||||
├── scripts/ # CLI 数据目录
|
||||
│ ├── data/ # 发票源文件、config.json 与 .invoice_cache 缓存
|
||||
│ └── test_*.py # 测试脚本
|
||||
├── tests/ # 测试目录
|
||||
├── docs/ # 用户文档(API 说明、操作指南等)
|
||||
├── images/ # 浏览器调试截图
|
||||
└── *.pdf / *.jpg / *.png # 发票 PDF 或图片(CLI 模式,放在 scripts/data/)
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PDF[PDF 发票 / 图片] --> Extract[extractor 多模态提取]
|
||||
Extract --> Cache[(.invoice_cache/*.json)]
|
||||
|
||||
Cache --> Classify{发票类型分类}
|
||||
Classify -->|差旅发票| Travel[高铁票 / 酒店住宿]
|
||||
Classify -->|普通发票| General[普通发票]
|
||||
Classify -->|支付记录| Payment[支付截图]
|
||||
Classify -->|申请单| Application[出差事前申请单]
|
||||
|
||||
Travel --> Matcher[matcher 金额匹配]
|
||||
Payment --> Matcher
|
||||
Matcher --> MatchResult[(match_result.json)]
|
||||
|
||||
Cache --> TravelLLM[LLM 差旅信息提取]
|
||||
MatchResult --> TravelLLM
|
||||
TravelLLM --> TravelInfo[(travel_info.json)]
|
||||
|
||||
Cache --> NormalLLM[LLM 普通发票信息提取]
|
||||
MatchResult --> NormalLLM
|
||||
NormalLLM --> NormalInfo[(normal_info.json)]
|
||||
|
||||
TravelInfo -->|差旅基本信息| Bot_T[bot/travel.py<br/>差旅填报流程]
|
||||
TravelInfo -->|报销明细| Bot_T
|
||||
TravelInfo -->|支付方式| Bot_T
|
||||
TravelInfo -->|补助清单| Bot_T
|
||||
TravelInfo -->|附件清单| Bot_T
|
||||
Bot_T --> Submit_T[差旅报销提交]
|
||||
|
||||
NormalInfo -->|报销说明| Bot_G[bot/normal.py<br/>普通填报流程]
|
||||
NormalInfo -->|发票总数/金额| Bot_G
|
||||
NormalInfo -->|支付方式| Bot_G
|
||||
NormalInfo -->|附件清单| Bot_G
|
||||
Bot_G --> Submit_G[普通报销提交]
|
||||
|
||||
General --> CSV[(invoice_summary.csv)]
|
||||
CSV --> Fill[fill_consumable_doc]
|
||||
Fill --> Doc[易耗品、出库单.doc]
|
||||
```
|
||||
PDF 文件 ──► extractor 提取 ──► 发票列表
|
||||
│
|
||||
支付截图 ──► OCR 识别 ────────────► 回填刷卡信息
|
||||
│
|
||||
invoice_summary.csv
|
||||
│
|
||||
bot 打开浏览器 ──► 自动填报
|
||||
```
|
||||
|
||||
### 关键中间产物
|
||||
|
||||
| 文件 | 生成阶段 | 作用 |
|
||||
|------|---------|------|
|
||||
| `.invoice_cache/*.json` | extractor 提取 | 单张发票/支付记录/申请单的结构化数据 |
|
||||
| `match_result.json` | matcher 匹配 | 支付截图与发票的关联关系(按金额匹配) |
|
||||
| `travel_info.json` | LLM 差旅信息提取 | 综合发票缓存 + 匹配结果,生成差旅报销所需的全部结构化数据 |
|
||||
| `normal_info.json` | LLM 普通发票信息提取 | 综合普通发票 + 匹配结果,生成普通报销所需的全部结构化数据 |
|
||||
| `invoice_summary.csv` | extractor 提取 | 普通发票汇总(用于生成易耗品出库单) |
|
||||
|
||||
### bot 模块架构
|
||||
|
||||
`bot/` 包负责浏览器自动化填报,仅接收已提取的信息并执行填报操作,不承担信息提取职责:
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| `bot/base.py` | `BaseBot` 基类:浏览器生命周期、登录、导航、截图 |
|
||||
| `bot/travel.py` | 差旅填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
|
||||
| `bot/normal.py` | 普通填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
|
||||
| `bot/__init__.py` | 入口函数:`run_bot()` / `run_bot_web()`,负责类型判断和流程路由 |
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3.10+
|
||||
- 依赖见下方安装步骤
|
||||
- **Python 3.12+**
|
||||
- **uv** 包管理器([安装指南](https://docs.astral.sh/uv/getting-started/installation/))
|
||||
- Windows(易耗品出库单填写依赖 Microsoft Word + COM,仅 Windows 可用)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
> 以下依赖需在 MinerU 虚拟环境中安装:
|
||||
> ```bash
|
||||
> conda activate MinerU
|
||||
> ```
|
||||
|
||||
```bash
|
||||
pip install pdfplumber==0.11.9 paddleocr==2.8.1 playwright==1.60.0 flask==3.0.3
|
||||
playwright install chromium
|
||||
# 同步所有依赖(运行时 + 开发工具)
|
||||
make install
|
||||
# Windows 上等效命令:
|
||||
python tasks.py install
|
||||
```
|
||||
|
||||
### 2. 准备数据
|
||||
项目使用 `uv` 管理依赖,所有包版本锁定在 `uv.lock` 中,确保可复现。
|
||||
|
||||
将发票 PDF 和对应的支付截图放在项目根目录下。脚本会自动匹配 PDF 与截图:
|
||||
| 依赖 | 用途 |
|
||||
|------|------|
|
||||
| PyMuPDF | PDF 图片渲染(供多模态 LLM 使用) |
|
||||
| llama-index | LLM 信息提取(发票识别、差旅信息提取) |
|
||||
| playwright | 财务系统浏览器自动化 |
|
||||
| flask | Web 服务 |
|
||||
| pywin32 | 填写 Word 出库单(`fill_consumable_doc`) |
|
||||
|
||||
1. **优先文件名匹配** — PDF 与截图同名(如 `发票.pdf` ↔ `发票.png`)
|
||||
2. **金额近邻匹配** — 文件名不同时,自动提取 PDF 的价税合计和截图的刷卡金额进行配对
|
||||
### 2. 准备数据(CLI 模式)
|
||||
|
||||
截图支持格式:`.png`、`.jpg`、`.jpeg`、`.bmp`、`.webp`。
|
||||
```
|
||||
将发票 PDF 或图片(`.jpg`、`.png`、`.webp`、`.bmp`)放在 `scripts/data/` 目录下。
|
||||
|
||||
### 3. 配置
|
||||
|
||||
编辑 `config.json`,填写登录凭据和默认值:
|
||||
编辑 `scripts/config.json`(参考 `config.example.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -75,72 +153,207 @@ playwright install chromium
|
||||
"password": "你的密码",
|
||||
"default_name": "默认报销人姓名",
|
||||
"default_card_no": "默认公务卡号",
|
||||
"default_person_id": "默认人员编号"
|
||||
"default_person_id": "默认人员编号",
|
||||
"consumable_storage": "物料存储地"
|
||||
}
|
||||
```
|
||||
|
||||
未填写的字段将使用默认值,URL 类配置一般无需修改。
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `username` / `password` | 信息门户登录凭据 |
|
||||
| `default_name` | 默认报销人姓名 |
|
||||
| `default_card_no` | 默认公务卡号 |
|
||||
| `default_person_id` | 默认人员编号(工号) |
|
||||
| `consumable_storage` | 出库单「存放地点」列默认值(默认: `躬行楼 C205`) |
|
||||
|
||||
### 4. 运行
|
||||
**服务端配置**(SSO 地址、报销系统 URL、LLM 参数)通过环境变量提供,有默认值,一般无需修改:
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|----------|--------|------|
|
||||
| `SSO_LOGIN_URL` | `https://tyrz.fynu.edu.cn/sso/login` | SSO 登录地址 |
|
||||
| `PORTAL_URL` | `https://tyrz.fynu.edu.cn/oshall` | 统一信息平台地址 |
|
||||
| `REIMBURSE_URL` | `http://210.45.32.214:8081` | 报销系统地址 |
|
||||
| `REIMBURSE_PAGE` | `/expen/common/common?v=4.0` | 普通报销页面路径 |
|
||||
| `TRAVEL_PAGE` | `/expen/travel/travel?v=4.0` | 差旅报销页面路径 |
|
||||
| `LLM_MODEL` | `qwen-vl-max` | LLM 模型名称 |
|
||||
| `LLM_API_BASE` | `http://localhost:8080/v1` | LLM API 地址 |
|
||||
| `LLM_API_KEY` | `lm-studio` | LLM API 密钥 |
|
||||
|
||||
### 4. 运行(CLI)
|
||||
|
||||
```bash
|
||||
# 全流程(发票提取 → OCR 识别 → 浏览器填报)
|
||||
python run.py
|
||||
# 全流程(发票提取 → 浏览器填报)
|
||||
make run
|
||||
# Windows 等效:
|
||||
python tasks.py run
|
||||
|
||||
# 仅执行某一步
|
||||
python run.py --step invoice # 仅发票提取
|
||||
python run.py --step ocr # 仅 OCR 识别
|
||||
python run.py --step submit # 仅浏览器填报
|
||||
uv run python src/main.py --step invoice # 仅发票提取
|
||||
uv run python src/main.py --step submit # 仅浏览器填报
|
||||
|
||||
# 覆盖配置中的登录凭据
|
||||
python run.py -u 工号 -p 密码
|
||||
uv run python src/main.py -u 工号 -p 密码
|
||||
```
|
||||
|
||||
### 5. 填写易耗品出库单(CLI)
|
||||
|
||||
需已生成 `invoice_summary.csv`,且本机已安装 **Microsoft Word**:
|
||||
|
||||
```bash
|
||||
uv run python -m src.doc.fill_consumable_doc
|
||||
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
uv run python -m src.doc.fill_consumable_doc --config scripts/config.json # 指定配置文件
|
||||
uv run python -m src.doc.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
|
||||
```
|
||||
|
||||
填写规则概要:
|
||||
|
||||
- 表头「日期」使用**填写当天**的日期(非发票开票日期)
|
||||
- 从 `spec_model` 解析品名、规格、单位、数量、单价;`card_amount` 写入金额列
|
||||
- 单价/金额保留两位小数;表格内统一为 **宋体五号(10.5 磅)**
|
||||
- 存放地点取自 `consumable_storage`(默认: `躬行楼 C205`);购货人/领用人签字、备注保持空白
|
||||
|
||||
## 执行步骤说明
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 发票提取 | `--step invoice` | 扫描根目录 PDF,提取发票号码、金额、销售方等信息,生成 `invoice_summary.csv` 和 `.md` |
|
||||
| OCR 识别 | `--step ocr` | 对支付截图执行 OCR,识别刷卡日期、刷卡金额、持卡人姓名,回填到 CSV |
|
||||
| 浏览器填报 | `--step submit` | 打开浏览器,登录信息门户 → 进入报销系统 → 自动填单、录入明细、上传附件 |
|
||||
| 发票提取 | `--step invoice` | 扫描 `scripts/data/` 目录的 PDF 和图片,生成 `invoice_summary.csv` |
|
||||
| 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 |
|
||||
|
||||
> 分步执行时,上一步的 CSV 产物会自动成为下一步的输入。
|
||||
> 全流程执行时数据在内存中流转,CSV 为参考产物。分步执行时,缓存数据会自动成为下一步的输入。
|
||||
|
||||
### 缓存机制
|
||||
|
||||
系统使用 JSON 缓存作为数据中转站,串联整个处理流程:
|
||||
|
||||
```
|
||||
源文件 (PDF/图片) → LLM 多模态提取 → JSON 缓存 → 匹配/分类/填报
|
||||
```
|
||||
|
||||
| 缓存文件 | 说明 |
|
||||
|----------|------|
|
||||
| `<文件名>.json` | 每个源文件的 LLM 提取结果(发票信息、支付记录等) |
|
||||
| `match_result.json` | 发票与支付记录的匹配结果 |
|
||||
| `travel_info.json` | 差旅信息(事由、地点、时间等)提取结果 |
|
||||
|
||||
缓存位置:CLI 模式为 `scripts/data/.invoice_cache/`,Web 模式为 `src/web/uploads/<session_id>/.invoice_cache/`。缓存文件与源文件同名(如 `发票1.pdf` 对应 `.invoice_cache/发票1.json`),后续步骤(金额匹配、发票分类、浏览器填报)均从缓存读取结构化数据。删除缓存后下次处理会重新提取。
|
||||
|
||||
### CSV 字段说明
|
||||
|
||||
发票级别 CSV(`invoice_summary.csv`)使用英文列名:
|
||||
|
||||
| 列名 | 说明 |
|
||||
|------|------|
|
||||
| `index` | 行号 |
|
||||
| `invoice_type` | 发票类型:`train` / `hotel` / `general` |
|
||||
| `invoice_number` | 电子发票号码 |
|
||||
| `invoice_date` | 开票日期 |
|
||||
| `item_name` | 货物或应税劳务名称 |
|
||||
| `spec_model` | 规格型号(差旅发票为出发站→到达站) |
|
||||
| `total_amount` | 发票含税金额 |
|
||||
| `seller_name` | 销方名称 |
|
||||
| `departure` / `arrival` | 出发站 / 到达站(高铁票专用) |
|
||||
| `train_no` / `ride_date` / `seat_class` | 车次 / 乘车日期 / 座位等级(高铁票专用) |
|
||||
| `person_name` | 人员姓名 |
|
||||
| `card_date` / `card_no` / `card_amount` | 刷卡日期 / 公务卡号 / 刷卡金额 |
|
||||
| `remark` | 备注 |
|
||||
| `person_id` | 工号 |
|
||||
|
||||
## Web 服务
|
||||
|
||||
提供浏览器界面,上传文件即可自动处理:
|
||||
提供浏览器界面:上传文件 → 自动处理 → 在线编辑 → 下载产物 → 可选提交财务系统。
|
||||
|
||||
```bash
|
||||
python web/app.py
|
||||
uv run python src/web/app.py
|
||||
```
|
||||
|
||||
访问 `http://localhost:5000`,上传文件并填写配置后点击「开始处理」。
|
||||
访问 `http://localhost:5000`。
|
||||
|
||||
### 两种处理模式
|
||||
### 推荐使用流程
|
||||
|
||||
| 模式 | 入口 | 说明 |
|
||||
|------|------|------|
|
||||
| **PDF 模式** | 上传 PDF + 截图 | 自动提取发票信息 → OCR 识别刷卡记录 → 生成 CSV → 可选浏览器填报 |
|
||||
| **CSV 快捷模式** | 上传已有 CSV 文件 | 跳过提取和 OCR,直接使用 CSV 数据进行浏览器填报 |
|
||||
1. 上传 PDF 或图片(或上传已有 CSV)
|
||||
2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充
|
||||
3. 点击 **开始处理** — 完成发票提取、生成 CSV,系统自动识别发票类型并分类统计
|
||||
4. **普通发票**:自动生成 **易耗品、出库单.doc**,可下载
|
||||
5. **差旅发票**(高铁票/酒店住宿):跳过出库单生成,直接进入差旅报销流程
|
||||
6. 在表格中核对、修改发票数据(提交财务系统前会自动保存)
|
||||
7. 确认无误后点击 **提交到财务系统**
|
||||
|
||||
### 功能说明
|
||||
### 处理流程
|
||||
|
||||
上传 PDF 或图片 → LLM 识别文档类型 → 结构化提取 → 分类处理
|
||||
|
||||
系统通过 LLM 多模态识别自动判断每张文档的类型,无需手动指定:
|
||||
|
||||
| 文档类型 | 处理方式 | 状态 |
|
||||
|----------|----------|------|
|
||||
| 发票(高铁票/酒店住宿/普通发票) | 提取发票信息 → 金额匹配 → 分类 | 已实现 |
|
||||
| 支付记录(刷卡截图) | 提取刷卡信息 → 与发票匹配 | 已实现 |
|
||||
| 出差事前申请单 | 提取出差事由、地点、时间 | 已实现 |
|
||||
| 飞机票 | 同高铁票处理流程 | 计划中 |
|
||||
|
||||
### 功能一览
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 发票提取 + OCR | 上传 PDF 后自动完成,无需手动操作 |
|
||||
| CSV 快捷上传 | 已有发票数据 CSV 可直接上传,跳过前面的步骤 |
|
||||
| 浏览器填报 | 勾选「同时提交到财务系统」后自动运行 |
|
||||
| 实时日志 | 处理进度通过 SSE 实时推送 |
|
||||
| 下载 CSV | 处理后下载发票汇总表 |
|
||||
| 配置上传 | 可上传 `config.json` 自动填充表单 |
|
||||
| 发票提取 | 上传 PDF 或图片后自动完成 |
|
||||
| 发票类型自动分类 | 高铁票/酒店住宿/普通发票,自动分流处理 |
|
||||
| 易耗品出库单 | 仅普通发票自动生成 Word,差旅发票跳过 |
|
||||
| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 |
|
||||
| 财务系统填报 | 单独按钮触发,处理阶段不会自动提交 |
|
||||
| 实时日志 | SSE 推送处理进度 |
|
||||
| 配置上传 | 支持上传 `config.json` 填充表单 |
|
||||
| 手机扫码上传 | 二维码打开移动端页面,拍照上传,PC 端轮询同步 |
|
||||
|
||||
> Web 模式下浏览器以无头模式运行,不会弹出窗口。
|
||||
> 若未上传 PDF 附件,浏览器填报阶段将自动跳过附件上传步骤。
|
||||
> Web 端浏览器填报以无头模式运行。未上传 PDF 或图片时,填报阶段会跳过附件上传。
|
||||
> 出库单生成需要 **Windows + Word + pywin32**;若失败,页面会显示具体原因,CSV 等其它产物仍可正常使用。
|
||||
|
||||
### 会话产物
|
||||
|
||||
每次上传生成独立会话,产物存放在 `src/web/uploads/<session_id>/`:
|
||||
|
||||
| 产物 | 说明 |
|
||||
|------|------|
|
||||
| `invoice_summary.csv` | 发票汇总数据(含 LLM 识别结果) |
|
||||
| `payment_records.csv` | 支付记录级别数据(含匹配结果) |
|
||||
| `travel_applications.json` | 出差事前申请单数据(JSON 格式) |
|
||||
| `易耗品、出库单.doc` | 自动填写的出库单(仅普通发票) |
|
||||
| `config.json` | 当次会话配置 |
|
||||
| `session.log` | 处理日志 |
|
||||
| `result.json` | 处理结果 |
|
||||
| `.invoice_cache/` | LLM 提取结果缓存(JSON 格式,避免重复处理) |
|
||||
|
||||
会话缓存机制与 CLI 模式相同,缓存目录中的 JSON 数据是后续匹配、分类和填报的唯一数据来源。
|
||||
|
||||
接口说明见 [API.md](./API.md)。
|
||||
|
||||
## 开发任务
|
||||
|
||||
项目提供统一的任务脚本,支持跨平台使用:
|
||||
|
||||
| 任务 | Makefile | tasks.py | 说明 |
|
||||
|------|----------|----------|------|
|
||||
| 安装依赖 | `make install` | `python tasks.py install` | 同步依赖 + 安装 pre-commit |
|
||||
| 代码检查 | `make check` | `python tasks.py check` | Ruff lint + 格式化 + MyPy 类型检查 + deptry 依赖检查 |
|
||||
| 运行测试 | `make test` | `python tasks.py test` | pytest + 覆盖率报告 |
|
||||
| 运行 CLI | `make run` | `python tasks.py run` | 执行全流程 |
|
||||
| 清理缓存 | `make clean` | `python tasks.py clean` | 删除虚拟环境和缓存 |
|
||||
|
||||
## 代码质量
|
||||
|
||||
项目配置了完整的代码质量工具链:
|
||||
|
||||
- **Ruff** — 快速 lint 检查和代码格式化(替代 flake8 + isort + black)
|
||||
- **MyPy** — 严格模式类型检查(`strict = true`)
|
||||
- **deptry** — 检测未声明、未使用、过时依赖
|
||||
- **pre-commit** — 提交前自动运行 Ruff 检查和格式化
|
||||
|
||||
所有检查通过后方可提交代码。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 第三步会打开浏览器窗口,请勿关闭或切换标签页
|
||||
- 首次运行可能需要手动处理 SSO 登录(如已保存会话则跳过)
|
||||
- 调试截图保存在 `images/` 目录,出错时可查看
|
||||
- `invoice_summary.csv` 中空白的字段会在 OCR 步骤自动回填,不会覆盖已有数据
|
||||
- 提交按钮默认未启用,确认数据无误后可在 `app/bot.py` 中取消注释 `bot.submit()`
|
||||
- 浏览器填报时会打开或使用 Chromium,请勿手动干扰自动化流程
|
||||
- 调试截图保存在 `images/` 目录
|
||||
- 项目根目录需保留 `易耗品、出库单.doc` 模板;Web 每次从模板复制到会话目录再填写,不修改原模板
|
||||
- `scripts/config.json` 含敏感信息,请勿提交到公开仓库
|
||||
- **发票类型区分**:差旅发票(高铁票/酒店住宿)不会生成易耗品出库单,差旅报销填报流程已完整实现(含差旅信息提取、明细录入、支付方式、补助清单、附件上传)
|
||||
@@ -1,35 +0,0 @@
|
||||
"""财务报销自动化工具包"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_LOG_FMT = "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s"
|
||||
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_FILE = Path(__file__).resolve().parent.parent / "pipeline.log"
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""获取带时间戳的日志记录器
|
||||
|
||||
输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ...
|
||||
日志同时输出到终端和项目根目录的 pipeline.log
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
if not logger.handlers:
|
||||
logger.setLevel(logging.INFO)
|
||||
formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT)
|
||||
|
||||
# 终端输出
|
||||
utf8_stream = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
stream_handler = logging.StreamHandler(utf8_stream)
|
||||
stream_handler.setFormatter(formatter)
|
||||
logger.addHandler(stream_handler)
|
||||
|
||||
# 文件输出
|
||||
file_handler = logging.FileHandler(str(_LOG_FILE), encoding="utf-8")
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
435
app/bot.py
435
app/bot.py
@@ -1,435 +0,0 @@
|
||||
"""
|
||||
浏览器自动化填报
|
||||
|
||||
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
|
||||
|
||||
对外接口:
|
||||
load_invoice_data(csv_path, config) -> list[dict] 从 CSV 加载并补全默认值
|
||||
run_bot(config, invoices) 启动浏览器并执行填报流程
|
||||
"""
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from . import get_logger
|
||||
|
||||
log = get_logger("bot")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 日期格式化
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _format_date(date_str: str) -> str:
|
||||
"""将 '2026/5/13' 或 '2026-5-13' 转为 '2026-05-13'"""
|
||||
if not date_str:
|
||||
return ""
|
||||
parts = date_str.replace("-", "/").split("/")
|
||||
if len(parts) == 3:
|
||||
return f"{parts[0].zfill(4)}-{parts[1].zfill(2)}-{parts[2].zfill(2)}"
|
||||
return date_str
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CSV 数据加载
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load_invoice_data(csv_path: str, config: dict) -> list[dict]:
|
||||
"""从 CSV 加载发票数据,自动补全空白字段的默认值"""
|
||||
invoices = []
|
||||
with open(csv_path, encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
invoices.append({
|
||||
"seq": row.get("序号", ""),
|
||||
"invoice_no": row.get("发票号码", ""),
|
||||
"invoice_date": row.get("开票日期", ""),
|
||||
"item_name": row.get("项目名称", ""),
|
||||
"spec_model": row.get("规格型号", ""),
|
||||
"total_amount": float(row.get("价税合计", 0)),
|
||||
"seller_name": row.get("销售方名称", ""),
|
||||
"person_name": row.get("人员姓名") or config.get("default_name", ""),
|
||||
"card_date": _format_date(row.get("刷卡日期") or ""),
|
||||
"card_no": row.get("公务卡号") or config.get("default_card_no", ""),
|
||||
"card_amount": float(row.get("刷卡金额") or "0"),
|
||||
"remark": row.get("备注") or "",
|
||||
"person_id": row.get("工号") or config.get("default_person_id", ""),
|
||||
})
|
||||
return invoices
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 报销机器人
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class ReimburseBot:
|
||||
"""财务报销自动化机器人"""
|
||||
|
||||
def __init__(self, config: dict, headless: bool = False):
|
||||
self.config = config
|
||||
self.headless = headless
|
||||
self.work_dir: Path | None = None
|
||||
self.browser = None
|
||||
self.context = None
|
||||
self.page = None
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
self._pw_ctx = sync_playwright()
|
||||
self.pw = self._pw_ctx.__enter__()
|
||||
|
||||
def launch(self):
|
||||
"""启动浏览器"""
|
||||
self.browser = self.pw.chromium.launch(headless=self.headless)
|
||||
self.context = self.browser.new_context(viewport={"width": 1920, "height": 1080})
|
||||
self.page = self.context.new_page()
|
||||
self.page.set_default_timeout(30000)
|
||||
|
||||
def login_portal(self):
|
||||
"""登录信息门户"""
|
||||
log.info("登录信息门户...")
|
||||
|
||||
self.page.goto(self.config["sso_login_url"], wait_until="domcontentloaded")
|
||||
self._wait_for('text="微信扫码登录"', timeout=5000)
|
||||
|
||||
try:
|
||||
self.page.fill('input[placeholder*="工号"], input[placeholder*="学号"]', self.config["username"])
|
||||
self.page.fill('input[placeholder*="密码"]', self.config["password"])
|
||||
except Exception:
|
||||
log.warning("未找到登录输入框,可能已登录")
|
||||
|
||||
try:
|
||||
checkbox = self.page.query_selector('input[type="checkbox"]')
|
||||
if checkbox and not checkbox.is_checked():
|
||||
checkbox.click()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for selector in ['button:has-text("登录")', 'input[value="登录"]', 'text="登录"']:
|
||||
try:
|
||||
self.page.click(selector, timeout=3000)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
self._wait_for_portal()
|
||||
|
||||
def _wait_for_portal(self):
|
||||
"""等待跳转到统一信息平台"""
|
||||
for _ in range(30):
|
||||
self.page.wait_for_timeout(1000)
|
||||
url = self.page.url
|
||||
if any(kw in url for kw in ("tyrz.fynu.edu.cn/zs-uip", "tyrz.fynu.edu.cn/oshall", "portal")):
|
||||
self._screenshot("portal_loaded")
|
||||
return
|
||||
log.error("等待门户跳转超时")
|
||||
self._screenshot("portal_timeout")
|
||||
raise TimeoutError("登录超时,未跳转到信息门户")
|
||||
|
||||
def navigate_to_reimburse(self):
|
||||
"""从统一信息平台进入报销系统"""
|
||||
log.info("进入报销系统...")
|
||||
self._wait_for('text="快捷入口"', timeout=5000)
|
||||
|
||||
try:
|
||||
self.page.click('text="财务系统"', timeout=5000)
|
||||
except Exception:
|
||||
log.warning("未找到财务系统入口")
|
||||
|
||||
new_tab = None
|
||||
for _ in range(15):
|
||||
self.page.wait_for_timeout(1000)
|
||||
for p in self.context.pages:
|
||||
if "dddl" in p.url or "210.45.32.214" in p.url:
|
||||
new_tab = p
|
||||
break
|
||||
if new_tab:
|
||||
break
|
||||
|
||||
if new_tab:
|
||||
self.page = new_tab
|
||||
self._wait_for('text="网络报销"', timeout=5000)
|
||||
else:
|
||||
log.warning(f"未找到单点登录页面,当前 URL: {self.page.url}")
|
||||
|
||||
for p in self.context.pages[:-1]:
|
||||
try:
|
||||
p.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
link = self.page.query_selector('a:has(img[src*="wlbx"])')
|
||||
if link:
|
||||
reimburse_url = link.get_attribute("href")
|
||||
self.page.goto(reimburse_url, wait_until="domcontentloaded", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._wait_for('text="报销录入"', timeout=5000)
|
||||
common_url = self.config["reimburse_url"] + self.config["reimburse_page"]
|
||||
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
|
||||
self._wait_for('text="单据状态:"', timeout=5000)
|
||||
|
||||
def open_reimburse_menu(self):
|
||||
"""点击「新增」创建新报销单"""
|
||||
log.info("创建新报销单...")
|
||||
self.page.wait_for_timeout(2000)
|
||||
|
||||
try:
|
||||
self.page.click('button:has-text("新增")', timeout=5000)
|
||||
except Exception:
|
||||
try:
|
||||
self.page.click("text=新增", timeout=3000)
|
||||
except Exception:
|
||||
self._screenshot("no_add_button")
|
||||
raise RuntimeError("无法点击新增按钮")
|
||||
|
||||
self.page.wait_for_timeout(3000)
|
||||
self._screenshot("after_add_click")
|
||||
|
||||
def fill_basic_info(self, description: str = "元器件采购报销"):
|
||||
"""填写基本信息"""
|
||||
log.info("填写基本信息...")
|
||||
|
||||
try:
|
||||
self.page.fill("#EXPENEXPLAIN", description)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.page.click("#PROJECTCODE", timeout=10000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._screenshot("step3_project_modal")
|
||||
|
||||
try:
|
||||
self.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
|
||||
first_row = self.page.query_selector("#promodal .fixed-table-body tbody tr")
|
||||
if first_row:
|
||||
first_row.click()
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._screenshot("step3_project_selected")
|
||||
|
||||
try:
|
||||
self.page.click("#saveAndNext", timeout=5000)
|
||||
self.page.wait_for_timeout(2000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._screenshot("step3_done")
|
||||
|
||||
def add_reimburse_items(self, invoices: list[dict]):
|
||||
"""录入报销明细(一条总明细)"""
|
||||
card_amount = sum(inv["card_amount"] for inv in invoices)
|
||||
log.info(f"录入报销明细 (合计 ¥{card_amount:.2f})...")
|
||||
|
||||
try:
|
||||
self.page.click("#insertDetail", timeout=5000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
self._wait_for('text="经济事项名称"', timeout=5000)
|
||||
self.page.click("#economicscode2")
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
try:
|
||||
self.page.wait_for_selector("#econmodal .fixed-table-body tbody tr", timeout=10000)
|
||||
rows = self.page.query_selector_all("#econmodal .fixed-table-body tbody tr")
|
||||
if len(rows) >= 3:
|
||||
rows[2].click()
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.page.fill('input[name="expenPwCommondetail.HOWBILLS"]', f"{len(invoices)}")
|
||||
self.page.fill("#je_zwzcdz", f"{card_amount:.2f}")
|
||||
self.page.click("#detailAdd", timeout=3000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
self._screenshot("item_total")
|
||||
except Exception as e:
|
||||
log.error(f"录入总明细失败: {e}")
|
||||
self._screenshot("item_total_error")
|
||||
raise
|
||||
|
||||
def fill_payment(self, invoices: list[dict]):
|
||||
"""录入支付信息"""
|
||||
log.info("录入支付信息...")
|
||||
try:
|
||||
self.page.click('text="下一步(支付方式)"', timeout=5000)
|
||||
self._wait_for('text="下一步(附件清单)"', timeout=5000)
|
||||
|
||||
for inv in invoices:
|
||||
self.page.click("#insertPay", timeout=5000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
self.page.fill("#personid2", inv["person_id"])
|
||||
self.page.fill("#accountname2", inv["person_name"])
|
||||
self.page.fill("#receiptdate2", inv["card_date"])
|
||||
self.page.fill("#localaccount2", inv["card_no"])
|
||||
self.page.fill("#receiptmoney2", str(inv["card_amount"]))
|
||||
self.page.fill("#money2", str(inv["card_amount"]))
|
||||
self.page.fill("#merchant2", inv["seller_name"])
|
||||
self.page.fill("#smark2", inv["remark"])
|
||||
self.page.click("#payAdd", timeout=3000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"支付方式录入失败: {e}")
|
||||
self._screenshot("step5_error")
|
||||
raise
|
||||
|
||||
self._screenshot("step5_done")
|
||||
|
||||
def upload_attachments(self, invoices: list[dict]):
|
||||
"""上传发票附件"""
|
||||
log.info("上传附件...")
|
||||
|
||||
try:
|
||||
self.page.click("#next3", timeout=5000)
|
||||
self._wait_for("#submit2", timeout=5000)
|
||||
|
||||
attachment_files = sorted((self.work_dir or Path(__file__).parent.parent).glob("*.pdf"))
|
||||
if not attachment_files:
|
||||
log.warning("未找到附件 PDF,跳过附件上传")
|
||||
return
|
||||
|
||||
for i, inv in enumerate(invoices):
|
||||
file_path = attachment_files[i] if i < len(attachment_files) else None
|
||||
|
||||
self.page.click("#insertAcc", timeout=5000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
try:
|
||||
self._wait_for("#fjlx", timeout=5000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.page.select_option("#fjlx", "1")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
explanation = f"{inv['item_name']} - {inv['invoice_no']}"
|
||||
self.page.fill("#fpsmxx", explanation)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if file_path and file_path.exists():
|
||||
try:
|
||||
self.page.set_input_files("#file", str(file_path))
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"文件上传失败: {e}")
|
||||
|
||||
try:
|
||||
self.page.click("#cjtj", timeout=5000)
|
||||
self.page.wait_for_timeout(1500)
|
||||
except Exception:
|
||||
try:
|
||||
self.page.press("body", "Escape")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"附件上传失败: {e}")
|
||||
self._screenshot("step6_error")
|
||||
raise
|
||||
|
||||
self._screenshot("step6_done")
|
||||
|
||||
def submit(self):
|
||||
"""提交报销单"""
|
||||
log.info("提交报销单...")
|
||||
try:
|
||||
self.page.click("#submit", timeout=5000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
self._screenshot("submitted")
|
||||
except Exception as e:
|
||||
log.error(f"提交失败: {e}")
|
||||
self._screenshot("submit_error")
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""关闭浏览器"""
|
||||
if self.context:
|
||||
self.context.close()
|
||||
if self.browser:
|
||||
self.browser.close()
|
||||
try:
|
||||
self._pw_ctx.__exit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 辅助方法
|
||||
# --------------------------------------------------------
|
||||
|
||||
def _wait_for(self, selector: str, timeout: int = None):
|
||||
self.page.wait_for_selector(selector, timeout=timeout)
|
||||
|
||||
def _screenshot(self, name: str):
|
||||
img_dir = Path(__file__).parent.parent / "images"
|
||||
img_dir.mkdir(exist_ok=True)
|
||||
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 对外入口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run_bot(config: dict, invoices: list[dict], headless: bool = False, work_dir: Path | None = None):
|
||||
"""执行完整的浏览器填报流程"""
|
||||
if not config["username"] or not config["password"]:
|
||||
raise ValueError("缺少用户名或密码")
|
||||
|
||||
bot = ReimburseBot(config, headless=headless)
|
||||
bot.work_dir = work_dir
|
||||
try:
|
||||
bot.launch()
|
||||
bot.login_portal()
|
||||
bot.navigate_to_reimburse()
|
||||
bot.open_reimburse_menu()
|
||||
bot.fill_basic_info()
|
||||
bot.add_reimburse_items(invoices)
|
||||
bot.fill_payment(invoices)
|
||||
bot.upload_attachments(invoices)
|
||||
# bot.submit() # 确认无误后再取消注释
|
||||
except Exception as e:
|
||||
log.error(f"操作失败: {e}")
|
||||
try:
|
||||
bot._screenshot("error")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
bot.close()
|
||||
|
||||
|
||||
def run_bot_web(config: dict, invoices: list[dict], work_dir: Path):
|
||||
"""Web 模式填报 — headless,附件从指定目录读取"""
|
||||
if not config["username"] or not config["password"]:
|
||||
raise ValueError("缺少用户名或密码")
|
||||
|
||||
bot = ReimburseBot(config, headless=True)
|
||||
bot.work_dir = work_dir
|
||||
try:
|
||||
bot.launch()
|
||||
bot.login_portal()
|
||||
bot.navigate_to_reimburse()
|
||||
bot.open_reimburse_menu()
|
||||
bot.fill_basic_info()
|
||||
bot.add_reimburse_items(invoices)
|
||||
bot.fill_payment(invoices)
|
||||
bot.upload_attachments(invoices)
|
||||
except Exception as e:
|
||||
log.error(f"操作失败: {e}")
|
||||
try:
|
||||
bot._screenshot("error")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
bot.close()
|
||||
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
配置加载
|
||||
|
||||
从项目根目录的 config.json 读取配置,返回结构化的配置字典。
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_CONFIG_PATH = Path(__file__).parent.parent / "config.json"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""加载并合并配置,缺失字段使用默认值"""
|
||||
raw = {}
|
||||
if _CONFIG_PATH.exists():
|
||||
with open(_CONFIG_PATH, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
project_root = _CONFIG_PATH.parent
|
||||
|
||||
return {
|
||||
"sso_login_url": raw.get("sso_login_url", "https://tyrz.fynu.edu.cn/sso/login"),
|
||||
"portal_url": raw.get("portal_url", "https://tyrz.fynu.edu.cn/oshall"),
|
||||
"reimburse_url": raw.get("reimburse_url", "http://210.45.32.214:8081"),
|
||||
"reimburse_page": raw.get("reimburse_page", "/expen/common/common?v=4.0"),
|
||||
"username": raw.get("username", ""),
|
||||
"password": raw.get("password", ""),
|
||||
"default_name": raw.get("default_name", ""),
|
||||
"default_card_no": raw.get("default_card_no", ""),
|
||||
"default_person_id": raw.get("default_person_id", ""),
|
||||
"attachment_dir": project_root / "attachments",
|
||||
}
|
||||
256
app/extractor.py
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
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
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
config.example.json
Normal file
8
config.example.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"username": "你的工号",
|
||||
"password": "你的密码",
|
||||
"default_name": "默认报销人姓名",
|
||||
"default_card_no": "默认公务卡号",
|
||||
"default_person_id": "默认人员编号",
|
||||
"consumable_storage": "躬行楼 C205"
|
||||
}
|
||||
11
config.json
11
config.json
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"username": "202407021",
|
||||
"password": "wang!1624155937",
|
||||
"sso_login_url": "https://tyrz.fynu.edu.cn/sso/login",
|
||||
"portal_url": "https://tyrz.fynu.edu.cn/oshall",
|
||||
"reimburse_url": "http://210.45.32.214:8081",
|
||||
"reimburse_page": "/expen/common/common?v=4.0",
|
||||
"default_name": "王建锋",
|
||||
"default_card_no": "6282880139161682",
|
||||
"default_person_id": "202407021"
|
||||
}
|
||||
494
docs/API.md
Normal file
494
docs/API.md
Normal file
@@ -0,0 +1,494 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
---
|
||||
|
||||
# 财务报销自动化 — API 文档
|
||||
|
||||
> 基础地址: `http://localhost:5000`
|
||||
> 启动: `uv run python src/web/app.py`
|
||||
|
||||
## 总览
|
||||
|
||||
| # | 方法 | 路径 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | GET | `/` | PC 端主页 |
|
||||
| 2 | POST | `/api/session` | 创建会话 |
|
||||
| 3 | POST | `/api/upload/<session_id>` | 上传文件(PDF/图片) |
|
||||
| 4 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
|
||||
| 5 | POST | `/api/process/<session_id>` | 启动处理(提取+LLM识别+出库单) |
|
||||
| 6 | GET | `/api/logs/<session_id>` | SSE 日志流 |
|
||||
| 7 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
|
||||
| 8 | GET | `/api/data/<session_id>` | 获取发票数据(JSON) |
|
||||
| 9 | POST | `/api/save/<session_id>` | 保存编辑后的发票数据 |
|
||||
| 10 | POST | `/api/submit-financial/<session_id>` | 提交到财务系统 |
|
||||
| 11 | GET | `/mobile/<session_id>` | 移动端上传页面 |
|
||||
| 12 | POST | `/api/mobile-upload/<session_id>` | 移动端上传图片 |
|
||||
|
||||
---
|
||||
|
||||
## 会话与目录
|
||||
|
||||
- 调用 `POST /api/session` 获得 `session_id`
|
||||
- 该会话下所有文件存放在 `src/web/uploads/<session_id>/`
|
||||
- 典型产物:`invoice_summary.csv`、`易耗品、出库单.doc`、`config.json`、`session.log`、`result.json`
|
||||
|
||||
---
|
||||
|
||||
## 接口详情
|
||||
|
||||
### 1. 创建会话
|
||||
|
||||
```
|
||||
POST /api/session
|
||||
```
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{ "session_id": "a1b2c3d4e5f6" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 上传文件(PDF/图片)
|
||||
|
||||
```
|
||||
POST /api/upload/<session_id>
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| file | File | PDF 发票或支付截图 |
|
||||
|
||||
**响应(成功):**
|
||||
|
||||
```json
|
||||
{ "ok": true, "filename": "1. 电容一批.pdf" }
|
||||
```
|
||||
|
||||
**响应(失败):**
|
||||
|
||||
```json
|
||||
{ "error": "未选择文件" }
|
||||
```
|
||||
|
||||
HTTP `400`
|
||||
|
||||
---
|
||||
|
||||
### 3. 列出会话文件
|
||||
|
||||
```
|
||||
GET /api/files/<session_id>
|
||||
```
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"pdfs": ["1. 电容一批.pdf"],
|
||||
"images": ["payment_01.jpg"]
|
||||
}
|
||||
```
|
||||
|
||||
`images` 包含扩展名:`.png`、`.jpg`、`.jpeg`、`.bmp`、`.webp`。
|
||||
|
||||
---
|
||||
|
||||
### 5. 启动管道处理
|
||||
|
||||
```
|
||||
POST /api/process/<session_id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| username | string | 否 | 财务系统工号 |
|
||||
| password | string | 否 | 登录密码 |
|
||||
| default_name | string | 否 | 默认报销人姓名 |
|
||||
| default_card_no | string | 否 | 默认公务卡号 |
|
||||
| default_person_id | string | 否 | 默认人员编号 |
|
||||
| consumable_storage | string | 否 | 出库单存放地点;未填则用 `config.json` 中的值 |
|
||||
|
||||
**处理内容:**
|
||||
|
||||
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv`
|
||||
2. 对支付截图多模态 LLM 识别,回填刷卡字段
|
||||
3. 根据发票类型自动分类:差旅发票(高铁票/酒店住宿)不生成出库单;普通发票从模板复制并自动填写
|
||||
|
||||
配置会写入 `src/web/uploads/<session_id>/config.json`。
|
||||
|
||||
**响应(立即):**
|
||||
|
||||
```json
|
||||
{ "status": "started" }
|
||||
```
|
||||
|
||||
处理在后台线程执行,进度与结果通过 `GET /api/logs/<session_id>`(SSE)获取。
|
||||
|
||||
**SSE 完成时 `result` 示例(成功):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"elapsed": "45.2s",
|
||||
"invoice_count": 4,
|
||||
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
||||
"travel_count": 2,
|
||||
"general_count": 2,
|
||||
"doc_url": "/api/download/<session_id>/%E6%98%93%E8%80%97%E5%93%81%E3%80%81%E5%87%BA%E5%BA%93%E5%8D%95.doc",
|
||||
"doc_ok": true
|
||||
}
|
||||
```
|
||||
|
||||
**纯差旅发票(跳过出库单生成):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"invoice_count": 3,
|
||||
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
||||
"travel_count": 3,
|
||||
"general_count": 0,
|
||||
"doc_ok": null,
|
||||
"doc_skipped": true,
|
||||
"doc_message": "差旅发票无需生成易耗品出库单"
|
||||
}
|
||||
```
|
||||
|
||||
**出库单生成失败时(CSV 等仍可能成功):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"invoice_count": 4,
|
||||
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
||||
"travel_count": 2,
|
||||
"general_count": 2,
|
||||
"doc_ok": false,
|
||||
"doc_error": "服务器未安装 pywin32,无法生成 Word 出库单"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `travel_count` | int | 差旅发票数量(高铁票/酒店住宿) |
|
||||
| `general_count` | int | 普通发票数量 |
|
||||
| `doc_ok` | bool/null | `true`=成功,`false`=失败,`null`=已跳过(纯差旅发票) |
|
||||
| `doc_skipped` | bool | 是否因纯差旅发票而跳过出库单生成 |
|
||||
| `doc_message` | string | 跳过时的提示信息 |
|
||||
|
||||
---
|
||||
|
||||
### 6. SSE 日志流
|
||||
|
||||
```
|
||||
GET /api/logs/<session_id>
|
||||
Accept: text/event-stream
|
||||
```
|
||||
|
||||
**日志行格式:**
|
||||
|
||||
```
|
||||
data: 2026-05-26 12:00:01 [INFO ] extractor: 正在提取发票...
|
||||
```
|
||||
|
||||
**结束消息:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "done",
|
||||
"result": { }
|
||||
}
|
||||
```
|
||||
|
||||
`result` 结构取决于触发来源:
|
||||
|
||||
| 来源 | 典型字段 |
|
||||
|------|----------|
|
||||
| `/api/process` | `ok`, `elapsed`, `invoice_count`, `csv_url`, `travel_count`, `general_count`, `doc_url`, `doc_ok`, `doc_skipped`, `doc_error` |
|
||||
| `/api/submit-financial` | `ok`, `submit_ok`, `submit_error` |
|
||||
|
||||
> SSE 超时时间为 10 分钟(600 秒)。
|
||||
|
||||
---
|
||||
|
||||
### 7. 下载文件
|
||||
|
||||
```
|
||||
GET /api/download/<session_id>/<filename>
|
||||
```
|
||||
|
||||
**响应:** 文件二进制流,带 `Content-Disposition: attachment` 与 UTF-8 文件名。
|
||||
|
||||
| 扩展名 | Content-Type |
|
||||
|--------|----------------|
|
||||
| `.csv` | `text/csv; charset=utf-8` |
|
||||
| `.doc` | `application/msword` |
|
||||
| 其它 | `application/octet-stream` |
|
||||
|
||||
**常见文件名:**
|
||||
|
||||
| 文件名 | 说明 |
|
||||
|--------|------|
|
||||
| `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) |
|
||||
| `易耗品、出库单.doc` | 自动填写的出库单 |
|
||||
|
||||
**错误:**
|
||||
|
||||
```json
|
||||
{ "error": "文件不存在" }
|
||||
```
|
||||
|
||||
HTTP `404`。`filename` 仅允许会话目录内的文件名(防止路径穿越)。
|
||||
|
||||
---
|
||||
|
||||
### 8. 获取发票数据
|
||||
|
||||
```
|
||||
GET /api/data/<session_id>
|
||||
```
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"csv_filename": "invoice_summary.csv",
|
||||
"fields": [
|
||||
"序号", "发票号码", "开票日期", "项目名称", "规格型号",
|
||||
"价税合计", "销售方名称", "人员姓名", "刷卡日期",
|
||||
"公务卡号", "刷卡金额", "备注", "工号"
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"__row": 0,
|
||||
"序号": "1",
|
||||
"发票号码": "26442000005432755951",
|
||||
"价税合计": "2900.00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `fields`:列顺序
|
||||
- `data[].__row`:内部行索引(保存时不需要提交,服务端按数组顺序写回)
|
||||
|
||||
**错误:** `404` 未找到 CSV;`500` 读取失败。
|
||||
|
||||
---
|
||||
|
||||
### 9. 保存编辑后的发票数据
|
||||
|
||||
```
|
||||
POST /api/save/<session_id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"csv_filename": "invoice_summary.csv",
|
||||
"data": [
|
||||
{
|
||||
"序号": "1",
|
||||
"发票号码": "26442000005432755951",
|
||||
"开票日期": "2026/05/18",
|
||||
"项目名称": "...",
|
||||
"规格型号": "...",
|
||||
"价税合计": "2900.00",
|
||||
"销售方名称": "...",
|
||||
"人员姓名": "",
|
||||
"刷卡日期": "2026/04/28",
|
||||
"公务卡号": "",
|
||||
"刷卡金额": "2850.00",
|
||||
"备注": "",
|
||||
"工号": "202407021"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**响应(成功):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"doc_ok": true,
|
||||
"doc_url": "/api/download/<session_id>/%E6%98%93%E8%80%97%E5%93%81%E3%80%81%E5%87%BA%E5%BA%93%E5%8D%95.doc"
|
||||
}
|
||||
```
|
||||
|
||||
保存后会根据最新 CSV **重新生成** 出库单 Word(与会话 `config.json` 中的 `consumable_storage` 等配置一致)。
|
||||
|
||||
**纯差旅发票跳过出库单:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"doc_ok": null,
|
||||
"doc_skipped": true
|
||||
}
|
||||
```
|
||||
|
||||
**出库单生成失败:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"doc_ok": false,
|
||||
"doc_error": "出库单模板不存在,请将模板放在项目根目录"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. 提交到财务系统
|
||||
|
||||
```
|
||||
POST /api/submit-financial/<session_id>
|
||||
```
|
||||
|
||||
**前置条件:**
|
||||
|
||||
- 会话目录存在 `config.json`(由 `/api/process` 写入),否则返回 `400`
|
||||
- 存在可用的发票 CSV(通常为 `invoice_summary.csv`)
|
||||
|
||||
**说明:**
|
||||
|
||||
- 前端一般在提交前调用 `/api/save` 保存表格修改
|
||||
- 本接口**不会**自动执行发票提取或 LLM 识别
|
||||
- 根据发票类型选择填报模式:纯差旅发票走差旅报销流程,含普通发票走普通报销流程
|
||||
|
||||
**响应(立即):**
|
||||
|
||||
```json
|
||||
{ "status": "started" }
|
||||
```
|
||||
|
||||
**SSE 完成示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "done",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"submit_ok": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
失败时 `submit_ok: false`,`submit_error` 为错误描述。
|
||||
|
||||
---
|
||||
|
||||
### 11. 移动端上传页面
|
||||
|
||||
```
|
||||
GET /mobile/<session_id>
|
||||
```
|
||||
|
||||
返回移动端 HTML 页面。扫码上传的图片与 PC 端共用同一会话目录;PC 通过轮询 `GET /api/files/<session_id>` 同步文件列表。
|
||||
|
||||
---
|
||||
|
||||
### 12. 移动端上传图片
|
||||
|
||||
```
|
||||
POST /api/mobile-upload/<session_id>
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| file | File | 图片文件 |
|
||||
|
||||
逻辑与 `POST /api/upload/<session_id>` 相同。
|
||||
|
||||
---
|
||||
|
||||
## 错误码汇总
|
||||
|
||||
| HTTP | 场景 |
|
||||
|------|------|
|
||||
| 400 | 参数缺失、未找到配置等 |
|
||||
| 404 | `session_id` 不存在、文件不存在 |
|
||||
| 500 | CSV 读取失败等内部错误 |
|
||||
|
||||
统一错误体:
|
||||
|
||||
```json
|
||||
{ "error": "错误描述" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 发票类型分类
|
||||
|
||||
系统自动将发票分为两类,影响出库单生成和后续报销流程:
|
||||
|
||||
| 类型 | 判断依据 | 出库单 | 报销流程 |
|
||||
|------|----------|--------|----------|
|
||||
| 差旅发票 | 高铁票、酒店住宿等 | 不生成 | 差旅报销 |
|
||||
| 普通发票 | 其他(办公用品、耗材等) | 自动生成 | 普通报销 |
|
||||
|
||||
`/api/process` 和 `/api/save` 的响应中 `travel_count` / `general_count` 即为分类统计。
|
||||
|
||||
---
|
||||
|
||||
## 端到端流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PC as PC 端
|
||||
participant Server as Server
|
||||
participant Mobile as 移动端
|
||||
participant Word as Word COM
|
||||
|
||||
PC->>Server: POST /api/session
|
||||
Server-->>PC: session_id
|
||||
|
||||
PC->>Server: POST /api/upload/{sid}
|
||||
PC->>Server: POST /api/process/{sid}
|
||||
Note over Server: PDF 提取 + LLM 识别 + 写 config.json
|
||||
alt 含普通发票
|
||||
Server->>Word: 从模板复制并填写出库单
|
||||
else 纯差旅发票
|
||||
Note over Server: 跳过出库单生成
|
||||
end
|
||||
Server-->>PC: SSE done (csv_url, doc_url, ...)
|
||||
|
||||
PC->>Server: GET /api/data/{sid}
|
||||
Server-->>PC: fields + data
|
||||
PC->>Server: POST /api/save/{sid}
|
||||
Note over Server: 更新 CSV,重新生成出库单
|
||||
Server-->>PC: doc_url
|
||||
|
||||
PC->>Server: GET /api/download/{sid}/易耗品、出库单.doc
|
||||
|
||||
PC->>Server: POST /api/submit-financial/{sid}
|
||||
Note over Server: Playwright 浏览器填报
|
||||
Server-->>PC: SSE done (submit_ok)
|
||||
|
||||
Mobile->>Server: POST /api/mobile-upload/{sid}
|
||||
PC->>Server: GET /api/files/{sid} (轮询)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关 CLI
|
||||
|
||||
不经过 Web、在本地直接填写出库单:
|
||||
|
||||
```bash
|
||||
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
```
|
||||
|
||||
详见 [README.md](./README.md)。
|
||||
18
docs/README.md
Normal file
18
docs/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# docs — 公开文档目录
|
||||
|
||||
本目录存放面向项目用户和外部贡献者的公开文档及说明文件。
|
||||
|
||||
## 文档清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `API.md` | Web API 完整接口文档:路由、请求/响应格式、SSE 日志流、错误码、端到端流程 |
|
||||
| `报销操作指南.md` | 面向最终用户的操作步骤说明 |
|
||||
|
||||
## 文档边界
|
||||
|
||||
维护规范、实施方案、经验总结等内部资料统一放置在 `.agents/` 目录下,不混入本目录。
|
||||
454
docs/报销操作指南.md
Normal file
454
docs/报销操作指南.md
Normal file
@@ -0,0 +1,454 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
---
|
||||
|
||||
# 阜阳师范大学财务报销系统 - 自动化操作指南
|
||||
|
||||
> 适用场景:日常报销录入(支持 Web 界面和 CLI 两种方式)
|
||||
> 最后更新:2026-06-09
|
||||
|
||||
---
|
||||
|
||||
## 一、系统概览
|
||||
|
||||
```
|
||||
整体流程:
|
||||
|
||||
信息门户(SSO登录) → 财务系统入口 → 单点登录页 → 网络报销 → 日常报销录入
|
||||
(tyrz.fynu.edu.cn) (点击"财务系统") (新标签页) (a:has(img)) (/expen/common/common)
|
||||
|
||||
目标系统: http://210.45.32.214:8081
|
||||
用户: 张三 (工号: xxxxxxx)
|
||||
```
|
||||
|
||||
### 关键 URL
|
||||
|
||||
| 系统 | URL | 说明 |
|
||||
|------|-----|------|
|
||||
| SSO 登录 | `https://tyrz.fynu.edu.cn/sso/login` | 统一认证入口 |
|
||||
| 信息门户 | `https://tyrz.fynu.edu.cn/oshall` | 登录后跳转目标 |
|
||||
| 报销系统 | `http://210.45.32.214:8081` | 网络报销主系统 |
|
||||
| 日常报销录入 | `/expen/common/common?v=4.0` | 目标录入页面 |
|
||||
|
||||
---
|
||||
|
||||
## 二、两种使用方式
|
||||
|
||||
### 方式一:Web 界面(推荐)
|
||||
|
||||
适合日常使用,可视化操作,支持手机端拍照上传。
|
||||
|
||||
```bash
|
||||
uv run python src/web/app.py
|
||||
# 访问: http://localhost:5000
|
||||
```
|
||||
|
||||
### 方式二:CLI 命令行
|
||||
|
||||
适合脚本化、批量处理。
|
||||
|
||||
```bash
|
||||
# 全流程(发票提取 → LLM 识别 → 浏览器填报)
|
||||
uv run python src/main.py
|
||||
|
||||
# 分步执行
|
||||
uv run python src/main.py --step invoice # 仅发票提取
|
||||
uv run python src/main.py --step submit # 仅浏览器填报
|
||||
|
||||
# 覆盖登录凭据
|
||||
uv run python src/main.py -u 202407021 -p "your_password"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、数据准备
|
||||
|
||||
### 3.1 发票数据 CSV
|
||||
|
||||
上传 PDF 发票文件和支付截图,系统自动完成:
|
||||
1. 从 PDF 提取发票信息
|
||||
2. 多模态 LLM 识别支付截图中的刷卡信息
|
||||
3. 生成 `invoice_summary.csv`
|
||||
|
||||
### 3.2 发票类型分类
|
||||
|
||||
系统自动将发票分为两类,影响后续处理流程:
|
||||
|
||||
| 类型 | 判断依据 | 出库单 | 报销流程 |
|
||||
|------|----------|--------|----------|
|
||||
| 差旅发票 | 高铁票、酒店住宿等 | 不生成 | 差旅报销 |
|
||||
| 普通发票 | 其他(办公用品、耗材等) | 自动生成 | 普通报销 |
|
||||
|
||||
### 3.3 附件文件
|
||||
|
||||
PDF 发票文件需与支付截图配对上传。系统通过金额匹配自动关联发票和支付记录。
|
||||
|
||||
---
|
||||
|
||||
## 四、Web 界面操作流程
|
||||
|
||||
### 4.1 启动服务
|
||||
|
||||
```bash
|
||||
uv run python src/web/app.py
|
||||
# 访问: http://localhost:5000
|
||||
```
|
||||
|
||||
### 4.2 上传文件
|
||||
|
||||
1. **发票 PDF**:点击或拖拽上传 PDF 文件(支持多选)
|
||||
2. **支付截图**:点击或拖拽上传图片文件(支持多选)
|
||||
3. **手机扫码上传**:扫描页面二维码,通过手机拍照上传支付截图
|
||||
|
||||
### 4.3 配置信息
|
||||
|
||||
填写以下配置项(也可通过上传 `config.json` 快速填充):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 账号 | 财务系统工号 |
|
||||
| 密码 | 登录密码 |
|
||||
| 默认姓名 | 报销人姓名 |
|
||||
| 公务卡号 | 公务卡卡号 |
|
||||
| 人员编号 | 人员编号 |
|
||||
| 存放地点 | 出库单存放地点 |
|
||||
|
||||
### 4.4 开始处理
|
||||
|
||||
点击"开始处理"按钮,系统自动执行:
|
||||
|
||||
1. **发票提取**:从 PDF 提取发票信息
|
||||
2. **LLM 信息提取**:多模态 LLM 识别支付截图中的刷卡信息
|
||||
3. **发票分类**:自动区分差旅发票和普通发票
|
||||
4. **出库单生成**:普通发票自动生成易耗品出库单(差旅发票跳过)
|
||||
|
||||
处理过程中可在日志面板实时查看进度。
|
||||
|
||||
### 4.5 编辑发票数据
|
||||
|
||||
处理完成后,发票数据以可编辑表格形式展示:
|
||||
|
||||
- 直接点击单元格即可编辑
|
||||
- 修改后点击"提交到财务系统"时自动保存
|
||||
- 保存后会自动重新生成出库单
|
||||
|
||||
### 4.6 下载文件
|
||||
|
||||
处理完成后下载:
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `invoice_summary.csv` | 发票汇总数据 |
|
||||
| `易耗品、出库单.doc` | 自动填写的出库单(仅普通发票) |
|
||||
|
||||
### 4.7 提交到财务系统
|
||||
|
||||
确认数据无误后,点击"🚀 提交到财务系统"按钮,系统自动:
|
||||
|
||||
1. 登录信息门户
|
||||
2. 进入财务系统
|
||||
3. 创建报销单
|
||||
4. 填写基本信息
|
||||
5. 录入报销明细
|
||||
6. 录入支付方式
|
||||
7. 上传附件
|
||||
|
||||
---
|
||||
|
||||
## 五、CLI 执行流程
|
||||
|
||||
### 5.1 全流程执行
|
||||
|
||||
```bash
|
||||
uv run python src/main.py
|
||||
```
|
||||
|
||||
执行步骤:
|
||||
|
||||
```
|
||||
Step 1: 发票提取
|
||||
├── 扫描 PDF 发票文件
|
||||
├── 提取发票信息
|
||||
├── 生成 invoice_summary.csv
|
||||
└── 自动分类(差旅/普通)
|
||||
|
||||
Step 2: LLM 信息提取
|
||||
├── 扫描支付截图
|
||||
├── 多模态 LLM 识别刷卡信息
|
||||
├── 回填 CSV 刷卡字段
|
||||
└── 更新 invoice_summary.csv
|
||||
|
||||
Step 3: 报销提交
|
||||
├── 登录信息门户
|
||||
├── 进入财务系统
|
||||
├── 创建报销单
|
||||
├── 填写基本信息
|
||||
├── 录入报销明细
|
||||
├── 录入支付方式
|
||||
├── 上传附件
|
||||
└── 提交(需手动确认)
|
||||
```
|
||||
|
||||
### 5.2 分步执行
|
||||
|
||||
```bash
|
||||
# 仅发票提取
|
||||
uv run python src/main.py --step invoice
|
||||
|
||||
# 仅浏览器填报
|
||||
uv run python src/main.py --step submit
|
||||
```
|
||||
|
||||
### 5.3 上传目录
|
||||
|
||||
CLI 模式自动发现 `src/web/uploads/` 下最新的会话文件夹,该文件夹包含上传的 PDF 和图片。
|
||||
|
||||
---
|
||||
|
||||
## 六、页面元素速查
|
||||
|
||||
### 基本信息页
|
||||
|
||||
| 字段 | 选择器 | 操作 |
|
||||
|------|--------|------|
|
||||
| 报销说明 | `#EXPENEXPLAIN` | fill |
|
||||
| 项目代码 | `#PROJECTCODE` | click → 弹窗选择 |
|
||||
| 项目弹窗 | `#promodal .fixed-table-body tbody tr` | 点击第一行 |
|
||||
| 下一步按钮 | `#saveAndNext` | click |
|
||||
|
||||
### 报销明细页
|
||||
|
||||
| 字段 | 选择器 | 操作 |
|
||||
|------|--------|------|
|
||||
| 增加按钮 | `#insertDetail` | click |
|
||||
| 经济事项代码 | `#economicscode2` | click → 弹窗选择 |
|
||||
| 经济科目弹窗 | `#econmodal .fixed-table-body tbody tr` | 点击第 3 行 |
|
||||
| 单据数 | `input[name="expenPwCommondetail.HOWBILLS"]` | fill |
|
||||
| 报销总金额 | `#je_zwzcdz` | fill |
|
||||
| 确定按钮 | `#detailAdd` | click |
|
||||
|
||||
### 支付方式页
|
||||
|
||||
| 字段 | 选择器 | 操作 |
|
||||
|------|--------|------|
|
||||
| 增加按钮 | `#insertPay` | click |
|
||||
| 人员编号 | `#personid2` | fill |
|
||||
| 人员姓名 | `#accountname2` | fill |
|
||||
| 刷卡日期 | `#receiptdate2` | fill |
|
||||
| 公务卡号 | `#localaccount2` | fill |
|
||||
| 刷卡金额 | `#receiptmoney2` | fill |
|
||||
| 实报金额 | `#money2` | fill |
|
||||
| 商户 | `#merchant2` | fill |
|
||||
| 备注 | `#smark2` | fill |
|
||||
| 确定按钮 | `#payAdd` | click |
|
||||
|
||||
### 附件清单页
|
||||
|
||||
| 字段 | 选择器 | 操作 |
|
||||
|------|--------|------|
|
||||
| 增加按钮 | `#insertAcc` | click |
|
||||
| 附件类型 | `#fjlx` | select_option → '1'(发票) |
|
||||
| 附件说明 | `#fpsmxx` | fill |
|
||||
| 文件上传 | `#file` | set_input_files |
|
||||
| 确定按钮 | `#cjtj` | click |
|
||||
|
||||
### 提交
|
||||
|
||||
| 操作 | 选择器 |
|
||||
|------|--------|
|
||||
| 提交按钮 | `#submit` |
|
||||
| 提交按钮(备用) | `#submit2` |
|
||||
|
||||
---
|
||||
|
||||
## 七、数据流向
|
||||
|
||||
```
|
||||
PDF 发票 + 支付截图
|
||||
│
|
||||
▼ extract_invoices()
|
||||
│
|
||||
├── 读取 PDF 发票
|
||||
├── 提取发票信息
|
||||
├── 自动分类(差旅/普通)
|
||||
└── 输出: invoice_summary.csv
|
||||
│
|
||||
▼ enrich_with_llm()
|
||||
├── 多模态 LLM 识别支付截图
|
||||
├── 回填刷卡字段
|
||||
└── 更新 invoice_summary.csv
|
||||
│
|
||||
▼ fill_consumable_from_template()
|
||||
├── 仅普通发票
|
||||
├── 从模板复制出库单
|
||||
└── 自动填写出库单
|
||||
│
|
||||
▼ run_bot()
|
||||
├── 登录信息门户
|
||||
├── 进入财务系统
|
||||
├── 创建报销单
|
||||
├── 填写基本信息
|
||||
├── 录入报销明细
|
||||
├── 录入支付方式
|
||||
├── 上传附件
|
||||
└── 提交
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、关键设计说明
|
||||
|
||||
### 8.1 浏览器启动
|
||||
|
||||
系统使用 Playwright 的 `sync_playwright()` 启动 Chromium 浏览器,支持 `headless` 模式。每次运行均启动新浏览器实例,需重新登录。可通过 `headless` 参数控制是否显示浏览器窗口。
|
||||
|
||||
### 8.2 明细录入策略
|
||||
|
||||
系统采用"一条总明细"策略:将所有发票合并为一条报销明细,报销总金额为所有发票刷卡金额之和,单据数为发票总张数。支付方式则逐张发票分别录入,每张发票对应一条支付记录。
|
||||
|
||||
### 8.3 经济科目选择
|
||||
|
||||
系统在经济科目弹窗中固定选择第 3 行。如需更改科目,修改 `rows[2]` 的索引即可。
|
||||
|
||||
### 8.4 项目选择
|
||||
|
||||
系统在项目选择弹窗中固定选择第 1 行。如需更改项目,修改 `first_row` 的选择逻辑即可。
|
||||
|
||||
### 8.5 网络报销链接动态获取
|
||||
|
||||
单点登录页的"网络报销"链接参数每次不同,系统通过 `a:has(img[src*="wlbx"])` 精确定位链接,动态提取 `href` 属性后导航,不硬编码 URL。
|
||||
|
||||
### 8.6 发票类型分流
|
||||
|
||||
- **差旅发票**(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
||||
- **普通发票**:生成易耗品出库单,走普通报销流程
|
||||
|
||||
### 8.7 移动端同步
|
||||
|
||||
PC 端生成二维码指向移动端上传页面,手机端上传的图片通过轮询同步到 PC 端,实现跨设备协作。
|
||||
|
||||
---
|
||||
|
||||
## 九、配置说明
|
||||
|
||||
### config.json
|
||||
|
||||
项目根目录 `config.json` 包含默认配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "202407021",
|
||||
"password": "your_password",
|
||||
"sso_login_url": "https://tyrz.fynu.edu.cn/sso/login",
|
||||
"portal_url": "https://tyrz.fynu.edu.cn/oshall",
|
||||
"reimburse_url": "http://210.45.32.214:8081",
|
||||
"reimburse_page": "/expen/common/common?v=4.0",
|
||||
"default_name": "王建锋",
|
||||
"default_card_no": "6282880139161682",
|
||||
"default_person_id": "202407021",
|
||||
"consumable_storage": "躬行楼 C205",
|
||||
"llm": {
|
||||
"model": "qwen3.5-9b",
|
||||
"api_base": "http://100.123.83.115:1234/v1",
|
||||
"api_key": "123456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `username` | 财务系统工号 |
|
||||
| `password` | 登录密码 |
|
||||
| `sso_login_url` | SSO 登录地址 |
|
||||
| `portal_url` | 信息门户地址 |
|
||||
| `reimburse_url` | 报销系统地址 |
|
||||
| `reimburse_page` | 报销录入页面路径 |
|
||||
| `default_name` | 默认报销人姓名 |
|
||||
| `default_card_no` | 默认公务卡号 |
|
||||
| `default_person_id` | 默认人员编号 |
|
||||
| `consumable_storage` | 出库单存放地点 |
|
||||
| `llm.model` | LLM 模型名称 |
|
||||
| `llm.api_base` | LLM API 地址 |
|
||||
| `llm.api_key` | LLM API 密钥 |
|
||||
|
||||
---
|
||||
|
||||
## 十、日志与调试
|
||||
|
||||
### 日志输出
|
||||
|
||||
- 控制台实时输出(INFO 级别)
|
||||
- 文件日志:`logs/<日期>.log`(UTF-8 编码)
|
||||
- Web 界面:实时 SSE 日志流
|
||||
|
||||
### 截图保存
|
||||
|
||||
每个关键步骤自动截图到 `images/` 目录:
|
||||
|
||||
| 截图文件 | 对应步骤 |
|
||||
|----------|----------|
|
||||
| `debug_portal_loaded.png` | 登录成功 |
|
||||
| `debug_step3_project_modal.png` | 项目弹窗打开 |
|
||||
| `debug_step3_project_selected.png` | 项目选择完成 |
|
||||
| `debug_step3_done.png` | 基本信息完成 |
|
||||
| `debug_after_add_click.png` | 点击新增后 |
|
||||
| `debug_item_total.png` | 总明细录入完成 |
|
||||
| `debug_step5_done.png` | 支付方式完成 |
|
||||
| `debug_step6_done.png` | 附件上传完成 |
|
||||
| `debug_error.png` | 异常状态 |
|
||||
|
||||
### 超时设置
|
||||
|
||||
- 页面默认超时:30 秒
|
||||
- 登录门户等待:最多 30 秒
|
||||
- 单点登录页等待:最多 15 秒
|
||||
- SSE 日志流超时:10 分钟(600 秒)
|
||||
|
||||
---
|
||||
|
||||
## 十一、常见问题
|
||||
|
||||
| 问题 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| 登录超时 | SSO 需要手动验证码/微信扫码 | 手动完成验证后脚本继续 |
|
||||
| 未找到财务系统入口 | 门户页面结构变化 | 检查 `images/debug_*` 截图定位 |
|
||||
| 经济科目选择失败 | 弹窗加载延迟 | 检查超时设置,增加等待时间 |
|
||||
| 附件上传失败 | PDF 文件不存在或路径错误 | 确认 PDF 在当前工作目录 |
|
||||
| 金额不匹配 | 明细合计 ≠ 支付合计 | 检查 CSV 数据中刷卡金额 |
|
||||
| 提交被拦截 | 必填项为空 | 检查 `logs/<日期>.log` 定位失败步骤 |
|
||||
| LLM 多模态提取失败 | llama-index 版本不兼容 | 确保使用 llama-index-core >= 0.14.x,多模态消息使用 `blocks` 格式 |
|
||||
| 出库单生成失败 | 缺少 pywin32 或模板文件 | 安装 `pywin32`,确保项目根目录有 `易耗品、出库单.doc` 模板 |
|
||||
| 差旅发票生成了出库单 | 分类不准确 | 检查发票内容是否包含"高铁票""酒店住宿"等关键词 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、相关 CLI 命令
|
||||
|
||||
### 单独填写出库单
|
||||
|
||||
```bash
|
||||
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
```
|
||||
|
||||
### 分步执行管道
|
||||
|
||||
```bash
|
||||
# 仅发票提取
|
||||
uv run python src/main.py --step invoice
|
||||
|
||||
# 仅浏览器填报
|
||||
uv run python src/main.py --step submit
|
||||
```
|
||||
|
||||
### 启动 Web 服务
|
||||
|
||||
```bash
|
||||
uv run python src/web/app.py
|
||||
# 访问: http://localhost:5000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:API 文档
|
||||
|
||||
详细的 API 接口文档见 [API.md](./API.md)。
|
||||
@@ -1,5 +0,0 @@
|
||||
序号,发票号码,开票日期,项目名称,规格型号,价税合计,销售方名称,人员姓名,刷卡日期,公务卡号,刷卡金额,备注,工号
|
||||
1,26442000005432755951,2026/05/18,*电子元件*电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71,电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71,2900.00,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2850.00,,
|
||||
2,26442000005432652421,2026/05/18,*电子元件*电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71,电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71,2900.00,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2880.00,,
|
||||
3,26442000005432937661,2026/05/18,*集成电路*集成电路 LED一批 个 2638.92 1% 26.39,集成电路 LED一批 个 2638.92 1% 26.39,2665.31,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2661.61,,
|
||||
4,26442000005468940571,2026/05/18,*电子工业设备*元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48,元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48,96.00,东莞市长安顺淘电子工具经营部,陈陈,2026/05/07,,96.00,,
|
||||
|
@@ -1,11 +0,0 @@
|
||||
# 发票信息汇总表
|
||||
|
||||
| 序号 | 发票号码 | 开票日期 | 项目名称 | 规格型号 | 价税合计 | 销售方名称 | 人员姓名 | 刷卡日期 | 公务卡号 | 刷卡金额 | 备注 | 工号 |
|
||||
|------|------|------|------|------|------|------|------|------|------|------|------|------|
|
||||
| 1 | 26442000005432755951 | 2026/05/18 | *电子元件*电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71 | 电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71 | ¥2,900.00 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,850.00 | | |
|
||||
| 2 | 26442000005432652421 | 2026/05/18 | *电子元件*电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71 | 电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71 | ¥2,900.00 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,880.00 | | |
|
||||
| 3 | 26442000005432937661 | 2026/05/18 | *集成电路*集成电路 LED一批 个 2638.92 1% 26.39 | 集成电路 LED一批 个 2638.92 1% 26.39 | ¥2,665.31 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,661.61 | | |
|
||||
| 4 | 26442000005468940571 | 2026/05/18 | *电子工业设备*元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48 | 元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48 | ¥96.00 | 东莞市长安顺淘电子工具经营部 | 陈陈 | 2026/05/07 | | ¥96.00 | | |
|
||||
|
||||
**价税合计总计: ¥8,561.31**
|
||||
**刷卡金额总计: ¥8,487.61**
|
||||
49
pyproject.toml
Normal file
49
pyproject.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[project]
|
||||
name = "auto-reimbursement-system"
|
||||
version = "0.1.0"
|
||||
description = "财务报销自动化系统"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"flask>=3.0",
|
||||
"playwright>=1.40",
|
||||
"PyMuPDF>=1.24",
|
||||
"pywin32>=306",
|
||||
"llama-index>=0.12.0",
|
||||
"llama-index-llms-openai-like==0.7.2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=5.0",
|
||||
"ruff>=0.9",
|
||||
"mypy>=1.14",
|
||||
"deptry>=0.22",
|
||||
"pre-commit>=4.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tests.*"
|
||||
ignore_errors = true
|
||||
|
||||
[tool.deptry]
|
||||
ignore_notebooks = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
84
run_all.py
84
run_all.py
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
财务报销全流程编排脚本
|
||||
|
||||
依次执行:
|
||||
1. extract_invoice.py — 从 PDF 发票提取信息,生成 invoice_summary.csv
|
||||
2. extract_image_ocr.py — 从支付截图 OCR 识别刷卡信息,更新 CSV
|
||||
3. reimburse.py — 打开浏览器登录财务系统并自动填报
|
||||
|
||||
用法:
|
||||
python run_all.py # 默认执行全部三步
|
||||
python run_all.py --step invoice # 仅执行第 1 步
|
||||
python run_all.py --step ocr # 仅执行第 2 步
|
||||
python run_all.py --step submit # 仅执行第 3 步
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent.resolve()
|
||||
PYTHON = sys.executable
|
||||
|
||||
|
||||
def step(name: str, module: str, args: list[str]) -> bool:
|
||||
"""运行单个步骤,返回是否成功"""
|
||||
cmd = [PYTHON, str(PROJECT_DIR / module), *args]
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" [{name}] {module}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
result = subprocess.run(cmd, cwd=str(PROJECT_DIR))
|
||||
ok = result.returncode == 0
|
||||
|
||||
if ok:
|
||||
print(f"\n[{name}] 完成")
|
||||
else:
|
||||
print(f"\n[错误] {name} 失败 (exit code: {result.returncode})")
|
||||
|
||||
return ok
|
||||
|
||||
|
||||
def run_all() -> int:
|
||||
print("=" * 60)
|
||||
print(" 财务报销自动化流程")
|
||||
print(f" 工作目录: {PROJECT_DIR}")
|
||||
print(f" Python: {PYTHON}")
|
||||
print("=" * 60)
|
||||
|
||||
if not step("发票提取", "extract_invoice.py", []):
|
||||
return 1
|
||||
|
||||
if not step("OCR 识别", "extract_image_ocr.py", []):
|
||||
return 1
|
||||
|
||||
if not step("报销提交", "reimburse.py", ["--data", str(PROJECT_DIR / "invoice_summary.csv")]):
|
||||
return 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" 全流程执行完毕")
|
||||
print("=" * 60)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "--step":
|
||||
name = sys.argv[2]
|
||||
steps = {
|
||||
"invoice": ("发票提取", "extract_invoice.py", []),
|
||||
"ocr": ("OCR 识别", "extract_image_ocr.py", []),
|
||||
"submit": ("报销提交", "reimburse.py", ["--data", str(PROJECT_DIR / "invoice_summary.csv")]),
|
||||
}
|
||||
if name not in steps:
|
||||
print(f"未知步骤: {name},可选: {', '.join(steps)}")
|
||||
return 1
|
||||
label, module, args = steps[name]
|
||||
return 0 if step(label, module, args) else 1
|
||||
|
||||
return run_all()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
21
scripts/README.md
Normal file
21
scripts/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# scripts — 测试脚本目录
|
||||
|
||||
存放用于测试各模块功能的独立脚本,可直接运行。
|
||||
|
||||
## 脚本清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `test_multimodal.py` | 测试 PDF 多模态提取完整链路(PDF 渲染 + LLM 提取) |
|
||||
| `test_travel_info.py` | 测试差旅信息提取函数(数据从 `data/.invoice_cache` 缓存加载) |
|
||||
|
||||
## 运行方式
|
||||
|
||||
```bash
|
||||
uv run python scripts/test_multimodal.py
|
||||
uv run python scripts/test_travel_info.py
|
||||
```
|
||||
81
scripts/test_multimodal.py
Normal file
81
scripts/test_multimodal.py
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测试 PDF 多模态提取完整链路
|
||||
|
||||
用法:
|
||||
python scripts/test_multimodal.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Windows 终端强制 UTF-8
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT)) # noqa: E402
|
||||
|
||||
from src.doc.llm_extractor import extract_document # noqa: E402
|
||||
from src.doc.pdf import render_pdf_to_images # noqa: E402
|
||||
|
||||
|
||||
def test_render() -> None:
|
||||
"""测试 PDF 渲染"""
|
||||
pdf_path = ROOT / "事前申请单.pdf"
|
||||
if not pdf_path.exists():
|
||||
print(f"跳过: {pdf_path.name} 不存在")
|
||||
return
|
||||
|
||||
print("=" * 60)
|
||||
print("测试 PDF 渲染")
|
||||
print("=" * 60)
|
||||
try:
|
||||
images = render_pdf_to_images(pdf_path, dpi=150)
|
||||
if images:
|
||||
print(f"成功渲染 {len(images)} 页")
|
||||
for i, img_b64 in enumerate(images):
|
||||
print(f" 第 {i + 1} 页: base64 长度 {len(img_b64)} 字符")
|
||||
else:
|
||||
print("渲染返回空列表!")
|
||||
except ImportError as e:
|
||||
print(f"导入失败: {e}")
|
||||
except Exception as e:
|
||||
print(f"渲染异常: {e}")
|
||||
|
||||
|
||||
def test_multimodal_extract() -> None:
|
||||
"""测试完整的多模态提取链路"""
|
||||
pdf_path = ROOT / "事前申请单.pdf"
|
||||
if not pdf_path.exists():
|
||||
print(f"跳过: {pdf_path.name} 不存在")
|
||||
return
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("测试多模态提取")
|
||||
print("=" * 60)
|
||||
try:
|
||||
result = extract_document(pdf_path)
|
||||
if result:
|
||||
print("提取成功:")
|
||||
import json
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print("提取返回空字典!")
|
||||
except Exception as e:
|
||||
print(f"提取异常: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
test_render()
|
||||
test_multimodal_extract()
|
||||
print()
|
||||
print("测试完成!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
54
scripts/test_travel_info.py
Normal file
54
scripts/test_travel_info.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""直接测试 extract_travel_info 函数
|
||||
|
||||
所有数据从 .invoice_cache 缓存中自动加载,无需手动构造样本数据。
|
||||
|
||||
用法:
|
||||
python scripts/test_travel_info.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT)) # noqa: E402
|
||||
|
||||
from src.doc.llm_extractor import extract_travel_info # noqa: E402
|
||||
|
||||
|
||||
def main() -> None:
|
||||
source_dir = ROOT / "scripts" / "data"
|
||||
|
||||
if not source_dir.exists():
|
||||
print(f"源文件目录不存在: {source_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
print("=" * 60)
|
||||
print("测试 extract_travel_info(数据来自缓存)")
|
||||
print("=" * 60)
|
||||
print(f"源文件目录: {source_dir}")
|
||||
print()
|
||||
|
||||
try:
|
||||
result = extract_travel_info(
|
||||
source_dir=source_dir,
|
||||
)
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("提取结果:")
|
||||
print("=" * 60)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
print()
|
||||
print("测试通过!")
|
||||
except Exception as e:
|
||||
print(f"测试失败: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
94
src/README.md
Normal file
94
src/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
last_reviewed: 2026-06-12
|
||||
---
|
||||
|
||||
# src — 主源码目录
|
||||
|
||||
包含财务报销自动化系统的核心模块。
|
||||
|
||||
## 模块清单
|
||||
|
||||
| 文件/目录 | 说明 |
|
||||
|-----------|------|
|
||||
| `__init__.py` | 包初始化:提供 `get_logger()` 日志工厂(支持终端 + 文件双输出,按日期自动分文件) |
|
||||
| `config.py` | 配置加载:从 `config.json` 读取用户凭据和默认值,从环境变量读取服务端配置(SSO 地址、LLM 参数) |
|
||||
| `pipeline.py` | 流程编排:串联发票提取 → 类型判断 → 差旅/普通信息提取 → 浏览器填报,支持分步执行 |
|
||||
| `main.py` | CLI 入口:支持 `--step` 分步执行、`-u/-p` 覆盖凭据、`--cache-dir` 指定缓存目录 |
|
||||
| `bot/` | 浏览器自动化:Playwright 驱动的财务系统填报机器人(仅负责接收信息并填报) |
|
||||
| `doc/` | 文档处理模块:PDF 渲染、LLM 提取、支付匹配、发票分类、出库单生成 |
|
||||
| `web/` | Web 界面模块:Flask 应用、SSE 日志、可编辑表格、移动端上传、会话隔离 |
|
||||
|
||||
## 数据流
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[CLI/Web 入口] --> B["pipeline.py (编排)"]
|
||||
B --> C["doc/extractor.py (统一提取入口)"]
|
||||
C --> D["doc/pdf.py (PDF 渲染为图片)"]
|
||||
C --> E["doc/llm_extractor.py (多模态 LLM 识别)"]
|
||||
E --> F["发票 invoice_type=train/hotel/general"]
|
||||
E --> G["支付记录 invoice_type=payment"]
|
||||
E --> H["出差事前申请单 invoice_type=application"]
|
||||
C --> I["doc/matcher.py (发票与支付记录按金额匹配)"]
|
||||
I --> J["一对一匹配 发票数 == 刷卡数"]
|
||||
I --> K["一对多匹配 贪心算法 相对容差 3%"]
|
||||
C --> L["doc/invoice.py (CSV/JSON 读写)"]
|
||||
L --> M["payment_records.csv (支付记录级别)"]
|
||||
L --> N["invoice_summary.csv (发票级别)"]
|
||||
L --> O["travel_applications.json (出差申请单)"]
|
||||
B --> R{"判断报销类型"}
|
||||
R -->|差旅| T["doc/llm_extractor.py (差旅信息提取)"]
|
||||
R -->|普通| V["doc/llm_extractor.py (普通发票信息提取)"]
|
||||
T --> W["travel_info.json (差旅信息: 交通/住宿明细、补贴、附件清单)"]
|
||||
V --> X["normal_info.json (普通发票信息: 报销说明、发票总数、总金额、支付方式、附件清单)"]
|
||||
W --> P["bot/ (浏览器填报 - 仅接收信息并填报)"]
|
||||
X --> P
|
||||
P --> Q["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
|
||||
P --> S["普通模式: 基本信息 → 录入明细 → 支付信息 → 上传附件"]
|
||||
```
|
||||
|
||||
**数据流变更(2026-06-11):** 差旅信息提取从 `bot.py` 提升到 `pipeline.py` 编排层。在发票提取和匹配完成后立即判断报销类型,差旅发票调用 LLM 提取 `travel_info.json`,普通发票调用 LLM 提取 `normal_info.json`。Bot 仅负责接收信息并填报,不再承担信息提取职责。
|
||||
|
||||
## 文档处理子模块 (`doc/`)
|
||||
|
||||
详见 [`doc/README.md`](doc/README.md)
|
||||
|
||||
核心能力:
|
||||
- **统一文档提取**:LLM 自行判断文档类型(发票/支付记录/出差事前申请单),无需正则回退
|
||||
- **JSON 缓存**:提取结果缓存于 `.invoice_cache/`,避免重复处理
|
||||
- **金额匹配**:支持一对多匹配,相对容差 3%,未匹配发票单独列为记录
|
||||
- **差旅信息提取**:综合发票、支付记录和匹配结果,提取出差事由、地点、时间等
|
||||
- **普通发票信息提取**:综合普通发票、支付记录和匹配结果,提取报销说明、发票总数、总金额、支付方式、附件清单
|
||||
- **出库单生成**:将 CSV 数据填入 Word 模板(pywin32 COM,仅 Windows)
|
||||
|
||||
## Web 界面子模块 (`web/`)
|
||||
|
||||
详见 [`web/README.md`](web/README.md)
|
||||
|
||||
核心能力:
|
||||
- **会话隔离**:每次上传生成独立 `session_id`,文件/日志/配置/结果各自隔离
|
||||
- **移动端同步**:PC 端生成二维码指向 `/mobile/<sid>`,跨设备协作上传
|
||||
- **可编辑表格**:前端加载 CSV 数据,支持在线编辑后保存
|
||||
|
||||
## 启动方式
|
||||
|
||||
```bash
|
||||
# CLI 模式
|
||||
uv run python src/main.py --step all
|
||||
|
||||
# Web 模式
|
||||
uv run python src/web/app.py
|
||||
# 访问: http://localhost:5000
|
||||
```
|
||||
|
||||
## 发票类型与路由
|
||||
|
||||
系统根据 `invoice_type` 字段自动分流:
|
||||
|
||||
| 发票类型 | 走什么流程 | 是否生成出库单 |
|
||||
|----------|-----------|--------------|
|
||||
| `train` / `hotel` | 差旅报销 | 否 |
|
||||
| `general` | 普通报销 | 是(易耗品出库单) |
|
||||
| `application` | 出差事前申请单 | 否(单独存储为 JSON) |
|
||||
|
||||
**注意**:差旅发票和普通发票不支持混报,混合时会报错。
|
||||
58
src/__init__.py
Normal file
58
src/__init__.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""财务报销自动化工具包"""
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_LOG_FMT = "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s"
|
||||
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||
|
||||
|
||||
def _get_log_file() -> Path:
|
||||
"""返回当日日志文件路径,如 logs/2026-06-09.log"""
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return _LOG_DIR / f"{datetime.date.today():%Y-%m-%d}.log"
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""获取带时间戳的日志记录器
|
||||
|
||||
输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ...
|
||||
日志同时输出到终端和 logs/<日期>.log
|
||||
|
||||
只在最顶层的 logger 上添加标准 handler(stream + file),
|
||||
子 logger 通过 propagate 将日志传递给父 logger 统一处理。
|
||||
这样 _SSELogHandler 只需挂在父 logger 上即可捕获所有子日志。
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 检查是否有父 logger 已初始化标准 handler
|
||||
# 如果有,子 logger 不重复添加,靠 propagate 传递即可
|
||||
parent_name = name.rsplit(".", 1)[0] if "." in name else None
|
||||
parent_has_handlers = False
|
||||
if parent_name:
|
||||
parent = logging.getLogger(parent_name)
|
||||
parent_has_handlers = getattr(parent, "_standard_handlers_initialized", False)
|
||||
|
||||
if not getattr(logger, "_standard_handlers_initialized", False) and not parent_has_handlers:
|
||||
formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT)
|
||||
|
||||
# 只有没有已初始化的父 logger 时,才添加标准 handler
|
||||
# 终端输出
|
||||
utf8_stream = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
stream_handler = logging.StreamHandler(utf8_stream)
|
||||
stream_handler.setFormatter(formatter)
|
||||
logger.addHandler(stream_handler)
|
||||
|
||||
# 文件输出
|
||||
file_handler = logging.FileHandler(str(_get_log_file()), encoding="utf-8")
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
logger._standard_handlers_initialized = True # type: ignore[attr-defined]
|
||||
|
||||
return logger
|
||||
33
src/bot/README.md
Normal file
33
src/bot/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
last_reviewed: 2026-06-12
|
||||
---
|
||||
|
||||
# bot — 浏览器自动化填报模块
|
||||
|
||||
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
|
||||
|
||||
## 模块清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `__init__.py` | 对外入口:`run_bot()` 和 `run_bot_web()`,负责类型判断和流程路由 |
|
||||
| `base.py` | `BaseBot` 基类:浏览器生命周期、登录、导航、截图、日期格式化 |
|
||||
| `travel.py` | 差旅报销填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
|
||||
| `normal.py` | 普通发票报销填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
|
||||
|
||||
## 架构设计
|
||||
|
||||
```
|
||||
run_bot(config, travel_info, normal_info)
|
||||
├── 创建 BaseBot,启动浏览器,登录门户
|
||||
├── travel_info 存在 → travel.run(bot, travel_info)
|
||||
└── normal_info 存在 → normal.run(bot, normal_info)
|
||||
```
|
||||
|
||||
- **`BaseBot`** 只保留公共操作(launch、login、navigate、create_new_form、close、screenshot)
|
||||
- **差旅/普通流程** 作为独立函数接受 `bot: BaseBot` 参数,符合函数式编程偏好
|
||||
- **`__init__.py`** 仅做路由分发,不包含具体填报逻辑
|
||||
|
||||
## 变更历史
|
||||
|
||||
- **2026-06-12**:从 `bot.py` 单文件重构为 `bot/` 包,分离差旅和普通报销逻辑
|
||||
93
src/bot/__init__.py
Normal file
93
src/bot/__init__.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
浏览器自动化填报
|
||||
|
||||
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
|
||||
|
||||
对外接口:
|
||||
run_bot(config, travel_info, normal_info) 启动浏览器并执行填报流程
|
||||
run_bot_web(config, work_dir) Web 模式填报(从缓存加载信息)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from .base import BaseBot
|
||||
|
||||
log = get_logger("bot")
|
||||
|
||||
|
||||
def run_bot(
|
||||
config: dict[str, Any],
|
||||
headless: bool = False,
|
||||
work_dir: Path | None = None,
|
||||
travel_info: dict[str, Any] | None = None,
|
||||
normal_info: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""执行完整的浏览器填报流程,根据发票类型自动路由
|
||||
|
||||
Args:
|
||||
config: 配置字典。
|
||||
headless: 是否无头模式。
|
||||
work_dir: 工作目录。
|
||||
travel_info: 差旅信息(由 pipeline 层提前提取并传入,非差旅时传 None)。
|
||||
normal_info: 普通发票信息(由 pipeline 层提前提取并传入,非普通时传 None)。
|
||||
"""
|
||||
if not config["username"] or not config["password"]:
|
||||
raise ValueError("缺少用户名或密码")
|
||||
|
||||
if not work_dir:
|
||||
raise ValueError("缺少工作目录")
|
||||
|
||||
bot = BaseBot(config, headless=headless)
|
||||
bot.work_dir = work_dir
|
||||
|
||||
try:
|
||||
bot.launch()
|
||||
bot.login_portal()
|
||||
|
||||
if travel_info is not None:
|
||||
log.info("处理差旅发票...")
|
||||
bot.navigate_to_reimburse(page_key="travel_page")
|
||||
bot.create_new_form()
|
||||
from . import travel
|
||||
|
||||
travel.run(bot, travel_info)
|
||||
elif normal_info is not None:
|
||||
log.info("处理普通发票...")
|
||||
bot.navigate_to_reimburse(page_key="reimburse_page")
|
||||
bot.create_new_form()
|
||||
from . import normal
|
||||
|
||||
normal.run(bot, normal_info)
|
||||
else:
|
||||
raise ValueError("缺少差旅信息(travel_info)和普通发票信息(normal_info),无法继续填报")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"操作失败: {e}")
|
||||
try:
|
||||
bot._screenshot("error")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
bot.close()
|
||||
|
||||
|
||||
def run_bot_web(config: dict[str, Any], work_dir: Path) -> None:
|
||||
"""Web 模式填报 — headless,附件从指定目录读取
|
||||
|
||||
Web 端的信息提取由 app.py 的管道负责,此处从缓存加载。
|
||||
"""
|
||||
from ..doc.llm_extractor import load_cache
|
||||
|
||||
cache_map = load_cache(work_dir)
|
||||
travel_info = cache_map.get("travel_info")
|
||||
normal_info = cache_map.get("normal_info")
|
||||
run_bot(
|
||||
config,
|
||||
headless=True,
|
||||
work_dir=work_dir,
|
||||
travel_info=travel_info,
|
||||
normal_info=normal_info,
|
||||
)
|
||||
204
src/bot/base.py
Normal file
204
src/bot/base.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
浏览器自动化填报 — 公共基类
|
||||
|
||||
提供浏览器生命周期管理、登录、导航、截图等公共操作。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
|
||||
log = get_logger("bot")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 工具函数
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def format_date(date_str: str) -> str:
|
||||
"""将 '2026/5/13' 或 '2026-5-13' 转为 '2026-05-13'"""
|
||||
if not date_str:
|
||||
return ""
|
||||
parts = date_str.replace("-", "/").split("/")
|
||||
if len(parts) == 3:
|
||||
return f"{parts[0].zfill(4)}-{parts[1].zfill(2)}-{parts[2].zfill(2)}"
|
||||
return date_str
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 基类
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class BaseBot:
|
||||
"""浏览器自动化基类 — 管理浏览器生命周期与公共操作"""
|
||||
|
||||
def __init__(self, config: dict[str, Any], headless: bool = False) -> None:
|
||||
self.config = config
|
||||
self.headless = headless
|
||||
self.work_dir: Path | None = None
|
||||
self.browser: Any = None
|
||||
self.context: Any = None
|
||||
self.page: Any = None
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
self._pw_ctx = sync_playwright()
|
||||
self.pw = self._pw_ctx.__enter__()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 浏览器生命周期
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def launch(self) -> None:
|
||||
"""启动浏览器"""
|
||||
self.browser = self.pw.chromium.launch(headless=self.headless)
|
||||
self.context = self.browser.new_context(viewport={"width": 1360, "height": 768})
|
||||
self.page = self.context.new_page()
|
||||
self.page.set_default_timeout(30000)
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭浏览器"""
|
||||
if self.context:
|
||||
self.context.close()
|
||||
if self.browser:
|
||||
self.browser.close()
|
||||
try:
|
||||
self._pw_ctx.__exit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 登录
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def login_portal(self) -> None:
|
||||
"""登录信息门户"""
|
||||
log.info("登录信息门户...")
|
||||
|
||||
self.page.goto(self.config["sso_login_url"], wait_until="domcontentloaded")
|
||||
self._wait_for('text="微信扫码登录"', timeout=5000)
|
||||
|
||||
try:
|
||||
self.page.fill(
|
||||
'input[placeholder*="工号"], input[placeholder*="学号"]',
|
||||
self.config["username"],
|
||||
)
|
||||
self.page.fill('input[placeholder*="密码"]', self.config["password"])
|
||||
except Exception:
|
||||
log.warning("未找到登录输入框,可能已登录")
|
||||
|
||||
try:
|
||||
checkbox = self.page.query_selector('input[type="checkbox"]')
|
||||
if checkbox and not checkbox.is_checked():
|
||||
checkbox.click()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for selector in ['button:has-text("登录")', 'input[value="登录"]', 'text="登录"']:
|
||||
try:
|
||||
self.page.click(selector, timeout=3000)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
self._wait_for_portal()
|
||||
|
||||
def _wait_for_portal(self) -> None:
|
||||
"""等待跳转到统一信息平台"""
|
||||
for _ in range(30):
|
||||
self.page.wait_for_timeout(1000)
|
||||
url = self.page.url
|
||||
if any(
|
||||
kw in url
|
||||
for kw in (
|
||||
"tyrz.fynu.edu.cn/zs-uip",
|
||||
"tyrz.fynu.edu.cn/oshall",
|
||||
"portal",
|
||||
)
|
||||
):
|
||||
self._screenshot("portal_loaded")
|
||||
return
|
||||
log.error("等待门户跳转超时")
|
||||
self._screenshot("portal_timeout")
|
||||
raise TimeoutError("登录超时,未跳转到信息门户")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 导航
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
|
||||
"""从统一信息平台进入报销系统"""
|
||||
log.info("进入报销系统...")
|
||||
self._wait_for('text="快捷入口"', timeout=5000)
|
||||
|
||||
try:
|
||||
self.page.click('text="财务系统"', timeout=5000)
|
||||
except Exception:
|
||||
log.warning("未找到财务系统入口")
|
||||
|
||||
new_tab = None
|
||||
for _ in range(15):
|
||||
self.page.wait_for_timeout(1000)
|
||||
for p in self.context.pages:
|
||||
if "dddl" in p.url or "210.45.32.214" in p.url:
|
||||
new_tab = p
|
||||
break
|
||||
if new_tab:
|
||||
break
|
||||
|
||||
if new_tab:
|
||||
self.page = new_tab
|
||||
self._wait_for('text="网络报销"', timeout=5000)
|
||||
else:
|
||||
log.warning(f"未找到单点登录页面,当前 URL: {self.page.url}")
|
||||
|
||||
for p in self.context.pages[:-1]:
|
||||
try:
|
||||
p.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
link = self.page.query_selector('a:has(img[src*="wlbx"])')
|
||||
if link:
|
||||
reimburse_url = link.get_attribute("href")
|
||||
self.page.goto(reimburse_url, wait_until="domcontentloaded", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._wait_for('text="报销录入"', timeout=5000)
|
||||
common_url = self.config["reimburse_url"] + self.config[page_key]
|
||||
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
|
||||
self._wait_for('text="单据状态:"', timeout=5000)
|
||||
|
||||
def create_new_form(self) -> None:
|
||||
"""点击「新增」创建新单据"""
|
||||
log.info("创建新单据...")
|
||||
self.page.wait_for_timeout(2000)
|
||||
|
||||
try:
|
||||
self.page.click("#insert", timeout=5000)
|
||||
except Exception:
|
||||
try:
|
||||
self.page.click("text=新增", timeout=3000)
|
||||
except Exception as err:
|
||||
self._screenshot("no_add_button")
|
||||
raise RuntimeError("无法点击新增按钮") from err
|
||||
|
||||
self.page.wait_for_timeout(3000)
|
||||
self._screenshot("after_add_click")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 辅助方法
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
|
||||
self.page.wait_for_selector(selector, timeout=timeout)
|
||||
|
||||
def _screenshot(self, name: str) -> None:
|
||||
img_dir = Path(__file__).parent.parent.parent / "images"
|
||||
img_dir.mkdir(exist_ok=True)
|
||||
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
|
||||
176
src/bot/normal.py
Normal file
176
src/bot/normal.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
普通报销填报流程
|
||||
|
||||
负责普通发票报销的完整填报步骤:
|
||||
基本信息 → 总明细 → 支付方式 → 附件上传
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from .base import BaseBot, format_date
|
||||
|
||||
log = get_logger("bot.normal")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 普通报销流程
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def run(
|
||||
bot: BaseBot,
|
||||
normal_info: dict[str, Any],
|
||||
) -> None:
|
||||
"""执行普通发票报销填报流程
|
||||
|
||||
Args:
|
||||
bot: 已启动并登录的 BaseBot 实例。
|
||||
normal_info: 普通发票信息字典。
|
||||
"""
|
||||
log.info("开始普通发票报销填报...")
|
||||
|
||||
log.info("填写基本信息...")
|
||||
description = normal_info.get("basic_info", {}).get("reimbursement_description", "元器件采购报销")
|
||||
fill_basic_info(bot, description)
|
||||
|
||||
total_invoices = normal_info.get("reimbursement_details", {}).get("total_invoices", 0)
|
||||
total_amount = normal_info.get("reimbursement_details", {}).get("total_amount", 0)
|
||||
log.info(f"录入普通发票总明细 (共 {total_invoices} 张, 合计 ¥{total_amount:.2f})...")
|
||||
add_normal_item(bot, total_invoices, total_amount)
|
||||
|
||||
payment_info = normal_info.get("payment_methods", [])
|
||||
log.info(f"录入普通发票支付信息 (共 {len(payment_info)} 笔)...")
|
||||
fill_normal_payment(bot, payment_info)
|
||||
|
||||
log.info("上传普通发票附件...")
|
||||
attachment_info = normal_info.get("attachments", [])
|
||||
upload_normal_attachments(bot, attachment_info)
|
||||
|
||||
log.info("普通发票报销填报完成")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 基本信息
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_basic_info(bot: BaseBot, description: str = "元器件采购报销") -> None:
|
||||
"""填写基本信息"""
|
||||
|
||||
bot.page.fill("#EXPENEXPLAIN", description)
|
||||
bot.page.click("#PROJECTCODE", timeout=10000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
|
||||
first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr")
|
||||
|
||||
if first_row:
|
||||
first_row.click()
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
bot.page.click("#saveAndNext", timeout=5000)
|
||||
bot.page.wait_for_timeout(2000)
|
||||
|
||||
bot._screenshot("step3_done")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 总明细
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_normal_item(bot: BaseBot, total_invoices: int, total_amount: float) -> None:
|
||||
"""录入普通发票总明细"""
|
||||
try:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="经济事项名称"', timeout=5000)
|
||||
bot.page.click("#economicscode2")
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
try:
|
||||
bot.page.wait_for_selector("#econmodal .fixed-table-body tbody tr", timeout=10000)
|
||||
rows = bot.page.query_selector_all("#econmodal .fixed-table-body tbody tr")
|
||||
if len(rows) >= 3:
|
||||
rows[2].click()
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bot.page.fill('input[name="expenPwCommondetail.HOWBILLS"]', str(total_invoices))
|
||||
bot.page.fill("#je_zwzcdz", f"{total_amount:.2f}")
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
bot._screenshot("normal_item_done")
|
||||
except Exception as e:
|
||||
log.error(f"录入总明细失败: {e}")
|
||||
bot._screenshot("normal_item_error")
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 支付方式
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_normal_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> None:
|
||||
"""录入普通发票支付信息"""
|
||||
try:
|
||||
bot.page.click('text="下一步(支付方式)"', timeout=5000)
|
||||
bot._wait_for('text="下一步(附件清单)"', timeout=5000)
|
||||
|
||||
for info in payment_info:
|
||||
bot.page.click("#insertPay", timeout=5000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.fill("#personid2", bot.config["default_name"])
|
||||
bot.page.fill("#accountname2", bot.config["default_person_id"])
|
||||
bot.page.fill("#receiptdate2", format_date(str(info.get("card_date", ""))))
|
||||
bot.page.fill("#localaccount2", bot.config["default_card_no"])
|
||||
bot.page.fill("#receiptmoney2", str(info.get("card_amount", 0)))
|
||||
bot.page.fill("#money2", str(info.get("card_amount", 0)))
|
||||
bot.page.fill("#merchant2", str(info.get("merchant", "")))
|
||||
bot.page.fill("#smark2", str(info.get("remark", "")))
|
||||
bot.page.click("#payAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"支付方式录入失败: {e}")
|
||||
bot._screenshot("normal_payment_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("normal_payment_done")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 附件上传
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def upload_normal_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]]) -> None:
|
||||
"""上传普通发票附件"""
|
||||
log.info(f"上传普通发票附件 (共 {len(attachment_info)} 个)...")
|
||||
try:
|
||||
bot.page.click("#next3", timeout=5000)
|
||||
bot._wait_for("#submit2", timeout=5000)
|
||||
|
||||
for info in attachment_info:
|
||||
attachment_file = bot.work_dir / info["filename"]
|
||||
bot._wait_for("#insertAcc", timeout=20000)
|
||||
bot.page.click("#insertAcc", timeout=5000)
|
||||
bot._wait_for("#fjlx", timeout=5000)
|
||||
if info.get("attachment_type") == "invoice":
|
||||
bot.page.select_option("#fjlx", "1")
|
||||
else:
|
||||
bot.page.select_option("#fjlx", "2")
|
||||
bot.page.fill("#fpsmxx", info.get("attachment_desc", ""))
|
||||
if attachment_file and attachment_file.exists():
|
||||
bot.page.set_input_files("#file", str(attachment_file))
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.click("#cjtj", timeout=5000)
|
||||
log.info("上传普通发票附件完成")
|
||||
except Exception as e:
|
||||
log.error(f"附件上传失败: {e}")
|
||||
bot._screenshot("normal_attachment_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("normal_attachment_done")
|
||||
295
src/bot/travel.py
Normal file
295
src/bot/travel.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
差旅报销填报流程
|
||||
|
||||
负责差旅报销的完整填报步骤:
|
||||
基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from .base import BaseBot, format_date
|
||||
|
||||
log = get_logger("bot.travel")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 差旅填报流程
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def run(bot: BaseBot, travel_info: dict[str, Any]) -> None:
|
||||
"""执行差旅报销填报流程
|
||||
|
||||
Args:
|
||||
bot: 已启动并登录的 BaseBot 实例。
|
||||
travel_info: 差旅信息字典(包含 basic_info、reimbursement_details 等)。
|
||||
"""
|
||||
log.info("开始差旅报销填报...")
|
||||
|
||||
log.info("填写差旅报销信息...")
|
||||
basic_info = travel_info["basic_info"]
|
||||
fill_travel_info(bot, basic_info)
|
||||
|
||||
log.info("填写差旅报销明细...")
|
||||
travel_items = travel_info["reimbursement_details"]
|
||||
add_travel_items(bot, travel_items)
|
||||
|
||||
log.info("填写差旅报销支付方式...")
|
||||
payment_info = travel_info["payment_methods"]
|
||||
fill_travel_payment(bot, payment_info)
|
||||
|
||||
log.info("填写差旅报销补助清单...")
|
||||
subsidy_info = travel_info["subsidy_list"]
|
||||
fill_travel_subsidy(bot, subsidy_info)
|
||||
|
||||
log.info("上传差旅报销附件...")
|
||||
attachment_info = travel_info["attachments"]
|
||||
upload_travel_attachments(bot, attachment_info)
|
||||
|
||||
log.info("差旅报销填报完成")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 基本信息
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_travel_info(bot: BaseBot, basic_info: dict[str, Any]) -> None:
|
||||
"""填写差旅报销基本信息"""
|
||||
try:
|
||||
bot.page.fill("#CAUSE", basic_info.get("travel_purpose", ""))
|
||||
bot.page.fill("#SITE", basic_info.get("travel_location", ""))
|
||||
|
||||
bot.page.click("#PROJECTCODE", timeout=10000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
|
||||
first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr")
|
||||
if first_row:
|
||||
first_row.click()
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
bot.page.fill("#THEKSRQ", format_date(basic_info.get("start_date", "")))
|
||||
bot.page.fill("#THEJSRQ", format_date(basic_info.get("end_date", "")))
|
||||
bot.page.click("#saveAndNext", timeout=5000)
|
||||
bot.page.wait_for_timeout(2000)
|
||||
bot._screenshot("travel_basic_done")
|
||||
except Exception:
|
||||
log.error("填写基本信息失败")
|
||||
bot._screenshot("travel_basic_error")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 差旅明细
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_travel_items(bot: BaseBot, travel_items: dict[str, Any]) -> None:
|
||||
"""录入差旅报销明细"""
|
||||
vehicle_map = {
|
||||
"火车": "01",
|
||||
"汽车": "02",
|
||||
"轮船": "03",
|
||||
"自带车": "04",
|
||||
"公务车": "05",
|
||||
"飞机": "06",
|
||||
"租车": "07",
|
||||
"自驾车": "08",
|
||||
}
|
||||
|
||||
try:
|
||||
traffic_info = travel_items.get("transport_fee") or []
|
||||
for item in traffic_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
bot.page.select_option("#cost", "1")
|
||||
bot.page.wait_for_timeout(500)
|
||||
vehicle = item.get("vehicle_type", "")
|
||||
if vehicle in vehicle_map:
|
||||
bot.page.select_option("#jtgj", vehicle_map[vehicle])
|
||||
|
||||
bot.page.fill("#ksdd", item.get("departure_place", ""))
|
||||
bot.page.fill("#jsdd", item.get("arrival_place", ""))
|
||||
bot.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.HOWBILL"]',
|
||||
str(item.get("bill_count", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
hotel_info = travel_items.get("hotel_fee") or []
|
||||
for item in hotel_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
bot.page.select_option("#cost", "2")
|
||||
bot.page.wait_for_timeout(500)
|
||||
bot.page.fill("#ksrq2", format_date(str(item.get("checkin_date", ""))))
|
||||
bot.page.fill("#jsrq2", format_date(str(item.get("checkout_date", ""))))
|
||||
bot.page.fill("#ts2", str(item.get("days", "")))
|
||||
bot.page.fill("#rs2", str(item.get("person_count", "")))
|
||||
bot.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.FPMONEY"]',
|
||||
str(item.get("invoice_amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("reimburse_amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
conference_info = travel_items.get("conference_fee") or []
|
||||
for item in conference_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
bot.page.select_option("#cost", "3")
|
||||
bot.page.wait_for_timeout(500)
|
||||
bot.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.HOWBILL"]',
|
||||
str(item.get("bill_count", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"录入总明细失败: {e}")
|
||||
bot._screenshot("item_total_error")
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 支付方式
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_travel_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> None:
|
||||
"""录入差旅支付信息"""
|
||||
try:
|
||||
bot.page.click('text="下一步(支付方式)"', timeout=5000)
|
||||
bot._wait_for('text="下一步(补助清单)"', timeout=5000)
|
||||
|
||||
for info in payment_info:
|
||||
bot.page.click("#insertPay", timeout=5000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.fill("#personid2", bot.config["default_name"])
|
||||
bot.page.fill("#accountname2", bot.config["default_person_id"])
|
||||
bot.page.fill("#receiptdate2", format_date(info["card_date"]))
|
||||
bot.page.fill("#localaccount2", bot.config["default_card_no"])
|
||||
bot.page.fill("#receiptmoney2", str(info["card_amount"]))
|
||||
bot.page.fill("#money2", str(info["card_amount"]))
|
||||
bot.page.fill("#merchant2", info.get("merchant", ""))
|
||||
bot.page.fill("#smark2", info.get("remark", ""))
|
||||
bot.page.click("#payAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"支付方式录入失败: {e}")
|
||||
bot._screenshot("step5_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("step5_done")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 补助清单
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_travel_subsidy(bot: BaseBot, subsidy_info: list[dict[str, Any]]) -> None:
|
||||
"""录入差旅补助清单"""
|
||||
try:
|
||||
bot.page.click("#next3", timeout=5000)
|
||||
bot._wait_for("#next4", timeout=5000)
|
||||
|
||||
for info in subsidy_info:
|
||||
bot.page.click("#insertSubsidy", timeout=5000)
|
||||
bot._wait_for('text="增加补助清单"', timeout=5000)
|
||||
bot.page.click("#jzg3", timeout=5000)
|
||||
bot.page.wait_for_timeout(500)
|
||||
if info["person_name"] and info["person_name"] != "":
|
||||
bot.page.fill("#seacher", info["person_name"])
|
||||
elif info["person_id"] and info["person_id"] != "":
|
||||
bot.page.fill("#seacher", info["person_id"])
|
||||
else:
|
||||
raise ValueError(f"人员编号和人员姓名不能同时为空: {info}")
|
||||
bot.page.click("#cx", timeout=5000)
|
||||
bot.page.wait_for_selector("div.fixed-table-loading", state="hidden", timeout=10000)
|
||||
bot.page.click("#tableEmp tbody tr", timeout=10000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
open_bank = bot.page.input_value("#openbank1")
|
||||
if not open_bank:
|
||||
log.info("员工开户行未填写,默认填写中国工商银行")
|
||||
bot.page.fill("#openbank1", "中国工商银行")
|
||||
|
||||
bot.page.fill("#startdate1", format_date(info["start_date"]))
|
||||
bot.page.fill("#enddate1", format_date(info["end_date"]))
|
||||
bot.page.fill("#trafficdays1", str(info["days"]))
|
||||
bot.page.fill("#fooddays1", str(info["days"]))
|
||||
bot.page.fill("#trafficnorm1", str(80))
|
||||
bot.page.fill("#foodnorm1", str(100))
|
||||
trafficmoney = int(info["days"]) * 80
|
||||
foodmoney = int(info["days"]) * 100
|
||||
subsidymoney = trafficmoney + foodmoney
|
||||
bot.page.fill("#trafficmoney1", str(trafficmoney))
|
||||
bot.page.fill("#foodmoney1", str(foodmoney))
|
||||
bot.page.fill("#subsidymoney1", str(subsidymoney))
|
||||
bot.page.click("#add", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"差旅补助清单录入失败: {e}")
|
||||
bot._screenshot("subsidy_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("subsidy_done")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 附件上传
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def upload_travel_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]]) -> None:
|
||||
"""上传差旅附件"""
|
||||
try:
|
||||
bot.page.click("#next4", timeout=5000)
|
||||
bot._wait_for("#submit2", timeout=5000)
|
||||
|
||||
for info in attachment_info:
|
||||
attachment_file = bot.work_dir / info["filename"]
|
||||
bot._wait_for("#insertAcc", timeout=20000)
|
||||
bot.page.click("#insertAcc", timeout=5000)
|
||||
bot._wait_for("#fjlx", timeout=5000)
|
||||
if info["attachment_type"] == "invoice":
|
||||
bot.page.select_option("#fjlx", "1")
|
||||
else:
|
||||
bot.page.select_option("#fjlx", "2")
|
||||
bot.page.fill("#fpsmxx", info["attachment_desc"])
|
||||
if attachment_file and attachment_file.exists():
|
||||
bot.page.set_input_files("#file", str(attachment_file))
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.click("#cjtj", timeout=5000)
|
||||
log.info("上传差旅附件完成")
|
||||
except Exception as e:
|
||||
log.error(f"差旅附件上传失败: {e}")
|
||||
bot._screenshot("travel_attachment_error")
|
||||
raise
|
||||
bot._screenshot("travel_attachment_done")
|
||||
45
src/config.py
Normal file
45
src/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
配置加载
|
||||
|
||||
从 scripts/data/config.json 读取用户配置,从环境变量读取服务端配置(LLM + 系统 URL)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_CONFIG_PATH = Path(__file__).parent.parent.parent / "scripts" / "data" / "config.json"
|
||||
|
||||
|
||||
def load_config() -> dict[str, str | Path]:
|
||||
"""加载并合并配置,缺失字段使用默认值"""
|
||||
raw = {}
|
||||
if _CONFIG_PATH.exists():
|
||||
with open(_CONFIG_PATH, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
project_root = _CONFIG_PATH.parent
|
||||
|
||||
return {
|
||||
"sso_login_url": os.environ.get("SSO_LOGIN_URL", "https://tyrz.fynu.edu.cn/sso/login"),
|
||||
"portal_url": os.environ.get("PORTAL_URL", "https://tyrz.fynu.edu.cn/oshall"),
|
||||
"reimburse_url": os.environ.get("REIMBURSE_URL", "http://210.45.32.214:8081"),
|
||||
"reimburse_page": os.environ.get("REIMBURSE_PAGE", "/expen/common/common?v=4.0"),
|
||||
"travel_page": os.environ.get("TRAVEL_PAGE", "/expen/travel/travel?v=4.0"),
|
||||
"username": raw.get("username", ""),
|
||||
"password": raw.get("password", ""),
|
||||
"default_name": raw.get("default_name", ""),
|
||||
"default_card_no": raw.get("default_card_no", ""),
|
||||
"default_person_id": raw.get("default_person_id", ""),
|
||||
"consumable_storage": raw.get("consumable_storage", "躬行楼 C205"),
|
||||
"attachment_dir": project_root / "attachments",
|
||||
}
|
||||
|
||||
|
||||
def get_llm_config() -> dict[str, str]:
|
||||
"""加载 LLM 配置,优先从环境变量读取,缺失字段使用默认值"""
|
||||
return {
|
||||
"model": os.environ.get("LLM_MODEL", "qwen-vl-max"),
|
||||
"api_base": os.environ.get("LLM_API_BASE", "http://localhost:8080/v1"),
|
||||
"api_key": os.environ.get("LLM_API_KEY", "lm-studio"),
|
||||
}
|
||||
60
src/doc/README.md
Normal file
60
src/doc/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# src/doc — 文档处理模块
|
||||
|
||||
负责发票信息提取、基于 LLM 的支付截图信息识别、差旅/普通报销信息提取、以及将数据填入 Word 出库单模板。
|
||||
|
||||
## 模块清单
|
||||
|
||||
|
||||
| 文件 | 作用 |
|
||||
| ------------------------ | -------------------------------------------------- |
|
||||
| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 |
|
||||
| `pdf.py` | PDF 图片渲染(PyMuPDF) |
|
||||
| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态 + 差旅/普通报销信息综合提取) |
|
||||
| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 |
|
||||
| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 |
|
||||
| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板(pywin32 COM) |
|
||||
| `prompt.py` | LLM 提示词模板加载 |
|
||||
| `prompts/` | 提示词模板文件(`invoice_system.md`、`travel_info_system.md`、`normal_info_system.md`) |
|
||||
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
|
||||
支付截图 → llm_extractor.py → [刷卡记录]
|
||||
↓
|
||||
matcher.py(按金额贪心匹配,相对容差 3%)
|
||||
↓
|
||||
invoice.py 分类 → CSV(已回填刷卡日期/卡号/金额)
|
||||
↓
|
||||
fill_consumable_doc → 易耗品出库单.doc
|
||||
|
||||
[发票列表 + 匹配结果] → llm_extractor.py
|
||||
↓
|
||||
差旅发票 → extract_travel_info() → travel_info.json
|
||||
普通发票 → extract_normal_info() → normal_info.json
|
||||
```
|
||||
|
||||
## 依赖说明
|
||||
|
||||
- **PyMuPDF (pymupdf)** — PDF 图片渲染
|
||||
- **pywin32** — Word COM 自动化(仅 Windows)
|
||||
- **llama-index** — LLM 信息提取
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `fill_consumable_doc.py` 依赖 Microsoft Word + COM,仅 Windows 可用
|
||||
- LLM 提取不会覆盖 CSV 中已有非空字段
|
||||
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
|
||||
- LLM 提取失败时直接报错,无正则回退
|
||||
|
||||
## 变更说明(2026-06-11)
|
||||
|
||||
- `llm_extractor.py` 新增 `extract_normal_info()`:综合普通发票、支付记录和匹配结果,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json`
|
||||
- `llm_extractor.py` 的 `load_cache()` 扩展支持加载 `normal_info.json`
|
||||
- `prompt.py` 新增 `build_normal_info_system_prompt()`:加载 `normal_info_system.md`
|
||||
- `prompts/` 新增 `normal_info_system.md`:普通发票信息提取的系统提示词
|
||||
4
src/doc/__init__.py
Normal file
4
src/doc/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""文档处理模块
|
||||
|
||||
包含发票提取、LLM 信息提取、出库单填写等功能。
|
||||
"""
|
||||
241
src/doc/extractor.py
Normal file
241
src/doc/extractor.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""发票提取编排
|
||||
|
||||
统一扫描目录下所有文件(PDF + 图片),通过 LLM 提取结构化数据,
|
||||
根据 LLM 返回的「invoice_type」字段自动分类为发票/支付记录/出差事前申请单。
|
||||
|
||||
对外接口:
|
||||
extract_invoices(directory) -> tuple[list[dict], list[dict], dict]
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from .llm_extractor import extract_document
|
||||
from .matcher import match_invoices_to_cards
|
||||
|
||||
log = get_logger("extractor")
|
||||
|
||||
|
||||
def _classify_invoice_batch(
|
||||
invoices: list[dict[str, str]],
|
||||
) -> dict[str, list[dict[str, str]]]:
|
||||
"""按发票类型分组"""
|
||||
travel: list[dict[str, str]] = []
|
||||
general: list[dict[str, str]] = []
|
||||
application: list[dict[str, str]] = []
|
||||
for inv in invoices:
|
||||
inv_type = inv.get("invoice_type", "general")
|
||||
if inv_type == "application":
|
||||
application.append(inv)
|
||||
elif inv_type in ("train", "hotel"):
|
||||
travel.append(inv)
|
||||
else:
|
||||
general.append(inv)
|
||||
return {"travel": travel, "general": general, "application": application}
|
||||
|
||||
|
||||
# JSON 缓存目录(相对于源文件目录)
|
||||
CACHE_DIR_NAME = ".invoice_cache"
|
||||
|
||||
# 支持的文件扩展名
|
||||
SUPPORTED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".bmp"}
|
||||
|
||||
|
||||
def _get_cache_dir(source_dir: Path) -> Path:
|
||||
"""获取缓存目录路径"""
|
||||
cache_dir = source_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
|
||||
def _get_json_path(file_path: Path, cache_dir: Path) -> Path:
|
||||
"""根据文件路径生成对应的 JSON 缓存路径(包含后缀名以区分同名的 PDF/图片)"""
|
||||
return cache_dir / f"{file_path.stem}{file_path.suffix}.json"
|
||||
|
||||
|
||||
def _save_to_cache(file_path: Path, extracted_data: dict[str, Any], cache_dir: Path) -> Path:
|
||||
"""将提取结果保存到 JSON 缓存文件,并记录源文件路径和后缀名"""
|
||||
json_path = _get_json_path(file_path, cache_dir)
|
||||
cache_data = {
|
||||
"source_file": str(file_path),
|
||||
"source_filename": file_path.name,
|
||||
"source_extension": file_path.suffix.lower(),
|
||||
"extracted_data": extracted_data,
|
||||
}
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(cache_data, f, ensure_ascii=False, indent=2)
|
||||
log.info(f"提取结果已缓存: {json_path.name}")
|
||||
return json_path
|
||||
|
||||
|
||||
def _load_from_cache(json_path: Path, expected_extension: str | None = None) -> dict[str, Any] | None:
|
||||
"""从 JSON 缓存文件加载提取结果,可选校验后缀名一致性"""
|
||||
if not json_path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(json_path, encoding="utf-8") as f:
|
||||
cache_data: dict[str, Any] = json.load(f)
|
||||
# 校验后缀名是否一致,防止同名不同后缀的文件误命中缓存
|
||||
if expected_extension and cache_data.get("source_extension", "").lower() != expected_extension.lower():
|
||||
return None
|
||||
result: dict[str, Any] | None = cache_data.get("extracted_data")
|
||||
return result
|
||||
except Exception as e:
|
||||
log.warning(f"读取缓存失败 {json_path.name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _extract_document(file_path: Path, cache_dir: Path) -> dict[str, str] | None:
|
||||
"""提取单个文件的结构化信息,优先使用缓存。
|
||||
|
||||
根据 LLM 返回的「invoice_type」字段自动分类:
|
||||
- "payment" -> 支付记录
|
||||
- "application" -> 申请单
|
||||
- 有 "invoice_number" -> 发票
|
||||
- 其他 -> 无法识别
|
||||
|
||||
Args:
|
||||
file_path: 文件路径(PDF 或图片)。
|
||||
cache_dir: JSON 缓存目录。
|
||||
|
||||
Returns:
|
||||
提取结果字典,失败时返回 None。
|
||||
"""
|
||||
json_path = _get_json_path(file_path, cache_dir)
|
||||
cached = _load_from_cache(json_path, expected_extension=file_path.suffix.lower())
|
||||
if cached:
|
||||
cached["_source_file"] = file_path.name
|
||||
log.info(f"使用缓存: {file_path.name}")
|
||||
return cached
|
||||
|
||||
log.info(f"使用多模态提取: {file_path.name}")
|
||||
try:
|
||||
result = extract_document(file_path)
|
||||
if result:
|
||||
result["_source_file"] = file_path.name
|
||||
_save_to_cache(file_path, result, cache_dir)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.warning(f"多模态提取失败: {file_path.name} ({e})")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_all_files(directory: str) -> list[Path]:
|
||||
"""扫描目录下所有支持的文件(PDF + 图片)"""
|
||||
dir_path = Path(directory)
|
||||
files = [f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS]
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def _save_match_result(payment_records: list[dict[str, Any]], cache_dir: Path) -> None:
|
||||
"""将发票与支付记录的匹配结果保存到缓存。"""
|
||||
match_data: dict[str, list[dict[str, Any]]] = {}
|
||||
for record in payment_records:
|
||||
card_source = record.get("_source_file", "")
|
||||
matched = record.get("_matched_invoices", [])
|
||||
card_amount = record.get("card_amount", "")
|
||||
|
||||
if matched:
|
||||
invoice_list = [
|
||||
{
|
||||
"file": inv.get("_source_file", ""),
|
||||
"type": inv.get("invoice_type", ""),
|
||||
"amount": inv.get("total_amount", inv.get("total_amount", "")),
|
||||
}
|
||||
for inv in matched
|
||||
]
|
||||
|
||||
if card_source:
|
||||
match_data[f"{card_source} (¥{card_amount})"] = invoice_list
|
||||
else:
|
||||
key = "__unmatched__"
|
||||
if key not in match_data:
|
||||
match_data[key] = []
|
||||
match_data[key].extend(invoice_list)
|
||||
|
||||
if not match_data:
|
||||
return
|
||||
|
||||
match_path = cache_dir / "match_result.json"
|
||||
with open(match_path, "w", encoding="utf-8") as f:
|
||||
json.dump(match_data, f, ensure_ascii=False, indent=2)
|
||||
log.info(f"匹配结果已缓存: {match_path.name}")
|
||||
|
||||
|
||||
def extract_invoices(
|
||||
directory: str = ".",
|
||||
) -> tuple[list[dict[str, str]], list[dict[str, str]], dict[str, list[dict[str, str]]]]:
|
||||
"""扫描目录下所有文件,提取信息并匹配支付记录
|
||||
|
||||
统一使用 LLM 提取,根据返回的「invoice_type」自动分类:
|
||||
- 发票(有 invoice_number)-> 参与金额匹配
|
||||
- 支付记录(invoice_type="payment")-> 参与金额匹配
|
||||
- 出差事前申请单 -> 单独存储,不参与匹配
|
||||
|
||||
Returns:
|
||||
(payment_records, applications, groups):
|
||||
- payment_records: 支付记录列表(仅包含真实发票,不含申请单)
|
||||
- applications: 出差事前申请单列表(单独存储,不参与支付匹配)
|
||||
- groups: 按文档类型分组的字典
|
||||
{'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单]}
|
||||
"""
|
||||
source_dir = Path(directory)
|
||||
cache_dir = _get_cache_dir(source_dir)
|
||||
|
||||
all_files = _find_all_files(directory)
|
||||
if not all_files:
|
||||
log.warning("未找到支持的文件")
|
||||
return [], [], {"travel": [], "general": [], "application": []}
|
||||
|
||||
log.info(f"发现 {len(all_files)} 个文件")
|
||||
|
||||
all_invoices = []
|
||||
all_cards = []
|
||||
applications = []
|
||||
|
||||
for file_path in all_files:
|
||||
result = _extract_document(file_path, cache_dir)
|
||||
|
||||
if not result:
|
||||
log.warning(f"未能解析: {file_path.name}")
|
||||
continue
|
||||
|
||||
inv_type = result.get("invoice_type", "")
|
||||
|
||||
if inv_type == "application":
|
||||
applications.append(result)
|
||||
log.info(f"[{inv_type}] 已解析: {file_path.name}")
|
||||
elif inv_type == "payment":
|
||||
all_cards.append(result)
|
||||
log.info(f"[{inv_type}] 已解析: {file_path.name}")
|
||||
elif result.get("invoice_number"):
|
||||
all_invoices.append(result)
|
||||
log.info(f"[{inv_type}] 已解析: {file_path.name}")
|
||||
else:
|
||||
log.warning(f"无法分类: {file_path.name} (invoice_type={inv_type})")
|
||||
|
||||
log.info(f"分类结果: 发票 {len(all_invoices)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)} 份")
|
||||
|
||||
if not all_invoices:
|
||||
log.warning("未成功解析任何发票")
|
||||
|
||||
payment_records = match_invoices_to_cards(all_invoices, all_cards)
|
||||
_save_match_result(payment_records, cache_dir)
|
||||
|
||||
# 构建分类
|
||||
all_documents: list[dict[str, str]] = []
|
||||
for record in payment_records:
|
||||
all_documents.extend(record.get("_matched_invoices", []))
|
||||
all_documents.extend(applications)
|
||||
|
||||
groups = _classify_invoice_batch(all_documents)
|
||||
log.info(
|
||||
f"文档分类: 差旅发票 {len(groups['travel'])} 张, "
|
||||
f"普通发票 {len(groups['general'])} 张, "
|
||||
f"出差事前申请单 {len(groups['application'])} 份"
|
||||
)
|
||||
|
||||
return payment_records, applications, groups
|
||||
259
src/doc/fill_consumable_doc.py
Normal file
259
src/doc/fill_consumable_doc.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。
|
||||
|
||||
仅写入表格数据单元格,保留原模板字体、边框与版式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from ..config import load_config
|
||||
from ..doc.invoice import load_invoice_csv
|
||||
|
||||
log = get_logger("fill_consumable_doc")
|
||||
|
||||
CONSUMABLE_DOC_FILENAME = "易耗品、出库单.doc"
|
||||
|
||||
# Word COM 常量
|
||||
WD_CHARACTER = 1
|
||||
|
||||
# 表格统一字体:宋体、五号(10.5 磅)
|
||||
TABLE_FONT_NAME = "宋体"
|
||||
TABLE_FONT_SIZE = 10.5
|
||||
|
||||
|
||||
def _split_name_spec(left: str) -> tuple[str, str]:
|
||||
m = re.search(r"(\S+一批)\s*$", left)
|
||||
if m:
|
||||
return m.group(1), left[: m.start()].strip()
|
||||
parts = left.split(" ", 1)
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return left, ""
|
||||
|
||||
|
||||
def parse_spec_model(spec: str) -> dict[str, str]:
|
||||
spec = (spec or "").strip()
|
||||
if " 个 " not in spec:
|
||||
return {
|
||||
"product_name": spec,
|
||||
"spec": "",
|
||||
"unit": "",
|
||||
"qty": "",
|
||||
"unit_price": "",
|
||||
}
|
||||
|
||||
left, right = spec.split(" 个 ", 1)
|
||||
product_name, model_spec = _split_name_spec(left.strip())
|
||||
tokens = right.split()
|
||||
|
||||
qty = ""
|
||||
unit_price = ""
|
||||
if len(tokens) >= 4 and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]):
|
||||
qty, unit_price = tokens[0], tokens[1]
|
||||
elif tokens and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]):
|
||||
qty, unit_price = "1", tokens[0]
|
||||
|
||||
return {
|
||||
"product_name": product_name,
|
||||
"spec": model_spec,
|
||||
"unit": "个",
|
||||
"qty": qty,
|
||||
"unit_price": unit_price,
|
||||
}
|
||||
|
||||
|
||||
def _format_money(value: str | float) -> str:
|
||||
"""单价、金额:固定保留两位小数。"""
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
return f"{num:.2f}"
|
||||
|
||||
|
||||
def _today_cn_date() -> str:
|
||||
"""当前日期,格式:2026年5月26日"""
|
||||
today = date.today()
|
||||
return f"{today.year}年{today.month}月{today.day}日"
|
||||
|
||||
|
||||
def _apply_font(rng: Any) -> None:
|
||||
"""将范围字体设为宋体五号(含数字与英文)。"""
|
||||
font = rng.Font
|
||||
font.Name = TABLE_FONT_NAME
|
||||
font.NameFarEast = TABLE_FONT_NAME
|
||||
font.NameAscii = TABLE_FONT_NAME
|
||||
font.NameOther = TABLE_FONT_NAME
|
||||
font.NameBi = TABLE_FONT_NAME
|
||||
font.Size = TABLE_FONT_SIZE
|
||||
|
||||
|
||||
def _set_cell_value(cell: Any, text: str) -> None:
|
||||
"""写入单元格正文(不含末尾单元格标记)。"""
|
||||
rng = cell.Range
|
||||
rng.MoveEnd(WD_CHARACTER, -1)
|
||||
rng.Text = "" if text is None else str(text)
|
||||
_apply_font(rng)
|
||||
|
||||
|
||||
def _normalize_table_font(tbl: Any) -> None:
|
||||
"""填写完成后统一整张表的字体。"""
|
||||
for row in tbl.Rows:
|
||||
for cell in row.Cells:
|
||||
rng = cell.Range
|
||||
rng.MoveEnd(WD_CHARACTER, -1)
|
||||
_apply_font(rng)
|
||||
|
||||
|
||||
def _replace_date_in_doc(doc: Any, new_date: str) -> None:
|
||||
"""仅替换表头段落中的日期文字,不改动段落其余部分。"""
|
||||
if not new_date:
|
||||
return
|
||||
try:
|
||||
para = doc.Paragraphs(3)
|
||||
except Exception:
|
||||
return
|
||||
rng = para.Range
|
||||
text = rng.Text.replace("\r", "").replace("\x07", "")
|
||||
m = re.search(r"\d{4}年\d{1,2}月\d{1,2}日", text)
|
||||
if not m:
|
||||
return
|
||||
start = rng.Start + m.start()
|
||||
end = rng.Start + m.end()
|
||||
doc.Range(Start=start, End=end).Text = new_date
|
||||
|
||||
|
||||
def fill_consumable_doc(
|
||||
csv_path: str | Path,
|
||||
doc_path: str | Path,
|
||||
config: dict[str, Any] | None = None,
|
||||
backup: bool = True,
|
||||
) -> Path:
|
||||
csv_path = Path(csv_path)
|
||||
doc_path = Path(doc_path)
|
||||
if config is None:
|
||||
config = load_config()
|
||||
|
||||
invoices = load_invoice_csv(csv_path.parent / "invoice_summary.csv") or []
|
||||
|
||||
if backup:
|
||||
bak = doc_path.with_suffix(doc_path.suffix + ".bak")
|
||||
shutil.copy2(doc_path, bak)
|
||||
|
||||
import pythoncom
|
||||
import win32com.client
|
||||
|
||||
pythoncom.CoInitialize()
|
||||
try:
|
||||
word = win32com.client.Dispatch("Word.Application")
|
||||
word.Visible = False
|
||||
word.DisplayAlerts = 0
|
||||
doc = word.Documents.Open(str(doc_path.resolve()))
|
||||
|
||||
try:
|
||||
_replace_date_in_doc(doc, _today_cn_date())
|
||||
|
||||
tbl = doc.Tables(1)
|
||||
storage = config.get("consumable_storage", "躬行楼 C205")
|
||||
|
||||
for i, inv in enumerate(invoices):
|
||||
row_idx = i + 2
|
||||
if row_idx > tbl.Rows.Count:
|
||||
break
|
||||
|
||||
parsed = parse_spec_model(str(inv.get("spec_model", "")))
|
||||
# 当规格型号为空时,从项目名称提取产品信息
|
||||
if not parsed["product_name"]:
|
||||
item_name = str(inv.get("item_name", ""))
|
||||
# 去除 "*分类*" 前缀(如 "*电子工业设备*元件盒" -> "元件盒")
|
||||
if "*" in item_name:
|
||||
item_name = item_name.split("*")[-1].strip()
|
||||
parsed["product_name"] = item_name
|
||||
|
||||
card_amount_raw = inv.get("card_amount") or 0
|
||||
card_amount: float = float(str(card_amount_raw).replace(",", ""))
|
||||
qty_str = parsed["qty"]
|
||||
qty_val = int(qty_str) if qty_str and qty_str.isdigit() else 0
|
||||
|
||||
# 金额填写刷卡金额,单价由刷卡金额反算
|
||||
amount = _format_money(card_amount)
|
||||
unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount)
|
||||
|
||||
# 数量:去掉前导零;若无数量则默认为 1
|
||||
qty = str(qty_val) if qty_val > 0 else "1"
|
||||
|
||||
values = [
|
||||
str(inv.get("index", i + 1)),
|
||||
parsed["product_name"],
|
||||
parsed["spec"],
|
||||
parsed["unit"],
|
||||
qty,
|
||||
unit_price,
|
||||
amount,
|
||||
"", # 购货人签字 — 保持空白
|
||||
storage,
|
||||
"", # 领用人签字 — 保持空白
|
||||
"", # 备注 — 保持空白,避免撑破版式
|
||||
]
|
||||
|
||||
for col_idx, val in enumerate(values, start=1):
|
||||
_set_cell_value(tbl.Cell(row_idx, col_idx), str(val))
|
||||
|
||||
_normalize_table_font(tbl)
|
||||
|
||||
doc.Save()
|
||||
finally:
|
||||
doc.Close()
|
||||
word.Quit()
|
||||
finally:
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
return doc_path
|
||||
|
||||
|
||||
def fill_consumable_from_template(
|
||||
csv_path: str | Path,
|
||||
template_path: str | Path,
|
||||
output_path: str | Path,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> Path:
|
||||
"""从模板复制并填写出库单(Web 会话每次从模板重新生成)。"""
|
||||
template_path = Path(template_path)
|
||||
output_path = Path(output_path)
|
||||
if not template_path.exists():
|
||||
raise FileNotFoundError(f"出库单模板不存在: {template_path}")
|
||||
shutil.copy2(template_path, output_path)
|
||||
return fill_consumable_doc(csv_path, output_path, config=config, backup=False)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(description="将发票 CSV 填入易耗品出库单")
|
||||
parser.add_argument("--csv", default=str(root / "invoice_summary.csv"))
|
||||
parser.add_argument("--doc", default=str(root / "易耗品、出库单.doc"))
|
||||
parser.add_argument("--config", default=str(root / "scripts" / "data" / "config.json"))
|
||||
parser.add_argument("--no-backup", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = None
|
||||
if Path(args.config).exists():
|
||||
import json
|
||||
|
||||
with open(args.config, encoding="utf-8") as f:
|
||||
cfg = {**load_config(), **json.load(f)}
|
||||
out = fill_consumable_doc(args.csv, args.doc, config=cfg, backup=not args.no_backup)
|
||||
print(f"已填写并保存: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
233
src/doc/invoice.py
Normal file
233
src/doc/invoice.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""发票数据模型与 CSV 工具
|
||||
|
||||
定义 CSV 列结构,提供发票分类和 CSV 读写功能。
|
||||
|
||||
对外接口:
|
||||
load_csv(path) 读取支付记录 CSV
|
||||
save_csv(payment_records, path) 保存支付记录 CSV
|
||||
save_invoice_csv(payment_records, path) 保存发票级别 CSV
|
||||
save_application_json(applications, path) 保存出差申请单 JSON
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .. import get_logger
|
||||
|
||||
log = get_logger("invoice")
|
||||
|
||||
# CSV 列名
|
||||
INVOICE_LEVEL_COLUMNS = [
|
||||
"index",
|
||||
"invoice_type",
|
||||
"invoice_number",
|
||||
"invoice_date",
|
||||
"item_name",
|
||||
"spec_model",
|
||||
"total_amount",
|
||||
"seller_name",
|
||||
"departure",
|
||||
"arrival",
|
||||
"train_no",
|
||||
"ride_date",
|
||||
"seat_class",
|
||||
"person_name",
|
||||
"card_date",
|
||||
"card_no",
|
||||
"card_amount",
|
||||
"remark",
|
||||
"person_id",
|
||||
]
|
||||
|
||||
PAYMENT_RECORD_COLUMNS = [
|
||||
"index",
|
||||
"card_date",
|
||||
"card_no",
|
||||
"card_amount",
|
||||
"relative_invoice_count",
|
||||
"invoice_detail",
|
||||
"remark",
|
||||
"_matched_invoices",
|
||||
"person_id",
|
||||
]
|
||||
|
||||
|
||||
def _is_application_document(invoice_type: str) -> bool:
|
||||
"""判断是否为出差事前申请单"""
|
||||
return invoice_type == "application"
|
||||
|
||||
|
||||
def _classify_invoice_batch(
|
||||
invoices: list[dict[str, str]],
|
||||
) -> dict[str, list[dict[str, str]]]:
|
||||
"""按发票类型分组"""
|
||||
travel: list[dict[str, str]] = []
|
||||
general: list[dict[str, str]] = []
|
||||
application: list[dict[str, str]] = []
|
||||
for inv in invoices:
|
||||
inv_type = inv.get("invoice_type", "general")
|
||||
if _is_application_document(inv_type):
|
||||
application.append(inv)
|
||||
elif inv_type in ("train", "hotel"):
|
||||
travel.append(inv)
|
||||
else:
|
||||
general.append(inv)
|
||||
return {"travel": travel, "general": general, "application": application}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CSV 读写工具
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clean_invoice_for_json(inv: dict[str, str]) -> dict[str, str]:
|
||||
"""清理发票字典中的内部字段,保留可序列化的字段"""
|
||||
clean = {}
|
||||
for k, v in inv.items():
|
||||
if k.startswith("_"):
|
||||
continue
|
||||
clean[k] = v
|
||||
return clean
|
||||
|
||||
|
||||
def _load_csv(
|
||||
csv_path: Path,
|
||||
required_columns: list[str],
|
||||
label: str = "CSV",
|
||||
) -> list[dict[str, str]] | None:
|
||||
"""通用 CSV 读取器:按 required_columns 校验列,失败返回 None"""
|
||||
try:
|
||||
with open(csv_path, encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
fieldnames = reader.fieldnames or []
|
||||
missing = [c for c in required_columns if c not in fieldnames]
|
||||
if missing:
|
||||
log.error(f"{label} 缺少必要列: {missing}")
|
||||
return None
|
||||
return [row for row in reader]
|
||||
except FileNotFoundError:
|
||||
log.error(f"{label} 文件不存在: {csv_path.name}")
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"{label} 读取失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def load_csv(csv_path: Path) -> list[dict[str, str]] | None:
|
||||
"""读取支付记录 CSV 为 dict 列表,失败返回 None"""
|
||||
return _load_csv(csv_path, PAYMENT_RECORD_COLUMNS, "CSV")
|
||||
|
||||
|
||||
def load_invoice_csv(csv_path: Path) -> list[dict[str, str]] | None:
|
||||
"""读取发票级别 CSV 为 dict 列表(每行一张发票),失败返回 None"""
|
||||
return _load_csv(csv_path, INVOICE_LEVEL_COLUMNS, "发票 CSV")
|
||||
|
||||
|
||||
def save_csv(
|
||||
payment_records: list[dict[str, str]],
|
||||
output_path: str | Path = "payment_records.csv",
|
||||
) -> None:
|
||||
"""将支付记录列表保存为 CSV(以支付记录为主键)
|
||||
|
||||
每条支付记录包含:
|
||||
- card_date, card_no, card_amount(支付信息)
|
||||
- relative_invoice_count, invoice_detail(发票聚合信息)
|
||||
- _matched_invoices(内部字段,序列化为 JSON 存储在 CSV 中)
|
||||
"""
|
||||
csv_path = Path(output_path)
|
||||
|
||||
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(PAYMENT_RECORD_COLUMNS)
|
||||
|
||||
for idx, record in enumerate(payment_records, 1):
|
||||
matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment]
|
||||
invoices_json = json.dumps(
|
||||
[_clean_invoice_for_json(inv) for inv in matched_invoices],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
idx,
|
||||
record.get("card_date", ""),
|
||||
record.get("card_no", ""),
|
||||
record.get("card_amount", ""),
|
||||
record.get("relative_invoice_count", str(len(matched_invoices))),
|
||||
record.get("invoice_detail", ""),
|
||||
record.get("remark", ""),
|
||||
invoices_json,
|
||||
record.get("person_id", ""),
|
||||
]
|
||||
)
|
||||
|
||||
log.info(f"支付记录 CSV 已保存: {csv_path.name}")
|
||||
|
||||
|
||||
def save_invoice_csv(
|
||||
payment_records: list[dict[str, str]],
|
||||
output_path: str | Path = "invoice_summary.csv",
|
||||
) -> None:
|
||||
"""将支付记录展平为发票级别 CSV(每行一张发票)
|
||||
|
||||
从 _matched_invoices 中还原每张发票,回填刷卡信息,
|
||||
生成以发票为主键的 CSV,用于人工填写报销单参考。
|
||||
出差事前申请单不会被写入此文件(它们有独立的 CSV)。
|
||||
"""
|
||||
csv_path = Path(output_path)
|
||||
|
||||
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(INVOICE_LEVEL_COLUMNS)
|
||||
|
||||
idx = 1
|
||||
for record in payment_records:
|
||||
matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment]
|
||||
for inv in matched_invoices:
|
||||
clean_inv = _clean_invoice_for_json(inv)
|
||||
if _is_application_document(clean_inv.get("invoice_type", "")):
|
||||
continue
|
||||
writer.writerow(
|
||||
[
|
||||
idx,
|
||||
clean_inv.get("invoice_type", ""),
|
||||
clean_inv.get("invoice_number", ""),
|
||||
clean_inv.get("invoice_date", ""),
|
||||
clean_inv.get("item_name", ""),
|
||||
clean_inv.get("spec_model", ""),
|
||||
clean_inv.get("total_amount", ""),
|
||||
clean_inv.get("seller_name", ""),
|
||||
clean_inv.get("departure", ""),
|
||||
clean_inv.get("arrival", ""),
|
||||
clean_inv.get("train_no", ""),
|
||||
clean_inv.get("ride_date", ""),
|
||||
clean_inv.get("seat_class", ""),
|
||||
clean_inv.get("person_name", ""),
|
||||
record.get("card_date", ""),
|
||||
record.get("card_no", ""),
|
||||
record.get("card_amount", ""),
|
||||
record.get("remark", ""),
|
||||
record.get("person_id", ""),
|
||||
]
|
||||
)
|
||||
idx += 1
|
||||
|
||||
log.info(f"发票级别 CSV 已保存: {csv_path.name}")
|
||||
|
||||
|
||||
def save_application_json(
|
||||
applications: list[dict[str, str]],
|
||||
output_path: str | Path = "travel_applications.json",
|
||||
) -> None:
|
||||
"""将出差事前申请单列表保存为独立 JSON 文件
|
||||
|
||||
使用 JSON 保留完整嵌套结构(如出差人员信息的列表形式),
|
||||
避免 CSV 扁平化导致的字段丢失。
|
||||
"""
|
||||
json_path = Path(output_path)
|
||||
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(applications, f, ensure_ascii=False, indent=2)
|
||||
|
||||
log.info(f"出差申请单 JSON 已保存: {json_path.name}")
|
||||
395
src/doc/llm_extractor.py
Normal file
395
src/doc/llm_extractor.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""LLM 信息提取
|
||||
|
||||
使用 LLM 从 PDF 文本/图片、支付截图中提取结构化数据。
|
||||
|
||||
## 功能模块
|
||||
|
||||
- **统一文档提取**:使用一套提示词,LLM 自行判断文档类型(发票/支付记录/出差事前申请单等),支持 JSON 格式输出。
|
||||
- **差旅信息提取**:综合多张发票、支付记录和匹配结果,提取出差事由、地点、时间等差旅相关信息。
|
||||
- **缓存管理**:支持从 `.invoice_cache/` 目录加载已提取的结构化数据和匹配结果,避免重复处理。
|
||||
|
||||
## 对外接口
|
||||
|
||||
- `extract_document(file_path) -> dict` — 统一入口:从任意图片/PDF 提取信息
|
||||
- `extract_travel_info(source_dir) -> dict` — 综合发票和匹配结果提取差旅信息
|
||||
- `load_cache(source_dir) -> dict` — 加载缓存的结构化数据
|
||||
- `load_match_result(source_dir) -> dict` — 加载发票与支付记录的匹配结果
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from .. import get_logger
|
||||
from .prompt import (
|
||||
build_invoice_system_prompt,
|
||||
build_normal_info_system_prompt,
|
||||
build_travel_info_system_prompt,
|
||||
)
|
||||
|
||||
log = get_logger("llm_extractor")
|
||||
|
||||
|
||||
def _create_llm() -> Any:
|
||||
"""根据配置文件创建 LLM 实例。"""
|
||||
try:
|
||||
from llama_index.llms.openai_like import OpenAILike
|
||||
except ImportError:
|
||||
log.error("缺少 llama-index-llms-openai-like,请执行: uv pip install llama-index-llms-openai-like")
|
||||
raise
|
||||
|
||||
from ..config import get_llm_config
|
||||
|
||||
llm_config = get_llm_config()
|
||||
return OpenAILike(
|
||||
model=llm_config["model"],
|
||||
api_base=llm_config["api_base"],
|
||||
api_key=llm_config.get("api_key", "lm-studio"),
|
||||
temperature=0.1,
|
||||
max_tokens=65535,
|
||||
request_timeout=600.0,
|
||||
is_chat_model=True,
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_response(text: str) -> dict[str, Any]:
|
||||
"""从 LLM 响应中提取 JSON,处理可能的 Markdown 包裹。"""
|
||||
text = text.strip()
|
||||
|
||||
# 处理 ```json ... ``` 包裹
|
||||
if "```" in text:
|
||||
# 提取第一个代码块
|
||||
start = text.find("```") + 3
|
||||
end = text.find("```", start)
|
||||
if end > start:
|
||||
text = text[start:end].strip()
|
||||
|
||||
# 去掉可能的前缀 (如 "json")
|
||||
if text.lower().startswith("json"):
|
||||
text = text[4:].strip()
|
||||
|
||||
return cast(dict[str, Any], json.loads(text))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 统一文档提取(多模态,直接传图片给 LLM)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _image_to_base64(image_path: Path) -> str:
|
||||
"""将图片文件读取为 base64 字符串。"""
|
||||
with open(image_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
|
||||
def _llm_query_multimodal(
|
||||
system_prompt: str,
|
||||
text: str | None = None,
|
||||
image_b64s: list[str] | None = None,
|
||||
blocks: list[Any] | None = None,
|
||||
reasoning_effort: str = "none",
|
||||
) -> str:
|
||||
"""发送多模态请求到 LLM。
|
||||
|
||||
Args:
|
||||
system_prompt: 系统提示词。
|
||||
text: 用户文本(与 image_b64s 配合使用,文本在前、图片在后)。
|
||||
image_b64s: base64 编码的图片列表。
|
||||
blocks: 预构建的内容块列表(TextBlock/ImageBlock),传入时忽略 text 和 image_b64s。
|
||||
|
||||
Returns:
|
||||
LLM 响应文本。
|
||||
"""
|
||||
from llama_index.core.base.llms.types import ImageBlock, TextBlock
|
||||
from llama_index.core.llms import ChatMessage
|
||||
|
||||
from ..config import get_llm_config
|
||||
|
||||
if blocks is not None:
|
||||
final_blocks = blocks
|
||||
else:
|
||||
text = text or ""
|
||||
image_b64s = image_b64s or []
|
||||
final_blocks = [TextBlock(text=text)]
|
||||
for img_b64 in image_b64s:
|
||||
final_blocks.append(
|
||||
ImageBlock(
|
||||
url=f"data:image/jpeg;base64,{img_b64}",
|
||||
detail="high",
|
||||
)
|
||||
)
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="system", content=system_prompt),
|
||||
ChatMessage(role="user", blocks=final_blocks),
|
||||
]
|
||||
|
||||
llm_config = get_llm_config()
|
||||
llm = _create_llm()
|
||||
log.info(
|
||||
"开始请求 LLM 多模态 (model=%s, base=%s, blocks=%d)",
|
||||
llm_config["model"],
|
||||
llm_config["api_base"],
|
||||
len(final_blocks),
|
||||
)
|
||||
|
||||
try:
|
||||
parts = []
|
||||
for resp in llm.stream_chat(
|
||||
messages,
|
||||
temperature=0.1,
|
||||
extra_body={"reasoning_effort": reasoning_effort},
|
||||
):
|
||||
delta = resp.delta
|
||||
if delta:
|
||||
parts.append(delta)
|
||||
text = "".join(parts)
|
||||
log.info("LLM 多模态请求完成,响应总长度: %d 字符", len(text))
|
||||
log.info("LLM 多模态响应: %s", text)
|
||||
return text
|
||||
except Exception as e:
|
||||
log.error("LLM 多模态请求失败: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def extract_document(file_path: Path) -> dict[str, Any]:
|
||||
"""统一文档提取入口:从任意图片/PDF 中提取结构化信息。
|
||||
|
||||
LLM 会根据统一提示词自行判断文档类型(发票/支付记录/出差事前申请单等)。
|
||||
|
||||
Args:
|
||||
file_path: 文件路径(支持 PDF 和图片格式)。
|
||||
|
||||
Returns:
|
||||
包含提取字段的字典。
|
||||
"""
|
||||
from .pdf import render_pdf_to_images
|
||||
|
||||
system_prompt = build_invoice_system_prompt()
|
||||
user_text = f"请分析以下财务文档并提取信息:\n\n文件名: {file_path.name}"
|
||||
|
||||
# PDF 先渲染为图片
|
||||
suffix = file_path.suffix.lower()
|
||||
if suffix == ".pdf":
|
||||
image_b64s = render_pdf_to_images(file_path)
|
||||
else:
|
||||
image_b64s = [_image_to_base64(file_path)]
|
||||
|
||||
if not image_b64s:
|
||||
log.warning(f"文件渲染为空: {file_path.name}")
|
||||
return {}
|
||||
|
||||
try:
|
||||
response = _llm_query_multimodal(system_prompt, user_text, image_b64s)
|
||||
result = _parse_json_response(response)
|
||||
log.info("LLM 文档提取成功: %s", file_path.name)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.error("LLM 文档提取失败: %s (%s)", file_path.name, e)
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 差旅信息提取
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
CACHE_DIR_NAME = ".invoice_cache"
|
||||
|
||||
|
||||
def load_cache(source_dir: Path) -> dict[str, Any]:
|
||||
"""从 JSON 缓存目录加载结构化数据,构建 source filename -> 缓存数据的映射。
|
||||
|
||||
Args:
|
||||
source_dir: 源文件目录(包含 .invoice_cache 子目录)。
|
||||
|
||||
Returns:
|
||||
{source_filename: extracted_data} 字典。
|
||||
额外包含 "travel_info" 键(如果 travel_info.json 存在)。
|
||||
"""
|
||||
cache_map: dict[str, Any] = {}
|
||||
cache_dir = source_dir / CACHE_DIR_NAME
|
||||
if not cache_dir.exists():
|
||||
return cache_map
|
||||
|
||||
for json_path in sorted(cache_dir.glob("*.json")):
|
||||
try:
|
||||
with open(json_path, encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
# travel_info.json / normal_info.json 结构不同,直接存储
|
||||
if json_path.name in ("travel_info.json", "normal_info.json"):
|
||||
cache_map[json_path.name.replace(".json", "")] = cache_data
|
||||
continue
|
||||
|
||||
extracted = cache_data.get("extracted_data", {})
|
||||
src_file = extracted.get("_source_file", "")
|
||||
if src_file:
|
||||
cache_map[src_file] = extracted
|
||||
except Exception as e:
|
||||
log.warning(f"读取缓存失败 {json_path.name}: {e}")
|
||||
|
||||
return cache_map
|
||||
|
||||
|
||||
def load_match_result(source_dir: Path) -> dict[str, list[dict[str, Any]]]:
|
||||
"""从 JSON 缓存目录加载发票与支付记录的匹配结果。
|
||||
|
||||
Args:
|
||||
source_dir: 源文件目录(包含 .invoice_cache 子目录)。
|
||||
|
||||
Returns:
|
||||
{支付记录源文件 (含金额): [发票信息列表]} 字典。
|
||||
每个发票信息包含 file, type, amount 字段。
|
||||
"""
|
||||
cache_dir = source_dir / CACHE_DIR_NAME
|
||||
match_path = cache_dir / "match_result.json"
|
||||
if not match_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(match_path, encoding="utf-8") as f:
|
||||
result: dict[str, list[dict[str, Any]]] = json.load(f)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.warning(f"读取匹配结果缓存失败: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def extract_travel_info(
|
||||
source_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""根据差旅发票(bot 格式),让 LLM 提取出差相关信息。
|
||||
|
||||
仅支持从 JSON 缓存加载数据。
|
||||
|
||||
bot 格式的发票包含以下字段:
|
||||
- 发票类型, invoice_no, invoice_date, item_name, spec_model
|
||||
- total_amount, seller_name, person_name, person_id
|
||||
- card_date, card_no, card_amount, remark
|
||||
|
||||
Args:
|
||||
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
|
||||
|
||||
Returns:
|
||||
包含出差事由、地点、交通工具、时间、住宿信息等字段的字典。
|
||||
"""
|
||||
# 仅从 JSON 缓存加载结构化数据
|
||||
if not source_dir:
|
||||
log.warning("未提供 source_dir,无法加载缓存数据")
|
||||
return {}
|
||||
|
||||
system_prompt = build_travel_info_system_prompt()
|
||||
|
||||
# 构建 source filename -> 缓存数据的映射
|
||||
cache_map = load_cache(source_dir)
|
||||
|
||||
# 加载发票与支付记录的匹配结果
|
||||
match_result = load_match_result(source_dir)
|
||||
|
||||
# 拼接纯文本消息
|
||||
parts = [
|
||||
"以下是本次报销的所有源文件及其提取出的结构化数据。"
|
||||
"每个源文件的数据来自 OCR 识别和发票信息提取,已按文件名分组展示。"
|
||||
]
|
||||
|
||||
# 如果有匹配结果,作为额外上下文提供
|
||||
if match_result:
|
||||
parts.append(
|
||||
"【发票与支付记录匹配结果】"
|
||||
"以下数据已将发票信息与对应的支付记录进行关联匹配,"
|
||||
"用于判断每笔支付对应的发票和商户信息。\n" + json.dumps(match_result, ensure_ascii=False, indent=2)
|
||||
)
|
||||
|
||||
# 按源文件名提供结构化数据
|
||||
for filename, extracted in cache_map.items():
|
||||
parts.append(
|
||||
f"【源文件: {filename}】"
|
||||
"以下为从该文件提取的结构化发票/支付/申请单数据。\n" + json.dumps(extracted, ensure_ascii=False, indent=2)
|
||||
)
|
||||
|
||||
parts.append("\n=== 请返回 JSON 格式结果 ===")
|
||||
user_message = "\n".join(parts)
|
||||
log.info(f"user_message: {user_message}")
|
||||
try:
|
||||
response = _llm_query_multimodal(
|
||||
system_prompt=system_prompt,
|
||||
text=user_message,
|
||||
reasoning_effort="low",
|
||||
)
|
||||
result = _parse_json_response(response)
|
||||
log.info("LLM 差旅信息提取成功")
|
||||
return result
|
||||
except Exception as e:
|
||||
log.error("LLM 差旅信息提取失败: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 普通发票信息提取
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_normal_info(
|
||||
source_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""根据普通发票(非差旅),让 LLM 提取报销相关信息。
|
||||
|
||||
仅支持从 JSON 缓存加载数据。
|
||||
|
||||
Args:
|
||||
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
|
||||
|
||||
Returns:
|
||||
包含报销说明、发票总数、总金额、支付方式、附件清单等字段的字典。
|
||||
"""
|
||||
if not source_dir:
|
||||
log.warning("未提供 source_dir,无法加载缓存数据")
|
||||
return {}
|
||||
|
||||
system_prompt = build_normal_info_system_prompt()
|
||||
|
||||
# 构建 source filename -> 缓存数据的映射
|
||||
cache_map = load_cache(source_dir)
|
||||
|
||||
# 加载发票与支付记录的匹配结果
|
||||
match_result = load_match_result(source_dir)
|
||||
|
||||
# 拼接纯文本消息
|
||||
parts = [
|
||||
"以下是本次报销的所有源文件及其提取出的结构化数据。"
|
||||
"每个源文件的数据来自 OCR 识别和发票信息提取,已按文件名分组展示。"
|
||||
]
|
||||
|
||||
# 如果有匹配结果,作为额外上下文提供
|
||||
if match_result:
|
||||
parts.append(
|
||||
"【发票与支付记录匹配结果】"
|
||||
"以下数据已将发票信息与对应的支付记录进行关联匹配,"
|
||||
"用于判断每笔支付对应的发票和商户信息。\n" + json.dumps(match_result, ensure_ascii=False, indent=2)
|
||||
)
|
||||
|
||||
# 按源文件名提供结构化数据
|
||||
for filename, extracted in cache_map.items():
|
||||
parts.append(
|
||||
f"【源文件: {filename}】"
|
||||
"以下为从该文件提取的结构化发票/支付/申请单数据。\n" + json.dumps(extracted, ensure_ascii=False, indent=2)
|
||||
)
|
||||
|
||||
parts.append("\n=== 请返回 JSON 格式结果 ===")
|
||||
user_message = "\n".join(parts)
|
||||
log.info(f"user_message: {user_message}")
|
||||
try:
|
||||
response = _llm_query_multimodal(
|
||||
system_prompt=system_prompt,
|
||||
text=user_message,
|
||||
reasoning_effort="low",
|
||||
)
|
||||
result = _parse_json_response(response)
|
||||
log.info("LLM 普通发票信息提取成功")
|
||||
return result
|
||||
except Exception as e:
|
||||
log.error("LLM 普通发票信息提取失败: %s", e)
|
||||
raise
|
||||
396
src/doc/matcher.py
Normal file
396
src/doc/matcher.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""发票与支付记录匹配
|
||||
|
||||
将提取到的发票数据与支付记录进行金额匹配,
|
||||
输出以支付记录为主键的结果列表。
|
||||
|
||||
支付记录由统一提取模块(extractor)根据 LLM 返回的「invoice_type」字段分类而来,
|
||||
不再按文件类型假设文档类型。
|
||||
|
||||
## 业务约束
|
||||
|
||||
- 发票数 >= 付款记录数(最少一张发票对应一张付款记录)
|
||||
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
|
||||
- 若发票数 == 付款数,走一对一匹配,无需一对多
|
||||
|
||||
## 匹配流程
|
||||
|
||||
1. 接收分类好的发票和支付记录列表
|
||||
2. 解析发票和刷卡记录的金额,进行总额校验
|
||||
- 发票总额 < 刷卡总额时发出 warning
|
||||
3. 按金额降序排序
|
||||
4. 文件名匹配(最高优先级):发票和刷卡记录的文件名(不含后缀)一致时直接匹配
|
||||
5. 根据数量关系选择匹配策略:
|
||||
- 数量相等 → 一对一匹配:按金额从大到小依次配对,相对容差内即匹配
|
||||
- 发票更多 → 一对多匹配:对每张刷卡记录贪心凑金额,相对容差内结束
|
||||
6. 构建以支付记录为主键的结果列表
|
||||
7. 未匹配的发票单独作为一条记录(无刷卡信息)
|
||||
8. 清理内部字段,输出支付记录列表
|
||||
|
||||
## 容差计算
|
||||
|
||||
使用相对容差(默认 3%),以刷卡金额为基准:
|
||||
- ¥2900 发票 vs ¥2850 刷卡 → 差 ¥50,容差 ¥85.5 → 匹配成功
|
||||
- ¥100 发票 vs ¥95 刷卡 → 差 ¥5,容差 ¥3.0 → 不匹配(需精确匹配或调整)
|
||||
|
||||
## 一对多匹配细节
|
||||
|
||||
- 对每张刷卡记录,维护 remaining(剩余待匹配金额)
|
||||
- 遍历未分配的发票(按金额降序):
|
||||
- 若发票金额 + 容差 >= remaining,视为最后一张,匹配后退出
|
||||
- 否则发票金额不超过 remaining + 容差即可匹配
|
||||
- 匹配后 remaining 为负且超出容差时回滚最后一张发票
|
||||
- 每张发票只会被分配一次
|
||||
|
||||
## 对外接口
|
||||
|
||||
match_invoices_to_cards(invoices, cards, tolerance) -> list[dict]
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
|
||||
log = get_logger("matcher")
|
||||
|
||||
|
||||
def _safe_float(value: str | None, default: float = 0.0) -> float:
|
||||
"""安全转换为浮点数"""
|
||||
if value is None or str(value).strip() == "":
|
||||
return default
|
||||
try:
|
||||
return float(str(value).replace(",", ""))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _build_invoice_summary(invoices: list[dict[str, Any]]) -> str:
|
||||
"""将多张发票信息汇总为备注字符串"""
|
||||
parts = []
|
||||
for inv in invoices:
|
||||
inv_type = inv.get("invoice_type", "unknown")
|
||||
amount = inv.get("total_amount", "unknown")
|
||||
|
||||
if inv_type == "train":
|
||||
label = inv.get("person_name") or inv.get("invoice_number", "unknown")
|
||||
elif inv_type == "hotel":
|
||||
label = "hotel"
|
||||
else:
|
||||
label = inv.get("item_name") or inv.get("invoice_number", "unknown")
|
||||
|
||||
parts.append(f"{inv_type}[{label}]¥{amount}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _relative_tolerance(base: float, rate: float = 0.03) -> float:
|
||||
"""根据基准金额计算相对容差(默认 3%)"""
|
||||
return abs(base) * rate
|
||||
|
||||
|
||||
def match_invoices_to_cards(
|
||||
invoices: list[dict[str, Any]],
|
||||
cards: list[dict[str, Any]] | None = None,
|
||||
tolerance: float = 0.03,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""将发票与支付记录按金额匹配,输出以支付记录为主键的结果列表
|
||||
|
||||
支付记录由统一提取模块(extractor)根据 LLM 返回的「invoice_type」字段分类而来。
|
||||
|
||||
业务约束:
|
||||
- 发票数 >= 付款记录数
|
||||
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
|
||||
- 若发票数 == 付款数,一对一匹配,无需一对多
|
||||
|
||||
Args:
|
||||
invoices: 发票列表,需包含 "total_amount" 字段
|
||||
cards: 支付记录列表,需包含 "card_amount" 字段(由 extractor 分类提供)
|
||||
tolerance: 金额匹配容差比例(默认 0.03 = 3%)
|
||||
|
||||
Returns:
|
||||
以支付记录为主键的结果列表,每条记录包含:
|
||||
- card_date, card_no, card_amount(支付信息)
|
||||
- 关联发票列表(_matched_invoices)
|
||||
- 发票详情备注
|
||||
- 未匹配发票单独作为一条无刷卡信息的记录
|
||||
"""
|
||||
if not cards:
|
||||
log.warning("无支付记录可供匹配,发票将保持原状")
|
||||
return _invoices_to_records(invoices)
|
||||
|
||||
# 解析金额
|
||||
for card in cards:
|
||||
card["_amount"] = _safe_float(card.get("card_amount"))
|
||||
for inv in invoices:
|
||||
inv["_amount"] = _safe_float(inv.get("total_amount"))
|
||||
|
||||
# 数据校验
|
||||
total_invoices = sum(inv["_amount"] for inv in invoices)
|
||||
total_cards = sum(card["_amount"] for card in cards)
|
||||
log.info(
|
||||
f"金额校验: 发票总额 ¥{total_invoices:.2f}, 刷卡总额 ¥{total_cards:.2f}, "
|
||||
f"发票数 {len(invoices)}, 刷卡数 {len(cards)}"
|
||||
)
|
||||
|
||||
total_tolerance = _relative_tolerance(max(total_invoices, total_cards), tolerance)
|
||||
if total_invoices < total_cards - total_tolerance:
|
||||
log.warning(
|
||||
f"发票总额 (¥{total_invoices:.2f}) 小于刷卡总额 (¥{total_cards:.2f}), "
|
||||
f"超出容差 {tolerance * 100:.0f}%, 匹配结果可能有偏差"
|
||||
)
|
||||
|
||||
# 按金额降序排序
|
||||
cards.sort(key=lambda c: c["_amount"], reverse=True)
|
||||
invoices.sort(key=lambda i: i["_amount"], reverse=True)
|
||||
|
||||
# 执行匹配,返回 {card_index: [invoice_indices]} 的映射
|
||||
card_to_invoices = _match(cards, invoices, tolerance)
|
||||
|
||||
# 构建支付记录列表
|
||||
records = _build_payment_records(cards, invoices, card_to_invoices)
|
||||
|
||||
# 清理内部字段
|
||||
for inv in invoices:
|
||||
inv.pop("_amount", None)
|
||||
for card in cards:
|
||||
card.pop("_amount", None)
|
||||
|
||||
# 统计
|
||||
matched_invoices = sum(len(inv_list) for inv_list in card_to_invoices.values())
|
||||
unmatched_count = len(invoices) - matched_invoices
|
||||
log.info(f"匹配完成: {len(records)} 条支付记录, {matched_invoices}/{len(invoices)} 张发票已关联")
|
||||
if unmatched_count:
|
||||
log.info(f"未匹配发票: {unmatched_count} 张(已单独列为记录)")
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _match(
|
||||
cards: list[dict[str, Any]],
|
||||
invoices: list[dict[str, Any]],
|
||||
tolerance: float,
|
||||
) -> dict[int, list[int]]:
|
||||
"""执行匹配,返回 {card_index: [invoice_indices]} 的映射
|
||||
|
||||
tolerance 为相对容差比例(如 0.03 表示 3%)
|
||||
|
||||
匹配优先级(从高到低):
|
||||
1. 文件名匹配:发票和刷卡记录的文件名(不含后缀)一致时直接匹配
|
||||
2. 精确匹配:金额差 <= 0.01 元
|
||||
3. 一对一 / 一对多贪心匹配:按金额容差匹配
|
||||
"""
|
||||
result: dict[int, list[int]] = {}
|
||||
assigned: set[int] = set()
|
||||
|
||||
# ---- 阶段 0:文件名匹配(最高优先级)----
|
||||
_match_by_filename(invoices, cards, assigned, result)
|
||||
|
||||
if len(invoices) == len(cards):
|
||||
_match_one_to_one(invoices, cards, tolerance, assigned, result)
|
||||
else:
|
||||
_match_one_to_many(invoices, cards, tolerance, assigned, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _match_by_filename(
|
||||
invoices: list[dict[str, Any]],
|
||||
cards: list[dict[str, Any]],
|
||||
assigned: set[int],
|
||||
result: dict[int, list[int]],
|
||||
) -> None:
|
||||
"""文件名匹配:发票和刷卡记录的文件名(不含后缀)一致时直接匹配"""
|
||||
for card_idx, card in enumerate(cards):
|
||||
card_name = card.get("_source_file", "")
|
||||
if not card_name:
|
||||
continue
|
||||
card_stem = Path(card_name).stem
|
||||
|
||||
for idx, inv in enumerate(invoices):
|
||||
if idx in assigned:
|
||||
continue
|
||||
inv_name = inv.get("_source_file", "")
|
||||
if not inv_name:
|
||||
continue
|
||||
inv_stem = Path(inv_name).stem
|
||||
|
||||
if inv_stem == card_stem:
|
||||
assigned.add(idx)
|
||||
result[card_idx] = [idx]
|
||||
log.info(f"[文件名匹配] {inv_name} ↔ {card_name}")
|
||||
break
|
||||
|
||||
|
||||
def _match_one_to_one(
|
||||
invoices: list[dict[str, Any]],
|
||||
cards: list[dict[str, Any]],
|
||||
tolerance: float,
|
||||
assigned: set[int],
|
||||
result: dict[int, list[int]],
|
||||
) -> None:
|
||||
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对"""
|
||||
for card_idx, card in enumerate(cards):
|
||||
if card_idx >= len(invoices):
|
||||
break
|
||||
inv = invoices[card_idx]
|
||||
diff = abs(inv["_amount"] - card["_amount"])
|
||||
card_tol = _relative_tolerance(card["_amount"], tolerance)
|
||||
if diff <= card_tol:
|
||||
assigned.add(card_idx)
|
||||
result[card_idx] = [card_idx]
|
||||
log.info(
|
||||
f"[一对一] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
|
||||
f"↔ {card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}"
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
f"[一对一] 金额偏差超出容差: "
|
||||
f"{inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
|
||||
f"vs ¥{card['_amount']:.2f} (差 ¥{diff:.2f}, 容差 ¥{card_tol:.2f})"
|
||||
)
|
||||
|
||||
|
||||
def _match_one_to_many(
|
||||
invoices: list[dict[str, Any]],
|
||||
cards: list[dict[str, Any]],
|
||||
tolerance: float,
|
||||
assigned: set[int],
|
||||
result: dict[int, list[int]],
|
||||
) -> None:
|
||||
"""一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配"""
|
||||
|
||||
# ---- 阶段 1:精确匹配(金额差 <= 0.01 元视为相等)----
|
||||
exact_tolerance = 0.01
|
||||
for card_idx, card in enumerate(cards):
|
||||
card_amount = card["_amount"]
|
||||
if card_amount <= 0:
|
||||
continue
|
||||
|
||||
for idx, inv in enumerate(invoices):
|
||||
if idx in assigned:
|
||||
continue
|
||||
inv_amount = inv["_amount"]
|
||||
if inv_amount <= 0:
|
||||
continue
|
||||
|
||||
if abs(inv_amount - card_amount) <= exact_tolerance:
|
||||
assigned.add(idx)
|
||||
result[card_idx] = [idx]
|
||||
log.info(
|
||||
f"[一对多-精确] {inv.get('invoice_number', 'unknown')} ¥{inv_amount:.2f} "
|
||||
f"↔ {card.get('_source_file', 'unknown')} ¥{card_amount:.2f}"
|
||||
)
|
||||
break
|
||||
|
||||
# ---- 阶段 2:贪心匹配(仅处理未精确匹配的刷卡记录)----
|
||||
for card_idx, card in enumerate(cards):
|
||||
if card_idx in result:
|
||||
continue
|
||||
|
||||
card_amount = card["_amount"]
|
||||
if card_amount <= 0:
|
||||
continue
|
||||
|
||||
card_tol = _relative_tolerance(card_amount, tolerance)
|
||||
|
||||
remaining = card_amount
|
||||
matched_indices: list[int] = []
|
||||
|
||||
for idx, inv in enumerate(invoices):
|
||||
if idx in assigned:
|
||||
continue
|
||||
if remaining <= card_tol:
|
||||
break
|
||||
|
||||
inv_amount = inv["_amount"]
|
||||
if inv_amount <= 0:
|
||||
continue
|
||||
|
||||
if inv_amount + card_tol >= remaining:
|
||||
is_match = True
|
||||
else:
|
||||
is_match = inv_amount <= remaining + card_tol
|
||||
|
||||
if is_match:
|
||||
assigned.add(idx)
|
||||
matched_indices.append(idx)
|
||||
remaining -= inv_amount
|
||||
if remaining <= card_tol:
|
||||
break
|
||||
|
||||
# 回滚:如果匹配后 remaining 为负且超出容差
|
||||
if remaining < -card_tol and matched_indices:
|
||||
last_idx = matched_indices.pop()
|
||||
assigned.discard(last_idx)
|
||||
remaining += invoices[last_idx]["_amount"]
|
||||
|
||||
# 记录匹配结果
|
||||
if matched_indices:
|
||||
result[card_idx] = matched_indices
|
||||
for idx in matched_indices:
|
||||
inv = invoices[idx]
|
||||
log.info(
|
||||
f"[一对多-贪心] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
|
||||
f"→ {card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}"
|
||||
)
|
||||
|
||||
|
||||
def _build_payment_records(
|
||||
cards: list[dict[str, Any]],
|
||||
invoices: list[dict[str, Any]],
|
||||
card_to_invoices: dict[int, list[int]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""构建以支付记录为主键的结果列表"""
|
||||
records: list[dict[str, Any]] = []
|
||||
|
||||
for card_idx, inv_indices in card_to_invoices.items():
|
||||
card = cards[card_idx]
|
||||
matched_invs = [invoices[idx] for idx in inv_indices]
|
||||
|
||||
record = {
|
||||
"card_date": card.get("card_date", ""),
|
||||
"card_no": card.get("card_no", ""),
|
||||
"card_amount": str(card["_amount"]),
|
||||
"relative_invoice_count": str(len(matched_invs)),
|
||||
"invoice_detail": _build_invoice_summary(matched_invs),
|
||||
"remark": "",
|
||||
"_source_file": card.get("_source_file", ""),
|
||||
"_matched_invoices": matched_invs,
|
||||
}
|
||||
records.append(record)
|
||||
|
||||
# 未匹配的发票,单独作为记录
|
||||
matched_indices = set()
|
||||
for inv_indices in card_to_invoices.values():
|
||||
matched_indices.update(inv_indices)
|
||||
|
||||
unmatched = [inv for idx, inv in enumerate(invoices) if idx not in matched_indices]
|
||||
for inv in unmatched:
|
||||
record = {
|
||||
"card_date": "",
|
||||
"card_no": "",
|
||||
"card_amount": "",
|
||||
"relative_invoice_count": "1",
|
||||
"invoice_detail": _build_invoice_summary([inv]),
|
||||
"remark": "unmatched",
|
||||
"_matched_invoices": [inv],
|
||||
}
|
||||
records.append(record)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _invoices_to_records(invoices: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""无刷卡记录时,将每张发票转为独立记录"""
|
||||
records = []
|
||||
for inv in invoices:
|
||||
record = {
|
||||
"card_date": "",
|
||||
"card_no": "",
|
||||
"card_amount": "",
|
||||
"relative_invoice_count": "1",
|
||||
"invoice_detail": _build_invoice_summary([inv]),
|
||||
"remark": "",
|
||||
"_matched_invoices": [inv],
|
||||
}
|
||||
records.append(record)
|
||||
return records
|
||||
46
src/doc/pdf.py
Normal file
46
src/doc/pdf.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""PDF 图片渲染
|
||||
|
||||
从 PDF 发票文件中渲染为图片供多模态 LLM 使用。
|
||||
|
||||
对外接口:
|
||||
render_pdf_to_images(filepath, dpi) -list[str] 渲染 PDF 为图片字节
|
||||
"""
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import fitz
|
||||
|
||||
from .. import get_logger
|
||||
|
||||
log = get_logger("pdf")
|
||||
|
||||
|
||||
def render_pdf_to_images(filepath: Path, dpi: int = 300) -> list[str]:
|
||||
"""将 PDF 渲染为图片,返回 base64 编码的 JPEG 字符串列表。
|
||||
|
||||
Args:
|
||||
filepath: PDF 文件路径。
|
||||
dpi: 渲染分辨率(默认 150,平衡质量与速度)。
|
||||
|
||||
Returns:
|
||||
base64 编码的 JPEG 图片字符串列表(每页一个)。
|
||||
"""
|
||||
images = []
|
||||
try:
|
||||
doc = fitz.open(filepath)
|
||||
zoom = dpi / 72.0 # 72 DPI 是 fitz 默认
|
||||
matrix = fitz.Matrix(zoom, zoom)
|
||||
|
||||
for page in doc:
|
||||
pix = page.get_pixmap(matrix=matrix)
|
||||
jpg_bytes = pix.tobytes("jpg")
|
||||
b64 = base64.b64encode(jpg_bytes).decode("utf-8")
|
||||
images.append(b64)
|
||||
|
||||
doc.close()
|
||||
log.info(f"PDF 渲染成功: {filepath.name} ({len(images)} 页, {dpi} DPI)")
|
||||
except Exception as e:
|
||||
log.error(f"PDF 渲染失败 {filepath.name}: {e}")
|
||||
|
||||
return images
|
||||
31
src/doc/prompt.py
Normal file
31
src/doc/prompt.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
LLM 提示词模板
|
||||
|
||||
从 src/prompts/ 目录加载 .md 文件作为提示词模板。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
_PROMPTS_DIR = os.path.join(os.path.dirname(__file__), "prompts")
|
||||
|
||||
|
||||
def _load_prompt(filename: str) -> str:
|
||||
"""从 prompts 目录加载提示词文件内容。"""
|
||||
path = os.path.join(_PROMPTS_DIR, filename)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def build_invoice_system_prompt() -> str:
|
||||
"""构建发票提取系统提示词。"""
|
||||
return _load_prompt("invoice_system.md")
|
||||
|
||||
|
||||
def build_travel_info_system_prompt() -> str:
|
||||
"""构建差旅信息提取系统提示词。"""
|
||||
return _load_prompt("travel_info_system.md")
|
||||
|
||||
|
||||
def build_normal_info_system_prompt() -> str:
|
||||
"""构建普通发票信息提取系统提示词。"""
|
||||
return _load_prompt("normal_info_system.md")
|
||||
20
src/doc/prompts/README.md
Normal file
20
src/doc/prompts/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# src/doc/prompts — LLM 提示词模板
|
||||
|
||||
存放 LLM 信息提取使用的系统提示词模板文件,由 `src/doc/prompt.py` 动态加载。
|
||||
|
||||
## 模板清单
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `invoice_system.md` | 发票提取系统提示词:指导 LLM 从发票图片、支付截图、出差申请单等文档中提取结构化信息 |
|
||||
| `travel_info_system.md` | 差旅信息提取系统提示词:指导 LLM 整合已结构化的发票信息、付款记录和出差申请单,生成差旅报销所需的结构化数据 |
|
||||
|
||||
## 加载方式
|
||||
|
||||
```python
|
||||
from src.doc.prompt import build_invoice_system_prompt, build_travel_info_system_prompt
|
||||
```
|
||||
96
src/doc/prompts/invoice_system.md
Normal file
96
src/doc/prompts/invoice_system.md
Normal file
@@ -0,0 +1,96 @@
|
||||
你是财务文档信息提取助手。你的任务是从图片中提取结构化信息,可能是支付截图、银行转账记录、微信/支付宝付款凭证等,也可能是发票文件,也可能是出差事前申请单,也可能是易耗品、出库单,不管任何形式都要用统一的 JSON 格式返回信息。
|
||||
|
||||
第一步要先判断是,支付记录、高铁票、酒店住宿,普通发票,然后不同类型输出的信息不同。
|
||||
|
||||
## 输出示例
|
||||
|
||||
以下是火车票类型发票的完整示例:
|
||||
```json
|
||||
{
|
||||
"invoice_type": "train", //必填项
|
||||
"invoice_number": "26349119343000335414",
|
||||
"invoice_date": "2026-06-05", //必填项
|
||||
"ride_date": "2026-06-02", //必填项
|
||||
"departure": "阜阳西", //必填项
|
||||
"arrival": "合肥南", //必填项
|
||||
"seat_class": "二等座",
|
||||
"train_no": "G1967",
|
||||
"person_name": "王建锋", //必填项
|
||||
"total_amount": "115.50" //必填项
|
||||
}
|
||||
```
|
||||
以下是支付记录的完整示例:
|
||||
```json
|
||||
{
|
||||
"invoice_type": "payment",//必填项
|
||||
"card_date": "2026-06-01",//必填项
|
||||
"card_amount": "231.00",//必填项
|
||||
"card_no": "6282****1682"
|
||||
}
|
||||
```
|
||||
以下是酒店住宿发票的完整示例:
|
||||
```json
|
||||
{
|
||||
"invoice_type": "hotel",//必填项
|
||||
"invoice_number": "26342000001715702281",
|
||||
"invoice_date": "2026-06-03",
|
||||
"total_amount": "536.00"//必填项
|
||||
}
|
||||
以下是易耗品出入库单的完整示例:
|
||||
```json
|
||||
{
|
||||
"invoice_type": "note",//必填项且只有这一项
|
||||
}
|
||||
```
|
||||
## 重要规则
|
||||
- **必填项**:invoice_type、invoice_date、ride_date、departure、arrival、person_name、total_amount 字段为必填,必须填写。
|
||||
- **空值处理**:可选字段如没有对应信息,返回空字符串;金额字段找不到才填`0`,否则尽量填写实际金额。
|
||||
|
||||
**支付记录**:如果是支付截图、银行转账记录、微信/支付宝付款凭证大概率就是支付记录,请返回如下字段(全部必填,无法识别时返回空字符串):
|
||||
1. invoice_type: "payment"
|
||||
2. card_date: 支付发生的日期,格式为 YYYY-M-D
|
||||
3. card_amount: 实际支付金额,只保留数字(如 123.45)
|
||||
4. card_no: 付款银行卡号,如果截图中有显示则提取,没有则返回空字符串
|
||||
|
||||
**出差事前申请单**,如果是**出差事前申请单**请返回如下字段:
|
||||
1. invoice_type: "application"
|
||||
2. project_name:通常是(项目编号/项目名称)
|
||||
2. purpose:一段文本描述
|
||||
3. start_date:格式为 YYYY-M-D
|
||||
4. end_date:格式为 YYYY-M-D
|
||||
5. person_info,包含:
|
||||
1. person_id:字母+数字
|
||||
2. person_name:有编号就肯定由姓名
|
||||
|
||||
发票需要提取的字段(全部必填,无法识别时返回空字符串):
|
||||
先判断发票类型,如果是高铁票/或者火车票,返回如下字段:
|
||||
1. invoice_type: "train"
|
||||
2. invoice_number: 发票的唯一编号
|
||||
3. invoice_date: 格式为 YYYY-M-D
|
||||
4. ride_date: 格式为 YYYY-M-D
|
||||
5. departure: 没有留空
|
||||
6. arrival: 没有留空
|
||||
7. seat_class: 没有留空
|
||||
8. train_no: 没有留空
|
||||
9. person_name: 没有留空
|
||||
10. total_amount:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息
|
||||
|
||||
如果是酒店住宿(酒店住宿通产包含关键字:住宿服务,酒店,生产生活服务等,请仔细分析,这种发票和普通发票类似),返回如下字段:
|
||||
1. invoice_type: "hotel"
|
||||
2. invoice_number: 发票的唯一编号
|
||||
3. invoice_date: 格式为 YYYY-M-D
|
||||
4. total_amount: 金额数字
|
||||
|
||||
如果是普通发票,返回如下字段:
|
||||
1. invoice_type: "general"
|
||||
2. invoice_number: 发票的唯一编号
|
||||
3. invoice_date: 格式为 YYYY-M-D
|
||||
4. item_name: 商品或服务名称,总结的人能看懂
|
||||
5. spec_model: 规格描述
|
||||
6. total_amount: 金额数字
|
||||
7. seller_name: 卖方全称
|
||||
|
||||
如果是易耗品出入库单,返回如下字段:
|
||||
1. invoice_type: "note"
|
||||
|
||||
**千万注意!千万注意!**:严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。
|
||||
78
src/doc/prompts/normal_info_system.md
Normal file
78
src/doc/prompts/normal_info_system.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 普通报销信息提取系统提示词
|
||||
|
||||
你是财务报销信息提取助手。根据发票信息和付款记录,提取报销相关的结构化数据,并以严格符合类型要求的 JSON 格式返回。
|
||||
|
||||
> **核心原则**:类型约束为最高优先级规则,任何情况下不得违反。
|
||||
|
||||
## 强制类型约束
|
||||
|
||||
以下类型规则为最高优先级,任何情况下不得违反。
|
||||
|
||||
### 1. 根节点字段(共 4 个,类型不可变更)
|
||||
|
||||
| 字段名 | 强制类型 | 空值处理 |
|
||||
| --- | --- | --- |
|
||||
| `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 |
|
||||
| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含下述 2 个子字段 |
|
||||
| `payment_methods` | 数组 (list) | 必填,无数据时赋值为 `[]` |
|
||||
| `attachments` | 数组 (list) | 必填,无数据时赋值为 `[]` |
|
||||
|
||||
### 2. `reimbursement_details` 子字段(共 2 个)
|
||||
|
||||
| 字段名 | 强制类型 | 空值处理 |
|
||||
| --- | --- | --- |
|
||||
| `total_invoices` | 数字 (int) | 必填 |
|
||||
| `total_amount` | 数字 (float) | 必填 |
|
||||
|
||||
### 3. 禁止行为
|
||||
|
||||
* 省略任何根节点字段或 `reimbursement_details` 的子字段
|
||||
* 将 `payment_methods` 或 `attachments` 赋值为 `null`、字符串、数字或对象
|
||||
* 在 `reimbursement_details` 中添加未定义的子字段
|
||||
* 合并不同模块的数组数据
|
||||
|
||||
**正确示例**:
|
||||
```json
|
||||
{
|
||||
"basic_info": {...},
|
||||
"reimbursement_details": {...},
|
||||
"payment_methods": [],
|
||||
"attachments": [{"filename":"发票.pdf", ...}]
|
||||
}
|
||||
```
|
||||
|
||||
**错误示例**:
|
||||
```json
|
||||
{
|
||||
"basic_info": {...},
|
||||
"reimbursement_details": {...},
|
||||
"payment_methods": null,
|
||||
}
|
||||
```
|
||||
|
||||
## 输入数据说明
|
||||
你会收到以下数据:
|
||||
1. **发票信息**:包含购买物品的发票信息
|
||||
2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息
|
||||
|
||||
需要提取的信息:
|
||||
1. `basic_info`:(必填,每一项都必须填,给出合理的猜测)
|
||||
1. `reimbursement_description`:根据所有信息写一句20字以内的报销说明
|
||||
2. `reimbursement_details`:(至少有一项)
|
||||
1. `total_invoices`: 发票的总份数
|
||||
2. `total_amount`:填写付款记录总金额
|
||||
3. `payment_methods`:(多少笔支付记录就有多少条;多项请采用上述通用 JSON 数组格式)
|
||||
1. `card_date`:根据付款记录,格式 YYYY-M-D
|
||||
2. `card_amount`:根据付款记录填写,单位为元,数字
|
||||
3. `merchant`:根据发票信息推测商户信息
|
||||
4. `remark`:说明该笔付款关联的发票信息
|
||||
4. `attachments`:(必填,用户已经告诉你所有文件了`【源文件: {filename}】`,"invoice_type": "payment"的不作为附件)
|
||||
1. `filename`: 严格使用用户提供的原始文件名,不得修改任何字符
|
||||
2. `attachment_type`:从以下两个选项中选择:invoice、other
|
||||
3. `attachment_desc`:简要描述该文件的基本信息
|
||||
|
||||
## 最终输出要求
|
||||
|
||||
* 仅输出纯 JSON 字符串,不包含任何思考过程、解释文字或 Markdown 标记
|
||||
* JSON 语法必须正确,无多余逗号、引号等错误
|
||||
* 严格遵守所有强制性类型约束,违反类型要求的输出视为无效
|
||||
123
src/doc/prompts/travel_info_system.md
Normal file
123
src/doc/prompts/travel_info_system.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 差旅信息提取系统提示词
|
||||
|
||||
你是财务差旅信息提取助手。你的任务是根据发票信息、付款记录,提取出差相关的结构化信息,并以严格符合以下类型要求的 JSON 格式返回。所有类型约束为最高优先级规则,任何情况下不得违反。
|
||||
|
||||
🔴 最高优先级:强制性类型约束(优先级高于所有其他规则)
|
||||
1. 根节点必须包含且仅包含以下 5 个字段,字段类型绝对不可变更:
|
||||
|
||||
| 字段名 | 强制类型 | 空值处理规则 |
|
||||
| ------ | --------- | ------------------ |
|
||||
| `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 |
|
||||
| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含以下 3 个子字段 |
|
||||
| `payment_methods` | 数组 (list) | 必填,无数据时赋值为`[]` |
|
||||
| `subsidy_list` | 数组 (list) | 必填,无数据时赋值为`[]` |
|
||||
| `attachments` | 数组 (list) | 必填,无数据时赋值为`[]` |
|
||||
2. `reimbursement_details`对象必须包含且仅包含以下 3 个子字段,每个子字段必须是数组类型:
|
||||
|
||||
|字段名|强制类型|空值处理规则|
|
||||
|---|---|---|
|
||||
|`transport_fee`|数组 (list)|无数据时赋值为`[]`|
|
||||
|`hotel_fee`|数组 (list)|无数据时赋值为`[]`|
|
||||
|`conference_fee`|数组 (list)|无数据时赋值为`[]`|
|
||||
3. 绝对禁止以下行为:
|
||||
* 省略上述任何一个根节点字段或报销明细的子字段
|
||||
* 将数组类型的字段赋值为null、字符串、数字或对象
|
||||
* 在报销明细中添加任何未定义的子字段
|
||||
* 合并不同模块的数组数据
|
||||
✅ 正确类型示例
|
||||
```json
|
||||
{
|
||||
"basic_info": {...},
|
||||
"reimbursement_details": {
|
||||
"transport_fee": [{"vehicle_type":"train", ...}],
|
||||
"hotel_fee": [],
|
||||
"conference_fee": []
|
||||
},
|
||||
"payment_methods": [],
|
||||
"subsidy_list": [{"person_name":"张三", ...}],
|
||||
"attachments": [{"filename":"发票.pdf", ...}]
|
||||
}
|
||||
```
|
||||
❌ 错误类型示例(绝对禁止)
|
||||
```json
|
||||
{
|
||||
"basic_info": {...},
|
||||
"reimbursement_details": {
|
||||
"transport_fee": [{"vehicle_type":"train", ...}]
|
||||
// 错误:省略了hotel_fee和conference_fee字段
|
||||
},
|
||||
"payment_methods": null, // 错误:数组类型不能为null
|
||||
"subsidy_list": "" // 错误:数组类型不能为字符串
|
||||
// 错误:省略了attachments字段
|
||||
}
|
||||
```
|
||||
|
||||
## 输入数据说明
|
||||
你会收到以下数据:
|
||||
1. **发票信息**:包含高铁票(火车/飞机票)和酒店住宿发票的结构化提取数据
|
||||
2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息
|
||||
3. **出差事前申请单**(可选):包含项目名称、出差事由、出差时间、出差人员等信息
|
||||
|
||||
需要提取的信息:
|
||||
1. `basic_info`:(必填,每一项都必须填,给出合理的猜测)
|
||||
1. `travel_purpose`:如果有出差事前申请单,优先使用申请单中的出差事由;否则根据所有发票信息总结一个合理的出差事由(如"参加XX学术会议"、"前往XX办理公务"等)
|
||||
2. `travel_location`:出差目的地,注意一定是从阜阳出发,根据交通工具出发点和目的地也可以推断得到出差地点,出差事前申请单也有说明
|
||||
3. `start_date`:由交通工具发票的乘车日期推断,没有的话从一切可以知道的信息推断,格式 YYYY-M-D
|
||||
4. `end_date`:由交通工具发票的乘车日期推断,没有的话从一切可以知道的信息推断,格式 YYYY-M-D
|
||||
2. `reimbursement_details`:(至少有一项)
|
||||
1. `transport_fee`:(如有,每一项都要必填,无直接信息时给出合理猜测;多项请采用上述通用 JSON 数组格式)
|
||||
1. `vehicle_type`:从以下选项中选择最符合的一个:train、car、ship、personal_car、official_car、plane、rental_car、self_drive
|
||||
2. `start_date`: 由交通工具发票的乘车日期填写,格式 YYYY-M-D
|
||||
3. `end_date`: 由交通工具发票的乘车日期填写,格式 YYYY-M-D
|
||||
4. `departure_place`:由交通工具发票的信息填写,通常是城市名称
|
||||
5. `arrival_place`:由交通工具发票的信息填写,通常是城市名称
|
||||
6. `amount`:由交通工具发票的信息填写,通常是城市名称
|
||||
7. `bill_count`:由交通工具发票的信息填写,通常是城市名称
|
||||
8. `remark`:填写基本信息,例如:王建锋和张国庆高铁票
|
||||
2. `hotel_fee`:(如有,每一项都要必填,无直接信息时给出合理猜测;多项请采用上述通用 JSON 数组格式)
|
||||
1. `checkin_date`:(酒店发票,通常不含)、(交通工具发票,优先级最高)、(出差事前申请单,时间有可能不对,实际不一定按照规划的进行,以交通工具离开阜阳时间为最高优先级)综合推断,格式 YYYY-M-D,例如:2026-06-01
|
||||
2. `checkout_date`:(酒店发票,通常不含)、(交通工具发票,优先级最高)、(出差事前申请单,时间有可能不对,实际不一定按照规划的进行,以交通工具回阜阳时间为最高优先级)综合推断,格式 YYYY-M-D,例如:2026-06-03
|
||||
3. `days`:结束日期 - 开始日期,整数,例如 2026-06-03 - 2026-06-01,天数为 2 天
|
||||
4. `person_count`:根据发票信息和车票信息综合判断住宿人数,有可能开成一张发票,人数一定是整数
|
||||
5. `invoice_amount`:所有酒店住宿发票的价税合计总额,数字
|
||||
6. `reimburse_amount`:所有酒店住宿付款记录的合计总额,数字
|
||||
7. `remark`:根据所有信息综合判断住宿人员,然后就填写所有人姓名,例如:王建锋、张国庆住宿
|
||||
3. `conference_fee`(如果有,每一项都要必填,给出合理的猜测)
|
||||
1. `bill_count`:根据发票信息判断,有几张关于会务费培训费的发票,一定是整数
|
||||
2. `amount`:会务费培训发票的总金额
|
||||
3. `remark`:会务培训的基本信息
|
||||
|
||||
4. `payment_methods`:(多少笔支付记录就有多少条;多项请采用上述通用 JSON 数组格式)
|
||||
1. `card_date`:根据付款记录,格式 YYYY-M-D
|
||||
2. `card_amount`:根据付款记录填写,单位为元,数字
|
||||
3. `merchant`:根据发票信息推测商户信息(高铁票统一为中国铁路)
|
||||
4. `remark`:说明该笔付款关联的发票信息,例如:王建锋和张国庆从阜阳西 - 合肥南高铁票
|
||||
5. `subsidy_list`:(必填;多项请采用上述通用 JSON 数组格式)
|
||||
1. `person_id`:无直接信息时给出合理编号
|
||||
2. `person_name`:根据车票、住宿等信息推断出差人员姓名
|
||||
3. `start_date`:根据当前人员的来回的交通工具发票上的时间推断,如果没有依据基本信息中的日期信息,格式 YYYY-M-D,例如:2026-06-01
|
||||
4. `end_date`:根据当前人员的来回的交通工具发票上的时间推断,如果没有依据基本信息中的日期信息,格式 YYYY-M-D,例如:2026-06-03
|
||||
5. `days`:结束日期 - 开始日期 + 1,整数(例如:2026-06-03 - 2026-06-01 + 1,天数为 3 天)
|
||||
6. `attachments`:(必填,用户已经告诉你所有文件了`【源文件: {filename}】`,"invoice_type": "payment"的不作为附件)
|
||||
1. `filename`: 严格使用用户提供的原始文件名,不得修改任何字符
|
||||
2. `attachment_type`:从以下两个选项中选择:invoice、other
|
||||
3. `attachment_desc`:简要描述该文件的基本信息
|
||||
|
||||
**推理规则**:
|
||||
- 补助清单由人员数量决定:例如`[{"person_id": "xxxxxxx", "person_name": "张三", "start_date":"2026-06-01", "end_date": "2026-06-03", "days": 3}, {"person_id": "2024xxxxx", "person_name": "李四", "start_date":"2026-06-01", "end_date": "2026-06-03", "days": 3}]`
|
||||
- 支付方式示例:`[{"card_date": "2026-06-01","card_amount": 231.0,"merchant": "中国铁路网络有限公司","remark": "张国庆和王建锋从阜阳西-合肥南高铁票"},{"card_date": "2026-06-01","card_amount": 167.0,"merchant": "中国铁路网络有限公司","remark": "陈曙光从阜阳西-合肥南高铁票"}]`
|
||||
- 交通费,去和回不能放在一起,最好放在两个交通费单里,去时放一个,回时放一个
|
||||
- 如果有出差事前申请单,优先使用申请单中的出差事由
|
||||
- 出差开始时间优先取最早的交通工具乘车日期,无交通工具发票时参考申请单时间
|
||||
- 出差结束时间优先取最晚的交通工具乘车日期,无交通工具发票时参考申请单时间
|
||||
- 若无交通工具发票,用开票日期和付款日期综合判断
|
||||
- 住宿天数 = checkout_date - checkin_date 结果要大于等于 0
|
||||
- 若只有单张酒店发票且无明确天数信息,住宿天数默认为 1
|
||||
- 若只有单张酒店发票且无明确人数信息,住宿人数默认为 1
|
||||
- 支付记录不放在附件中!
|
||||
|
||||
## 最终输出要求
|
||||
* 严格只输出符合上述所有要求的 JSON 字符串
|
||||
* 不要输出任何思考过程、解释文字、Markdown 标记或其他内容
|
||||
* 输出的 JSON 必须语法正确,无多余逗号、引号等语法错误
|
||||
* 必须严格遵守所有强制性类型约束,任何违反类型要求的输出均视为无效
|
||||
@@ -1,17 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
财务报销自动化
|
||||
|
||||
依次执行:
|
||||
1. 发票提取 — 从 PDF 发票提取信息,生成 invoice_summary.csv
|
||||
2. OCR 识别 — 从支付截图识别刷卡信息,回填 CSV
|
||||
3. 报销提交 — 打开浏览器登录财务系统并自动填报
|
||||
2. 报销提交 — 打开浏览器登录财务系统并自动填报
|
||||
|
||||
用法:
|
||||
python run.py # 全流程
|
||||
python run.py --step invoice # 仅发票提取
|
||||
python run.py --step ocr # 仅 OCR 识别
|
||||
python run.py --step submit # 仅浏览器填报
|
||||
python run.py -u 工号 -p 密码 # 覆盖登录凭据
|
||||
"""
|
||||
@@ -21,30 +18,37 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 确保项目根目录在 sys.path 中
|
||||
sys.path.insert(0, str(Path(__file__).parent.resolve()))
|
||||
sys.path.insert(0, str(Path(__file__).parent.resolve().parent))
|
||||
|
||||
from app.pipeline import run_pipeline
|
||||
from src.pipeline import run_pipeline
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="财务报销自动化 - 发票提取 → OCR 识别 → 浏览器填报",
|
||||
description="财务报销自动化 - 发票提取 → 浏览器填报",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--step",
|
||||
choices=["all", "invoice", "ocr", "submit"],
|
||||
choices=["all", "invoice", "submit"],
|
||||
default="all",
|
||||
help="执行步骤 (默认: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-u", "--username",
|
||||
"-u",
|
||||
"--username",
|
||||
default=None,
|
||||
help="信息门户登录账号(覆盖 config.json)",
|
||||
help="信息门户登录账号(覆盖 scripts/config.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--password",
|
||||
"-p",
|
||||
"--password",
|
||||
default=None,
|
||||
help="信息门户登录密码(覆盖 config.json)",
|
||||
help="信息门户登录密码(覆盖 scripts/config.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cache-dir",
|
||||
default=None,
|
||||
help="发票缓存目录(包含 .invoice_cache 子目录,默认: scripts/data)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -52,9 +56,10 @@ def main():
|
||||
step=args.step,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
cache_dir=args.cache_dir,
|
||||
)
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
219
src/pipeline.py
Normal file
219
src/pipeline.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
报销全流程编排
|
||||
|
||||
将发票提取 → 差旅/普通信息提取 → 浏览器填报串联为一条管道,
|
||||
数据在内存中流转,同时生成 CSV 中间产物。
|
||||
|
||||
发票类型区分:
|
||||
- 差旅发票(train/hotel):不生成易耗品出库单,走差旅报销流程
|
||||
- 普通发票(general):生成易耗品出库单,走普通报销流程
|
||||
|
||||
数据流变更(2026-06-11):
|
||||
在发票提取和匹配完成后立即判断报销类型(差旅/普通),
|
||||
差旅调用 LLM 提取 travel_info.json,普通预留 normal_info.json。
|
||||
Bot 仅负责接收信息并填报,不再承担信息提取职责。
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from . import get_logger
|
||||
from .config import load_config
|
||||
from .doc.extractor import extract_invoices
|
||||
from .doc.invoice import (
|
||||
save_application_json,
|
||||
save_invoice_csv,
|
||||
)
|
||||
from .doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
from .doc.llm_extractor import (
|
||||
CACHE_DIR_NAME,
|
||||
extract_normal_info,
|
||||
extract_travel_info,
|
||||
load_cache,
|
||||
)
|
||||
|
||||
|
||||
def _classify_invoice_batch(
|
||||
invoices: list[dict[str, str]],
|
||||
) -> dict[str, list[dict[str, str]]]:
|
||||
"""按发票类型分组"""
|
||||
travel: list[dict[str, str]] = []
|
||||
general: list[dict[str, str]] = []
|
||||
application: list[dict[str, str]] = []
|
||||
for inv in invoices:
|
||||
inv_type = inv.get("invoice_type", "general")
|
||||
if inv_type == "application":
|
||||
application.append(inv)
|
||||
elif inv_type in ("train", "hotel"):
|
||||
travel.append(inv)
|
||||
else:
|
||||
general.append(inv)
|
||||
return {"travel": travel, "general": general, "application": application}
|
||||
|
||||
|
||||
log = get_logger("pipeline")
|
||||
|
||||
|
||||
def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
|
||||
"""从缓存目录读取发票数据并按类型分组"""
|
||||
from .doc.llm_extractor import load_cache
|
||||
|
||||
cache_map = load_cache(cache_path)
|
||||
invoices = [data for data in cache_map.values() if data.get("invoice_type") not in ("application", "payment")]
|
||||
return _classify_invoice_batch(invoices)
|
||||
|
||||
|
||||
def _extract_travel_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None:
|
||||
"""当存在差旅发票时,调用 LLM 提取差旅信息并缓存。
|
||||
|
||||
Returns:
|
||||
差旅信息字典,非差旅时返回 None。
|
||||
"""
|
||||
if not groups.get("travel"):
|
||||
return None
|
||||
|
||||
from .doc.llm_extractor import CACHE_DIR_NAME, load_cache
|
||||
|
||||
# 检查缓存是否已有
|
||||
cache_map = load_cache(cache_path)
|
||||
if cache_map.get("travel_info"):
|
||||
log.info("使用已有差旅信息缓存")
|
||||
return cast(dict[str, Any] | None, cache_map["travel_info"])
|
||||
|
||||
log.info("开始提取差旅信息...")
|
||||
travel_info = extract_travel_info(source_dir=cache_path)
|
||||
|
||||
# 保存到缓存
|
||||
cache_dir = cache_path / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(travel_info, f, ensure_ascii=False, indent=2)
|
||||
log.info("差旅信息已保存到缓存")
|
||||
return travel_info
|
||||
|
||||
|
||||
def _extract_normal_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None:
|
||||
"""当存在普通发票时,调用 LLM 提取普通报销信息并缓存。
|
||||
|
||||
Returns:
|
||||
普通报销信息字典,非普通时返回 None。
|
||||
"""
|
||||
if not groups.get("general"):
|
||||
return None
|
||||
|
||||
# 检查缓存是否已有
|
||||
cache_map = load_cache(cache_path)
|
||||
if cache_map.get("normal_info"):
|
||||
log.info("使用已有普通发票信息缓存")
|
||||
return cast(dict[str, Any] | None, cache_map["normal_info"])
|
||||
|
||||
log.info("开始提取普通发票信息...")
|
||||
normal_info = extract_normal_info(source_dir=cache_path)
|
||||
|
||||
# 保存到缓存
|
||||
cache_dir = cache_path / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(normal_info, f, ensure_ascii=False, indent=2)
|
||||
log.info("普通发票信息已保存到缓存")
|
||||
return normal_info
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
step: str = "all",
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
cache_dir: str | None = None,
|
||||
) -> int:
|
||||
"""执行报销流程
|
||||
|
||||
Args:
|
||||
step: all | invoice | submit
|
||||
username: 覆盖 config.json 中的用户名
|
||||
password: 覆盖 config.json 中的密码
|
||||
cache_dir: 发票缓存目录(包含 .invoice_cache 子目录)
|
||||
"""
|
||||
config = load_config()
|
||||
if username:
|
||||
config["username"] = username
|
||||
if password:
|
||||
config["password"] = password
|
||||
|
||||
project_dir = Path(__file__).parent.parent
|
||||
cache_path = Path(cache_dir) if cache_dir else project_dir / "scripts" / "data"
|
||||
|
||||
# --------------------------------------------------
|
||||
# Step 1: 发票提取 + 类型判断 + 信息提取
|
||||
# --------------------------------------------------
|
||||
payment_records: list[dict[str, str]] | None = None
|
||||
groups: dict[str, list[dict[str, str]]] | None = None
|
||||
travel_info: dict[str, Any] | None = None
|
||||
normal_info: dict[str, Any] | None = None
|
||||
|
||||
if step in ("all", "invoice"):
|
||||
log.info("=" * 60)
|
||||
log.info("[1/2] 发票提取")
|
||||
log.info("=" * 60)
|
||||
|
||||
payment_records, applications, groups = extract_invoices(str(cache_path))
|
||||
if not payment_records:
|
||||
log.error("未提取到任何发票数据")
|
||||
return 1
|
||||
|
||||
save_payment_csv(payment_records, cache_path / "payment_records.csv")
|
||||
save_invoice_csv(payment_records, cache_path / "invoice_summary.csv")
|
||||
|
||||
if applications:
|
||||
save_application_json(applications, cache_path / "travel_applications.json")
|
||||
|
||||
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张")
|
||||
|
||||
# 发票提取完成后立即判断类型
|
||||
is_travel = bool(groups["travel"]) and not bool(groups["general"])
|
||||
if is_travel:
|
||||
travel_info = _extract_travel_info_if_needed(groups, cache_path)
|
||||
else:
|
||||
normal_info = _extract_normal_info_if_needed(groups, cache_path)
|
||||
|
||||
if step == "invoice":
|
||||
log.info("[1/2] 发票提取 完成")
|
||||
return 0
|
||||
|
||||
# --------------------------------------------------
|
||||
# Step 2: 浏览器填报
|
||||
# --------------------------------------------------
|
||||
if step in ("all", "submit"):
|
||||
log.info("=" * 60)
|
||||
log.info("[2/2] 报销提交")
|
||||
log.info("=" * 60)
|
||||
|
||||
from .bot import run_bot
|
||||
|
||||
if groups is None:
|
||||
groups = _classify_from_cache(cache_path)
|
||||
|
||||
is_travel = bool(groups["travel"]) and not bool(groups["general"])
|
||||
if is_travel:
|
||||
if travel_info is None:
|
||||
travel_info = _extract_travel_info_if_needed(groups, cache_path)
|
||||
log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||
run_bot(config, work_dir=cache_path, travel_info=travel_info)
|
||||
else:
|
||||
if normal_info is None:
|
||||
normal_info = _extract_normal_info_if_needed(groups, cache_path)
|
||||
log.info("检测到普通发票,使用普通报销模式")
|
||||
run_bot(config, work_dir=cache_path, normal_info=normal_info)
|
||||
|
||||
if step == "submit":
|
||||
log.info("[2/2] 报销提交 完成")
|
||||
return 0
|
||||
|
||||
log.info("=" * 60)
|
||||
log.info("全流程执行完毕")
|
||||
log.info("=" * 60)
|
||||
return 0
|
||||
104
src/web/README.md
Normal file
104
src/web/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# src/web 模块设计说明
|
||||
|
||||
## 设计思路
|
||||
|
||||
`src/web` 是一个基于 Flask 的轻量级 Web 界面,为财务报销自动化管道提供可视化操作入口。核心设计原则:
|
||||
|
||||
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
|
||||
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
|
||||
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5,不引入构建工具,保持单页应用轻量可维护。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/web/
|
||||
├── app.py # Flask 应用入口,路由、管道编排、日志收集
|
||||
├── templates/
|
||||
│ ├── index.html # PC 端主界面(上传、配置、处理、编辑、提交)
|
||||
│ └── mobile_upload.html # 移动端上传页面(拍照/相册选择)
|
||||
└── static/
|
||||
├── css/
|
||||
│ └── index.css # 全局样式(上传区、日志面板、可编辑表格)
|
||||
└── js/
|
||||
└── index.js # 前端逻辑(上传、SSE 日志、表格编辑、二维码同步)
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[用户上传文件] --> B[创建 session]
|
||||
B --> C[文件写入 uploads/<sid>/]
|
||||
C --> D[后台线程执行管道]
|
||||
D --> E["extract_invoices()"]
|
||||
E --> F["enrich_with_llm()"]
|
||||
F --> G["save_csv()"]
|
||||
G --> H["结果写入 session 目录"]
|
||||
H --> I["invoice_summary.csv"]
|
||||
H --> J["invoice_groups.json"]
|
||||
H --> K["result.json"]
|
||||
H --> L["session.log"]
|
||||
E --> M{"判断报销类型"}
|
||||
M -->|差旅| N["extract_travel_info()"]
|
||||
M -->|普通| O["extract_normal_info()"]
|
||||
N --> P["travel_info.json"]
|
||||
O --> Q["normal_info.json"]
|
||||
K --> R["前端 SSE 轮询 → 显示完成状态"]
|
||||
R --> S["前端加载 CSV → 可编辑表格"]
|
||||
S --> T["用户修改后保存"]
|
||||
T --> U["用户点击提交"]
|
||||
U --> V["run_financial_submit()"]
|
||||
V --> W["bot 自动填报财务系统"]
|
||||
W --> X["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
|
||||
W --> Y["普通模式: normal_info.json → 基本信息 → 录入明细 → 支付信息 → 上传附件"]
|
||||
```
|
||||
|
||||
## API 路由
|
||||
|
||||
| 方法 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 主界面 |
|
||||
| POST | `/api/session` | 创建会话,返回 session_id |
|
||||
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
|
||||
| GET | `/api/files/<sid>` | 列出会话文件 |
|
||||
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
|
||||
| GET | `/api/logs/<sid>` | SSE 日志流 |
|
||||
| GET | `/api/data/<sid>` | 获取发票数据 JSON |
|
||||
| POST | `/api/save/<sid>` | 保存前端编辑的发票数据 |
|
||||
| GET | `/api/download/<sid>/<filename>` | 下载生成文件 |
|
||||
| POST | `/api/submit-financial/<sid>` | 手动触发财务系统填报 |
|
||||
| GET | `/mobile/<sid>` | 移动端上传页面 |
|
||||
|
||||
## 关键机制
|
||||
|
||||
### 日志收集
|
||||
|
||||
`_SSELogHandler` 将管道日志写入 `session.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
|
||||
|
||||
### 发票类型分流
|
||||
|
||||
- **差旅发票**(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
||||
- **普通发票**:生成易耗品出库单(Word 文档),走普通报销流程
|
||||
|
||||
`extract_invoices()` 在提取阶段完成分类,结果保存为 `invoice_groups.json`(包含 `travel_count`、`general_count`、`application_count`)。后续步骤(出库单生成、财务填报)统一从该文件读取分类结果,避免重复解析 CSV 和 JSON 字段。
|
||||
|
||||
### LLM 信息提取(2026-06-11)
|
||||
|
||||
发票提取和匹配完成后,根据类型分流调用 LLM 提取结构化报销信息:
|
||||
|
||||
- **差旅发票**:调用 `extract_travel_info()`,提取出差事由、地点、时间、交通/住宿明细、补助清单、支付方式、附件清单,缓存为 `travel_info.json`。
|
||||
- **普通发票**:调用 `extract_normal_info()`,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json`。
|
||||
|
||||
Bot 填报时优先使用 LLM 提取的信息(`travel_info`/`normal_info`),降级时从缓存加载原始发票数据。
|
||||
|
||||
### 移动端同步
|
||||
|
||||
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的图片通过 `syncFiles()` 轮询同步到 PC 端内存中的 `imgFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
|
||||
|
||||
### 配置管理
|
||||
|
||||
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端支持通过上传 `config.json` 快速填充配置表单。
|
||||
722
src/web/app.py
Normal file
722
src/web/app.py
Normal file
@@ -0,0 +1,722 @@
|
||||
"""
|
||||
财务报销自动化 — Web 界面
|
||||
|
||||
用户上传 PDF 发票,配置账号信息,自动完成:
|
||||
1. 发票提取 2. 浏览器填报(可选)
|
||||
|
||||
发票类型区分:
|
||||
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
||||
- 普通发票:生成易耗品出库单,走普通报销流程
|
||||
|
||||
启动: uv run python src/web/app.py
|
||||
访问: http://localhost:5000
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
|
||||
|
||||
# 确保项目根目录在 sys.path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src import get_logger # noqa: E402, I001
|
||||
from src.config import load_config as load_project_config # noqa: E402, I001
|
||||
from src.doc.extractor import ( # noqa: E402, I001
|
||||
extract_invoices,
|
||||
)
|
||||
from src.doc.fill_consumable_doc import ( # noqa: E402, I001
|
||||
CONSUMABLE_DOC_FILENAME,
|
||||
fill_consumable_from_template,
|
||||
)
|
||||
from src.doc.invoice import ( # noqa: E402, I001
|
||||
load_csv,
|
||||
load_invoice_csv,
|
||||
save_csv as save_payment_csv,
|
||||
save_invoice_csv,
|
||||
save_application_json,
|
||||
)
|
||||
|
||||
|
||||
fill_log = get_logger("fill_consumable_doc")
|
||||
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
|
||||
|
||||
app = Flask(__name__, template_folder="templates")
|
||||
|
||||
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
|
||||
SESSION_LOG_FILE = "session.log"
|
||||
SESSION_RESULT_FILE = "result.json"
|
||||
INVOICE_GROUPS_FILE = "invoice_groups.json"
|
||||
|
||||
|
||||
def _save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
|
||||
"""保存发票分类结果到 session 目录的 JSON 文件"""
|
||||
groups_path = session_dir / INVOICE_GROUPS_FILE
|
||||
# 只保存各组的数量统计,避免重复存储完整发票数据
|
||||
data = {
|
||||
"travel_count": len(groups.get("travel", [])),
|
||||
"general_count": len(groups.get("general", [])),
|
||||
"application_count": len(groups.get("application", [])),
|
||||
}
|
||||
with open(groups_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _load_invoice_groups(session_dir: Path) -> dict[str, int] | None:
|
||||
"""从 session 目录加载发票分类统计"""
|
||||
groups_path = session_dir / INVOICE_GROUPS_FILE
|
||||
if not groups_path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(groups_path, encoding="utf-8") as f:
|
||||
return cast(dict[str, int] | None, json.load(f))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class _SSELogHandler(logging.Handler):
|
||||
"""将日志写入指定文件(线程安全)"""
|
||||
|
||||
def __init__(self, log_path: Path):
|
||||
super().__init__()
|
||||
self._lock = threading.Lock()
|
||||
self._file = open(log_path, "w", encoding="utf-8")
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
msg = self.format(record) + "\n"
|
||||
with self._lock:
|
||||
self._file.write(msg)
|
||||
self._file.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def close_file(self) -> None:
|
||||
try:
|
||||
self._file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _install_log_collector(session_dir: Path) -> _SSELogHandler:
|
||||
"""安装日志收集器到 app.* 模块"""
|
||||
log_path = session_dir / SESSION_LOG_FILE
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
handler = _SSELogHandler(log_path)
|
||||
handler.setFormatter(fmt)
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _remove_log_collector(handler: _SSELogHandler) -> None:
|
||||
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
|
||||
logging.getLogger(name).removeHandler(handler)
|
||||
handler.close_file()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 出库单填写
|
||||
# ================================================================
|
||||
|
||||
|
||||
def _load_session_config(session_dir: Path) -> dict[str, Any]:
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
config.update(json.load(f))
|
||||
return config
|
||||
|
||||
|
||||
def _resolve_payment_csv(session_dir: Path) -> Path | None:
|
||||
"""查找支付记录 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"):
|
||||
if f.name != SESSION_RESULT_FILE:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 出库单填写
|
||||
# ================================================================
|
||||
|
||||
|
||||
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""根据 CSV 填写易耗品出库单,供会话目录下载。
|
||||
|
||||
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
|
||||
"""
|
||||
if not CONSUMABLE_TEMPLATE.exists():
|
||||
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
|
||||
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
|
||||
|
||||
# 从统一的分类结果读取,避免重复解析 CSV/JSON
|
||||
groups = _load_invoice_groups(session_dir)
|
||||
if groups is None:
|
||||
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
|
||||
|
||||
if not groups.get("general_count", 0):
|
||||
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
|
||||
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
|
||||
|
||||
csv_path = _resolve_payment_csv(session_dir)
|
||||
if csv_path is None:
|
||||
return {"ok": False, "error": "未找到发票 CSV"}
|
||||
|
||||
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
|
||||
try:
|
||||
fill_log.info("开始填写出库单: %s", out_doc.name)
|
||||
fill_consumable_from_template(csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config)
|
||||
fill_log.info("出库单填写完成")
|
||||
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
|
||||
except ImportError:
|
||||
fill_log.error("填写出库单需要 pywin32,请执行: pip install pywin32")
|
||||
return {"ok": False, "error": "服务器未安装 pywin32,无法生成 Word 出库单"}
|
||||
except Exception as e:
|
||||
fill_log.exception("填写出库单失败: %s", e)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def _append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
|
||||
if doc_fill.get("ok"):
|
||||
fn = doc_fill["doc_filename"]
|
||||
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||
result["doc_ok"] = True
|
||||
elif doc_fill.get("skipped"):
|
||||
# 差旅发票,跳过出库单生成(不是错误)
|
||||
result["doc_ok"] = None
|
||||
result["doc_skipped"] = True
|
||||
result["doc_message"] = doc_fill.get("error", "")
|
||||
else:
|
||||
result["doc_ok"] = False
|
||||
result["doc_error"] = doc_fill.get("error", "未知错误")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 管道入口
|
||||
# ================================================================
|
||||
|
||||
|
||||
def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
|
||||
|
||||
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
|
||||
|
||||
在发票提取和匹配完成后立即判断报销类型:
|
||||
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
|
||||
- 普通发票:无需额外提取(normal_info.json 待实现)
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
# ---- Step 1: 发票提取 ----
|
||||
invoices, applications, groups = extract_invoices(str(session_dir))
|
||||
if not invoices:
|
||||
return {"ok": False, "error": "未提取到任何发票数据"}
|
||||
|
||||
# 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
|
||||
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
||||
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
|
||||
|
||||
# 出差申请单单独保存
|
||||
if applications:
|
||||
save_application_json(applications, session_dir / "travel_applications.json")
|
||||
|
||||
# 保存分类结果(供后续步骤统一读取)
|
||||
_save_invoice_groups(session_dir, groups)
|
||||
|
||||
# ---- Step 2: 差旅/普通信息提取 ----
|
||||
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
|
||||
if is_travel:
|
||||
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache
|
||||
|
||||
# 检查缓存是否已有
|
||||
cache_map = load_cache(session_dir)
|
||||
if not cache_map.get("travel_info"):
|
||||
fill_log.info("开始提取差旅信息...")
|
||||
travel_info = extract_travel_info(source_dir=session_dir)
|
||||
cache_dir = session_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(travel_info, f, ensure_ascii=False, indent=2)
|
||||
fill_log.info("差旅信息已保存到缓存")
|
||||
else:
|
||||
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache
|
||||
|
||||
# 检查缓存是否已有
|
||||
cache_map = load_cache(session_dir)
|
||||
if not cache_map.get("normal_info"):
|
||||
fill_log.info("开始提取普通发票信息...")
|
||||
normal_info = extract_normal_info(source_dir=session_dir)
|
||||
cache_dir = session_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(normal_info, f, ensure_ascii=False, indent=2)
|
||||
fill_log.info("普通发票信息已保存到缓存")
|
||||
|
||||
# 统计发票总数
|
||||
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
|
||||
|
||||
elapsed = time.time() - start
|
||||
result = {
|
||||
"ok": True,
|
||||
"elapsed": f"{elapsed:.1f}s",
|
||||
"invoice_count": invoice_count,
|
||||
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
|
||||
# 发票类型统计
|
||||
"travel_count": len(groups["travel"]),
|
||||
"general_count": len(groups["general"]),
|
||||
}
|
||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||
_append_doc_download(result, session_dir.name, doc_fill)
|
||||
return result
|
||||
|
||||
|
||||
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""执行财务系统填报(从前端确认后调用)
|
||||
|
||||
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
|
||||
- 纯差旅发票:差旅报销模式(TODO)
|
||||
- 含普通发票:普通报销模式
|
||||
"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
if not csv_path.exists():
|
||||
return {"ok": False, "error": "未找到发票数据,请先处理"}
|
||||
|
||||
from src.bot import run_bot_web
|
||||
|
||||
# 从统一的分类结果读取,避免重复解析
|
||||
groups = _load_invoice_groups(session_dir)
|
||||
if groups:
|
||||
if groups.get("travel_count", 0) and not groups.get("general_count", 0):
|
||||
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||
# TODO: 差旅报销填报流程
|
||||
else:
|
||||
fill_log.info("检测到普通发票,使用普通报销模式")
|
||||
|
||||
run_bot_web(config, session_dir)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Flask 路由
|
||||
# ================================================================
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index() -> Any:
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/session", methods=["POST"])
|
||||
def create_session() -> Any:
|
||||
"""创建上传会话,返回 session_id"""
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
session_dir = UPLOAD_BASE / sid
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
return jsonify({"session_id": sid})
|
||||
|
||||
|
||||
@app.route("/api/upload/<session_id>", methods=["POST"])
|
||||
def upload_file(session_id: str) -> Any:
|
||||
"""上传 PDF 或图片"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
return jsonify({"error": "未选择文件"}), 400
|
||||
|
||||
safe_name = Path(f.filename).name
|
||||
f.save(str(session_dir / safe_name))
|
||||
return jsonify({"ok": True, "filename": safe_name})
|
||||
|
||||
|
||||
@app.route("/api/files/<session_id>", methods=["GET"])
|
||||
def list_files(session_id: str) -> Any:
|
||||
"""列出会话目录中的文件"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
|
||||
imgs = sorted(f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"} for f in session_dir.glob(f"*{ext}"))
|
||||
return jsonify({"pdfs": pdfs, "images": imgs})
|
||||
|
||||
|
||||
@app.route("/api/process/<session_id>", methods=["POST"])
|
||||
def start_process(session_id: str) -> Any:
|
||||
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
|
||||
# 读取配置
|
||||
config = _build_web_config(body)
|
||||
|
||||
# 写入配置到会话目录
|
||||
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
# 在后台线程执行
|
||||
handler = _install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
result = run_pipeline_web(session_dir, config)
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
_remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@app.route("/api/logs/<session_id>")
|
||||
def stream_logs(session_id: str) -> Any:
|
||||
"""SSE 日志流"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
def generate() -> Any:
|
||||
# 先发送已有日志
|
||||
log_file = session_dir / SESSION_LOG_FILE
|
||||
last_size = 0
|
||||
start_time = time.time()
|
||||
timeout = 600 # 10 分钟超时
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
if log_file.exists():
|
||||
current_size = log_file.stat().st_size
|
||||
if current_size > last_size:
|
||||
with open(log_file, encoding="utf-8", errors="replace") as f:
|
||||
f.seek(last_size)
|
||||
chunk = f.read()
|
||||
if chunk:
|
||||
yield f"data: {_escape_sse(chunk)}\n\n"
|
||||
last_size = current_size
|
||||
|
||||
# 也通过队列发送实时日志
|
||||
# 检查是否完成
|
||||
result_file = session_dir / SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
with open(result_file, encoding="utf-8") as f:
|
||||
result = json.load(f)
|
||||
yield f"data: {_escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
|
||||
break
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/download/<session_id>/<filename>")
|
||||
def download_file(session_id: str, filename: str) -> Any:
|
||||
"""下载生成的文件"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
# 防止路径穿越
|
||||
safe_name = Path(filename).name
|
||||
filepath = session_dir / safe_name
|
||||
if not filepath.exists():
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
|
||||
if safe_name.endswith(".doc"):
|
||||
mimetype = "application/msword"
|
||||
elif safe_name.endswith(".csv"):
|
||||
mimetype = "text/csv; charset=utf-8"
|
||||
else:
|
||||
mimetype = "application/octet-stream"
|
||||
|
||||
disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}"
|
||||
return Response(
|
||||
filepath.read_bytes(),
|
||||
mimetype=mimetype,
|
||||
headers={"Content-Disposition": disposition},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/config/<session_id>", methods=["GET"])
|
||||
def get_session_config(session_id: str) -> Any:
|
||||
"""获取当前会话的配置(供前端回填表单)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
config.update(json.load(f))
|
||||
# 只返回前端需要的字段
|
||||
return jsonify(
|
||||
{
|
||||
"username": config.get("username", ""),
|
||||
"password": "", # 不返回密码
|
||||
"default_name": config.get("default_name", ""),
|
||||
"default_card_no": config.get("default_card_no", ""),
|
||||
"default_person_id": config.get("default_person_id", ""),
|
||||
"consumable_storage": config.get("consumable_storage", ""),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/data/<session_id>", methods=["GET"])
|
||||
def get_invoice_data(session_id: str) -> Any:
|
||||
"""读取发票数据并返回 JSON(供前端表格编辑)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
# 优先读取支付记录 CSV
|
||||
payment_csv = session_dir / "payment_records.csv"
|
||||
if payment_csv.exists():
|
||||
rows = load_csv(payment_csv)
|
||||
if rows is not None:
|
||||
data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry: dict[str, Any] = dict(row)
|
||||
entry["__row"] = i
|
||||
data.append(entry)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
|
||||
|
||||
# 回退到发票级别 CSV
|
||||
invoice_csv = session_dir / "invoice_summary.csv"
|
||||
if invoice_csv.exists():
|
||||
rows = load_invoice_csv(invoice_csv)
|
||||
if rows is not None:
|
||||
invoice_data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry2: dict[str, Any] = dict(row)
|
||||
entry2["__row"] = i
|
||||
invoice_data.append(entry2)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data})
|
||||
|
||||
# 最后尝试任意 CSV
|
||||
csv_files = list(session_dir.glob("*.csv"))
|
||||
csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
|
||||
if csv_files:
|
||||
csv_path = csv_files[0]
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
rows = load_invoice_csv(csv_path)
|
||||
if rows is not None:
|
||||
fallback_data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry3: dict[str, Any] = dict(row)
|
||||
entry3["__row"] = i
|
||||
fallback_data.append(entry3)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
|
||||
|
||||
return jsonify({"error": "未找到发票数据,请先处理"}), 404
|
||||
|
||||
|
||||
@app.route("/api/save/<session_id>", methods=["POST"])
|
||||
def save_invoice_data(session_id: str) -> Any:
|
||||
"""保存前端编辑后的发票数据到 CSV"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
data = body.get("data", [])
|
||||
csv_filename = body.get("csv_filename", "invoice_summary.csv")
|
||||
|
||||
csv_path = session_dir / csv_filename
|
||||
if not csv_path.exists():
|
||||
return jsonify({"error": "CSV 文件不存在"}), 404
|
||||
|
||||
# 读取原 CSV 获取字段顺序(使用第一个数据的 keys)
|
||||
original_rows = load_csv(csv_path)
|
||||
if original_rows is None or len(original_rows) == 0:
|
||||
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
|
||||
|
||||
# 从原数据中获取字段顺序(去掉内部字段)
|
||||
fieldnames = list(original_rows[0].keys())
|
||||
|
||||
import csv as csv_module
|
||||
|
||||
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for entry in data:
|
||||
row = {k: entry.get(k, "") for k in fieldnames}
|
||||
writer.writerow(row)
|
||||
|
||||
resp: dict[str, str | bool | None] = {"ok": True}
|
||||
config = _load_session_config(session_dir)
|
||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||
if doc_fill.get("ok"):
|
||||
fn = doc_fill["doc_filename"]
|
||||
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||
resp["doc_ok"] = True
|
||||
elif doc_fill.get("skipped"):
|
||||
resp["doc_ok"] = None
|
||||
resp["doc_skipped"] = True
|
||||
else:
|
||||
resp["doc_ok"] = False
|
||||
resp["doc_error"] = doc_fill.get("error") or ""
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@app.route("/api/submit-financial/<session_id>", methods=["POST"])
|
||||
def submit_financial(session_id: str) -> Any:
|
||||
"""手动触发财务系统填报"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
# 读取配置
|
||||
config_path = session_dir / "config.json"
|
||||
if not config_path.exists():
|
||||
return jsonify({"error": "未找到配置,请先配置后处理"}), 400
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
# 清除上次处理留下的结果文件,避免 SSE 误判为已完成
|
||||
result_file = session_dir / SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
result_file.unlink()
|
||||
|
||||
# 在后台线程执行提交
|
||||
handler = _install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
submit_result = run_financial_submit(session_dir, config)
|
||||
if submit_result.get("ok"):
|
||||
result = {"ok": True}
|
||||
else:
|
||||
result = submit_result
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
_remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 辅助函数
|
||||
# ================================================================
|
||||
|
||||
|
||||
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
|
||||
session_dir = UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
return jsonify({"error": "会话不存在"}), 404
|
||||
return session_dir
|
||||
|
||||
|
||||
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""从请求体构建配置"""
|
||||
config = load_project_config()
|
||||
for key in (
|
||||
"username",
|
||||
"password",
|
||||
"default_name",
|
||||
"default_card_no",
|
||||
"default_person_id",
|
||||
"consumable_storage",
|
||||
):
|
||||
if body.get(key):
|
||||
config[key] = body[key]
|
||||
return config
|
||||
|
||||
|
||||
def _escape_sse(text: str) -> str:
|
||||
"""SSE 数据转义,同时处理 Windows 行尾 \\r\\n"""
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
|
||||
|
||||
|
||||
@app.route("/mobile/<session_id>")
|
||||
def mobile_upload(session_id: str) -> Any:
|
||||
"""移动端上传页面"""
|
||||
session_dir = UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
return render_template("mobile_upload.html", error="会话不存在"), 404
|
||||
return render_template("mobile_upload.html", session_id=session_id)
|
||||
|
||||
|
||||
@app.route("/api/mobile-upload/<session_id>", methods=["POST"])
|
||||
def mobile_upload_file(session_id: str) -> Any:
|
||||
"""移动端上传图片(复用 PC 上传逻辑)"""
|
||||
return upload_file(session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
|
||||
print("启动 Web 服务: http://localhost:5000")
|
||||
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)
|
||||
18
src/web/static/README.md
Normal file
18
src/web/static/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# src/web/static — 静态资源目录
|
||||
|
||||
存放 Web 界面的 CSS 样式表和 JavaScript 前端逻辑。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `css/index.css` | 全局样式:上传区域、日志面板、可编辑表格、状态徽章 |
|
||||
| `js/index.js` | 前端逻辑:文件上传、SSE 日志监听、发票数据编辑、配置同步、移动端扫码上传、财务提交 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
原生 JavaScript + Bootstrap 5,无构建工具,保持单页应用轻量可维护。
|
||||
24
src/web/static/css/index.css
Normal file
24
src/web/static/css/index.css
Normal file
@@ -0,0 +1,24 @@
|
||||
body { background: #f5f7fa; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
|
||||
.upload-zone {
|
||||
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
|
||||
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
|
||||
}
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
|
||||
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
|
||||
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
|
||||
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
|
||||
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
|
||||
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
.log-container .empty { color: #666; font-style: italic; }
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
|
||||
.btn-process { font-size: 17px; padding: 10px 40px; }
|
||||
.status-badge { font-size: 13px; }
|
||||
|
||||
/* 可编辑表格 */
|
||||
.table-editable { font-size: 13px; }
|
||||
.table-editable th { position: sticky; top: 0; background: #f8f9fa; z-index: 1; font-weight: 600; white-space: nowrap; }
|
||||
.table-editable td { vertical-align: middle; }
|
||||
.table-editable input.form-control { font-size: 13px; padding: 4px 8px; min-width: 80px; }
|
||||
.table-wrapper { max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 8px; }
|
||||
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }
|
||||
483
src/web/static/js/index.js
Normal file
483
src/web/static/js/index.js
Normal file
@@ -0,0 +1,483 @@
|
||||
let sessionId = null;
|
||||
const pdfFiles = [], imgFiles = [];
|
||||
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
|
||||
let csvFilename = ''; // 当前 CSV 文件名
|
||||
let lastDownloadUrls = {}; // 最近一次可下载文件链接
|
||||
|
||||
// ---- Session ----
|
||||
async function ensureSession() {
|
||||
if (sessionId) return sessionId;
|
||||
const r = await fetch('/api/session', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
sessionId = d.session_id;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
// ---- 上传 ----
|
||||
function handleFiles(input, type) {
|
||||
const files = Array.from(input.files);
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
|
||||
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
|
||||
|
||||
files.forEach(f => {
|
||||
if (!list.find(x => x.name === f.name)) {
|
||||
f.__source = 'local'; // 标记为本地手动选择
|
||||
list.push(f);
|
||||
}
|
||||
});
|
||||
|
||||
renderFileList(type);
|
||||
document.getElementById(zoneId).classList.add('active');
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removeFile(type, index) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
list.splice(index, 1);
|
||||
renderFileList(type);
|
||||
if (list.length === 0) {
|
||||
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileList(type) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
|
||||
box.innerHTML = list.map((f, i) =>
|
||||
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">×</span></span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ---- 配置上传 ----
|
||||
function handleConfigUpload(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
const cfg = JSON.parse(e.target.result);
|
||||
const map = {
|
||||
'cfg-username': cfg.username,
|
||||
'cfg-password': cfg.password,
|
||||
'cfg-name': cfg.default_name,
|
||||
'cfg-card': cfg.default_card_no,
|
||||
'cfg-person-id': cfg.default_person_id,
|
||||
'cfg-storage': cfg.consumable_storage,
|
||||
};
|
||||
for (const [id, val] of Object.entries(map)) {
|
||||
if (val) document.getElementById(id).value = val;
|
||||
}
|
||||
// 同步 config.json 中的工号和公务卡号到表格
|
||||
if (cfg.username) syncConfigToTable('工号');
|
||||
if (cfg.default_card_no) syncConfigToTable('公务卡号');
|
||||
alert('配置已加载');
|
||||
} catch (err) {
|
||||
alert('config.json 解析失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// ---- 拖拽 ----
|
||||
['pdf','img'].forEach(type => {
|
||||
const zone = document.getElementById(type + '-zone');
|
||||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||
zone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('dragover');
|
||||
const files = Array.from(e.dataTransfer.files).filter(f => {
|
||||
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
|
||||
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
|
||||
});
|
||||
if (files.length) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
files.forEach(f => {
|
||||
f.__source = 'local'; // 标记为本地拖拽
|
||||
if (!list.find(x => x.name === f.name)) list.push(f);
|
||||
});
|
||||
renderFileList(type);
|
||||
zone.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 处理 ----
|
||||
async function startProcess() {
|
||||
if (!pdfFiles.length && !imgFiles.length) {
|
||||
alert('请先上传文件或图片');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-start');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '处理中...';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
|
||||
document.getElementById('log-box').innerHTML = '';
|
||||
document.getElementById('edit-section').style.display = 'none';
|
||||
document.getElementById('download-section').style.display = 'none';
|
||||
|
||||
try {
|
||||
await ensureSession();
|
||||
|
||||
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
|
||||
for (const {f} of allFiles) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
|
||||
|
||||
const cfg = {
|
||||
username: document.getElementById('cfg-username').value,
|
||||
password: document.getElementById('cfg-password').value,
|
||||
default_name: document.getElementById('cfg-name').value,
|
||||
default_card_no: document.getElementById('cfg-card').value,
|
||||
default_person_id: document.getElementById('cfg-person-id').value,
|
||||
consumable_storage: document.getElementById('cfg-storage').value,
|
||||
};
|
||||
|
||||
await fetch(`/api/process/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
|
||||
// 监听 SSE 日志
|
||||
const es = new EventSource(`/api/logs/${sessionId}`);
|
||||
const logBox = document.getElementById('log-box');
|
||||
let firstLine = true;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
const result = msg.result;
|
||||
document.getElementById('status').innerHTML = result.ok
|
||||
? '<span class="badge bg-success status-badge">完成</span>'
|
||||
: '<span class="badge bg-danger status-badge">失败</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
|
||||
if (result.ok) {
|
||||
showDownloadLinks(result);
|
||||
loadInvoiceData(); // 加载可编辑数据
|
||||
} else {
|
||||
alert('处理失败: ' + (result.error || '未知错误'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
logBox.innerHTML += e.data;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
alert('请求失败: ' + (e.message || '未知错误'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 下载链接 ----
|
||||
function showDownloadLinks(result) {
|
||||
const section = document.getElementById('download-section');
|
||||
const box = document.getElementById('download-links');
|
||||
const warn = document.getElementById('doc-fill-warning');
|
||||
if (!section || !box) return;
|
||||
|
||||
const items = [];
|
||||
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
|
||||
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
|
||||
|
||||
lastDownloadUrls = {};
|
||||
items.forEach(it => { lastDownloadUrls[it.label] = it.url; });
|
||||
|
||||
box.innerHTML = items.map(it =>
|
||||
`<a class="btn btn-outline-primary btn-sm" href="${it.url}" download>${it.label}</a>`
|
||||
).join('');
|
||||
|
||||
if (warn) {
|
||||
if (result.doc_skipped) {
|
||||
warn.style.display = 'block';
|
||||
warn.style.color = '#0d6efd';
|
||||
warn.textContent = result.doc_message || '差旅报销无需生成易耗品出库单';
|
||||
} else if (result.doc_ok === false && result.doc_error) {
|
||||
warn.style.display = 'block';
|
||||
warn.style.color = '';
|
||||
warn.textContent = '出库单未生成:' + result.doc_error;
|
||||
} else {
|
||||
warn.style.display = 'none';
|
||||
warn.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
section.style.display = items.length || (result.doc_ok === false) || result.doc_skipped ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ---- 发票数据编辑 ----
|
||||
async function loadInvoiceData() {
|
||||
try {
|
||||
const r = await fetch(`/api/data/${sessionId}`);
|
||||
const d = await r.json();
|
||||
if (d.error) return;
|
||||
|
||||
csvFilename = d.csv_filename || 'invoice_summary.csv';
|
||||
invoiceData = d.data || [];
|
||||
const fields = d.fields || Object.keys(invoiceData[0] || {}).filter(k => !k.startsWith('__'));
|
||||
renderTable(invoiceData, fields);
|
||||
|
||||
// 渲染后自动将配置区的工号/公务卡号同步到表格
|
||||
syncConfigToTable('工号');
|
||||
syncConfigToTable('公务卡号');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 配置区 → 表格的同步映射:工号 ↔ cfg-username,公务卡号 ↔ cfg-card
|
||||
const CONFIG_SYNC = {
|
||||
'工号': 'cfg-username',
|
||||
'公务卡号': 'cfg-card',
|
||||
};
|
||||
|
||||
// 从配置区输入框的值更新到所有表格行
|
||||
function syncConfigToTable(field) {
|
||||
const inputId = CONFIG_SYNC[field];
|
||||
if (!inputId) return;
|
||||
const val = document.getElementById(inputId).value || '';
|
||||
invoiceData.forEach((row, i) => {
|
||||
row[field] = val;
|
||||
});
|
||||
// 更新表格中对应单元格的显示
|
||||
const tbody = document.getElementById('invoice-tbody');
|
||||
if (!tbody) return;
|
||||
const rows = tbody.querySelectorAll('tr');
|
||||
rows.forEach((tr, i) => {
|
||||
const inputs = tr.querySelectorAll('input');
|
||||
fieldsCache.forEach((f, colIdx) => {
|
||||
if (f === field && inputs[colIdx]) {
|
||||
inputs[colIdx].value = val;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let fieldsCache = []; // renderTable 渲染后的字段列表,用于定位列索引
|
||||
|
||||
function renderTable(data, fields) {
|
||||
const section = document.getElementById('edit-section');
|
||||
if (!data.length) { section.style.display = 'none'; return; }
|
||||
section.style.display = 'block';
|
||||
|
||||
// 使用后端返回的字段顺序, fallback 到 Object.keys
|
||||
if (!fields || !fields.length) {
|
||||
fields = Object.keys(data[0]).filter(k => !k.startsWith('__'));
|
||||
}
|
||||
fieldsCache = fields;
|
||||
|
||||
// 表头
|
||||
document.getElementById('invoice-thead').innerHTML = `
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
${fields.map(f => `<th>${f}</th>`).join('')}
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// 表体:每行首列为序号,其后为各字段 input
|
||||
const rows = data.map((row, i) => {
|
||||
const cells = fields.map(f => {
|
||||
let onChangeStr = `invoiceData[${i}]['${f.replace(/'/g, "\\'")}']=this.value`;
|
||||
// 如果该字段参与配置区同步,额外调用 syncTableToConfig
|
||||
if (CONFIG_SYNC[f]) {
|
||||
onChangeStr += `;syncTableToConfig('${f.replace(/'/g, "\\'")}', this.value)`;
|
||||
}
|
||||
return `<td><input class="form-control form-control-sm" value="${escapeHtml(String(row[f] || ''))}"
|
||||
onchange="${onChangeStr}"></td>`;
|
||||
}).join('');
|
||||
return `<tr><td style="width:40px;text-align:center">${i + 1}</td>${cells}</tr>`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('invoice-tbody').innerHTML = rows;
|
||||
}
|
||||
|
||||
// 从表格修改同步回配置区
|
||||
function syncTableToConfig(field, value) {
|
||||
const inputId = CONFIG_SYNC[field];
|
||||
if (inputId) {
|
||||
document.getElementById(inputId).value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// 将当前表格编辑内容写回服务器(内部调用)
|
||||
async function saveInvoiceData() {
|
||||
try {
|
||||
const r = await fetch(`/api/save/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: invoiceData, csv_filename: csvFilename }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.doc_url || d.doc_ok === false) {
|
||||
showDownloadLinks({
|
||||
csv_url: lastDownloadUrls['invoice_summary.csv'],
|
||||
doc_url: d.doc_url,
|
||||
doc_ok: d.doc_ok,
|
||||
doc_error: d.doc_error,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('自动保存失败,继续提交:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 提交到财务系统 ----
|
||||
async function submitFinancial() {
|
||||
const btn = document.getElementById('btn-submit');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '提交中...';
|
||||
document.getElementById('log-box').innerHTML = '';
|
||||
|
||||
try {
|
||||
// 提交前先自动保存当前表格编辑内容
|
||||
await saveInvoiceData();
|
||||
|
||||
await fetch(`/api/submit-financial/${sessionId}`, { method: 'POST' });
|
||||
|
||||
// 监听日志流(复用 SSE)
|
||||
const es = new EventSource(`/api/logs/${sessionId}`);
|
||||
const logBox = document.getElementById('log-box');
|
||||
let firstLine = true;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
btn.disabled = false;
|
||||
const result = msg.result;
|
||||
if (result && result.submit_ok) {
|
||||
btn.textContent = '✅ 提交完成';
|
||||
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 3000);
|
||||
} else {
|
||||
const errorMsg = result?.submit_error || result?.error || '未知错误';
|
||||
btn.textContent = '❌ 提交失败';
|
||||
logBox.innerHTML += `<div style="color: #e74c3c; font-weight: bold;">❌ 提交失败:${errorMsg}</div>`;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
console.warn('提交失败:', errorMsg);
|
||||
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
logBox.innerHTML += e.data;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 提交到财务系统';
|
||||
};
|
||||
} catch (e) {
|
||||
alert('提交失败: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 提交到财务系统';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 二维码 / 手机扫码上传 ----
|
||||
let qrGenerated = false;
|
||||
let syncTimer = null;
|
||||
|
||||
async function generateQr() {
|
||||
await ensureSession();
|
||||
const mobileUrl = window.location.origin + '/mobile/' + sessionId;
|
||||
const box = document.getElementById('qrcode');
|
||||
box.innerHTML = '';
|
||||
new QRCode(box, {
|
||||
text: mobileUrl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
colorDark: '#333',
|
||||
colorLight: '#fff',
|
||||
});
|
||||
qrGenerated = true;
|
||||
}
|
||||
|
||||
// ---- 实时同步:轮询服务器文件列表,检测手机端上传的新图片 ----
|
||||
async function startSync() {
|
||||
if (syncTimer) return;
|
||||
await syncFiles();
|
||||
syncTimer = setInterval(syncFiles, 3000);
|
||||
}
|
||||
|
||||
function stopSync() {
|
||||
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
|
||||
}
|
||||
|
||||
async function syncFiles() {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const r = await fetch(`/api/files/${sessionId}`);
|
||||
const d = await r.json();
|
||||
const serverNames = new Set(d.images || []);
|
||||
const localNames = new Set(imgFiles.map(f => f.name));
|
||||
|
||||
for (const name of serverNames) {
|
||||
if (!localNames.has(name)) {
|
||||
const resp = await fetch(`/api/download/${sessionId}/${encodeURIComponent(name)}`);
|
||||
const blob = await resp.blob();
|
||||
const file = new File([blob], name, { type: blob.type });
|
||||
file.__source = 'server'; // 标记为扫码上传
|
||||
imgFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// 只清理来自服务器但已不存在的文件,保留本地手动选择的文件
|
||||
for (const f of [...imgFiles]) {
|
||||
if (!serverNames.has(f.name) && f.__source !== 'local') {
|
||||
const idx = imgFiles.indexOf(f);
|
||||
if (idx > -1) imgFiles.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
renderFileList('img');
|
||||
if (imgFiles.length) {
|
||||
document.getElementById('img-zone').classList.add('active');
|
||||
} else {
|
||||
document.getElementById('img-zone').classList.remove('active');
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
generateQr();
|
||||
startSync();
|
||||
|
||||
// ---- 配置区 → 表格的双向同步绑定 ----
|
||||
Object.values(CONFIG_SYNC).forEach(inputId => {
|
||||
const el = document.getElementById(inputId);
|
||||
if (el) {
|
||||
el.addEventListener('input', () => {
|
||||
// 根据 inputId 反查对应的字段名
|
||||
const field = Object.entries(CONFIG_SYNC).find(([, id]) => id === inputId)?.[0];
|
||||
if (field) syncConfigToTable(field);
|
||||
});
|
||||
}
|
||||
});
|
||||
14
src/web/templates/README.md
Normal file
14
src/web/templates/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# src/web/templates — HTML 模板目录
|
||||
|
||||
存放 Flask 渲染的 HTML 模板文件。
|
||||
|
||||
## 模板清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `index.html` | PC 端主界面:包含文件上传区、配置表单、处理按钮、SSE 日志面板、可编辑发票表格、下载链接、财务提交按钮、移动端二维码 |
|
||||
| `mobile_upload.html` | 移动端上传页面:支持拍照/相册选择,上传至当前会话 |
|
||||
127
src/web/templates/index.html
Normal file
127
src/web/templates/index.html
Normal file
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>财务报销自动化</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/static/css/index.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header text-center mb-4">
|
||||
<h3>财务报销自动化</h3>
|
||||
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、LLM 识别并填报</p>
|
||||
</div>
|
||||
|
||||
<div class="container" style="max-width:960px">
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="section-title">📄 发票 PDF <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||||
<div class="upload-zone" id="pdf-zone" onclick="document.getElementById('pdf-input').click()">
|
||||
<div class="icon">📁</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传 PDF 文件</div>
|
||||
<div id="pdf-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="section-title">🖼️ 支付截图 <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||||
<div class="upload-zone" id="img-zone" onclick="document.getElementById('img-input').click()">
|
||||
<div class="icon">🖼️</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传图片文件</div>
|
||||
<div id="img-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
|
||||
</div>
|
||||
<div class="col-md-4 text-center">
|
||||
<div class="section-title">📱 手机扫码上传</div>
|
||||
<div id="qrcode" style="display:inline-block"></div>
|
||||
<p class="text-muted mt-2 mb-0" style="font-size:12px">扫码即可拍照上传</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="section-title">⚙️ 配置
|
||||
<span style="font-size:12px;font-weight:normal;cursor:pointer;color:#667eea;margin-left:8px" onclick="document.getElementById('config-upload').click()">
|
||||
📤 上传 config.json
|
||||
</span>
|
||||
<input type="file" id="config-upload" accept=".json" hidden onchange="handleConfigUpload(this)">
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">账号 (工号)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-username" placeholder="202xxxx">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">密码</label>
|
||||
<input type="password" class="form-control form-control-sm" id="cfg-password" placeholder="登录密码">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">默认姓名</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-name" placeholder="张三">
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">公务卡号</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-card" placeholder="628288...">
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">存放地点(出库单)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-storage" placeholder="新工科楼">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="text-center mb-4">
|
||||
<button class="btn btn-primary btn-process" id="btn-start" onclick="startProcess()">开始处理</button>
|
||||
<span id="status" class="ms-3"></span>
|
||||
</div>
|
||||
|
||||
<!-- 下载区 -->
|
||||
<div class="card mb-4" id="download-section" style="display:none">
|
||||
<div class="card-body py-3">
|
||||
<div class="section-title mb-2">📥 下载文件</div>
|
||||
<div id="download-links" class="d-flex flex-wrap gap-2"></div>
|
||||
<div id="doc-fill-warning" class="text-warning mt-2" style="font-size:13px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发票数据编辑区 -->
|
||||
<div class="card mb-4" id="edit-section" style="display:none">
|
||||
<div class="card-body">
|
||||
<div class="section-title d-flex justify-content-between align-items-center">
|
||||
<span>📝 付款记录(可编辑)</span>
|
||||
<div class="d-flex justify-content-end align-items-center">
|
||||
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="edit-note">直接点击单元格即可编辑,提交时自动保存当前修改。</p>
|
||||
<div class="table-wrapper">
|
||||
<table class="table table-sm table-bordered table-editable mb-0" id="invoice-table">
|
||||
<thead id="invoice-thead"></thead>
|
||||
<tbody id="invoice-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<div class="section-title">📋 处理日志</div>
|
||||
<div class="log-container mb-4" id="log-box"><div class="empty">等待开始...</div></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
<script src="/static/js/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
124
src/web/templates/mobile_upload.html
Normal file
124
src/web/templates/mobile_upload.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>支付截图上传</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f5f7fa; padding-bottom: 40px; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 20px 16px; text-align: center; }
|
||||
.upload-btn { width: 100%; padding: 18px; font-size: 16px; border-radius: 12px; margin-top: 16px; }
|
||||
.img-preview { width: 100px; height: 100px; object-fit: cover; border-radius: 8px; margin: 4px; }
|
||||
.file-card { background: #fff; border-radius: 8px; padding: 8px; margin-bottom: 8px; display: flex; align-items: center; gap: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.file-card img { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; }
|
||||
.file-card .info { flex: 1; min-width: 0; }
|
||||
.file-card .name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-card .remove { color: #c00; cursor: pointer; padding: 4px 8px; }
|
||||
.status-msg { text-align: center; padding: 12px; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% if error %}
|
||||
<div class="header"><h5>错误</h5><p>{{ error }}</p></div>
|
||||
{% else %}
|
||||
|
||||
<div class="header">
|
||||
<h5>📸 支付截图上传</h5>
|
||||
<p class="mb-0 opacity-75" style="font-size:13px">拍照或从相册选择图片</p>
|
||||
</div>
|
||||
|
||||
<div class="container" style="max-width:480px">
|
||||
|
||||
<!-- 上传按钮 -->
|
||||
<input type="file" id="file-input" accept="image/*" multiple hidden onchange="handleSelect(this)">
|
||||
|
||||
<button class="btn btn-primary upload-btn" onclick="document.getElementById('file-input').click()">
|
||||
📷 选择图片 / 拍照
|
||||
</button>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<div id="upload-status" class="status-msg mt-3"></div>
|
||||
|
||||
<!-- 已上传图片列表 -->
|
||||
<h6 class="mt-3 mb-2">已上传 ({{ server_count | default(0) }})</h6>
|
||||
<div id="file-list"></div>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
const sessionId = "{{ session_id }}";
|
||||
|
||||
// ---- 加载已有文件 ----
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const r = await fetch(`/api/files/${sessionId}`);
|
||||
const d = await r.json();
|
||||
renderList(d.images || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(images) {
|
||||
const box = document.getElementById('file-list');
|
||||
if (!images.length) {
|
||||
box.innerHTML = '<p class="text-muted text-center" style="font-size:13px">暂无图片</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = images.map(name => `
|
||||
<div class="file-card">
|
||||
<img src="/api/download/${sessionId}/${encodeURIComponent(name)}#t=image/png" alt="">
|
||||
<div class="info"><div class="name">${name}</div></div>
|
||||
<span class="remove" onclick="removeFile('${name}')">×</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ---- 上传 ----
|
||||
async function handleSelect(input) {
|
||||
const files = Array.from(input.files);
|
||||
if (!files.length) return;
|
||||
|
||||
const status = document.getElementById('upload-status');
|
||||
let done = 0;
|
||||
status.innerHTML = `<div class="text-primary">上传中... 0/${files.length}</div>`;
|
||||
|
||||
for (const f of files) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
||||
done++;
|
||||
status.innerHTML = `<div class="text-primary">上传中... ${done}/${files.length}</div>`;
|
||||
} catch (e) {
|
||||
status.innerHTML = `<div class="text-danger">${f.name} 上传失败</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (done === files.length) {
|
||||
status.innerHTML = `<div class="text-success">✅ ${done} 张图片上传成功</div>`;
|
||||
} else {
|
||||
status.innerHTML = `<div class="text-warning">${done}/${files.length} 张上传成功</div>`;
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
// ---- 删除(前端视觉移除,实际保留在服务器)----
|
||||
function removeFile(name) {
|
||||
// 移动端暂不支持删除,提示用户回到 PC 端管理
|
||||
if (confirm('文件已上传到服务器。如需删除请回到电脑端操作。')) {
|
||||
loadFiles();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 定时刷新列表(同步 PC 端新增的文件)----
|
||||
setInterval(loadFiles, 5000);
|
||||
loadFiles();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
25
tasks.py
Normal file
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])
|
||||
@@ -1,314 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>财务报销自动化</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--card: #fff;
|
||||
--text: #333;
|
||||
--muted: #888;
|
||||
--primary: #4f46e5;
|
||||
--primary-hover: #4338ca;
|
||||
--success: #16a34a;
|
||||
--danger: #dc2626;
|
||||
--warning: #d97706;
|
||||
--border: #e5e7eb;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
|
||||
.card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); padding: 24px; margin-bottom: 16px; }
|
||||
.card h2 { font-size: 18px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
||||
.card h2 .badge { font-size: 12px; padding: 2px 8px; border-radius: 12px; background: var(--primary); color: #fff; }
|
||||
.upload-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 40px 20px; text-align: center; cursor: pointer; transition: border-color .2s; }
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: var(--primary); background: #f8f8ff; }
|
||||
.upload-zone p { color: var(--muted); margin-top: 8px; }
|
||||
.file-list { margin-top: 12px; }
|
||||
.file-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #f9fafb; border-radius: 6px; margin-bottom: 4px; font-size: 14px; }
|
||||
.file-item .remove { margin-left: auto; cursor: pointer; color: var(--danger); font-size: 16px; }
|
||||
label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; }
|
||||
input, textarea { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; outline: none; transition: border-color .2s; }
|
||||
input:focus, textarea:focus { border-color: var(--primary); }
|
||||
textarea { font-family: "SF Mono", "Fira Code", monospace; font-size: 13px; height: 160px; resize: vertical; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: background .2s; }
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-success { background: var(--success); color: #fff; }
|
||||
.btn-success:hover { background: #15803d; }
|
||||
.btn-success:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
|
||||
.log-panel { background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius); padding: 16px; max-height: 380px; overflow-y: auto; font-family: "SF Mono", "Fira Code", monospace; font-size: 13px; }
|
||||
.log-panel .entry { margin-bottom: 2px; white-space: pre-wrap; word-break: break-all; }
|
||||
.log-panel .INFO { color: #6a9955; }
|
||||
.log-panel .WARNING { color: #d97706; }
|
||||
.log-panel .ERROR { color: #f87171; }
|
||||
.log-panel .DEBUG { color: #8b8b8b; }
|
||||
|
||||
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.status-badge.uploaded { background: #e5e7eb; color: #6b7280; }
|
||||
.status-badge.extracting { background: #dbeafe; color: #2563eb; }
|
||||
.status-badge.done { background: #dcfce7; color: #16a34a; }
|
||||
.status-badge.submitting { background: #fef3c7; color: #d97706; }
|
||||
.status-badge.submitted { background: #dcfce7; color: #16a34a; }
|
||||
.status-badge.error { background: #fee2e2; color: #dc2626; }
|
||||
|
||||
.result-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
|
||||
.result-table th, .result-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; }
|
||||
.result-table th { background: #f9fafb; font-weight: 500; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<div class="card">
|
||||
<h2>财务报销自动化</h2>
|
||||
<p style="color:var(--muted);font-size:14px;">上传 PDF 发票和支付截图,自动提取信息并填报报销系统</p>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div class="card">
|
||||
<h2>1. 上传文件</h2>
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<div style="font-size:36px;margin-bottom:4px;">📂</div>
|
||||
<p>拖拽 PDF 和图片到此处,或点击选择文件</p>
|
||||
<input type="file" id="fileInput" multiple accept=".pdf,.png,.jpg,.jpeg,.bmp,.webp" style="display:none">
|
||||
</div>
|
||||
<div class="file-list" id="fileList"></div>
|
||||
</div>
|
||||
|
||||
<!-- 配置 -->
|
||||
<div class="card">
|
||||
<h2>2. 配置</h2>
|
||||
<div class="form-row">
|
||||
<div><label>用户名</label><input id="cfgUser" placeholder="工号/学号"></div>
|
||||
<div><label>密码</label><input id="cfgPass" type="password" placeholder="登录密码"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div><label>默认姓名</label><input id="cfgName" placeholder="报销人姓名"></div>
|
||||
<div><label>默认公务卡号</label><input id="cfgCard" placeholder="公务卡号后四位或完整号"></div>
|
||||
</div>
|
||||
<div><label>默认人员编号</label><input id="cfgPid" placeholder="人员编号"></div>
|
||||
<details style="margin-top:8px;">
|
||||
<summary style="font-size:14px;color:var(--muted);cursor:pointer;">高级配置 (JSON)</summary>
|
||||
<textarea id="cfgJson" style="margin-top:8px;">{}</textarea>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- 进度 -->
|
||||
<div class="card">
|
||||
<h2>3. 处理 <span class="status-badge" id="statusBadge" style="display:none"></span></h2>
|
||||
<div class="log-panel" id="logPanel">
|
||||
<div class="entry" style="color:var(--muted)">等待上传文件...</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="btnStart" disabled>开始处理</button>
|
||||
<button class="btn btn-success hidden" id="btnSubmit">提交报销</button>
|
||||
<button class="btn hidden" id="btnDownload" style="background:#6b7280;color:#fff;">下载 CSV</button>
|
||||
</div>
|
||||
<div id="resultArea" class="hidden" style="margin-top:12px;"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
const $$ = s => document.querySelectorAll(s);
|
||||
|
||||
let sessionId = null;
|
||||
let files = [];
|
||||
let evtSource = null;
|
||||
|
||||
// ---- 文件选择 ----
|
||||
const uploadZone = $('#uploadZone');
|
||||
const fileInput = $('#fileInput');
|
||||
const fileList = $('#fileList');
|
||||
|
||||
uploadZone.addEventListener('click', () => fileInput.click());
|
||||
uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
|
||||
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
|
||||
uploadZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.remove('dragover');
|
||||
addFiles(Array.from(e.dataTransfer.files));
|
||||
});
|
||||
fileInput.addEventListener('change', () => addFiles(Array.from(fileInput.files)));
|
||||
|
||||
function addFiles(newFiles) {
|
||||
for (const f of newFiles) {
|
||||
if (!files.find(x => x.name === f.name && x.size === f.size)) {
|
||||
files.push(f);
|
||||
}
|
||||
}
|
||||
renderFiles();
|
||||
checkReady();
|
||||
}
|
||||
|
||||
function removeFile(idx) {
|
||||
files.splice(idx, 1);
|
||||
renderFiles();
|
||||
checkReady();
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
fileList.innerHTML = files.length === 0 ? '' : files.map((f, i) =>
|
||||
`<div class="file-item">
|
||||
<span>${['.pdf','.PDF'].some(e => f.name.endsWith(e)) ? '📄' : '🖼'}</span>
|
||||
<span>${f.name}</span>
|
||||
<span style="color:var(--muted);font-size:12px;">${(f.size/1024).toFixed(1)} KB</span>
|
||||
<span class="remove" onclick="removeFile(${i})">×</span>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ---- 配置 ----
|
||||
const cfgUser = $('#cfgUser');
|
||||
const cfgPass = $('#cfgPass');
|
||||
const cfgName = $('#cfgName');
|
||||
const cfgCard = $('#cfgCard');
|
||||
const cfgPid = $('#cfgPid');
|
||||
const cfgJson = $('#cfgJson');
|
||||
|
||||
[cfgUser, cfgPass, cfgName, cfgCard, cfgPid, cfgJson].forEach(el =>
|
||||
el.addEventListener('input', checkReady)
|
||||
);
|
||||
|
||||
function getConfig() {
|
||||
let base = {};
|
||||
try { base = JSON.parse(cfgJson.value || '{}'); } catch (e) {}
|
||||
if (cfgUser.value) base.username = cfgUser.value;
|
||||
if (cfgPass.value) base.password = cfgPass.value;
|
||||
if (cfgName.value) base.default_name = cfgName.value;
|
||||
if (cfgCard.value) base.default_card_no = cfgCard.value;
|
||||
if (cfgPid.value) base.default_person_id = cfgPid.value;
|
||||
return base;
|
||||
}
|
||||
|
||||
function checkReady() {
|
||||
const hasFiles = files.length > 0;
|
||||
const hasUser = !!(cfgUser.value && cfgPass.value);
|
||||
$('#btnStart').disabled = !(hasFiles && hasUser);
|
||||
}
|
||||
|
||||
// ---- 开始处理 ----
|
||||
const btnStart = $('#btnStart');
|
||||
const btnSubmit = $('#btnSubmit');
|
||||
const btnDownload = $('#btnDownload');
|
||||
const statusBadge = $('#statusBadge');
|
||||
const logPanel = $('#logPanel');
|
||||
const resultArea = $('#resultArea');
|
||||
|
||||
btnStart.addEventListener('click', async () => {
|
||||
btnStart.disabled = true;
|
||||
statusBadge.style.display = 'inline-block';
|
||||
statusBadge.textContent = '上传中...';
|
||||
statusBadge.className = 'status-badge extracting';
|
||||
|
||||
const formData = new FormData();
|
||||
for (const f of files) formData.append('files', f);
|
||||
|
||||
const config = getConfig();
|
||||
formData.append('config', JSON.stringify(config));
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/start', { method: 'POST', body: formData });
|
||||
const data = await resp.json();
|
||||
if (data.error) { alert(data.error); btnStart.disabled = false; return; }
|
||||
sessionId = data.session_id;
|
||||
startStream();
|
||||
btnSubmit.classList.remove('hidden');
|
||||
btnDownload.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
alert('请求失败: ' + e.message);
|
||||
btnStart.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function startStream() {
|
||||
logPanel.innerHTML = '';
|
||||
if (evtSource) evtSource.close();
|
||||
evtSource = new EventSource(`/api/stream/${sessionId}`);
|
||||
|
||||
evtSource.addEventListener('message', e => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'log') {
|
||||
const lvl = (data.msg.match(/\[(\w+)\]/) || [])[1] || '';
|
||||
logPanel.innerHTML += `<div class="entry ${lvl}">${escHtml(data.msg)}</div>`;
|
||||
logPanel.scrollTop = logPanel.scrollHeight;
|
||||
}
|
||||
if (data.type === 'heartbeat' || data.type === 'status') {
|
||||
statusBadge.textContent = data.status;
|
||||
statusBadge.className = `status-badge ${data.status}`;
|
||||
}
|
||||
if (data.type === 'complete') {
|
||||
statusBadge.textContent = data.status;
|
||||
statusBadge.className = `status-badge ${data.status}`;
|
||||
evtSource.close();
|
||||
|
||||
if (data.status === 'done') {
|
||||
btnSubmit.classList.remove('hidden');
|
||||
loadResults();
|
||||
}
|
||||
btnStart.disabled = data.status !== 'done';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
try {
|
||||
const resp = await fetch(`/api/status/${sessionId}`);
|
||||
const data = await resp.json();
|
||||
if (!data.preview) return;
|
||||
|
||||
let html = '<table class="result-table"><thead><tr>';
|
||||
const keys = Object.keys(data.preview[0]);
|
||||
for (const k of keys) html += `<th>${k}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
for (const row of data.preview) {
|
||||
html += '<tr>';
|
||||
for (const k of keys) html += `<td>${row[k] || ''}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
html += `<p style="margin-top:8px;color:var(--muted);">共 ${data.total} 条记录</p>`;
|
||||
resultArea.innerHTML = html;
|
||||
resultArea.classList.remove('hidden');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ---- 浏览器填报 ----
|
||||
btnSubmit.addEventListener('click', async () => {
|
||||
if (!sessionId) return;
|
||||
btnSubmit.disabled = true;
|
||||
statusBadge.textContent = 'submitting';
|
||||
statusBadge.className = 'status-badge submitting';
|
||||
|
||||
try {
|
||||
await fetch(`/api/submit/${sessionId}`, { method: 'POST' });
|
||||
startStream();
|
||||
} catch (e) {
|
||||
alert('提交失败: ' + e.message);
|
||||
btnSubmit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 下载 CSV ----
|
||||
btnDownload.addEventListener('click', () => {
|
||||
if (sessionId) window.open(`/api/download/${sessionId}/csv`, '_blank');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
23
tests/README.md
Normal file
23
tests/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# tests — 单元测试目录
|
||||
|
||||
存放项目单元测试,使用 pytest 运行。
|
||||
|
||||
## 测试清单
|
||||
|
||||
| 文件 | 覆盖范围 |
|
||||
|------|---------|
|
||||
| `test_config.py` | 配置加载模块 |
|
||||
| `test_extractor.py` | 发票提取编排:空目录、提取失败、正常流程、分类结果、申请单分离、支付匹配 |
|
||||
| `test_invoice.py` | 发票分类、CSV 读写 |
|
||||
| `test_llm_extractor.py` | LLM 信息提取 |
|
||||
| `test_matcher.py` | 发票与支付记录匹配 |
|
||||
|
||||
## 运行方式
|
||||
|
||||
```bash
|
||||
uv run pytest tests/
|
||||
```
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
19
tests/test_config.py
Normal file
19
tests/test_config.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""配置模块单元测试"""
|
||||
|
||||
from src.config import load_config
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""配置加载"""
|
||||
|
||||
def test_returns_dict(self) -> None:
|
||||
config = load_config()
|
||||
assert isinstance(config, dict)
|
||||
|
||||
def test_has_username(self) -> None:
|
||||
config = load_config()
|
||||
assert "username" in config
|
||||
|
||||
def test_has_password(self) -> None:
|
||||
config = load_config()
|
||||
assert "password" in config
|
||||
340
tests/test_extractor.py
Normal file
340
tests/test_extractor.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""发票提取编排模块单元测试
|
||||
|
||||
覆盖范围:
|
||||
- extract_invoices:空目录、提取失败、正常流程、分类结果、申请单分离
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.doc.extractor import extract_invoices
|
||||
|
||||
# 字段键名(与源码中的字符串字面量保持一致)
|
||||
K_INVOICE_TYPE = "invoice_type"
|
||||
K_INVOICE_NUMBER = "invoice_number"
|
||||
K_TOTAL_AMOUNT = "total_amount"
|
||||
K_ITEM_NAME = "item_name"
|
||||
K_PERSON_NAME = "person_name"
|
||||
K_CARD_DATE = "card_date"
|
||||
K_CARD_NO = "card_no"
|
||||
K_CARD_AMOUNT = "card_amount"
|
||||
K_MATCHED_INVOICES = "_matched_invoices"
|
||||
K_RELATIVE_INVOICE_COUNT = "relative_invoice_count"
|
||||
K_INVOICE_DETAIL = "invoice_detail"
|
||||
K_REMARK = "remark"
|
||||
|
||||
# 发票类型
|
||||
INVOICE_TYPE_TRAIN = "train"
|
||||
INVOICE_TYPE_HOTEL = "hotel"
|
||||
INVOICE_TYPE_GENERAL = "general"
|
||||
INVOICE_TYPE_PAYMENT = "payment"
|
||||
DOCUMENT_TYPE_APPLICATION = "application"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fixture helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_invoice(number: str, amount: float, inv_type: str = INVOICE_TYPE_GENERAL) -> dict[str, Any]:
|
||||
inv: dict[str, Any] = {
|
||||
K_INVOICE_NUMBER: number,
|
||||
K_INVOICE_TYPE: inv_type,
|
||||
K_TOTAL_AMOUNT: str(amount),
|
||||
}
|
||||
if inv_type == INVOICE_TYPE_TRAIN:
|
||||
inv[K_PERSON_NAME] = f"person{number}"
|
||||
elif inv_type == INVOICE_TYPE_GENERAL:
|
||||
inv[K_ITEM_NAME] = f"item{number}"
|
||||
return inv
|
||||
|
||||
|
||||
def _make_application() -> dict[str, Any]:
|
||||
return {
|
||||
K_INVOICE_TYPE: DOCUMENT_TYPE_APPLICATION,
|
||||
"applicant": "张三",
|
||||
}
|
||||
|
||||
|
||||
def _make_card(amount: float) -> dict[str, Any]:
|
||||
return {
|
||||
K_INVOICE_TYPE: INVOICE_TYPE_PAYMENT,
|
||||
K_CARD_DATE: "2026-01-01",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: f"{amount:.2f}",
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# extract_invoices
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractInvoices:
|
||||
"""extract_invoices 编排函数"""
|
||||
|
||||
def test_empty_directory(self, tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setattr("src.doc.extractor._find_all_files", lambda d: [])
|
||||
|
||||
records, apps, groups = extract_invoices(str(tmp_path))
|
||||
assert records == []
|
||||
assert apps == []
|
||||
assert groups == {"travel": [], "general": [], "application": []}
|
||||
|
||||
def test_extraction_fails(self, tmp_path: Path, monkeypatch):
|
||||
pdf = tmp_path / "broken.pdf"
|
||||
pdf.touch()
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor._find_all_files", lambda d: [pdf])
|
||||
monkeypatch.setattr("src.doc.extractor._extract_document", lambda p, c: None)
|
||||
|
||||
records, apps, groups = extract_invoices(str(tmp_path))
|
||||
assert records == []
|
||||
assert apps == []
|
||||
assert groups == {"travel": [], "general": [], "application": []}
|
||||
|
||||
def test_normal_flow_general_invoices(self, tmp_path: Path, monkeypatch):
|
||||
pdf1 = tmp_path / "inv1.pdf"
|
||||
pdf2 = tmp_path / "inv2.pdf"
|
||||
pdf1.touch()
|
||||
pdf2.touch()
|
||||
|
||||
inv1 = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
|
||||
inv2 = _make_invoice("INV002", 200.0, INVOICE_TYPE_GENERAL)
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor._find_all_files", lambda d: [pdf1, pdf2])
|
||||
|
||||
call_index = [0]
|
||||
|
||||
def fake_extract(path, cache_dir):
|
||||
idx = call_index[0]
|
||||
call_index[0] += 1
|
||||
return inv1 if idx == 0 else inv2
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
|
||||
|
||||
def fake_match(invoices, cards):
|
||||
return [
|
||||
{
|
||||
K_CARD_DATE: "2026-01-01",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: "500.00",
|
||||
K_RELATIVE_INVOICE_COUNT: "2",
|
||||
K_INVOICE_DETAIL: "",
|
||||
K_REMARK: "",
|
||||
K_MATCHED_INVOICES: [inv1, inv2],
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
|
||||
|
||||
records, apps, groups = extract_invoices(str(tmp_path))
|
||||
|
||||
assert len(records) == 1
|
||||
assert records[0][K_RELATIVE_INVOICE_COUNT] == "2"
|
||||
assert len(groups["travel"]) == 0
|
||||
assert len(groups["general"]) == 2
|
||||
|
||||
def test_normal_flow_mixed_invoices(self, tmp_path: Path, monkeypatch):
|
||||
pdf1 = tmp_path / "train.pdf"
|
||||
pdf2 = tmp_path / "hotel.pdf"
|
||||
pdf3 = tmp_path / "general.pdf"
|
||||
pdf1.touch()
|
||||
pdf2.touch()
|
||||
pdf3.touch()
|
||||
|
||||
inv_train = _make_invoice("TRAIN001", 500.0, INVOICE_TYPE_TRAIN)
|
||||
inv_hotel = _make_invoice("HOTEL001", 800.0, INVOICE_TYPE_HOTEL)
|
||||
inv_general = _make_invoice("GEN001", 150.0, INVOICE_TYPE_GENERAL)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.doc.extractor._find_all_files",
|
||||
lambda d: [pdf1, pdf2, pdf3],
|
||||
)
|
||||
|
||||
invoices_list = [inv_train, inv_hotel, inv_general]
|
||||
call_index = [0]
|
||||
|
||||
def fake_extract(path, cache_dir):
|
||||
idx = call_index[0]
|
||||
call_index[0] += 1
|
||||
return invoices_list[idx]
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
|
||||
|
||||
def fake_match(invoices, cards):
|
||||
return [
|
||||
{
|
||||
K_CARD_DATE: "2026-01-01",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: "500.00",
|
||||
K_RELATIVE_INVOICE_COUNT: "1",
|
||||
K_INVOICE_DETAIL: "",
|
||||
K_REMARK: "",
|
||||
K_MATCHED_INVOICES: [inv_train],
|
||||
},
|
||||
{
|
||||
K_CARD_DATE: "2026-01-02",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: "800.00",
|
||||
K_RELATIVE_INVOICE_COUNT: "1",
|
||||
K_INVOICE_DETAIL: "",
|
||||
K_REMARK: "",
|
||||
K_MATCHED_INVOICES: [inv_hotel],
|
||||
},
|
||||
{
|
||||
K_CARD_DATE: "",
|
||||
K_CARD_NO: "",
|
||||
K_CARD_AMOUNT: "",
|
||||
K_RELATIVE_INVOICE_COUNT: "1",
|
||||
K_INVOICE_DETAIL: "",
|
||||
K_REMARK: "unmatched",
|
||||
K_MATCHED_INVOICES: [inv_general],
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
|
||||
|
||||
records, apps, groups = extract_invoices(str(tmp_path))
|
||||
|
||||
assert len(records) == 3
|
||||
assert len(groups["travel"]) == 2
|
||||
assert len(groups["general"]) == 1
|
||||
|
||||
travel_numbers = {inv[K_INVOICE_NUMBER] for inv in groups["travel"]}
|
||||
assert "TRAIN001" in travel_numbers
|
||||
assert "HOTEL001" in travel_numbers
|
||||
assert groups["general"][0][K_INVOICE_NUMBER] == "GEN001"
|
||||
|
||||
def test_application_documents_separated(self, tmp_path: Path, monkeypatch):
|
||||
pdf1 = tmp_path / "invoice.pdf"
|
||||
pdf2 = tmp_path / "application.pdf"
|
||||
pdf1.touch()
|
||||
pdf2.touch()
|
||||
|
||||
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
|
||||
app = _make_application()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.doc.extractor._find_all_files",
|
||||
lambda d: [pdf1, pdf2],
|
||||
)
|
||||
|
||||
results = [inv, app]
|
||||
call_index = [0]
|
||||
|
||||
def fake_extract(path, cache_dir):
|
||||
idx = call_index[0]
|
||||
call_index[0] += 1
|
||||
return results[idx]
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
|
||||
|
||||
def fake_match(invoices, cards):
|
||||
return [
|
||||
{
|
||||
K_CARD_DATE: "2026-01-01",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: "300.00",
|
||||
K_RELATIVE_INVOICE_COUNT: "1",
|
||||
K_INVOICE_DETAIL: "",
|
||||
K_REMARK: "",
|
||||
K_MATCHED_INVOICES: [inv],
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
|
||||
|
||||
records, apps, groups = extract_invoices(str(tmp_path))
|
||||
|
||||
assert len(apps) == 1
|
||||
assert apps[0]["applicant"] == "张三"
|
||||
assert len(groups["application"]) == 1
|
||||
|
||||
def test_payment_records_included(self, tmp_path: Path, monkeypatch):
|
||||
pdf1 = tmp_path / "invoice.pdf"
|
||||
pdf2 = tmp_path / "card.png"
|
||||
pdf1.touch()
|
||||
pdf2.touch()
|
||||
|
||||
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
|
||||
card = _make_card(300.0)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.doc.extractor._find_all_files",
|
||||
lambda d: [pdf1, pdf2],
|
||||
)
|
||||
|
||||
results = [inv, card]
|
||||
call_index = [0]
|
||||
|
||||
def fake_extract(path, cache_dir):
|
||||
idx = call_index[0]
|
||||
call_index[0] += 1
|
||||
return results[idx]
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
|
||||
|
||||
def fake_match(invoices, cards):
|
||||
return [
|
||||
{
|
||||
K_CARD_DATE: "2026-01-01",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: "300.00",
|
||||
K_RELATIVE_INVOICE_COUNT: "1",
|
||||
K_INVOICE_DETAIL: "",
|
||||
K_REMARK: "",
|
||||
K_MATCHED_INVOICES: [inv],
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
|
||||
|
||||
records, apps, groups = extract_invoices(str(tmp_path))
|
||||
|
||||
assert len(records) == 1
|
||||
assert len(groups["general"]) == 1
|
||||
|
||||
def test_partial_extraction_failure(self, tmp_path: Path, monkeypatch):
|
||||
pdf1 = tmp_path / "good.pdf"
|
||||
pdf2 = tmp_path / "bad.pdf"
|
||||
pdf1.touch()
|
||||
pdf2.touch()
|
||||
|
||||
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.doc.extractor._find_all_files",
|
||||
lambda d: [pdf1, pdf2],
|
||||
)
|
||||
|
||||
results = [inv, None]
|
||||
call_index = [0]
|
||||
|
||||
def fake_extract(path, cache_dir):
|
||||
idx = call_index[0]
|
||||
call_index[0] += 1
|
||||
return results[idx]
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
|
||||
|
||||
def fake_match(invoices, cards):
|
||||
return [
|
||||
{
|
||||
K_CARD_DATE: "2026-01-01",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: "300.00",
|
||||
K_RELATIVE_INVOICE_COUNT: "1",
|
||||
K_INVOICE_DETAIL: "",
|
||||
K_REMARK: "",
|
||||
K_MATCHED_INVOICES: [inv],
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
|
||||
|
||||
records, apps, groups = extract_invoices(str(tmp_path))
|
||||
|
||||
assert len(records) == 1
|
||||
assert len(groups["general"]) == 1
|
||||
111
tests/test_invoice.py
Normal file
111
tests/test_invoice.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""发票模块单元测试
|
||||
|
||||
覆盖发票分类、CSV 列定义、常量校验。
|
||||
"""
|
||||
|
||||
from src.doc.invoice import (
|
||||
INVOICE_LEVEL_COLUMNS,
|
||||
PAYMENT_RECORD_COLUMNS,
|
||||
)
|
||||
from src.doc.invoice import (
|
||||
_classify_invoice_batch as classify_invoice_batch,
|
||||
)
|
||||
|
||||
# 字段键名与发票类型(与源码中的字符串字面量保持一致)
|
||||
K_INVOICE_TYPE = "invoice_type"
|
||||
K_INVOICE_NUMBER = "invoice_number"
|
||||
K_TOTAL_AMOUNT = "total_amount"
|
||||
K_ITEM_NAME = "item_name"
|
||||
K_PERSON_NAME = "person_name"
|
||||
K_CARD_DATE = "card_date"
|
||||
K_CARD_NO = "card_no"
|
||||
K_CARD_AMOUNT = "card_amount"
|
||||
K_MATCHED_INVOICES = "_matched_invoices"
|
||||
K_RELATIVE_INVOICE_COUNT = "relative_invoice_count"
|
||||
K_INVOICE_DETAIL = "invoice_detail"
|
||||
K_REMARK = "remark"
|
||||
K_SOURCE_FILE = "source_file"
|
||||
|
||||
INVOICE_TYPE_TRAIN = "train"
|
||||
INVOICE_TYPE_HOTEL = "hotel"
|
||||
INVOICE_TYPE_GENERAL = "general"
|
||||
DOCUMENT_TYPE_APPLICATION = "application"
|
||||
INVOICE_TYPE_TRAVEL = [INVOICE_TYPE_TRAIN, INVOICE_TYPE_HOTEL]
|
||||
|
||||
|
||||
def is_travel_invoice(invoice_type: str) -> bool:
|
||||
return invoice_type in INVOICE_TYPE_TRAVEL
|
||||
|
||||
|
||||
class TestInvoiceConstants:
|
||||
"""发票常量校验"""
|
||||
|
||||
def test_travel_types_contain_train(self) -> None:
|
||||
assert INVOICE_TYPE_TRAIN in INVOICE_TYPE_TRAVEL
|
||||
|
||||
def test_travel_types_contain_hotel(self) -> None:
|
||||
assert INVOICE_TYPE_HOTEL in INVOICE_TYPE_TRAVEL
|
||||
|
||||
def test_general_not_in_travel(self) -> None:
|
||||
assert INVOICE_TYPE_GENERAL not in INVOICE_TYPE_TRAVEL
|
||||
|
||||
def test_invoice_columns_not_empty(self) -> None:
|
||||
assert len(INVOICE_LEVEL_COLUMNS) > 0
|
||||
|
||||
def test_payment_columns_not_empty(self) -> None:
|
||||
assert len(PAYMENT_RECORD_COLUMNS) > 0
|
||||
|
||||
|
||||
class TestIsTravelInvoice:
|
||||
"""差旅发票判断"""
|
||||
|
||||
def test_train_is_travel(self) -> None:
|
||||
assert is_travel_invoice(INVOICE_TYPE_TRAIN) is True
|
||||
|
||||
def test_hotel_is_travel(self) -> None:
|
||||
assert is_travel_invoice(INVOICE_TYPE_HOTEL) is True
|
||||
|
||||
def test_general_not_travel(self) -> None:
|
||||
assert is_travel_invoice(INVOICE_TYPE_GENERAL) is False
|
||||
|
||||
def test_unknown_not_travel(self) -> None:
|
||||
assert is_travel_invoice("unknown") is False
|
||||
|
||||
|
||||
class TestClassifyInvoiceBatch:
|
||||
"""发票分类"""
|
||||
|
||||
def test_empty_list(self) -> None:
|
||||
result = classify_invoice_batch([])
|
||||
assert result == {"travel": [], "general": [], "application": []}
|
||||
|
||||
def test_all_travel(self) -> None:
|
||||
invoices = [
|
||||
{K_INVOICE_TYPE: INVOICE_TYPE_TRAIN, K_INVOICE_NUMBER: "001"},
|
||||
{K_INVOICE_TYPE: INVOICE_TYPE_HOTEL, K_INVOICE_NUMBER: "002"},
|
||||
]
|
||||
result = classify_invoice_batch(invoices)
|
||||
assert len(result["travel"]) == 2
|
||||
assert len(result["general"]) == 0
|
||||
|
||||
def test_all_general(self) -> None:
|
||||
invoices = [
|
||||
{K_INVOICE_TYPE: INVOICE_TYPE_GENERAL, K_INVOICE_NUMBER: "001"},
|
||||
]
|
||||
result = classify_invoice_batch(invoices)
|
||||
assert len(result["travel"]) == 0
|
||||
assert len(result["general"]) == 1
|
||||
|
||||
def test_mixed(self) -> None:
|
||||
invoices = [
|
||||
{K_INVOICE_TYPE: INVOICE_TYPE_TRAIN, K_INVOICE_NUMBER: "001"},
|
||||
{K_INVOICE_TYPE: INVOICE_TYPE_GENERAL, K_INVOICE_NUMBER: "002"},
|
||||
]
|
||||
result = classify_invoice_batch(invoices)
|
||||
assert len(result["travel"]) == 1
|
||||
assert len(result["general"]) == 1
|
||||
|
||||
def test_missing_type_defaults_to_general(self) -> None:
|
||||
invoices = [{K_INVOICE_NUMBER: "001"}]
|
||||
result = classify_invoice_batch(invoices)
|
||||
assert len(result["general"]) == 1
|
||||
202
tests/test_llm_extractor.py
Normal file
202
tests/test_llm_extractor.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""LLM 信息提取模块单元测试
|
||||
|
||||
覆盖范围:
|
||||
- _parse_json_response:纯 JSON、Markdown 包裹、带前缀、解析失败
|
||||
- _image_to_base64:图片转 base64
|
||||
- extract_document:成功提取、LLM 失败
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.doc.llm_extractor import (
|
||||
_image_to_base64,
|
||||
_parse_json_response,
|
||||
extract_document,
|
||||
)
|
||||
|
||||
# 字段键名(与源码中的字符串字面量保持一致)
|
||||
K_INVOICE_NUMBER = "invoice_number"
|
||||
K_TOTAL_AMOUNT = "total_amount"
|
||||
K_CARD_DATE = "card_date"
|
||||
K_CARD_NO = "card_no"
|
||||
K_CARD_AMOUNT = "card_amount"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _parse_json_response
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseJsonResponse:
|
||||
"""JSON 响应解析"""
|
||||
|
||||
def test_pure_json(self):
|
||||
raw = json.dumps({K_INVOICE_NUMBER: "123456", K_TOTAL_AMOUNT: "100.00"})
|
||||
result = _parse_json_response(raw)
|
||||
assert result[K_INVOICE_NUMBER] == "123456"
|
||||
assert result[K_TOTAL_AMOUNT] == "100.00"
|
||||
|
||||
def test_markdown_json_block(self):
|
||||
raw = f'```json\n{{"{K_INVOICE_NUMBER}": "789"}}\n```'
|
||||
result = _parse_json_response(raw)
|
||||
assert result[K_INVOICE_NUMBER] == "789"
|
||||
|
||||
def test_markdown_block_without_lang(self):
|
||||
raw = '```\n{"key": "value"}\n```'
|
||||
result = _parse_json_response(raw)
|
||||
assert result["key"] == "value"
|
||||
|
||||
def test_json_prefix(self):
|
||||
raw = f'json\n{{"{K_INVOICE_NUMBER}": "001"}}'
|
||||
result = _parse_json_response(raw)
|
||||
assert result[K_INVOICE_NUMBER] == "001"
|
||||
|
||||
def test_json_prefix_with_whitespace(self):
|
||||
raw = ' json \n{"a": 1}'
|
||||
result = _parse_json_response(raw)
|
||||
assert result["a"] == 1
|
||||
|
||||
def test_nested_json(self):
|
||||
raw = json.dumps({"outer": {"inner": [1, 2, 3]}})
|
||||
result = _parse_json_response(raw)
|
||||
assert result["outer"]["inner"] == [1, 2, 3]
|
||||
|
||||
def test_whitespace_around_json(self):
|
||||
raw = ' \n {"x": 42} \n '
|
||||
result = _parse_json_response(raw)
|
||||
assert result["x"] == 42
|
||||
|
||||
def test_invalid_json_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_parse_json_response("not json at all")
|
||||
|
||||
def test_empty_string_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_parse_json_response("")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _image_to_base64
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestImageToBase64:
|
||||
"""图片转 base64"""
|
||||
|
||||
def test_png_to_base64(self, tmp_path: Path):
|
||||
content = b"\x89PNG\r\n\x1a\nfake_png_data"
|
||||
img_path = tmp_path / "test.png"
|
||||
img_path.write_bytes(content)
|
||||
|
||||
result = _image_to_base64(img_path)
|
||||
assert isinstance(result, str)
|
||||
assert base64.b64decode(result) == content
|
||||
|
||||
def test_jpg_to_base64(self, tmp_path: Path):
|
||||
content = b"\xff\xd8\xff\xe0fake_jpg_data"
|
||||
img_path = tmp_path / "test.jpg"
|
||||
img_path.write_bytes(content)
|
||||
|
||||
result = _image_to_base64(img_path)
|
||||
assert base64.b64decode(result) == content
|
||||
|
||||
def test_file_not_found_raises(self, tmp_path: Path):
|
||||
img_path = tmp_path / "nonexistent.png"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
_image_to_base64(img_path)
|
||||
|
||||
def test_returns_utf8_string(self, tmp_path: Path):
|
||||
content = b"test_image_content"
|
||||
img_path = tmp_path / "test.png"
|
||||
img_path.write_bytes(content)
|
||||
|
||||
result = _image_to_base64(img_path)
|
||||
assert type(result) is str
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# extract_document (mock LLM)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractDocument:
|
||||
"""图片提取:通过 mock _llm_query_multimodal 避免真实 LLM 调用"""
|
||||
|
||||
def _mock_multimodal(self, monkeypatch, response_text: str):
|
||||
def fake_query(system_prompt, text, image_b64, max_tokens=4096):
|
||||
return response_text
|
||||
|
||||
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", fake_query)
|
||||
|
||||
def test_success(self, tmp_path: Path, monkeypatch):
|
||||
img_path = tmp_path / "card.png"
|
||||
img_path.write_bytes(b"fake_image")
|
||||
|
||||
mock_result = {
|
||||
K_CARD_DATE: "2026-01-10",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: "500.00",
|
||||
}
|
||||
self._mock_multimodal(monkeypatch, json.dumps(mock_result))
|
||||
|
||||
result = extract_document(img_path)
|
||||
assert result[K_CARD_DATE] == "2026-01-10"
|
||||
assert result[K_CARD_NO] == "6228480000000000"
|
||||
assert result[K_CARD_AMOUNT] == "500.00"
|
||||
|
||||
def test_llm_failure_propagates(self, tmp_path: Path, monkeypatch):
|
||||
img_path = tmp_path / "card.png"
|
||||
img_path.write_bytes(b"fake_image")
|
||||
|
||||
def fake_query(system_prompt, text, image_b64, max_tokens=4096):
|
||||
raise RuntimeError("模型不可用")
|
||||
|
||||
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", fake_query)
|
||||
with pytest.raises(RuntimeError, match="模型不可用"):
|
||||
extract_document(img_path)
|
||||
|
||||
def test_file_not_found_propagates(self, tmp_path: Path, monkeypatch):
|
||||
img_path = tmp_path / "missing.png"
|
||||
self._mock_multimodal(monkeypatch, "{}")
|
||||
with pytest.raises(FileNotFoundError):
|
||||
extract_document(img_path)
|
||||
|
||||
def test_markdown_wrapped_json(self, tmp_path: Path, monkeypatch):
|
||||
img_path = tmp_path / "card.png"
|
||||
img_path.write_bytes(b"fake_image")
|
||||
|
||||
mock_result = {
|
||||
K_CARD_DATE: "2026-02-01",
|
||||
K_CARD_NO: "6228481111111111",
|
||||
K_CARD_AMOUNT: "300.00",
|
||||
}
|
||||
wrapped = f"```json\n{json.dumps(mock_result)}\n```"
|
||||
self._mock_multimodal(monkeypatch, wrapped)
|
||||
|
||||
result = extract_document(img_path)
|
||||
assert result[K_CARD_DATE] == "2026-02-01"
|
||||
|
||||
def test_image_encoded_as_base64(self, tmp_path: Path, monkeypatch):
|
||||
img_path = tmp_path / "card.png"
|
||||
expected_content = b"test_image_data"
|
||||
img_path.write_bytes(expected_content)
|
||||
|
||||
received_b64 = None
|
||||
|
||||
def capture_b64(system_prompt, text, image_b64s, max_tokens=4096):
|
||||
nonlocal received_b64
|
||||
received_b64 = image_b64s
|
||||
return json.dumps({K_CARD_DATE: "2026-01-01", K_CARD_NO: "0000", K_CARD_AMOUNT: "100"})
|
||||
|
||||
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", capture_b64)
|
||||
extract_document(img_path)
|
||||
|
||||
assert received_b64 is not None
|
||||
assert isinstance(received_b64, list)
|
||||
assert len(received_b64) >= 1
|
||||
assert base64.b64decode(received_b64[0]) == expected_content
|
||||
564
tests/test_matcher.py
Normal file
564
tests/test_matcher.py
Normal file
@@ -0,0 +1,564 @@
|
||||
"""发票与支付记录匹配模块单元测试
|
||||
|
||||
覆盖范围:
|
||||
- 辅助函数:_safe_float, _relative_tolerance, _build_invoice_summary
|
||||
- 一对一匹配:精确匹配、容差内匹配、容差外不匹配
|
||||
- 一对多匹配:精确匹配阶段、贪心匹配阶段、回滚逻辑
|
||||
- 记录构建:_build_payment_records, _invoices_to_records
|
||||
- 端到端:match_invoices_to_cards(直接传入分类好的支付记录)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.doc.matcher import (
|
||||
_build_invoice_summary,
|
||||
_build_payment_records,
|
||||
_invoices_to_records,
|
||||
_match,
|
||||
_match_one_to_many,
|
||||
_match_one_to_one,
|
||||
_relative_tolerance,
|
||||
_safe_float,
|
||||
match_invoices_to_cards,
|
||||
)
|
||||
|
||||
# 字段键名(与源码中的字符串字面量保持一致)
|
||||
K_INVOICE_TYPE = "invoice_type"
|
||||
K_INVOICE_NUMBER = "invoice_number"
|
||||
K_TOTAL_AMOUNT = "total_amount"
|
||||
K_ITEM_NAME = "item_name"
|
||||
K_PERSON_NAME = "person_name"
|
||||
K_CARD_DATE = "card_date"
|
||||
K_CARD_NO = "card_no"
|
||||
K_CARD_AMOUNT = "card_amount"
|
||||
K_MATCHED_INVOICES = "_matched_invoices"
|
||||
K_RELATIVE_INVOICE_COUNT = "relative_invoice_count"
|
||||
K_INVOICE_DETAIL = "invoice_detail"
|
||||
K_REMARK = "remark"
|
||||
K_SOURCE_FILE = "source_file"
|
||||
|
||||
INVOICE_TYPE_TRAIN = "train"
|
||||
INVOICE_TYPE_HOTEL = "hotel"
|
||||
INVOICE_TYPE_GENERAL = "general"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fixture helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_invoice(number: str, amount: float, inv_type: str = INVOICE_TYPE_GENERAL) -> dict[str, Any]:
|
||||
inv: dict[str, Any] = {
|
||||
K_INVOICE_NUMBER: number,
|
||||
K_INVOICE_TYPE: inv_type,
|
||||
K_TOTAL_AMOUNT: str(amount),
|
||||
}
|
||||
if inv_type == INVOICE_TYPE_TRAIN:
|
||||
inv[K_PERSON_NAME] = f"person{number}"
|
||||
elif inv_type == INVOICE_TYPE_GENERAL:
|
||||
inv[K_ITEM_NAME] = f"item{number}"
|
||||
return inv
|
||||
|
||||
|
||||
def _make_card(date: str, amount: float, card_no: str = "6228480000000000") -> dict[str, Any]:
|
||||
return {
|
||||
K_CARD_DATE: date,
|
||||
K_CARD_NO: card_no,
|
||||
K_CARD_AMOUNT: str(amount),
|
||||
K_SOURCE_FILE: "card.png",
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 辅助函数
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSafeFloat:
|
||||
"""安全浮点转换"""
|
||||
|
||||
def test_normal_string(self):
|
||||
assert _safe_float("123.45") == 123.45
|
||||
|
||||
def test_with_comma(self):
|
||||
assert _safe_float("1,234.56") == 1234.56
|
||||
|
||||
def test_none_returns_default(self):
|
||||
assert _safe_float(None) == 0.0
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
assert _safe_float("") == 0.0
|
||||
|
||||
def test_whitespace_returns_default(self):
|
||||
assert _safe_float(" ") == 0.0
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
assert _safe_float("abc") == 0.0
|
||||
|
||||
def test_custom_default(self):
|
||||
assert _safe_float(None, default=-1.0) == -1.0
|
||||
|
||||
def test_int_input(self):
|
||||
assert _safe_float(42) == 42.0
|
||||
|
||||
|
||||
class TestRelativeTolerance:
|
||||
"""相对容差计算"""
|
||||
|
||||
def test_default_rate(self):
|
||||
assert _relative_tolerance(1000) == 30.0
|
||||
|
||||
def test_custom_rate(self):
|
||||
assert _relative_tolerance(1000, 0.03) == 30.0
|
||||
|
||||
def test_negative_base(self):
|
||||
assert _relative_tolerance(-200, 0.03) == 6.0
|
||||
|
||||
def test_zero_base(self):
|
||||
assert _relative_tolerance(0, 0.03) == 0.0
|
||||
|
||||
|
||||
class TestBuildInvoiceSummary:
|
||||
"""发票汇总字符串"""
|
||||
|
||||
def test_single_invoice(self):
|
||||
invoices = [_make_invoice("INV001", 100.0)]
|
||||
result = _build_invoice_summary(invoices)
|
||||
assert "INV001" in result
|
||||
assert "100.0" in result
|
||||
|
||||
def test_multiple_invoices(self):
|
||||
invoices = [
|
||||
_make_invoice("INV001", 100.0),
|
||||
_make_invoice("INV002", 200.0),
|
||||
]
|
||||
result = _build_invoice_summary(invoices)
|
||||
assert " | " in result
|
||||
assert "INV001" in result
|
||||
assert "INV002" in result
|
||||
|
||||
def test_train_uses_person_name(self):
|
||||
invoices = [_make_invoice("TRAIN001", 500.0, INVOICE_TYPE_TRAIN)]
|
||||
result = _build_invoice_summary(invoices)
|
||||
assert "personTRAIN001" in result
|
||||
assert INVOICE_TYPE_TRAIN in result
|
||||
|
||||
def test_hotel_uses_fixed_label(self):
|
||||
invoices = [_make_invoice("HOTEL001", 800.0, INVOICE_TYPE_HOTEL)]
|
||||
result = _build_invoice_summary(invoices)
|
||||
assert "hotel[hotel]" in result
|
||||
|
||||
def test_general_uses_project_name(self):
|
||||
invoices = [_make_invoice("INV001", 100.0, INVOICE_TYPE_GENERAL)]
|
||||
result = _build_invoice_summary(invoices)
|
||||
assert "itemINV001" in result
|
||||
|
||||
def test_missing_fields_falls_back_to_number(self):
|
||||
inv = {K_INVOICE_NUMBER: "INV001", K_TOTAL_AMOUNT: "50.0"}
|
||||
result = _build_invoice_summary([inv])
|
||||
assert "INV001" in result
|
||||
|
||||
def test_empty_invoices_returns_empty(self):
|
||||
result = _build_invoice_summary([])
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 一对一匹配
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMatchOneToOne:
|
||||
"""一对一匹配"""
|
||||
|
||||
def test_exact_match(self):
|
||||
invoices = [_make_invoice("A", 500), _make_invoice("B", 300)]
|
||||
cards = [_make_card("2026-01-01", 500), _make_card("2026-01-02", 300)]
|
||||
for inv in invoices:
|
||||
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||
for card in cards:
|
||||
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||
|
||||
assigned: set[int] = set()
|
||||
result: dict[int, list[int]] = {}
|
||||
_match_one_to_one(invoices, cards, 0.03, assigned, result)
|
||||
|
||||
assert 0 in result
|
||||
assert 1 in result
|
||||
assert result[0] == [0]
|
||||
assert result[1] == [1]
|
||||
|
||||
def test_within_tolerance(self):
|
||||
invoices = [_make_invoice("A", 500)]
|
||||
cards = [_make_card("2026-01-01", 490)]
|
||||
for inv in invoices:
|
||||
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||
for card in cards:
|
||||
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||
|
||||
assigned: set[int] = set()
|
||||
result: dict[int, list[int]] = {}
|
||||
_match_one_to_one(invoices, cards, 0.03, assigned, result)
|
||||
|
||||
assert 0 in result
|
||||
|
||||
def test_outside_tolerance_no_match(self):
|
||||
invoices = [_make_invoice("A", 500)]
|
||||
cards = [_make_card("2026-01-01", 400)]
|
||||
for inv in invoices:
|
||||
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||
for card in cards:
|
||||
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||
|
||||
assigned: set[int] = set()
|
||||
result: dict[int, list[int]] = {}
|
||||
_match_one_to_one(invoices, cards, 0.03, assigned, result)
|
||||
|
||||
assert 0 not in result
|
||||
|
||||
def test_sorted_by_amount_desc(self):
|
||||
invoices = [_make_invoice("A", 100), _make_invoice("B", 500)]
|
||||
cards = [_make_card("2026-01-01", 100), _make_card("2026-01-02", 500)]
|
||||
for inv in invoices:
|
||||
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||
for card in cards:
|
||||
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||
|
||||
invoices.sort(key=lambda i: i["_amount"], reverse=True)
|
||||
cards.sort(key=lambda c: c["_amount"], reverse=True)
|
||||
|
||||
assigned: set[int] = set()
|
||||
result: dict[int, list[int]] = {}
|
||||
_match_one_to_one(invoices, cards, 0.03, assigned, result)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 一对多匹配
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMatchOneToMany:
|
||||
"""一对多匹配"""
|
||||
|
||||
def _prepare(self, invoices, cards):
|
||||
for inv in invoices:
|
||||
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||
for card in cards:
|
||||
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||
invoices.sort(key=lambda i: i["_amount"], reverse=True)
|
||||
cards.sort(key=lambda c: c["_amount"], reverse=True)
|
||||
|
||||
def test_exact_match_phase(self):
|
||||
invoices = [
|
||||
_make_invoice("A", 500),
|
||||
_make_invoice("B", 300),
|
||||
_make_invoice("C", 200),
|
||||
]
|
||||
cards = [_make_card("2026-01-01", 500)]
|
||||
self._prepare(invoices, cards)
|
||||
|
||||
assigned: set[int] = set()
|
||||
result: dict[int, list[int]] = {}
|
||||
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||
|
||||
assert 0 in result
|
||||
matched_inv = invoices[result[0][0]]
|
||||
assert matched_inv["_amount"] == 500.0
|
||||
|
||||
def test_greedy_match_multiple_invoices(self):
|
||||
invoices = [
|
||||
_make_invoice("A", 300),
|
||||
_make_invoice("B", 200),
|
||||
_make_invoice("C", 100),
|
||||
]
|
||||
cards = [_make_card("2026-01-01", 500)]
|
||||
self._prepare(invoices, cards)
|
||||
|
||||
assigned: set[int] = set()
|
||||
result: dict[int, list[int]] = {}
|
||||
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||
|
||||
assert 0 in result
|
||||
assert len(result[0]) == 2
|
||||
|
||||
def test_greedy_with_remaining_invoice(self):
|
||||
invoices = [
|
||||
_make_invoice("A", 300),
|
||||
_make_invoice("B", 200),
|
||||
_make_invoice("C", 100),
|
||||
]
|
||||
cards = [_make_card("2026-01-01", 500)]
|
||||
self._prepare(invoices, cards)
|
||||
|
||||
assigned: set[int] = set()
|
||||
result: dict[int, list[int]] = {}
|
||||
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||
|
||||
assert 0 in result
|
||||
assert len(assigned) == 2
|
||||
|
||||
def test_rollback_when_over_shooting(self):
|
||||
invoices = [
|
||||
_make_invoice("A", 600),
|
||||
_make_invoice("B", 500),
|
||||
]
|
||||
cards = [_make_card("2026-01-01", 1000)]
|
||||
self._prepare(invoices, cards)
|
||||
|
||||
assigned: set[int] = set()
|
||||
result: dict[int, list[int]] = {}
|
||||
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||
|
||||
assert 0 in result
|
||||
assert len(result[0]) == 1
|
||||
|
||||
def test_zero_amount_card_skipped(self):
|
||||
invoices = [_make_invoice("A", 100)]
|
||||
cards = [_make_card("2026-01-01", 0)]
|
||||
self._prepare(invoices, cards)
|
||||
|
||||
assigned: set[int] = set()
|
||||
result: dict[int, list[int]] = {}
|
||||
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||
|
||||
assert 0 not in result
|
||||
|
||||
def test_zero_amount_invoice_skipped(self):
|
||||
invoices = [
|
||||
_make_invoice("A", 100),
|
||||
_make_invoice("B", 0),
|
||||
]
|
||||
cards = [_make_card("2026-01-01", 100)]
|
||||
self._prepare(invoices, cards)
|
||||
|
||||
assigned: set[int] = set()
|
||||
result: dict[int, list[int]] = {}
|
||||
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||
|
||||
assert 0 in result
|
||||
matched_inv = invoices[result[0][0]]
|
||||
assert matched_inv["_amount"] == 100.0
|
||||
|
||||
def test_multiple_cards_greedy(self):
|
||||
invoices = [
|
||||
_make_invoice("A", 300),
|
||||
_make_invoice("B", 200),
|
||||
_make_invoice("C", 150),
|
||||
_make_invoice("D", 100),
|
||||
]
|
||||
cards = [
|
||||
_make_card("2026-01-01", 500),
|
||||
_make_card("2026-01-02", 150),
|
||||
]
|
||||
self._prepare(invoices, cards)
|
||||
|
||||
assigned: set[int] = set()
|
||||
result: dict[int, list[int]] = {}
|
||||
_match_one_to_many(invoices, cards, 0.03, assigned, result)
|
||||
|
||||
assert 0 in result
|
||||
assert 1 in result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _match 路由
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMatchRouter:
|
||||
"""_match 根据数量选择策略"""
|
||||
|
||||
def _prepare(self, invoices, cards):
|
||||
for inv in invoices:
|
||||
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||
for card in cards:
|
||||
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||
invoices.sort(key=lambda i: i["_amount"], reverse=True)
|
||||
cards.sort(key=lambda c: c["_amount"], reverse=True)
|
||||
|
||||
def test_equal_count_routes_to_one_to_one(self):
|
||||
invoices = [_make_invoice("A", 500)]
|
||||
cards = [_make_card("2026-01-01", 500)]
|
||||
self._prepare(invoices, cards)
|
||||
|
||||
result = _match(cards, invoices, 0.03)
|
||||
assert 0 in result
|
||||
|
||||
def test_more_invoices_routes_to_one_to_many(self):
|
||||
invoices = [_make_invoice("A", 300), _make_invoice("B", 200)]
|
||||
cards = [_make_card("2026-01-01", 500)]
|
||||
self._prepare(invoices, cards)
|
||||
|
||||
result = _match(cards, invoices, 0.03)
|
||||
assert 0 in result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 记录构建
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildPaymentRecords:
|
||||
"""构建支付记录列表"""
|
||||
|
||||
def _prepare(self, invoices, cards):
|
||||
for inv in invoices:
|
||||
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
|
||||
for card in cards:
|
||||
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
|
||||
|
||||
def test_matched_records_have_card_info(self):
|
||||
invoices = [_make_invoice("A", 500)]
|
||||
cards = [_make_card("2026-01-01", 500)]
|
||||
self._prepare(invoices, cards)
|
||||
|
||||
card_to_invoices = {0: [0]}
|
||||
records = _build_payment_records(cards, invoices, card_to_invoices)
|
||||
|
||||
assert len(records) == 1
|
||||
assert records[0][K_CARD_DATE] == "2026-01-01"
|
||||
assert records[0][K_RELATIVE_INVOICE_COUNT] == "1"
|
||||
assert "unmatched" not in records[0][K_REMARK]
|
||||
|
||||
def test_unmatched_invoices_become_separate_records(self):
|
||||
invoices = [_make_invoice("A", 500), _make_invoice("B", 300)]
|
||||
cards = [_make_card("2026-01-01", 500)]
|
||||
self._prepare(invoices, cards)
|
||||
|
||||
card_to_invoices = {0: [0]}
|
||||
records = _build_payment_records(cards, invoices, card_to_invoices)
|
||||
|
||||
assert len(records) == 2
|
||||
unmatched = [r for r in records if r[K_REMARK] == "unmatched"]
|
||||
assert len(unmatched) == 1
|
||||
|
||||
def test_empty_mapping_returns_no_records(self):
|
||||
invoices = []
|
||||
cards = []
|
||||
records = _build_payment_records(cards, invoices, {})
|
||||
assert records == []
|
||||
|
||||
|
||||
class TestInvoicesToRecords:
|
||||
"""无刷卡记录时将发票转为独立记录"""
|
||||
|
||||
def test_single_invoice(self):
|
||||
invoices = [_make_invoice("A", 100)]
|
||||
records = _invoices_to_records(invoices)
|
||||
assert len(records) == 1
|
||||
assert records[0][K_RELATIVE_INVOICE_COUNT] == "1"
|
||||
|
||||
def test_multiple_invoices(self):
|
||||
invoices = [_make_invoice("A", 100), _make_invoice("B", 200)]
|
||||
records = _invoices_to_records(invoices)
|
||||
assert len(records) == 2
|
||||
|
||||
def test_empty_invoices(self):
|
||||
records = _invoices_to_records([])
|
||||
assert records == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 端到端集成
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMatchInvoicesToCards:
|
||||
"""match_invoices_to_cards 端到端测试"""
|
||||
|
||||
def test_no_cards_returns_invoice_records(self):
|
||||
invoices = [_make_invoice("A", 100), _make_invoice("B", 200)]
|
||||
result = match_invoices_to_cards(invoices, cards=None)
|
||||
|
||||
assert len(result) == 2
|
||||
for rec in result:
|
||||
assert rec[K_CARD_DATE] == ""
|
||||
assert rec[K_CARD_NO] == ""
|
||||
|
||||
def test_one_to_one_end_to_end(self):
|
||||
cards = [
|
||||
{
|
||||
K_CARD_DATE: "2026-01-01",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: "500.00",
|
||||
}
|
||||
]
|
||||
invoices = [_make_invoice("A", 500)]
|
||||
result = match_invoices_to_cards(invoices, cards=cards)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0][K_CARD_DATE] == "2026-01-01"
|
||||
assert result[0][K_RELATIVE_INVOICE_COUNT] == "1"
|
||||
assert "_amount" not in result[0]
|
||||
|
||||
def test_one_to_many_end_to_end(self):
|
||||
cards = [
|
||||
{
|
||||
K_CARD_DATE: "2026-01-01",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: "500.00",
|
||||
}
|
||||
]
|
||||
invoices = [
|
||||
_make_invoice("A", 300),
|
||||
_make_invoice("B", 200),
|
||||
]
|
||||
result = match_invoices_to_cards(invoices, cards=cards)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0][K_RELATIVE_INVOICE_COUNT] == "2"
|
||||
|
||||
def test_unmatched_invoices_included(self):
|
||||
cards = [
|
||||
{
|
||||
K_CARD_DATE: "2026-01-01",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: "500.00",
|
||||
}
|
||||
]
|
||||
invoices = [
|
||||
_make_invoice("A", 500),
|
||||
_make_invoice("B", 100),
|
||||
]
|
||||
result = match_invoices_to_cards(invoices, cards=cards)
|
||||
|
||||
assert len(result) == 2
|
||||
unmatched = [r for r in result if r[K_REMARK] == "unmatched"]
|
||||
assert len(unmatched) == 1
|
||||
|
||||
def test_internal_fields_cleaned(self):
|
||||
cards = [
|
||||
{
|
||||
K_CARD_DATE: "2026-01-01",
|
||||
K_CARD_NO: "6228480000000000",
|
||||
K_CARD_AMOUNT: "500.00",
|
||||
}
|
||||
]
|
||||
invoices = [_make_invoice("A", 500)]
|
||||
result = match_invoices_to_cards(invoices, cards=cards)
|
||||
|
||||
for inv in result[0][K_MATCHED_INVOICES]:
|
||||
assert "_amount" not in inv
|
||||
|
||||
def test_empty_cards_list(self):
|
||||
invoices = [_make_invoice("A", 100)]
|
||||
result = match_invoices_to_cards(invoices, cards=[])
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0][K_CARD_DATE] == ""
|
||||
|
||||
def test_multiple_cards(self):
|
||||
cards = [
|
||||
{K_CARD_DATE: "2026-01-01", K_CARD_NO: "6228480000000001", K_CARD_AMOUNT: "500.00"},
|
||||
{K_CARD_DATE: "2026-01-02", K_CARD_NO: "6228480000000002", K_CARD_AMOUNT: "300.00"},
|
||||
]
|
||||
invoices = [
|
||||
_make_invoice("A", 500),
|
||||
_make_invoice("B", 300),
|
||||
]
|
||||
result = match_invoices_to_cards(invoices, cards=cards)
|
||||
|
||||
assert len(result) == 2
|
||||
375
web/app.py
375
web/app.py
@@ -1,375 +0,0 @@
|
||||
"""
|
||||
财务报销自动化 — Web 界面
|
||||
|
||||
用户上传 PDF 发票和支付截图,配置账号信息,自动完成:
|
||||
1. 发票提取 2. OCR 识别 3. 浏览器填报(可选)
|
||||
|
||||
启动: python web/app.py
|
||||
访问: http://localhost:5000
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
|
||||
|
||||
# 确保项目根目录在 sys.path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from app.config import load_config as load_project_config
|
||||
from app.extractor import extract_invoices, save_csv, save_markdown
|
||||
from app.ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv as load_ocr_csv
|
||||
|
||||
app = Flask(__name__, template_folder="templates")
|
||||
|
||||
UPLOAD_BASE = PROJECT_ROOT / "web" / "uploads"
|
||||
SESSION_LOG_FILE = "session.log"
|
||||
SESSION_RESULT_FILE = "result.json"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 日志收集器 — 捕获管道日志到文件,SSE 端点通过 tail -f 读取
|
||||
# ================================================================
|
||||
|
||||
class _SSELogHandler(logging.Handler):
|
||||
"""将日志写入指定文件(线程安全)"""
|
||||
|
||||
def __init__(self, log_path: Path):
|
||||
super().__init__()
|
||||
self._lock = threading.Lock()
|
||||
self._file = open(log_path, "w", encoding="utf-8")
|
||||
|
||||
def emit(self, record: logging.LogRecord):
|
||||
try:
|
||||
msg = self.format(record) + "\n"
|
||||
with self._lock:
|
||||
self._file.write(msg)
|
||||
self._file.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def close_file(self):
|
||||
try:
|
||||
self._file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _install_log_collector(session_dir: Path) -> _SSELogHandler:
|
||||
"""安装日志收集器到 app.* 模块"""
|
||||
log_path = session_dir / SESSION_LOG_FILE
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
handler = _SSELogHandler(log_path)
|
||||
handler.setFormatter(fmt)
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
for name in ["extractor", "ocr", "pipeline", "bot"]:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _remove_log_collector(handler: _SSELogHandler):
|
||||
for name in ["extractor", "ocr", "pipeline", "bot"]:
|
||||
logging.getLogger(name).removeHandler(handler)
|
||||
handler.close_file()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 管道入口
|
||||
# ================================================================
|
||||
|
||||
# ================================================================
|
||||
# 管道入口
|
||||
# ================================================================
|
||||
|
||||
def run_pipeline_web(session_dir: Path, config: dict, run_bot: bool = False):
|
||||
"""在 Web 会话目录中执行管道,结果写入 session 目录下的文件"""
|
||||
start = time.time()
|
||||
|
||||
# ---- Step 1: 发票提取 ----
|
||||
invoices = extract_invoices(str(session_dir))
|
||||
if not invoices:
|
||||
return {"ok": False, "error": "未提取到任何发票数据"}
|
||||
|
||||
save_csv(invoices, session_dir / "invoice_summary.csv")
|
||||
save_markdown(invoices, session_dir / "invoice_summary.md")
|
||||
|
||||
# ---- Step 2: OCR 识别 ----
|
||||
csv_path = session_dir / "invoice_summary.csv"
|
||||
rows = load_ocr_csv(csv_path)
|
||||
if rows is None:
|
||||
return {"ok": False, "error": "CSV 读取失败"}
|
||||
|
||||
rows = enrich_with_ocr(rows, str(session_dir))
|
||||
save_ocr_csv(csv_path, rows)
|
||||
save_markdown_from_csv(csv_path, rows)
|
||||
|
||||
# ---- Step 3: 浏览器填报(可选)----
|
||||
if run_bot:
|
||||
from app.bot import load_invoice_data, run_bot_web
|
||||
|
||||
bot_invoices = load_invoice_data(str(csv_path), config)
|
||||
run_bot_web(config, bot_invoices, session_dir)
|
||||
|
||||
elapsed = time.time() - start
|
||||
return {
|
||||
"ok": True,
|
||||
"elapsed": f"{elapsed:.1f}s",
|
||||
"invoice_count": len(rows),
|
||||
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
|
||||
"md_url": f"/api/download/{session_dir.name}/invoice_summary.md",
|
||||
}
|
||||
|
||||
|
||||
def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str, run_bot: bool = False):
|
||||
"""直接使用上传的 CSV 文件进行填报,跳过 PDF 提取和 OCR"""
|
||||
start = time.time()
|
||||
|
||||
csv_path = session_dir / csv_filename
|
||||
if not csv_path.exists():
|
||||
return {"ok": False, "error": "CSV 文件不存在"}
|
||||
|
||||
# 读取 CSV 行数
|
||||
rows = load_ocr_csv(csv_path)
|
||||
if rows is None:
|
||||
return {"ok": False, "error": "CSV 读取失败"}
|
||||
|
||||
# 浏览器填报
|
||||
if run_bot:
|
||||
from app.bot import load_invoice_data, run_bot_web
|
||||
|
||||
bot_invoices = load_invoice_data(str(csv_path), config)
|
||||
run_bot_web(config, bot_invoices, session_dir)
|
||||
|
||||
elapsed = time.time() - start
|
||||
return {
|
||||
"ok": True,
|
||||
"elapsed": f"{elapsed:.1f}s",
|
||||
"invoice_count": len(rows),
|
||||
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Flask 路由
|
||||
# ================================================================
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/session", methods=["POST"])
|
||||
def create_session():
|
||||
"""创建上传会话,返回 session_id"""
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
session_dir = UPLOAD_BASE / sid
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
return jsonify({"session_id": sid})
|
||||
|
||||
|
||||
@app.route("/api/upload/<session_id>", methods=["POST"])
|
||||
def upload_file(session_id: str):
|
||||
"""上传 PDF 或图片"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
return jsonify({"error": "未选择文件"}), 400
|
||||
|
||||
safe_name = Path(f.filename).name
|
||||
f.save(str(session_dir / safe_name))
|
||||
return jsonify({"ok": True, "filename": safe_name})
|
||||
|
||||
|
||||
@app.route("/api/upload-csv/<session_id>", methods=["POST"])
|
||||
def upload_csv(session_id: str):
|
||||
"""上传 CSV 发票数据文件(跳过 PDF 提取和 OCR)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
return jsonify({"error": "未选择文件"}), 400
|
||||
|
||||
safe_name = Path(f.filename).name
|
||||
f.save(str(session_dir / safe_name))
|
||||
return jsonify({"ok": True, "filename": safe_name})
|
||||
|
||||
|
||||
@app.route("/api/files/<session_id>", methods=["GET"])
|
||||
def list_files(session_id: str):
|
||||
"""列出会话目录中的文件"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
|
||||
imgs = sorted(
|
||||
f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
||||
for f in session_dir.glob(f"*{ext}")
|
||||
)
|
||||
return jsonify({"pdfs": pdfs, "images": imgs})
|
||||
|
||||
|
||||
@app.route("/api/process/<session_id>", methods=["POST"])
|
||||
def start_process(session_id: str):
|
||||
"""启动管道处理"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
run_bot_flag = body.get("submit", False)
|
||||
mode = body.get("mode", "auto") # "pdf", "csv", or "auto"
|
||||
|
||||
# 读取配置
|
||||
config = _build_web_config(body)
|
||||
|
||||
# 写入配置到会话目录
|
||||
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
# 在后台线程执行
|
||||
handler = _install_log_collector(session_dir)
|
||||
|
||||
def _run():
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
if mode == "csv":
|
||||
# CSV 模式:直接使用上传的 CSV,跳过 PDF 提取和 OCR
|
||||
csv_files = list(session_dir.glob("*.csv"))
|
||||
if not csv_files:
|
||||
result = {"ok": False, "error": "未找到 CSV 文件"}
|
||||
else:
|
||||
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name, run_bot_flag)
|
||||
else:
|
||||
# 自动检测:如果有 CSV 则走 csv 管道,否则走 pdf 管道
|
||||
csv_files = list(session_dir.glob("*.csv"))
|
||||
pdf_files = list(session_dir.glob("*.pdf"))
|
||||
if csv_files and not pdf_files:
|
||||
result = run_csv_pipeline_web(session_dir, config, csv_files[0].name, run_bot_flag)
|
||||
else:
|
||||
result = run_pipeline_web(session_dir, config, run_bot_flag)
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
# 原子写入:先写临时文件,再重命名,避免 SSE 读到截断的空文件
|
||||
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
_remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@app.route("/api/logs/<session_id>")
|
||||
def stream_logs(session_id: str):
|
||||
"""SSE 日志流"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
def generate():
|
||||
# 先发送已有日志
|
||||
log_file = session_dir / SESSION_LOG_FILE
|
||||
last_size = 0
|
||||
start_time = time.time()
|
||||
timeout = 600 # 10 分钟超时
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
if log_file.exists():
|
||||
current_size = log_file.stat().st_size
|
||||
if current_size > last_size:
|
||||
with open(log_file, encoding="utf-8", errors="replace") as f:
|
||||
f.seek(last_size)
|
||||
chunk = f.read()
|
||||
if chunk:
|
||||
yield f"data: {_escape_sse(chunk)}\n\n"
|
||||
last_size = current_size
|
||||
|
||||
# 也通过队列发送实时日志
|
||||
# 检查是否完成
|
||||
result_file = session_dir / SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
with open(result_file, encoding="utf-8") as f:
|
||||
result = json.load(f)
|
||||
yield f"data: {_escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
|
||||
break
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/download/<session_id>/<filename>")
|
||||
def download_file(session_id: str, filename: str):
|
||||
"""下载生成的文件"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
filepath = session_dir / filename
|
||||
if not filepath.exists():
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
|
||||
return Response(filepath.read_bytes(), mimetype="application/octet-stream")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 辅助函数
|
||||
# ================================================================
|
||||
|
||||
def _validate_session(session_id: str):
|
||||
session_dir = UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
return jsonify({"error": "会话不存在"}), 404
|
||||
return session_dir
|
||||
|
||||
|
||||
def _build_web_config(body: dict) -> dict:
|
||||
"""从请求体构建配置"""
|
||||
config = load_project_config()
|
||||
for key in ("username", "password", "default_name", "default_card_no", "default_person_id"):
|
||||
if body.get(key):
|
||||
config[key] = body[key]
|
||||
return config
|
||||
|
||||
|
||||
def _escape_sse(text: str) -> str:
|
||||
"""SSE 数据转义,同时处理 Windows 行尾 \\r\\n"""
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
|
||||
print(f"启动 Web 服务: http://localhost:5000")
|
||||
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)
|
||||
@@ -1,362 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>财务报销自动化</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f5f7fa; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
|
||||
.upload-zone {
|
||||
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
|
||||
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
|
||||
}
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
|
||||
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
|
||||
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
|
||||
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
|
||||
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
|
||||
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
.log-container .empty { color: #666; font-style: italic; }
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
|
||||
.btn-process { font-size: 17px; padding: 10px 40px; }
|
||||
.status-badge { font-size: 13px; }
|
||||
.result-box { background: #fff; border-radius: 8px; padding: 16px; display: none; }
|
||||
.result-box a { text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header text-center mb-4">
|
||||
<h3>财务报销自动化</h3>
|
||||
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、OCR 识别并填报</p>
|
||||
</div>
|
||||
|
||||
<div class="container" style="max-width:960px">
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="section-title">📄 发票 PDF <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||||
<div class="upload-zone" id="pdf-zone" onclick="document.getElementById('pdf-input').click()">
|
||||
<div class="icon">📁</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传 PDF 文件</div>
|
||||
<div id="pdf-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="section-title">🖼️ 支付截图 <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||||
<div class="upload-zone" id="img-zone" onclick="document.getElementById('img-input').click()">
|
||||
<div class="icon">🖼️</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传图片文件</div>
|
||||
<div id="img-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSV 快捷上传 -->
|
||||
<div class="mb-4">
|
||||
<div class="section-title">📊 CSV 快捷上传 <span class="text-muted fw-normal" style="font-size:12px">(已有发票数据 CSV 可直接上传,跳过提取和 OCR)</span></div>
|
||||
<div class="upload-zone" id="csv-zone" onclick="document.getElementById('csv-input').click()">
|
||||
<div class="icon">📊</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传 CSV 文件</div>
|
||||
<div id="csv-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="csv-input" accept=".csv" hidden onchange="handleCsvFile(this)">
|
||||
</div>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="section-title">⚙️ 配置
|
||||
<span style="font-size:12px;font-weight:normal;cursor:pointer;color:#667eea;margin-left:8px" onclick="document.getElementById('config-upload').click()">
|
||||
📤 上传 config.json
|
||||
</span>
|
||||
<input type="file" id="config-upload" accept=".json" hidden onchange="handleConfigUpload(this)">
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">账号 (工号)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-username" placeholder="202xxxx">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">密码</label>
|
||||
<input type="password" class="form-control form-control-sm" id="cfg-password" placeholder="登录密码">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">默认姓名</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-name" placeholder="张三">
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">公务卡号</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-card" placeholder="628288...">
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
|
||||
</div>
|
||||
<div class="col-sm-6 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="cfg-submit">
|
||||
<label class="form-check-label" for="cfg-submit" style="font-size:13px">同时提交到财务系统(需浏览器自动化)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="text-center mb-4">
|
||||
<button class="btn btn-primary btn-process" id="btn-start" onclick="startProcess()">开始处理</button>
|
||||
<span id="status" class="ms-3"></span>
|
||||
</div>
|
||||
|
||||
<!-- 结果 -->
|
||||
<div class="result-box mb-3" id="result-box">
|
||||
<div class="section-title">📊 处理结果</div>
|
||||
<div id="result-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<div class="section-title">📋 处理日志</div>
|
||||
<div class="log-container mb-4" id="log-box"><div class="empty">等待开始...</div></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sessionId = null;
|
||||
const pdfFiles = [], imgFiles = [];
|
||||
let csvFile = null;
|
||||
|
||||
// ---- Session ----
|
||||
async function ensureSession() {
|
||||
if (sessionId) return sessionId;
|
||||
const r = await fetch('/api/session', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
sessionId = d.session_id;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
// ---- 上传 ----
|
||||
function handleFiles(input, type) {
|
||||
const files = Array.from(input.files);
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
|
||||
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
|
||||
|
||||
files.forEach(f => {
|
||||
if (!list.find(x => x.name === f.name)) list.push(f);
|
||||
});
|
||||
|
||||
renderFileList(type);
|
||||
document.getElementById(zoneId).classList.add('active');
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removeFile(type, index) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
list.splice(index, 1);
|
||||
renderFileList(type);
|
||||
if (list.length === 0) {
|
||||
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileList(type) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
|
||||
box.innerHTML = list.map((f, i) =>
|
||||
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">×</span></span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ---- CSV 上传 ----
|
||||
function handleCsvFile(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
csvFile = file;
|
||||
document.getElementById('csv-zone').classList.add('active');
|
||||
document.getElementById('csv-list').innerHTML =
|
||||
`<span class="file-tag">${file.name}<span class="remove" onclick="event.stopPropagation();removeCsvFile()">×</span></span>`;
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removeCsvFile() {
|
||||
csvFile = null;
|
||||
document.getElementById('csv-zone').classList.remove('active');
|
||||
document.getElementById('csv-list').innerHTML = '';
|
||||
}
|
||||
|
||||
// ---- 配置上传 ----
|
||||
function handleConfigUpload(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
const cfg = JSON.parse(e.target.result);
|
||||
const map = {
|
||||
'cfg-username': cfg.username,
|
||||
'cfg-password': cfg.password,
|
||||
'cfg-name': cfg.default_name,
|
||||
'cfg-card': cfg.default_card_no,
|
||||
'cfg-person-id': cfg.default_person_id,
|
||||
};
|
||||
for (const [id, val] of Object.entries(map)) {
|
||||
if (val) document.getElementById(id).value = val;
|
||||
}
|
||||
alert('配置已加载');
|
||||
} catch (err) {
|
||||
alert('config.json 解析失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// ---- 拖拽 ----
|
||||
['pdf','img'].forEach(type => {
|
||||
const zone = document.getElementById(type + '-zone');
|
||||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||
zone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('dragover');
|
||||
const files = Array.from(e.dataTransfer.files).filter(f => {
|
||||
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
|
||||
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
|
||||
});
|
||||
if (files.length) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
files.forEach(f => { if (!list.find(x => x.name === f.name)) list.push(f); });
|
||||
renderFileList(type);
|
||||
zone.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// CSV 拖拽
|
||||
const csvZone = document.getElementById('csv-zone');
|
||||
csvZone.addEventListener('dragover', e => { e.preventDefault(); csvZone.classList.add('dragover'); });
|
||||
csvZone.addEventListener('dragleave', () => csvZone.classList.remove('dragover'));
|
||||
csvZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
csvZone.classList.remove('dragover');
|
||||
const file = Array.from(e.dataTransfer.files).find(f => f.name.toLowerCase().endsWith('.csv'));
|
||||
if (file) handleCsvFile({ files: [file] });
|
||||
});
|
||||
|
||||
// ---- 处理 ----
|
||||
async function startProcess() {
|
||||
const isCsvMode = !!csvFile;
|
||||
if (!isCsvMode && !pdfFiles.length && !imgFiles.length) {
|
||||
alert('请先上传文件或 CSV');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-start');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '处理中...';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
|
||||
document.getElementById('log-box').innerHTML = '';
|
||||
document.getElementById('result-box').style.display = 'none';
|
||||
|
||||
try {
|
||||
await ensureSession();
|
||||
|
||||
if (isCsvMode) {
|
||||
// CSV 模式:只上传 CSV 文件
|
||||
const fd = new FormData();
|
||||
fd.append('file', csvFile);
|
||||
await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd });
|
||||
} else {
|
||||
// 传统模式:上传 PDF 和图片
|
||||
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
|
||||
for (const {f} of allFiles) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
|
||||
|
||||
// 启动管道
|
||||
const cfg = {
|
||||
username: document.getElementById('cfg-username').value,
|
||||
password: document.getElementById('cfg-password').value,
|
||||
default_name: document.getElementById('cfg-name').value,
|
||||
default_card_no: document.getElementById('cfg-card').value,
|
||||
default_person_id: document.getElementById('cfg-person-id').value,
|
||||
submit: document.getElementById('cfg-submit').checked,
|
||||
mode: isCsvMode ? 'csv' : 'auto',
|
||||
};
|
||||
|
||||
await fetch(`/api/process/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
|
||||
// 监听 SSE 日志
|
||||
const es = new EventSource(`/api/logs/${sessionId}`);
|
||||
const logBox = document.getElementById('log-box');
|
||||
let firstLine = true;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
|
||||
// 尝试解析 JSON(完成信号),否则按日志文本处理
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
const result = msg.result;
|
||||
document.getElementById('status').innerHTML = result.ok
|
||||
? '<span class="badge bg-success status-badge">完成</span>'
|
||||
: '<span class="badge bg-danger status-badge">失败</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
if (result.ok) {
|
||||
showResult(result);
|
||||
} else {
|
||||
document.getElementById('result-box').style.display = 'block';
|
||||
document.getElementById('result-content').innerHTML = '<span class="text-danger">' + (result.error || '处理失败') + '</span>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
logBox.innerHTML += e.data;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
alert('请求失败: ' + (e.message || '未知错误'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function showResult(r) {
|
||||
const box = document.getElementById('result-box');
|
||||
box.style.display = 'block';
|
||||
const mdLink = r.md_url ? `<a class="btn btn-sm btn-outline-secondary" href="${r.md_url}" download>下载 Markdown</a>` : '';
|
||||
document.getElementById('result-content').innerHTML = `
|
||||
<p>处理 <b>${r.invoice_count}</b> 张发票,耗时 ${r.elapsed}</p>
|
||||
<a class="btn btn-sm btn-outline-primary me-2" href="${r.csv_url}" download>下载 CSV</a>
|
||||
${mdLink}
|
||||
`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
302
报销操作指南.md
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` 定位失败步骤 |
|
||||
BIN
易耗品、出库单.doc
Normal file
BIN
易耗品、出库单.doc
Normal file
Binary file not shown.
Reference in New Issue
Block a user