PayrollManagementSystem/backend/tests/integration/test_formal_payroll.py

324 lines
11 KiB
Python
Raw Normal View History

"""真实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"])
marketing = make_user(["marketing_ops"])
for headers in (normal["headers"], auth_headers(marketing)):
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=normal["headers"], json={}).status_code == 403
assert client.post("/api/v1/business/businesses", headers=auth_headers(other_admin), json={}).status_code == 400
assert (
client.get(
f"{BASE}/businesses/{business['id']}/input", headers=normal["headers"]
).status_code
== 403
)
assert client.get(f"{BASE}/businesses/{business['id']}/input", headers=auth_headers(other_admin)).status_code == 200
response = client.post(
f"{BASE}/businesses/{business['id']}/calculate",
headers=auth_headers(other_admin),
json={"month": "2026-03"},
)
assert response.status_code == 200, response.text
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"]