尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
PDF行程单解析实战:从半结构化文档到报销自动化
简介这份PDF是一份真实的滴滴出行行程报销单样本面向需要了解企业差旅报销流程、学习行程单数据结构或进行OCR文本识别练习的财务人员、行政人员及开发者。文档完整记录了某员工2024年11月20日至12月4日在宁波市的13笔打车明细涵盖快车、特惠快车与滴滴特快三种车型逐笔列明上车时间、起终点、里程与金额合计643.03元并附有姓名、工号、部门、手机号及申请日期等报销要素起终点涉及亚朵酒店、英普特购物、象山隔离点、栎社国际机场等地点可据此观察商务出行模式与费用构成。资源包仅含1个PDF文件大小约93KB分两页呈现结构清晰、字段规整适合作为报销单模板参考、财务核对案例或OCR识别与信息抽取的测试素材。目前已有453人学习下载可供需要真实票据样本的读者直接取用。1. 一张 PDF 行程单为什么值得单独拆一遍手里拿到一份「滴滴出行行程报销单.pdf」13 笔行程、合计 643.03 元起止日期 2024-11-20 到 2024-12-04车型混着快车、特惠快车、滴滴特快城市一栏还出现了「宁波市」和「宁波 市」两种写法第 11 到 13 笔的起点终点被分页切成了两行。很多人第一反应是「这不就是个报销附件吗」但真正做过费用核对的人知道这类 PDF 是典型的半结构化文档表头规整、正文是表格、分页处会断行、金额和里程带小数、时间字段混着星期。它既能当 OCR 的练手样本也能当报销自动化流水线的第一环。这篇笔记就围绕这份行程单讲清楚它作为数据源怎么解析、字段怎么对齐、金额怎么校验以及我在类似文档上翻过的车。适合做财务系统、RPA、文档解析的从业者照着复现。2. 先看清这份行程单的字段结构表头、明细、分页三块2.1 表头区与明细区的字段边界这份 PDF 的版面其实分三层。最上面是抬头和汇总信息姓名、工号、部门三个空字段加上申请日期 2024-12-12、行程起止日期 2024-11-20 至 2024-12-04、行程人手机号、共 13 笔行程、合计 643.03 元。中间是明细表列头固定为序号、车型、上车时间、城市、起点、终点、里程[公里]、金额[元]、备注。最下面是页码 1/2、2/2。做解析时第一件事不是急着上 OCR而是判断这份文档是「文本型 PDF」还是「扫描型 PDF」。文本型可以直接抽文字层坐标稳定、无识别误差扫描型才需要走 OCR。判断方法很土但有效用命令行工具看能不能直接抽出文字。# 文本型 PDF 能直接抽出文字层扫描型抽出来是空的 pdftotext -layout 滴滴出行行程报销单.pdf - | head -40-layout参数保留原始版面空格对表格类文档很关键不加的话列会挤成一团。如果这条命令输出的是乱码或空白说明是扫描件后面所有步骤都要换成 OCR 路线。我一般会先跑这一条再决定技术栈省得白搭一套 OCR 环境。字段边界上有个容易忽略的点表头里的「姓名 工号 部门」后面是空的这类空字段在正则里如果写成姓名(.)会直接匹配到下一行的内容把整段吞掉。正确做法是限定非贪婪且遇到空白或换行就停或者干脆按坐标切。明细区的「备注」列在这份样本里全空但列头存在解析时要保留这一列占位否则后面按列索引取值会整体错位。2.2 分页断行是这份文档最大的结构陷阱看第 11 到 13 笔问题全暴露了。第 11 笔的城市被拆成「宁波」和「二 市」两行第 12 笔的终点「英普特购物」被拆成「英普特购」和「二 市 物」第 13 笔的起点「象山滨海大道亚朵酒店」被拆成「象山滨海大道亚朵酒」和「三 市 店」终点还跨了「海曙区栎社宁波栎社国际机场」和「宁波栎社国际机场|宁波栎社国际机场T2 航站楼」两段。这不是识别错误是 PDF 分页时把一行单元格物理切开了。处理这类断行的思路是「先按页聚合再按序号归并」。因为每笔行程都有唯一的序号 1 到 13序号是天然的锚点。我的做法是把两页的文字都抽出来按序号切块每个块内的多行文本用空格拼接后再做字段清洗。拼接时要注意「二 市」「三 市」这种是「周二」「周三」被拆开的结果不是城市名的一部分清洗阶段要能识别并还原。import re def merge_pages(page_texts): 把多页文本按序号锚点归并成单条记录块 full \n.join(page_texts) # 以行首序号为切分锚点序号后必须跟车型关键词避免误切金额 pattern r(?m)^\s*(\d{1,2})\s(快车|特惠快车|滴滴特快|滴滴快车) parts re.split(pattern, full) records [] # re.split 带捕获组时结果按 [前文, 序号, 车型, 正文, 序号, 车型, 正文...] 排列 for i in range(1, len(parts), 3): seq parts[i] car_type parts[i 1] body parts[i 2] if i 2 len(parts) else records.append({seq: int(seq), car_type: car_type, raw: body}) return records这段代码的关键在切分正则序号后面必须紧跟车型词否则「206.23」这种金额里的数字也会被当成序号切一刀。(?m)让^匹配每行开头避免把行中间的序号误判。归并后每条记录的raw里还混着时间、城市、起终点、里程、金额需要二次拆分但至少分页断行已经被收拢到同一条记录里了。2.3 字段类型与清洗规则表归并完只是第一步字段级清洗才是决定这份数据能不能进报销系统的关键。下面这张表是我处理这类行程单时固定会过的规则照着填就能覆盖大部分脏数据。字段原始形态清洗规则目标类型序号113直接转 int校验连续无缺号int车型快车/特惠快车/滴滴特快去空格映射到枚举enum上车时间11-20 15:30 周三补年份 2024拆日期与星期datetime城市宁波市 / 宁波 市去内部空格统一为「宁波市」string起点/终点含「象山县|」前缀、分页断行去前缀、拼接断行、去多余空格string里程87.0转 float保留 1 位小数float金额206.23转 Decimal禁止用 float 累加Decimal备注空保留空串占位string金额这一列特别提醒一句643.03 这种合计如果用 float 累加13 笔加起来很可能得到 643.0299999报销系统对不上账就是血泪经验。统一用decimal.Decimal并指定两位小数是这类财务文档的硬规矩。3. 把 PDF 变成结构化数据抽取、对齐、校验三步走3.1 文本抽取与行重建文本型 PDF 抽出来的文字行序不一定等于视觉行序尤其是表格里同一行的多个单元格抽出来可能是按坐标排序的碎片。稳妥做法是用带坐标的抽取库按 y 坐标聚类成行、按 x 坐标排序成列。下面用 pdfplumber 演示它对表格线的识别比纯文本抽取稳。import pdfplumber def extract_rows(pdf_path): rows [] with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: # extract_words 返回带 x0/top 坐标的词按 top 聚类成视觉行 words page.extract_words(keep_blank_charsFalse) lines {} for w in words: key round(w[top] / 3) # 3pt 容差聚成同一行 lines.setdefault(key, []).append(w) for key in sorted(lines): line_words sorted(lines[key], keylambda x: x[x0]) rows.append( .join(w[text] for w in line_words)) return rowsround(w[top] / 3)里的 3 是容差单位是 pt。容差太小同一视觉行会被拆成多行太大相邻两行会被合并。表格行高一般在 1218pt取 3 比较稳。keep_blank_charsFalse让连续空格不单独成词减少噪声。抽出来的rows是纯文本行列表接下来交给上一节的序号锚点归并。如果文档是扫描件这一步换成 OCR但坐标聚类逻辑不变只是extract_words换成 OCR 引擎返回的带框结果。我一般会先用pdftotext判断类型文本型走 pdfplumber扫描型才上 OCR避免给文本型文档平白引入识别误差。3.2 明细行解析与字段对齐有了归并后的记录块下一步是把raw拆成具体字段。这里不能用一个大正则硬啃因为起点终点里本身带「|」和「/」会干扰分隔。我的策略是「从右往左剥」金额和里程在行尾格式最固定先剥掉剩下的中间段再按时间模式切。import re from decimal import Decimal TIME_RE re.compile(r(\d{2}-\d{2})\s(\d{2}:\d{2})\s(周[一二三四五六日])) TAIL_RE re.compile(r([\d.])\s([\d.])\s*$) # 里程 金额 在行尾 def parse_record(rec): raw re.sub(r\s, , rec[raw]).strip() # 1. 先剥行尾的里程和金额 tail TAIL_RE.search(raw) if not tail: return None mileage, amount tail.group(1), tail.group(2) body raw[:tail.start()].strip() # 2. 再找时间锚点时间之前是城市之后是起终点 t TIME_RE.search(body) if not t: return None date_part, clock, weekday t.group(1), t.group(2), t.group(3) city body[:t.start()].strip() route body[t.end():].strip() return { seq: rec[seq], car_type: rec[car_type], date: f2024-{date_part}, time: clock, weekday: weekday, city: city.replace( , ), route: route, mileage: float(mileage), amount: Decimal(amount), }逻辑说明TAIL_RE用$锚定行尾保证剥的是最后两个数字不会误伤起终点里的门牌号。TIME_RE把「11-20 15:30 周三」整体当锚点锚点前是城市、锚点后是起终点这个顺序在这份文档里是稳定的。city.replace( , )专门治「宁波 市」这种被拆开的城市名。route里还带着「象山县|」前缀和可能的断行残留留给下一步清洗。参数上TAIL_RE的[\d.]允许小数点和多位数字但没限制位数如果某笔金额是整数比如 20 元写成 20也能匹配。里程同理。真正要防的是起终点里出现「20m」「160米」这类数字好在它们不在行尾$锚点挡住了。3.3 金额合计校验与异常定位解析完 13 条记录必须做一次合计校验逐条金额累加是否等于表头的 643.03。这一步是整条流水线的「后悔药」能一次性抓出漏解析、重复解析、金额错位三类问题。from decimal import Decimal def verify_total(records, expected643.03): total sum((r[amount] for r in records), Decimal(0)) expected_d Decimal(expected) if total ! expected_d: diff total - expected_d print(f合计不符解析 {total}表头 {expected_d}差额 {diff}) # 差额往往等于某一条金额直接定位 for r in records: if r[amount] abs(diff): print(f疑似异常行序号 {r[seq]}金额 {r[amount]}) return False print(f校验通过共 {len(records)} 笔合计 {total}) return True这段的价值在差额定位如果差额正好等于某一条的金额大概率是那条被重复计入或漏计如果差额是几分钱多半是 float 精度问题混进来了如果差额是个不规则数回去查分页断行有没有把两条粘成一条。我处理过的类似文档里最常见的就是第 13 笔跨页那条被拆成两条记录导致多算一次 224.85。校验通过后13 条记录就可以落库或导出 CSV 进报销系统了。字段对齐的最终形态是序号、车型、日期、时间、星期、城市、起点、终点、里程、金额、备注一行一笔金额用字符串存 Decimal 避免序列化丢精度。4. 避坑与排查这类行程单最容易翻车的五个地方4.1 现象合计总是差几分钱 → 原因float 累加 → 解决全程 Decimal最早我用sum(r[amount] for r in records)金额是 float13 笔加起来得到 643.0299999999999和表头 643.03 一比就报错。查了半天以为是解析漏了其实是浮点表示问题。解决就是把金额从解析那一刻起就用Decimal(amount_str)累加也用Decimal(0)起手比较时两边都转 Decimal。里程可以用 float因为里程不参与对账但金额一分都不能错。4.2 现象第 13 笔金额翻倍 → 原因分页断行被当成两条 → 解决序号锚点归并后再解析跨页那条起点终点被切成两段如果按「每个文本块一条记录」解析就会把同一笔拆成两条金额 224.85 被算两次合计直接多出 224.85。根因是没做跨页归并。解决就是第 2 章那套序号锚点先把两页文本拼起来按「序号 车型」切块同一序号的多行合并成一条再解析。归并后还要校验序号 1 到 13 连续无重复重复序号就是归并失败的信号。4.3 现象城市字段出现「宁波 市」→ 原因单元格内空格被保留 → 解决字段级去空格而非全局去空格全局去空格会把起点终点里的「北广场-B1层-地下停车场(北)F区」也搅乱虽然这份样本里没造成大错但换一份文档就可能把地址粘死。正确做法是只对城市、车型这类枚举字段做replace( , )起终点保留原始空格只在首尾 strip。城市字段的合法值就「宁波市」一个清洗后做一次白名单校验不在白名单里的打日志人工看。4.4 现象时间解析出 2024-11-20 但星期对不上 → 原因星期是文档自带字段不是算出来的 → 解决以文档为准另做一致性抽检文档里「11-20 15:30 周三」的星期是原始数据不要用datetime.weekday()去覆盖它。我见过有人图省事用日期反推星期结果文档本身星期写错或跨年边界时反而把对的改错。做法是保留文档星期字段另起一个抽检用日期算出的星期和文档星期比对不一致的记 warning人工确认不自动改。4.5 现象OCR 路线下「象山县|」被识别成「象山县l」→ 原因竖线字符混淆 → 解决起终点前缀用正则容错扫描件走 OCR 时「|」经常被识别成「l」「I」「1」。起终点里的「象山县|」是区域前缀分隔符清洗时用re.sub(r^[^|lI1]*[|lI1], , route)这类容错正则剥前缀比精确匹配「|」稳。但容错不能太宽否则会把地址里正常的「1」也吃掉所以锚定在行首且只剥第一个分隔符之前的部分。文本型 PDF 没这个问题但换扫描件就得提前埋好这条规则。5. 进阶把 13 笔行程做成可复用的对账模板单次解析 13 笔不算本事能把这套逻辑固化成模板、下次换一份行程单直接跑才算落地。我的习惯是把「抽取 → 归并 → 解析 → 校验」四步封成一个函数输入 PDF 路径和表头合计输出结构化记录加校验结果任何一步失败都返回明确错误码而不是抛异常。这样接进 RPA 或批处理时失败的单据能单独捞出来人工处理不阻塞整批。模板化时有两个参数值得暴露出来一是年份这份文档的时间只有「11-20」没有年份年份得从表头「行程起止日期」里取取不到就传默认值二是城市白名单不同公司的行程单城市集合不同白名单外置成配置避免硬编码。下面是把前面几段串起来的骨架。def parse_trip_pdf(pdf_path, expected_total, default_year2024, city_whitelistNone): rows extract_rows(pdf_path) # 3.1 坐标聚类成行 records merge_pages(rows) # 2.2 序号锚点归并 parsed [parse_record(r) for r in records if r] parsed [p for p in parsed if p] # 去掉解析失败的 None # 序号连续性检查 seqs [p[seq] for p in parsed] if seqs ! list(range(1, len(seqs) 1)): return {ok: False, code: SEQ_GAP, seqs: seqs} # 城市白名单检查 if city_whitelist: bad [p for p in parsed if p[city] not in city_whitelist] if bad: return {ok: False, code: CITY_UNKNOWN, rows: bad} # 合计校验 total sum((p[amount] for p in parsed), Decimal(0)) if total ! Decimal(expected_total): return {ok: False, code: TOTAL_MISMATCH, parsed: str(total), expected: expected_total} return {ok: True, count: len(parsed), total: str(total), records: parsed}这个骨架里SEQ_GAP抓漏行和重复CITY_UNKNOWN抓城市字段污染TOTAL_MISMATCH抓金额问题三个错误码对应三类最常见的翻车。返回结构统一成ok/code/records上层批处理只看ok决定进人工还是进系统。default_year和city_whitelist外置换文档只改配置不改代码。验证方法也简单拿这份 13 笔、643.03 的样本跑一遍应该返回ok: True, count: 13, total: 643.03然后故意删掉一条记录再跑应该返回TOTAL_MISMATCH且差额等于被删那条的金额再把第 13 笔的跨页文本拆成两条喂进去应该返回SEQ_GAP或金额翻倍。三个用例都过模板才算稳。从那以后我每次接新的报销单 PDF都强制先跑一遍「文本型还是扫描型」的判断再拿表头合计做一次端到端校验两步不过就不往下写解析逻辑。这套习惯帮我省掉了大量返工。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

