commit 13bf1a0146beac707002f7ba63f51734d4c0ed8c Author: copper-xw Date: Thu May 28 19:28:23 2026 +0800 保存差旅报销自动化基础文件 仅保存说明、脚本和 Excel 模板,示例中的个人和公司信息已匿名化。 Co-Authored-By: Claude Opus 4.6 diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbf4ec6 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/差旅报销.py b/差旅报销.py new file mode 100644 index 0000000..d3019f5 --- /dev/null +++ b/差旅报销.py @@ -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() diff --git a/差旅费明细表.xlsx b/差旅费明细表.xlsx new file mode 100644 index 0000000..23ef04f Binary files /dev/null and b/差旅费明细表.xlsx differ