- 新增 system_settings(公司信息单行)/ business_secondary_allocations(二次 分配明细,比例不强制合计满额,分摊计算待澄清另批)/ approval_orders+ approval_steps(三级审批状态机,开关关闭自动通过留痕) - roles 种子新增营销后勤/财务/领导三职能角色;内置保护改 BUILTIN_ROLE_CODES 白名单(roles 表无 is_builtin 列,不加列) - PARAM_REGISTRY 首例 BOOL:两个审批开关(16→18 项,种子双写) - 双轨同步:V6 导出 SQL + init-dev.sql(表/索引/种子/版本号 79bcc5aa98d4) - 测试同步:EXPECTED_TABLES 四新表、角色种子断言 5 角色、目录断言 18 项 八分组、BOOL 校验边界;迁移 downgrade 修 1553(只保留 drop_table)并补 行级种子反向清理 Co-Authored-By: Claude Code <noreply@anthropic.com>
339 lines
12 KiB
Python
339 lines
12 KiB
Python
"""计提参数目录注册表。
|
||
|
||
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
|
||
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
|
||
值一律以 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,
|
||
),
|
||
ParamSpec(
|
||
code="approval.secondary_allocation.enabled",
|
||
name="二次分配审批开关",
|
||
group="审批开关",
|
||
value_type="BOOL",
|
||
unit=None,
|
||
dimension_type="NONE",
|
||
options=None,
|
||
default_value="0",
|
||
value_min=None,
|
||
value_max=None,
|
||
description=(
|
||
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
||
"1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)"
|
||
),
|
||
sort_order=17,
|
||
),
|
||
ParamSpec(
|
||
code="approval.monthly_settlement.enabled",
|
||
name="月度结算审批开关",
|
||
group="审批开关",
|
||
value_type="BOOL",
|
||
unit=None,
|
||
dimension_type="NONE",
|
||
options=None,
|
||
default_value="0",
|
||
value_min=None,
|
||
value_max=None,
|
||
description=(
|
||
"预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);"
|
||
"0=结算生成即生效,1=走财务复核→领导审核审批流"
|
||
),
|
||
sort_order=18,
|
||
),
|
||
]
|
||
}
|
||
|
||
|
||
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
|