☰
用Python批量解析滴滴行程单PDF,实现报销自动化
2026/10/15 7:51:19 网站建设 项目流程

简介:这是一份滴滴出行行程报销单PDF文件,面向企业员工、财务与行政人员,用于核对差旅费用、规范报销留档。文档完整记录一名员工2024年11月20日至12月4日期间在宁波市的13笔打车行程,总计643.03元,包含日期、上下车时间、起点终点、里程、金额及车型等明细,方便财务部门逐笔审核与归档。资源共1个PDF文档,压缩包大小约93KB,内容以两页表格呈现,适合直接打印或导入费用管理系统。目前已有448人学习浏览,可作为员工出行记录与报销凭证的参考模板。通过该行程单,读者能清晰看到快车、滴滴特快与特惠快车在不同时段和场景下的使用分布,也能依据里程与金额核对费用合理性,提升报销处理的透明度和效率。

1. 滴滴出行行程报销单.pdf:不只是张发票,是半自动报销的突破口

电脑桌面上摞着十几份刚从客户端里导出的「滴滴出行行程报销单.pdf」,文件名是一串谁也看不懂的编码,打开才知道是哪天、去了哪、花了多少钱。财务在催报销,你只能一份一份打开抄金额,两小时过去,台账还是乱的。这个场景我经历过不止一次,后来才发现:这类行程单是程序生成的PDF,页面里的行程时间、起终点、金额、行程单号全都以文本形式存在,也就是说,它可以被脚本批量读取。这个标题背后真正值得做的事,是把一批行程单PDF当作结构化数据源,用脚本在几十秒内完成重命名、合并、金额提取和Excel台账输出。适合谁?常出差要定期整理报销的,给团队做差旅补贴核对,以及接到这类票据处理需求的前端后端。读完这篇,你能直接跑出一套属于自己的报销流水线。

2. 行程单 PDF 内部结构:字段清单、文本层与版式差异

很多人第一次接触行程单PDF,第一个动作是双击打开、用眼睛看,然后截图贴进报销系统。这个动作没问题,但如果你要批量处理,就得先搞清楚两件事:页面上有哪些字段值得提取,以及这份PDF到底带不带文本层。文本层决定了一切——带文本层,pdfplumber 和 pypdf 就能直接读;不带,就只能走OCR或者人工。好在滴滴这种客户端导出的行程单基本都是文本型PDF,不是扫描件,这就给自动化留了很大的空间。

2.1 一份行程单里值得提取的字段:从报销角度排优先级

打开一份标准行程单PDF,从上到下大致能看到:平台名称、行程时间、车型、车牌号、出发地、到达地、里程、时长、金额小计。如果按报销用途排优先级,我的顺序是:金额第一、行程单号第二、起终点第三、时间第四。为什么金额最重要?因为报销系统只认数字,金额错了整张单作废。行程单号在页面中部有一串,它是唯一性标记,重命名、去重、防重复报销都靠它。起终点和时间往往是财务审核时抽查的内容,不需要每个都进Excel,但保留下来能应对“这趟是不是去XX了”这种质询。

字段优先级可以整理成一张表,方便提取脚本设计时决定哪些字段必须强校验、哪些可以留空。

优先级字段名在页面中的常见形态提取价值
P0实付金额底部小计区域“实付金额 ¥38.60”报销核心数值,必填
P0行程单号中部或底部一串字母数字唯一性标记,防止重复
P1上车时间 / 下车时间“行程时间 2025-01-15 09:30”判断工时归属和时长
P1出发地 / 到达地页面中部两行地址目的地合规核验
P2车牌号 / 车型行程信息区留底备查,批量处理时可不提取

设计提取脚本时,P0字段要严格解析:匹配不到就标记异常,而不是给一个空值继续跑。P1字段可以宽松一些,丢了也不影响核心报销。P2字段建议直接跳过,除非你有特殊需求。这样分优先级的意义在于:脚本的复杂度和准确率是冲突的,把校验资源集中在P0上,整体产出才可靠。

