完善差旅报销归档流程

将附件整理到本次处理目录,并在填表后按行程信息归档,避免过程文件和报销附件污染根目录。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
copper-xw 2026-05-29 11:50:12 +08:00
parent 4e869ba4b3
commit be21f0ab5a
3 changed files with 215 additions and 65 deletions

9
.gitignore vendored
View File

@ -1,3 +1,12 @@
*.pdf
*.jpg
*.jpeg
*.png
*.txt
*.json
*.xlsx
报销输出/
__pycache__/
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]/
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-*/
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]_*/

View File

@ -5,8 +5,8 @@
当用户把本 `README.md` 提供给大模型,或让大模型在本目录处理差旅报销时,大模型应直接按下面流程开始执行,不需要用户再复制额外提示词:
1. 先阅读本说明,确认当前目录是报销目录。
2. 检查当前目录是否存在基础文件:`差旅报销.py`、`差旅费明细表.xlsx`、本次报销 PDF。
3. 如果 `报销输出/pdf_raw_text.txt` 不存在,自动运行:
2. 检查当前目录是否存在基础文件:`差旅报销.py`、`差旅费明细表.xlsx`,以及用户放在根目录的本次报销 PDF/图片附件。
3. 自动运行 `extract`,脚本会先把根目录的 PDF/JPG/JPEG/PNG 移动到处理当天目录(如 `20260529/`),再提取 PDF 原文:
```bash
python3 差旅报销.py extract
@ -18,23 +18,23 @@
py 差旅报销.py extract
```
4. 读取 `报销输出/pdf_raw_text.txt`,按本文的 JSON Schema 分析出差人、日期、事由、费用分类、金额和证据。
4. 读取本次处理目录中的 `报销输出/pdf_raw_text.txt`,按本文的 JSON Schema 分析出差人、日期、事由、费用分类、金额和证据。
5. 如果同一笔滴滴/打车费用同时有行程单和电子发票,金额只按电子发票计入一次;行程单只作为附件和行程明细使用,不要与发票重复计入金额。
6. 如果出差人、出差事由、出发地、目的地、日期、金额等关键信息无法从原文可靠判断,必须先问用户;不要编造。
7. 将最终 JSON 保存为 `报销输出/analyzed_result.json`。
7. 将最终 JSON 保存为本次处理目录中的 `报销输出/analyzed_result.json`。
8. 确认 `questions` 为空、金额合计一致、附件张数一致后,自动运行:
```bash
python3 差旅报销.py fill 报销输出/analyzed_result.json
python3 差旅报销.py fill 20260529/报销输出/analyzed_result.json
```
Windows 环境可改用:
```powershell
py 差旅报销.py fill 报销输出/analyzed_result.json
py 差旅报销.py fill 20260529/报销输出/analyzed_result.json
```
9. 最后告诉用户生成的 Excel 文件路径,以及是否有任何需要人工复核的证据或假设;如果存在行程单和电子发票对应同一笔费用,必须明确提醒用户:行程单未重复计入金额,只作为附件和行程明细使用。
9. 最后告诉用户生成的 Excel 文件路径和本次归档目录,以及是否有任何需要人工复核的证据或假设;如果存在行程单和电子发票对应同一笔费用,必须明确提醒用户:行程单未重复计入金额,只作为附件和行程明细使用。
> 简单说:看到本 README 后,大模型应自动完成“提取 PDF 原文 → 分析生成 JSON → 填写 Excel”,只有在信息不确定时才暂停询问用户。
@ -99,7 +99,7 @@ py -m pip install pdfplumber openpyxl
```powershell
py 差旅报销.py extract
py 差旅报销.py fill 报销输出/analyzed_result.json
py 差旅报销.py fill 20260529/报销输出/analyzed_result.json
```
### Linux / macOS
@ -112,38 +112,54 @@ python3 -m pip install pdfplumber openpyxl
```bash
python3 差旅报销.py extract
python3 差旅报销.py fill 报销输出/analyzed_result.json
python3 差旅报销.py fill 20260529/报销输出/analyzed_result.json
```
---
## 标准目录结构
每次开始处理前,根目录只需要保留基础文件,并把本次报销附件直接放到根目录:
```text
报销目录/
├── 差旅费明细表.xlsx # 必需:模板
├── 差旅报销.py # 必需:脚本
├── README.md # 必需:说明
├── 住宿.pdf # 本次报销 PDF
├── 机票*.pdf # 本次报销 PDF
├── 滴滴出行行程报销单*.pdf # 本次报销 PDF
└── 滴滴电子发票*.pdf # 本次报销 PDF
├── 住宿.pdf # 本次报销 PDF,运行 extract 后会被移动
├── 机票*.pdf # 本次报销 PDF,运行 extract 后会被移动
├── 滴滴出行行程报销单*.pdf # 本次报销 PDF,运行 extract 后会被移动
├── 滴滴电子发票*.pdf # 本次报销 PDF,运行 extract 后会被移动
└── 打车截图.jpg # 本次报销图片附件,运行 extract 后会被移动
```
脚本输出统一放到:
```text
报销输出/
```
运行后会形成:
运行 `extract` 后会先形成处理当天目录,例如:
```text
报销目录/
└── 报销输出/
└── 20260529/
├── 住宿.pdf
├── 机票*.pdf
├── 滴滴出行行程报销单*.pdf
├── 滴滴电子发票*.pdf
├── 打车截图.jpg
└── 报销输出/
└── pdf_raw_text.txt
```
运行 `fill` 成功后,会把当天目录重命名为本次归档目录,并保留 `报销输出/` 结构:
```text
报销目录/
└── 20260520-20260522_北京-广州_售后维修/
├── 住宿.pdf
├── 机票*.pdf
├── 滴滴出行行程报销单*.pdf
├── 滴滴电子发票*.pdf
├── 打车截图.jpg
└── 报销输出/
├── pdf_raw_text.txt
├── analyzed_result.json
├── schema_example.json # 可选
└── 差旅费明细表_xxxx_20260520_广州_售后维修.xlsx
```
@ -163,23 +179,25 @@ Windows 可用:
py 差旅报销.py extract
```
输出:
运行时会把根目录的 PDF/JPG/JPEG/PNG 移动到处理当天目录,例如 `20260529/`。如果当天目录已存在且已有附件,脚本会询问是否合并;不合并会创建 `20260529-1/`、`20260529-2/` 这样的新目录。
输出位于本次处理目录中,例如:
```text
报销输出/pdf_raw_text.txt
20260529/报销输出/pdf_raw_text.txt
```
这个文件按 PDF 文件分块保存原始文本,例如:
```text
============================================================
文件: 住宿.pdf
文件: 20260529/住宿.pdf
============================================================
电子发票(增值税专用发票)
...
============================================================
文件: 滴滴出行行程报销单A.pdf
文件: 20260529/滴滴出行行程报销单A.pdf
============================================================
滴滴出行-行程单
...
@ -191,10 +209,10 @@ py 差旅报销.py extract
### Step 2:让大模型分析 TXT 并输出 JSON
把 `报销输出/pdf_raw_text.txt` 交给大模型,让大模型生成:
把本次处理目录中的 `报销输出/pdf_raw_text.txt` 交给大模型,让大模型生成同目录下的 JSON,例如:
```text
报销输出/analyzed_result.json
20260529/报销输出/analyzed_result.json
```
可以先查看 JSON 模板:
@ -221,17 +239,17 @@ python3 差旅报销.py schema --output
请根据本 README 自动处理当前目录的差旅报销。
你的任务:
1. 如果 `报销输出/pdf_raw_text.txt` 不存在,先运行 `python3 差旅报销.py extract` 提取 PDF 原文。
2. 读取 `报销输出/pdf_raw_text.txt`,从 PDF 原文中识别出差人、出差事由、出发日期、结束日期、出发地、目的地、出行方式、出差天数。
1. 先运行 `python3 差旅报销.py extract`,脚本会把根目录 PDF/JPG/JPEG/PNG 移动到本次处理目录,并提取 PDF 原文。
2. 读取 extract 输出提示中的 `报销输出/pdf_raw_text.txt`,从 PDF 原文中识别出差人、出差事由、出发日期、结束日期、出发地、目的地、出行方式、出差天数。
3. 将费用分为:住宿、交通、饮食、其他。
4. 每个费用类别都要给出金额、单据张数、说明。
5. 计算附件总张数、报销总额、预借差旅费、补领金额、退还金额。
6. 每个金额必须写入 evidence,说明来自哪个文件、依据是哪段文本。
7. 如果同一笔滴滴/打车费用同时有行程单和电子发票,金额只按电子发票计入一次;行程单只作为附件和行程明细使用,不要与发票重复计入金额。
8. 如果某个字段无法可靠判断,必须先问用户,不要编造;在未确认前不要填表。
9. 将最终 JSON 保存为 `报销输出/analyzed_result.json`,JSON 必须符合 README 中的 schema。
10. 确认 `questions` 为空、金额合计一致、附件张数一致后,运行 `python3 差旅报销.py fill 报销输出/analyzed_result.json` 生成最终 Excel。
11. 最后告诉用户生成的 Excel 文件路径,并列出需要人工复核的证据或假设;如果存在行程单和电子发票对应同一笔费用,必须明确提醒用户:行程单未重复计入金额,只作为附件和行程明细使用。
9. 将最终 JSON 保存到本次处理目录中的 `报销输出/analyzed_result.json`,JSON 必须符合 README 中的 schema。
10. 确认 `questions` 为空、金额合计一致、附件张数一致后,运行 `python3 差旅报销.py fill 本次处理目录/报销输出/analyzed_result.json` 生成最终 Excel,并把本次目录重命名为 `出发日期-结束日期_出发地-目的地_事由简写` 格式。
11. 最后告诉用户生成的 Excel 文件路径和本次归档目录,并列出需要人工复核的证据或假设;如果存在行程单和电子发票对应同一笔费用,必须明确提醒用户:行程单未重复计入金额,只作为附件和行程明细使用。
```
---
@ -322,13 +340,13 @@ python3 差旅报销.py schema --output
## Step 3:读取 JSON 并填表
```bash
python3 差旅报销.py fill 报销输出/analyzed_result.json
python3 差旅报销.py fill 20260529/报销输出/analyzed_result.json
```
Windows 可用:
```powershell
py 差旅报销.py fill 报销输出/analyzed_result.json
py 差旅报销.py fill 20260529/报销输出/analyzed_result.json
```
脚本会:
@ -338,7 +356,8 @@ py 差旅报销.py fill 报销输出/analyzed_result.json
3. 校验附件张数是否一致
4. 复制 `差旅费明细表.xlsx`
5. 用 openpyxl 填入固定单元格
6. 输出最终 XLSX 到 `报销输出/`
6. 输出最终 XLSX 到本次处理目录的 `报销输出/`
7. 将本次处理目录重命名为 `出发日期-结束日期_出发地-目的地_事由简写` 格式
输出文件命名规则:
@ -349,7 +368,7 @@ py 差旅报销.py fill 报销输出/analyzed_result.json
示例:
```text
报销输出/差旅费明细表_xxxx_20260520_广州_售后维修.xlsx
20260520-20260522_北京-广州_售后维修/报销输出/差旅费明细表_xxxx_20260520_广州_售后维修.xlsx
```
---

View File

@ -3,8 +3,8 @@
差旅报销自动化脚本
Python 只做两类确定性工作:
1. extract:提取 PDF 原文到 TXT
2. fill:读取大模型生成的 JSON,填入 Excel 模板
1. extract:整理输入附件,提取 PDF 原文到本次归档目录的 TXT
2. fill:读取大模型生成的 JSON,填入 Excel 模板,并完成本次归档目录命名
中间的发票理解、金额分类、日期/事由/人员判断全部由大模型完成。
模板使用 .xlsx,完全依赖 Python 包实现跨平台,不依赖 LibreOffice。
@ -14,14 +14,17 @@ import argparse
import json
import re
import shutil
from datetime import date
from pathlib import Path
import pdfplumber
OUTPUT_DIR = Path("报销输出")
RAW_TEXT_FILE = OUTPUT_DIR / "pdf_raw_text.txt"
OUTPUT_DIR_NAME = "报销输出"
RAW_TEXT_FILENAME = "pdf_raw_text.txt"
ANALYZED_RESULT_FILENAME = "analyzed_result.json"
TEMPLATE_FILE = Path("差旅费明细表.xlsx")
ATTACHMENT_SUFFIXES = {".pdf", ".jpg", ".jpeg", ".png"}
SCHEMA_EXAMPLE = {
@ -83,24 +86,24 @@ SCHEMA_EXAMPLE = {
}
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 文件。")
archive_dir = prepare_archive_dir()
output_dir = archive_dir / OUTPUT_DIR_NAME
output_dir.mkdir(exist_ok=True)
raw_text_file = output_dir / RAW_TEXT_FILENAME
with open(RAW_TEXT_FILE, "w", encoding="utf-8") as output:
pdf_files = sorted(archive_dir.glob("*.pdf"))
if not pdf_files:
raise SystemExit(f"{archive_dir} 中未找到 PDF 文件。请把 PDF 放到根目录后重新运行 extract。")
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(f"文件: {pdf_path}\n")
output.write("=" * 60 + "\n")
try:
@ -113,14 +116,93 @@ def command_extract(_args):
except Exception as exc:
output.write(f"\n[读取失败: {exc}]\n")
print(f"已提取 {len(pdf_files)} 个 PDF 文件到 {RAW_TEXT_FILE}")
print(f"本次附件目录:{archive_dir}")
print(f"已提取 {len(pdf_files)} 个 PDF 文件到 {raw_text_file}")
print(f"请将分析结果保存为:{output_dir / ANALYZED_RESULT_FILENAME}")
def prepare_archive_dir():
root_attachments = find_root_attachments()
today_dir = Path(date.today().strftime("%Y%m%d"))
if root_attachments:
archive_dir = choose_archive_dir(today_dir)
archive_dir.mkdir(exist_ok=True)
for attachment in root_attachments:
target = unique_file_path(archive_dir / attachment.name)
shutil.move(str(attachment), target)
print(f"已移动 {len(root_attachments)} 个输入文件到 {archive_dir}")
return archive_dir
existing_today_dir = find_existing_today_dir(today_dir)
if existing_today_dir:
return existing_today_dir
raise SystemExit("当前根目录未找到 PDF/JPG/JPEG/PNG 文件。请把本次发票、行程单或截图放到根目录后再运行 extract。")
def find_root_attachments():
return sorted(
path for path in Path(".").iterdir()
if path.is_file() and path.suffix.lower() in ATTACHMENT_SUFFIXES
)
def choose_archive_dir(today_dir):
if not today_dir.exists():
return today_dir
if has_attachments(today_dir):
answer = input(f"{today_dir} 已存在且包含附件,是否合并本次根目录附件?[y/N] ").strip().lower()
if answer in {"y", "yes"}:
return today_dir
return next_numbered_dir(today_dir)
return today_dir
def find_existing_today_dir(today_dir):
candidates = [today_dir] + sorted(Path(".").glob(f"{today_dir.name}-*"))
candidates = [path for path in candidates if path.is_dir() and list(path.glob("*.pdf"))]
if not candidates:
return None
return candidates[-1]
def has_attachments(directory):
return any(
path.is_file() and path.suffix.lower() in ATTACHMENT_SUFFIXES
for path in directory.iterdir()
)
def next_numbered_dir(base_dir):
index = 1
while True:
candidate = Path(f"{base_dir.name}-{index}")
if not candidate.exists():
return candidate
index += 1
def unique_file_path(path):
if not path.exists():
return path
index = 1
while True:
candidate = path.with_name(f"{path.stem}-{index}{path.suffix}")
if not candidate.exists():
return candidate
index += 1
def command_schema(args):
ensure_output_dir()
output_dir = Path(OUTPUT_DIR_NAME)
output_dir.mkdir(exist_ok=True)
text = json.dumps(SCHEMA_EXAMPLE, ensure_ascii=False, indent=2)
if args.output:
schema_path = OUTPUT_DIR / "schema_example.json"
schema_path = output_dir / "schema_example.json"
schema_path.write_text(text + "\n", encoding="utf-8")
print(f"已写入 {schema_path}")
else:
@ -132,9 +214,17 @@ def command_fill(args):
validate_result(result)
ensure_fill_dependencies()
output_file = fill_template(result)
json_path = Path(args.json_path)
output_dir = json_path.parent
archive_dir = output_dir.parent if output_dir.name == OUTPUT_DIR_NAME else Path(".")
output_file = fill_template(result, output_dir)
verify_output(output_file, result)
print(f"完成:{output_file}")
final_archive_dir = finalize_archive_dir(archive_dir, result)
final_output_file = final_archive_dir / OUTPUT_DIR_NAME / output_file.name
print(f"完成:{final_output_file}")
print(f"本次归档目录:{final_archive_dir}")
def load_json(json_path):
@ -213,13 +303,13 @@ def ensure_fill_dependencies():
raise SystemExit("缺少 openpyxl,请运行:python -m pip install openpyxl")
def fill_template(result):
ensure_output_dir()
def fill_template(result, output_dir):
output_dir.mkdir(exist_ok=True)
import openpyxl
meta = result["meta"]
expenses = result["expenses"]
output_file = OUTPUT_DIR / build_output_filename(meta)
output_file = output_dir / build_output_filename(meta)
shutil.copy(TEMPLATE_FILE, output_file)
wb = openpyxl.load_workbook(output_file)
@ -244,6 +334,38 @@ def fill_template(result):
return output_file
def finalize_archive_dir(archive_dir, result):
if archive_dir == Path("."):
return archive_dir
target_dir = unique_archive_dir(archive_dir.parent / build_archive_dir_name(result["meta"]))
if archive_dir.resolve() == target_dir.resolve():
return archive_dir
archive_dir.rename(target_dir)
return target_dir
def unique_archive_dir(path):
if not path.exists():
return path
index = 1
while True:
candidate = path.with_name(f"{path.name}-{index}")
if not candidate.exists():
return candidate
index += 1
def build_archive_dir_name(meta):
start_date = normalize_date_for_filename(meta["出发日期"])
end_date = normalize_date_for_filename(meta["结束日期"])
route = sanitize_filename(f'{meta["出发地"]}-{meta["目的地"]}')
reason = sanitize_filename(meta["事由简写"])
return f"{start_date}-{end_date}_{route}_{reason}"
def build_output_filename(meta):
date_str = normalize_date_for_filename(meta["出发日期"])
safe_parts = [
@ -304,14 +426,14 @@ 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 = subparsers.add_parser("extract", help="整理根目录附件并提取 PDF 原文到本次归档目录")
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 = subparsers.add_parser("fill", help="读取 JSON 并填入差旅费明细表模板,再归档本次目录")
fill_parser.add_argument("json_path", help="大模型生成的 analyzed_result.json 路径")
fill_parser.set_defaults(func=command_fill)