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:
liuwenxin 2026-10-12 16:22:32 +08:00
parent bc019cadf5
commit 5ed5665a54
6 changed files with 758 additions and 0 deletions

View File

@ -37,6 +37,15 @@ PASSWORD_MAX_BYTES = 72
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
CAPTCHA_LENGTH = 4 CAPTCHA_LENGTH = 4
# ===== 业务岗位编码(与 db/migration 种子一致,business_roles.role_code)=====
BUSINESS_ROLE_SALESPERSON = "salesperson"
BUSINESS_ROLE_SALES_MANAGER = "sales_manager"
BUSINESS_ROLE_INFO_OFFICER = "info_officer"
# ===== 产品类别码(与 param_values.dimension_value 口径一致)=====
PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
# ===== 操作日志词表(operation_logs 的 module / action 取值)===== # ===== 操作日志词表(operation_logs 的 module / action 取值)=====
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致 # 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
MODULE_USER = "用户管理" MODULE_USER = "用户管理"

View File

@ -0,0 +1,72 @@
"""计提公式模板注册表(代码内有限集合)。
一期不引入规则引擎的落地方式:公式骨架固定(本表枚举),变化的是参数值
(系统设置维护)。岗位角色(business_roles.calc_template)绑定模板编码,
新增岗位 = 选模板 + 配参数,无需改代码(需求 #28)。
新增公式形态(如未来「按产品数量计提」)需要新增模板 —— 一期接受,
岗位表单中模板为带说明的受控下拉,避免误以为可自由写公式。
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class TemplateSpec:
"""公式模板元数据。"""
code: str
name: str
params: tuple[str, ...]
description: str
TEMPLATES: dict[str, TemplateSpec] = {
"TPL_SALESPERSON_DECAY": TemplateSpec(
code="TPL_SALESPERSON_DECAY",
name="业务员个人户·天数线性递减",
params=(
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
"salesperson.days.boundary",
),
description="分段:≤分界天数 = 起始 − 天数×斜率;>分界天数 = 下限",
),
"TPL_MANAGER_SHARE": TemplateSpec(
code="TPL_MANAGER_SHARE",
name="营销经理·团队与全公司分成",
params=("manager.share.team", "manager.share.company"),
description="聚合:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
),
"TPL_ENTERPRISE_FIXED": TemplateSpec(
code="TPL_ENTERPRISE_FIXED",
name="企业户·固定计提",
params=("enterprise.rate",),
description="乘法:折算毛利润额 × 计提比例",
),
"TPL_INFO_SALES_FIXED": TemplateSpec(
code="TPL_INFO_SALES_FIXED",
name="信息员·销售额固定比例",
params=("info.rate",),
description="乘法:本人销售额 × 计提比例",
),
"TPL_PREPAY_FIXED": TemplateSpec(
code="TPL_PREPAY_FIXED",
name="预付款·固定计提",
params=("prepay.rate",),
description="乘法:回款额 × 计提比例",
),
}
def get_template(code: str) -> TemplateSpec:
"""取模板元数据。未注册编码属代码缺陷(500),不是用户错误。"""
tpl = TEMPLATES.get(code)
if tpl is None:
raise ValueError(f"公式模板编码未注册:{code}")
return tpl
def required_params(code: str) -> tuple[str, ...]:
"""模板声明的参数编码列表(岗位表单展示与校验用)。"""
return get_template(code).params

View File

@ -0,0 +1,88 @@
"""计提公式纯函数(无 IO、无会话)。
契约(与 M3 核算、计算预览共用,口径见 2026-10-12 业务裁决):
- 参数值按**存储口径**传入(含单位:比例参数为 ‰ 数如 10、百分比参数为 % 数如 60)
- 计算出的中间比率(如毛利润率)为**纯比例**(Fraction,如 9/20)
- 金额用 Decimal、比率用 Fraction,全程禁 float;Fraction→Decimal 转换在
分母仅含 2/5 因子时精确,否则按 Decimal 上下文精度(28 位)
- 绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3,禁按"互为倒数"实现)
"""
from datetime import date
from decimal import Decimal
from fractions import Fraction
def _to_decimal(value: Fraction) -> Decimal:
"""Fraction → Decimal。有限小数精确;循环小数按 Decimal 上下文精度(28 位)。"""
return Decimal(value.numerator) / Decimal(value.denominator)
def payment_days(ship_date: date, receipt_date: date) -> int:
"""回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。"""
return (receipt_date - ship_date).days + 1
def salesperson_rate(
days: int,
*,
start: Fraction,
slope: Fraction,
floor: Fraction,
boundary_days: int,
) -> Fraction:
"""业务员计提比例(纯比例)。
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
"""
if days > boundary_days:
return floor / 1000
return (start - days * slope) / 1000
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction:
"""毛利润率(纯比例):毛利润额 / 销售额(**不含运费**口径,对照表 #2)。
销售额为 0 时返回 0(防除零;折算额随之为 0)。
"""
if revenue == 0:
return Fraction(0)
return Fraction(profit) / Fraction(revenue)
def converted_profit(revenue: Decimal, rate: Fraction, threshold: Fraction) -> Decimal:
"""折算毛利润额(化简式):销售额 × (毛利润率 − 阈值)。
rate 为纯比例(profit_rate 的输出);threshold 为 % 数(存储口径,如 30)。
化简式等价于定义式「毛利润额 / 毛利润率 × (毛利润率 − 阈值)」,且避免
毛利润率为 0 时除零(对照表 #12/#13)。毛利润率低于阈值时结果为负 ——
业务确认负值正常计算、不归零。
"""
return _to_decimal(Fraction(revenue) * (rate - threshold / 100))
def performance_amount(base: Decimal, rate: Fraction) -> Decimal:
"""绩效金额 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。"""
return _to_decimal(Fraction(base) * rate)
def apply_barter(amount: Decimal, discount: Fraction) -> Decimal:
"""以物抵顶折算:应发绩效 × 折算比例(discount 为 % 数,默认 80,对照表 #24)。"""
return _to_decimal(Fraction(amount) * discount / 100)
def payment_weight(r: int) -> Fraction:
"""支付权重 R×R(纯比例):R 为回款占销售额百分比向下取整(对照表 #22)。
60 → 36/100;100 → 1(全额)。
"""
return Fraction(r, 100) * Fraction(r, 100)
def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bool:
"""起付判定:单项业务回款额达到销售额(不含运费)的 ratio%(对照表 #21)。"""
if revenue == 0:
return False
return Fraction(received) / Fraction(revenue) >= ratio / 100

View File

@ -0,0 +1,304 @@
"""计提参数目录注册表。
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
"""
from dataclasses import dataclass
from fractions import Fraction
from app.core.exceptions import BadRequestError
@dataclass(frozen=True, slots=True)
class ParamSpec:
"""单个参数的元数据(与 param_definitions 表字段一一对应)。"""
code: str
name: str
group: str
value_type: str
unit: str | None
dimension_type: str
options: tuple[str, ...] | None
default_value: str | None
value_min: str | None
value_max: str | None
description: str | None
sort_order: int
PARAM_REGISTRY: dict[str, ParamSpec] = {
spec.code: spec
for spec in [
ParamSpec(
code="salesperson.rate.start",
name="业务员起始计提比例",
group="业务员个人户",
value_type="DECIMAL",
unit="‰",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="10",
value_min="0",
value_max=None,
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
sort_order=1,
),
ParamSpec(
code="salesperson.rate.slope",
name="业务员日递减斜率",
group="业务员个人户",
value_type="DECIMAL",
unit="‰/天",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="4/360",
value_min="0",
value_max=None,
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
sort_order=2,
),
ParamSpec(
code="salesperson.rate.floor",
name="业务员计提下限",
group="业务员个人户",
value_type="DECIMAL",
unit="‰",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="4",
value_min="0",
value_max=None,
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
sort_order=3,
),
ParamSpec(
code="salesperson.days.boundary",
name="线性递减分界天数",
group="业务员个人户",
value_type="INT",
unit="天",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="360",
value_min="0",
value_max=None,
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
sort_order=4,
),
ParamSpec(
code="manager.share.team",
name="经理本团队分成比例",
group="经理分成",
value_type="DECIMAL",
unit="%",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="12.5",
value_min="0",
value_max="100",
description="∑本人带领业务员绩效 × 此比例(限本团队)",
sort_order=5,
),
ParamSpec(
code="manager.share.company",
name="经理全公司分成比例",
group="经理分成",
value_type="DECIMAL",
unit="%",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="5",
value_min="0",
value_max="100",
description="∑全部业务员绩效 × 此比例(全公司)",
sort_order=6,
),
ParamSpec(
code="enterprise.rate",
name="企业户计提比例",
group="企业户",
value_type="DECIMAL",
unit="‰",
dimension_type="PRODUCT_CATEGORY",
options=None,
default_value="3",
value_min="0",
value_max=None,
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
sort_order=7,
),
ParamSpec(
code="info.rate",
name="信息员计提比例",
group="信息员",
value_type="DECIMAL",
unit="‰",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="1",
value_min="0",
value_max=None,
description="信息员绩效 = 本人销售额 × 此比例",
sort_order=8,
),
ParamSpec(
code="prepay.rate",
name="预付款计提比例",
group="支付环节",
value_type="DECIMAL",
unit="‰",
dimension_type="NONE",
options=None,
default_value="10",
value_min="0",
value_max=None,
description="预付款统一按此比例计提",
sort_order=9,
),
ParamSpec(
code="barter.discount",
name="以物抵顶折算比例",
group="支付环节",
value_type="DECIMAL",
unit="%",
dimension_type="NONE",
options=None,
default_value="80",
value_min="0",
value_max="100",
description="以物抵顶回款绩效按应发绩效的此比例折算",
sort_order=10,
),
ParamSpec(
code="payment.start.ratio",
name="支付起付比例",
group="支付环节",
value_type="DECIMAL",
unit="%",
dimension_type="NONE",
options=None,
default_value="60",
value_min="0",
value_max="100",
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
sort_order=11,
),
ParamSpec(
code="payment.weight.mode",
name="支付权重应用方式",
group="支付环节",
value_type="ENUM",
unit=None,
dimension_type="NONE",
options=("FULL", "BALANCE"),
default_value="FULL",
value_min=None,
value_max=None,
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
sort_order=12,
),
ParamSpec(
code="product.threshold",
name="折算毛利润额阈值",
group="产品阈值",
value_type="DECIMAL",
unit="%",
dimension_type="PRODUCT_CATEGORY",
options=None,
default_value="30",
value_min="0",
value_max="100",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
sort_order=13,
),
ParamSpec(
code="salary.basic.mode",
name="月工资基本工资口径",
group="月工资口径",
value_type="ENUM",
unit=None,
dimension_type="BUSINESS_ROLE",
options=("INCLUDE", "DEDUCT", "EXCLUDE"),
default_value="INCLUDE",
value_min=None,
value_max=None,
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
sort_order=14,
),
ParamSpec(
code="probation.annual.threshold",
name="试用期年累计结算阈值",
group="月工资口径",
value_type="DECIMAL",
unit="元",
dimension_type="NONE",
options=None,
default_value="18000",
value_min="0",
value_max=None,
description="试用期年累计绩效超过此值部分年底一次性结算",
sort_order=15,
),
ParamSpec(
code="probation.salary.basic",
name="试用期基本工资标准",
group="月工资口径",
value_type="DECIMAL",
unit="元",
dimension_type="NONE",
options=None,
default_value="3000",
value_min="0",
value_max=None,
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
sort_order=16,
),
]
}
def get_spec(code: str) -> ParamSpec:
"""取参数元数据。未注册编码属代码缺陷(500),不是用户错误。"""
spec = PARAM_REGISTRY.get(code)
if spec is None:
raise ValueError(f"参数编码未注册:{code}")
return spec
def parse_fraction(text: str) -> Fraction:
"""Fraction 精确解析("4/360" → 1/90,不经 float)。非法输入抛 400。"""
try:
return Fraction(text.strip())
except (ValueError, ZeroDivisionError) as exc:
raise BadRequestError(f"参数值无法解析为数值:{text}") from exc
def validate_and_normalize(spec: ParamSpec, raw: str) -> str:
"""按元数据校验并规范化参数值,返回可入库字符串。
DECIMAL:Fraction 解析 + 取值域校验,分数式原样保留("4/360" 不截断);
INT:必须整数;BOOL:仅 0/1;ENUM:必须命中 options;TEXT:原样返回。
"""
value = raw.strip()
if spec.value_type in {"DECIMAL", "INT"}:
fraction = parse_fraction(value)
if spec.value_type == "INT" and fraction.denominator != 1:
raise BadRequestError(f"参数 {spec.code} 必须为整数:{raw}")
if spec.value_min is not None and fraction < parse_fraction(spec.value_min):
raise BadRequestError(f"参数 {spec.code} 低于取值下限 {spec.value_min}:{raw}")
if spec.value_max is not None and fraction > parse_fraction(spec.value_max):
raise BadRequestError(f"参数 {spec.code} 高于取值上限 {spec.value_max}:{raw}")
return value
if spec.value_type == "BOOL":
if value not in {"0", "1"}:
raise BadRequestError(f"参数 {spec.code} 仅接受 0/1:{raw}")
return value
if spec.value_type == "ENUM":
if not spec.options or value not in spec.options:
allowed = "/".join(spec.options or ())
raise BadRequestError(f"参数 {spec.code} 取值须为 {allowed}:{raw}")
return value
return value

View File

@ -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

View File

@ -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")