2.2 用 pdfplumber 快速验证 PDF 是否带文本层

拿到一批行程单PDF,第一步不是写完整解析脚本,而是先验证它们带不带文本层。这一步用 pdfplumber 十行代码就能完成。pdfplumber 是一个纯Python的PDF解析库,基于 pdfminer.six 封装,对文本型PDF提取效果稳定,是我处理这类票据的首选工具。

import pdfplumber pdf_path = "滴滴出行行程报销单_202501.pdf" with pdfplumber.open(pdf_path) as pdf: page = pdf.pages[0] text = page.extract_text() if text: print("带文本层,可以批量提取") print(text[:300]) else: print("无文本层,疑似扫描件或图片型PDF")

这段代码的核心是 page.extract_text():pdfplumber.open() 打开PDF后,pdf.pages[0] 取第一页,extract_text() 把整页文本按阅读顺序拼成字符串。如果返回值是 None 或者空白,基本可以断定这份文件是图片型或者扫描件,继续跑脚本只会得到一堆空字段。这个检查脚本跑完,把无文本层的文件单独放到一个“需人工处理”文件夹里再继续,能躲掉后面整批翻车的风险。

参数上的一个细节:pdfplumber 的 pages 列表索引从0开始,pages[0] 是第一页,pages[1] 是第二页。很多人第一次写的时候下意识写 pages[1] 取第一页,结果取到第二页,text 内容对不上,还以为是PDF解析出了问题。如果页面只有一页,pages[0] 就是全部内容,不需要做分页处理。

2.3 参数细节:pages 索引、坐标 bbox 与 layout 模式

extract_text() 有两个常见参数值得专门说。第一个是 layout=True。默认情况下,extract_text() 按阅读顺序拼接文本,适合绝大多数单栏排版;layout=True 会尽量保留原文的横向排版位置,适合页面里有多栏内容的场景。滴滴行程单是单栏结构,默认模式就够了。第二是 text_flow 之类的辅助参数,实际上用得很少。

另一个参数面是坐标提取。前面验证文本层只用了整页文本,但有时候你需要定位“金额”字样到底在页面哪个位置,这就要用到 extract_words():

words = page.extract_words() for w in words: if "金额" in w["text"]: print(w["text"], w["x0"], w["top"], w["x1"], w["bottom"])

extract_words() 返回每个词的坐标框,x0 是左侧x坐标,top 是上侧y坐标,x1 和 bottom 是右下角。坐标单位是PDF内部的point,不是屏幕像素。坐标法适合固定版式的PDF,比如同一家公司同一个模板导出的票据,字段位置基本不会变,坐标法提取快且准。但它有一个硬伤:只要上游改了一版模板,字段位移几个point,坐标法就全部失效。相比之下,基于关键词的正则提取更抗版式变化,我后面更推荐这种方案。坐标法可以用来做版式变更检测——当你发现关键词匹配率突然下降的时候,跑一遍坐标看字段是不是挪了位置。

3. 批量重命名与合并:把散乱的行程单变成按月份归档的报销包

出差一个月,下载的行程单PDF可能是这样的:一串乱序编号、时间在文件名里完全看不出来。这样的文件直接交进报销系统没有任何问题,但到了月底要跟财务对账,你就会发现文件名里没有信息量是最大的坑。批量重命名的目标,是把文件名变成“日期+金额+行程单号”这样人眼可读的格式。重命名之后,合并、筛选、按月份归档都变得非常自然。

3.1 批量重命名:按「日期+金额+行程单号」生成规范文件名

重命名之前先想清楚规则。我的常用规则是:yyyyMMdd_金额_行程单号.pdf。日期放在最前面,因为按月份筛选最频繁;金额第二,月底汇总对账一眼能看到总数;行程单号放最后,保证文件名的唯一性。如果两个文件是同一天、同一金额、不同行程单号,最后一段能区分开。没有行程单号,至少加一个三位序号兜底,否则一跑就覆盖。