CTF竞赛中Java爆破技术的Frida高效实现

CTF竞赛中Java爆破技术的Frida高效实现

1. CTF竞赛中的Java爆破技术背景在CTF夺旗赛中,Java类题目常出现需要爆破的场景。传统爆破方式往往通过外部脚本模拟输入,但遇到复杂逻辑时效率低下。Frida提供的动态插桩能力,让我们能够直接从进程内部调用目标方法,这种"里…

📅 2026/11/2 11:29:20
网站流量攻击类型分析与防御实战指南

网站流量攻击类型分析与防御实战指南

1. 网站流量攻击的常见类型与特征 当网站遭遇流量攻击时,首先需要明确攻击的类型。常见的流量攻击主要分为三类: DDoS攻击 :分布式拒绝服务攻击,特点是来自大量不同IP的请求在短时间内集中访问目标网站。攻击者通常利用僵尸网络…

📅 2026/11/2 11:29:20
BERT中文NER实战:从字词错位到CRF标签对齐的完整链路

BERT中文NER实战:从字词错位到CRF标签对齐的完整链路

简介:这份资源面向具备一定Python基础、希望入门或进阶自然语言处理的学习者,聚焦如何借助预训练BERT模型完成中文命名实体识别任务,解决从理论理解到代码落地的衔接问题。压缩包共9个文件,约3.72MB,以txt语料与词表、…

