"""二次分配方案录入(业务创建/编辑同事务)集成测试。 守的是(计划 T3 验收): - 创建带方案 → 详情回显含 personnel_name 冗余与比例 - 编辑全量替换(空数组=清空) - 离职/不存在人员 → 400「分摊参与人员不存在或已离职」 - 终态业务禁改方案(与编辑终态门槛同一口径) - 合计不强制 100%:hint 展示合计与余额(余额归属待澄清 #2) - 删除保护:被方案引用的人员 RESTRICT 禁删(400 而非静默剥离) - 人员删除后方案回显仍有名(personnel_name 固化的意义) 人员造数走 /api/v1/base/personnel(种子营销员岗位);离职用状态端点。 """ import uuid import pytest from fastapi.testclient import TestClient from tests.integration.factories import business_payload, customer pytestmark = pytest.mark.integration _BUSINESSES = "/api/v1/business/businesses" _PERSONNEL = "/api/v1/base/personnel" def _person(client: TestClient, headers: dict, **overrides: object) -> dict: role_id = overrides.pop("business_role_id", None) body = { "personnel_name": f"t_分摊_{uuid.uuid4().hex[:8]}", "business_role_id": role_id or _any_role_id(), "salary_standard": "3000.00", **overrides, } resp = client.post(_PERSONNEL, json=body, headers=headers) assert resp.status_code == 200, resp.text return resp.json()["data"] def _any_role_id() -> int: """种子营销员岗位 ID(内置启用)。""" from sqlalchemy import select from app.core.constants import BUSINESS_ROLE_SALESPERSON from app.core.db import get_session_factory from app.models import BusinessRole factory = get_session_factory() with factory() as session: return int( session.execute( select(BusinessRole.id).where( BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON, BusinessRole.status == 1, ) ).scalar_one() ) def _create_with_allocations(client: TestClient, headers: dict, allocations: list[dict]) -> dict: cust = customer(client, headers) resp = client.post( _BUSINESSES, json=business_payload(cust["id"], secondary_allocations=allocations), headers=headers, ) assert resp.status_code == 200, resp.text return resp.json()["data"] def _detail(client: TestClient, headers: dict, business_id: int) -> dict: resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers) assert resp.status_code == 200, resp.text return resp.json()["data"] class TestCreateAndEcho: """创建带方案与详情回显。""" def test_带方案创建_详情回显姓名与比例(self, client: TestClient, admin: dict) -> None: p1 = _person(client, admin["headers"]) p2 = _person(client, admin["headers"]) data = _create_with_allocations( client, admin["headers"], [ {"personnel_id": p1["id"], "ratio": "30.00"}, {"personnel_id": p2["id"], "ratio": "20.00"}, ], ) detail = _detail(client, admin["headers"], data["id"]) assert len(detail["secondary_allocations"]) == 2 names = {a["personnel_name"] for a in detail["secondary_allocations"]} assert p1["personnel_name"] in names and p2["personnel_name"] in names ratios = sorted(a["ratio"] for a in detail["secondary_allocations"]) assert ratios == ["20.00", "30.00"] # 合计 50%,余额 50% —— 只提示不落库(待澄清 #2) assert "50.00" in (detail["allocation_hint"] or "") assert "余额" in (detail["allocation_hint"] or "") def test_不建方案_详情为空且无hint(self, client: TestClient, admin: dict) -> None: data = _create_with_allocations(client, admin["headers"], []) detail = _detail(client, admin["headers"], data["id"]) assert detail["secondary_allocations"] == [] assert detail["allocation_hint"] is None def test_合计满额_hint只报合计(self, client: TestClient, admin: dict) -> None: p1 = _person(client, admin["headers"]) p2 = _person(client, admin["headers"]) data = _create_with_allocations( client, admin["headers"], [ {"personnel_id": p1["id"], "ratio": "60"}, {"personnel_id": p2["id"], "ratio": "40.00"}, ], ) hint = _detail(client, admin["headers"], data["id"])["allocation_hint"] assert hint is not None and "余额" not in hint class TestGuards: """校验链:在职/终态/去重已由 schema 层覆盖,此处打服务层口径。""" def test_离职人员参与被拒(self, client: TestClient, admin: dict) -> None: p = _person(client, admin["headers"]) resp = client.put( f"{_PERSONNEL}/{p['id']}/status", json={"status": 0}, headers=admin["headers"] ) assert resp.status_code == 200, resp.text cust = customer(client, admin["headers"]) resp = client.post( _BUSINESSES, json=business_payload( cust["id"], secondary_allocations=[{"personnel_id": p["id"], "ratio": "50.00"}], ), headers=admin["headers"], ) assert resp.status_code == 400 assert "分摊参与" in resp.json()["message"] def test_终态业务禁改方案(self, client: TestClient, admin: dict) -> None: p = _person(client, admin["headers"]) data = _create_with_allocations( client, admin["headers"], [{"personnel_id": p["id"], "ratio": "100"}] ) resp = client.put( f"{_BUSINESSES}/{data['id']}/status", json={"status": 2}, headers=admin["headers"], ) assert resp.status_code == 200, resp.text detail = _detail(client, admin["headers"], data["id"]) payload = business_payload(detail["customer_id"], secondary_allocations=[]) for key in ("business_no", "status", "owner_personnel_id", "total_sale_amount"): payload.pop(key, None) resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) assert resp.status_code == 400 assert "禁止编辑" in resp.json()["message"] def test_编辑全量替换与清空(self, client: TestClient, admin: dict) -> None: p1 = _person(client, admin["headers"]) p2 = _person(client, admin["headers"]) data = _create_with_allocations( client, admin["headers"], [{"personnel_id": p1["id"], "ratio": "40.00"}] ) detail = _detail(client, admin["headers"], data["id"]) payload = business_payload( detail["customer_id"], secondary_allocations=[{"personnel_id": p2["id"], "ratio": "70.00"}], ) resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) assert resp.status_code == 200, resp.text replaced = _detail(client, admin["headers"], data["id"]) assert len(replaced["secondary_allocations"]) == 1 assert replaced["secondary_allocations"][0]["personnel_id"] == p2["id"] payload = business_payload(detail["customer_id"], secondary_allocations=[]) resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) assert resp.status_code == 200, resp.text assert _detail(client, admin["headers"], data["id"])["secondary_allocations"] == [] class TestDeleteProtection: """RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。""" def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None: p = _person(client, admin["headers"]) data = _create_with_allocations( client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}] ) resp = client.delete(f"{_PERSONNEL}/{p['id']}", headers=admin["headers"]) assert resp.status_code == 400 # 方案仍在,回显有名(RESTRICT 保证未静默剥离) detail = _detail(client, admin["headers"], data["id"]) assert detail["secondary_allocations"][0]["personnel_name"] == p["personnel_name"] def test_删除业务后可删人员(self, client: TestClient, admin: dict) -> None: p = _person(client, admin["headers"]) data = _create_with_allocations( client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}] ) resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]) assert resp.status_code == 200, resp.text # 方案 CASCADE 随业务删除 assert client.delete(f"{_PERSONNEL}/{p['id']}", headers=admin["headers"]).status_code == 200