import os import re import pdfplumber src_dir = "raw_pdfs" if not os.path.exists(src_dir): os.makedirs(src_dir) for filename in os.listdir(src_dir): if not filename.lower().endswith(".pdf"): continue filepath = os.path.join(src_dir, filename) with pdfplumber.open(filepath) as pdf: text = pdf.pages[0].extract_text() m_date = re.search(r"行程时间[::]\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})", text) m_amount = re.search(r"实付金额[::]\s*[¥¥]?\s*(\d+\.\d{2})", text) m_no = re.search(r"(?:行程单号|发票号|单号)[::]\s*([A-Z0-9]{6,})", text) date_str = m_date.group(1).replace("-", "") if m_date else "未知日期" amount_str = m_amount.group(1) if m_amount else "未知金额" no_str = m_no.group(1) if m_no else "未知单号" new_name = f"{date_str}_{amount_str}_{no_str}.pdf" os.rename(filepath, os.path.join(src_dir, new_name))

这段脚本里三个正则分别处理日期、金额、单号。日期正则抓“行程时间:2025-01-15 09:30”里的日期部分,替换掉横杠变成20250115;金额正则匹配“实付金额:¥38.60”这类写法,[¥¥]?兼容全角半角符号;单号正则用(?:行程单号|发票号|单号)做别名匹配,因为不同版本的行程单对同一字段的称呼不完全一致。每个正则都带了兜底值,匹配失败用“未知日期”这样的占位符,保证脚本不中断。

跑之前有个重要习惯:先改成打印 new_name,不要直接 os.rename。把预览结果看一遍再放真正的重命名,能避免一堆意外的路径问题。重命名之后,文件夹里就是一堆规则清晰的文件,下一步合并按月份分组就非常容易了。顺带说一句,跨目录 rename 实际上会变成复制加删除,如果文件很多,建议先 os.chdir 切进目标目录再跑,减少路径拼接的麻烦。

3.2 用 pypdf 按月份合并 PDF,并写上 metadata

财务报销有时希望一个月一张汇总PDF,不要几十个分散文件。合并PDF我一般用 pypdf,它是 PyPDF2 的延续版本,API 更干净,add_page 合并页面的逻辑很直接。重命名规则里日期在文件名最前面,合并时按文件名排序,天然就是按时间顺序排列页。

from pypdf import PdfReader, PdfWriter import os src_dir = "raw_pdfs" writer = PdfWriter() month = "202501" pdfs = [f for f in os.listdir(src_dir) if f.startswith(month) and f.endswith(".pdf")] pdfs.sort() for name in pdfs: reader = PdfReader(os.path.join(src_dir, name)) for page in reader.pages: writer.add_page(page) writer.add_metadata({ "/Title": "2025-01 差旅行程单合辑", "/Creator": "internal-expense-tool", }) with open(f"{month}_merged.pdf", "wb") as f: writer.write(f)

pdfs.sort() 这一步不要省。列表排序保证合并后的页面顺序是时间正序,否则每次跑出来的页序都不一样。add_page 是浅层拷贝,原文件不受影响,可以放心多次试验。add_metadata 接收的键必须带斜杠前缀,这是PDF规范里的文档信息字段,不是随便写的键值对。实测用中文Title没问题,但不要在 metadata 里塞奇奇怪怪的自定义键,部分报销系统解析PDF时会告警。

合并时有个常见的量级问题:一个月出差20次,每个PDF一页,合出来就是20页。20页以内的PDF几乎所有报销系统都能正常解析。但如果一个人同时交了半年的行程单,合并文件超过100页,某些上传组件就会超时或者只解析前几页。我的建议是:按单个pdf文件不要超过30页来分组,多的单独上传。这个上限不是官方标准,是保守的兼容性选择。

4. 结构化提取行程字段:从 PDF 到 Excel 报销台账的落地脚本

重命名和合并只是把文件整理好了,报销真正要的是那张台账表——每一趟行程的日期、起终点、金额。从PDF到Excel,是这个标题里最值钱的一步。做完这一步,月底对账不再需要打开任何一个PDF。

