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

+62 -1
View File
@@ -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 已给出新公司名)