PayrollManagementSystem/backend/app/services/param_registry.py
liuwenxin 5ece9f6dab refactor(backend): 仓储层散参数收敛为查询 DTO
仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数
传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的
约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery),
服务层与测试调用同步调整,行为不变。

- repositories 层查询方法签名改为 (session, query),分页与筛选条件
  由 Query 承载,_apply_filters 同步收口
- schemas 层补齐各模块 Query 模型(approval/business_role/report 等)
- services 层调用点同步改为传 Query
- tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、
  参数预览顺序稳定

验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:43:20 +08:00

407 lines
15 KiB
Python
Raw 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 —— 见算法红线)。
"""
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,
),
# ===== 预警规则(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="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
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=("发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"),
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=("待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"),
sort_order=23,
),
]
}
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