完善差旅报销归档流程
将附件整理到本次处理目录,并在填表后按行程信息归档,避免过程文件和报销附件污染根目录。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4e869ba4b3
commit
be21f0ab5a
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -1,3 +1,12 @@
|
||||||
*.pdf
|
*.pdf
|
||||||
*.jpg
|
*.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]_*/
|
||||||
|
|
|
||||||
93
README.md
93
README.md
|
|
@ -5,8 +5,8 @@
|
||||||
当用户把本 `README.md` 提供给大模型,或让大模型在本目录处理差旅报销时,大模型应直接按下面流程开始执行,不需要用户再复制额外提示词:
|
当用户把本 `README.md` 提供给大模型,或让大模型在本目录处理差旅报销时,大模型应直接按下面流程开始执行,不需要用户再复制额外提示词:
|
||||||
|
|
||||||
1. 先阅读本说明,确认当前目录是报销目录。
|
1. 先阅读本说明,确认当前目录是报销目录。
|
||||||
2. 检查当前目录是否存在基础文件:`差旅报销.py`、`差旅费明细表.xlsx`、本次报销 PDF。
|
2. 检查当前目录是否存在基础文件:`差旅报销.py`、`差旅费明细表.xlsx`,以及用户放在根目录的本次报销 PDF/图片附件。
|
||||||
3. 如果 `报销输出/pdf_raw_text.txt` 不存在,自动运行:
|
3. 自动运行 `extract`,脚本会先把根目录的 PDF/JPG/JPEG/PNG 移动到处理当天目录(如 `20260529/`),再提取 PDF 原文:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 差旅报销.py extract
|
python3 差旅报销.py extract
|
||||||
|
|
@ -18,23 +18,23 @@
|
||||||
py 差旅报销.py extract
|
py 差旅报销.py extract
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 读取 `报销输出/pdf_raw_text.txt`,按本文的 JSON Schema 分析出差人、日期、事由、费用分类、金额和证据。
|
4. 读取本次处理目录中的 `报销输出/pdf_raw_text.txt`,按本文的 JSON Schema 分析出差人、日期、事由、费用分类、金额和证据。
|
||||||
5. 如果同一笔滴滴/打车费用同时有行程单和电子发票,金额只按电子发票计入一次;行程单只作为附件和行程明细使用,不要与发票重复计入金额。
|
5. 如果同一笔滴滴/打车费用同时有行程单和电子发票,金额只按电子发票计入一次;行程单只作为附件和行程明细使用,不要与发票重复计入金额。
|
||||||
6. 如果出差人、出差事由、出发地、目的地、日期、金额等关键信息无法从原文可靠判断,必须先问用户;不要编造。
|
6. 如果出差人、出差事由、出发地、目的地、日期、金额等关键信息无法从原文可靠判断,必须先问用户;不要编造。
|
||||||
7. 将最终 JSON 保存为 `报销输出/analyzed_result.json`。
|
7. 将最终 JSON 保存为本次处理目录中的 `报销输出/analyzed_result.json`。
|
||||||
8. 确认 `questions` 为空、金额合计一致、附件张数一致后,自动运行:
|
8. 确认 `questions` 为空、金额合计一致、附件张数一致后,自动运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 差旅报销.py fill 报销输出/analyzed_result.json
|
python3 差旅报销.py fill 20260529/报销输出/analyzed_result.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows 环境可改用:
|
Windows 环境可改用:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
py 差旅报销.py fill 报销输出/analyzed_result.json
|
py 差旅报销.py fill 20260529/报销输出/analyzed_result.json
|
||||||
```
|
```
|
||||||
|
|
||||||
9. 最后告诉用户生成的 Excel 文件路径,以及是否有任何需要人工复核的证据或假设;如果存在行程单和电子发票对应同一笔费用,必须明确提醒用户:行程单未重复计入金额,只作为附件和行程明细使用。
|
9. 最后告诉用户生成的 Excel 文件路径和本次归档目录,以及是否有任何需要人工复核的证据或假设;如果存在行程单和电子发票对应同一笔费用,必须明确提醒用户:行程单未重复计入金额,只作为附件和行程明细使用。
|
||||||
|
|
||||||
> 简单说:看到本 README 后,大模型应自动完成“提取 PDF 原文 → 分析生成 JSON → 填写 Excel”,只有在信息不确定时才暂停询问用户。
|
> 简单说:看到本 README 后,大模型应自动完成“提取 PDF 原文 → 分析生成 JSON → 填写 Excel”,只有在信息不确定时才暂停询问用户。
|
||||||
|
|
||||||
|
|
@ -99,7 +99,7 @@ py -m pip install pdfplumber openpyxl
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
py 差旅报销.py extract
|
py 差旅报销.py extract
|
||||||
py 差旅报销.py fill 报销输出/analyzed_result.json
|
py 差旅报销.py fill 20260529/报销输出/analyzed_result.json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Linux / macOS
|
### Linux / macOS
|
||||||
|
|
@ -112,38 +112,54 @@ python3 -m pip install pdfplumber openpyxl
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 差旅报销.py extract
|
python3 差旅报销.py extract
|
||||||
python3 差旅报销.py fill 报销输出/analyzed_result.json
|
python3 差旅报销.py fill 20260529/报销输出/analyzed_result.json
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 标准目录结构
|
## 标准目录结构
|
||||||
|
|
||||||
|
每次开始处理前,根目录只需要保留基础文件,并把本次报销附件直接放到根目录:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
报销目录/
|
报销目录/
|
||||||
├── 差旅费明细表.xlsx # 必需:模板
|
├── 差旅费明细表.xlsx # 必需:模板
|
||||||
├── 差旅报销.py # 必需:脚本
|
├── 差旅报销.py # 必需:脚本
|
||||||
├── README.md # 必需:说明
|
├── README.md # 必需:说明
|
||||||
├── 住宿.pdf # 本次报销 PDF
|
├── 住宿.pdf # 本次报销 PDF,运行 extract 后会被移动
|
||||||
├── 机票*.pdf # 本次报销 PDF
|
├── 机票*.pdf # 本次报销 PDF,运行 extract 后会被移动
|
||||||
├── 滴滴出行行程报销单*.pdf # 本次报销 PDF
|
├── 滴滴出行行程报销单*.pdf # 本次报销 PDF,运行 extract 后会被移动
|
||||||
└── 滴滴电子发票*.pdf # 本次报销 PDF
|
├── 滴滴电子发票*.pdf # 本次报销 PDF,运行 extract 后会被移动
|
||||||
|
└── 打车截图.jpg # 本次报销图片附件,运行 extract 后会被移动
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本输出统一放到:
|
运行 `extract` 后会先形成处理当天目录,例如:
|
||||||
|
|
||||||
```text
|
|
||||||
报销输出/
|
|
||||||
```
|
|
||||||
|
|
||||||
运行后会形成:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
报销目录/
|
报销目录/
|
||||||
└── 报销输出/
|
└── 20260529/
|
||||||
|
├── 住宿.pdf
|
||||||
|
├── 机票*.pdf
|
||||||
|
├── 滴滴出行行程报销单*.pdf
|
||||||
|
├── 滴滴电子发票*.pdf
|
||||||
|
├── 打车截图.jpg
|
||||||
|
└── 报销输出/
|
||||||
|
└── pdf_raw_text.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
运行 `fill` 成功后,会把当天目录重命名为本次归档目录,并保留 `报销输出/` 结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
报销目录/
|
||||||
|
└── 20260520-20260522_北京-广州_售后维修/
|
||||||
|
├── 住宿.pdf
|
||||||
|
├── 机票*.pdf
|
||||||
|
├── 滴滴出行行程报销单*.pdf
|
||||||
|
├── 滴滴电子发票*.pdf
|
||||||
|
├── 打车截图.jpg
|
||||||
|
└── 报销输出/
|
||||||
├── pdf_raw_text.txt
|
├── pdf_raw_text.txt
|
||||||
├── analyzed_result.json
|
├── analyzed_result.json
|
||||||
├── schema_example.json # 可选
|
|
||||||
└── 差旅费明细表_xxxx_20260520_广州_售后维修.xlsx
|
└── 差旅费明细表_xxxx_20260520_广州_售后维修.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -163,23 +179,25 @@ Windows 可用:
|
||||||
py 差旅报销.py extract
|
py 差旅报销.py extract
|
||||||
```
|
```
|
||||||
|
|
||||||
输出:
|
运行时会把根目录的 PDF/JPG/JPEG/PNG 移动到处理当天目录,例如 `20260529/`。如果当天目录已存在且已有附件,脚本会询问是否合并;不合并会创建 `20260529-1/`、`20260529-2/` 这样的新目录。
|
||||||
|
|
||||||
|
输出位于本次处理目录中,例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
报销输出/pdf_raw_text.txt
|
20260529/报销输出/pdf_raw_text.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
这个文件按 PDF 文件分块保存原始文本,例如:
|
这个文件按 PDF 文件分块保存原始文本,例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
============================================================
|
============================================================
|
||||||
文件: 住宿.pdf
|
文件: 20260529/住宿.pdf
|
||||||
============================================================
|
============================================================
|
||||||
电子发票(增值税专用发票)
|
电子发票(增值税专用发票)
|
||||||
...
|
...
|
||||||
|
|
||||||
============================================================
|
============================================================
|
||||||
文件: 滴滴出行行程报销单A.pdf
|
文件: 20260529/滴滴出行行程报销单A.pdf
|
||||||
============================================================
|
============================================================
|
||||||
滴滴出行-行程单
|
滴滴出行-行程单
|
||||||
...
|
...
|
||||||
|
|
@ -191,10 +209,10 @@ py 差旅报销.py extract
|
||||||
|
|
||||||
### Step 2:让大模型分析 TXT 并输出 JSON
|
### Step 2:让大模型分析 TXT 并输出 JSON
|
||||||
|
|
||||||
把 `报销输出/pdf_raw_text.txt` 交给大模型,让大模型生成:
|
把本次处理目录中的 `报销输出/pdf_raw_text.txt` 交给大模型,让大模型生成同目录下的 JSON,例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
报销输出/analyzed_result.json
|
20260529/报销输出/analyzed_result.json
|
||||||
```
|
```
|
||||||
|
|
||||||
可以先查看 JSON 模板:
|
可以先查看 JSON 模板:
|
||||||
|
|
@ -221,17 +239,17 @@ python3 差旅报销.py schema --output
|
||||||
请根据本 README 自动处理当前目录的差旅报销。
|
请根据本 README 自动处理当前目录的差旅报销。
|
||||||
|
|
||||||
你的任务:
|
你的任务:
|
||||||
1. 如果 `报销输出/pdf_raw_text.txt` 不存在,先运行 `python3 差旅报销.py extract` 提取 PDF 原文。
|
1. 先运行 `python3 差旅报销.py extract`,脚本会把根目录 PDF/JPG/JPEG/PNG 移动到本次处理目录,并提取 PDF 原文。
|
||||||
2. 读取 `报销输出/pdf_raw_text.txt`,从 PDF 原文中识别出差人、出差事由、出发日期、结束日期、出发地、目的地、出行方式、出差天数。
|
2. 读取 extract 输出提示中的 `报销输出/pdf_raw_text.txt`,从 PDF 原文中识别出差人、出差事由、出发日期、结束日期、出发地、目的地、出行方式、出差天数。
|
||||||
3. 将费用分为:住宿、交通、饮食、其他。
|
3. 将费用分为:住宿、交通、饮食、其他。
|
||||||
4. 每个费用类别都要给出金额、单据张数、说明。
|
4. 每个费用类别都要给出金额、单据张数、说明。
|
||||||
5. 计算附件总张数、报销总额、预借差旅费、补领金额、退还金额。
|
5. 计算附件总张数、报销总额、预借差旅费、补领金额、退还金额。
|
||||||
6. 每个金额必须写入 evidence,说明来自哪个文件、依据是哪段文本。
|
6. 每个金额必须写入 evidence,说明来自哪个文件、依据是哪段文本。
|
||||||
7. 如果同一笔滴滴/打车费用同时有行程单和电子发票,金额只按电子发票计入一次;行程单只作为附件和行程明细使用,不要与发票重复计入金额。
|
7. 如果同一笔滴滴/打车费用同时有行程单和电子发票,金额只按电子发票计入一次;行程单只作为附件和行程明细使用,不要与发票重复计入金额。
|
||||||
8. 如果某个字段无法可靠判断,必须先问用户,不要编造;在未确认前不要填表。
|
8. 如果某个字段无法可靠判断,必须先问用户,不要编造;在未确认前不要填表。
|
||||||
9. 将最终 JSON 保存为 `报销输出/analyzed_result.json`,JSON 必须符合 README 中的 schema。
|
9. 将最终 JSON 保存到本次处理目录中的 `报销输出/analyzed_result.json`,JSON 必须符合 README 中的 schema。
|
||||||
10. 确认 `questions` 为空、金额合计一致、附件张数一致后,运行 `python3 差旅报销.py fill 报销输出/analyzed_result.json` 生成最终 Excel。
|
10. 确认 `questions` 为空、金额合计一致、附件张数一致后,运行 `python3 差旅报销.py fill 本次处理目录/报销输出/analyzed_result.json` 生成最终 Excel,并把本次目录重命名为 `出发日期-结束日期_出发地-目的地_事由简写` 格式。
|
||||||
11. 最后告诉用户生成的 Excel 文件路径,并列出需要人工复核的证据或假设;如果存在行程单和电子发票对应同一笔费用,必须明确提醒用户:行程单未重复计入金额,只作为附件和行程明细使用。
|
11. 最后告诉用户生成的 Excel 文件路径和本次归档目录,并列出需要人工复核的证据或假设;如果存在行程单和电子发票对应同一笔费用,必须明确提醒用户:行程单未重复计入金额,只作为附件和行程明细使用。
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -322,13 +340,13 @@ python3 差旅报销.py schema --output
|
||||||
## Step 3:读取 JSON 并填表
|
## Step 3:读取 JSON 并填表
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 差旅报销.py fill 报销输出/analyzed_result.json
|
python3 差旅报销.py fill 20260529/报销输出/analyzed_result.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows 可用:
|
Windows 可用:
|
||||||
|
|
||||||
```powershell
|
```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. 校验附件张数是否一致
|
3. 校验附件张数是否一致
|
||||||
4. 复制 `差旅费明细表.xlsx`
|
4. 复制 `差旅费明细表.xlsx`
|
||||||
5. 用 openpyxl 填入固定单元格
|
5. 用 openpyxl 填入固定单元格
|
||||||
6. 输出最终 XLSX 到 `报销输出/`
|
6. 输出最终 XLSX 到本次处理目录的 `报销输出/`
|
||||||
|
7. 将本次处理目录重命名为 `出发日期-结束日期_出发地-目的地_事由简写` 格式
|
||||||
|
|
||||||
输出文件命名规则:
|
输出文件命名规则:
|
||||||
|
|
||||||
|
|
@ -349,7 +368,7 @@ py 差旅报销.py fill 报销输出/analyzed_result.json
|
||||||
示例:
|
示例:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
报销输出/差旅费明细表_xxxx_20260520_广州_售后维修.xlsx
|
20260520-20260522_北京-广州_售后维修/报销输出/差旅费明细表_xxxx_20260520_广州_售后维修.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
170
差旅报销.py
170
差旅报销.py
|
|
@ -3,8 +3,8 @@
|
||||||
差旅报销自动化脚本
|
差旅报销自动化脚本
|
||||||
|
|
||||||
Python 只做两类确定性工作:
|
Python 只做两类确定性工作:
|
||||||
1. extract:提取 PDF 原文到 TXT
|
1. extract:整理输入附件,提取 PDF 原文到本次归档目录的 TXT
|
||||||
2. fill:读取大模型生成的 JSON,填入 Excel 模板
|
2. fill:读取大模型生成的 JSON,填入 Excel 模板,并完成本次归档目录命名
|
||||||
|
|
||||||
中间的发票理解、金额分类、日期/事由/人员判断全部由大模型完成。
|
中间的发票理解、金额分类、日期/事由/人员判断全部由大模型完成。
|
||||||
模板使用 .xlsx,完全依赖 Python 包实现跨平台,不依赖 LibreOffice。
|
模板使用 .xlsx,完全依赖 Python 包实现跨平台,不依赖 LibreOffice。
|
||||||
|
|
@ -14,14 +14,17 @@ import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pdfplumber
|
import pdfplumber
|
||||||
|
|
||||||
|
|
||||||
OUTPUT_DIR = Path("报销输出")
|
OUTPUT_DIR_NAME = "报销输出"
|
||||||
RAW_TEXT_FILE = OUTPUT_DIR / "pdf_raw_text.txt"
|
RAW_TEXT_FILENAME = "pdf_raw_text.txt"
|
||||||
|
ANALYZED_RESULT_FILENAME = "analyzed_result.json"
|
||||||
TEMPLATE_FILE = Path("差旅费明细表.xlsx")
|
TEMPLATE_FILE = Path("差旅费明细表.xlsx")
|
||||||
|
ATTACHMENT_SUFFIXES = {".pdf", ".jpg", ".jpeg", ".png"}
|
||||||
|
|
||||||
|
|
||||||
SCHEMA_EXAMPLE = {
|
SCHEMA_EXAMPLE = {
|
||||||
|
|
@ -83,24 +86,24 @@ SCHEMA_EXAMPLE = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def ensure_output_dir():
|
|
||||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def command_extract(_args):
|
def command_extract(_args):
|
||||||
ensure_output_dir()
|
archive_dir = prepare_archive_dir()
|
||||||
pdf_files = sorted(Path(".").glob("*.pdf"))
|
output_dir = archive_dir / OUTPUT_DIR_NAME
|
||||||
if not pdf_files:
|
output_dir.mkdir(exist_ok=True)
|
||||||
raise SystemExit("当前目录未找到 PDF 文件。")
|
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("=" * 60 + "\n")
|
||||||
output.write("PDF 原始内容汇总\n")
|
output.write("PDF 原始内容汇总\n")
|
||||||
output.write("=" * 60 + "\n\n")
|
output.write("=" * 60 + "\n\n")
|
||||||
|
|
||||||
for pdf_path in pdf_files:
|
for pdf_path in pdf_files:
|
||||||
output.write(f"\n{'=' * 60}\n")
|
output.write(f"\n{'=' * 60}\n")
|
||||||
output.write(f"文件: {pdf_path.name}\n")
|
output.write(f"文件: {pdf_path}\n")
|
||||||
output.write("=" * 60 + "\n")
|
output.write("=" * 60 + "\n")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -113,14 +116,93 @@ def command_extract(_args):
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
output.write(f"\n[读取失败: {exc}]\n")
|
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):
|
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)
|
text = json.dumps(SCHEMA_EXAMPLE, ensure_ascii=False, indent=2)
|
||||||
if args.output:
|
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")
|
schema_path.write_text(text + "\n", encoding="utf-8")
|
||||||
print(f"已写入 {schema_path}")
|
print(f"已写入 {schema_path}")
|
||||||
else:
|
else:
|
||||||
|
|
@ -132,9 +214,17 @@ def command_fill(args):
|
||||||
validate_result(result)
|
validate_result(result)
|
||||||
ensure_fill_dependencies()
|
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)
|
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):
|
def load_json(json_path):
|
||||||
|
|
@ -213,13 +303,13 @@ def ensure_fill_dependencies():
|
||||||
raise SystemExit("缺少 openpyxl,请运行:python -m pip install openpyxl")
|
raise SystemExit("缺少 openpyxl,请运行:python -m pip install openpyxl")
|
||||||
|
|
||||||
|
|
||||||
def fill_template(result):
|
def fill_template(result, output_dir):
|
||||||
ensure_output_dir()
|
output_dir.mkdir(exist_ok=True)
|
||||||
import openpyxl
|
import openpyxl
|
||||||
|
|
||||||
meta = result["meta"]
|
meta = result["meta"]
|
||||||
expenses = result["expenses"]
|
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)
|
shutil.copy(TEMPLATE_FILE, output_file)
|
||||||
wb = openpyxl.load_workbook(output_file)
|
wb = openpyxl.load_workbook(output_file)
|
||||||
|
|
@ -244,6 +334,38 @@ def fill_template(result):
|
||||||
return output_file
|
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):
|
def build_output_filename(meta):
|
||||||
date_str = normalize_date_for_filename(meta["出发日期"])
|
date_str = normalize_date_for_filename(meta["出发日期"])
|
||||||
safe_parts = [
|
safe_parts = [
|
||||||
|
|
@ -304,14 +426,14 @@ def build_parser():
|
||||||
parser = argparse.ArgumentParser(description="差旅报销自动化脚本")
|
parser = argparse.ArgumentParser(description="差旅报销自动化脚本")
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
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)
|
extract_parser.set_defaults(func=command_extract)
|
||||||
|
|
||||||
schema_parser = subparsers.add_parser("schema", help="输出大模型需要生成的 JSON schema 示例")
|
schema_parser = subparsers.add_parser("schema", help="输出大模型需要生成的 JSON schema 示例")
|
||||||
schema_parser.add_argument("--output", action="store_true", help="写入 报销输出/schema_example.json,而不是打印到终端")
|
schema_parser.add_argument("--output", action="store_true", help="写入 报销输出/schema_example.json,而不是打印到终端")
|
||||||
schema_parser.set_defaults(func=command_schema)
|
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.add_argument("json_path", help="大模型生成的 analyzed_result.json 路径")
|
||||||
fill_parser.set_defaults(func=command_fill)
|
fill_parser.set_defaults(func=command_fill)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user