📅 2026/11/2 11:29:20
MORE NEWS

更多资讯

📰

实测MiMo2.6Pro:中文指令跟随与长文本抽取亮眼,代码生成仍需把关

1. 先聊聊这台模型火在哪 最近朋友圈被三个字刷屏了:MiMo2.6Pro。准确说,是“国模一哥”这个标签被挂在它头上之后,讨论度一下子上来了。作为常年泡在各种大模型里跑测试的人,我对这种称号本来是不太感冒的,但架不住产…

📰

显式状态驱动的Coding Agent:可审计、可回溯的工程化编码底座

1. 项目概述:这不是又一个“Agent套壳”,而是状态可追溯、行为可审计的工程化编码底座你有没有遇到过这样的情况:写了个自动化脚本,跑着跑着就卡在某个环节,日志里只有一行“task failed”,根本不知道是网络…

📰

ABB C# SDK环境配置实战:RobotStudio、PC SDK与VS版本匹配指南

简介:本资源是面向工业自动化领域开发者与机器人工程师的ABB机器人C#二次开发SDK权威说明文档,聚焦解决基于Windows平台对ABB真实或虚拟控制器进行高级集成与定制化控制的核心需求。文档系统覆盖SDK定位与适用场景、控制器应用开发全流程、开发环境搭建&…

📰

财务共享服务落地五层技术架构与MVP实施路径

简介:本资源是一份面向企业财务数字化转型从业者、集团管控信息化建设人员及共享服务中心规划者的专业级解决方案文档,聚焦财务共享服务落地实践,系统解答如何构建标准化、集约化、服务化的财务集中作业体系。文档由国内知名管理软件厂商久其…

📰

前端竞态问题解析与解决方案

1. 竞态问题本质解析 竞态问题(Race Condition)是计算机科学中一个经典且棘手的问题,它描述的是系统输出结果依赖于不受控制的事件执行顺序或时机。这种情况就像田径赛场上的选手们争夺冠军——最终名次不仅取决于各自速度,更取决…

📰

Java万年历记事本课设:Swing实现日期算法与本地存储

简介:这份JAVA万年历源码面向Java初学者与课程设计实践者,以Swing构建图形界面,将万年历显示与记事本功能合为一体,帮助读者在完整可运行的项目中理解日期时间处理、界面布局与事件驱动编程。源码围绕Calendar与java.time的日期操…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