diff --git a/backend/app/repositories/performance_ledger_repository.py b/backend/app/repositories/performance_ledger_repository.py index fc019b6..181211f 100644 --- a/backend/app/repositories/performance_ledger_repository.py +++ b/backend/app/repositories/performance_ledger_repository.py @@ -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 ( diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 48c7bfa..40029bb 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -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( diff --git a/backend/app/services/calc_service.py b/backend/app/services/calc_service.py index 1c386d3..3cfd511 100644 --- a/backend/app/services/calc_service.py +++ b/backend/app/services/calc_service.py @@ -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 diff --git a/backend/tests/integration/test_businesses.py b/backend/tests/integration/test_businesses.py index c204048..842ecb0 100644 --- a/backend/tests/integration/test_businesses.py +++ b/backend/tests/integration/test_businesses.py @@ -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: """状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。""" diff --git a/backend/tests/integration/test_calc.py b/backend/tests/integration/test_calc.py index 90372ac..d088b1f 100644 --- a/backend/tests/integration/test_calc.py +++ b/backend/tests/integration/test_calc.py @@ -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"]