保存差旅报销自动化基础文件
仅保存说明、脚本和 Excel 模板,示例中的个人和公司信息已匿名化。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
13bf1a0146
467
README.md
Normal file
467
README.md
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
# 差旅报销自动化流程
|
||||||
|
|
||||||
|
## 给大模型的自动执行入口
|
||||||
|
|
||||||
|
当用户把本 `README.md` 提供给大模型,或让大模型在本目录处理差旅报销时,大模型应直接按下面流程开始执行,不需要用户再复制额外提示词:
|
||||||
|
|
||||||
|
1. 先阅读本说明,确认当前目录是报销目录。
|
||||||
|
2. 检查当前目录是否存在基础文件:`差旅报销.py`、`差旅费明细表.xlsx`、本次报销 PDF。
|
||||||
|
3. 如果 `报销输出/pdf_raw_text.txt` 不存在,自动运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 差旅报销.py extract
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 环境可改用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py 差旅报销.py extract
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 读取 `报销输出/pdf_raw_text.txt`,按本文的 JSON Schema 分析出差人、日期、事由、费用分类、金额和证据。
|
||||||
|
5. 如果出差人、出差事由、出发地、目的地、日期、金额等关键信息无法从原文可靠判断,必须先问用户;不要编造。
|
||||||
|
6. 将最终 JSON 保存为 `报销输出/analyzed_result.json`。
|
||||||
|
7. 确认 `questions` 为空、金额合计一致、附件张数一致后,自动运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 差旅报销.py fill 报销输出/analyzed_result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 环境可改用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py 差旅报销.py fill 报销输出/analyzed_result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
8. 最后告诉用户生成的 Excel 文件路径,以及是否有任何需要人工复核的证据或假设。
|
||||||
|
|
||||||
|
> 简单说:看到本 README 后,大模型应自动完成“提取 PDF 原文 → 分析生成 JSON → 填写 Excel”,只有在信息不确定时才暂停询问用户。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心思想
|
||||||
|
|
||||||
|
这个流程分成三段:
|
||||||
|
|
||||||
|
1. **Python 提取原文**:只把 PDF 内容提取成 TXT,不理解、不计算、不猜测。
|
||||||
|
2. **大模型分析**:读取 TXT,判断出差人、日期、事由、费用分类和金额,输出标准 JSON。
|
||||||
|
3. **Python 填表**:只读取 JSON,把数据填入 Excel 模板,生成最终 XLSX。
|
||||||
|
|
||||||
|
也就是说:**Python 只做机械衔接,大模型负责理解发票内容。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 基础三件套
|
||||||
|
|
||||||
|
每次复用这个流程,只需要保留下面三个基础文件:
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `差旅费明细表.xlsx` | 原始 Excel 模板,脚本会复制它,不直接修改 |
|
||||||
|
| `差旅报销.py` | 唯一 Python 脚本,提供 `extract` / `schema` / `fill` 三个命令 |
|
||||||
|
| `README.md` | 本说明,给人和大模型看 |
|
||||||
|
|
||||||
|
PDF 发票、`pdf_raw_text.txt`、`analyzed_result.json`、最终 XLSX 都是每次报销产生的过程文件或结果文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么使用 XLSX
|
||||||
|
|
||||||
|
为了跨平台,模板统一使用 `.xlsx`。
|
||||||
|
|
||||||
|
这样脚本只依赖 Python 包:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pdfplumber # 提取 PDF 文本
|
||||||
|
openpyxl # 复制和填写 xlsx 模板
|
||||||
|
```
|
||||||
|
|
||||||
|
不再依赖 LibreOffice,也不需要系统命令做 `.xls` / `.xlsx` 转换。
|
||||||
|
|
||||||
|
如果你手里只有 `.xls` 模板,请先用 Excel、WPS 或 LibreOffice 手动另存为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
差旅费明细表.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖安装
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -m pip install pdfplumber openpyxl
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py 差旅报销.py extract
|
||||||
|
py 差旅报销.py fill 报销输出/analyzed_result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux / macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pip install pdfplumber openpyxl
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 差旅报销.py extract
|
||||||
|
python3 差旅报销.py fill 报销输出/analyzed_result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 标准目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
报销目录/
|
||||||
|
├── 差旅费明细表.xlsx # 必需:模板
|
||||||
|
├── 差旅报销.py # 必需:脚本
|
||||||
|
├── README.md # 必需:说明
|
||||||
|
├── 住宿.pdf # 本次报销 PDF
|
||||||
|
├── 机票*.pdf # 本次报销 PDF
|
||||||
|
├── 滴滴出行行程报销单*.pdf # 本次报销 PDF
|
||||||
|
└── 滴滴电子发票*.pdf # 本次报销 PDF
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本输出统一放到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
报销输出/
|
||||||
|
```
|
||||||
|
|
||||||
|
运行后会形成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
报销目录/
|
||||||
|
└── 报销输出/
|
||||||
|
├── pdf_raw_text.txt
|
||||||
|
├── analyzed_result.json
|
||||||
|
├── schema_example.json # 可选
|
||||||
|
└── 差旅费明细表_xxxx_20260520_广州_售后维修.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
### Step 1:提取 PDF 原文
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 差旅报销.py extract
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 可用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py 差旅报销.py extract
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
报销输出/pdf_raw_text.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
这个文件按 PDF 文件分块保存原始文本,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
============================================================
|
||||||
|
文件: 住宿.pdf
|
||||||
|
============================================================
|
||||||
|
电子发票(增值税专用发票)
|
||||||
|
...
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
文件: 滴滴出行行程报销单A.pdf
|
||||||
|
============================================================
|
||||||
|
滴滴出行-行程单
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:这一步**不会**提取金额、不会判断类型、不会计算总额。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2:让大模型分析 TXT 并输出 JSON
|
||||||
|
|
||||||
|
把 `报销输出/pdf_raw_text.txt` 交给大模型,让大模型生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
报销输出/analyzed_result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
可以先查看 JSON 模板:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 差旅报销.py schema
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以直接输出模板文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 差旅报销.py schema --output
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
报销输出/schema_example.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 给大模型的推荐提示词
|
||||||
|
|
||||||
|
```text
|
||||||
|
请根据本 README 自动处理当前目录的差旅报销。
|
||||||
|
|
||||||
|
你的任务:
|
||||||
|
1. 如果 `报销输出/pdf_raw_text.txt` 不存在,先运行 `python3 差旅报销.py extract` 提取 PDF 原文。
|
||||||
|
2. 读取 `报销输出/pdf_raw_text.txt`,从 PDF 原文中识别出差人、出差事由、出发日期、结束日期、出发地、目的地、出行方式、出差天数。
|
||||||
|
3. 将费用分为:住宿、交通、饮食、其他。
|
||||||
|
4. 每个费用类别都要给出金额、单据张数、说明。
|
||||||
|
5. 计算附件总张数、报销总额、预借差旅费、补领金额、退还金额。
|
||||||
|
6. 每个金额必须写入 evidence,说明来自哪个文件、依据是哪段文本。
|
||||||
|
7. 如果某个字段无法可靠判断,必须先问用户,不要编造;在未确认前不要填表。
|
||||||
|
8. 将最终 JSON 保存为 `报销输出/analyzed_result.json`,JSON 必须符合 README 中的 schema。
|
||||||
|
9. 确认 `questions` 为空、金额合计一致、附件张数一致后,运行 `python3 差旅报销.py fill 报销输出/analyzed_result.json` 生成最终 Excel。
|
||||||
|
10. 最后告诉用户生成的 Excel 文件路径,并列出需要人工复核的证据或假设。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JSON Schema
|
||||||
|
|
||||||
|
大模型必须输出如下结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"出差人": "xxxx",
|
||||||
|
"出差事由": "前往广州进行售后维修工作",
|
||||||
|
"出发日期": "2026/5/20",
|
||||||
|
"结束日期": "2026/5/22",
|
||||||
|
"出发地": "北京",
|
||||||
|
"目的地": "广州",
|
||||||
|
"出行方式": "飞机",
|
||||||
|
"出差天数": 3,
|
||||||
|
"事由简写": "售后维修"
|
||||||
|
},
|
||||||
|
"expenses": {
|
||||||
|
"住宿": {
|
||||||
|
"金额": 510.0,
|
||||||
|
"单据张数": 1,
|
||||||
|
"说明": "广州市番禺明乐宾馆住宿费"
|
||||||
|
},
|
||||||
|
"交通": {
|
||||||
|
"金额": 2013.77,
|
||||||
|
"单据张数": 5,
|
||||||
|
"说明": "机票1690.00 + 滴滴323.77"
|
||||||
|
},
|
||||||
|
"饮食": {
|
||||||
|
"金额": 0.0,
|
||||||
|
"单据张数": 0,
|
||||||
|
"说明": "未发现餐饮发票"
|
||||||
|
},
|
||||||
|
"其他": {
|
||||||
|
"金额": 0.0,
|
||||||
|
"单据张数": 0,
|
||||||
|
"说明": "无其他费用"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"附件总张数": 6,
|
||||||
|
"报销总额": 2523.77,
|
||||||
|
"预借差旅费": 0.0,
|
||||||
|
"补领金额": 2523.77,
|
||||||
|
"退还金额": 0.0
|
||||||
|
},
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"文件": "住宿.pdf",
|
||||||
|
"类别": "住宿",
|
||||||
|
"金额": 510.0,
|
||||||
|
"依据": "价税合计(小写)¥510.00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"文件": "机票预订出行订单尾号87377629-电子普通发票.pdf",
|
||||||
|
"类别": "交通",
|
||||||
|
"金额": 1690.0,
|
||||||
|
"依据": "价税合计(小写)¥1690.00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"questions": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `meta.出差人` | 出差人姓名,不确定就问用户 |
|
||||||
|
| `meta.出差事由` | 出差目的,不确定就问用户 |
|
||||||
|
| `meta.出发日期` / `meta.结束日期` | 格式建议为 `YYYY/M/D` |
|
||||||
|
| `meta.出差天数` | 含首尾日期,例如 5/20 至 5/22 为 3 天 |
|
||||||
|
| `expenses.住宿` | 酒店住宿类费用 |
|
||||||
|
| `expenses.交通` | 机票、火车、打车等交通类费用 |
|
||||||
|
| `expenses.饮食` | 餐饮费用,没有就 0 |
|
||||||
|
| `expenses.其他` | 不属于前三类的费用,没有就 0 |
|
||||||
|
| `summary.附件总张数` | 四类费用单据张数之和 |
|
||||||
|
| `summary.报销总额` | 四类费用金额之和 |
|
||||||
|
| `evidence` | 每笔金额的来源证据,便于人工核对 |
|
||||||
|
| `questions` | 如果大模型无法判断,可以列出问题;最终填表前应为空 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3:读取 JSON 并填表
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 差旅报销.py fill 报销输出/analyzed_result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 可用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py 差旅报销.py fill 报销输出/analyzed_result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会:
|
||||||
|
|
||||||
|
1. 校验 JSON 字段是否完整
|
||||||
|
2. 校验金额合计是否一致
|
||||||
|
3. 校验附件张数是否一致
|
||||||
|
4. 复制 `差旅费明细表.xlsx`
|
||||||
|
5. 用 openpyxl 填入固定单元格
|
||||||
|
6. 输出最终 XLSX 到 `报销输出/`
|
||||||
|
|
||||||
|
输出文件命名规则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
差旅费明细表_[出差人]_[出发日期YYYYMMDD]_[目的地]_[事由简写].xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
报销输出/差旅费明细表_xxxx_20260520_广州_售后维修.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Excel 填写位置
|
||||||
|
|
||||||
|
脚本只填写这些单元格:
|
||||||
|
|
||||||
|
| 单元格 | 内容 |
|
||||||
|
|--------|------|
|
||||||
|
| `E3` | 出差事由 |
|
||||||
|
| `A6` | 出发日期-结束日期 |
|
||||||
|
| `B6` | 出发地-目的地 |
|
||||||
|
| `C6` | 出差天数 |
|
||||||
|
| `D6` | 出行方式 |
|
||||||
|
| `H6` / `I6` | 住宿单据张数 / 住宿金额 |
|
||||||
|
| `H7` / `I7` | 饮食单据张数 / 饮食金额 |
|
||||||
|
| `H8` / `I8` | 交通单据张数 / 交通金额 |
|
||||||
|
| `H9` / `I9` | 其他单据张数 / 其他金额 |
|
||||||
|
|
||||||
|
脚本**不会覆盖公式单元格**,例如:
|
||||||
|
|
||||||
|
| 单元格 | 公式 |
|
||||||
|
|--------|------|
|
||||||
|
| `H2` | 附件张数合计 |
|
||||||
|
| `F9` | 天数合计 |
|
||||||
|
| `C10` | 报销总额 |
|
||||||
|
| `H10` | 补领金额 |
|
||||||
|
| `H11` | 退还金额 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 校验规则
|
||||||
|
|
||||||
|
`fill` 命令会检查:
|
||||||
|
|
||||||
|
```text
|
||||||
|
summary.报销总额 = 住宿金额 + 交通金额 + 饮食金额 + 其他金额
|
||||||
|
summary.附件总张数 = 住宿单据张数 + 交通单据张数 + 饮食单据张数 + 其他单据张数
|
||||||
|
```
|
||||||
|
|
||||||
|
如果不一致,脚本会停止,不会写 Excel。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本次数据示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"出差人": "xxxx",
|
||||||
|
"出差事由": "前往广州进行售后维修工作",
|
||||||
|
"出发日期": "2026/5/20",
|
||||||
|
"结束日期": "2026/5/22",
|
||||||
|
"出发地": "北京",
|
||||||
|
"目的地": "广州",
|
||||||
|
"出行方式": "飞机",
|
||||||
|
"出差天数": 3,
|
||||||
|
"事由简写": "售后维修"
|
||||||
|
},
|
||||||
|
"expenses": {
|
||||||
|
"住宿": {"金额": 510.0, "单据张数": 1, "说明": "住宿费"},
|
||||||
|
"交通": {"金额": 2013.77, "单据张数": 5, "说明": "机票1690.00 + 滴滴323.77"},
|
||||||
|
"饮食": {"金额": 0.0, "单据张数": 0, "说明": "无餐饮发票"},
|
||||||
|
"其他": {"金额": 0.0, "单据张数": 0, "说明": "无其他费用"}
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"附件总张数": 6,
|
||||||
|
"报销总额": 2523.77,
|
||||||
|
"预借差旅费": 0.0,
|
||||||
|
"补领金额": 2523.77,
|
||||||
|
"退还金额": 0.0
|
||||||
|
},
|
||||||
|
"evidence": [],
|
||||||
|
"questions": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见错误
|
||||||
|
|
||||||
|
### 1. `找不到模板文件:差旅费明细表.xlsx`
|
||||||
|
|
||||||
|
确认当前目录存在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
差旅费明细表.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
如果只有 `.xls`,请先用 Excel / WPS / LibreOffice 另存为 `.xlsx`。
|
||||||
|
|
||||||
|
### 2. `报销总额不一致`
|
||||||
|
|
||||||
|
说明 JSON 里的总额和分类费用合计不一致,检查:
|
||||||
|
|
||||||
|
```text
|
||||||
|
住宿 + 交通 + 饮食 + 其他
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `附件总张数不一致`
|
||||||
|
|
||||||
|
说明 JSON 里的附件总数和分类单据张数合计不一致。
|
||||||
|
|
||||||
|
### 4. 缺少 Python 库
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -m pip install pdfplumber openpyxl
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux / macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pip install pdfplumber openpyxl
|
||||||
|
```
|
||||||
328
差旅报销.py
Normal file
328
差旅报销.py
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
差旅报销自动化脚本
|
||||||
|
|
||||||
|
Python 只做两类确定性工作:
|
||||||
|
1. extract:提取 PDF 原文到 TXT
|
||||||
|
2. fill:读取大模型生成的 JSON,填入 Excel 模板
|
||||||
|
|
||||||
|
中间的发票理解、金额分类、日期/事由/人员判断全部由大模型完成。
|
||||||
|
模板使用 .xlsx,完全依赖 Python 包实现跨平台,不依赖 LibreOffice。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pdfplumber
|
||||||
|
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path("报销输出")
|
||||||
|
RAW_TEXT_FILE = OUTPUT_DIR / "pdf_raw_text.txt"
|
||||||
|
TEMPLATE_FILE = Path("差旅费明细表.xlsx")
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_EXAMPLE = {
|
||||||
|
"meta": {
|
||||||
|
"出差人": "从文本中提取或询问",
|
||||||
|
"出差事由": "从文本中提取或询问",
|
||||||
|
"出发日期": "从文本中提取或询问",
|
||||||
|
"结束日期": "从文本中提取或询问",
|
||||||
|
"出发地": "从文本中提取或询问",
|
||||||
|
"目的地": "从文本中提取或询问",
|
||||||
|
"出行方式": "从文本中提取或询问",
|
||||||
|
"出差天数": "从文本中提取或询问",
|
||||||
|
"事由简写": "从文本中提取或询问,适合文件名使用的简短事由描述"
|
||||||
|
},
|
||||||
|
"expenses": {
|
||||||
|
"住宿": {
|
||||||
|
"金额": 510.0,
|
||||||
|
"单据张数": 1,
|
||||||
|
"说明": "广州市番禺明乐宾馆住宿费"
|
||||||
|
},
|
||||||
|
"交通": {
|
||||||
|
"金额": 2013.77,
|
||||||
|
"单据张数": 5,
|
||||||
|
"说明": "机票1690.00 + 滴滴323.77"
|
||||||
|
},
|
||||||
|
"饮食": {
|
||||||
|
"金额": 0.0,
|
||||||
|
"单据张数": 0,
|
||||||
|
"说明": "未发现餐饮发票"
|
||||||
|
},
|
||||||
|
"其他": {
|
||||||
|
"金额": 0.0,
|
||||||
|
"单据张数": 0,
|
||||||
|
"说明": "无其他费用"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"附件总张数": 6,
|
||||||
|
"报销总额": 2523.77,
|
||||||
|
"预借差旅费": 0.0,
|
||||||
|
"补领金额": 2523.77,
|
||||||
|
"退还金额": 0.0
|
||||||
|
},
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"文件": "住宿.pdf",
|
||||||
|
"类别": "住宿",
|
||||||
|
"金额": 510.0,
|
||||||
|
"依据": "价税合计(小写)¥510.00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"文件": "机票预订出行订单尾号87377629-电子普通发票.pdf",
|
||||||
|
"类别": "交通",
|
||||||
|
"金额": 1690.0,
|
||||||
|
"依据": "价税合计(小写)¥1690.00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"questions": []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_output_dir():
|
||||||
|
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def command_extract(_args):
|
||||||
|
ensure_output_dir()
|
||||||
|
pdf_files = sorted(Path(".").glob("*.pdf"))
|
||||||
|
if not pdf_files:
|
||||||
|
raise SystemExit("当前目录未找到 PDF 文件。")
|
||||||
|
|
||||||
|
with open(RAW_TEXT_FILE, "w", encoding="utf-8") as output:
|
||||||
|
output.write("=" * 60 + "\n")
|
||||||
|
output.write("PDF 原始内容汇总\n")
|
||||||
|
output.write("=" * 60 + "\n\n")
|
||||||
|
|
||||||
|
for pdf_path in pdf_files:
|
||||||
|
output.write(f"\n{'=' * 60}\n")
|
||||||
|
output.write(f"文件: {pdf_path.name}\n")
|
||||||
|
output.write("=" * 60 + "\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pdfplumber.open(pdf_path) as pdf:
|
||||||
|
for page_index, page in enumerate(pdf.pages, start=1):
|
||||||
|
text = page.extract_text() or ""
|
||||||
|
output.write(f"\n--- 第 {page_index} 页 ---\n")
|
||||||
|
output.write(text)
|
||||||
|
output.write("\n")
|
||||||
|
except Exception as exc:
|
||||||
|
output.write(f"\n[读取失败: {exc}]\n")
|
||||||
|
|
||||||
|
print(f"已提取 {len(pdf_files)} 个 PDF 文件到 {RAW_TEXT_FILE}")
|
||||||
|
|
||||||
|
|
||||||
|
def command_schema(args):
|
||||||
|
ensure_output_dir()
|
||||||
|
text = json.dumps(SCHEMA_EXAMPLE, ensure_ascii=False, indent=2)
|
||||||
|
if args.output:
|
||||||
|
schema_path = OUTPUT_DIR / "schema_example.json"
|
||||||
|
schema_path.write_text(text + "\n", encoding="utf-8")
|
||||||
|
print(f"已写入 {schema_path}")
|
||||||
|
else:
|
||||||
|
print(text)
|
||||||
|
|
||||||
|
|
||||||
|
def command_fill(args):
|
||||||
|
result = load_json(args.json_path)
|
||||||
|
validate_result(result)
|
||||||
|
ensure_fill_dependencies()
|
||||||
|
|
||||||
|
output_file = fill_template(result)
|
||||||
|
verify_output(output_file, result)
|
||||||
|
print(f"完成:{output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(json_path):
|
||||||
|
path = Path(json_path)
|
||||||
|
if not path.exists():
|
||||||
|
raise SystemExit(f"找不到 JSON 文件:{json_path}")
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
try:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise SystemExit(f"JSON 格式错误:{exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_result(result):
|
||||||
|
require_keys(result, ["meta", "expenses", "summary"], "根对象")
|
||||||
|
|
||||||
|
meta = result["meta"]
|
||||||
|
expenses = result["expenses"]
|
||||||
|
summary = result["summary"]
|
||||||
|
|
||||||
|
require_keys(meta, [
|
||||||
|
"出差人", "出差事由", "出发日期", "结束日期", "出发地", "目的地",
|
||||||
|
"出行方式", "出差天数", "事由简写"
|
||||||
|
], "meta")
|
||||||
|
|
||||||
|
require_keys(expenses, ["住宿", "交通", "饮食", "其他"], "expenses")
|
||||||
|
for category in ["住宿", "交通", "饮食", "其他"]:
|
||||||
|
require_keys(expenses[category], ["金额", "单据张数", "说明"], f"expenses.{category}")
|
||||||
|
require_number(expenses[category]["金额"], f"expenses.{category}.金额")
|
||||||
|
require_integer(expenses[category]["单据张数"], f"expenses.{category}.单据张数")
|
||||||
|
|
||||||
|
require_keys(summary, ["附件总张数", "报销总额", "预借差旅费", "补领金额", "退还金额"], "summary")
|
||||||
|
|
||||||
|
expense_total = sum(float(expenses[c]["金额"]) for c in ["住宿", "交通", "饮食", "其他"])
|
||||||
|
if abs(float(summary["报销总额"]) - expense_total) > 0.01:
|
||||||
|
raise SystemExit(
|
||||||
|
f"报销总额不一致:summary.报销总额={summary['报销总额']},"
|
||||||
|
f"费用合计={expense_total:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt_total = sum(int(expenses[c]["单据张数"]) for c in ["住宿", "交通", "饮食", "其他"])
|
||||||
|
if int(summary["附件总张数"]) != receipt_total:
|
||||||
|
raise SystemExit(
|
||||||
|
f"附件总张数不一致:summary.附件总张数={summary['附件总张数']},"
|
||||||
|
f"分类单据合计={receipt_total}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_keys(obj, keys, label):
|
||||||
|
missing = [key for key in keys if key not in obj]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(f"{label} 缺少字段:{', '.join(missing)}")
|
||||||
|
|
||||||
|
|
||||||
|
def require_number(value, label):
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
raise SystemExit(f"{label} 必须是数字,当前为:{value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def require_integer(value, label):
|
||||||
|
if not isinstance(value, int):
|
||||||
|
raise SystemExit(f"{label} 必须是整数,当前为:{value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_fill_dependencies():
|
||||||
|
if not TEMPLATE_FILE.exists():
|
||||||
|
raise SystemExit(
|
||||||
|
"找不到模板文件:差旅费明细表.xlsx\n"
|
||||||
|
"请将 Excel 模板另存为 .xlsx 后放到当前目录。"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import openpyxl # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
raise SystemExit("缺少 openpyxl,请运行:python -m pip install openpyxl")
|
||||||
|
|
||||||
|
|
||||||
|
def fill_template(result):
|
||||||
|
ensure_output_dir()
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
meta = result["meta"]
|
||||||
|
expenses = result["expenses"]
|
||||||
|
output_file = OUTPUT_DIR / build_output_filename(meta)
|
||||||
|
|
||||||
|
shutil.copy(TEMPLATE_FILE, output_file)
|
||||||
|
wb = openpyxl.load_workbook(output_file)
|
||||||
|
ws = wb.active
|
||||||
|
|
||||||
|
ws["E3"] = meta["出差事由"]
|
||||||
|
ws["A6"] = f'{meta["出发日期"]}-{meta["结束日期"]}'
|
||||||
|
ws["B6"] = f'{meta["出发地"]}-{meta["目的地"]}'
|
||||||
|
ws["C6"] = f'{meta["出差天数"]}天'
|
||||||
|
ws["D6"] = meta["出行方式"]
|
||||||
|
|
||||||
|
ws["H6"] = expenses["住宿"]["单据张数"]
|
||||||
|
ws["I6"] = expenses["住宿"]["金额"]
|
||||||
|
ws["H7"] = expenses["饮食"]["单据张数"]
|
||||||
|
ws["I7"] = expenses["饮食"]["金额"]
|
||||||
|
ws["H8"] = expenses["交通"]["单据张数"]
|
||||||
|
ws["I8"] = expenses["交通"]["金额"]
|
||||||
|
ws["H9"] = expenses["其他"]["单据张数"]
|
||||||
|
ws["I9"] = expenses["其他"]["金额"]
|
||||||
|
|
||||||
|
wb.save(output_file)
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
|
||||||
|
def build_output_filename(meta):
|
||||||
|
date_str = normalize_date_for_filename(meta["出发日期"])
|
||||||
|
safe_parts = [
|
||||||
|
"差旅费明细表",
|
||||||
|
sanitize_filename(meta["出差人"]),
|
||||||
|
date_str,
|
||||||
|
sanitize_filename(meta["目的地"]),
|
||||||
|
sanitize_filename(meta["事由简写"]),
|
||||||
|
]
|
||||||
|
return "_".join(safe_parts) + ".xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_date_for_filename(date_text):
|
||||||
|
parts = re.split(r"[/-]", str(date_text))
|
||||||
|
if len(parts) != 3:
|
||||||
|
return sanitize_filename(str(date_text))
|
||||||
|
return f"{int(parts[0])}{int(parts[1]):02d}{int(parts[2]):02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(text):
|
||||||
|
text = str(text).strip()
|
||||||
|
text = re.sub(r"[\\/:*?\"<>|\s]+", "", text)
|
||||||
|
return text or "未填写"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_output(output_file, result):
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(output_file, data_only=False)
|
||||||
|
ws = wb.active
|
||||||
|
meta = result["meta"]
|
||||||
|
|
||||||
|
expected_route = f'{meta["出发地"]}-{meta["目的地"]}'
|
||||||
|
expected_date = f'{meta["出发日期"]}-{meta["结束日期"]}'
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
(ws["E3"].value, meta["出差事由"], "出差事由"),
|
||||||
|
(ws["A6"].value, expected_date, "日期"),
|
||||||
|
(ws["B6"].value, expected_route, "起止地点"),
|
||||||
|
]
|
||||||
|
for actual, expected, label in checks:
|
||||||
|
if actual != expected:
|
||||||
|
raise SystemExit(f"输出校验失败:{label} 实际={actual!r} 预期={expected!r}")
|
||||||
|
|
||||||
|
formula_checks = {
|
||||||
|
"H2": "附件张数合计",
|
||||||
|
"F9": "天数合计",
|
||||||
|
"C10": "报销总额",
|
||||||
|
"H10": "补领金额",
|
||||||
|
}
|
||||||
|
for cell, label in formula_checks.items():
|
||||||
|
value = ws[cell].value
|
||||||
|
if not isinstance(value, str) or not value.startswith("="):
|
||||||
|
raise SystemExit(f"输出校验失败:{label} 单元格 {cell} 公式丢失,当前值={value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
parser = argparse.ArgumentParser(description="差旅报销自动化脚本")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
extract_parser = subparsers.add_parser("extract", help="提取当前目录所有 PDF 原文到 报销输出/pdf_raw_text.txt")
|
||||||
|
extract_parser.set_defaults(func=command_extract)
|
||||||
|
|
||||||
|
schema_parser = subparsers.add_parser("schema", help="输出大模型需要生成的 JSON schema 示例")
|
||||||
|
schema_parser.add_argument("--output", action="store_true", help="写入 报销输出/schema_example.json,而不是打印到终端")
|
||||||
|
schema_parser.set_defaults(func=command_schema)
|
||||||
|
|
||||||
|
fill_parser = subparsers.add_parser("fill", help="读取 JSON 并填入差旅费明细表模板")
|
||||||
|
fill_parser.add_argument("json_path", help="大模型生成的 analyzed_result.json 路径")
|
||||||
|
fill_parser.set_defaults(func=command_fill)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
差旅费明细表.xlsx
Normal file
BIN
差旅费明细表.xlsx
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user