PayrollManagementSystem/backend/tests/unit/test_formal_payroll.py
liuwenxin 108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
2026-11-02 09:42:53 +08:00

466 lines
15 KiB
Python

"""正式链路使用独立内存库,验证金额、防重、生成锁与历史归属。"""
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,
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,
)
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