feat(calc): 二次分摊拆行与 #12 单笔侧开关——按方案比例拆给参与人、余额归负责人

- _personal_rate 的 TPL_MANAGER_SHARE 分支读 manager.personal.included:1=按业务员递减公式计提,0=单笔落 0(现状)
- _load_allocations 装载生效方案并防御拦截(合计超 100%、参与人含负责人)
- _persist_ledger 拆行落库:参与人=份额×比例(q2)、负责人=q2 全额−已分累计(行合计守恒防分漂);快照带 alloc_plan/alloc_role/alloc_ratio
- _prepare_mode 重算改 list_active_by_receipt 作废全部拆分行;uk 三列版支持同回款按人多行
- business_service 保存侧同口径校验(超 100% 400、负责人自分 400、hint 文案定稿)
- 集成用例 +5(拆行守恒/叠加进度登记/重算全作废/双拦截)+保存侧 3 条

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-19 13:15:59 +08:00
parent 48c0a86279
commit ccfd20651b
5 changed files with 479 additions and 58 deletions

View File

@ -16,7 +16,12 @@ def get_by_id(session: Session, ledger_id: int) -> PerformanceLedger | None:
def get_active_by_receipt( def get_active_by_receipt(
session: Session, receipt_id: int, *, calc_type: int = 1 session: Session, receipt_id: int, *, calc_type: int = 1
) -> PerformanceLedger | None: ) -> PerformanceLedger | None:
"""取某回款的当前生效核算行(is_active=1;无则 None)。""" """取某回款的当前生效核算行(is_active=1;无则 None)。
二次分摊拆行后同回款可有多行(负责人+参与人)——重算作废用
``list_active_by_receipt``;本函数仅限确知单行的调用方(重算前的
存在性判断按业务维度走 has_active_for_business)。
"""
return session.execute( return session.execute(
select(PerformanceLedger).where( select(PerformanceLedger).where(
PerformanceLedger.receipt_id == receipt_id, PerformanceLedger.receipt_id == receipt_id,
@ -26,6 +31,21 @@ def get_active_by_receipt(
).scalar_one_or_none() ).scalar_one_or_none()
def list_active_by_receipt(
session: Session, receipt_id: int, *, calc_type: int = 1
) -> list[PerformanceLedger]:
"""取某回款当前生效的**全部**核算行(分摊拆行后负责人+参与人各一行)。"""
return list(
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.receipt_id == receipt_id,
PerformanceLedger.calc_type == calc_type,
PerformanceLedger.is_active == 1,
)
).scalars()
)
def has_active_for_business(session: Session, business_id: int, *, calc_type: int = 1) -> bool: def has_active_for_business(session: Session, business_id: int, *, calc_type: int = 1) -> bool:
"""该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。""" """该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。"""
return ( return (

View File

@ -298,8 +298,24 @@ def _build_allocation_rows(
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对 编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
**新加入**的参与人做在职校验。 **新加入**的参与人做在职校验。
:raises BadRequestError: 新增参与人员不存在或已离职 :raises BadRequestError: 新增参与人员不存在或已离职 / 比例合计超 100%
/ 参与人含合同负责人(口径见澄清 #1/#2 答复 2026-10-19:分配绩效、
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
自分份额则双重计入)
""" """
business = business_repository.get_by_id(session, business_id)
if business is None:
raise NotFoundError("业务不存在")
total = sum((Decimal(item.ratio) for item in items), Decimal("0"))
if total > Decimal("100"):
raise BadRequestError(
f"二次分配比例合计 {total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}%"
" 超过 100%(余额归合同负责营销人员,比例不可超满额)"
)
owner_id = business.owner_personnel_id
if owner_id is not None and any(item.personnel_id == owner_id for item in items):
raise BadRequestError("分摊参与人不可包含合同负责营销人员(负责人得余额份额)")
rows: list[SecondaryAllocation] = [] rows: list[SecondaryAllocation] = []
for item in items: for item in items:
if item.personnel_id in old_ids: if item.personnel_id in old_ids:
@ -320,7 +336,7 @@ def _build_allocation_rows(
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None: def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
"""方案合计/余额提示(不强制合计满额是需求原文;余额归属待澄清 #2)。 """方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。 无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
""" """
@ -331,7 +347,7 @@ def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
if total == Decimal("100"): if total == Decimal("100"):
return f"二次分配合计 {total_text}%" return f"二次分配合计 {total_text}%"
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"二次分配合计 {total_text}%,余额 {rest}%(归属待业务确认)" return f"二次分配合计 {total_text}%,余额 {rest}%(归合同负责营销人员)"
def create_business( def create_business(

View File

@ -6,9 +6,13 @@
按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、 按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、
核算才是算钱的法定固化点。 核算才是算钱的法定固化点。
策略点(待业务答复,全部集中为单函数,答复后只改函数 + 按回款批量重算): 策略点(业务答复 2026-10-19 已回填,后续调整仍集中在对应函数 + 按回款批量重算):
- ``_personal_rate``:#12 经理个人户单笔落台账 rate=0/绩效 0(12.5%+5% - ``_personal_rate``:#12 经理个人户单笔由参数 manager.personal.included
聚合口径在月结时由 manager_team_share 计算);#20 在 ``_row_profit`` 分流——0=单笔落 0(聚合口径在月结时由 manager_team_share 计算)、
1=按业务员递减公式计提;#20 在 ``_row_profit``(暂按不含税原样相减)
- 二次分摊(澄清 #1 答复「分配绩效」,算后分):``_load_allocations``
读生效方案,``_persist_ledger`` 按比例拆行(参与人份额 + 负责人余额,
行合计守恒);#2 余额归负责人、#3 二次分配即协助场景的核算载体
- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发, - 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发,
后续回款仅置 settlement_status=1(repeated=True),不重复计提 后续回款仅置 settlement_status=1(repeated=True),不重复计提
""" """
@ -29,6 +33,7 @@ from app.models import (
BusinessItem, BusinessItem,
Customer, Customer,
PerformanceLedger, PerformanceLedger,
Personnel,
Receipt, Receipt,
Shipment, Shipment,
User, User,
@ -54,8 +59,8 @@ from app.services.param_service import parse_fraction, resolve_snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# snapshot_json 里的策略点注记(答复回填时的检索锚点) # snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(#12 待答复):聚合口径在月结时由团队聚合计算" _NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -115,31 +120,34 @@ def calc_receipt(
person = _load_person(session, business) person = _load_person(session, business)
snapshot = resolve_snapshot(session, receipt.receipt_date) snapshot = resolve_snapshot(session, receipt.receipt_date)
items = _load_items(session, business) items = _load_items(session, business)
allocations = _load_allocations(session, business, person.id)
conversion = _convert_items(snapshot, person, items) conversion = _convert_items(snapshot, person, items)
amounts = _compose_amounts( amounts = _compose_amounts(
session, snapshot, person, business, receipt, shipment, conversion session, snapshot, person, business, receipt, shipment, conversion
) )
row = _persist_ledger( rows = _persist_ledger(
session, session,
receipt=receipt, receipt=receipt,
business=business, business=business,
person=person, person=person,
shipment=shipment, shipment=shipment,
items=items, items=items,
allocations=allocations,
conversion=conversion, conversion=conversion,
amounts=amounts, amounts=amounts,
recalc_reason=recalc_reason, recalc_reason=recalc_reason,
is_recalc=is_recalc, is_recalc=is_recalc,
) )
session.flush() session.flush()
out = CalcReceiptOut(ledger=_to_out(row), repeated=False) out = CalcReceiptOut(ledger=_to_out(rows.owner_row), repeated=False)
session.commit() session.commit()
logger.info( logger.info(
"核算完成 | receipt=%s | ledger=%s | payable=%s", "核算完成 | receipt=%s | ledger=%s | payable=%s | 分摊行=%s",
receipt_id, receipt_id,
row.id, rows.owner_row.id,
amounts.payable, amounts.payable,
len(rows.share_rows),
) )
return out return out
@ -184,10 +192,12 @@ def _prepare_mode(
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现 # 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
# 两行生效行导致双重计提 → 一律 400 # 两行生效行导致双重计提 → 一律 400
if is_recalc: if is_recalc:
old = performance_ledger_repository.get_active_by_receipt(session, receipt.id) # 分摊拆行后旧版本可能多行(负责人+参与人)——全部作废再落新版本
if old is None: olds = performance_ledger_repository.list_active_by_receipt(session, receipt.id)
if not olds:
raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算") raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
performance_ledger_repository.mark_inactive(session, old) for old in olds:
performance_ledger_repository.mark_inactive(session, old)
return None return None
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效 # 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
@ -231,6 +241,27 @@ def _load_items(session: Session, business: Business) -> list[BusinessItem]:
return items return items
def _load_allocations(session: Session, business: Business, owner_id: int) -> list:
"""业务生效的二次分配方案(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
校验双保险之一(保存侧在 business_service):比例合计 >100% 会把负责人
份额算成负数(多算参与人绩效);合同负责人不可出现在参与人列表(自分
份额会双重计入)。无方案返回空列表 = 全额归负责人。
"""
allocations = business_repository.list_allocations(session, business.id)
if not allocations:
return []
total = sum((a.ratio for a in allocations), Decimal("0"))
if total > Decimal("100"):
raise BadRequestError(
f"二次分配比例合计 {total.quantize(Decimal('0.01'))}% 超过 100%,"
"无法核算(先调整方案比例)"
)
if any(a.personnel_id == owner_id for a in allocations):
raise BadRequestError("二次分配参与人含合同负责营销人员,无法核算(先调整方案)")
return list(allocations)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _Conversion: class _Conversion:
"""逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。""" """逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。"""
@ -347,6 +378,14 @@ def _compose_amounts(
) )
@dataclass(frozen=True, slots=True)
class _LedgerRows:
"""拆行落库结果:负责人行恒存在(出参与 CAS 锚点),参与人份额行可空。"""
owner_row: PerformanceLedger
share_rows: list[PerformanceLedger]
def _persist_ledger( def _persist_ledger(
session: Session, session: Session,
*, *,
@ -355,14 +394,23 @@ def _persist_ledger(
person, person,
shipment: Shipment, shipment: Shipment,
items: list[BusinessItem], items: list[BusinessItem],
allocations: list,
conversion: _Conversion, conversion: _Conversion,
amounts: _Amounts, amounts: _Amounts,
recalc_reason: str | None, recalc_reason: str | None,
is_recalc: bool, is_recalc: bool,
) -> PerformanceLedger: ) -> _LedgerRows:
"""落台账行(版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。""" """落台账行(分摊拆行;版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。
分摊(澄清 #1 答复 2026-10-19「分配绩效」,算后分):业务级全额(含
递减比例、抵顶、支付权重全环节)算完后按方案比例拆给参与人,余额归
合同负责人。行合计与业务级全额**守恒**(负责人行取 q2 全额减参与人
q2 份额的差额,防分漂);每行快照带完整方案快照,行级可独立回溯。
参与人离职照分(历史方案事实,方案调整是业务侧责任——入职校验只在
保存侧,核算侧只要求人员存在,FK RESTRICT 已保证)。
"""
receipt_date = receipt.receipt_date receipt_date = receipt.receipt_date
snapshot_json: dict[str, Any] = { base_snapshot: dict[str, Any] = {
"params_as_of": str(receipt_date), "params_as_of": str(receipt_date),
"line_details": conversion.line_details, "line_details": conversion.line_details,
"start_payment_ok": amounts.start_ok, "start_payment_ok": amounts.start_ok,
@ -371,43 +419,100 @@ def _persist_ledger(
"profit_total": str(conversion.total_profit), "profit_total": str(conversion.total_profit),
} }
if amounts.decision.manager_pending: if amounts.decision.manager_pending:
snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING base_snapshot["strategy_note"] = _NOTE_MANAGER_PENDING
if allocations:
base_snapshot["alloc_plan"] = [
{
"personnel_id": a.personnel_id,
"personnel_name": a.personnel_name,
"ratio": str(a.ratio),
}
for a in allocations
]
version = performance_ledger_repository.next_version(session, receipt.id)
customer_type = _customer_type(session, business)
category = _category_label(items)
# 份额清单:(person, ratio, tag)——ratio 为 None 时走余额公式(q2 全额
# 减已分累计,守恒);负责人余额的百分数(100−∑参与人)仅作快照展示,
# 不参与计算——按它乘全额会丢 q2 尾差(39.1111×30% ≠ 39.11−27.37)
shares: list[tuple[Any, Decimal | None, str]] = []
owner_ratio_text: str | None = None
if not allocations:
shares.append((person, None, "owner"))
owner_ratio_text = "100"
else:
total_ratio = Decimal("0")
for a in allocations:
shares.append((session.get(Personnel, a.personnel_id), a.ratio, "participant"))
total_ratio += a.ratio
owner_ratio_text = str((Decimal("100") - total_ratio).quantize(Decimal("0.01")))
shares.append((person, None, "owner"))
def _share(full: Decimal, ratio: Decimal | None, *, added: Decimal = Decimal("0")) -> Decimal:
"""单份额金额:参与人 = 全额×比例(q2);负责人 = q2 全额 − 已分累计。"""
if ratio is None:
return _q2(full - added) or Decimal("0")
# Fraction 乘积先 to_decimal 再 q2(Fraction 无 quantize,直接喂 _q2 会炸)
return _q2(param_service.to_decimal(Fraction(full) * Fraction(ratio) / 100)) or Decimal("0")
added_perf = Decimal("0")
added_conv = Decimal("0")
added_pay = Decimal("0")
owner_row: PerformanceLedger | None = None
share_rows: list[PerformanceLedger] = []
for target, ratio, tag in shares:
perf = _share(amounts.performance, ratio, added=added_perf)
conv = _share(amounts.converted, ratio, added=added_conv)
pay = _share(amounts.payable, ratio, added=added_pay)
added_perf += perf
added_conv += conv
added_pay += pay
row = performance_ledger_repository.add(
session,
PerformanceLedger(
receipt_id=receipt.id,
calc_version=version,
calc_type=CALC_TYPE_RECEIPT,
business_id=business.id,
business_no=business.business_no,
personnel_id=target.id,
personnel_name=target.personnel_name,
role_code=role_code_of(target),
customer_type=customer_type,
product_category=category,
ship_date=shipment.ship_date,
receipt_date=receipt_date,
payment_days=amounts.days,
rate_text=str(amounts.decision.rate),
converted_profit=conv,
performance_amount=perf,
barter_applied=1 if amounts.barter_applied else 0,
weight_r=amounts.weight_r,
payment_weight=param_service.to_decimal(amounts.weight),
payable_amount=pay,
settlement_month=receipt_date.strftime("%Y-%m"),
is_probation=1 if target.is_probation == 1 else 0,
missing_cost=1 if conversion.missing_cost else 0,
snapshot_json={
**base_snapshot,
"alloc_role": tag,
"alloc_ratio": owner_ratio_text if tag == "owner" else str(ratio),
},
calc_reason=recalc_reason,
),
)
if tag == "owner":
owner_row = row
else:
share_rows.append(row)
row = performance_ledger_repository.add(
session,
PerformanceLedger(
receipt_id=receipt.id,
calc_version=performance_ledger_repository.next_version(session, receipt.id),
calc_type=CALC_TYPE_RECEIPT,
business_id=business.id,
business_no=business.business_no,
personnel_id=person.id,
personnel_name=person.personnel_name,
role_code=role_code_of(person),
customer_type=_customer_type(session, business),
product_category=_category_label(items),
ship_date=shipment.ship_date,
receipt_date=receipt_date,
payment_days=amounts.days,
rate_text=str(amounts.decision.rate),
converted_profit=_q2(amounts.converted),
performance_amount=_q2(amounts.performance),
barter_applied=1 if amounts.barter_applied else 0,
weight_r=amounts.weight_r,
payment_weight=param_service.to_decimal(amounts.weight),
payable_amount=_q2(amounts.payable),
settlement_month=receipt_date.strftime("%Y-%m"),
is_probation=1 if person.is_probation == 1 else 0,
missing_cost=1 if conversion.missing_cost else 0,
snapshot_json=snapshot_json,
calc_reason=recalc_reason,
),
)
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0 # CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
# 会 rowcount=0 误报并发撞车) # 会 rowcount=0 误报并发撞车)
if not is_recalc: if not is_recalc:
_mark_settled(session, receipt) _mark_settled(session, receipt)
return row return _LedgerRows(owner_row=owner_row, share_rows=share_rows)
def _q2(value: Decimal | None) -> Decimal | None: def _q2(value: Decimal | None) -> Decimal | None:
@ -443,12 +548,23 @@ def _personal_rate(
template = role.calc_template if role else "TPL_SALESPERSON_DECAY" template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
if template == "TPL_MANAGER_SHARE": if template == "TPL_MANAGER_SHARE":
return _RateDecision( # #12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户
Decimal("0"), # 单按业务员递减公式单笔计提(个人户单本质是正常业务,有发货/回款/
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0", # 毛利;聚合基数开关在 settlement_service 同参数消费)——0 时单笔落 0
None, if snapshot.get("manager.personal.included") != "1":
True, return _RateDecision(
) Decimal("0"),
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
None,
True,
)
start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天"
return _RateDecision(rate, note, None, False)
if template == "TPL_INFO_SALES_FIXED": if template == "TPL_INFO_SALES_FIXED":
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线 # 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
rate = parse_fraction(snapshot.get("info.rate")) / 1000 rate = parse_fraction(snapshot.get("info.rate")) / 1000

View File

@ -412,6 +412,71 @@ class TestEdit:
assert len(detail["items"]) == 1 assert len(detail["items"]) == 1
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100") assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
def test_二次分配方案合计超100_保存400(self, client: TestClient, normal: dict) -> None:
"""口径(澄清 #1/#2 答复 2026-10-19):分配绩效、余额归合同负责人——
超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。"""
headers = normal["headers"]
customer = _customer(client, headers)
owner = _personnel(client, headers)
part = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
resp = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(
customer["id"],
secondary_allocations=[
{"personnel_id": part["id"], "ratio": "60"},
{"personnel_id": _personnel(client, headers)["id"], "ratio": "50"},
],
),
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "超过 100%" in resp.json()["message"]
def test_二次分配参与人含负责人_保存400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
owner = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
resp = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(
customer["id"],
secondary_allocations=[{"personnel_id": owner["id"], "ratio": "30"}],
),
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "含合同负责营销人员" in resp.json()["message"]
def test_二次分配方案合法比例_保存成功且回显(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
owner = _personnel(client, headers)
part = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
resp = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(
customer["id"],
secondary_allocations=[{"personnel_id": part["id"], "ratio": "40"}],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
# 方案在详情回包(更新回包为简版 Out,不含方案区)
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
assert len(detail["secondary_allocations"]) == 1
assert Decimal(detail["secondary_allocations"][0]["ratio"]) == Decimal("40")
# 余额 60% 归合同负责人(hint 文案 = #2 答复口径)
assert detail["allocation_hint"] is not None and "60" in detail["allocation_hint"]
class TestStatus: class TestStatus:
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。""" """状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""

View File

@ -22,7 +22,7 @@ from fastapi.testclient import TestClient
from sqlalchemy import select from sqlalchemy import select
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.models import PerformanceLedger, Receipt from app.models import PerformanceLedger, Personnel, Receipt, SecondaryAllocation
from app.models.receipt import SETTLEMENT_DONE from app.models.receipt import SETTLEMENT_DONE
from tests.integration.factories import business_payload, customer from tests.integration.factories import business_payload, customer
@ -584,3 +584,207 @@ class TestBatch:
assert by_id[r1["id"]]["ok"] is True and by_id[r1["id"]]["repeated"] is False assert by_id[r1["id"]]["ok"] is True and by_id[r1["id"]]["repeated"] is False
assert by_id[r2["id"]]["ok"] is True and by_id[r2["id"]]["repeated"] is True assert by_id[r2["id"]]["ok"] is True and by_id[r2["id"]]["repeated"] is True
assert by_id[999999]["ok"] is False assert by_id[999999]["ok"] is False
class TestSecondaryAllocation:
"""二次分摊拆行(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
方案行直插入库(跳过保存侧校验,聚焦核算引擎;保存侧校验用例在
test_businesses)。金标准:双类别混合业务绩效 39.11 / 折算 4000.00,
拆 30%+40% 后参与人 11.73/15.64、负责人余额 11.74,行合计守恒。
"""
def _add_plan(self, business_id: int, rows: list[tuple[int, str]]) -> None:
"""直插方案行(测试隔离造数;核算引擎按「当前落库方案」消费)。"""
factory = get_session_factory()
with factory() as session:
for pid, ratio in rows:
person = session.get(Personnel, pid)
session.add(
SecondaryAllocation(
business_id=business_id,
personnel_id=pid,
personnel_name=person.personnel_name,
ratio=Decimal(ratio),
)
)
session.commit()
def test_分摊拆行_参与人比例与负责人余额守恒(self, client: TestClient, admin: dict) -> None:
biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
parts = [_person(client, admin["headers"]) for _ in range(2)]
self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")])
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 200, resp.text
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.receipt_id == receipt["id"],
PerformanceLedger.is_active == 1,
)
)
.scalars()
.all()
)
assert len(rows) == 3, "负责人+两参与人各一行"
by_pid = {r.personnel_id: r for r in rows}
a, b, o = (
by_pid[parts[0]["id"]],
by_pid[parts[1]["id"]],
by_pid[biz["owner_personnel_id"]],
)
# 绩效份额:A=39.11×30%=11.73、B=39.11×40%=15.64、负责人=守恒余额 11.74
assert (a.performance_amount, b.performance_amount, o.performance_amount) == (
Decimal("11.73"),
Decimal("15.64"),
Decimal("11.74"),
)
# 行合计 == 业务级全额(守恒是分摊红线:多一分少一分都是错账)
assert a.payable_amount + b.payable_amount + o.payable_amount == Decimal("39.11")
# 折算额同比例拆分:4000 的 30%/40%/余额
assert (a.converted_profit, b.converted_profit, o.converted_profit) == (
Decimal("1200.00"),
Decimal("1600.00"),
Decimal("1200.00"),
)
# 行级身份固化:参与人各自岗位;快照带方案与角色标签
# (ratio 为 NUMERIC(5,2) 读回两位 scale,断言用数值比较)
assert a.role_code == b.role_code == "salesperson"
assert a.snapshot_json["alloc_role"] == "participant"
assert Decimal(a.snapshot_json["alloc_ratio"]) == Decimal("30")
assert o.snapshot_json["alloc_role"] == "owner"
assert Decimal(o.snapshot_json["alloc_plan"][0]["ratio"]) == Decimal("30")
def test_分摊后第二笔回款仅登记进度(self, client: TestClient, admin: dict) -> None:
"""一次性计提(#25)与分摊叠加:第二笔不新增行、不重复拆分。"""
biz, first = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="10000.00",
)
part = _person(client, admin["headers"])
self._add_plan(biz["id"], [(part["id"], "50")])
assert _calc(client, admin["headers"], first["id"]).status_code == 200
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-10-25",
"amount": "5000.00",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
second = resp.json()["data"]
out = _calc(client, admin["headers"], second["id"]).json()["data"]
assert out["repeated"] is True
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.business_id == biz["id"],
PerformanceLedger.is_active == 1,
)
)
.scalars()
.all()
)
assert len(rows) == 2, "第二笔回款不得新增拆分行(一次性计提)"
def test_重算作废全部拆分行并落新版本(
self, client: TestClient, admin: dict, finance: dict
) -> None:
biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
parts = [_person(client, admin["headers"]) for _ in range(2)]
self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")])
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
resp = client.post(
f"{_CALC}/receipts/{receipt['id']}/recalc",
json={"reason": "用例:分摊重算"},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(PerformanceLedger.receipt_id == receipt["id"])
)
.scalars()
.all()
)
active = [r for r in rows if r.is_active == 1]
inactive = [r for r in rows if r.is_active == 0]
assert len(active) == 3 and len(inactive) == 3
assert {r.calc_version for r in active} == {2}, "新版本三行"
assert {r.calc_version for r in inactive} == {1}, "旧版本三行全部作废"
def test_合计超100核算侧拦截(self, client: TestClient, admin: dict) -> None:
biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="100.00",
)
part = _person(client, admin["headers"])
self._add_plan(biz["id"], [(part["id"], "60")])
# 再直插另一行绕过保存校验(合计 110),核算侧防御必须拦
other = _person(client, admin["headers"])
factory = get_session_factory()
with factory() as session:
person = session.get(Personnel, other["id"])
session.add(
SecondaryAllocation(
business_id=biz["id"],
personnel_id=other["id"],
personnel_name=person.personnel_name,
ratio=Decimal("50"),
)
)
session.commit()
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 400
assert "超过 100%" in resp.json()["message"]
def test_参与人含负责人核算侧拦截(self, client: TestClient, admin: dict) -> None:
biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="100.00",
)
self._add_plan(biz["id"], [(biz["owner_personnel_id"], "30")])
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 400
assert "含合同负责营销人员" in resp.json()["message"]