4.1 用关键词定位代替坐标定位:抗版式变动

前面说过坐标法提取的风险:上游一改模板,字段坐标位移,脚本就废。我在实际处理中更喜欢用关键词定位——在整页文本里用正则找到“实付金额”后面的数字,不关心它画在页面哪个位置。只要界面上的文案没变,版式再怎么调整都能提取到。当然,关键词法也有边界:如果滴滴改了页面文案,比如“实付金额”改成“支付金额”,正则就要跟着改。两害相权,文案变化的频率远低于坐标变化的频率,所以关键词法是更稳的长期方案。

import pdfplumber import re def get(pattern, text): m = re.search(pattern, text) return m.group(1).strip() if m else None pdf_list = ["raw_pdfs/20250115_38.60_ABC123456.pdf"] for path in pdf_list: with pdfplumber.open(path) as pdf: text = pdf.pages[0].extract_text() row = { "文件名": path.split("/")[-1], "上车时间": get(r"上车时间[::]\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})", text), "下车时间": get(r"下车时间[::]\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})", text), "出发地": get(r"出发地[::]\s*(.+)", text), "到达地": get(r"到达地[::]\s*(.+)", text), "金额原值": get(r"实付金额[::]\s*([¥¥]?\s*[\d.,]+)", text), } print(row)

get() 函数把 re.search 包装了一层,匹配不到返回 None。这里的关键设计是 None 而不是空字符串:None 可以被后续逻辑识别为“提取失败”,空字符串会和正常的空值混淆,后面的数据清洗阶段很难区分。出发地和到达地的正则用了(.+)贪婪匹配,因为地址行通常是一行文字,直接取整行;如果地址后面还有别的内容,这个正则可能会贪过头,需要用(.+?)\n或者更精确的结束符来控制。

get() 函数还有一层作用:它接受 pattern 作为参数,这样只要界面文案变了,只需要改传入的正则,不需要重写整个提取函数。维护成本大幅度降低。如果你要处理的是历史归档的行程单,建议对每一批不同时期的PDF都先跑一遍这个脚本,看一下哪些字段大面积匹配失败,再针对性地调整正则。

4.2 金额清洗与日期格式化:把「¥ 38.60“变成 38.6 的工程细节

提取出来的金额原值长这样:“¥38.60”“¥38.60”“38,60”甚至全角数字。直接拿去算总和肯定不行。金额清洗的完整流程是:去掉货币符号、去掉千分位逗号、把全角数字和全角句号转半角、最后转 float 并 round 到两位。

def clean_amount(raw): if not raw: return None s = raw.replace("¥", "").replace("¥", "") s = s.replace(",", "").replace(" ", "") s = s.replace(".", ".") # 全角句号转半角 # 全角数字 0-9 转半角 s = "".join(chr(ord(c) - 0xFEE0) if "0" <= c <= "9" else c for c in s) try: return round(float(s), 2) except ValueError: return None

这个函数里面有两处容易出问题的点。第一是全角句号,PDF文本层里的句号有时是全角“.”,直接 float() 会报错,必须先替换成半角点。第二是全角数字,某些字体环境下数字会以全角形式写入文本层,看起来一模一样,但 float() 解析不了。replace 逗号处理千分位是为了兼容金额超过1000的场景,比如 1,234.50,不处理的话 float() 会直接报错。清洗完的浮点数统一用 round(x, 2),避免浮点运算产生 38.599999 这种结果。

日期清洗相对简单。正则提取出来的日期已经是“2025-01-15 09:30”这种规范格式,直接存字符串就行,不需要转 datetime。除非你要按月份做透视表,那就用 pd.to_datetime 转一下。日期清洗的一个坑是文本层里的分隔符可能是全角冒号“:”而不是半角,正则里写成[::]兼容两种。

4.3 直接用 pandas 写 xlsx,别绕道 CSV

