保存差旅报销自动化基础文件

仅保存说明、脚本和 Excel 模板,示例中的个人和公司信息已匿名化。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
copper-xw 2026-05-28 19:28:23 +08:00
commit 13bf1a0146
3 changed files with 795 additions and 0 deletions

467
README.md Normal file
View 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
View 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

Binary file not shown.