如何将数值拆分为多个计费区间实现 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,表示尚未计费的小时数。依次处理每个计费区间:
- 如果区间有容量限制,使用
min(remaining, capacity)计算落入该区间的小时数。 - 如果区间没有上限,将所有剩余小时数计入该区间。
- 用区间小时数乘以单价,并累加到总金额中。
- 从
remaining中扣除已经处理的小时数。 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这样的浮点数。 - 需要明确金额是在每个区间分别舍入,还是汇总后统一舍入。这两种方式在某些数据下会得到不同结果。
- 如果费率会随日期变化,还要为计费方案设置生效时间,以免配置更新后改变历史账单。
备注:内容仅供参考。
