969 lines
32 KiB
Python
969 lines
32 KiB
Python
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
from django.contrib import admin
|
||
from django.contrib.auth.models import Permission
|
||
from django.contrib.messages.storage.fallback import FallbackStorage
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import IntegrityError, transaction
|
||
from django.test import RequestFactory
|
||
from django.urls import reverse
|
||
|
||
from finance.enums import (
|
||
ApprovalStatus,
|
||
PayrollPeriodStatus,
|
||
PayrollRunStatus,
|
||
)
|
||
from finance.admin import PayrollPeriodAdmin
|
||
from finance.models import (
|
||
EmployeePayRate,
|
||
PayrollAuditEvent,
|
||
PayrollComponent,
|
||
PayrollInputLine,
|
||
PayrollPeriod,
|
||
PayrollRun,
|
||
PayrollStatement,
|
||
PayrollStatementAcknowledgement,
|
||
PayrollWorkRecord,
|
||
)
|
||
from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied
|
||
from finance.payroll.services import (
|
||
acknowledge_statement,
|
||
approve_input_line,
|
||
approve_pay_rate,
|
||
approve_run,
|
||
approve_work_record,
|
||
calculate_period,
|
||
publish_run,
|
||
return_run_for_correction,
|
||
submit_run_for_review,
|
||
)
|
||
|
||
pytestmark = pytest.mark.django_db
|
||
|
||
|
||
def grant(user, *codenames):
|
||
permissions = Permission.objects.filter(
|
||
content_type__app_label="finance",
|
||
codename__in=codenames,
|
||
)
|
||
assert permissions.count() == len(codenames)
|
||
user.user_permissions.add(*permissions)
|
||
for cache_name in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
|
||
if hasattr(user, cache_name):
|
||
delattr(user, cache_name)
|
||
|
||
|
||
@pytest.fixture
|
||
def payroll_users(user_factory):
|
||
operator = user_factory(email="payroll.operator@example.test")
|
||
input_approver = user_factory(email="payroll.inputs@example.test")
|
||
run_approver = user_factory(email="payroll.approver@example.test")
|
||
publisher = user_factory(email="payroll.publisher@example.test")
|
||
employee = user_factory(
|
||
email="igor.nadein@example.test",
|
||
first_name="Игорь",
|
||
last_name="Надеин",
|
||
)
|
||
grant(operator, "calculate_payroll")
|
||
grant(input_approver, "approve_payroll_inputs")
|
||
grant(run_approver, "approve_payroll")
|
||
grant(publisher, "publish_payroll")
|
||
return {
|
||
"operator": operator,
|
||
"input_approver": input_approver,
|
||
"run_approver": run_approver,
|
||
"publisher": publisher,
|
||
"employee": employee,
|
||
}
|
||
|
||
|
||
@pytest.fixture
|
||
def june_period(payroll_users):
|
||
return PayrollPeriod.objects.create(
|
||
code="2026-06",
|
||
name="Июнь 2026",
|
||
date_from=date(2026, 6, 1),
|
||
date_to=date(2026, 6, 30),
|
||
pay_date=date(2026, 7, 5),
|
||
currency="RUB",
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
|
||
|
||
def create_approved_excel_inputs(period, users):
|
||
operator = users["operator"]
|
||
approver = users["input_approver"]
|
||
employee = users["employee"]
|
||
rate = EmployeePayRate.objects.create(
|
||
employee=employee,
|
||
amount=Decimal("80000"),
|
||
point_rate=Decimal("100"),
|
||
currency="RUB",
|
||
effective_from=date(2026, 1, 1),
|
||
created_by=operator,
|
||
reason="Штатная ставка",
|
||
)
|
||
approve_pay_rate(
|
||
rate.pk,
|
||
actor=approver,
|
||
expected_lock_version=rate.lock_version,
|
||
)
|
||
|
||
work_record = PayrollWorkRecord.objects.create(
|
||
period=period,
|
||
employee=employee,
|
||
target_points=Decimal("110"),
|
||
actual_points=Decimal("110"),
|
||
expected_point_amount=Decimal("0"),
|
||
expected_gross=Decimal("115000"),
|
||
expected_recalculated_gross=Decimal("115000"),
|
||
expected_payable=Decimal("115000"),
|
||
source="excel",
|
||
source_ref=f"excel:{period.code}:employee:{employee.pk}:work",
|
||
created_by=operator,
|
||
)
|
||
approve_work_record(
|
||
work_record.pk,
|
||
actor=approver,
|
||
expected_lock_version=work_record.lock_version,
|
||
)
|
||
|
||
bonus = PayrollInputLine.objects.create(
|
||
period=period,
|
||
employee=employee,
|
||
component=PayrollComponent.objects.get(code="BONUS"),
|
||
amount=Decimal("15000"),
|
||
source="excel",
|
||
source_ref=f"excel:{period.code}:employee:{employee.pk}:bonus",
|
||
created_by=operator,
|
||
)
|
||
correction = PayrollInputLine.objects.create(
|
||
period=period,
|
||
employee=employee,
|
||
component=PayrollComponent.objects.get(code="CORRECTION_CREDIT"),
|
||
amount=Decimal("20000"),
|
||
reason="Коррекция из исходной таблицы",
|
||
source="excel",
|
||
source_ref=f"excel:{period.code}:employee:{employee.pk}:correction",
|
||
created_by=operator,
|
||
)
|
||
approve_input_line(
|
||
bonus.pk,
|
||
actor=approver,
|
||
expected_lock_version=bonus.lock_version,
|
||
)
|
||
approve_input_line(
|
||
correction.pk,
|
||
actor=approver,
|
||
expected_lock_version=correction.lock_version,
|
||
)
|
||
return rate, work_record, bonus, correction
|
||
|
||
|
||
def calculate_excel_period(period, users, *, idempotency_key=None):
|
||
create_approved_excel_inputs(period, users)
|
||
return calculate_period(
|
||
period.pk,
|
||
actor=users["operator"],
|
||
idempotency_key=idempotency_key,
|
||
)
|
||
|
||
|
||
def publish_calculation(run, users):
|
||
submit_run_for_review(run.pk, actor=users["operator"])
|
||
approve_run(run.pk, actor=users["run_approver"])
|
||
return publish_run(run.pk, actor=users["publisher"])
|
||
|
||
|
||
def test_excel_row_is_persisted_as_a_deterministic_statement(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
run = calculate_excel_period(june_period, payroll_users)
|
||
|
||
run.refresh_from_db()
|
||
june_period.refresh_from_db()
|
||
statement = run.statements.get(employee=payroll_users["employee"])
|
||
assert run.revision == 1
|
||
assert run.status == PayrollRunStatus.CALCULATED
|
||
assert run.employee_count == 1
|
||
assert run.gross_total == Decimal("115000.00")
|
||
assert run.deduction_total == Decimal("0.00")
|
||
assert run.payable_total == Decimal("115000.00")
|
||
assert june_period.current_run_id == run.pk
|
||
assert june_period.status == PayrollPeriodStatus.CALCULATED
|
||
|
||
assert statement.gross_before_adjustments == Decimal("95000.00")
|
||
assert statement.adjustment_total == Decimal("20000.00")
|
||
assert statement.point_delta == Decimal("0.0000")
|
||
assert statement.gross_total == Decimal("115000.00")
|
||
assert statement.net_pay == Decimal("115000.00")
|
||
assert statement.payable == Decimal("115000.00")
|
||
assert statement.employee_snapshot["display_name"] == "Игорь Надеин"
|
||
assert statement.input_snapshot["base_source_ref"].startswith("pay-rate:")
|
||
assert statement.result_snapshot["warnings"] == []
|
||
assert list(statement.lines.values_list("code", flat=True)) == [
|
||
"BASE",
|
||
"POINT_ADJUSTMENT",
|
||
"BONUS",
|
||
"CORRECTION_CREDIT",
|
||
]
|
||
assert PayrollAuditEvent.objects.filter(
|
||
action="payroll.calculated",
|
||
object_id=str(run.pk),
|
||
).exists()
|
||
|
||
|
||
def test_calculation_uses_excel_point_basis_with_bonus(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
operator = payroll_users["operator"]
|
||
approver = payroll_users["input_approver"]
|
||
employee = payroll_users["employee"]
|
||
rate = EmployeePayRate.objects.create(
|
||
employee=employee,
|
||
amount=Decimal("30000"),
|
||
point_rate=None,
|
||
currency="RUB",
|
||
effective_from=date(2026, 1, 1),
|
||
created_by=operator,
|
||
)
|
||
work = PayrollWorkRecord.objects.create(
|
||
period=june_period,
|
||
employee=employee,
|
||
target_points=Decimal("110"),
|
||
actual_points=Decimal("90"),
|
||
expected_point_amount=Decimal("-7272.73"),
|
||
expected_gross=Decimal("40000"),
|
||
expected_recalculated_gross=Decimal("32727.27"),
|
||
expected_payable=Decimal("32727.27"),
|
||
created_by=operator,
|
||
)
|
||
bonus = PayrollInputLine.objects.create(
|
||
period=june_period,
|
||
employee=employee,
|
||
component=PayrollComponent.objects.get(code="BONUS"),
|
||
amount=Decimal("10000"),
|
||
created_by=operator,
|
||
)
|
||
for instance, approve in (
|
||
(rate, approve_pay_rate),
|
||
(work, approve_work_record),
|
||
(bonus, approve_input_line),
|
||
):
|
||
approve(
|
||
instance.pk,
|
||
actor=approver,
|
||
expected_lock_version=instance.lock_version,
|
||
)
|
||
|
||
run = calculate_period(june_period.pk, actor=operator)
|
||
|
||
statement = run.statements.get(employee=employee)
|
||
point_line = statement.lines.get(code="POINT_ADJUSTMENT")
|
||
assert statement.input_snapshot["point_base_accrual"] == "40000"
|
||
assert statement.input_snapshot["point_rate"] == "363.6364"
|
||
assert statement.input_snapshot["point_rate_overridden"] is False
|
||
assert point_line.kind == "adjustment_debit"
|
||
assert point_line.amount == Decimal("7272.73")
|
||
assert statement.gross_before_adjustments == Decimal("40000.00")
|
||
assert statement.adjustment_total == Decimal("-7272.73")
|
||
assert statement.gross_total == Decimal("32727.27")
|
||
assert statement.payable == Decimal("32727.27")
|
||
assert statement.result_snapshot["warnings"] == []
|
||
|
||
|
||
def test_full_workflow_requires_a_second_person_and_employee_can_acknowledge(
|
||
june_period,
|
||
payroll_users,
|
||
user_factory,
|
||
):
|
||
run = calculate_excel_period(june_period, payroll_users)
|
||
submit_run_for_review(run.pk, actor=payroll_users["operator"])
|
||
|
||
grant(payroll_users["operator"], "approve_payroll")
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
approve_run(run.pk, actor=payroll_users["operator"])
|
||
assert error.value.code == "SELF_APPROVAL_FORBIDDEN"
|
||
|
||
approve_run(run.pk, actor=payroll_users["run_approver"])
|
||
publish_run(run.pk, actor=payroll_users["publisher"])
|
||
run.refresh_from_db()
|
||
june_period.refresh_from_db()
|
||
assert run.status == PayrollRunStatus.PUBLISHED
|
||
assert june_period.status == PayrollPeriodStatus.PUBLISHED
|
||
|
||
statement = run.statements.get()
|
||
stranger = user_factory(email="not.the.employee@example.test")
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
acknowledge_statement(statement.pk, actor=stranger)
|
||
assert error.value.code == "STATEMENT_NOT_FOUND"
|
||
|
||
key = uuid.uuid4()
|
||
acknowledgement = acknowledge_statement(
|
||
statement.pk,
|
||
actor=payroll_users["employee"],
|
||
idempotency_key=key,
|
||
)
|
||
duplicate = acknowledge_statement(
|
||
statement.pk,
|
||
actor=payroll_users["employee"],
|
||
idempotency_key=key,
|
||
)
|
||
assert duplicate.pk == acknowledgement.pk
|
||
assert acknowledgement.content_hash == statement.result_hash
|
||
assert acknowledgement.acknowledged_at is not None
|
||
assert PayrollStatementAcknowledgement.objects.count() == 1
|
||
|
||
|
||
def test_override_permission_never_replaces_operational_approval_permissions(
|
||
user_factory,
|
||
):
|
||
override_only = user_factory(email="override.without.approval@example.test")
|
||
employee = user_factory(email="override.target@example.test")
|
||
grant(override_only, "override_payroll_approval")
|
||
rate = EmployeePayRate.objects.create(
|
||
employee=employee,
|
||
amount=Decimal("80000"),
|
||
effective_from=date(2026, 1, 1),
|
||
created_by=override_only,
|
||
)
|
||
|
||
with pytest.raises(PayrollPermissionDenied) as input_error:
|
||
approve_pay_rate(
|
||
rate.pk,
|
||
actor=override_only,
|
||
expected_lock_version=rate.lock_version,
|
||
)
|
||
assert input_error.value.details["permission"] == ("finance.approve_payroll_inputs")
|
||
|
||
with pytest.raises(PayrollPermissionDenied) as run_error:
|
||
approve_run(999_999, actor=override_only)
|
||
assert run_error.value.details["permission"] == "finance.approve_payroll"
|
||
|
||
|
||
def test_database_rejects_override_flag_without_an_actual_self_approval(
|
||
payroll_users,
|
||
):
|
||
rate = EmployeePayRate.objects.create(
|
||
employee=payroll_users["employee"],
|
||
amount=Decimal("80000"),
|
||
effective_from=date(2026, 1, 1),
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
|
||
with pytest.raises(IntegrityError):
|
||
with transaction.atomic():
|
||
EmployeePayRate.objects.filter(pk=rate.pk).update(
|
||
self_approval_overridden=True
|
||
)
|
||
|
||
|
||
def test_recalculation_creates_a_revision_without_mutating_published_result(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
first_run = calculate_excel_period(june_period, payroll_users)
|
||
publish_calculation(first_run, payroll_users)
|
||
first_statement = first_run.statements.get()
|
||
first_hash = first_statement.result_hash
|
||
|
||
debit = PayrollInputLine.objects.create(
|
||
period=june_period,
|
||
employee=payroll_users["employee"],
|
||
component=PayrollComponent.objects.get(code="CORRECTION_DEBIT"),
|
||
amount=Decimal("5000"),
|
||
reason="Исправление после публикации",
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
approve_input_line(
|
||
debit.pk,
|
||
actor=payroll_users["input_approver"],
|
||
expected_lock_version=debit.lock_version,
|
||
)
|
||
old_work_record = PayrollWorkRecord.objects.get(
|
||
period=june_period,
|
||
status=ApprovalStatus.APPROVED,
|
||
)
|
||
replacement_work_record = PayrollWorkRecord.objects.create(
|
||
period=june_period,
|
||
employee=payroll_users["employee"],
|
||
target_points=old_work_record.target_points,
|
||
actual_points=old_work_record.actual_points,
|
||
expected_point_amount=Decimal("0"),
|
||
expected_gross=Decimal("110000"),
|
||
expected_recalculated_gross=Decimal("110000"),
|
||
expected_payable=Decimal("110000"),
|
||
revision=2,
|
||
replaces=old_work_record,
|
||
reason="Контрольные итоги после утверждённой корректировки",
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
approve_work_record(
|
||
replacement_work_record.pk,
|
||
actor=payroll_users["input_approver"],
|
||
expected_lock_version=replacement_work_record.lock_version,
|
||
)
|
||
|
||
second_run = calculate_period(
|
||
june_period.pk,
|
||
actor=payroll_users["operator"],
|
||
recalculation_reason="Исправление утверждённой корректировки",
|
||
)
|
||
second_statement = second_run.statements.get()
|
||
first_run.refresh_from_db()
|
||
first_statement.refresh_from_db()
|
||
assert second_run.revision == 2
|
||
assert second_run.supersedes_id == first_run.pk
|
||
assert second_statement.supersedes_id == first_statement.pk
|
||
assert second_statement.payable == Decimal("110000.00")
|
||
assert first_run.status == PayrollRunStatus.PUBLISHED
|
||
assert first_statement.result_hash == first_hash
|
||
assert first_statement.payable == Decimal("115000.00")
|
||
|
||
publish_calculation(second_run, payroll_users)
|
||
first_run.refresh_from_db()
|
||
second_run.refresh_from_db()
|
||
assert first_run.status == PayrollRunStatus.SUPERSEDED
|
||
assert second_run.status == PayrollRunStatus.PUBLISHED
|
||
|
||
|
||
def test_calculation_idempotency_key_returns_the_same_run(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
key = uuid.uuid4()
|
||
first = calculate_excel_period(
|
||
june_period,
|
||
payroll_users,
|
||
idempotency_key=key,
|
||
)
|
||
second = calculate_period(
|
||
june_period.pk,
|
||
actor=payroll_users["operator"],
|
||
idempotency_key=key,
|
||
)
|
||
|
||
assert first.pk == second.pk
|
||
assert PayrollRun.objects.count() == 1
|
||
assert PayrollStatement.objects.count() == 1
|
||
|
||
|
||
def test_unapproved_work_record_is_not_used_and_run_is_not_partially_created(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
rate = EmployeePayRate.objects.create(
|
||
employee=payroll_users["employee"],
|
||
amount=Decimal("80000"),
|
||
effective_from=date(2026, 1, 1),
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
approve_pay_rate(
|
||
rate.pk,
|
||
actor=payroll_users["input_approver"],
|
||
expected_lock_version=rate.lock_version,
|
||
)
|
||
PayrollWorkRecord.objects.create(
|
||
period=june_period,
|
||
employee=payroll_users["employee"],
|
||
target_points=Decimal("110"),
|
||
actual_points=Decimal("110"),
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
calculate_period(june_period.pk, actor=payroll_users["operator"])
|
||
|
||
assert error.value.code == "NO_APPROVED_WORK_RECORDS"
|
||
assert PayrollRun.objects.count() == 0
|
||
june_period.refresh_from_db()
|
||
assert june_period.status == PayrollPeriodStatus.OPEN
|
||
|
||
|
||
def test_ruleset_not_effective_keeps_domain_code_and_localized_message(
|
||
settings,
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
create_approved_excel_inputs(june_period, payroll_users)
|
||
settings.FINANCE_PAYROLL = {"EFFECTIVE_FROM": "2026-07-01"}
|
||
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
calculate_period(june_period.pk, actor=payroll_users["operator"])
|
||
|
||
assert error.value.code == "RULESET_NOT_EFFECTIVE"
|
||
assert error.value.message == (
|
||
"Для выбранного периода нет действующих правил расчёта. "
|
||
"Набор eusrr-standard, версия 2026.07.4, применяется с 01.07.2026. "
|
||
"Измените период или подключите историческую версию правил."
|
||
)
|
||
assert error.value.details["employee_id"] == payroll_users["employee"].pk
|
||
assert error.value.details["period"] == {
|
||
"date_from": "2026-06-01",
|
||
"date_to": "2026-06-30",
|
||
}
|
||
assert error.value.details["ruleset"] == {
|
||
"id": "eusrr-standard",
|
||
"version": "2026.07.4",
|
||
"effective_from": "2026-07-01",
|
||
}
|
||
assert {issue["code"] for issue in error.value.details["issues"]} == {
|
||
"RULESET_NOT_EFFECTIVE"
|
||
}
|
||
assert PayrollRun.objects.count() == 0
|
||
|
||
|
||
def test_mid_period_rate_change_fails_closed_until_policy_is_defined(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
create_approved_excel_inputs(june_period, payroll_users)
|
||
second_rate = EmployeePayRate.objects.create(
|
||
employee=payroll_users["employee"],
|
||
amount=Decimal("90000"),
|
||
effective_from=date(2026, 6, 15),
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
approve_pay_rate(
|
||
second_rate.pk,
|
||
actor=payroll_users["input_approver"],
|
||
expected_lock_version=second_rate.lock_version,
|
||
)
|
||
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
calculate_period(june_period.pk, actor=payroll_users["operator"])
|
||
|
||
assert error.value.code == "MID_PERIOD_RATE_CHANGE_UNSUPPORTED"
|
||
assert PayrollRun.objects.count() == 0
|
||
|
||
|
||
def test_staff_admin_can_calculate_without_dedicated_finance_permissions(
|
||
june_period,
|
||
payroll_users,
|
||
user_factory,
|
||
):
|
||
create_approved_excel_inputs(june_period, payroll_users)
|
||
unrelated_staff = user_factory(
|
||
email="staff.without.finance@example.test",
|
||
staff=True,
|
||
)
|
||
|
||
run = calculate_period(june_period.pk, actor=unrelated_staff)
|
||
|
||
assert run.requested_by == unrelated_staff
|
||
assert run.status == PayrollRunStatus.CALCULATED
|
||
assert PayrollRun.objects.count() == 1
|
||
|
||
|
||
def test_staff_admin_can_approve_own_run_without_override_role(
|
||
june_period,
|
||
payroll_users,
|
||
user_factory,
|
||
):
|
||
create_approved_excel_inputs(june_period, payroll_users)
|
||
administrator = user_factory(
|
||
email="staff.direct.approval@example.test",
|
||
staff=True,
|
||
)
|
||
run = calculate_period(june_period.pk, actor=administrator)
|
||
submit_run_for_review(run.pk, actor=administrator)
|
||
|
||
approved = approve_run(run.pk, actor=administrator)
|
||
|
||
assert approved.status == PayrollRunStatus.APPROVED
|
||
assert approved.approved_by == administrator
|
||
assert approved.self_approval_overridden is True
|
||
event = PayrollAuditEvent.objects.get(
|
||
action="payroll.approved",
|
||
object_id=str(run.pk),
|
||
)
|
||
assert "self_approval_overridden" not in event.metadata
|
||
assert "approval_mode" not in event.metadata
|
||
|
||
|
||
def test_every_new_revision_requires_a_reason(june_period, payroll_users):
|
||
first_run = calculate_excel_period(june_period, payroll_users)
|
||
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
calculate_period(june_period.pk, actor=payroll_users["operator"])
|
||
|
||
assert error.value.code == "RECALCULATION_REASON_REQUIRED"
|
||
assert PayrollRun.objects.get() == first_run
|
||
|
||
|
||
def test_reviewer_can_return_run_and_operator_creates_new_revision(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
first_run = calculate_excel_period(june_period, payroll_users)
|
||
submit_run_for_review(first_run.pk, actor=payroll_users["operator"])
|
||
|
||
returned = return_run_for_correction(
|
||
first_run.pk,
|
||
actor=payroll_users["run_approver"],
|
||
reason="Неверно указана корректировка",
|
||
)
|
||
returned.refresh_from_db()
|
||
june_period.refresh_from_db()
|
||
assert returned.status == PayrollRunStatus.RETURNED
|
||
assert june_period.status == PayrollPeriodStatus.CALCULATED
|
||
|
||
second_run = calculate_period(
|
||
june_period.pk,
|
||
actor=payroll_users["operator"],
|
||
recalculation_reason="Исправлена корректировка",
|
||
)
|
||
returned.refresh_from_db()
|
||
assert second_run.revision == 2
|
||
assert returned.status == PayrollRunStatus.SUPERSEDED
|
||
|
||
|
||
def test_excel_mismatch_blocks_review(june_period, payroll_users):
|
||
create_approved_excel_inputs(june_period, payroll_users)
|
||
PayrollWorkRecord.objects.filter(period=june_period).update(
|
||
expected_gross=Decimal("1")
|
||
)
|
||
run = calculate_period(june_period.pk, actor=payroll_users["operator"])
|
||
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
submit_run_for_review(run.pk, actor=payroll_users["operator"])
|
||
|
||
assert error.value.code == "RECONCILIATION_MISMATCH"
|
||
run.refresh_from_db()
|
||
assert run.status == PayrollRunStatus.CALCULATED
|
||
|
||
|
||
def test_approved_input_outside_roster_blocks_calculation(
|
||
june_period,
|
||
payroll_users,
|
||
user_factory,
|
||
):
|
||
create_approved_excel_inputs(june_period, payroll_users)
|
||
outsider = user_factory(email="outside.roster@example.test")
|
||
outside_bonus = PayrollInputLine.objects.create(
|
||
period=june_period,
|
||
employee=outsider,
|
||
component=PayrollComponent.objects.get(code="BONUS"),
|
||
amount=Decimal("100"),
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
approve_input_line(
|
||
outside_bonus.pk,
|
||
actor=payroll_users["input_approver"],
|
||
expected_lock_version=outside_bonus.lock_version,
|
||
)
|
||
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
calculate_period(june_period.pk, actor=payroll_users["operator"])
|
||
|
||
assert error.value.code == "INPUT_EMPLOYEE_NOT_IN_ROSTER"
|
||
assert PayrollRun.objects.count() == 0
|
||
|
||
|
||
def test_employee_api_only_returns_own_published_statement_and_no_stores_it(
|
||
june_period,
|
||
payroll_users,
|
||
user_factory,
|
||
auth_client_factory,
|
||
extract_results,
|
||
):
|
||
run = calculate_excel_period(june_period, payroll_users)
|
||
publish_calculation(run, payroll_users)
|
||
statement = run.statements.get()
|
||
employee_client = auth_client_factory(payroll_users["employee"])
|
||
stranger = user_factory(email="api.stranger@example.test")
|
||
stranger_client = auth_client_factory(stranger)
|
||
|
||
list_url = reverse("api:v1:finance-payroll:own-statement-list")
|
||
response = employee_client.get(list_url)
|
||
assert response.status_code == 200
|
||
items = extract_results(response.json())
|
||
assert len(items) == 1
|
||
assert items[0]["public_id"] == str(statement.public_id)
|
||
assert items[0]["payable"] == "115000.00"
|
||
assert "lines" not in items[0]
|
||
assert "gross_total" not in items[0]
|
||
assert "employee" not in items[0]
|
||
assert "input_snapshot" not in items[0]
|
||
assert "no-store" in response["Cache-Control"]
|
||
assert PayrollAuditEvent.objects.filter(
|
||
action="payroll.statement_summary_viewed",
|
||
actor=payroll_users["employee"],
|
||
).exists()
|
||
|
||
detail_url = reverse(
|
||
"api:v1:finance-payroll:own-statement-detail",
|
||
kwargs={"public_id": statement.public_id},
|
||
)
|
||
assert stranger_client.get(detail_url).status_code == 404
|
||
detail_response = employee_client.get(detail_url)
|
||
assert detail_response.status_code == 200
|
||
assert len(detail_response.json()["lines"]) == 4
|
||
assert PayrollAuditEvent.objects.filter(
|
||
action="payroll.statement_viewed",
|
||
actor=payroll_users["employee"],
|
||
).exists()
|
||
|
||
acknowledgement_url = reverse(
|
||
"api:v1:finance-payroll:own-statement-acknowledge",
|
||
kwargs={"public_id": statement.public_id},
|
||
)
|
||
ack_response = employee_client.post(acknowledgement_url, {}, format="json")
|
||
assert ack_response.status_code == 200
|
||
assert ack_response.json()["acknowledged_at"] is not None
|
||
assert (
|
||
stranger_client.post(
|
||
acknowledgement_url,
|
||
{},
|
||
format="json",
|
||
).status_code
|
||
== 404
|
||
)
|
||
|
||
|
||
def test_new_approval_requires_return_and_recalculation(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
run = calculate_excel_period(june_period, payroll_users)
|
||
extra_bonus = PayrollInputLine.objects.create(
|
||
period=june_period,
|
||
employee=payroll_users["employee"],
|
||
component=PayrollComponent.objects.get(code="BONUS"),
|
||
amount=Decimal("100"),
|
||
source_ref="manual:late-bonus",
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
approve_input_line(
|
||
extra_bonus.pk,
|
||
actor=payroll_users["input_approver"],
|
||
expected_lock_version=extra_bonus.lock_version,
|
||
)
|
||
assert error.value.code == "PERIOD_INPUTS_LOCKED"
|
||
|
||
return_run_for_correction(
|
||
run.pk,
|
||
actor=payroll_users["operator"],
|
||
reason="Поздно утверждённая премия",
|
||
)
|
||
approve_input_line(
|
||
extra_bonus.pk,
|
||
actor=payroll_users["input_approver"],
|
||
expected_lock_version=extra_bonus.lock_version,
|
||
)
|
||
revised = calculate_period(
|
||
june_period.pk,
|
||
actor=payroll_users["operator"],
|
||
recalculation_reason="Добавлена поздно утверждённая премия",
|
||
)
|
||
|
||
assert revised.revision == 2
|
||
assert revised.payable_total == Decimal("115100.00")
|
||
|
||
|
||
def test_freshness_gate_detects_source_changes_before_review(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
run = calculate_excel_period(june_period, payroll_users)
|
||
PayrollWorkRecord.objects.filter(
|
||
period=june_period,
|
||
employee=payroll_users["employee"],
|
||
status=ApprovalStatus.APPROVED,
|
||
).update(actual_points=Decimal("111"))
|
||
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
submit_run_for_review(run.pk, actor=payroll_users["operator"])
|
||
|
||
assert error.value.code == "RUN_STALE_RECALCULATION_REQUIRED"
|
||
|
||
|
||
def test_rate_approval_is_blocked_while_employee_run_is_active(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
run = calculate_excel_period(june_period, payroll_users)
|
||
original_rate = EmployeePayRate.objects.get(status=ApprovalStatus.APPROVED)
|
||
replacement = EmployeePayRate.objects.create(
|
||
employee=payroll_users["employee"],
|
||
rate_code=original_rate.rate_code,
|
||
amount=Decimal("81000"),
|
||
point_rate=original_rate.point_rate,
|
||
currency=original_rate.currency,
|
||
effective_from=original_rate.effective_from,
|
||
revision=2,
|
||
replaces=original_rate,
|
||
reason="Изменение ставки",
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
approve_pay_rate(
|
||
replacement.pk,
|
||
actor=payroll_users["input_approver"],
|
||
expected_lock_version=replacement.lock_version,
|
||
)
|
||
|
||
assert run.status == PayrollRunStatus.CALCULATED
|
||
assert error.value.code == "RATE_USED_BY_ACTIVE_RUN"
|
||
|
||
|
||
def test_employee_visible_period_fields_freeze_after_calculation(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
calculate_excel_period(june_period, payroll_users)
|
||
june_period.pay_date = date(2026, 7, 6)
|
||
|
||
with pytest.raises(ValidationError):
|
||
june_period.save()
|
||
|
||
|
||
def test_stale_period_form_cannot_overwrite_calculation_state(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
stale_period = PayrollPeriod.objects.get(pk=june_period.pk)
|
||
run = calculate_excel_period(june_period, payroll_users)
|
||
|
||
stale_period.save()
|
||
stale_period.refresh_from_db()
|
||
|
||
assert stale_period.status == PayrollPeriodStatus.CALCULATED
|
||
assert stale_period.current_run_id == run.pk
|
||
assert stale_period.lock_version == 1
|
||
|
||
|
||
def test_checker_approves_only_the_version_they_reviewed(
|
||
payroll_users,
|
||
):
|
||
rate = EmployeePayRate.objects.create(
|
||
employee=payroll_users["employee"],
|
||
amount=Decimal("80000"),
|
||
point_rate=Decimal("100"),
|
||
currency="RUB",
|
||
effective_from=date(2026, 1, 1),
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
reviewed_version = rate.lock_version
|
||
rate.amount = Decimal("800000")
|
||
rate.save()
|
||
assert rate.lock_version == reviewed_version + 1
|
||
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
approve_pay_rate(
|
||
rate.pk,
|
||
actor=payroll_users["input_approver"],
|
||
expected_lock_version=reviewed_version,
|
||
)
|
||
|
||
assert error.value.code == "APPROVAL_VERSION_CONFLICT"
|
||
rate.refresh_from_db()
|
||
assert rate.status == ApprovalStatus.DRAFT
|
||
|
||
|
||
def test_stale_draft_save_is_rejected(payroll_users):
|
||
rate = EmployeePayRate.objects.create(
|
||
employee=payroll_users["employee"],
|
||
amount=Decimal("80000"),
|
||
effective_from=date(2026, 1, 1),
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
stale = EmployeePayRate.objects.get(pk=rate.pk)
|
||
rate.amount = Decimal("81000")
|
||
rate.save()
|
||
stale.amount = Decimal("82000")
|
||
|
||
with pytest.raises(ValidationError):
|
||
stale.save()
|
||
|
||
rate.refresh_from_db()
|
||
assert rate.amount == Decimal("81000.0000")
|
||
assert rate.lock_version == 1
|
||
|
||
|
||
def test_component_semantics_freeze_after_creation(payroll_users):
|
||
component = PayrollComponent.objects.get(code="BONUS")
|
||
component.kind = "deduction"
|
||
|
||
with pytest.raises(ValidationError):
|
||
component.save()
|
||
|
||
component.refresh_from_db()
|
||
component.is_active = False
|
||
component.save()
|
||
assert component.is_active is False
|
||
|
||
|
||
def test_inactive_component_cannot_be_approved(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
component = PayrollComponent.objects.get(code="BONUS")
|
||
component.is_active = False
|
||
component.save()
|
||
line = PayrollInputLine.objects.create(
|
||
period=june_period,
|
||
employee=payroll_users["employee"],
|
||
component=component,
|
||
amount=Decimal("100"),
|
||
created_by=payroll_users["operator"],
|
||
)
|
||
|
||
with pytest.raises(PayrollOperationError) as error:
|
||
approve_input_line(
|
||
line.pk,
|
||
actor=payroll_users["input_approver"],
|
||
expected_lock_version=line.lock_version,
|
||
)
|
||
|
||
assert error.value.code == "PAYROLL_COMPONENT_INACTIVE"
|
||
|
||
|
||
def test_admin_recalculation_collects_and_persists_reason(
|
||
june_period,
|
||
payroll_users,
|
||
):
|
||
calculate_excel_period(june_period, payroll_users)
|
||
model_admin = PayrollPeriodAdmin(PayrollPeriod, admin.site)
|
||
queryset = PayrollPeriod.objects.filter(pk=june_period.pk)
|
||
|
||
preview_request = RequestFactory().post(
|
||
"/admin/finance/payrollperiod/",
|
||
{
|
||
"action": "calculate_selected",
|
||
"_selected_action": [june_period.pk],
|
||
},
|
||
)
|
||
preview_request.user = payroll_users["operator"]
|
||
preview = model_admin.calculate_selected(preview_request, queryset)
|
||
assert preview.context_data["reason_required"] is True
|
||
|
||
reason = "Повторная сверка с исходной таблицей"
|
||
confirm_request = RequestFactory().post(
|
||
"/admin/finance/payrollperiod/",
|
||
{
|
||
"action": "calculate_selected",
|
||
"_selected_action": [june_period.pk],
|
||
"confirm_payroll_action": "1",
|
||
"reason": reason,
|
||
},
|
||
)
|
||
confirm_request.user = payroll_users["operator"]
|
||
confirm_request.session = {}
|
||
confirm_request._messages = FallbackStorage(confirm_request)
|
||
|
||
model_admin.calculate_selected(confirm_request, queryset)
|
||
|
||
revised = PayrollRun.objects.get(period=june_period, revision=2)
|
||
assert revised.recalculation_reason == reason
|