2026-10-06 · 喵窝资源铺
报销单、凭证、台账分散在好几个文件里,票号格式还五花八门:144012309876、1440-1230-9878、1 4401 2309 881。肉眼对票号防重复报销,几十条就已经眼花。这篇给一个 60 行脚本:批量扫 Excel 备注列和文本凭证,正则提取票号,归一化后跨文件查重,重复的直接列出「首见在哪、重复在哪」。代码实跑验证,真实输出贴在下面。
3 个测试文件:报销单 Excel(含带横杠/空格分隔的票号、8 位短号、无票号行)、文本凭证(两行同票号=跨文件重复)、数字单元格 Excel。跑完:
=== 提取结果:共 13 个票号,去重后 8 个 === 144012309876 @baoxiao.xlsx:2 报销 办公用品 发票号144012309876 广场酒店 144012309877 @baoxiao.xlsx:3 住宿 ticket no. 144012309877 144012309878 @baoxiao.xlsx:5 发票号码:1440-1230-9878 带横杠 144012309879 @baoxiao.xlsx:7 票号144012309879(电子票) 144012309876 @baoxiao.xlsx:8 重复报销 发票号144012309876 144012309880 @baoxiao.xlsx:10 缺日期 有票号144012309880 144012309881 @baoxiao.xlsx:11 增值税 1 4401 2309 881 空格分隔 144012309882 @vouchers.txt:1 报销凭证 张三 2026-09-12 发票代码144012309882 打印费12元 144012309882 @vouchers.txt:2 差旅 王五 144012309882 出租车 46.00 144012309876 @numcell.xlsx:2 144012309876 144012309876 @numcell.xlsx:3 144012309876 144012309876 @numcell.xlsx:4 144012309876 @TRUNCATED@14401230987612350464 @numcell.xlsx:5 1.440123098761235e+19 === 跨文件/跨行重复 5 处 === 144012309876: 首见 baoxiao.xlsx:2 | 重复 baoxiao.xlsx:8 144012309882: 首见 vouchers.txt:1 | 重复 vouchers.txt:2 144012309876: 首见 baoxiao.xlsx:2 | 重复 numcell.xlsx:2 144012309876: 首见 baoxiao.xlsx:2 | 重复 numcell.xlsx:3 144012309876: 首见 baoxiao.xlsx:2 | 重复 numcell.xlsx:4
13 个票号提取后 8 个唯一值,5 处重复全揪出(含同一票号在报销单里出现两次——典型的重复报销线索)。「编号12345」这种 8 位短号和无票号的「餐饮招待」行都没误报。
# -*- coding: utf-8 -*-
"""发票号/订单号批量提取查重:扫 Excel 备注列 + 文本凭证,正则抓票号,跨文件查重。"""
import re, sys, os
from openpyxl import load_workbook
# 票号特征:8位号码(按你的票号位数改 {8}),兼容空格/横杠分隔
RE_TICKET = re.compile(r'(?=8位整数)说明被Excel转科学计数、尾数已丢——报错不是猜。"""
if isinstance(v, bool):
return None
if isinstance(v, int):
return str(v) if RE_INT_CELL.match(str(v)) else None
if isinstance(v, float):
if v == int(v) and len(str(int(v))) >= 8:
return '@TRUNCATED@' + str(int(v)) # 精度已丢,标记转人工
return None
def normalize(s):
"""票号统一去空格和横杠,用于查重比对。"""
return re.sub(r'[-\s]', '', s)
def scan_text(path):
found = []
with open(path, encoding='utf-8', errors='ignore') as f:
for i, line in enumerate(f, 1):
for m in RE_TICKET.finditer(line):
found.append((os.path.basename(path), i, normalize(m.group(1)), line.strip()))
return found
def scan_excel(path, col_names=('备注', '事由', '摘要', '说明', '发票号', '票号', '订单号')):
wb = load_workbook(path, data_only=True)
ws = wb.active
hdr = [c.value for c in ws[1]]
cols = [i for i, h in enumerate(hdr) if h in col_names]
found = []
for rno, row in enumerate(ws.iter_rows(min_row=2, values_only=True), 2):
for ci in cols if cols else range(1, len(row)):
v = row[ci]
if v is None:
continue
s = str(v)
if not s.strip():
continue
matched = False
for m in RE_TICKET.finditer(s):
found.append((os.path.basename(path), rno, normalize(m.group(1)), s.strip()))
matched = True
if not matched:
n = cell_num(v)
if n:
found.append((os.path.basename(path), rno, n, s.strip() or '(数字单元格)'))
return found
if __name__ == '__main__':
items = []
for p in sys.argv[1:]:
if p.endswith('.xlsx'):
items += scan_excel(p)
else:
items += scan_text(p)
seen = {}
dup = []
for src, loc, num, ctx in items:
if num in seen:
dup.append((num, seen[num], f'{src}:{loc}'))
else:
seen[num] = f'{src}:{loc}'
print(f'=== 提取结果:共 {len(items)} 个票号,去重后 {len(seen)} 个 ===')
for src, loc, num, ctx in items:
print(f' {num} @{src}:{loc} {ctx[:40]}')
print(f'\n=== 跨文件/跨行重复 {len(dup)} 处 ===')
for num, first, second in dup:
print(f' {num}: 首见 {first} | 重复 {second}')
(?:\d[-\s]?){8}(?!\d) 和 ...{8}(?!\d)) 里括号包不包 (?!\d) 决定负向断言是「每个数字后面都不能跟数字」还是「只在结尾判断」。写错了不报错、不崩,就是静默 0 命中——最阴险的失败方式。这次实跑第一版就是 0 命中,把正则拆出来单独 print 才定位到。提取类脚本上线前必须先拿 1 条已知样本验证「抓得到」,再看「抓得全」。int 直接转回字符串),12 位以上会转成科学计数法 float,尾数直接丢失且不可恢复——实测 14401230987612345678 读回来是 1.440123098761235e+19,最后几位的真实值已经没了。这种只能标记 @TRUNCATED@ 报出来转人工,永远别试图「猜回」尾数。预防办法是录入时就把票号列设成文本格式。str 形式的票号」既被正则抓到一次、又被数字兜底抓到一次,查重结果重复 7 处变成虚高。正确做法是 matched 标记互斥:正则命中过就跳过数字兜底。python invoice_dedup.py 报销单.xlsx 凭证.txt 台账.xlsx 一次传多个文件即可。票号位数在 RE_TICKET 的 {8} 处改;列名不匹配时会自动扫所有列(首行表头匹配 备注/事由/摘要/发票号/票号/订单号)。重复不一定是报销造假——也可能是同一张票跨系统录入,但都该人工看一眼。如果你要的是按内容归集而不是查重,可以看本站的报销单按事由归集科目。
相关文章:报销单按事由关键词归集科目 · 身份证号批量提取去重 · 多Excel按关键字搜客户