场景:计算结果没报错,核对总额却对不上

订单预览、费用计算和导出报表里,容易出现两种相似的现象:2.675 保留两位变成 2.67;两条 0.335 的明细分别显示 0.34,总计却显示 0.67。前者可能是输入已经经过二进制浮点数,后者可能是逐行舍入与汇总舍入的口径不同。只把所有变量换成 Decimal,解决不了第二类问题。

本文给出一个独立的 Python 3.12+ 示例:输入金额最多六位小数,非负,单行不超过一百万元,最多一千行;选择“每行先保留两位,再累加”的示例口径。这里的舍入规则用于演示软件行为,接入真实订单前应以产品约定的币种、精度和结算规则为准。

先复现,别急着改数据库字段

将下面代码保存为 reproduce.py,运行 python reproduce.py:

from decimal import Decimal, ROUND_HALF_UP

cent = Decimal("0.01")
print("浮点舍入:", round(2.675, 2))
print("浮点转十进制:", Decimal(2.675).quantize(cent, rounding=ROUND_HALF_UP))
print("原始字符串:", Decimal("2.675").quantize(cent, rounding=ROUND_HALF_UP))
rows = [Decimal("0.335"), Decimal("0.335")]
print("逐行舍入:", sum((x.quantize(cent, rounding=ROUND_HALF_UP) for x in rows), Decimal("0")))
print("汇总舍入:", sum(rows, Decimal("0")).quantize(cent, rounding=ROUND_HALF_UP))

五行结果依次为 2.67、2.67、2.68、0.68、0.67。排查时先用这类小样本把两个原因分开,再检查接口解析、计算、存储、导出的真实链路。

Decimal(float_value) 保留的是已经形成的浮点值,无法找回最初的十进制文本。Decimal(str(float_value)) 有时让输出看起来正常,但也无法恢复此前运算丢失的信息。二进制浮点数的表示限制可参考 Python 浮点数教程。

输入边界:金额从字符串进入,从字符串出去

接口约定可以使用 {"amount": "2.675"},或在固定到分的业务里使用整数分。不要先做 float(payload["amount"]),再交给计算函数。

建议顺着一条问题订单逐段检查:请求字段是否为字符串;框架或模型是否转换为 float;数据库驱动返回什么类型;导出是否重新转 float;前端是否重复计算。记录计算阶段、规则版本、输入类型和差额,不要把完整订单或个人信息直接打进日志。

下面的边界特意拒绝空白、科学计数法、逗号分隔、负数、特殊值与超长文本。它是一套明确的接口合同,若业务要允许退款负数或其他币种,应另行设计范围和精度,不能顺手放宽正则。

实现:校验格式、限制规模、固定局部计算上下文

保存为 money.py:

import re
from decimal import Context, Decimal, ROUND_HALF_UP, localcontext

AMOUNT_PATTERN = re.compile(r"(?:0|[1-9][0-9]{0,6})(?:\.[0-9]{1,6})?")
CENT = Decimal("0.01")
MAX_AMOUNT = Decimal("1000000")
MAX_ROWS = 1000


def parse_amount(value: object) -> Decimal:
    """在接口边界拒绝浮点数和超出合同的金额文本。"""
    if not isinstance(value, str):
        raise ValueError("金额必须以十进制字符串提供")
    if len(value) > 14 or AMOUNT_PATTERN.fullmatch(value) is None:
        raise ValueError("金额格式无效,应为最多六位小数的非负数字")
    amount = Decimal(value)
    if amount > MAX_AMOUNT:
        raise ValueError("单行金额不能超过一百万元")
    return amount


def calculate_total(values: list[str]) -> str:
    """按逐行舍入口径汇总,输出固定两位小数字符串。"""
    if not isinstance(values, list) or len(values) > MAX_ROWS:
        raise ValueError("金额明细必须为列表,且不能超过一千行")
    # 显式上下文避免调用方的精度、舍入模式或陷阱配置污染计算。
    with localcontext(Context(prec=28, rounding=ROUND_HALF_UP)):
        total = Decimal("0.00")
        for value in values:
            amount = parse_amount(value)
            total += amount.quantize(CENT, rounding=ROUND_HALF_UP)
        return format(total, ".2f")

parse_amount 接收 object 是为了在运行时检验来自接口的实际类型;类型注解本身不会阻止调用方传 float 或布尔值。通过长度和格式检查的值才进入 Decimal 构造器,所以特殊值和巨大指数没有机会参与计算。失败统一向调用方抛出中文 ValueError,Web 层再转换成既有的参数错误响应,不应返回成功默认值。

