openpyxl 对账实战:金额浮点、前导零丢失、20 位单号科学计数法
两张表放在一起核,单号看着一样、金额看着一样,脚本却报「两边各缺一笔」。
这篇记的是写对账脚本时真正让结果出错的 5 个地方。都是实际踩出来的,每个都给能直接抄的处理代码。
适合正在做对账、报表比对、数据导入校验的人。不适合找 pandas 教程的人 ------ 这里全是「数据本身是脏的」引起的问题,换库不解决。
坑 1:金额不能直接比
0.1 + 0.2 == 0.3 在 Python 里是 False,这个大家都知道。真实数据里的形式更隐蔽:
python
>>> 1234.56 + 0.0
1234.56
>>> repr(1234.56 * 100)
'123456.00000000001'
从 Excel 读出来的 1234.56,底层可能是 1234.5599999999999。两张表同一笔钱,一边存的是用户手输的 1234.56,一边是公式算出来的,直接 == 就是不相等。
处理办法是统一换算成「分」的整数再比,并且转换过程别经过 float:
python
from decimal import Decimal, InvalidOperation
import re
def to_cent(value):
"""金额统一成「分」的整数再比,绕开浮点误差。认不出来返回 None。"""
if value is None or value == "":
return None
if isinstance(value, (int, float)):
text = repr(float(value)) # repr 保留完整精度,不是 str
else:
text = re.sub(r"[¥¥,,\s]", "", str(value)) # 认得出 1,234.56 和 ¥1234.56
if not text:
return None
try:
return int((Decimal(text) * 100).quantize(Decimal("1")))
except (InvalidOperation, ValueError):
return None
两个细节:
repr(float(value))而不是str(value)。Python 3 里两者对 float 已经一致,但写repr是明确表达「我要完整精度」的意图。- 先
Decimal(text)再乘 100,不要Decimal(1234.56),那样是把已经有误差的 float 喂进 Decimal,误差会被保留下来。
顺带一个意外收获:Decimal 自己认全角数字。
python
>>> to_cent('1234.56')
123456
我原本以为要先做一次全角转半角,实测不用,Decimal 内部按 Unicode 数字属性解析。少写一行。
坑 2:20 位单号一旦被 Excel 存成数字,末尾几位就永久改掉了
这个坑最狠,因为它不可逆,而且大多数人以为把单元格格式改回「文本」就能修好。
现在的数电发票号码是 20 位。粘进 Excel,格子里会显示成 2.43120E+19:
python
>>> n = '24312000000123456789'
>>> f'{float(n):.0f}'
'24312000000123457536'
末尾的 456789 变成了 457536。
原因是 IEEE 754 双精度只有 15-17 位有效十进制数字,20 位数字超出后末尾被舍入。Excel 把它当数字存进去的那一刻,那几位就已经没了,后面改成文本格式,只是让它不再用科学计数法显示,数字本身回不来。
看着像显示问题,实际是数据损坏,而且不报错。
对账脚本能做的只有两件事:
- 读的时候别让它再退化一次(下面坑 3 的
clean_key顺带处理了12345678.0这种) - 在报告里单独列一页「疑似同一笔」,把这类抓出来提示人工核
真正的修法在导出那一步:导出向导里把单号列指定为「文本」,或者用 openpyxl 写出时显式设成字符串。已经坏掉的数据,只能重新导。
坑 3:前导零被吃掉
000123456 在另一张表里变成 123456。金额一模一样,单号严格比对却对不上,于是被判成「A 表缺一笔、B 表也缺一笔」。
处理办法不是直接把前导零删掉当默认规则(那会把真的不同单号合并),而是先严格比,剩下的没配上的再用宽松形式找一遍,并且把结果单独列出来标为「疑似」,不自动合并:
python
def loose(key: str) -> str:
"""宽松形式:纯数字的去掉前导零,用来发现「其实是同一笔,只是被 Excel 改了样」。"""
return (key.lstrip("0") or "0") if key.isdigit() else key
or "0" 这段是为了处理 "000" 这种全零串,"000".lstrip("0") 得到空字符串。
坑 4:看不见的字符
系统里复制出来的单号常带这些东西,肉眼完全看不出来,但比对必然失败:
- 首尾空格、全角空格(
\u3000)、不换行空格(\xa0) - 零宽字符(
\u200b-\u200f)、BOM(\ufeff)、双向排版控制符(\u202a-\u202e) - 全角数字
123和半角123
比较之前统一洗一遍:
python
import re, unicodedata
INVISIBLE = re.compile(r"[\u200b-\u200f\u202a-\u202e\ufeff\xa0]")
def clean_key(value) -> str:
"""把单号洗成能比的文本:去不可见字符、全角转半角、去所有空白、去掉 Excel 加的小数点尾巴。"""
if value is None:
return ""
# Excel 把纯数字单号读成 float 时会变成 12345678.0,先把这个 .0 去掉
if isinstance(value, float) and value.is_integer():
text = str(int(value))
else:
text = str(value)
text = INVISIBLE.sub("", text)
text = unicodedata.normalize("NFKC", text) # 全角数字/字母 → 半角
return "".join(text.split()) # 去掉中间和首尾所有空白
unicodedata.normalize("NFKC", ...) 一行就把全角转半角,比自己写码位偏移可靠。
⚠️ "".join(text.split()) 会去掉中间 的空白,单号里本来不该有空格,所以这里是对的。如果你的 key 是公司名之类可能含空格的字段,只 strip() 别 split()。
坑 5:全负数的时候,a + b == c 这种自校验必然失配
这个是最近才挖到的。
发票有个恒等式:不含税金额 + 税额 = 价税合计。拿它做自校验很自然:从票面抓到的一堆金额里,找出满足这个等式的三个数,找到就说明抓对了。
实现一般是从大到小试:
python
for total in sorted(set(amts), reverse=True):
smaller = [x for x in amts if x < total - 1e-9]
for a, b in combinations(smaller, 2):
if abs(a + b - total) < 0.005:
return max(a, b), min(a, b), total
这段在正数上没问题。遇到**红冲票(冲销发票)**就全废了,那种票金额全是负数。
负数里「最大的那个」是绝对值最小的。total 取到它之后,smaller 里全是更负的数,两个更负的数相加只会更负,永远配不上。结果是这类票一个金额都读不出来。
修法是先判断再在绝对值上配对,找到后还原符号:
python
nonzero = [x for x in amts if abs(x) > 1e-9]
if nonzero and all(x < 0 for x in nonzero):
found = _find_amount_triple([abs(x) for x in nonzero])
return tuple(-v for v in found) if found else None
顺带一个更早的坑:抓金额的正则也得认负号。r"[¥¥]\s*([\d,]+\.\d{2})" 遇到 ¥-1234.56 会直接匹配失败,- 不在 [\d,] 里。加个 -? 就好:
python
r"[¥¥]\s*(-?[\d,]+\.\d{2})"
还有一个连代码也救不了的
上面五个都能在代码里处理,但有一类不能:单号已经在导出那一步被改掉了(坑 2)。
所以对账脚本的输出应该分清两件事:
- 「确定对不上」,金额不一致、单号一边有一边没有
- 「疑似同一笔」,宽松比对能对上,但严格比对不行
第二类不要自动合并,列出来让人看。自动合并等于用猜测覆盖数据,比报错更糟。
最值得记的 5 个坑
| 坑 | 现象 | 处理 |
|---|---|---|
| 浮点误差 | 金额看着一样,== 为假 |
统一换成「分」的整数,转换别经过 float |
| 科学计数法 | 20 位单号末尾几位变了 | 不可逆,只能在导出那步设成文本 |
| 前导零 | 000123 对不上 123 |
严格比对之后,再用宽松形式找「疑似」,不自动合并 |
| 不可见字符 | 肉眼一样就是比不上 | NFKC + 去零宽 + 去所有空白 |
| 全负数 | 红冲票金额一个都读不出 | 在绝对值上做自校验,再还原符号 |
前四个是「数据本身脏」,第五个是「校验逻辑没考虑符号」。两类都不是换个库能解决的。
参考: