feat(system): 计提公式模板注册表与精确分数计算函数
- formula.py 八个纯函数:回款天数(首日计1)、业务员分段递减(360=6‰/361=4‰)、折算毛利润额化简式(防除零)、乘法方向绩效、以物抵顶/R×R/起付 - param_registry.py:16 项参数元数据(与迁移种子同源)+ Fraction 精确解析(4/360 禁截断)+ 类型/取值域/枚举校验 - calc_templates.py:5 个公式模板注册表(岗位绑定的受控集合) - 新增 43 项单元用例:精确分数断言(899/90000、27 天、9/25),畸形值拒绝 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
bc019cadf5
commit
5ed5665a54
6 files changed
+758
No files matched your search
@@ -0,0 +1,72 @@
|
||||
"""计提公式模板注册表(代码内有限集合)。
|
||||
|
||||
一期不引入规则引擎的落地方式:公式骨架固定(本表枚举),变化的是参数值
|
||||
(系统设置维护)。岗位角色(business_roles.calc_template)绑定模板编码,
|
||||
新增岗位 = 选模板 + 配参数,无需改代码(需求 #28)。
|
||||
新增公式形态(如未来「按产品数量计提」)需要新增模板 —— 一期接受,
|
||||
岗位表单中模板为带说明的受控下拉,避免误以为可自由写公式。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateSpec:
|
||||
"""公式模板元数据。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
params: tuple[str, ...]
|
||||
description: str
|
||||
|
||||
|
||||
TEMPLATES: dict[str, TemplateSpec] = {
|
||||
"TPL_SALESPERSON_DECAY": TemplateSpec(
|
||||
code="TPL_SALESPERSON_DECAY",
|
||||
name="业务员个人户·天数线性递减",
|
||||
params=(
|
||||
"salesperson.rate.start",
|
||||
"salesperson.rate.slope",
|
||||
"salesperson.rate.floor",
|
||||
"salesperson.days.boundary",
|
||||
),
|
||||
description="分段:≤分界天数 = 起始 − 天数×斜率;>分界天数 = 下限",
|
||||
),
|
||||
"TPL_MANAGER_SHARE": TemplateSpec(
|
||||
code="TPL_MANAGER_SHARE",
|
||||
name="营销经理·团队与全公司分成",
|
||||
params=("manager.share.team", "manager.share.company"),
|
||||
description="聚合:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
|
||||
),
|
||||
"TPL_ENTERPRISE_FIXED": TemplateSpec(
|
||||
code="TPL_ENTERPRISE_FIXED",
|
||||
name="企业户·固定计提",
|
||||
params=("enterprise.rate",),
|
||||
description="乘法:折算毛利润额 × 计提比例",
|
||||
),
|
||||
"TPL_INFO_SALES_FIXED": TemplateSpec(
|
||||
code="TPL_INFO_SALES_FIXED",
|
||||
name="信息员·销售额固定比例",
|
||||
params=("info.rate",),
|
||||
description="乘法:本人销售额 × 计提比例",
|
||||
),
|
||||
"TPL_PREPAY_FIXED": TemplateSpec(
|
||||
code="TPL_PREPAY_FIXED",
|
||||
name="预付款·固定计提",
|
||||
params=("prepay.rate",),
|
||||
description="乘法:回款额 × 计提比例",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_template(code: str) -> TemplateSpec:
|
||||
"""取模板元数据。未注册编码属代码缺陷(500),不是用户错误。"""
|
||||
tpl = TEMPLATES.get(code)
|
||||
if tpl is None:
|
||||
raise ValueError(f"公式模板编码未注册:{code}")
|
||||
return tpl
|
||||
|
||||
|
||||
def required_params(code: str) -> tuple[str, ...]:
|
||||
"""模板声明的参数编码列表(岗位表单展示与校验用)。"""
|
||||
return get_template(code).params
|
||||
@@ -0,0 +1,88 @@
|
||||
"""计提公式纯函数(无 IO、无会话)。
|
||||
|
||||
契约(与 M3 核算、计算预览共用,口径见 2026-10-12 业务裁决):
|
||||
- 参数值按**存储口径**传入(含单位:比例参数为 ‰ 数如 10、百分比参数为 % 数如 60)
|
||||
- 计算出的中间比率(如毛利润率)为**纯比例**(Fraction,如 9/20)
|
||||
- 金额用 Decimal、比率用 Fraction,全程禁 float;Fraction→Decimal 转换在
|
||||
分母仅含 2/5 因子时精确,否则按 Decimal 上下文精度(28 位)
|
||||
- 绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3,禁按"互为倒数"实现)
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
|
||||
def _to_decimal(value: Fraction) -> Decimal:
|
||||
"""Fraction → Decimal。有限小数精确;循环小数按 Decimal 上下文精度(28 位)。"""
|
||||
return Decimal(value.numerator) / Decimal(value.denominator)
|
||||
|
||||
|
||||
def payment_days(ship_date: date, receipt_date: date) -> int:
|
||||
"""回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。"""
|
||||
return (receipt_date - ship_date).days + 1
|
||||
|
||||
|
||||
def salesperson_rate(
|
||||
days: int,
|
||||
*,
|
||||
start: Fraction,
|
||||
slope: Fraction,
|
||||
floor: Fraction,
|
||||
boundary_days: int,
|
||||
) -> Fraction:
|
||||
"""业务员计提比例(纯比例)。
|
||||
|
||||
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
|
||||
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
|
||||
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
|
||||
"""
|
||||
if days > boundary_days:
|
||||
return floor / 1000
|
||||
return (start - days * slope) / 1000
|
||||
|
||||
|
||||
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction:
|
||||
"""毛利润率(纯比例):毛利润额 / 销售额(**不含运费**口径,对照表 #2)。
|
||||
|
||||
销售额为 0 时返回 0(防除零;折算额随之为 0)。
|
||||
"""
|
||||
if revenue == 0:
|
||||
return Fraction(0)
|
||||
return Fraction(profit) / Fraction(revenue)
|
||||
|
||||
|
||||
def converted_profit(revenue: Decimal, rate: Fraction, threshold: Fraction) -> Decimal:
|
||||
"""折算毛利润额(化简式):销售额 × (毛利润率 − 阈值)。
|
||||
|
||||
rate 为纯比例(profit_rate 的输出);threshold 为 % 数(存储口径,如 30)。
|
||||
化简式等价于定义式「毛利润额 / 毛利润率 × (毛利润率 − 阈值)」,且避免
|
||||
毛利润率为 0 时除零(对照表 #12/#13)。毛利润率低于阈值时结果为负 ——
|
||||
业务确认负值正常计算、不归零。
|
||||
"""
|
||||
return _to_decimal(Fraction(revenue) * (rate - threshold / 100))
|
||||
|
||||
|
||||
def performance_amount(base: Decimal, rate: Fraction) -> Decimal:
|
||||
"""绩效金额 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。"""
|
||||
return _to_decimal(Fraction(base) * rate)
|
||||
|
||||
|
||||
def apply_barter(amount: Decimal, discount: Fraction) -> Decimal:
|
||||
"""以物抵顶折算:应发绩效 × 折算比例(discount 为 % 数,默认 80,对照表 #24)。"""
|
||||
return _to_decimal(Fraction(amount) * discount / 100)
|
||||
|
||||
|
||||
def payment_weight(r: int) -> Fraction:
|
||||
"""支付权重 R×R(纯比例):R 为回款占销售额百分比向下取整(对照表 #22)。
|
||||
|
||||
60 → 36/100;100 → 1(全额)。
|
||||
"""
|
||||
return Fraction(r, 100) * Fraction(r, 100)
|
||||
|
||||
|
||||
def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bool:
|
||||
"""起付判定:单项业务回款额达到销售额(不含运费)的 ratio%(对照表 #21)。"""
|
||||
if revenue == 0:
|
||||
return False
|
||||
return Fraction(received) / Fraction(revenue) >= ratio / 100
|
||||
@@ -0,0 +1,304 @@
|
||||
"""计提参数目录注册表。
|
||||
|
||||
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
|
||||
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
|
||||
值一律以 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
|
||||
Reference in new issue
Block a user