diff --git a/.gitignore b/.gitignore index 42d9aec..761e59f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ *.pdf *.jpg -报销输出/ \ No newline at end of file +*.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]_*/ diff --git a/README.md b/README.md index 0eaf7ca..bbf9400 100644 --- a/README.md +++ b/README.md @@ -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,39 +112,55 @@ 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 报销目录/ -└── 报销输出/ - ├── pdf_raw_text.txt - ├── analyzed_result.json - ├── schema_example.json # 可选 - └── 差旅费明细表_xxxx_20260520_广州_售后维修.xlsx +└── 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 + └── 差旅费明细表_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 ``` --- diff --git a/差旅报销.py b/差旅报销.py index d3019f5..306c3cc 100644 --- a/差旅报销.py +++ b/差旅报销.py @@ -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)