PayrollManagementSystem/backend/app/services/param_registry.py
liuwenxin 1e53792ad6 feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
2026-11-05 18:47:13 +08:00

425 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""计提参数目录注册表。
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**:
说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、
内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。
"""
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,以保留准确取值。",
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="回款天数超过分界天数后,按此固定比例计提。",
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="按日递减的回款天数上限。超过该天数后,使用业务员计提下限。",
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="企业户按此比例计提,不随回款天数递减。实际适用值以回款到账日的生效记录为准。",
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="receipt.confirmation.enabled",
name="回款人工确认开关",
group="回款确认",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="1",
value_min=None,
value_max=None,
description="默认开启:未人工确认的回款不得正式核算。关闭仅跳过人工确认,仍检查凭证等资格;与工资审核开关独立。",
sort_order=16,
),
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="历史核算的支付基数设置。正式核算按累计回款比例的平方计算累计应付,本月只计与历史应付的差额。",
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="折算毛利润额=不含运费的含税销售额×(毛利率-阈值)。低于阈值时结果可为负值,仍按规则计入。",
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="设置基本工资是否与绩效相加、扣除已发基本工资,或不参与月工资计算。",
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="旧版比例分配方案的审核设置,仅用于历史核算流程。当前正式核算按各人发货对应的绩效分配。",
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="启用系统审核后,设置月度结算是否经过财务复核和领导审核。系统审核关闭时,工资生成即完成,不产生审核流程记录。",
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="启用系统审核后,设置调整单是否经过财务复核和领导审核。关闭时调整单直接生效,业务操作记录仍保留。",
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="历史核算的经理个人户汇总设置。当前正式核算排除经理本人业务,不受此参数影响。",
sort_order=20,
),
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
# 首批三条中「同步连续失败」依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目
ParamSpec(
code="warning.enabled",
name="风险预警总开关",
group="预警规则",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="1",
value_min=None,
value_max=None,
description="控制首页的业务风险预警,关闭后停止按回款逾期、审批超时等规则扫描。",
sort_order=21,
),
ParamSpec(
code="warning.receipt.overdue.days",
name="回款逾期天数阈值",
group="预警规则",
value_type="INT",
unit="天",
dimension_type="NONE",
options=None,
default_value="90",
value_min="1",
value_max=None,
description="发货后超过设定天数仍未收到回款的批次,会显示在首页风险预警中。",
sort_order=22,
),
ParamSpec(
code="warning.approval.timeout.days",
name="审批超时天数阈值",
group="预警规则",
value_type="INT",
unit="天",
dimension_type="NONE",
options=None,
default_value="3",
value_min="1",
value_max=None,
description="审核流程开启后,提交超过设定天数仍未处理的单据,会显示在首页风险预警中。",
sort_order=23,
),
]
}
for _field, _title, _kind, _unit in (
("first", "个人户首日比例", "DECIMAL", "‰"),
("start", "个人户线性阶段起始比例", "DECIMAL", "‰"),
("slope", "个人户线性阶段日斜率", "DECIMAL", "‰/天"),
("floor", "个人户超期比例", "DECIMAL", "‰"),
("boundary", "个人户线性阶段截止天数", "INT", "天"),
):
_code = f"coefficient.personal.{_field}"
PARAM_REGISTRY[_code] = ParamSpec(
code=_code, name=_title, group="产品系数表", value_type=_kind, unit=_unit,
dimension_type="PRODUCT_CATEGORY", options=None, default_value=None,
value_min="1" if _kind == "INT" else "0", value_max="3660" if _kind == "INT" else None,
description="按产品类别维护;未配置时沿用同日期的通用业务员参数。", sort_order=30 + len(PARAM_REGISTRY),
)
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