feat/m3-increments #1
@ -16,7 +16,12 @@ def get_by_id(session: Session, ledger_id: int) -> PerformanceLedger | None:
|
||||
def get_active_by_receipt(
|
||||
session: Session, receipt_id: int, *, calc_type: int = 1
|
||||
) -> PerformanceLedger | None:
|
||||
"""取某回款的当前生效核算行(is_active=1;无则 None)。"""
|
||||
"""取某回款的当前生效核算行(is_active=1;无则 None)。
|
||||
|
||||
二次分摊拆行后同回款可有多行(负责人+参与人)——重算作废用
|
||||
``list_active_by_receipt``;本函数仅限确知单行的调用方(重算前的
|
||||
存在性判断按业务维度走 has_active_for_business)。
|
||||
"""
|
||||
return session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.receipt_id == receipt_id,
|
||||
@ -26,6 +31,21 @@ def get_active_by_receipt(
|
||||
).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:
|
||||
"""该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。"""
|
||||
return (
|
||||
|
||||
@ -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] = []
|
||||
for item in items:
|
||||
if item.personnel_id in old_ids:
|
||||
@ -320,7 +336,7 @@ def _build_allocation_rows(
|
||||
|
||||
|
||||
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
||||
"""方案合计/余额提示(不强制合计满额是需求原文;余额归属待澄清 #2)。
|
||||
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
|
||||
|
||||
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
|
||||
"""
|
||||
@ -331,7 +347,7 @@ def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
||||
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}%(归属待业务确认)"
|
||||
return f"二次分配合计 {total_text}%,余额 {rest}%(归合同负责营销人员)"
|
||||
|
||||
|
||||
def create_business(
|
||||
|
||||
@ -6,9 +6,13 @@
|
||||
按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、
|
||||
核算才是算钱的法定固化点。
|
||||
|
||||
策略点(待业务答复,全部集中为单函数,答复后只改函数 + 按回款批量重算):
|
||||
- ``_personal_rate``:#12 经理个人户单笔落台账 rate=0/绩效 0(12.5%+5%
|
||||
聚合口径在月结时由 manager_team_share 计算);#20 在 ``_row_profit``
|
||||
策略点(业务答复 2026-10-19 已回填,后续调整仍集中在对应函数 + 按回款批量重算):
|
||||
- ``_personal_rate``:#12 经理个人户单笔由参数 manager.personal.included
|
||||
分流——0=单笔落 0(聚合口径在月结时由 manager_team_share 计算)、
|
||||
1=按业务员递减公式计提;#20 在 ``_row_profit``(暂按不含税原样相减)
|
||||
- 二次分摊(澄清 #1 答复「分配绩效」,算后分):``_load_allocations``
|
||||
读生效方案,``_persist_ledger`` 按比例拆行(参与人份额 + 负责人余额,
|
||||
行合计守恒);#2 余额归负责人、#3 二次分配即协助场景的核算载体
|
||||
- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发,
|
||||
后续回款仅置 settlement_status=1(repeated=True),不重复计提
|
||||
"""
|
||||
@ -29,6 +33,7 @@ from app.models import (
|
||||
BusinessItem,
|
||||
Customer,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
Receipt,
|
||||
Shipment,
|
||||
User,
|
||||
@ -54,8 +59,8 @@ from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# snapshot_json 里的策略点注记(答复回填时的检索锚点)
|
||||
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(#12 待答复):聚合口径在月结时由团队聚合计算"
|
||||
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
|
||||
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -115,31 +120,34 @@ def calc_receipt(
|
||||
person = _load_person(session, business)
|
||||
snapshot = resolve_snapshot(session, receipt.receipt_date)
|
||||
items = _load_items(session, business)
|
||||
allocations = _load_allocations(session, business, person.id)
|
||||
|
||||
conversion = _convert_items(snapshot, person, items)
|
||||
amounts = _compose_amounts(
|
||||
session, snapshot, person, business, receipt, shipment, conversion
|
||||
)
|
||||
row = _persist_ledger(
|
||||
rows = _persist_ledger(
|
||||
session,
|
||||
receipt=receipt,
|
||||
business=business,
|
||||
person=person,
|
||||
shipment=shipment,
|
||||
items=items,
|
||||
allocations=allocations,
|
||||
conversion=conversion,
|
||||
amounts=amounts,
|
||||
recalc_reason=recalc_reason,
|
||||
is_recalc=is_recalc,
|
||||
)
|
||||
session.flush()
|
||||
out = CalcReceiptOut(ledger=_to_out(row), repeated=False)
|
||||
out = CalcReceiptOut(ledger=_to_out(rows.owner_row), repeated=False)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"核算完成 | receipt=%s | ledger=%s | payable=%s",
|
||||
"核算完成 | receipt=%s | ledger=%s | payable=%s | 分摊行=%s",
|
||||
receipt_id,
|
||||
row.id,
|
||||
rows.owner_row.id,
|
||||
amounts.payable,
|
||||
len(rows.share_rows),
|
||||
)
|
||||
return out
|
||||
|
||||
@ -184,10 +192,12 @@ def _prepare_mode(
|
||||
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
|
||||
# 两行生效行导致双重计提 → 一律 400
|
||||
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("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
|
||||
performance_ledger_repository.mark_inactive(session, old)
|
||||
for old in olds:
|
||||
performance_ledger_repository.mark_inactive(session, old)
|
||||
return None
|
||||
|
||||
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
|
||||
@ -231,6 +241,27 @@ def _load_items(session: Session, business: Business) -> list[BusinessItem]:
|
||||
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)
|
||||
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(
|
||||
session: Session,
|
||||
*,
|
||||
@ -355,14 +394,23 @@ def _persist_ledger(
|
||||
person,
|
||||
shipment: Shipment,
|
||||
items: list[BusinessItem],
|
||||
allocations: list,
|
||||
conversion: _Conversion,
|
||||
amounts: _Amounts,
|
||||
recalc_reason: str | None,
|
||||
is_recalc: bool,
|
||||
) -> PerformanceLedger:
|
||||
"""落台账行(版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。"""
|
||||
) -> _LedgerRows:
|
||||
"""落台账行(分摊拆行;版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。
|
||||
|
||||
分摊(澄清 #1 答复 2026-10-19「分配绩效」,算后分):业务级全额(含
|
||||
递减比例、抵顶、支付权重全环节)算完后按方案比例拆给参与人,余额归
|
||||
合同负责人。行合计与业务级全额**守恒**(负责人行取 q2 全额减参与人
|
||||
q2 份额的差额,防分漂);每行快照带完整方案快照,行级可独立回溯。
|
||||
参与人离职照分(历史方案事实,方案调整是业务侧责任——入职校验只在
|
||||
保存侧,核算侧只要求人员存在,FK RESTRICT 已保证)。
|
||||
"""
|
||||
receipt_date = receipt.receipt_date
|
||||
snapshot_json: dict[str, Any] = {
|
||||
base_snapshot: dict[str, Any] = {
|
||||
"params_as_of": str(receipt_date),
|
||||
"line_details": conversion.line_details,
|
||||
"start_payment_ok": amounts.start_ok,
|
||||
@ -371,43 +419,100 @@ def _persist_ledger(
|
||||
"profit_total": str(conversion.total_profit),
|
||||
}
|
||||
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
|
||||
# 会 rowcount=0 误报并发撞车)
|
||||
if not is_recalc:
|
||||
_mark_settled(session, receipt)
|
||||
return row
|
||||
return _LedgerRows(owner_row=owner_row, share_rows=share_rows)
|
||||
|
||||
|
||||
def _q2(value: Decimal | None) -> Decimal | None:
|
||||
@ -443,12 +548,23 @@ def _personal_rate(
|
||||
|
||||
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
|
||||
if template == "TPL_MANAGER_SHARE":
|
||||
return _RateDecision(
|
||||
Decimal("0"),
|
||||
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
|
||||
None,
|
||||
True,
|
||||
)
|
||||
# #12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户
|
||||
# 单按业务员递减公式单笔计提(个人户单本质是正常业务,有发货/回款/
|
||||
# 毛利;聚合基数开关在 settlement_service 同参数消费)——0 时单笔落 0
|
||||
if snapshot.get("manager.personal.included") != "1":
|
||||
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":
|
||||
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
||||
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
||||
|
||||
@ -412,6 +412,71 @@ class TestEdit:
|
||||
assert len(detail["items"]) == 1
|
||||
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:
|
||||
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||
|
||||
@ -22,7 +22,7 @@ from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
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 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[r2["id"]]["ok"] is True and by_id[r2["id"]]["repeated"] is True
|
||||
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"]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user