From 8668e58c301a42a04bd5b7f285e8590da9ccac9b Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 09:26:08 +0800 Subject: [PATCH] =?UTF-8?q?test(system):=20=E8=A1=A5=E4=BA=A4=E5=B2=97?= =?UTF-8?q?=E4=BD=8D=E8=A7=92=E8=89=B2=E4=B8=8E=E5=8F=82=E6=95=B0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=9B=86=E6=88=90=E7=94=A8=E4=BE=8B=EF=BC=88M1=20?= =?UTF-8?q?=E9=81=97=E6=BC=8F=E5=85=A5=E5=BA=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .../tests/integration/test_business_roles.py | 155 ++++++++ .../integration/test_param_management.py | 347 ++++++++++++++++++ 2 files changed, 502 insertions(+) create mode 100644 backend/tests/integration/test_business_roles.py create mode 100644 backend/tests/integration/test_param_management.py diff --git a/backend/tests/integration/test_business_roles.py b/backend/tests/integration/test_business_roles.py new file mode 100644 index 0000000..62a943a --- /dev/null +++ b/backend/tests/integration/test_business_roles.py @@ -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 diff --git a/backend/tests/integration/test_param_management.py b/backend/tests/integration/test_param_management.py new file mode 100644 index 0000000..43d68f4 --- /dev/null +++ b/backend/tests/integration/test_param_management.py @@ -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