提取完一批数据,输出成 Excel 是最后一步。很多人偷懒直接 to_csv,然后发现:编码乱码、金额前导零消失、日期变成一串数字。绕道 CSV 的坑比省的那几行代码多得多。直接用 pandas 的 ExcelWriter 写 xlsx,底层引擎用 openpyxl,一行代码解决格式问题。

import pandas as pd records = [] # 上一步收集的 row 字典列表 df = pd.DataFrame(records) df["金额"] = df["金额原值"].map(clean_amount) df["异常标记"] = df["金额"].isna().map(lambda x: "提取失败" if x else "") # 只保留台账需要的列 cols = ["文件名", "上车时间", "下车时间", "出发地", "到达地", "金额", "异常标记"] df = df[cols] df.to_excel("差旅报销台账.xlsx", index=False, sheet_name="行程明细", engine="openpyxl")

.to_excel 的两个参数值得说明:index=False 表示不输出 DataFrame 自带的行号,免得Excel里多出一列没用的数字;sheet_name 指定工作表名,默认是 Sheet1,改成有意义的名字方便后续用 pandas 读取时定位。engine="openpyxl" 是写 xlsx 格式所必需的,如果你经常用旧代码里的 engine="xlsxwriter",那是另一套依赖,两个引擎不能混用。

pandas 写 xlsx 还有一层好处:列宽和单元格格式可以后续用 openpyxl 微调,比 CSV 灵活得多。异常标记这一列是额外的保障:clean_amount 返回 None 的行,会在异常标记列显示“提取失败”,你拿到 Excel 后只用筛选这一列就能找出所有没解析成功的文件。这里的思路是把失败暴露给用户,而不是悄悄补一个0。补0会让最终汇总数字看起来合理,但实际上少了一笔钱,这种错误在财务对账时很难发现。

5. 避坑:行程单批量处理中常见的 5 个翻车现场

脚本写出来跑通只是第一步,真正花时间的是排错。这一章我整理了处理这类行程单PDF时最常碰到的5个坑,每个都是我实际踩过的,按“现象→原因→解决”写清楚。

5.1 文件不是标准文本 PDF:没判断就批量跑,结果全部白干

现象:脚本跑完,台账里的金额和起终点全是空白,但PDF用肉眼打开看明明有字。 原因:你手里的PDF可能是别人从微信转发、截图后另存的图片型PDF,或者干脆就是从某个打印系统导出的扫描件。这两种都没有文本层,pdfplumber 的 extract_text() 返回 None。 解决:批量处理前先跑一遍第2.2节的检测脚本,把所有无文本层的文件单独分出来。如果有OCR需求,可以用 OCR 服务把图片转成文本,但我要泼一盆冷水:行程单里的金额识别错误率不低,报销场景里OCR结果必须人工复核。我现在的习惯是:无文本层的文件坚决不进自动流水线,直接走人工,哪怕只有10份,也比花了半小时OCR结果错3份强。

5.2 金额末尾 0 被吞掉:提取出来对不上账的元凶

现象:PDF上写着38.60,Excel里变成了38.6,合计金额差了几分钱,财务对不上。 原因:正则写成\d+\.\d+而不是\d+\.\d{2},38.60 匹配到的是38.6,末尾的0在文本提取时就被吞掉了;加上 float 之后不会自动补位,38.60 变成 38.6。 解决:正则写成\d+\.\d{2},强制匹配两位小数。如果文本层里的金额本来就是一个小数位,说明这份PDF格式有问题,标成异常比强行补0更好。别忘了在清洗函数里 round(x, 2),虽然不能找回丢失的0,但能保证浮点误差不进一步扩散。

5.3 合并后报销系统只认第一页:单文件页数有上限

现象:合并出一个60页的PDF上传到报销系统,结果对方只报销了第一页的行程单,后面的全部被忽略。 原因:某些报销系统的附件上传组件对单文件页数有隐性限制,超过一定页数就只解析前N页或者直接报错。这个限制不会写在产品文档里,不试一次根本不知道。 解决:合并前先确认目标报销系统的附件限制。没有准确数据时,保守策略是按30页一组拆分合并,或者在合并脚本里加一个分组参数,超过30页就生成多个文件。如果报销系统支持多附件上传,拆成多个文件反而更稳。

