from datetime import date from decimal import Decimal import pytest from django.contrib.auth.models import Permission from django.db import connection from django.test.utils import CaptureQueriesContext from django.urls import reverse from django.utils import timezone from finance.enums import ( ApprovalStatus, PayrollCarryoverStatus, PayrollPeriodStatus, ) from finance.models import ( FinanceSettings, PayrollComponent, PayrollInputLine, PayrollPeriod, ) from finance.payroll.balances import opening_balances from finance.payroll.carryovers import ( cancel_carryover, create_carryover, materialize_pending_carryovers, ) from finance.payroll.exceptions import PayrollOperationError from finance.payroll.services import close_period 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) def api_url(name, **kwargs): return reverse(f"api:v1:finance-payroll:{name}", kwargs=kwargs or None) def make_period(*, creator, month, status=PayrollPeriodStatus.OPEN): starts = {6: date(2026, 6, 1), 7: date(2026, 7, 1), 8: date(2026, 8, 1)} ends = {6: date(2026, 6, 30), 7: date(2026, 7, 31), 8: date(2026, 8, 31)} return PayrollPeriod.objects.create( code=f"2026-{month:02d}", name=f"Месяц {month}", date_from=starts[month], date_to=ends[month], currency="RUB", status=status, created_by=creator, ) def test_pending_carryover_materializes_as_next_period_draft(user_factory): manager = user_factory(email="carryover.manager@example.test") employee = user_factory(email="carryover.employee@example.test") source = make_period( creator=manager, month=6, status=PayrollPeriodStatus.CLOSED, ) carryover = create_carryover( source_period=source, employee=employee, direction="company_owes", amount=Decimal("5000.00"), reason="Недоплата за июнь", actor=manager, ) assert carryover.status == PayrollCarryoverStatus.PENDING assert carryover.target_period_id is None target = make_period(creator=manager, month=7) transferred = materialize_pending_carryovers(target) assert [item.pk for item in transferred] == [carryover.pk] carryover.refresh_from_db() assert carryover.status == PayrollCarryoverStatus.TRANSFERRED assert carryover.target_period == target line = carryover.generated_input_line assert line.period == target assert line.employee == employee assert line.component.code == "CORRECTION_CREDIT" assert line.amount == Decimal("5000.00") assert line.relates_to_period == source assert line.status == ApprovalStatus.DRAFT assert line.source == "api" assert line.source_ref == f"payroll-carryover:{carryover.pk}" assert "Недоплата за июнь" in line.reason assert materialize_pending_carryovers(target) == [] assert PayrollInputLine.objects.filter(source_ref=line.source_ref).count() == 1 def test_employee_debt_uses_deduction_and_cancel_voids_draft(user_factory): manager = user_factory(email="carryover.debt.manager@example.test") employee = user_factory(email="carryover.debt.employee@example.test") source = make_period( creator=manager, month=6, status=PayrollPeriodStatus.CLOSED, ) make_period(creator=manager, month=7) carryover = create_carryover( source_period=source, employee=employee, direction="employee_owes", amount=Decimal("1200.00"), reason="Возврат переплаты", actor=manager, ) assert carryover.status == PayrollCarryoverStatus.TRANSFERRED assert carryover.generated_input_line.component.code == "DEDUCTION" with CaptureQueriesContext(connection) as queries: cancelled = cancel_carryover(carryover.pk, actor=manager) assert cancelled.status == PayrollCarryoverStatus.CANCELLED cancelled.generated_input_line.refresh_from_db() assert cancelled.generated_input_line.status == ApprovalStatus.VOIDED if connection.features.has_select_for_update: # Cancellation must still lock the line it updates, rather than merely # adding OF(carryover) to the former nullable join. line_locks = [ entry["sql"] for entry in queries if 'FROM "finance_payrollinputline"' in entry["sql"] and "FOR UPDATE" in entry["sql"] ] assert len(line_locks) == 1 assert "JOIN" not in line_locks[0] def test_pending_carryover_without_nullable_relations_can_be_cancelled(user_factory): manager = user_factory(email="carryover.pending.manager@example.test") employee = user_factory(email="carryover.pending.employee@example.test") source = make_period(creator=manager, month=6, status=PayrollPeriodStatus.CLOSED) carryover = create_carryover( source_period=source, employee=employee, direction="company_owes", amount=Decimal("55.71"), reason="Перенос без следующего периода", actor=manager, ) assert carryover.target_period_id is None assert carryover.generated_input_line_id is None cancelled = cancel_carryover(carryover.pk, actor=manager) repeated = cancel_carryover(carryover.pk, actor=manager) assert cancelled.status == PayrollCarryoverStatus.CANCELLED assert repeated.cancelled_at == cancelled.cancelled_at assert repeated.generated_input_line_id is None assert not PayrollInputLine.objects.filter( source_ref=f"payroll-carryover:{carryover.pk}" ).exists() def test_carryover_requires_closed_source_and_approved_transfer_cannot_cancel( user_factory, ): manager = user_factory(email="carryover.lock.manager@example.test") employee = user_factory(email="carryover.lock.employee@example.test") source = make_period(creator=manager, month=6) with pytest.raises(PayrollOperationError) as open_error: create_carryover( source_period=source, employee=employee, direction="company_owes", amount=Decimal("10.00"), reason="Ошибка", actor=manager, ) assert open_error.value.code == "CARRYOVER_SOURCE_NOT_CLOSED" source.status = PayrollPeriodStatus.CLOSED source.save(update_fields=["status", "updated_at"]) make_period(creator=manager, month=7) carryover = create_carryover( source_period=source, employee=employee, direction="company_owes", amount=Decimal("10.00"), reason="Подтверждённая доплата", actor=manager, ) line = carryover.generated_input_line line.status = ApprovalStatus.APPROVED line.approved_by = manager line.approved_at = carryover.transferred_at line.self_approval_overridden = True line.save( update_fields=[ "status", "approved_by", "approved_at", "self_approval_overridden", ] ) with pytest.raises(PayrollOperationError) as approved_error: cancel_carryover(carryover.pk, actor=manager) assert approved_error.value.code == "CARRYOVER_ALREADY_APPROVED" def test_manual_carryover_api_is_disabled( user_factory, auth_client_factory, ): manager = user_factory(email="carryover.api.manager@example.test") employee = user_factory( email="carryover.api.employee@example.test", first_name="Анна", last_name="Балансова", ) grant(manager, "manage_payroll_inputs", "view_all_payroll") source = make_period( creator=manager, month=6, status=PayrollPeriodStatus.CLOSED, ) client = auth_client_factory(manager) created = client.post( api_url("admin-period-carryovers", pk=source.pk), { "employee_id": employee.pk, "direction": "company_owes", "amount": "3500.00", "reason": "Исправление после закрытия", }, format="json", ) assert created.status_code == 410, created.content assert created.json()["code"] == "MANUAL_CARRYOVER_DISABLED" assert "колонок, включённых в переходящие остатки" in created.json()["message"] def test_carryover_ledger_lists_history_and_can_exclude_a_record( user_factory, auth_client_factory, ): manager = user_factory(email="carryover.ledger.manager@example.test") employee = user_factory( email="carryover.ledger.employee@example.test", first_name="Анна", last_name="Балансова", ) grant(manager, "manage_payroll_inputs", "view_all_payroll") june = make_period(creator=manager, month=6) july = make_period(creator=manager, month=7) correction = PayrollInputLine.objects.create( period=june, employee=employee, component=PayrollComponent.objects.get(code="CORRECTION_DEBIT"), amount=Decimal("3500.00"), reason="Недоплата за июнь", status=ApprovalStatus.APPROVED, created_by=manager, approved_by=manager, approved_at=timezone.now(), self_approval_overridden=True, ) client = auth_client_factory(manager) ledger = client.get(api_url("admin-period-carryovers", pk=july.pk)) assert ledger.status_code == 200, ledger.content assert ledger.json()["summary"]["balance"] == "3500.00" assert len(ledger.json()["entries"]) == 1 entry = ledger.json()["entries"][0] assert entry["id"] == correction.pk assert entry["period"]["id"] == june.pk assert entry["raw_contribution"] == "3500.00" assert entry["contribution"] == "3500.00" assert entry["counts_in_balance"] is True assert opening_balances(july, [employee.pk])[employee.pk] == Decimal("3500.00") excluded = client.patch( api_url("admin-input-line-carryover-inclusion", pk=correction.pk), {"included": False}, format="json", ) assert excluded.status_code == 200, excluded.content correction.refresh_from_db() assert correction.include_in_carryover is False assert correction.amount == Decimal("3500.00") assert opening_balances(july, [employee.pk]) == {} refreshed = client.get(api_url("admin-period-carryovers", pk=july.pk)).json() assert refreshed["summary"]["balance"] == "0.00" assert refreshed["entries"][0]["contribution"] == "0.00" assert refreshed["entries"][0]["raw_contribution"] == "3500.00" def test_component_setting_adds_existing_values_to_carryover_balance( user_factory, auth_client_factory, ): manager = user_factory(email="carryover.component.manager@example.test") employee = user_factory(email="carryover.component.employee@example.test") grant(manager, "manage_payroll_inputs", "view_all_payroll") june = make_period(creator=manager, month=6) july = make_period(creator=manager, month=7) line = PayrollInputLine.objects.create( period=june, employee=employee, component=PayrollComponent.objects.get(code="ONE_TIME_PAYMENT"), amount=Decimal("2400.00"), reason="Разовая выплата", status=ApprovalStatus.APPROVED, created_by=manager, approved_by=manager, approved_at=timezone.now(), self_approval_overridden=True, ) assert line.include_in_carryover is True assert opening_balances(july, [employee.pk]) == {} settings = FinanceSettings.get_solo() settings.payroll_component_overrides = { "ONE_TIME_PAYMENT": {"include_in_carryover": True} } settings.save(update_fields=["payroll_component_overrides", "updated_at"]) assert opening_balances(july, [employee.pk]) == {employee.pk: Decimal("-2400.00")} ledger = auth_client_factory(manager).get( api_url("admin-period-carryovers", pk=july.pk) ) assert ledger.status_code == 200, ledger.content assert [entry["id"] for entry in ledger.json()["entries"]] == [line.pk] assert ledger.json()["summary"]["balance"] == "-2400.00" excluded = auth_client_factory(manager).patch( api_url("admin-input-line-carryover-inclusion", pk=line.pk), {"included": False}, format="json", ) assert excluded.status_code == 200, excluded.content assert opening_balances(july, [employee.pk]) == {} def test_closed_period_carryover_uses_frozen_component_settings( user_factory, auth_client_factory, ): manager = user_factory(email="carryover.snapshot.manager@example.test") employee = user_factory(email="carryover.snapshot.employee@example.test") grant( manager, "manage_payroll_inputs", "view_all_payroll", "publish_payroll", ) june = make_period(creator=manager, month=6) july = make_period(creator=manager, month=7) settings = FinanceSettings.get_solo() settings.payroll_component_overrides = { "ONE_TIME_PAYMENT": { "include_in_carryover": True, "kind": "earning", } } settings.save(update_fields=["payroll_component_overrides", "updated_at"]) line = PayrollInputLine.objects.create( period=june, employee=employee, component_code="ONE_TIME_PAYMENT", amount=Decimal("2400.00"), reason="Разовая выплата", status=ApprovalStatus.APPROVED, created_by=manager, approved_by=manager, approved_at=timezone.now(), self_approval_overridden=True, ) june.status = PayrollPeriodStatus.PUBLISHED june.save(update_fields=["status", "updated_at"]) close_period(june.pk, actor=manager) settings.payroll_component_overrides = { "ONE_TIME_PAYMENT": { "include_in_carryover": False, "kind": "payment", } } settings.save(update_fields=["payroll_component_overrides", "updated_at"]) assert opening_balances(july, [employee.pk]) == {employee.pk: Decimal("-2400.00")} ledger = auth_client_factory(manager).get( api_url("admin-period-carryovers", pk=july.pk) ) assert ledger.status_code == 200, ledger.content assert ledger.json()["entries"][0]["component"]["kind"] == "earning" assert ledger.json()["entries"][0]["raw_contribution"] == "-2400.00" blocked = auth_client_factory(manager).patch( api_url("admin-input-line-carryover-inclusion", pk=line.pk), {"included": False}, format="json", ) assert blocked.status_code == 409, blocked.content assert blocked.json()["code"] == "PERIOD_INPUTS_LOCKED" line.refresh_from_db() assert line.include_in_carryover is True