PAYATHON 2026

如何将数值拆分为多个计费区间实现 tiered pricing?

支付老李

结论

这种计价模型通常称为阶梯计价(tiered pricing)、分段计费(banded pricing)或累进计费(graduated pricing)。计算时不用真的把数值拆开,只需按顺序遍历计费区间。每次取“剩余小时数”和“区间容量”中较小的值,乘以当前区间的单价,再从剩余小时数中扣除。

计费规则可以表示为“区间容量 + 单价”,不必为每个小时重复保存价格:

tiers = [
    (3, 80),
    (3, 70),
    (None, 50),  # None 表示最后一个区间没有上限
]

15.2 小时的费用为:

3 × 80 + 3 × 70 + 9.2 × 50 = 910

通用计算方法

计算时维护一个 remaining,表示尚未计费的小时数。依次处理每个计费区间:

  1. 如果区间有容量限制,使用 min(remaining, capacity) 计算落入该区间的小时数。
  2. 如果区间没有上限,将所有剩余小时数计入该区间。
  3. 用区间小时数乘以单价,并累加到总金额中。
  4. 从 remaining 中扣除已经处理的小时数。
  5. remaining 变成 0 后结束计算。

这种算法不受区间数量限制,可以让不同 client 动态配置任意数量的计费 steps。

Python 实现

计算金额时建议使用 Decimal,以免受到二进制浮点数舍入误差的影响:

from decimal import Decimal
from typing import Optional

Tier = tuple[Optional[Decimal], Decimal]


def calculate_charge(hours: Decimal, tiers: list[Tier]) -> Decimal:
    if hours < 0:
        raise ValueError("hours cannot be negative")

    remaining = hours
    total = Decimal("0")

    for capacity, rate in tiers:
        if rate < 0:
            raise ValueError("rate cannot be negative")

        if remaining == 0:
            break

        if capacity is None:
            billable_hours = remaining
        else:
            if capacity <= 0:
                raise ValueError("tier capacity must be greater than zero")

            billable_hours = min(remaining, capacity)

        total += billable_hours * rate
        remaining -= billable_hours

    if remaining > 0:
        raise ValueError("billing tiers do not cover all hours")

    return total

使用示例:

tiers = [
    (Decimal("3"), Decimal("80")),
    (Decimal("3"), Decimal("70")),
    (None, Decimal("50")),
]

total = calculate_charge(Decimal("15.2"), tiers)

print(total)  # 910.0

如果最终金额需要保留两位小数,可以按照业务规则明确指定舍入方式:

from decimal import ROUND_HALF_UP

total = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

print(total)  # 910.00

具体舍入方式应以合同或财务规则为准。ROUND_HALF_UP 是常见选择,但并不适用于所有场景。

同时返回各区间明细

实际 timesheet 通常还要在账单中列出每个区间的用量和金额。可以让函数在返回总金额的同时生成明细:

from decimal import Decimal
from typing import Optional

Tier = tuple[Optional[Decimal], Decimal]


def calculate_charge(
    hours: Decimal,
    tiers: list[Tier],
) -> tuple[Decimal, list[dict]]:
    if hours < 0:
        raise ValueError("hours cannot be negative")

    remaining = hours
    total = Decimal("0")
    breakdown = []

    for capacity, rate in tiers:
        if rate < 0:
            raise ValueError("rate cannot be negative")

        if remaining == 0:
            break

        if capacity is None:
            billable_hours = remaining
        else:
            if capacity <= 0:
                raise ValueError("tier capacity must be greater than zero")

            billable_hours = min(remaining, capacity)

        subtotal = billable_hours * rate

        breakdown.append({
            "hours": billable_hours,
            "rate": rate,
            "subtotal": subtotal,
        })

        total += subtotal
        remaining -= billable_hours

    if remaining > 0:
        raise ValueError("billing tiers do not cover all hours")

    return total, breakdown

调用方式:

tiers = [
    (Decimal("3"), Decimal("80")),
    (Decimal("3"), Decimal("70")),
    (None, Decimal("50")),
]

total, breakdown = calculate_charge(Decimal("15.2"), tiers)

for item in breakdown:
    print(
        f'{item["hours"]} hours at {item["rate"]} '
        f'= {item["subtotal"]}'
    )

print(f"total = {total}")

输出:

3 hours at 80 = 240
3 hours at 70 = 210
9.2 hours at 50 = 460.0
total = 910.0

为什么不建议使用原来的价格数组

下面这种写法看起来直观,却把区间结构藏在了重复值中:

scale = [80, 80, 80, 70, 70, 70, 50]

它有几个问题:

  • 无法明确最后的 50 只适用于第 7 小时,还是适用于此后的所有时间。
  • 很难表示非整数小时的区间,例如前 2.5 小时。
  • 区间较大时,需要保存大量重复数据。
  • 调整价格或区间长度时容易出现配置错误。
  • 无法直接添加区间名称、税率或折扣类型等信息。

如果要把配置保存到 JSON 或数据库,可以改用含义更清楚的对象结构:

tiers = [
    {"capacity": "3", "rate": "80"},
    {"capacity": "3", "rate": "70"},
    {"capacity": None, "rate": "50"},
]

读取配置后再将字段转换为 Decimal:

parsed_tiers = [
    (
        Decimal(tier["capacity"]) if tier["capacity"] is not None else None,
        Decimal(tier["rate"]),
    )
    for tier in tiers
]

另一种表示:累计上限

有些系统会记录每个区间的累计上限:

tiers = [
    {"up_to": Decimal("3"), "rate": Decimal("80")},
    {"up_to": Decimal("6"), "rate": Decimal("70")},
    {"up_to": None, "rate": Decimal("50")},
]

第二档的 up_to: 6 表示累计工作时间从第 3 小时到第 6 小时,并不是说该区间本身包含 6 小时。

两种表示都能使用,但不能混在一起:

  • capacity 表示当前区间的宽度。
  • up_to 表示从零开始计算的累计上限。

动态配置时,capacity 通常更容易计算。如果管理界面需要显示“0 到 3、3 到 6、6+”,累计上限可能更直观。

与 volume pricing 的区别

这里采用的是累进式阶梯计价,每个单价只用于落入相应区间的那部分用量。

它不同于有时被称为 volume pricing 的整体定价方式。整体定价会先根据总用量确定档位,再把该档位的单价应用于全部小时。例如,15.2 小时全部按 50 计价时,金额是:

15.2 × 50 = 760

当前规则会分别计算各个区间,因此金额为 910。需求说明、配置字段和合同都应明确区分这两种模式,以免账单产生歧义。

配置时需要注意

  • 区间必须按照计费顺序保存。
  • 除最后一档外,每一档都要设置明确且大于零的容量。
  • 没有上限的区间只能放在最后。
  • 如果没有设置无上限区间,而总工时又超过了所有区间的容量,需要明确选择报错、封顶或使用其他价格,不能直接忽略剩余工时。
  • 金额和工时都应使用 Decimal,并从字符串创建,例如 Decimal("15.2"),不要先转换成 15.2 这样的浮点数。
  • 需要明确金额是在每个区间分别舍入,还是汇总后统一舍入。这两种方式在某些数据下会得到不同结果。
  • 如果费率会随日期变化,还要为计费方案设置生效时间,以免配置更新后改变历史账单。

备注:内容仅供参考。