feat(system): 计提公式模板注册表与精确分数计算函数
- formula.py 八个纯函数:回款天数(首日计1)、业务员分段递减(360=6‰/361=4‰)、折算毛利润额化简式(防除零)、乘法方向绩效、以物抵顶/R×R/起付 - param_registry.py:16 项参数元数据(与迁移种子同源)+ Fraction 精确解析(4/360 禁截断)+ 类型/取值域/枚举校验 - calc_templates.py:5 个公式模板注册表(岗位绑定的受控集合) - 新增 43 项单元用例:精确分数断言(899/90000、27 天、9/25),畸形值拒绝 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
bc019cadf5
commit
5ed5665a54
6 files changed
+758
No files matched your search
@@ -0,0 +1,131 @@
|
||||
"""公式纯函数测试。
|
||||
|
||||
守的是:算钱公式的精确性与边界 —— 一旦按 float 或字面量截断计算,
|
||||
360/361 天分界、4/360 斜率、27 天回款示例会全部错位,直接影响工资金额。
|
||||
断言一律用 Fraction/Decimal 精确值,禁近似比较。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from app.services.formula import (
|
||||
apply_barter,
|
||||
converted_profit,
|
||||
payment_days,
|
||||
payment_weight,
|
||||
performance_amount,
|
||||
profit_rate,
|
||||
salesperson_rate,
|
||||
start_payment_ok,
|
||||
)
|
||||
|
||||
# 与迁移种子一致的业务员参数(存储口径:start/floor 为 ‰ 数,slope 为 ‰/天)
|
||||
SP_START = Fraction(10)
|
||||
SP_SLOPE = Fraction(4, 360)
|
||||
SP_FLOOR = Fraction(4)
|
||||
SP_BOUNDARY = 360
|
||||
|
||||
|
||||
def _sp_rate(days: int) -> Fraction:
|
||||
return salesperson_rate(
|
||||
days, start=SP_START, slope=SP_SLOPE, floor=SP_FLOOR, boundary_days=SP_BOUNDARY
|
||||
)
|
||||
|
||||
|
||||
class TestPaymentDays:
|
||||
"""回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。"""
|
||||
|
||||
def test_发货日当天回款为第1天(self) -> None:
|
||||
d = date(2026, 1, 1)
|
||||
assert payment_days(d, d) == 1
|
||||
|
||||
def test_12月15日发货1月10日回款为27天(self) -> None:
|
||||
assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27
|
||||
|
||||
def test_跨年按自然日连续累计(self) -> None:
|
||||
assert payment_days(date(2025, 12, 31), date(2026, 1, 1)) == 2
|
||||
|
||||
|
||||
class TestSalespersonRate:
|
||||
"""业务员计提比例:1~360 天线性递减、≥361 天固定下限(对照表 #4)。"""
|
||||
|
||||
def test_第360天恰为千分之6_线性段终点(self) -> None:
|
||||
assert _sp_rate(360) == Fraction(6, 1000)
|
||||
|
||||
def test_第361天固定下限千分之4_分界线(self) -> None:
|
||||
# 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」
|
||||
assert _sp_rate(361) == Fraction(4, 1000)
|
||||
|
||||
def test_第1天按公式为899比90000_非取整为10(self) -> None:
|
||||
# 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000
|
||||
assert _sp_rate(1) == Fraction(899, 90000)
|
||||
|
||||
def test_第180天线性段中点千分之8(self) -> None:
|
||||
assert _sp_rate(180) == Fraction(8, 1000)
|
||||
|
||||
def test_返回值为纯比例_非千分数(self) -> None:
|
||||
# 绩效 = 折算额 × 比例,比例必须是 0.006 而非 6
|
||||
assert _sp_rate(360) == Fraction(6, 1000)
|
||||
assert _sp_rate(360) != Fraction(6)
|
||||
|
||||
|
||||
class TestProfitRate:
|
||||
"""毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。"""
|
||||
|
||||
def test_毛利润率_不含运费(self) -> None:
|
||||
assert profit_rate(Decimal("45"), Decimal("100")) == Fraction(9, 20)
|
||||
|
||||
def test_销售额为零_返回零_防除零(self) -> None:
|
||||
assert profit_rate(Decimal("0"), Decimal("0")) == Fraction(0)
|
||||
|
||||
|
||||
class TestConvertedProfit:
|
||||
"""折算毛利润额(化简式):销售额 ×(毛利润率 − 阈值)(对照表 #12/#13)。"""
|
||||
|
||||
def test_化简式结果_市政水利30点阈值(self) -> None:
|
||||
# 100 × (9/20 − 30/100) = 15
|
||||
assert converted_profit(Decimal("100"), Fraction(9, 20), Fraction(30)) == Decimal("15")
|
||||
|
||||
def test_化简式与定义式等价(self) -> None:
|
||||
# 定义式:毛利润额 / 毛利润率 × (毛利润率 − 阈值) = 45 / (9/20) × (9/20 − 3/10) = 15
|
||||
revenue, profit = Decimal("100"), Decimal("45")
|
||||
rate = profit_rate(profit, revenue)
|
||||
assert converted_profit(revenue, rate, Fraction(30)) == Decimal("15")
|
||||
|
||||
def test_毛利润额为零_折算为零_不除零(self) -> None:
|
||||
assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0")
|
||||
|
||||
def test_毛利润率低于阈值_折算为负_不归零(self) -> None:
|
||||
# 100 × (1/10 − 3/10) = -20(业务确认负值正常计算)
|
||||
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20")
|
||||
|
||||
|
||||
class TestPerformanceAmount:
|
||||
"""绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。"""
|
||||
|
||||
def test_乘法方向_折算额乘比例(self) -> None:
|
||||
# 15000 × 97/10000 = 145.5
|
||||
assert performance_amount(Decimal("15000"), Fraction(97, 10000)) == Decimal("145.5")
|
||||
|
||||
def test_负折算额_绩效为负不归零(self) -> None:
|
||||
assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12")
|
||||
|
||||
|
||||
class TestModifiers:
|
||||
"""修饰器:以物抵顶、支付权重 R×R、起付判定(对照表 #21/#22/#24)。"""
|
||||
|
||||
def test_以物抵顶_按80点折算(self) -> None:
|
||||
assert apply_barter(Decimal("145.5"), Fraction(80)) == Decimal("116.4")
|
||||
|
||||
def test_支付权重_R为60时0点36(self) -> None:
|
||||
assert payment_weight(60) == Fraction(36, 100)
|
||||
|
||||
def test_支付权重_R为100时全额(self) -> None:
|
||||
assert payment_weight(100) == Fraction(1)
|
||||
|
||||
def test_起付判定_达到60点(self) -> None:
|
||||
assert start_payment_ok(Decimal("60000"), Decimal("100000"), Fraction(60)) is True
|
||||
|
||||
def test_起付判定_未达到60点(self) -> None:
|
||||
assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False
|
||||
@@ -0,0 +1,154 @@
|
||||
"""参数注册表与公式模板注册表测试。
|
||||
|
||||
守的是:注册表是参数种子数据与运行期校验的唯一来源 —— 一旦编码或默认值
|
||||
与迁移种子漂移,参数解析会取到错误口径;一旦取值校验失守,
|
||||
畸形值(如 "1/0")会落库并在核算时崩溃或算错钱。
|
||||
"""
|
||||
|
||||
from fractions import Fraction
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.services.calc_templates import TEMPLATES, get_template, required_params
|
||||
from app.services.param_registry import (
|
||||
PARAM_REGISTRY,
|
||||
get_spec,
|
||||
parse_fraction,
|
||||
validate_and_normalize,
|
||||
)
|
||||
|
||||
# 与 db/migration/V3 迁移种子保持一致的 16 项编码
|
||||
EXPECTED_CODES = {
|
||||
"salesperson.rate.start",
|
||||
"salesperson.rate.slope",
|
||||
"salesperson.rate.floor",
|
||||
"salesperson.days.boundary",
|
||||
"manager.share.team",
|
||||
"manager.share.company",
|
||||
"enterprise.rate",
|
||||
"info.rate",
|
||||
"prepay.rate",
|
||||
"barter.discount",
|
||||
"payment.start.ratio",
|
||||
"payment.weight.mode",
|
||||
"product.threshold",
|
||||
"salary.basic.mode",
|
||||
"probation.annual.threshold",
|
||||
"probation.salary.basic",
|
||||
}
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||
|
||||
def test_注册表共16项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 16
|
||||
|
||||
def test_编码集合与迁移种子一致(self) -> None:
|
||||
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
||||
|
||||
def test_每项必填字段非空(self) -> None:
|
||||
for spec in PARAM_REGISTRY.values():
|
||||
assert spec.code, "code 为空"
|
||||
assert spec.name, f"{spec.code} 缺中文名称"
|
||||
assert spec.group, f"{spec.code} 缺分组"
|
||||
assert spec.value_type in {"DECIMAL", "INT", "BOOL", "ENUM", "TEXT"}
|
||||
|
||||
def test_默认值可按类型解析(self) -> None:
|
||||
for spec in PARAM_REGISTRY.values():
|
||||
if spec.default_value is None:
|
||||
continue
|
||||
if spec.value_type in {"DECIMAL", "INT"}:
|
||||
parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError
|
||||
elif spec.value_type == "ENUM":
|
||||
assert spec.options, f"{spec.code} 为 ENUM 但无选项"
|
||||
assert spec.default_value in spec.options
|
||||
|
||||
def test_取值域上下限可解析(self) -> None:
|
||||
for spec in PARAM_REGISTRY.values():
|
||||
if spec.value_min is not None:
|
||||
parse_fraction(spec.value_min)
|
||||
if spec.value_max is not None:
|
||||
parse_fraction(spec.value_max)
|
||||
|
||||
def test_取未注册编码抛ValueError_属代码缺陷(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
get_spec("not.exist.code")
|
||||
|
||||
|
||||
class TestParseFraction:
|
||||
"""Fraction 精确解析:4/360 不许经 float 中转。"""
|
||||
|
||||
def test_分数式精确解析_4除以360(self) -> None:
|
||||
assert parse_fraction("4/360") == Fraction(1, 90)
|
||||
|
||||
def test_整数解析(self) -> None:
|
||||
assert parse_fraction("10") == Fraction(10)
|
||||
|
||||
def test_小数解析(self) -> None:
|
||||
assert parse_fraction("12.5") == Fraction(25, 2)
|
||||
|
||||
def test_除零分数拒绝(self) -> None:
|
||||
with pytest.raises(BadRequestError):
|
||||
parse_fraction("1/0")
|
||||
|
||||
def test_非数字拒绝(self) -> None:
|
||||
with pytest.raises(BadRequestError):
|
||||
parse_fraction("abc")
|
||||
|
||||
|
||||
class TestValidateAndNormalize:
|
||||
"""参数值校验:类型/取值域/枚举(审查重点 1:畸形值不得落库)。"""
|
||||
|
||||
def test_去除首尾空白后返回规范值(self) -> None:
|
||||
spec = PARAM_REGISTRY["barter.discount"]
|
||||
assert validate_and_normalize(spec, " 80 ") == "80"
|
||||
|
||||
def test_超取值域上限拒绝(self) -> None:
|
||||
spec = PARAM_REGISTRY["barter.discount"] # value_max=100
|
||||
with pytest.raises(BadRequestError):
|
||||
validate_and_normalize(spec, "120")
|
||||
|
||||
def test_低于取值域下限拒绝(self) -> None:
|
||||
spec = PARAM_REGISTRY["barter.discount"] # value_min=0
|
||||
with pytest.raises(BadRequestError):
|
||||
validate_and_normalize(spec, "-1")
|
||||
|
||||
def test_枚举越界拒绝(self) -> None:
|
||||
spec = PARAM_REGISTRY["payment.weight.mode"]
|
||||
with pytest.raises(BadRequestError):
|
||||
validate_and_normalize(spec, "PARTIAL")
|
||||
|
||||
def test_枚举合法值通过(self) -> None:
|
||||
spec = PARAM_REGISTRY["payment.weight.mode"]
|
||||
assert validate_and_normalize(spec, "BALANCE") == "BALANCE"
|
||||
|
||||
def test_整数类型拒绝小数(self) -> None:
|
||||
spec = PARAM_REGISTRY["salesperson.days.boundary"]
|
||||
with pytest.raises(BadRequestError):
|
||||
validate_and_normalize(spec, "360.5")
|
||||
|
||||
def test_分数式斜率原样保留不截断(self) -> None:
|
||||
spec = PARAM_REGISTRY["salesperson.rate.slope"]
|
||||
assert validate_and_normalize(spec, "4/360") == "4/360"
|
||||
|
||||
|
||||
class TestTemplates:
|
||||
"""公式模板注册表:有限集合,声明参数必须真实存在。"""
|
||||
|
||||
def test_共五个模板(self) -> None:
|
||||
assert len(TEMPLATES) == 5
|
||||
|
||||
def test_模板声明参数均在注册表内(self) -> None:
|
||||
for tpl in TEMPLATES.values():
|
||||
for code in tpl.params:
|
||||
assert code in PARAM_REGISTRY, f"{tpl.code} 声明了未注册参数 {code}"
|
||||
|
||||
def test_取模板声明参数(self) -> None:
|
||||
params = required_params("TPL_SALESPERSON_DECAY")
|
||||
assert "salesperson.rate.slope" in params
|
||||
|
||||
def test_未知模板抛ValueError_属代码缺陷(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
get_template("TPL_NOT_EXIST")
|
||||
Reference in new issue
Block a user