5.4 同名文件覆盖:重命名规则没加唯一后缀,跑了就后悔

现象:重命名跑完,文件夹里文件数量少了几份,有些文件找不回来了。 原因:同一天、同金额、行程单号字段没匹配到,new_name 变成未知日期_38.60_未知单号.pdf,第二次生成同名文件时直接覆盖掉第一份。 解决:文件名里必须有唯一的行程单号,没有行程单号就加时间戳或者三位序号。我的做法是在脚本里检查目标文件名是否已存在,如果存在就自动加_1、_2后缀。另一个习惯是:重命名本质上会修改文件名,建议只在副本文件夹上操作,原件保留一份,给“后悔药”留个后路。

5.5 提取结果错误比失败更隐蔽:加验证集和异常标记

现象:某一行金额比实际支付少了10块,全表没有任何报错。 原因:正则匹配到了一个长得像金额但不是金额的字段,比如“含优惠券抵扣 10.00”里的数字被实付金额[::]之后的匹配逻辑吸收,或者 PDF 文本层里出现了空白字符导致匹配偏移。提取失败会报空,但提取错误是静默的。 解决:两个手段。第一,加验证集:人工从当批PDF里抽10份,把期望值记下来,跑完自动比对,不一致就输出报警。第二,给金额列加合理性校验:单笔金额超过一定阈值(比如800)或者低于1元的,标记成“需复核”。这两个手段花的时间加起来不到十分钟,但能把财务对账时的两小时救回来。

6. 进阶:把提取结果反写进 PDF 角注,再做 10% 人工抽检

到Excel台账这一步,报销流程已经完成80%。剩下的时间花在人工核验上:财务要打开PDF看金额对不对,还要从几十份文件里找某一笔单子。这一章讲两个提高核验效率的做法:把提取金额反写进PDF页面角注,以及用抽检策略控制错误率。

6.1 用 PyMuPDF 给行程单加角注

提取完金额后,在每一页PDF的右上角写入金额和行程单号,这样人工核验时不需要翻到页面底部找小字,扫一眼角注就知道这页值多少钱。PyMuPDF(通常用 fitz 导入)在这方面比 pypdf 好用得多,它直接支持 insert_text,可以把文本画在页面上。

import fitz doc = fitz.open("2025-01_merged.pdf") for i, page in enumerate(doc): rect = page.rect page.insert_text( (rect.width - 150, 20), f"金额 {amount_list[i]:.2f}", fontsize=9, color=(0.9, 0.1, 0.1), ) doc.save("2025-01_annotated.pdf")

insert_text 的第一个参数是坐标元组,x 和 y 都是相对PDF页面左上角的位置,(rect.width - 150, 20) 表示右上角区域。fontsize=9 比较克制,不影响主要内容阅读。color 用小尺寸的红色系,方便快速扫到。注意 amount_list 要和页面顺序对齐,如果PDF是按第3章的方式合并的,台账的行序和页序一致,直接用索引取就行。这一步跑完后,打开任何一个PDF,右上角都有一行红字标明金额,核验效率能提升三倍以上。

6.2 抽检策略与阈值校验

反写角注只是让核验变快,真正决定报销安全的还是抽检策略。我现在的固定流程是:每批行程单随机抽10%人工比对PDF和Excel金额,抽到的文件优先选金额最大的前几笔;异常标记列有“提取失败”的全查;单笔金额大于800或小于5元的全查。这个策略花的时间很少,但覆盖了最容易出错的区间。至于那些提取失败的文件,跑完脚本后单独列一个文件夹,人工填完再补进台账。

做这件事最深的体会是:自动化不是用来消灭人工核验的,而是把人工核验集中到最需要看的地方。我现在跑任何一批行程单,都会先花十分钟做抽检样本,这十分钟省掉的是后来对账两小时。这个习惯已经变成一种条件反射了,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询