保存差旅报销自动化基础文件
仅保存说明、脚本和 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