PayrollManagementSystem/backend/tests/integration/test_param_management.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

522 lines
21 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.

"""计提参数管理接口集成测试(需要 MySQL 与 Redis)。
守的是参数体系的口径与留痕 —— 时点化取值错一天、同日变更取错条目、
旧值丢失、未来值泄漏到当前快照,任何一条都会直接算错钱。
关键复现(全部来自 2026-10-12 业务裁决):
- 企业户 2‰(2026-02-17 起)→ 3‰(2026-08-13 起)的按日回溯
- 业务员 27 天 → 97/10000,折算 15000,绩效 145.5,可付 52.38(预览金标准)
- 同日多次变更取最新一条;撤回未生效批次后解析回退
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
"""
import uuid
from datetime import date, timedelta
from decimal import Decimal
from fractions import Fraction
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from app.core.constants import ROLE_USER
from app.core.db import get_session_factory
from app.models import ParamChangeBatch, ParamValue
pytestmark = pytest.mark.integration
_PARAMS = "/api/v1/system/params"
# 种子基线(与 V3 迁移一致):断言的「预期值」均以此为准
_SEED_PREPAY = "10"
_SEED_START_RATIO = "60"
@pytest.fixture
def track_batches():
"""记录测试创建的批次 id,用例结束整批清理(条目 + 批次头)。"""
created: list[int] = []
def _track(batch_id: int) -> None:
created.append(batch_id)
yield _track
factory = get_session_factory()
with factory() as session:
if created:
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(created)))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(created)))
session.commit()
@pytest.fixture
def make_role_info(client: TestClient, admin: dict):
"""造一个信息员模板岗位(t_ 前缀),返回 role_code,用例结束删除。
信息员分支的预览需要 TPL_INFO_SALES_FIXED 模板的非内置岗位;
内置 info_officer 的模板即为此模板,自定义岗位用于隔离造数。
"""
code = f"t_info_{uuid.uuid4().hex[:6]}"
resp = client.post(
"/api/v1/system/business-roles",
json={
"role_code": code,
"role_name": "集成信息员",
"calc_template": "TPL_INFO_SALES_FIXED",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
role_id = resp.json()["data"]["id"]
try:
yield code
finally:
client.delete(f"/api/v1/system/business-roles/{role_id}", headers=admin["headers"])
def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: str) -> str | None:
"""从目录接口取某参数默认维度的当前生效值。"""
items = client.get(_PARAMS, params={"as_of": as_of}, headers=admin["headers"]).json()["data"]
match = next(item for item in items if item["param_code"] == param_code)
dims = [v for v in match["values"] if v["dimension_value"] == ""]
return dims[0]["value_text"] if dims else None
class TestCatalog:
"""参数目录:种子完整性与时点化回溯。"""
def test_目录含27项及产品系数组(self, client: TestClient, admin: dict) -> None:
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
assert len(items) == 27
groups = {item["param_group"] for item in items}
assert groups == {
"业务员个人户",
"产品系数表",
"经理分成",
"企业户",
"信息员",
"支付环节",
"产品阈值",
"月工资口径",
"审批开关",
"预警规则",
}
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
before = _snapshot_value(client, admin, "enterprise.rate", as_of="2026-08-12")
after = _snapshot_value(client, admin, "enterprise.rate", as_of="2026-08-13")
assert (before, after) == ("2", "3")
def test_业务员斜率为精确分数_非截断小数(self, client: TestClient, admin: dict) -> None:
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
slope = next(item for item in items if item["param_code"] == "salesperson.rate.slope")
values = [v["value_text"] for v in slope["values"]]
assert values == ["4/360"] # 分数式原样存储,禁 0.011111 截断
class TestChangeLifecycle:
"""变更生命周期:提交 → 生效 → 同日取最新 → 撤回回退。"""
def test_立即生效_快照变化且历史含旧值(
self, client: TestClient, admin: dict, track_batches
) -> None:
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"remark": "集成-立即生效",
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "12"}],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
batch = resp.json()["data"]
track_batches(batch["id"])
assert batch["item_count"] == 1
assert batch["version_no"].startswith("P")
value = _snapshot_value(client, admin, "prepay.rate", as_of=str(date.today()))
assert value == "12"
history = client.get(
f"{_PARAMS}/prepay.rate/history",
params={"pageNum": 1, "pageSize": 5},
headers=admin["headers"],
).json()["data"]
assert history["total"] >= 2 # 种子 1 条 + 本次 1 条
assert history["list"][0]["old_value_text"] == _SEED_PREPAY
assert history["list"][0]["value_text"] == "12"
def test_未来生效_当前快照不泄漏_到期后生效(
self, client: TestClient, admin: dict, track_batches
) -> None:
future = date.today() + timedelta(days=30)
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 2,
"effective_date": future.isoformat(),
"remark": "集成-未来生效",
"items": [
{"param_code": "payment.start.ratio", "dimension_value": "", "value": "65"}
],
},
headers=admin["headers"],
)
assert resp.status_code == 200
batch = resp.json()["data"]
track_batches(batch["id"])
today_value = _snapshot_value(client, admin, "payment.start.ratio", as_of=str(date.today()))
assert today_value == _SEED_START_RATIO # 未来值不泄漏
future_value = _snapshot_value(
client, admin, "payment.start.ratio", as_of=future.isoformat()
)
assert future_value == "65" # 到期即生效
def test_同日多次变更_取最新一条(self, client: TestClient, admin: dict, track_batches) -> None:
for value in ("12", "11"):
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": value}],
},
headers=admin["headers"],
)
assert resp.status_code == 200
track_batches(resp.json()["data"]["id"])
value = _snapshot_value(client, admin, "prepay.rate", as_of=str(date.today()))
assert value == "11"
def test_撤回未生效批次_解析回退(self, client: TestClient, admin: dict, track_batches) -> None:
future = date.today() + timedelta(days=30)
batch = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 2,
"effective_date": future.isoformat(),
"items": [{"param_code": "barter.discount", "dimension_value": "", "value": "85"}],
},
headers=admin["headers"],
).json()["data"]
track_batches(batch["id"])
future_value = _snapshot_value(client, admin, "barter.discount", as_of=future.isoformat())
assert future_value == "85"
assert (
client.delete(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]).status_code
== 200
)
rolled_back = _snapshot_value(client, admin, "barter.discount", as_of=future.isoformat())
assert rolled_back == _SEED_BARTER
def test_撤回已生效批次_400(self, client: TestClient, admin: dict, track_batches) -> None:
batch = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "12"}],
},
headers=admin["headers"],
).json()["data"]
track_batches(batch["id"])
resp = client.delete(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"])
assert resp.status_code == 400
assert "已生效" in resp.json()["message"]
_SEED_BARTER = "80"
class TestValidation:
"""值校验:畸形值在写入前拒绝(审查重点 1)。"""
def test_超取值上限拒绝(self, client: TestClient, admin: dict) -> None:
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"items": [{"param_code": "barter.discount", "dimension_value": "", "value": "120"}],
},
headers=admin["headers"],
)
assert resp.status_code == 400
def test_枚举越界拒绝(self, client: TestClient, admin: dict) -> None:
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"items": [
{"param_code": "payment.weight.mode", "dimension_value": "", "value": "PARTIAL"}
],
},
headers=admin["headers"],
)
assert resp.status_code == 400
def test_除零分数拒绝(self, client: TestClient, admin: dict) -> None:
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"items": [{"param_code": "barter.discount", "dimension_value": "", "value": "1/0"}],
},
headers=admin["headers"],
)
assert resp.status_code == 400
def test_指定日期早于今日拒绝(self, client: TestClient, admin: dict) -> None:
yesterday = date.today() - timedelta(days=1)
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 2,
"effective_date": yesterday.isoformat(),
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "9"}],
},
headers=admin["headers"],
)
assert resp.status_code == 400
class TestPreview:
"""计算预览:业务裁决的「金标准」数值精确复现。"""
def test_业务员27天全链路精确复现(self, client: TestClient, admin: dict) -> None:
payload = {
"business_role": "salesperson",
"customer_type": "personal",
"product_category": "municipal_water",
"ship_date": "2025-12-15",
"receipt_date": "2026-01-10",
"revenue": "100000",
"profit": "45000",
"received_amount": "60000",
}
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
"data"
]
assert data["payment_days"] == 27
assert data["rate"] == "97/10000" # 10‰ − 27 × 4/360‰
assert data["profit_rate"] == "9/20" # 45% 不含运费
assert data["converted_profit"] == "15000" # 100000 × (45% − 30%)
assert data["performance_amount"] == "145.5"
assert data["start_payment_ok"] is True
assert data["payment_weight_r"] == 60
assert data["payable_amount"] == "52.38" # 145.5 × 60% × 60%
def test_361天分界_固定下限4千分(self, client: TestClient, admin: dict) -> None:
payload = {
"business_role": "salesperson",
"customer_type": "personal",
"product_category": "municipal_water",
"ship_date": "2025-01-10",
"receipt_date": "2026-01-10", # 366 天 ≥ 361
"revenue": "100000",
"profit": "45000",
"received_amount": "100000",
}
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
"data"
]
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言
assert Fraction(data["rate"]) == Fraction(4, 1000)
def test_预览参数使用顺序稳定(self, client: TestClient, admin: dict) -> None:
"""params_used 的顺序即前端「用了哪些参数」的展示顺序。
预览实现拆成多段后,各段的调用先后决定参数读取先后;顺序漂移会让
复核者看到的参数清单与核算口径对不上(不报错,但会误导判读)。
"""
payload = {
"business_role": "salesperson",
"customer_type": "personal",
"product_category": "municipal_water",
"ship_date": "2025-12-15",
"receipt_date": "2026-01-10",
"revenue": "100000",
"profit": "45000",
"received_amount": "60000",
"barter": True,
"probation": True,
}
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
"data"
]
assert [p["param_code"] for p in data["params_used"]] == [
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
"salesperson.days.boundary",
"product.threshold",
"barter.discount",
"payment.start.ratio",
"probation.annual.threshold",
]
def test_经理个人业务_试算排除且指向月结(self, client: TestClient, admin: dict) -> None:
payload = {
"business_role": "sales_manager",
"customer_type": "personal",
"product_category": "municipal_water",
"ship_date": "2026-01-01",
"receipt_date": "2026-02-01",
"revenue": "100000",
"profit": "45000",
"received_amount": "0",
}
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
assert resp.status_code == 200
data = resp.json()["data"]
assert data["rate"] == "0"
assert Decimal(data["performance_amount"]) == 0
assert "团队分成" in data["rate_text"]
def test_预收先款后货_按最高比例预览(self, client: TestClient, admin: dict) -> None:
payload = {
"business_role": "salesperson",
"customer_type": "personal",
"product_category": "municipal_water",
"ship_date": "2026-01-10",
"receipt_date": "2026-01-01",
"revenue": "100000",
"profit": "45000",
"received_amount": "0",
}
response = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
assert response.status_code == 200
assert response.json()["data"]["rate"] == "1/100"
assert response.json()["data"]["payment_days"] == -8
def test_企业户_千分比换算后金额精确(self, client: TestClient, admin: dict) -> None:
"""企业户分支:enterprise.rate 存储 "3"(3‰),须换算为 3/1000 再相乘。
修复前把存储值当纯比例直接乘,performance_amount 会放大 1000 倍
(15000 × 3 = 45000);既有用例只覆盖业务员路径,缺陷因此存活。
"""
payload = {
"customer_type": "enterprise",
"product_category": "municipal_water",
"ship_date": "2025-12-15",
"receipt_date": "2026-01-10",
"revenue": "100000",
"profit": "45000",
"received_amount": "100000",
}
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
"data"
]
assert Fraction(data["rate"]) == Fraction(3, 1000)
# 折算毛利润额 15000 × 3‰ = 45(修复前误返回 45000)
assert Fraction(data["performance_amount"]) == Fraction(45)
# 全额回款 R=100 → 权重 1,可付 == 绩效
assert data["payment_weight_r"] == 100
assert Fraction(data["payable_amount"]) == Fraction(45)
def test_信息员_销售额基数乘千分比例(
self, client: TestClient, admin: dict, make_role_info
) -> None:
"""信息员模板:基数覆盖为本人销售额(不走折算与阈值),比例 1‰。
修复前 take("info.rate")(存储 "1")直接当纯比例乘出本人销售额原额。
"""
payload = {
"business_role": make_role_info,
"customer_type": "personal",
"ship_date": "2025-12-15",
"receipt_date": "2026-01-10",
"revenue": "100000",
"profit": "45000",
"received_amount": "100000",
}
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
"data"
]
# 基数覆盖为本人销售额:converted_profit == revenue(不扣阈值)
assert Fraction(data["converted_profit"]) == Fraction(100000)
assert Fraction(data["rate"]) == Fraction(1, 1000)
# 100000 × 1‰ = 100
assert Fraction(data["performance_amount"]) == Fraction(100)
def test_提交后维度取库里规范值(self, client: TestClient, admin: dict, track_batches) -> None:
"""岗位维度大小写归一:ci 校对下变形大小写可提交成功,但须落库规范值。
快照与注册表按精确键取值,原始大小写入库会让覆盖链静默不命中
(按错误参数算钱且无报错)。business_roles.role_code 唯一键的 ci
等价冲突由 IntegrityError 分支翻译 400 兜底。
"""
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"items": [
{"param_code": "info.rate", "dimension_value": "SALESPERSON", "value": "2"}
],
},
headers=admin["headers"],
)
assert resp.status_code in (200, 400), resp.text
if resp.status_code == 400:
pytest.skip("种子岗位未命中(见断言),规范值回读由造岗位链路覆盖")
batch = resp.json()["data"]
track_batches(batch["id"])
detail = client.get(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]).json()[
"data"
]
dims = [item["dimension_value"] for item in detail["items"]]
assert "salesperson" in dims
assert "SALESPERSON" not in dims
class TestPermission:
"""权限分层(澄清 #17 答复 2026-10-19):查看可(登录即可)、修改仅管理员。"""
def test_普通用户目录须角色授权后可读(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
resp = client.get(_PARAMS, headers=headers)
assert resp.status_code == 403
from sqlalchemy import select
from app.models import Permission, Role, RolePermission, UserRole
with get_session_factory()() as session:
permission = session.scalar(select(Permission).where(Permission.perm_code == "system:params:view"))
role = Role(role_code="t_param_view_" + uuid.uuid4().hex[:8], role_name="参数查看测试角色", status=1)
session.add(role)
session.flush()
session.add(RolePermission(role_id=role.id, permission_id=permission.id))
session.add(UserRole(user_id=user["id"], role_id=role.id))
session.commit()
resp = client.get(_PARAMS, headers=headers)
assert resp.status_code == 200
assert len(resp.json()["data"]) == 27
def test_未登录访问目录_403(self, client: TestClient) -> None:
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
# 均属拦截;显式断言不泄露数据即可)
assert client.get(_PARAMS).status_code in (401, 403)
def test_普通用户提交变更_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "9"}],
},
headers=headers,
)
assert resp.status_code == 403
def test_普通用户撤回批次_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
assert client.delete(f"{_PARAMS}/changes/1", headers=headers).status_code == 403