"""单笔回款核算集成测试(M3 ④:金标准复算到分)。 金标准场景(双类别混合业务,覆盖行级折算与汇总): - 行1 市政水利:100×100.00 = 销售 10000,成本 3000 → 毛利率 0.7,阈值 30% → 折算 10000×(0.7−0.3) = 4000 - 行2 轨道交通:50×200.00 = 销售 10000,成本 8000 → 毛利率 0.2,阈值 20% → 折算 10000×(0.2−0.2) = 0 - 折算合计 4000;发货 10-01 → 回款 10-20,days=20 - 率 = (10 − 20×4/360)‰ = 88/9000;绩效 = 4000×88/9000 ≈ 39.11 - 回款 20000(=销售额 100%)→ R=100、权重 1 → 应付 = 39.11 守的还有:核算粒度 #25(业务级一次性计提——第二笔回款仅登记进度)、 已核算再核算 400、未指定批次 400、成本缺失行毛利按 0 计、负绩效照算、 准入(normal 403 / marketing_ops 可用)。 """ import uuid from decimal import Decimal import pytest from fastapi.testclient import TestClient from sqlalchemy import select from app.core.db import get_session_factory from app.models import PerformanceLedger, Personnel, Receipt, SecondaryAllocation from app.models.receipt import SETTLEMENT_DONE from tests.integration.factories import business_payload, customer pytestmark = pytest.mark.integration _CALC = "/api/v1/calc" _BUSINESSES = "/api/v1/business/businesses" _SHIPMENTS = "/api/v1/business/shipments" _RECEIPTS = "/api/v1/business/receipts" _PERSONNEL = "/api/v1/base/personnel" def _person(client: TestClient, headers: dict) -> dict: resp = client.post( _PERSONNEL, json={ "personnel_name": f"t_核算_{uuid.uuid4().hex[:8]}", "business_role_id": _role_id("salesperson"), "salary_standard": "3000.00", }, headers=headers, ) assert resp.status_code == 200, resp.text return resp.json()["data"] def _role_id(code: str) -> int: from sqlalchemy import select from app.core.constants import BUSINESS_ROLE_SALESPERSON from app.core.db import get_session_factory from app.models import BusinessRole factory = get_session_factory() with factory() as session: role_code = BUSINESS_ROLE_SALESPERSON if code == "salesperson" else code return int( session.execute( select(BusinessRole.id).where( BusinessRole.role_code == role_code, BusinessRole.status == 1 ) ).scalar_one() ) def _create_full_chain( client: TestClient, headers: dict, *, items: list[dict], ship_date: str, receipt_date: str, amount: str, ) -> tuple[dict, dict]: """客户 → 人员 → 业务(双行明细+归属)→ 发货 → 回款(指定批次)。""" cust = customer(client, headers) person = _person(client, headers) resp = client.post( _BUSINESSES, json=business_payload(cust["id"], owner_personnel_id=person["id"], items=items), headers=headers, ) assert resp.status_code == 200, resp.text biz = resp.json()["data"] resp = client.post( _SHIPMENTS, json={ "business_id": biz["id"], "ship_date": ship_date, "items": [{"product_name": "t_发货物", "quantity": "1", "sale_price": "1.00"}], }, headers=headers, ) assert resp.status_code == 200, resp.text shipment = resp.json()["data"] resp = client.post( _RECEIPTS, json={ "business_id": biz["id"], "receipt_date": receipt_date, "amount": amount, "shipment_id": shipment["id"], }, headers=headers, ) assert resp.status_code == 200, resp.text receipt = resp.json()["data"] return biz, receipt def _calc(client: TestClient, headers: dict, receipt_id: int): return client.post(f"{_CALC}/receipts/{receipt_id}/calc", headers=headers) @pytest.fixture def finance(make_user, auth_headers) -> dict: """财务角色账号(重算授权角色之一)。""" user = make_user(["finance"]) user["headers"] = auth_headers(user) return user _GOLD_ITEMS = [ { "product_name": "t_市政_金标", "product_category": "municipal_water", "quantity": "100", "sale_price": "100.00", "cost_amount": "3000.00", }, { "product_name": "t_轨交_金标", "product_category": "rail_transit", "quantity": "50", "sale_price": "200.00", "cost_amount": "8000.00", }, ] class TestGoldenStandard: """金标准:双类别混合业务复算到分(Decimal 精确断言)。""" 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", ) resp = _calc(client, admin["headers"], receipt["id"]) assert resp.status_code == 200, resp.text out = resp.json()["data"] assert out["repeated"] is False ledger = out["ledger"] assert ledger["payment_days"] == 20 assert ledger["settlement_month"] == "2026-10" # 率 = (10 − 20×4/360)‰ = 88/9000,Fraction 约分原文 11/1125 assert ledger["rate_text"] == "11/1125" assert ledger["weight_r"] == 100 assert ledger["missing_cost"] == 0 assert ledger["snapshot_json"]["line_details"][0]["converted"] == "4000.00" # 金额锚定 DB 事实源(NUMERIC(14,2) 读回恒两位;响应 JSON 的 Decimal # 序列化 scale 不作断言——归前端展示问题) factory = get_session_factory() with factory() as session: row = session.execute( select(PerformanceLedger).where( PerformanceLedger.receipt_id == receipt["id"], PerformanceLedger.is_active == 1, ) ).scalar_one() assert row.converted_profit == Decimal("4000.00") # 绩效 = 4000 × 88/9000 = 39.111… → 落库两位 39.11 assert row.performance_amount == Decimal("39.11") # 应付 = 绩效 × 权重 1 = 39.11 assert row.payable_amount == Decimal("39.11") # 回款状态被 CAS 置已核算 detail = client.get(f"{_RECEIPTS}/{receipt['id']}", headers=admin["headers"]).json()["data"] assert detail["settlement_status"] == 1 def test_已核算回款再核算400(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", ) assert _calc(client, admin["headers"], receipt["id"]).status_code == 200 resp = _calc(client, admin["headers"], receipt["id"]) assert resp.status_code == 400 assert "已核算" in resp.json()["message"] 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", ) 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", "shipment_id": None, }, headers=admin["headers"], ) assert resp.status_code == 200, resp.text second = resp.json()["data"] resp = _calc(client, admin["headers"], second["id"]) assert resp.status_code == 200, resp.text out = resp.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) == 1, "第二笔回款不得新增台账行(一次性计提)" receipts = ( session.execute(select(Receipt).where(Receipt.business_id == biz["id"])) .scalars() .all() ) assert all(r.settlement_status == SETTLEMENT_DONE for r in receipts) class TestGuards: """校验链与策略点。""" def test_未指定批次400(self, client: TestClient, admin: dict) -> None: cust = customer(client, admin["headers"]) person = _person(client, admin["headers"]) resp = client.post( _BUSINESSES, json=business_payload(cust["id"], owner_personnel_id=person["id"]), headers=admin["headers"], ) assert resp.status_code == 200 biz = resp.json()["data"] resp = client.post( _RECEIPTS, json={ "business_id": biz["id"], "receipt_date": "2026-10-20", "amount": "100.00", }, headers=admin["headers"], ) assert resp.status_code == 200 receipt = resp.json()["data"] resp = _calc(client, admin["headers"], receipt["id"]) assert resp.status_code == 400 assert "发货批次" in resp.json()["message"] def test_成本缺失行_毛利按0计并标记(self, client: TestClient, admin: dict) -> None: items = [ { "product_name": "t_无成本_市政", "product_category": "municipal_water", "quantity": "100", "sale_price": "100.00", "cost_amount": None, } ] _biz, receipt = _create_full_chain( client, admin["headers"], items=items, ship_date="2026-10-01", receipt_date="2026-10-20", amount="10000.00", ) resp = _calc(client, admin["headers"], receipt["id"]) assert resp.status_code == 200, resp.text ledger = resp.json()["data"]["ledger"] # 成本缺失 → 毛利按 0 → 折算 = 销售额×(0−30%) = **−3000**(保守少算 # 方向:缺成本的行宁少算不多发),missing_cost=1 assert ledger["missing_cost"] == 1 assert float(ledger["converted_profit"]) == -3000.0 assert float(ledger["performance_amount"]) == pytest.approx(-29.33, abs=0.01) def test_负绩效照算不归零(self, client: TestClient, admin: dict) -> None: items = [ { "product_name": "t_亏损_市政", "product_category": "municipal_water", "quantity": "100", "sale_price": "100.00", "cost_amount": "9000.00", } ] _biz, receipt = _create_full_chain( client, admin["headers"], items=items, ship_date="2026-10-01", receipt_date="2026-10-20", amount="10000.00", ) resp = _calc(client, admin["headers"], receipt["id"]) assert resp.status_code == 200, resp.text ledger = resp.json()["data"]["ledger"] # 毛利 1000、率 0.1 < 阈值 30% → 折算 10000×(0.1−0.3) = −2000 → 负绩效 assert float(ledger["converted_profit"]) == -2000.0 assert float(ledger["performance_amount"]) < 0 def test_准入_normal403_marketing_ops可用( self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers ) -> 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", ) resp = _calc(client, normal["headers"], receipt["id"]) assert resp.status_code == 403 ops = make_user(["marketing_ops"]) ops["headers"] = auth_headers(ops) resp = _calc(client, ops["headers"], receipt["id"]) assert resp.status_code == 200, resp.text def test_分界天数参数小数原文_核算不500(self, client: TestClient, admin: dict) -> None: """守 int(参数原文) 缺陷:INT 参数入库允许 '360.0'(Fraction 分母为 1), 直接 int(str) 抛 ValueError 会让整月核算 500。""" resp = client.post( "/api/v1/system/params/changes", json={ "change_type": 1, "remark": "用例:INT 参数小数原文", "items": [ { "param_code": "salesperson.days.boundary", "dimension_value": "salesperson", "value": "360.0", } ], }, headers=admin["headers"], ) assert resp.status_code == 200, resp.text _biz, receipt = _create_full_chain( client, admin["headers"], items=_GOLD_ITEMS, ship_date="2026-10-01", receipt_date="2026-10-20", amount="20000.00", ) resp = _calc(client, admin["headers"], receipt["id"]) assert resp.status_code == 200, resp.text # 分界取整无损:20 天 < 360 天 → 同一递减公式 assert resp.json()["data"]["ledger"]["rate_text"] == "11/1125" 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", ) _calc(client, admin["headers"], receipt["id"]) resp = client.get( f"{_CALC}/ledger", params={"settlement_month": "2026-10", "is_active": 1}, headers=admin["headers"], ) assert resp.status_code == 200 rows = resp.json()["data"]["list"] assert rows and rows[0]["business_no"] == biz["business_no"] class TestRecalc: """重算:授权动作,作废旧版本、落新版本并留痕。""" 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", ) assert _calc(client, admin["headers"], receipt["id"]).status_code == 200 fin = finance resp = client.post( f"{_CALC}/receipts/{receipt['id']}/recalc", json={"reason": "成本录入修正"}, headers=fin["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"]) .order_by(PerformanceLedger.calc_version) ) .scalars() .all() ) assert [(r.calc_version, r.is_active) for r in rows] == [(1, 0), (2, 1)] assert rows[1].calc_reason == "成本录入修正" # 详情回显的是新版本 assert ( client.get( f"{_CALC}/ledger", params={"receipt_id": receipt["id"], "is_active": 1}, headers=admin["headers"], ).json()["data"]["list"][0]["calc_version"] == 2 ) def test_重算_原因缺失400(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", ) assert _calc(client, admin["headers"], receipt["id"]).status_code == 200 resp = client.post( f"{_CALC}/receipts/{receipt['id']}/recalc", json={"reason": " "}, headers=admin["headers"], ) assert resp.status_code == 400 def test_未核算过_重算400(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", ) resp = client.post( f"{_CALC}/receipts/{receipt['id']}/recalc", json={"reason": "无旧版本"}, headers=admin["headers"], ) assert resp.status_code == 400 assert "尚未核算" in resp.json()["message"] def test_进度登记回款重算400_不得凭空落行(self, client: TestClient, admin: dict) -> None: """业务级一次性计提的第二笔回款无台账行;重算若落新行会双重计提。""" biz, first = _create_full_chain( client, admin["headers"], items=_GOLD_ITEMS, ship_date="2026-10-01", receipt_date="2026-10-20", amount="20000.00", ) 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": "100.00", }, headers=admin["headers"], ) assert resp.status_code == 200, resp.text second = resp.json()["data"] assert _calc(client, admin["headers"], second["id"]).json()["data"]["repeated"] is True resp = client.post( f"{_CALC}/receipts/{second['id']}/recalc", json={"reason": "尝试重算进度登记"}, headers=admin["headers"], ) assert resp.status_code == 400, resp.text assert "进度登记" in resp.json()["message"] # 同业务仍只有一行生效台账(未凭空新增) 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) == 1 def test_重算_普通用户与营销后勤403( self, client: TestClient, normal: dict, make_user, auth_headers ) -> None: ops = make_user(["marketing_ops"]) ops["headers"] = auth_headers(ops) for headers in (normal["headers"], ops["headers"]): resp = client.post(f"{_CALC}/receipts/1/recalc", json={"reason": "x"}, headers=headers) assert resp.status_code == 403 class TestBatch: """批量核算:逐笔独立事务,失败不中断。""" def test_批量_逐笔成败清单(self, client: TestClient, admin: dict) -> None: biz, r1 = _create_full_chain( client, admin["headers"], items=_GOLD_ITEMS, ship_date="2026-10-01", receipt_date="2026-10-20", amount="20000.00", ) # 同业务第二笔回款:核算走 #25 重复分支(ok=True、repeated=True) resp = client.post( _RECEIPTS, json={ "business_id": biz["id"], "receipt_date": "2026-10-25", "amount": "100.00", }, headers=admin["headers"], ) assert resp.status_code == 200, resp.text r2 = resp.json()["data"] resp = client.post( f"{_CALC}/batch", json={"receipt_ids": [r1["id"], r2["id"], 999999]}, headers=admin["headers"], ) assert resp.status_code == 200, resp.text results = resp.json()["data"] by_id = {r["receipt_id"]: r for r in results} 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"]