"""正式链路使用独立内存库,验证金额、防重、生成锁与历史归属。""" import copy from datetime import date from decimal import Decimal from types import SimpleNamespace from uuid import uuid4 import pytest from sqlalchemy import func, select from app.core.config import get_settings from app.core.exceptions import BadRequestError, ForbiddenError from app.models import ( Business, BusinessRole, Customer, PayrollAllocation, PayrollInput, PayrollMonth, PerformanceLedger, Personnel, Product, Receipt, Shipment, ShipmentItem, TPlusSourceLink, TPlusWorkRun, User, ) from app.services import manager_share, payroll_calculation, payroll_months from app.services.payroll_access import can_write_payroll from app.services.tplus.business_import import execute_business from app.services.tplus.workbench import digest from tests.unit.test_tplus_workbench import apply, fact, prepare_calc, preview from tests.unit.test_tplus_workbench import work_session as work_session @pytest.fixture def payroll_case(work_session, monkeypatch): s = work_session user = User(username="test_maker", password="test_hash_only", status=1) s.add(user) role = BusinessRole( role_name="经理", role_code="sales_manager", calc_template="SALES_MANAGER", status=1, sort_order=2, ) s.add(role) s.flush() manager = Personnel( personnel_name="测试经理", business_role_id=role.id, status=1, source_no="90001" ) s.add(manager) s.commit() monkeypatch.setattr(get_settings(), "payroll_maker_user_id", user.id) prepare_calc(s) apply(s, preview(s, ["personnel"])) product = Product( product_name="测试产品", spec_model="测试规格", unit="件", product_category="municipal_water", status=1, ) customer = Customer(company_name="测试客户", owner_name="负责人", customer_type="personal") s.add_all([product, customer]) s.flush() s.add_all( [ TPlusSourceLink( source_database="test_source", entity_type="products", source_id="9", target_id=product.id, ), TPlusSourceLink( source_database="test_source", entity_type="customers", source_id="8", target_id=customer.id, ), ] ) s.commit() s.add( TPlusSourceLink( source_database="test_source", entity_type="personnel", source_id="90001", target_id=manager.id, ) ) s.commit() fact( s, "PERSONNEL", "90001", { "code": "90001", "name": manager.personnel_name, "role_code": "sales_manager", "status": 1, }, ) fact( s, "MANAGER", "manager-biz", {"business_key": "biz", "manager_id": "90001", "effective_date": "2026-03-01"}, ) from app.models import TPlusFact guidance = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GUIDANCE")) guidance.data_json = {**guidance.data_json, "is_finalized": True} s.commit() return s, user, manager def business_work(s, user, kind, previous=None): run = TPlusWorkRun( run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source", requested_by=user.id, kind=kind, status="RUNNING", input_json={"preview_no": previous.run_no} if previous else {"month": "2026-03", "group_keys": ["g"]}, ) s.add(run) s.flush() execute_business(s, run) s.commit() return run def imported(case): s, user, _manager = case pre = business_work(s, user, "BUSINESS_PREVIEW") assert pre.status == "SUCCEEDED", pre.summary_json assert pre.summary_json["counts"] == {"CREATE": 1} run = business_work(s, user, "BUSINESS_APPLY", pre) assert run.status == "SUCCEEDED", run.summary_json return s.scalar(select(Business)) def test_指定账号而非管理员或岗位放行(payroll_case): _, user, _ = payroll_case assert can_write_payroll(user) assert not can_write_payroll(SimpleNamespace(id=user.id + 1, status=1)) assert not can_write_payroll(None) def test_正式导入防重与来源可追溯(payroll_case): s, user, _ = payroll_case business = imported(payroll_case) assert s.scalar(select(func.count()).select_from(Receipt)) == 1 assert s.scalar(select(func.count()).select_from(ShipmentItem)) == 1 again = business_work(s, user, "BUSINESS_PREVIEW") assert again.summary_json["counts"] == {"SKIP": 1} business_work(s, user, "BUSINESS_APPLY", again) assert s.scalar(select(func.count()).select_from(Business)) == 1 record = payroll_calculation.latest_input(s, business.id) assert record.data_json["source_facts"] def test_正式FIFO台账金额与逐段过程守恒(payroll_case): s, user, manager = payroll_case business = imported(payroll_case) result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user) assert result["performance"] == "0.20" assert result["payable"] == "0.20" ledger = s.get(PerformanceLedger, result["ledger_ids"][0]) assert ledger.snapshot_json["manager_id"] == manager.id assert s.scalar(select(PayrollAllocation)).amount == Decimal("100.00") with pytest.raises(BadRequestError, match="已有核算"): payroll_calculation.calculate(s, business.id, "2026-03", operator=user) def test_输入变化使旧预览失效(payroll_case): s, user, _ = payroll_case pre = business_work(s, user, "BUSINESS_PREVIEW") product = s.scalar(select(Product)) product.product_category = "rail_transit" s.commit() with pytest.raises(ValueError, match="PREVIEW_STALE"): business_work(s, user, "BUSINESS_APPLY", pre) s.rollback() assert s.scalar(select(func.count()).select_from(Business)) == 0 def test_经理汇总读取固化业务分管(payroll_case): s, user, manager = payroll_case business = imported(payroll_case) payroll_calculation.calculate(s, business.id, "2026-03", operator=user) rows = list(s.scalars(select(PerformanceLedger))) owner = s.get(Personnel, rows[0].personnel_id) owner.manager_id = None s.commit() buckets = manager_share.collect_team_buckets(s, rows, False) assert buckets[manager.id]["performance"] == Decimal("0.20") def test_生成标记独立于审批状态(payroll_case): s, user, _ = payroll_case control = payroll_months.lock_month(s, "2026-03", user) from datetime import datetime control.generated_at = datetime(2026, 3, 31) s.commit() with pytest.raises(BadRequestError, match="已锁定"): payroll_months.ensure_allocation_editable(s, "2026-03", user) s.rollback() assert payroll_months.ensure_allocation_editable(s, "2026-04", user).generated_at is None def test_生成失败不留下月份锁(payroll_case): s, user, _ = payroll_case from app.services.settlement_service import generate with pytest.raises(BadRequestError, match="无可结算"): generate(s, "2026-03", operator=user) assert s.scalar(select(PayrollMonth)) is None def test_非制作人无法生成工资(payroll_case): s, user, _ = payroll_case with pytest.raises(ForbiddenError): payroll_months.lock_month(s, "2026-03", SimpleNamespace(id=user.id + 1, status=1)) def replace_input(s, record, values): newer = PayrollInput( business_id=record.business_id, version=record.version + 1, source_database=record.source_database, group_key=record.group_key, fingerprint=digest(values), data_json=values, requested_by=record.requested_by, ) s.add(newer) s.commit() return newer @pytest.mark.parametrize( "amount,performance,payable,start", [("50", "0.10", "0.00", False), ("60", "0.12", "0.04", True), ("100", "0.20", "0.20", True)], ) def test_起付百分之六十与平方权重(payroll_case, amount, performance, payable, start): s, user, _ = payroll_case business = imported(payroll_case) record = payroll_calculation.latest_input(s, business.id) data = copy.deepcopy(record.data_json) data["receipts"][0]["amount"] = amount receipt = s.scalar(select(Receipt)) receipt.amount = Decimal(amount) replace_input(s, record, data) result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user) assert (result["performance"], result["payable"], result["start_payment_ok"]) == ( performance, payable, start, ) @pytest.mark.parametrize("legacy_date", [date(2026, 2, 10), date(2026, 2, 28)]) def test_历史发货不重复计入新业务起付分母(payroll_case, legacy_date): """旧应收先冲抵,启用前发货不得再次压低新业务的回款比例。""" s, user, _ = payroll_case business = imported(payroll_case) record = payroll_calculation.latest_input(s, business.id) data = copy.deepcopy(record.data_json) legacy = Shipment( business_id=business.id, shipment_no="SH-LEGACY", ship_date=legacy_date, total_amount=Decimal("100"), freight_amount=Decimal("0"), ) s.add(legacy) s.flush() original = s.get(ShipmentItem, data["maps"]["shipment_items"][data["shipments"][0]["line_id"]]) item = ShipmentItem( shipment_id=legacy.id, product_id=original.product_id, product_name=original.product_name, spec_model=original.spec_model, unit=original.unit, quantity=Decimal("2"), sale_price=Decimal("50"), amount=Decimal("100"), ) s.add(item) s.flush() data["shipments"].append( { **data["shipments"][0], "source_id": "99", "line_id": "99", "date": legacy_date.isoformat(), } ) data["maps"]["shipments"]["99"] = legacy.id data["maps"]["shipment_items"]["99"] = item.id data["group"]["opening_balance"] = "100" data["receipts"][0]["amount"] = "200" s.get(Receipt, data["maps"]["receipts"][data["receipts"][0]["source_id"]]).amount = Decimal( "200" ) replace_input(s, record, data) result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user) assert result["start_payment_ok"] is True assert result["performance"] == result["payable"] == "0.20" allocations = list(s.scalars(select(PayrollAllocation))) assert sum(row.amount for row in allocations) == Decimal("100.00") assert all(row.data_json["shipment_date"] == "2026-03-10" for row in allocations) def test_跨月续算仅计应付差额且离职未发给接手人(payroll_case): s, user, _manager = payroll_case business = imported(payroll_case) record = payroll_calculation.latest_input(s, business.id) data = copy.deepcopy(record.data_json) receipt = s.scalar(select(Receipt)) receipt.amount = Decimal("80") data["receipts"][0]["amount"] = "80" record = replace_input(s, record, data) march = payroll_calculation.calculate(s, business.id, "2026-03", operator=user) assert march["payable"] == "0.10" old = s.get(PerformanceLedger, march["ledger_ids"][0]) new_person = Personnel( personnel_name="接手业务员", business_role_id=s.get(Personnel, old.personnel_id).business_role_id, status=1, ) s.add(new_person) s.flush() second = Receipt( business_id=business.id, receipt_batch_no="HK-NEXT", receipt_date=date(2026, 4, 10), amount=Decimal("20"), is_barter=0, settlement_status=0, ) s.add(second) s.flush() data = copy.deepcopy(record.data_json) data["receipts"].append( { "source_id": "11", "source_no": "HK-NEXT", "date": "2026-04-10", "group_key": "g", "amount": "20", "voucher_confirmed": True, } ) data["maps"]["receipts"]["11"] = second.id data["maps"]["personnel"]["next-person"] = new_person.id data["facts"]["personnel"]["next-person"] = {"role_code": "salesperson", "status": 1} data["facts"]["handovers"] = [ { "mode": "RESIGNATION", "business_key": "biz", "from_personnel_id": "00043", "to_personnel_id": "next-person", "effective_date": "2026-04-01", } ] replace_input(s, record, data) april = payroll_calculation.calculate(s, business.id, "2026-04", operator=user) assert april["payable"] == "0.10" assert old.payable_amount == Decimal("0.10") and old.personnel_id != new_person.id assert all( s.get(PerformanceLedger, lid).personnel_id == new_person.id for lid in april["ledger_ids"] ) assert sum(s.scalars(select(PerformanceLedger.payable_amount))) == Decimal("0.20") rebuilt = payroll_calculation.calculate( s, business.id, "2026-03", operator=user, reason="复核分次回款" ) assert rebuilt["recalculated_months"] == ["2026-03", "2026-04"] active = list(s.scalars(select(PerformanceLedger).where(PerformanceLedger.is_active == 1))) assert sum(r.payable_amount for r in active) == Decimal("0.20") assert all(r.calc_reason == "复核分次回款" for r in active) def test_已核算源金额变化不静默改工资(payroll_case): s, user, _ = payroll_case business = imported(payroll_case) receipt = s.scalar(select(Receipt)) receipt.amount = Decimal("99") s.commit() with pytest.raises(BadRequestError, match="不一致"): payroll_calculation.calculate(s, business.id, "2026-03", operator=user) assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0 def test_同客户不同合同合并平账_一款对应多发货(payroll_case): from tests.unit.test_tplus_workbench import source s, user, _ = payroll_case source( s, "shipments", [ { "id": 20, "code": "SH-ONE", "idcustomer": 8, "voucherdate": "2026-03-10", "voucherState": 189, }, { "id": 22, "code": "SH-TWO", "idcustomer": 8, "voucherdate": "2026-03-11", "voucherState": 189, }, ], ) source( s, "shipment_items", [ { "id": 21, "source_parent_id": 20, "idinventory": 9, "taxAmount": "100", "quantity": "2", }, { "id": 23, "source_parent_id": 22, "idinventory": 9, "taxAmount": "100", "quantity": "2", }, ], ) source( s, "receipts", [ { "id": 10, "code": "RC-ONE", "idpartner": 8, "voucherDate": "2026-03-12", "isReceiveFlag": 1, "voucherstate": 189, "amount": "200", } ], ) fact( s, "SHIPMENT", "23", { "sales_amount_excludes_freight": True, "business_key": "second-contract", "personnel_id": "00043", }, ) fact( s, "MANAGER", "second-manager", {"business_key": "second-contract", "manager_id": "90001", "effective_date": "2026-03-01"}, ) business = imported(payroll_case) assert s.scalar(select(func.count()).select_from(Business)) == 1 result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user) assert len(result["ledger_ids"]) == 2 assert sum(s.scalars(select(PayrollAllocation.amount))) == Decimal("200.00") record = payroll_calculation.latest_input(s, business.id) assert record.data_json["group"]["shipment_ids"] == ["20", "22"] def test_封存发货月指导成本不随后续回款改变(payroll_case): from app.models import TPlusFact from app.schemas.tplus_workbench import FactIn from app.services.tplus import workbench s, user, _ = payroll_case cost = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GUIDANCE")) with pytest.raises(BadRequestError, match="已封存"): workbench.save_fact( s, FactIn( kind="GUIDANCE", fact_key=cost.fact_key, expected_version=cost.version, evidence="试图修改已封存月份", data={**cost.data_json, "amount": "30"}, ), user.id, ) def test_同客户旧项目期初不一致必须先合并事实(payroll_case): from app.models import TPlusFact s, user, _ = payroll_case group = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GROUP")).data_json fact(s, "GROUP", "old-contract-group", {**group, "opening_balance": "10"}) run = business_work(s, user, "BUSINESS_PREVIEW") assert run.status == "PARTIAL" assert run.summary_json["counts"] == {"PENDING": 1} assert s.scalar(select(func.count()).select_from(Business)) == 0