补齐 code-review 遗留 2 项:审批单此前只固化 ref_id/refer_no 指针,复核者看到 的是业务当前内容;二次分配方案在编辑替换与驳回清空时一律物理删除,方案事实 无处回溯。 - 审批单加 version_no 与 snapshot_json(信封 schema_version/order_type/ captured_at/data);二次分配、月结、调整单三类提交均固化送审内容,开关关闭 的自动通过路径同样写快照;详情按 snapshot/live/missing 三态回显,历史单 只读回退当前内容并显式告警,不回填历史快照 - 二次分配方案行改 append-only 版本化(version_no/is_active,唯一键扩为 business+version+personnel):编辑替换与驳回清空只置历史,核算与详情默认 只取生效行,业务详情新增 allocation_history - 送审内容未变不重提、方案未变不新增版本:判据统一为送审内容,两个 version_no 语义解耦(方案版本=内容第几版,审批单版本=第几次送审) - 审批单列表、工作台待办、审批超时预警三处 defer 快照列,避免整列拉取 - 月结提交取行加 for_update,防并发提交算出重复的审批单版本号 - 修复审批类型筛选上界 le=2(调整单无法筛选)、快照金额把空值写成 0.00 - V11 迁移三件套同步(Alembic / db/migration / init-dev.sql) 测试:单元 +16(方案内容比较 10、审批入参 6)、集成 +12;全量 891 passed。 Co-Authored-By: Claude Code <noreply@anthropic.com>
301 lines
13 KiB
Python
301 lines
13 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 _set_switch(client: TestClient, headers: dict, value: str) -> None:
|
||
"""显式设置二次分配审批开关(V8 起默认开启,用例不得依赖种子默认值)。"""
|
||
resp = client.post(
|
||
"/api/v1/system/params/changes",
|
||
json={
|
||
"change_type": 1,
|
||
"remark": f"用例设置二次分配审批开关={value}",
|
||
"items": [
|
||
{
|
||
"param_code": "approval.secondary_allocation.enabled",
|
||
"dimension_value": "",
|
||
"value": value,
|
||
}
|
||
],
|
||
},
|
||
headers=headers,
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
|
||
|
||
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:
|
||
"""历史参与人离职后:编辑业务(保留该行)不应被 400 卡死——
|
||
personnel_name 冗余固化的设计本就允许离职后回显;仅新增参与人
|
||
才做在职校验。"""
|
||
p = _person(client, admin["headers"])
|
||
data = _create_with_allocations(
|
||
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
|
||
)
|
||
resp = client.put(
|
||
f"{_PERSONNEL}/{p['id']}/status", json={"status": 0}, headers=admin["headers"]
|
||
)
|
||
assert resp.status_code == 200
|
||
|
||
detail = _detail(client, admin["headers"], data["id"])
|
||
payload = business_payload(
|
||
detail["customer_id"],
|
||
secondary_allocations=[{"personnel_id": p["id"], "ratio": "50.00"}],
|
||
)
|
||
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||
assert resp.status_code == 200, resp.text
|
||
after = _detail(client, admin["headers"], data["id"])
|
||
assert after["secondary_allocations"][0]["personnel_name"] == p["personnel_name"]
|
||
|
||
# 新增一名离职人员仍要拦(新增才做在职校验)
|
||
p2 = _person(client, admin["headers"])
|
||
client.put(f"{_PERSONNEL}/{p2['id']}/status", json={"status": 0}, headers=admin["headers"])
|
||
payload = business_payload(
|
||
detail["customer_id"],
|
||
secondary_allocations=[
|
||
{"personnel_id": p["id"], "ratio": "30.00"},
|
||
{"personnel_id": p2["id"], "ratio": "20.00"},
|
||
],
|
||
)
|
||
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:
|
||
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:
|
||
"""守「置历史后同一会话内的旧行必须同步刷新」这一行为:Core UPDATE
|
||
走的是 SQLAlchemy 的会话同步策略,条件复杂化时可能退化为不刷新,
|
||
届时行对象会停留在 is_active=1 的过期内存值(库中实际已置历史),
|
||
被序列化或参与判断就会呈现「生效态」的假象。"""
|
||
from app.core.db import get_session_factory
|
||
from app.models import SecondaryAllocation
|
||
from app.repositories import business_repository
|
||
|
||
p = _person(client, admin["headers"])
|
||
data = _create_with_allocations(
|
||
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
|
||
)
|
||
|
||
factory = get_session_factory()
|
||
with factory() as session:
|
||
loaded = business_repository.list_allocations(session, data["id"])
|
||
assert loaded and all(a.is_active == 1 for a in loaded)
|
||
business_repository.deactivate_allocations(session, data["id"])
|
||
# 同一 session 内的行对象必须已是历史态,不能停留在过期内存值
|
||
assert all(a.is_active == 0 for a in loaded), "旧行未随置历史同步刷新"
|
||
# 库中同样已置历史
|
||
rows = (
|
||
session.query(SecondaryAllocation)
|
||
.filter(SecondaryAllocation.business_id == data["id"])
|
||
.all()
|
||
)
|
||
assert rows and all(r.is_active == 0 for r in rows)
|
||
session.rollback()
|
||
|
||
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:
|
||
# 显式关开关:开启时审批单在途、删业务被 400 拦截(产品行为,另有用例覆盖)
|
||
_set_switch(client, admin["headers"], "0")
|
||
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
|