diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index fc44fd9..ed67f0a 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -37,6 +37,15 @@ PASSWORD_MAX_BYTES = 72 CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" 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 取值)===== # 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致 MODULE_USER = "用户管理" diff --git a/backend/app/services/calc_templates.py b/backend/app/services/calc_templates.py new file mode 100644 index 0000000..9f22044 --- /dev/null +++ b/backend/app/services/calc_templates.py @@ -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 diff --git a/backend/app/services/formula.py b/backend/app/services/formula.py new file mode 100644 index 0000000..fd2c999 --- /dev/null +++ b/backend/app/services/formula.py @@ -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 diff --git a/backend/app/services/param_registry.py b/backend/app/services/param_registry.py new file mode 100644 index 0000000..8063a46 --- /dev/null +++ b/backend/app/services/param_registry.py @@ -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 diff --git a/backend/tests/unit/test_formula.py b/backend/tests/unit/test_formula.py new file mode 100644 index 0000000..f252d17 --- /dev/null +++ b/backend/tests/unit/test_formula.py @@ -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 diff --git a/backend/tests/unit/test_param_registry.py b/backend/tests/unit/test_param_registry.py new file mode 100644 index 0000000..6c7c596 --- /dev/null +++ b/backend/tests/unit/test_param_registry.py @@ -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")