"""计提参数目录注册表。 注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义 一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。 值一律以 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