Corp/backend/tests/finance/test_payroll_services.py

969 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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