计算精度 28 是有效数字位数,不能理解成“保留 28 位小数”。这个示例的输入规模受到限制,不涉及除法,累加也远未触及该精度。将来增加汇率、费率或数量乘法,需要重新计算中间精度上界。

quantize(CENT) 固定到两位;ROUND_HALF_UP 在恰好处于中点时远离零。这里输入非负,2.675 因而变为 2.68。构造、上下文与舍入的 API 语义见 Python decimal 官方文档。

用回归测试把“差一分”变成明确合同

把下面内容保存为同目录的 test_money.py,执行 python -m unittest -v test_money。无需第三方依赖,也不连接数据库或网络。

import unittest
from decimal import Inexact, ROUND_DOWN, localcontext

from money import calculate_total, parse_amount


class MoneyTests(unittest.TestCase):
    """锁定输入合同、舍入位置和调用上下文隔离。"""

    def test_decimal_midpoint(self) -> None:
        self.assertEqual(calculate_total(["2.675"]), "2.68")

    def test_round_each_row(self) -> None:
        self.assertEqual(calculate_total(["0.335", "0.335"]), "0.68")

    def test_empty_and_zero(self) -> None:
        self.assertEqual(calculate_total([]), "0.00")
        self.assertEqual(calculate_total(["0", "0.000000"]), "0.00")

    def test_valid_boundaries(self) -> None:
        self.assertEqual(calculate_total(["1000000"] * 1000), "1000000000.00")
        self.assertEqual(calculate_total(["0.000001"]), "0.00")

    def test_reject_invalid_inputs(self) -> None:
        values = [2.675, True, None, "", "NaN", "Infinity", "1e3",
                  "-0.01", " 1.00", "1,000", "01", "1.1234567",
                  "1000000.000001", "9" * 100]
        for value in values:
            with self.subTest(value=value), self.assertRaises(ValueError):
                parse_amount(value)

    def test_reject_invalid_collection(self) -> None:
        for values in (["0"] * 1001, "1.00", None):
            with self.subTest(values_type=type(values).__name__):
                with self.assertRaises(ValueError):
                    calculate_total(values)

    def test_caller_context_is_unchanged(self) -> None:
        with localcontext() as context:
            context.prec = 2
            context.rounding = ROUND_DOWN
            context.traps[Inexact] = True
            self.assertEqual(calculate_total(["2.675"]), "2.68")
            self.assertEqual(context.prec, 2)
            self.assertEqual(context.rounding, ROUND_DOWN)
            self.assertTrue(context.traps[Inexact])


if __name__ == "__main__":
    unittest.main()

七项测试里,逐行舍入用例尤其关键:若把 quantize 移到总额之后,它会从 0.68 变成 0.67,测试必须失败。边界用例还验证了超行数拒绝、单行上限以及调用方主动降低精度时的隔离行为。

修复上线:不要把历史差额一律重算

先明确哪些页面显示逐行金额,哪些接口存储最终总额,以及最终金额由哪个服务负责计算。若实际合同是汇总后舍入,应同步更改实现与对应测试,并明确页面明细之和与总额可能有差异。若要求明细必须加总一致,需要专门的差额分摊规则,例如按照固定排序分配尾差,并记录规则版本;仅改变显示格式不够。

对于新请求,收紧输入合同可能影响仍发送 JSON number 的旧客户端。可先在边界统计字段类型,更新 SDK 和调用方,再切换严格校验。避免在兼容分支悄悄把数字转成字符串后声称精度已恢复。

对于历史数据,先抽样核对原始十进制文本、已存明细和对外确认金额。缺少原始值时,不能推断浮点值前的真实输入。回滚应按计算规则版本切换,并保留已经确认的结果,不要让同一订单在重试时换一套舍入规则。

数据库使用精确数值类型或整数最小货币单位,JSON 输出保持字符串或约定的整数单位;导出 CSV/Excel 时也要检查工具的自动类型转换。日志记录差额数量与计算规则版本,告警阈值按业务约定设置。

验证范围与结论

本文示例可用标准库完成本地验证,覆盖输入拒绝、逐行舍入、规模边界和上下文隔离。它不包含税额、优惠分摊、多币种或真实支付通道对账逻辑,也未连接生产订单系统。

排查差一分钱,先确认十进制输入是否被转换成 float,再确认舍入发生在哪一步。精确输入、明确舍入位置、固定输出合同与小样本回归测试,缺少任何一个环节,差额都可能在下一次重构时重新出现。