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
|