feat/m3-increments #1

Merged
liuwenxin merged 100 commits from feat/m3-increments into main 2026-10-19 04:59:57 -04:00
2 changed files with 502 additions and 0 deletions
Showing only changes of commit 8668e58c30 - Show all commits

View File

@ -0,0 +1,155 @@
"""岗位角色管理接口集成测试(需要 MySQL 与 Redis)。
守的是岗位管理的保护规则与模板绑定 —— 内置岗位被删停会让参数维度值
失效、核算模板断锚;模板编码不校验会让核算引擎取不到公式。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from app.core.constants import ROLE_USER
pytestmark = pytest.mark.integration
_ROLES = "/api/v1/system/business-roles"
_TEMPLATES = "/api/v1/system/business-roles/meta/templates"
def _new_code() -> str:
return f"t_br_{uuid.uuid4().hex[:6]}"
class TestCrudFlow:
"""正向链路:建 → 查(列表 + 模板下拉)→ 改 → 删。"""
def test_新增自定义岗位并联动列表(self, client: TestClient, admin: dict) -> None:
code = _new_code()
resp = client.post(
_ROLES,
json={
"role_code": code,
"role_name": "集成测试岗位",
"calc_template": "TPL_SALESPERSON_DECAY",
"description": "集成测试",
"sort_order": 50,
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
body = resp.json()["data"]
assert body["role_code"] == code
assert body["is_builtin"] == 0
assert body["status"] == 1
listed = client.get(_ROLES, params={"keyword": code}, headers=admin["headers"])
assert any(r["role_code"] == code for r in listed.json()["data"]["list"])
# 清理
assert client.delete(f"{_ROLES}/{body['id']}", headers=admin["headers"]).status_code == 200
def test_重复编码拒绝(self, client: TestClient, admin: dict) -> None:
code = _new_code()
payload = {
"role_code": code,
"role_name": "首个",
"calc_template": "TPL_SALESPERSON_DECAY",
}
assert client.post(_ROLES, json=payload, headers=admin["headers"]).status_code == 200
dup = client.post(_ROLES, json=payload, headers=admin["headers"])
assert dup.status_code == 400
assert "已存在" in dup.json()["message"]
client.delete(
f"{_ROLES}/{client.get(_ROLES, params={'keyword': code}, headers=admin['headers']).json()['data']['list'][0]['id']}",
headers=admin["headers"],
)
def test_模板编码非法拒绝(self, client: TestClient, admin: dict) -> None:
resp = client.post(
_ROLES,
json={
"role_code": _new_code(),
"role_name": "坏模板岗位",
"calc_template": "TPL_NOT_EXIST",
},
headers=admin["headers"],
)
assert resp.status_code == 400
def test_编辑岗位换模板生效(self, client: TestClient, admin: dict) -> None:
code = _new_code()
created = client.post(
_ROLES,
json={
"role_code": code,
"role_name": "待改岗位",
"calc_template": "TPL_SALESPERSON_DECAY",
},
headers=admin["headers"],
).json()["data"]
updated = client.put(
f"{_ROLES}/{created['id']}",
json={
"role_name": "已改岗位",
"calc_template": "TPL_ENTERPRISE_FIXED",
"sort_order": 7,
},
headers=admin["headers"],
)
assert updated.status_code == 200
body = updated.json()["data"]
assert body["role_name"] == "已改岗位"
assert body["calc_template"] == "TPL_ENTERPRISE_FIXED"
assert body["role_code"] == code # 编码不变
client.delete(f"{_ROLES}/{created['id']}", headers=admin["headers"])
def test_模板下拉含五个模板且声明参数已注册(self, client: TestClient, admin: dict) -> None:
options = client.get(_TEMPLATES, headers=admin["headers"]).json()["data"]
assert len(options) == 5
for tpl in options:
assert tpl["params"], f"{tpl['code']} 未声明参数"
class TestBuiltinProtection:
"""内置岗位(种子 3 个)禁删禁停 —— 参数维度与模板按编码锚定。"""
def test_停用内置岗位_403(self, client: TestClient, admin: dict) -> None:
rows = client.get(
_ROLES, params={"keyword": "salesperson"}, headers=admin["headers"]
).json()["data"]["list"]
target = next(r for r in rows if r["role_code"] == "salesperson" and r["is_builtin"] == 1)
resp = client.put(
f"{_ROLES}/{target['id']}/status", json={"status": 0}, headers=admin["headers"]
)
assert resp.status_code == 403
def test_删除内置岗位_403(self, client: TestClient, admin: dict) -> None:
rows = client.get(
_ROLES, params={"keyword": "sales_manager"}, headers=admin["headers"]
).json()["data"]["list"]
target = next(r for r in rows if r["role_code"] == "sales_manager")
resp = client.delete(f"{_ROLES}/{target['id']}", headers=admin["headers"])
assert resp.status_code == 403
def test_种子内置岗位三个且不可删标记正确(self, client: TestClient, admin: dict) -> None:
rows = client.get(_ROLES, params={"pageSize": 50}, headers=admin["headers"]).json()["data"][
"list"
]
builtins = [r for r in rows if r["is_builtin"] == 1]
assert {r["role_code"] for r in builtins} == {
"salesperson",
"sales_manager",
"info_officer",
}
class TestPermission:
"""权限:普通用户全部 403(当前默认管理员专用)。"""
def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
assert client.get(_ROLES, headers=headers).status_code == 403

View File

@ -0,0 +1,347 @@
"""计提参数管理接口集成测试(需要 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``
"""
from datetime import date, timedelta
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()
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_目录共16项且覆盖七个分组(self, client: TestClient, admin: dict) -> None:
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
assert len(items) == 16
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:
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 == 400
assert "聚合" in resp.json()["message"]
def test_回款日早于发货日_400(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",
}
assert (
client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).status_code
== 400
)
class TestPermission:
"""权限:普通用户全部 403。"""
def test_普通用户访问目录与提交_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
assert client.get(_PARAMS, headers=headers).status_code == 403
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