- SecondaryAllocationIn/Out:比例走金额红线口径(str 入参、两位小数、 超 100 拒绝不静默舍入);参与人 schema 层去重(uk 前置友好提示) - create/update 同事务写入(replace_allocations,先删后 flush 再插—— uk 表防御写法);create 放编号重试循环内,rollback 后方案随业务重来 - 人员在职复用 ensure_active(label=分摊参与),personnel_name 冗余固化; FK RESTRICT:被方案引用人员禁删,方案回显不静默失联 - 合计不强制 100%(需求原文):详情 allocation_hint 只展示合计/余额, 余额归属待澄清 #2 不落库 - 单测 13 例(比例边界/去重)+ 集成 9 例(回显/替换清空/离职 400/ 终态禁改/RESTRICT 保护) Co-Authored-By: Claude Code <noreply@anthropic.com>
212 lines
8.7 KiB
Python
212 lines
8.7 KiB
Python
"""二次分配方案录入(业务创建/编辑同事务)集成测试。
|
||
|
||
守的是(计划 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
|