PayrollManagementSystem/backend/tests/integration/test_secondary_allocation.py
liuwenxin 680661a80b feat(allocation): 二次分配方案录入/替换/终态门槛(分摊计算待澄清另批)
- 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>
2026-10-15 18:31:27 +08:00

212 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""二次分配方案录入(业务创建/编辑同事务)集成测试。
守的是(计划 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