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

225 lines
8.3 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.

"""参数注册表与公式模板注册表测试。
守的是:注册表是参数种子数据与运行期校验的唯一来源 —— 一旦编码或默认值
与迁移种子漂移,参数解析会取到错误口径;一旦取值校验失守,
畸形值(如 "1/0")会落库并在核算时崩溃或算错钱。
"""
from fractions import Fraction
from types import SimpleNamespace
import pytest
from app.core.exceptions import BadRequestError
from app.services.calc_templates import FORMULAS, TEMPLATES, get_formula
from app.services.param_registry import (
PARAM_REGISTRY,
get_spec,
parse_fraction,
validate_and_normalize,
)
# 与 db/migration 迁移种子保持一致的 22 项编码(V3 十六项 + V6 审批开关两项
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
# V10 增工作台预警规则三项 #10)
EXPECTED_CODES = {
"approval.adjustment.enabled",
"approval.monthly_settlement.enabled",
"approval.secondary_allocation.enabled",
"barter.discount",
"coefficient.personal.boundary",
"coefficient.personal.first",
"coefficient.personal.floor",
"coefficient.personal.slope",
"coefficient.personal.start",
"enterprise.rate",
"info.rate",
"manager.personal.included",
"manager.share.company",
"manager.share.team",
"payment.start.ratio",
"payment.weight.mode",
"prepay.rate",
"probation.annual.threshold",
"product.threshold",
"receipt.confirmation.enabled",
"salary.basic.mode",
"salesperson.days.boundary",
"salesperson.rate.floor",
"salesperson.rate.slope",
"salesperson.rate.start",
"warning.approval.timeout.days",
"warning.enabled",
"warning.receipt.overdue.days"
}
def test_目录说明使用当前规则且不改写已有参数值(monkeypatch):
"""旧数据库的说明可更新,历史生效值和日期必须保持原样。"""
from datetime import date
from app.services import param_service
spec = get_spec("approval.monthly_settlement.enabled")
definition = SimpleNamespace(
param_code=spec.code,
param_name=spec.name,
param_group=spec.group,
value_type=spec.value_type,
unit=spec.unit,
dimension_type=spec.dimension_type,
default_value=spec.default_value,
value_min=spec.value_min,
value_max=spec.value_max,
description="旧说明:关闭后自动记录通过痕迹",
)
monkeypatch.setattr(param_service.param_repository, "list_definitions", lambda *a, **k: [definition])
monkeypatch.setattr(
param_service.param_repository,
"resolve_values",
lambda *a: [(spec.code, "", "1", date(2026, 1, 1))],
)
item = param_service.get_catalog(None, as_of=date(2026, 11, 2))[0]
assert item.description == spec.description
assert item.values[0].value_text == "1"
assert item.values[0].effective_date == date(2026, 1, 1)
class TestRegistry:
"""注册表元数据完整性(与迁移种子同源)。"""
def test_注册表共27项(self) -> None:
assert len(PARAM_REGISTRY) == 28
def test_编码集合与迁移种子一致(self) -> None:
assert set(PARAM_REGISTRY) == EXPECTED_CODES
def test_每项必填字段非空(self) -> None:
for spec in PARAM_REGISTRY.values():
assert spec.code, "code 为空"
assert spec.name, f"{spec.code} 缺中文名称"
assert spec.group, f"{spec.code} 缺分组"
assert spec.value_type in {"DECIMAL", "INT", "BOOL", "ENUM", "TEXT"}
def test_默认值可按类型解析(self) -> None:
for spec in PARAM_REGISTRY.values():
if spec.default_value is None:
continue
if spec.value_type in {"DECIMAL", "INT"}:
parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError
elif spec.value_type == "BOOL":
assert spec.default_value in {"0", "1"}, f"{spec.code} 默认值非 0/1"
elif spec.value_type == "ENUM":
assert spec.options, f"{spec.code} 为 ENUM 但无选项"
assert spec.default_value in spec.options
def test_BOOL开关_仅接受0与1(self) -> None:
"""审批开关 BOOL 组(V6 两个 + V7 调整单):合法值与非法值边界。"""
for code in (
"approval.secondary_allocation.enabled",
"approval.monthly_settlement.enabled",
"approval.adjustment.enabled",
):
spec = PARAM_REGISTRY[code]
assert spec.value_type == "BOOL"
assert validate_and_normalize(spec, "0") == "0"
assert validate_and_normalize(spec, "1") == "1"
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "true")
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "2")
def test_取值域上下限可解析(self) -> None:
for spec in PARAM_REGISTRY.values():
if spec.value_min is not None:
parse_fraction(spec.value_min)
if spec.value_max is not None:
parse_fraction(spec.value_max)
def test_取未注册编码抛ValueError_属代码缺陷(self) -> None:
with pytest.raises(ValueError):
get_spec("not.exist.code")
class TestParseFraction:
"""Fraction 精确解析:4/360 不许经 float 中转。"""
def test_分数式精确解析_4除以360(self) -> None:
assert parse_fraction("4/360") == Fraction(1, 90)
def test_整数解析(self) -> None:
assert parse_fraction("10") == Fraction(10)
def test_小数解析(self) -> None:
assert parse_fraction("12.5") == Fraction(25, 2)
def test_除零分数拒绝(self) -> None:
with pytest.raises(BadRequestError):
parse_fraction("1/0")
def test_非数字拒绝(self) -> None:
with pytest.raises(BadRequestError):
parse_fraction("abc")
class TestValidateAndNormalize:
"""参数值校验:类型/取值域/枚举(审查重点 1:畸形值不得落库)。"""
def test_去除首尾空白后返回规范值(self) -> None:
spec = PARAM_REGISTRY["barter.discount"]
assert validate_and_normalize(spec, " 80 ") == "80"
def test_超取值域上限拒绝(self) -> None:
spec = PARAM_REGISTRY["barter.discount"] # value_max=100
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "120")
def test_低于取值域下限拒绝(self) -> None:
spec = PARAM_REGISTRY["barter.discount"] # value_min=0
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "-1")
def test_枚举越界拒绝(self) -> None:
spec = PARAM_REGISTRY["payment.weight.mode"]
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "PARTIAL")
def test_枚举合法值通过(self) -> None:
spec = PARAM_REGISTRY["payment.weight.mode"]
assert validate_and_normalize(spec, "BALANCE") == "BALANCE"
def test_整数类型拒绝小数(self) -> None:
spec = PARAM_REGISTRY["salesperson.days.boundary"]
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "360.5")
def test_分数式斜率原样保留不截断(self) -> None:
spec = PARAM_REGISTRY["salesperson.rate.slope"]
assert validate_and_normalize(spec, "4/360") == "4/360"
class TestTemplates:
"""公式模板:骨架受控,内置注册表的参数绑定必须真实存在。"""
def test_共五个内置模板且五个骨架(self) -> None:
assert len(TEMPLATES) == 5
assert len(FORMULAS) == 5
def test_内置模板参数绑定均在注册表内且覆盖骨架角色(self) -> None:
for tpl in TEMPLATES.values():
spec = get_formula(tpl.formula_type)
assert set(tpl.params) == set(spec.roles), tpl.code
for code in tpl.params.values():
assert code in PARAM_REGISTRY, f"{tpl.code} 声明了未注册参数 {code}"
def test_取模板声明参数(self) -> None:
params = TEMPLATES["TPL_SALESPERSON_DECAY"].params
assert params["slope"] == "salesperson.rate.slope"
def test_未知骨架抛ValueError(self) -> None:
with pytest.raises(ValueError):
get_formula("NOT_EXIST")