补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
316 lines
11 KiB
Python
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"]
|