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>
This commit is contained in:
parent
efed29647c
commit
680661a80b
@ -18,6 +18,7 @@ from app.models import (
|
||||
OwnershipChangeLog,
|
||||
Personnel,
|
||||
Receipt,
|
||||
SecondaryAllocation,
|
||||
Shipment,
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
@ -198,6 +199,35 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
|
||||
session.flush()
|
||||
|
||||
|
||||
def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
|
||||
"""二次分配方案(详情回显;空列表=未设置方案)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(SecondaryAllocation)
|
||||
.where(SecondaryAllocation.business_id == business_id)
|
||||
.order_by(SecondaryAllocation.id)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def replace_allocations(
|
||||
session: Session, business_id: int, rows: list[SecondaryAllocation]
|
||||
) -> None:
|
||||
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
|
||||
|
||||
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
|
||||
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
|
||||
"""
|
||||
session.execute(
|
||||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
)
|
||||
session.flush()
|
||||
for row in rows:
|
||||
row.business_id = business_id
|
||||
session.add(row)
|
||||
session.flush()
|
||||
|
||||
|
||||
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
|
||||
"""追加状态留痕(不 commit)。"""
|
||||
session.add(log)
|
||||
|
||||
@ -151,10 +151,53 @@ class BusinessOut(BaseModel):
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class SecondaryAllocationIn(BaseModel):
|
||||
"""二次分配参与人入参(2026-10-15 补充需求「二次分配方案」)。
|
||||
|
||||
比例**不要求合计 100%**(需求原文):余额归属待业务澄清(待澄清 #2),
|
||||
服务层仅以 hint 展示合计/余额,不做强制校验。ratio 沿金额红线口径:
|
||||
字符串入参、两位小数、超限拒绝不静默舍入。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
personnel_id: int = Field(ge=1, description="参与业务员ID(personnel.id)")
|
||||
ratio: str = Field(description="分摊比例(0-100,两位小数)")
|
||||
|
||||
@field_validator("ratio")
|
||||
@classmethod
|
||||
def _check_ratio(cls, value: str) -> str:
|
||||
return _check_amount(value, "分摊比例", Decimal("100"), allow_positive_only=True) or value
|
||||
|
||||
|
||||
class SecondaryAllocationOut(BaseModel):
|
||||
"""二次分配参与人响应(personnel_name 冗余固化,离职后回显有名)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
ratio: str
|
||||
|
||||
|
||||
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
|
||||
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
|
||||
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
|
||||
seen: set[int] = set()
|
||||
for item in items:
|
||||
if item.personnel_id in seen:
|
||||
raise ValueError(f"二次分配参与人重复:人员ID {item.personnel_id}")
|
||||
seen.add(item.personnel_id)
|
||||
|
||||
|
||||
class BusinessDetailOut(BusinessOut):
|
||||
"""业务详情:附产品明细行。"""
|
||||
"""业务详情:附产品明细行与二次分配方案。"""
|
||||
|
||||
items: list[BusinessItemOut] = Field(default_factory=list)
|
||||
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
|
||||
allocation_hint: str | None = Field(
|
||||
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
|
||||
)
|
||||
|
||||
|
||||
class BusinessCreateIn(BaseModel):
|
||||
@ -175,6 +218,9 @@ class BusinessCreateIn(BaseModel):
|
||||
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
|
||||
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
|
||||
items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行")
|
||||
secondary_allocations: list[SecondaryAllocationIn] = Field(
|
||||
default_factory=list, description="二次分配方案(只出现在合同刚建立时;可空)"
|
||||
)
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
@ -184,9 +230,14 @@ class BusinessCreateIn(BaseModel):
|
||||
raise ValueError("特殊业务必须填写说明")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_allocations(self) -> "BusinessCreateIn":
|
||||
_ensure_allocations_unique(self.secondary_allocations)
|
||||
return self
|
||||
|
||||
|
||||
class BusinessUpdateIn(BaseModel):
|
||||
"""编辑业务(全量替换明细;归属不在此端点调整,走 /ownership)。"""
|
||||
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
@ -207,6 +258,9 @@ class BusinessUpdateIn(BaseModel):
|
||||
items: list[BusinessItemIn] = Field(
|
||||
min_length=1, description="产品明细行,至少 1 行(全量替换)"
|
||||
)
|
||||
secondary_allocations: list[SecondaryAllocationIn] = Field(
|
||||
default_factory=list, description="二次分配方案(全量替换;空数组=清空方案)"
|
||||
)
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
@ -215,6 +269,11 @@ class BusinessUpdateIn(BaseModel):
|
||||
raise ValueError("特殊业务必须填写说明")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_allocations(self) -> "BusinessUpdateIn":
|
||||
_ensure_allocations_unique(self.secondary_allocations)
|
||||
return self
|
||||
|
||||
|
||||
class BusinessStatusIn(BaseModel):
|
||||
"""状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。"""
|
||||
|
||||
@ -41,6 +41,7 @@ from app.models import (
|
||||
OwnershipChangeLog,
|
||||
Personnel,
|
||||
Receipt,
|
||||
SecondaryAllocation,
|
||||
Shipment,
|
||||
User,
|
||||
)
|
||||
@ -65,6 +66,8 @@ from app.schemas.business import (
|
||||
GrossProfitOut,
|
||||
GrossProfitRowOut,
|
||||
OwnershipChangeLogOut,
|
||||
SecondaryAllocationIn,
|
||||
SecondaryAllocationOut,
|
||||
)
|
||||
from app.services import audit_service, param_service, personnel_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
@ -257,7 +260,7 @@ def list_businesses(session: Session, query: BusinessQuery) -> PageResult[Busine
|
||||
|
||||
|
||||
def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
"""业务详情(附明细行,供编辑抽屉回显)。
|
||||
"""业务详情(附明细行与二次分配方案,供编辑抽屉回显)。
|
||||
|
||||
:raises NotFoundError: 业务不存在
|
||||
"""
|
||||
@ -268,9 +271,53 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
out.items = [
|
||||
_item_to_out(item) for item in business_repository.list_items(session, business_id)
|
||||
]
|
||||
allocations = business_repository.list_allocations(session, business_id)
|
||||
out.secondary_allocations = [
|
||||
SecondaryAllocationOut(
|
||||
personnel_id=a.personnel_id, personnel_name=a.personnel_name, ratio=str(a.ratio)
|
||||
)
|
||||
for a in allocations
|
||||
]
|
||||
out.allocation_hint = _allocation_hint(allocations)
|
||||
return out
|
||||
|
||||
|
||||
def _build_allocation_rows(
|
||||
session: Session, business_id: int, items: list[SecondaryAllocationIn]
|
||||
) -> list[SecondaryAllocation]:
|
||||
"""构造二次分配明细行:人员在职校验 + 姓名冗余固化 + 比例转 Decimal。
|
||||
|
||||
:raises BadRequestError: 参与人员不存在或已离职
|
||||
"""
|
||||
rows: list[SecondaryAllocation] = []
|
||||
for item in items:
|
||||
person = _ensure_personnel_active(session, item.personnel_id, "分摊参与")
|
||||
rows.append(
|
||||
SecondaryAllocation(
|
||||
business_id=business_id,
|
||||
personnel_id=item.personnel_id,
|
||||
personnel_name=person.personnel_name,
|
||||
ratio=Decimal(item.ratio),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
||||
"""方案合计/余额提示(不强制合计满额是需求原文;余额归属待澄清 #2)。
|
||||
|
||||
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
|
||||
"""
|
||||
if not allocations:
|
||||
return None
|
||||
total = sum((a.ratio for a in allocations), Decimal("0"))
|
||||
total_text = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if total == Decimal("100"):
|
||||
return f"二次分配合计 {total_text}%"
|
||||
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
return f"二次分配合计 {total_text}%,余额 {rest}%(归属待业务确认)"
|
||||
|
||||
|
||||
def create_business(
|
||||
session: Session,
|
||||
payload: BusinessCreateIn,
|
||||
@ -335,6 +382,13 @@ def create_business(
|
||||
operator_name=operator.username,
|
||||
),
|
||||
)
|
||||
# 二次分配方案与业务同事务(需求:只出现在合同刚建立时);
|
||||
# 放在重试循环内——rollback 后方案行随业务一并重来
|
||||
business_repository.replace_allocations(
|
||||
session,
|
||||
business.id,
|
||||
_build_allocation_rows(session, business.id, payload.secondary_allocations),
|
||||
)
|
||||
customer = customer_repository.get_by_id(session, business.customer_id)
|
||||
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||||
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
|
||||
@ -408,6 +462,13 @@ def update_business(
|
||||
business_repository.replace_items(
|
||||
session, business_id, _build_item_rows(business_id, payload.items)
|
||||
)
|
||||
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
|
||||
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
|
||||
business_repository.replace_allocations(
|
||||
session,
|
||||
business_id,
|
||||
_build_allocation_rows(session, business_id, payload.secondary_allocations),
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
||||
|
||||
211
backend/tests/integration/test_secondary_allocation.py
Normal file
211
backend/tests/integration/test_secondary_allocation.py
Normal file
@ -0,0 +1,211 @@
|
||||
"""二次分配方案录入(业务创建/编辑同事务)集成测试。
|
||||
|
||||
守的是(计划 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
|
||||
111
backend/tests/unit/test_secondary_allocation_schema.py
Normal file
111
backend/tests/unit/test_secondary_allocation_schema.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""二次分配方案入参校验(schema 层,秒级)。
|
||||
|
||||
守的是:比例红线(字符串入参、两位小数、超限拒绝不静默舍入)与
|
||||
参与人去重——uk(business_id, personnel_id) 的友好前置校验。
|
||||
明细行同批创建的正常路径由集成测试覆盖,这里集中打边界。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.business import SecondaryAllocationIn
|
||||
|
||||
|
||||
def _make(personnel_id: int = 1, ratio: str = "50.00") -> dict:
|
||||
return {"personnel_id": personnel_id, "ratio": ratio}
|
||||
|
||||
|
||||
class TestRatioBoundary:
|
||||
"""比例边界:0 / 满额 / 超限 / 精度 / 非数字。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ratio", "ok"),
|
||||
[
|
||||
("0", False), # 必须大于 0
|
||||
("0.01", True),
|
||||
("33.33", True),
|
||||
("100", True),
|
||||
("100.00", True),
|
||||
("100.01", False), # 超上限
|
||||
("-5", False), # 负数
|
||||
("33.333", False), # 三位小数:拒绝不静默舍入
|
||||
("abc", False), # 非数字
|
||||
("", False), # 空串
|
||||
],
|
||||
)
|
||||
def test_比例边界(self, ratio: str, *, ok: bool) -> None:
|
||||
if ok:
|
||||
assert SecondaryAllocationIn(**_make(ratio=ratio)).ratio == ratio
|
||||
else:
|
||||
with pytest.raises(ValidationError):
|
||||
SecondaryAllocationIn(**_make(ratio=ratio))
|
||||
|
||||
def test_人员id必须为正整数(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SecondaryAllocationIn(**_make(personnel_id=0))
|
||||
with pytest.raises(ValidationError):
|
||||
SecondaryAllocationIn(**_make(personnel_id=-1))
|
||||
|
||||
|
||||
class TestDuplicate:
|
||||
"""参与人去重(Create/Update 两个入参模型同款校验)。"""
|
||||
|
||||
def test_创建入参_重复参与人被拒(self) -> None:
|
||||
from app.schemas.business import BusinessCreateIn
|
||||
|
||||
payload = {
|
||||
"customer_id": 1,
|
||||
"items": [
|
||||
{
|
||||
"product_name": "t_产品",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "1.00",
|
||||
"sale_price": "10.00",
|
||||
}
|
||||
],
|
||||
"secondary_allocations": [
|
||||
_make(personnel_id=5, ratio="30.00"),
|
||||
_make(personnel_id=5, ratio="20.00"),
|
||||
],
|
||||
}
|
||||
with pytest.raises(ValidationError, match="重复"):
|
||||
BusinessCreateIn(**payload)
|
||||
|
||||
def test_编辑入参_重复参与人被拒(self) -> None:
|
||||
from app.schemas.business import BusinessUpdateIn
|
||||
|
||||
payload = {
|
||||
"customer_id": 1,
|
||||
"items": [
|
||||
{
|
||||
"product_name": "t_产品",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "1.00",
|
||||
"sale_price": "10.00",
|
||||
}
|
||||
],
|
||||
"secondary_allocations": [_make(personnel_id=5), _make(personnel_id=5)],
|
||||
}
|
||||
with pytest.raises(ValidationError, match="重复"):
|
||||
BusinessUpdateIn(**payload)
|
||||
|
||||
def test_不同参与人合法(self) -> None:
|
||||
from app.schemas.business import BusinessCreateIn
|
||||
|
||||
payload = {
|
||||
"customer_id": 1,
|
||||
"items": [
|
||||
{
|
||||
"product_name": "t_产品",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "1.00",
|
||||
"sale_price": "10.00",
|
||||
}
|
||||
],
|
||||
"secondary_allocations": [
|
||||
_make(personnel_id=5, ratio="30.00"),
|
||||
_make(personnel_id=6, ratio="20.00"),
|
||||
],
|
||||
}
|
||||
model = BusinessCreateIn(**payload)
|
||||
assert len(model.secondary_allocations) == 2
|
||||
Loading…
Reference in New Issue
Block a user