- #5/#7 答复默认开启:两审批开关 default 翻 1,插 10-19 生效行保时点历史 - #16 答复逐人维护:撤 probation.salary.basic(无业务消费点,月结已读 personnel.salary_standard) - #12 答复参数化:新增 manager.personal.included(默认 0=不计入聚合基数) - #1 答复分配绩效铺路:台账 uk 扩为 (receipt_id, calc_version, personnel_id) 支持分摊拆行 - schema_sync 解析器过滤 ADD CONSTRAINT 误捕获 Co-Authored-By: Claude Code <noreply@anthropic.com>
363 lines
13 KiB
Python
363 lines
13 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="approval.secondary_allocation.enabled",
|
||
name="二次分配审批开关",
|
||
group="审批开关",
|
||
value_type="BOOL",
|
||
unit=None,
|
||
dimension_type="NONE",
|
||
options=None,
|
||
default_value="1",
|
||
value_min=None,
|
||
value_max=None,
|
||
description=(
|
||
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
||
"1=提交后进入财务复核→领导审核审批流。"
|
||
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
|
||
),
|
||
sort_order=17,
|
||
),
|
||
ParamSpec(
|
||
code="approval.monthly_settlement.enabled",
|
||
name="月度结算审批开关",
|
||
group="审批开关",
|
||
value_type="BOOL",
|
||
unit=None,
|
||
dimension_type="NONE",
|
||
options=None,
|
||
default_value="1",
|
||
value_min=None,
|
||
value_max=None,
|
||
description=(
|
||
"月度结算审批链开关(④ 月结提交时消费):"
|
||
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
|
||
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
|
||
),
|
||
sort_order=18,
|
||
),
|
||
ParamSpec(
|
||
code="approval.adjustment.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=走财务复核→领导审核审批流。"
|
||
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
|
||
"默认值业务未明确,暂按直接生效,业务可开启)"
|
||
),
|
||
sort_order=19,
|
||
),
|
||
ParamSpec(
|
||
code="manager.personal.included",
|
||
name="经理个人户计入聚合基数",
|
||
group="经理分成",
|
||
value_type="BOOL",
|
||
unit=None,
|
||
dimension_type="NONE",
|
||
options=None,
|
||
default_value="0",
|
||
value_min=None,
|
||
value_max=None,
|
||
description=(
|
||
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
|
||
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
|
||
"0=不计入(默认,暂口径) 1=计入"
|
||
),
|
||
sort_order=20,
|
||
),
|
||
]
|
||
}
|
||
|
||
|
||
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
|