"""真实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"]