PayrollManagementSystem/backend/tests/integration/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

316 lines
11 KiB
Python

"""真实MySQL验证正式核算、工资生成锁、身份权限及冻结报表。"""
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal
from uuid import uuid4
import pytest
from sqlalchemy import func, select
from app.core.db import get_session_factory
from app.models import (
AdjustmentOrder,
ApprovalOrder,
ApprovalStep,
BusinessRole,
PayrollMonth,
PerformanceLedger,
Personnel,
)
from tests.integration.factories import business_payload, customer
pytestmark = pytest.mark.integration
BASE = "/api/v1/payroll"
def post(client, path, headers, data):
response = client.post(path, headers=headers, json=data)
assert response.status_code == 200, response.text
return response.json()["data"]
@pytest.fixture
def formal_chain(client, admin):
headers = admin["headers"]
with get_session_factory()() as s:
roles = {r.role_code: r.id for r in s.scalars(select(BusinessRole))}
persons = []
for code in ("sales_manager", "salesperson"):
persons.append(
post(
client,
"/api/v1/base/personnel",
headers,
{
"personnel_name": "t_正式工资_" + uuid4().hex[:8],
"business_role_id": roles[code],
"salary_standard": "0",
},
)
)
manager, owner = persons
cust = customer(client, headers)
item = {
"product_name": "t_正式产品",
"spec_model": "",
"product_category": "municipal_water",
"quantity": "2",
"sale_price": "50",
"guided_price": "25",
}
business = post(
client,
"/api/v1/business/businesses",
headers,
business_payload(cust["id"], owner_personnel_id=owner["id"], items=[item]),
)
shipment = post(
client,
"/api/v1/business/shipments",
headers,
{
"business_id": business["id"],
"ship_date": "2026-03-10",
"freight_amount": "0",
"items": [
{
"product_name": item["product_name"],
"spec_model": "",
"quantity": "2",
"sale_price": "50",
}
],
},
)
detail = client.get(f"/api/v1/business/shipments/{shipment['id']}", headers=headers).json()[
"data"
]
receipt = post(
client,
"/api/v1/business/receipts",
headers,
{"business_id": business["id"], "receipt_date": "2026-03-10", "amount": "100"},
)
setup = {
"month": "2026-03",
"expected_version": 0,
"evidence": "隔离样本已逐笔核实",
"opening_balance": "0",
"history_confirmed": True,
"guidance_finalized": True,
"shipment_ownerships": [
{
"shipment_item_id": detail["items"][0]["id"],
"personnel_id": owner["id"],
"manager_id": manager["id"],
"unit_cost": "25",
}
],
}
post(client, f"{BASE}/businesses/{business['id']}/input", headers, setup)
return business, receipt, setup, owner, manager
def test_正式核算生成工资锁定及报表一致(client, admin, formal_chain):
business, _, setup, owner, manager = formal_chain
headers = admin["headers"]
result = post(
client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"}
)
assert result["performance"] == "0.20" and result["payable"] == "0.20"
generated = post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"})
assert generated["status"] == 3 and not generated["approval_required"]
with get_session_factory()() as s:
assert s.scalar(select(func.count()).select_from(ApprovalOrder)) == 0
assert s.scalar(select(func.count()).select_from(ApprovalStep)) == 0
assert Decimal(generated["total_payable"]) == Decimal("0.24")
assert client.get(BASE + "/months/2026-03", headers=headers).json()["data"]["allocation_locked"]
setup["expected_version"] = 1
assert (
client.post(
f"{BASE}/businesses/{business['id']}/input", headers=headers, json=setup
).status_code
== 400
)
assert (
client.post(
f"{BASE}/businesses/{business['id']}/calculate",
headers=headers,
json={"month": "2026-03", "reason": "不允许绕过生成锁"},
).status_code
== 400
)
with get_session_factory()() as s:
s.get(Personnel, owner["id"]).manager_id = None
s.commit()
report = client.get(
"/api/v1/reports/department-summary",
headers=headers,
params={"settlement_month": "2026-03"},
)
assert report.status_code == 200, report.text
data = report.json()["data"]
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
assert Decimal(team["team_base"]) == Decimal("0.20")
assert Decimal(data["totals"]["payable_total"]) == Decimal(generated["total_payable"])
def test_非制作人包括其他管理员均不能写(
client, admin, normal, make_user, auth_headers, formal_chain
):
business, _, setup, _, _ = formal_chain
other_admin = make_user(["admin"])
for headers in (normal["headers"], auth_headers(other_admin)):
assert (
client.post(
f"{BASE}/businesses/{business['id']}/calculate",
headers=headers,
json={"month": "2026-03"},
).status_code
== 403
)
assert (
client.post(
f"{BASE}/businesses/{business['id']}/input", headers=headers, json=setup
).status_code
== 403
)
assert (
client.post("/api/v1/business/businesses", headers=headers, json={}).status_code == 403
)
assert (
client.get(
f"{BASE}/businesses/{business['id']}/input", headers=normal["headers"]
).status_code
== 200
)
def test_并发核算仅一份正式结果(client, admin, formal_chain):
business, _, _, _, _ = formal_chain
def call():
return client.post(
f"{BASE}/businesses/{business['id']}/calculate",
headers=admin["headers"],
json={"month": "2026-03"},
).status_code
with ThreadPoolExecutor(max_workers=2) as pool:
statuses = list(pool.map(lambda _: call(), range(2)))
assert sorted(statuses) == [200, 400]
with get_session_factory()() as s:
rows = list(
s.scalars(
select(PerformanceLedger).where(
PerformanceLedger.business_id == business["id"],
PerformanceLedger.is_active == 1,
)
)
)
assert len(rows) == 1
def test_生成失败不锁定且重新生成不解锁(client, admin, formal_chain):
business, _, _, _, _ = formal_chain
headers = admin["headers"]
assert (
client.post(
"/api/v1/settlements/generate", headers=headers, json={"month": "2026-03"}
).status_code
== 400
)
with get_session_factory()() as s:
control = s.scalar(select(PayrollMonth))
assert control is None or control.generated_at is None
post(client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"})
post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"})
assert (
client.post(
"/api/v1/settlements/generate", headers=headers, json={"month": "2026-03"}
).status_code
== 400
)
assert client.get(BASE + "/months/2026-03", headers=headers).json()["data"]["allocation_locked"]
def test_保留可选审核_默认关闭_开启后原节点可用(
client, admin, formal_chain, make_user, auth_headers, monkeypatch
):
from app.core.config import get_settings
monkeypatch.setattr(get_settings(), "payroll_approval_enabled", True)
business, _, _, _, _ = formal_chain
headers = admin["headers"]
post(client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"})
generated = post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"})
assert generated["status"] == 1 and generated["approval_required"]
post(client, f"/api/v1/settlements/{generated['id']}/submit", headers, {})
with get_session_factory()() as s:
order = s.scalar(
select(ApprovalOrder).where(
ApprovalOrder.ref_id == generated["id"], ApprovalOrder.order_type == 2
)
)
order_id = order.id
finance = make_user(["finance"])
leader = make_user(["leader"])
first = post(
client, f"/api/v1/system/approvals/{order_id}/action", auth_headers(finance), {"action": 1}
)
assert first["status"] == 2
last = post(
client, f"/api/v1/system/approvals/{order_id}/action", auth_headers(leader), {"action": 1}
)
assert last["status"] == 3
def test_分配调整固定当月且关闭审核不留审批痕迹(client, admin, formal_chain):
from app.services.payroll_months import current_month
business, _, _, owner, manager = formal_chain
headers = admin["headers"]
result = post(
client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"}
)
post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"})
adjustment = post(
client,
BASE + "/allocation-adjustments",
headers,
{
"ledger_id": result["ledger_ids"][0],
"personnel_id": owner["id"],
"direction": 1,
"amount": "0.01",
"reason": "逐笔核对差额",
},
)
with get_session_factory()() as s:
order = s.get(AdjustmentOrder, adjustment["id"])
assert order.status == 2 and order.settlement_month == current_month()
assert order.attribution_json["manager_id"] == manager["id"]
assert s.scalar(select(func.count()).select_from(ApprovalOrder)) == 0
assert s.scalar(select(func.count()).select_from(ApprovalStep)) == 0
def test_无正式资料时默认拒绝旧核算(client, admin, formal_chain):
_, _receipt, _, _, _ = formal_chain
# 使用没有正式准备的新业务,验证旧批次入口默认关闭,而不是依赖角色拒绝。
business = post(
client,
"/api/v1/business/businesses",
admin["headers"],
business_payload(customer(client, admin["headers"])["id"]),
)
empty_receipt = post(
client,
"/api/v1/business/receipts",
admin["headers"],
{"business_id": business["id"], "receipt_date": "2026-03-10", "amount": "100"},
)
response = client.post(
f"/api/v1/calc/receipts/{empty_receipt['id']}/calc", headers=admin["headers"]
)
assert response.status_code == 400 and "正式核算" in response.json()["message"]