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:
liuwenxinandClaude Code committed 2026-10-15 18:31:27 +08:00
1 parent efed29647c
commit 680661a80b
5 files changed
+475 -3

No files matched your search

+61 -2
View File
@@ -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已终止(终态不可再变更)。"""