diff --git a/backend/api/v1/finance/admin/balance_adjustments.py b/backend/api/v1/finance/admin/balance_adjustments.py new file mode 100644 index 00000000..f87f19e5 --- /dev/null +++ b/backend/api/v1/finance/admin/balance_adjustments.py @@ -0,0 +1,80 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 +from rest_framework import serializers, status +from rest_framework.response import Response + +from finance.balance_adjustments.services import create_adjustment, void_adjustment +from finance.enums import PayrollCarryoverDirection +from finance.models import PayrollBalanceAdjustment, PayrollPeriod + +from .common import PAYROLL_PERMISSIONS, PayrollAdminAPIView + + +class BalanceAdjustmentWriteSerializer(serializers.Serializer): + employee_id = serializers.PrimaryKeyRelatedField( + source="employee", + queryset=get_user_model().objects.visible_in_ui(), + ) + effective_period_id = serializers.PrimaryKeyRelatedField( + source="effective_period", + queryset=PayrollPeriod.objects.all(), + ) + source_period_id = serializers.PrimaryKeyRelatedField( + source="source_period", + queryset=PayrollPeriod.objects.all(), + required=False, + allow_null=True, + default=None, + ) + direction = serializers.ChoiceField(choices=PayrollCarryoverDirection.choices) + amount = serializers.DecimalField( + max_digits=19, decimal_places=2, min_value=Decimal("0.01") + ) + reason = serializers.CharField( + max_length=1000, required=False, allow_blank=True, default="" + ) + request_id = serializers.UUIDField() + + +class BalanceAdjustmentVoidSerializer(serializers.Serializer): + reason = serializers.CharField( + max_length=1000, required=False, allow_blank=True, default="" + ) + + +class PayrollBalanceAdjustmentCreateView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + serializer_class = BalanceAdjustmentWriteSerializer + + def post(self, request): + command = self.serializer_class(data=request.data) + command.is_valid(raise_exception=True) + adjustment, created = create_adjustment( + actor=request.user, **command.validated_data + ) + return Response( + { + "id": adjustment.pk, + "effective_period_id": adjustment.effective_period_id, + "message": "Корректировка остатка сохранена. Зарплата и расчётные листки не изменены.", + }, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + +class PayrollBalanceAdjustmentVoidView(PayrollAdminAPIView): + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + serializer_class = BalanceAdjustmentVoidSerializer + + def post(self, request, pk): + get_object_or_404( + PayrollBalanceAdjustment, pk=pk, employee__is_hidden_in_ui=False + ) + command = self.serializer_class(data=request.data) + command.is_valid(raise_exception=True) + adjustment = void_adjustment(pk, actor=request.user, **command.validated_data) + return Response( + {"id": adjustment.pk, "message": "Корректировка остатка аннулирована."} + ) diff --git a/backend/api/v1/finance/admin/queries.py b/backend/api/v1/finance/admin/queries.py index 388a5293..5b612291 100644 --- a/backend/api/v1/finance/admin/queries.py +++ b/backend/api/v1/finance/admin/queries.py @@ -6,14 +6,17 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Count, Max from communications.models import Message +from finance.balance_adjustments.services import locked_periods from finance.enums import ( ApprovalStatus, InputSource, PayrollPeriodStatus, ) from finance.models import ( + PayrollBalanceAdjustment, PayrollCellCommentThread, PayrollInputLine, + PayrollPeriod, PayrollStatement, ) from finance.payroll.balances import CARRYOVER_DEBIT_KINDS @@ -193,6 +196,90 @@ def _carryover_ledger(period): "created_at": line.created_at, } ) + frozen_starts = list( + locked_periods( + PayrollPeriod.objects.filter(currency=period.currency) + ).values_list("date_from", flat=True) + ) + adjustments = ( + PayrollBalanceAdjustment.objects.filter( + effective_period__currency=period.currency, + effective_period__date_from__lte=period.date_from, + employee__is_hidden_in_ui=False, + ) + .select_related( + "effective_period", + "source_period", + "employee", + "employee__position", + "created_by", + "voided_by", + ) + .prefetch_related("employee__departments_links__department") + ) + for adjustment in adjustments: + active = adjustment.voided_at is None + contribution = adjustment.contribution if active else Decimal("0.00") + balances_by_employee[adjustment.employee_id] = ( + balances_by_employee.get(adjustment.employee_id, Decimal("0.00")) + + contribution + ) + included_count += int(active) + excluded_count += int(not active) + effective = adjustment.effective_period + entries.append( + { + "id": adjustment.pk, + "entry_type": "manual_adjustment", + "period": { + "id": effective.pk, + "code": effective.code, + "name": effective.name, + "date_from": effective.date_from, + "date_to": effective.date_to, + "currency": effective.currency, + }, + "source_period": ( + { + "id": adjustment.source_period_id, + "name": adjustment.source_period.name, + "code": adjustment.source_period.code, + } + if adjustment.source_period_id + else None + ), + "employee": PayrollEmployeeMiniSerializer(adjustment.employee).data, + "component": { + "code": "MANUAL_BALANCE_ADJUSTMENT", + "name": "Ручная корректировка остатка", + }, + "amount": str(adjustment.amount), + "raw_contribution": str(adjustment.contribution), + "contribution": str(contribution), + "reason": adjustment.reason, + "status": "approved" if active else "voided", + "status_label": "Действует" if active else "Аннулирована", + "include_in_carryover": active, + "counts_in_balance": active, + "created_by": PayrollUserRefSerializer(adjustment.created_by).data, + "created_at": adjustment.created_at, + "can_void": active + and not any(start >= effective.date_from for start in frozen_starts), + "voided_by": PayrollUserRefSerializer(adjustment.voided_by).data + if adjustment.voided_by_id + else None, + "voided_at": adjustment.voided_at, + "void_reason": adjustment.void_reason, + } + ) + entries.sort( + key=lambda entry: ( + entry["period"]["date_from"], + entry["created_at"], + entry["id"], + ), + reverse=True, + ) company_owes = sum( (max(balance, Decimal("0.00")) for balance in balances_by_employee.values()), Decimal("0.00"), diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index 715853c5..457a2593 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -48,6 +48,10 @@ from .admin import ( PayrollWorkRecordListCreateView, PayrollWorkRecordReviseView, ) +from .admin.balance_adjustments import ( + PayrollBalanceAdjustmentCreateView, + PayrollBalanceAdjustmentVoidView, +) from .admin.off_cycle import ( PayrollOffCyclePaymentListView, PayrollOffCyclePaymentVoidView, @@ -71,6 +75,16 @@ from .views import ( app_name = "finance-payroll" urlpatterns = [ + path( + "payroll/admin/balance-adjustments/", + PayrollBalanceAdjustmentCreateView.as_view(), + name="admin-balance-adjustment-create", + ), + path( + "payroll/admin/balance-adjustments//void/", + PayrollBalanceAdjustmentVoidView.as_view(), + name="admin-balance-adjustment-void", + ), path( "payroll/admin/off-cycle-payments/", PayrollOffCyclePaymentListView.as_view(), diff --git a/backend/finance/balance_adjustments/__init__.py b/backend/finance/balance_adjustments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/finance/balance_adjustments/models.py b/backend/finance/balance_adjustments/models.py new file mode 100644 index 00000000..da6289fe --- /dev/null +++ b/backend/finance/balance_adjustments/models.py @@ -0,0 +1,96 @@ +"""Balance-only corrections, deliberately unrelated to payroll input lines.""" + +import uuid +from decimal import Decimal + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +from django.db import models + +from finance.enums import PayrollCarryoverDirection + + +class PayrollBalanceAdjustment(models.Model): + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="payroll_balance_adjustments", + ) + effective_period = models.ForeignKey( + "finance.PayrollPeriod", + on_delete=models.PROTECT, + related_name="balance_adjustments", + ) + source_period = models.ForeignKey( + "finance.PayrollPeriod", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="referenced_balance_adjustments", + ) + direction = models.CharField( + max_length=20, choices=PayrollCarryoverDirection.choices + ) + amount = models.DecimalField( + max_digits=19, + decimal_places=2, + validators=[MinValueValidator(Decimal("0.01"))], + ) + reason = models.CharField(max_length=1000, blank=True) + request_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="payroll_balance_adjustments_created", + ) + created_at = models.DateTimeField(auto_now_add=True) + voided_at = models.DateTimeField(null=True, blank=True) + voided_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="payroll_balance_adjustments_voided", + ) + void_reason = models.CharField(max_length=1000, blank=True) + + class Meta: + ordering = ["-effective_period__date_from", "-created_at", "-pk"] + verbose_name = "Ручная корректировка остатка" + verbose_name_plural = "Ручные корректировки остатков" + constraints = [ + models.CheckConstraint( + condition=models.Q(amount__gt=0), + name="balance_adjustment_positive", + ), + models.CheckConstraint( + condition=models.Q(direction__in=["company_owes", "employee_owes"]), + name="balance_adjustment_direction", + ), + models.CheckConstraint( + condition=( + models.Q( + voided_at__isnull=True, voided_by__isnull=True, void_reason="" + ) + | models.Q(voided_at__isnull=False, voided_by__isnull=False) + ), + name="balance_adjustment_void_consistent", + ), + ] + + @property + def contribution(self): + return self.amount if self.direction == "company_owes" else -self.amount + + def clean(self): + super().clean() + if self.source_period_id and self.effective_period_id: + if self.source_period.currency != self.effective_period.currency: + raise ValidationError( + {"source_period": "Валюты периодов должны совпадать."} + ) + if self.source_period.date_to >= self.effective_period.date_from: + raise ValidationError( + {"source_period": "Выберите более ранний период."} + ) diff --git a/backend/finance/balance_adjustments/services.py b/backend/finance/balance_adjustments/services.py new file mode 100644 index 00000000..462a68c0 --- /dev/null +++ b/backend/finance/balance_adjustments/services.py @@ -0,0 +1,150 @@ +"""Audited balance corrections; no accruals, payments or notifications.""" + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.db.models import Q +from django.utils import timezone + +from finance.enums import PayrollPeriodStatus +from finance.models import PayrollAuditEvent, PayrollBalanceAdjustment, PayrollPeriod +from finance.payroll.access import can_access_payroll_management, has_payroll_permission +from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied + + +def _require_write_access(actor): + if not can_access_payroll_management(actor) or not has_payroll_permission( + actor, "finance.manage_payroll_inputs" + ): + raise PayrollPermissionDenied("finance.manage_payroll_inputs") + + +def affected_periods(period): + return PayrollPeriod.objects.filter( + currency=period.currency, + date_from__gte=period.date_from, + ).order_by("date_from", "pk") + + +def locked_periods(queryset): + return queryset.filter( + ~Q(status=PayrollPeriodStatus.OPEN) | Q(current_run__isnull=False) + ) + + +def _lock_editable_periods(period): + # Calculation/closing also lock PayrollPeriod. Lock all affected periods so + # a correction cannot race calculation or silently invalidate a later run. + # Period dates/currency may have changed since HTTP deserialization. Lock + # and reread the effective period before determining the affected range. + period = PayrollPeriod.objects.select_for_update().get(pk=period.pk) + periods = list(affected_periods(period).select_for_update()) + if not periods or any( + item.status != PayrollPeriodStatus.OPEN or item.current_run_id + for item in periods + ): + raise PayrollOperationError( + "BALANCE_ADJUSTMENT_PERIOD_LOCKED", + "Корректировка возможна только начиная с открытого периода без расчёта. " + "Последующие рассчитанные и закрытые периоды также нельзя изменять. " + "Выберите более поздний открытый период.", + ) + return period + + +def _audit(adjustment, actor, action): + PayrollAuditEvent.objects.create( + actor=actor, + action=action, + object_type=adjustment._meta.label_lower, + object_id=str(adjustment.pk), + period=adjustment.effective_period, + metadata={ + "channel": "balance_adjustments", + "employee_id": adjustment.employee_id, + "source_period_id": adjustment.source_period_id, + "direction": adjustment.direction, + "amount": str(adjustment.amount), + "reason": adjustment.reason, + "void_reason": adjustment.void_reason, + }, + ) + + +@transaction.atomic +def create_adjustment( + *, actor, employee, effective_period, source_period=None, **fields +): + _require_write_access(actor) + # Serialize a user's retries without blocking FK key-share locks taken by + # concurrent calculation/audit inserts referencing the same user. + get_user_model().objects.select_for_update(no_key=True).get(pk=actor.pk) + existing = PayrollBalanceAdjustment.objects.filter( + request_id=fields["request_id"] + ).first() + if existing: + if not ( + existing.created_by_id == actor.pk + and existing.employee_id == employee.pk + and existing.effective_period_id == effective_period.pk + and existing.source_period_id + == (source_period.pk if source_period else None) + and all(getattr(existing, key) == value for key, value in fields.items()) + ): + raise PayrollOperationError( + "BALANCE_ADJUSTMENT_REQUEST_CONFLICT", + "Этот запрос уже использован для другой корректировки.", + ) + return existing, False + effective_period = _lock_editable_periods(effective_period) + adjustment = PayrollBalanceAdjustment( + employee=employee, + effective_period=effective_period, + source_period=source_period, + created_by=actor, + **fields, + ) + try: + adjustment.full_clean(validate_unique=False) + except ValidationError as exc: + raise PayrollOperationError( + "BALANCE_ADJUSTMENT_INVALID", + " ".join(exc.messages), + ) from exc + try: + with transaction.atomic(): + adjustment.save() + except IntegrityError as exc: + if PayrollBalanceAdjustment.objects.filter( + request_id=fields["request_id"] + ).exists(): + raise PayrollOperationError( + "BALANCE_ADJUSTMENT_REQUEST_CONFLICT", + "Этот запрос уже использован для другой корректировки.", + ) from exc + raise + _audit(adjustment, actor, "payroll.balance_adjustment_created") + return adjustment, True + + +@transaction.atomic +def void_adjustment(adjustment_id, *, actor, reason=""): + _require_write_access(actor) + initial = PayrollBalanceAdjustment.objects.select_related("effective_period").get( + pk=adjustment_id + ) + if initial.voided_at: + return initial + # Same period-before-record lock order as creation/calculation. + _lock_editable_periods(initial.effective_period) + adjustment = PayrollBalanceAdjustment.objects.select_for_update().get( + pk=adjustment_id + ) + if adjustment.voided_at: + return adjustment + adjustment.voided_at = timezone.now() + adjustment.voided_by = actor + adjustment.void_reason = reason + adjustment.save(update_fields=["voided_at", "voided_by", "void_reason"]) + _audit(adjustment, actor, "payroll.balance_adjustment_voided") + return adjustment diff --git a/backend/finance/migrations/0040_payrollbalanceadjustment.py b/backend/finance/migrations/0040_payrollbalanceadjustment.py new file mode 100644 index 00000000..279cc9b9 --- /dev/null +++ b/backend/finance/migrations/0040_payrollbalanceadjustment.py @@ -0,0 +1,131 @@ +import uuid +from decimal import Decimal + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("finance", "0039_payrolloffcyclepayment"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PayrollBalanceAdjustment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "direction", + models.CharField( + choices=[ + ("company_owes", "Доплата сотруднику"), + ("employee_owes", "Долг сотрудника"), + ], + max_length=20, + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=2, + max_digits=19, + validators=[ + django.core.validators.MinValueValidator(Decimal("0.01")) + ], + ), + ), + ("reason", models.CharField(blank=True, max_length=1000)), + ( + "request_id", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("voided_at", models.DateTimeField(blank=True, null=True)), + ("void_reason", models.CharField(blank=True, max_length=1000)), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="payroll_balance_adjustments", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="payroll_balance_adjustments_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "voided_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="payroll_balance_adjustments_voided", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "effective_period", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="balance_adjustments", + to="finance.payrollperiod", + ), + ), + ( + "source_period", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="referenced_balance_adjustments", + to="finance.payrollperiod", + ), + ), + ], + options={ + "ordering": ["-effective_period__date_from", "-created_at", "-pk"], + "verbose_name": "Ручная корректировка остатка", + "verbose_name_plural": "Ручные корректировки остатков", + "constraints": [ + models.CheckConstraint( + condition=models.Q(amount__gt=0), + name="balance_adjustment_positive", + ), + models.CheckConstraint( + condition=models.Q( + direction__in=["company_owes", "employee_owes"] + ), + name="balance_adjustment_direction", + ), + models.CheckConstraint( + condition=( + models.Q( + voided_at__isnull=True, + voided_by__isnull=True, + void_reason="", + ) + | models.Q(voided_at__isnull=False, voided_by__isnull=False) + ), + name="balance_adjustment_void_consistent", + ), + ], + }, + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 121b0db3..8bb7cf7f 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -16,6 +16,7 @@ from django.db import models, transaction from django.db.models import F, Q from .analytics.models import ReportingEntry # noqa: F401 +from .balance_adjustments.models import PayrollBalanceAdjustment # noqa: F401 from .enums import ( ApprovalStatus, InputSource, diff --git a/backend/finance/payroll/balances.py b/backend/finance/payroll/balances.py index d8f2ac65..3c660a6f 100644 --- a/backend/finance/payroll/balances.py +++ b/backend/finance/payroll/balances.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from decimal import Decimal from finance.enums import ApprovalStatus, InputSource -from finance.models import PayrollInputLine, PayrollPeriod +from finance.models import PayrollBalanceAdjustment, PayrollInputLine, PayrollPeriod from finance.payroll.components import get_payroll_components CARRYOVER_DEBIT_KINDS = frozenset({"adjustment_debit", "deduction", "payment"}) @@ -60,4 +60,16 @@ def opening_balances( balances[line["employee_id"]] = balances.get( line["employee_id"], Decimal("0.00") ) + (line["amount"] * direction) + # Effective-period corrections enter the opening balance exactly once per + # cumulative reconstruction. They never become payroll input lines. + for adjustment in PayrollBalanceAdjustment.objects.filter( + employee_id__in=requested_ids, + effective_period__currency=period.currency, + effective_period__date_from__lte=period.date_from, + voided_at__isnull=True, + ): + balances[adjustment.employee_id] = ( + balances.get(adjustment.employee_id, Decimal("0.00")) + + adjustment.contribution + ) return balances diff --git a/backend/tests/finance/test_balance_adjustments.py b/backend/tests/finance/test_balance_adjustments.py new file mode 100644 index 00000000..fb50277b --- /dev/null +++ b/backend/tests/finance/test_balance_adjustments.py @@ -0,0 +1,376 @@ +import calendar +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import date +from decimal import Decimal +from threading import Barrier + +import pytest +from django.contrib.auth import get_user_model +from django.db import close_old_connections, connection +from django.urls import reverse + +from finance.balance_adjustments.services import create_adjustment, void_adjustment +from finance.models import ( + FinanceSettings, + PayrollAuditEvent, + PayrollBalanceAdjustment, + PayrollInputLine, + PayrollPeriod, + PayrollStatement, + PayrollWorkRecord, +) +from finance.payroll.balances import opening_balances +from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied +from finance.payroll.reporting import build_payroll_period_table +from finance.payroll.services import calculate_period +from tests.finance.test_payroll_admin_api import grant, make_approved_period_inputs + +pytestmark = pytest.mark.django_db + + +def url(name, **kwargs): + return reverse(f"api:v1:finance-payroll:{name}", kwargs=kwargs or None) + + +@pytest.fixture +def setup(user_factory, auth_client_factory): + manager = user_factory() + employee = user_factory() + grant(manager, "manage_payroll_inputs", "view_all_payroll", "calculate_payroll") + periods = {} + for month in (8, 9, 10, 11): + periods[month] = PayrollPeriod.objects.create( + code=f"2026-{month:02}", + name=f"Месяц {month}", + date_from=date(2026, month, 1), + date_to=date(2026, month, calendar.monthrange(2026, month)[1]), + status="closed" if month < 10 else "open", + created_by=manager, + ) + payload = { + "employee_id": employee.pk, + "effective_period_id": periods[10].pk, + "source_period_id": periods[8].pk, + "direction": "company_owes", + "amount": "1234.56", + "request_id": str(uuid.uuid4()), + } + return manager, employee, periods, payload, auth_client_factory(manager) + + +def create(setup, **overrides): + _, _, _, payload, client = setup + return client.post( + url("admin-balance-adjustment-create"), {**payload, **overrides}, format="json" + ) + + +@pytest.mark.parametrize( + "direction,contribution", + [("company_owes", "1234.56"), ("employee_owes", "-1234.56")], +) +def test_effective_from_october_once_and_no_payroll_inputs( + setup, direction, contribution +): + manager, employee, periods, _, client = setup + frozen = list( + PayrollPeriod.objects.filter(pk__in=[periods[8].pk, periods[9].pk]).values() + ) + result = create(setup, direction=direction) + assert result.status_code == 201, result.content + adjustment = PayrollBalanceAdjustment.objects.get() + assert adjustment.reason == "" + assert adjustment.created_by == manager + assert not PayrollInputLine.objects.exists() + assert not PayrollStatement.objects.exists() + assert ( + PayrollAuditEvent.objects.filter( + action="payroll.balance_adjustment_created" + ).count() + == 1 + ) + for month in (8, 9): + assert opening_balances(periods[month], [employee.pk]) == {} + ledger = client.get(url("admin-period-carryovers", pk=periods[month].pk)).json() + assert ledger["entries"] == [] + for month in (10, 11): + assert opening_balances(periods[month], [employee.pk]) == { + employee.pk: Decimal(contribution) + } + ledger = client.get(url("admin-period-carryovers", pk=periods[month].pk)).json() + assert ledger["summary"]["balance"] == contribution + assert len(ledger["entries"]) == 1 + entry = ledger["entries"][0] + assert entry["entry_type"] == "manual_adjustment" + assert entry["can_void"] is True + assert entry["source_period"]["id"] == periods[8].pk + assert frozen == list( + PayrollPeriod.objects.filter(pk__in=[periods[8].pk, periods[9].pk]).values() + ) + + +def test_idempotency_and_conflicting_retries(setup): + assert create(setup).status_code == 201 + assert create(setup).status_code == 200 + result = create(setup, amount="99") + assert result.status_code == 409, result.content + assert result.json()["code"] == "BALANCE_ADJUSTMENT_REQUEST_CONFLICT" + assert PayrollBalanceAdjustment.objects.count() == 1 + + +@pytest.mark.django_db(transaction=True) +def test_concurrent_retries_are_exactly_once_on_postgres(setup): + if connection.vendor != "postgresql": + pytest.skip("PostgreSQL row-lock integration test") + manager, employee, periods, payload, _ = setup + FinanceSettings.get_solo() + barrier = Barrier(2) + + def record(): + close_old_connections() + try: + actor = get_user_model().objects.get(pk=manager.pk) + person = get_user_model().objects.get(pk=employee.pk) + effective = PayrollPeriod.objects.get(pk=periods[10].pk) + source = PayrollPeriod.objects.get(pk=periods[8].pk) + barrier.wait(timeout=15) + adjustment, created = create_adjustment( + actor=actor, + employee=person, + effective_period=effective, + source_period=source, + direction="company_owes", + amount=Decimal("1234.56"), + reason="", + request_id=uuid.UUID(payload["request_id"]), + ) + return adjustment.pk, created + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(record) + second = pool.submit(record) + results = [first.result(timeout=30), second.result(timeout=30)] + assert results[0][0] == results[1][0] + assert sorted(result[1] for result in results) == [False, True] + assert PayrollBalanceAdjustment.objects.count() == 1 + assert ( + PayrollAuditEvent.objects.filter( + action="payroll.balance_adjustment_created" + ).count() + == 1 + ) + + +def test_void_keeps_history_and_cancels_only_balance(setup): + manager, employee, periods, _, client = setup + assert create(setup).status_code == 201 + adjustment = PayrollBalanceAdjustment.objects.get() + result = client.post( + url("admin-balance-adjustment-void", pk=adjustment.pk), {}, format="json" + ) + assert result.status_code == 200, result.content + assert ( + client.post( + url("admin-balance-adjustment-void", pk=adjustment.pk), {}, format="json" + ).status_code + == 200 + ) + adjustment.refresh_from_db() + assert adjustment.voided_by == manager + assert adjustment.voided_at + assert not opening_balances(periods[10], [employee.pk]) + entry = client.get(url("admin-period-carryovers", pk=periods[10].pk)).json()[ + "entries" + ][0] + assert entry["status"] == "voided" + assert entry["contribution"] == "0.00" + assert entry["can_void"] is False + assert entry["voided_by"]["id"] == manager.pk + assert ( + PayrollAuditEvent.objects.filter( + action="payroll.balance_adjustment_voided" + ).count() + == 1 + ) + assert not PayrollInputLine.objects.exists() + + +@pytest.mark.parametrize( + "status", ["calculated", "review", "approved", "published", "closed"] +) +def test_calculated_and_later_frozen_periods_block_create_and_void(setup, status): + _, employee, periods, _, client = setup + assert create(setup).status_code == 201 + adjustment = PayrollBalanceAdjustment.objects.get() + PayrollPeriod.objects.filter(pk=periods[11].pk).update(status=status) + assert create(setup, request_id=str(uuid.uuid4())).status_code == 409 + result = client.post( + url("admin-balance-adjustment-void", pk=adjustment.pk), {}, format="json" + ) + assert result.status_code == 409 + assert opening_balances(periods[10], [employee.pk])[employee.pk] == Decimal( + "1234.56" + ) + entry = client.get(url("admin-period-carryovers", pk=periods[10].pk)).json()[ + "entries" + ][0] + assert entry["can_void"] is False + + +def test_closed_effective_period_rejected(setup): + assert create(setup, effective_period_id=setup[2][9].pk).status_code == 409 + assert not PayrollBalanceAdjustment.objects.exists() + + +def test_future_effective_period_does_not_change_current_balance(setup): + _, employee, periods, _, client = setup + assert create(setup, effective_period_id=periods[11].pk).status_code == 201 + assert opening_balances(periods[10], [employee.pk]) == {} + assert ( + client.get(url("admin-period-carryovers", pk=periods[10].pk)).json()["entries"] + == [] + ) + assert opening_balances(periods[11], [employee.pk])[employee.pk] == Decimal( + "1234.56" + ) + + +def test_retry_of_already_voided_adjustment_after_closing_is_read_only(setup): + _, _, periods, _, client = setup + assert create(setup).status_code == 201 + adjustment = PayrollBalanceAdjustment.objects.get() + endpoint = url("admin-balance-adjustment-void", pk=adjustment.pk) + assert client.post(endpoint, {}, format="json").status_code == 200 + PayrollPeriod.objects.filter(pk=periods[10].pk).update(status="closed") + assert client.post(endpoint, {}, format="json").status_code == 200 + assert ( + PayrollAuditEvent.objects.filter( + action="payroll.balance_adjustment_voided" + ).count() + == 1 + ) + + +@pytest.mark.parametrize( + "overrides", + [ + {"amount": "0"}, + {"amount": "-1"}, + {"amount": "1.001"}, + {"direction": "unknown"}, + {"request_id": "invalid"}, + ], +) +def test_invalid_input(setup, overrides): + assert create(setup, **overrides).status_code == 400 + assert not PayrollBalanceAdjustment.objects.exists() + + +def test_source_is_reference_only_and_must_be_earlier_same_currency(setup): + periods = setup[2] + assert create(setup, source_period_id=periods[10].pk).status_code == 409 + PayrollPeriod.objects.filter(pk=periods[8].pk).update(currency="USD") + assert create(setup).status_code == 409 + assert create(setup, source_period_id=None).status_code == 201 + + +def test_inactive_allowed_hidden_forbidden(setup): + employee = setup[1] + employee.is_active = False + employee.save(update_fields=["is_active"]) + assert create(setup).status_code == 201 + employee.is_hidden_in_ui = True + employee.save(update_fields=["is_hidden_in_ui"]) + assert create(setup, request_id=str(uuid.uuid4())).status_code == 400 + + +def test_permissions_and_management_allowlist(setup, user_factory, auth_client_factory): + manager, employee, periods, payload, client = setup + outsider = user_factory() + grant(outsider, "view_all_payroll") + outsider_client = auth_client_factory(outsider) + assert ( + outsider_client.post( + url("admin-balance-adjustment-create"), payload, format="json" + ).status_code + == 403 + ) + with pytest.raises(PayrollPermissionDenied): + create_adjustment( + actor=outsider, employee=employee, effective_period=periods[10] + ) + settings = FinanceSettings.get_solo() + settings.management_users.add(outsider) + assert create(setup).status_code == 403 + with pytest.raises(PayrollPermissionDenied): + create_adjustment( + actor=manager, employee=employee, effective_period=periods[10] + ) + + +def test_preview_and_calculation_change_only_balance_preserve_september_snapshot( + setup, user_factory +): + manager, employee, periods, _, client = setup + approver = user_factory() + september = periods[9] + september.status = "open" + september.save(update_fields=["status"]) + make_approved_period_inputs( + period=september, employee=employee, creator=manager, approver=approver + ) + september_run = calculate_period(september.pk, actor=manager) + september.refresh_from_db() + september.status = "closed" + september.save(update_fields=["status"]) + snapshot_before = list(PayrollStatement.objects.filter(run=september_run).values()) + work = PayrollWorkRecord.objects.get(period=september, employee=employee) + work.pk = None + work.period = periods[10] + work.save() + before = next( + row + for row in build_payroll_period_table(periods[10])["rows"] + if row["employee"]["id"] == employee.pk + ) + assert create(setup).status_code == 201 + after = next( + row + for row in build_payroll_period_table(periods[10])["rows"] + if row["employee"]["id"] == employee.pk + ) + assert Decimal(after["opening_balance"]) - Decimal( + before["opening_balance"] + ) == Decimal("1234.56") + assert Decimal(after["closing_balance"]) - Decimal( + before["closing_balance"] + ) == Decimal("1234.56") + for key in ( + "gross_total", + "deduction_total", + "net_pay", + "payment_total", + "payable", + ): + assert before[key] == after[key], key + run = calculate_period(periods[10].pk, actor=manager) + statement = PayrollStatement.objects.get(run=run, employee=employee) + assert statement.opening_balance == Decimal(after["opening_balance"]) + assert statement.closing_balance == Decimal(after["closing_balance"]) + assert statement.payable == Decimal(before["payable"]) + assert snapshot_before == list( + PayrollStatement.objects.filter(run=september_run).values() + ) + assert not PayrollInputLine.objects.exists() + adjustment = PayrollBalanceAdjustment.objects.get() + assert ( + client.post( + url("admin-balance-adjustment-void", pk=adjustment.pk), {}, format="json" + ).status_code + == 409 + ) + with pytest.raises(PayrollOperationError): + void_adjustment(adjustment.pk, actor=manager) diff --git a/frontend/src/components/finance/admin/PayrollBalanceAdjustmentModal.tsx b/frontend/src/components/finance/admin/PayrollBalanceAdjustmentModal.tsx new file mode 100644 index 00000000..c99fb3ae --- /dev/null +++ b/frontend/src/components/finance/admin/PayrollBalanceAdjustmentModal.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useMemo, useRef, useState, type FormEvent } from "react"; + +import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; +import { Modal } from "@/components/ui/Modal"; +import { apiClient } from "@/lib/api"; +import type { + PayrollAdminEmployee, + PayrollAdminPeriod, + PayrollBalanceAdjustmentWrite, +} from "@/lib/api/finance"; +import { + balanceAdjustmentPeriods, + balanceAdjustmentSources, +} from "@/lib/payroll-balance-adjustments"; +import { + matchesPayrollEmployeeSearch, + payrollInputEmployeeOptions, +} from "@/lib/payroll-employee-search"; +import { getPayrollAdminError } from "@/lib/payroll-admin"; +import { FormError, ModalActions } from "./PayrollAdminModals"; + +const inputClass = "app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"; + +export function PayrollBalanceAdjustmentModal({ + employees, + periods, + initialEmployeeId, + initialPeriodId, + onClose, + onSaved, +}: { + employees: PayrollAdminEmployee[]; + periods: PayrollAdminPeriod[]; + initialEmployeeId: number | null; + initialPeriodId: number; + onClose: () => void; + onSaved: (periodId: number, employeeId: number) => void | Promise; +}) { + const eligiblePeriods = useMemo( + () => balanceAdjustmentPeriods(periods), + [periods], + ); + const [employeeId, setEmployeeId] = useState( + initialEmployeeId, + ); + const [effectiveId, setEffectiveId] = useState( + () => + eligiblePeriods.find((period) => period.id === initialPeriodId)?.id ?? + eligiblePeriods.find( + (period) => + period.currency === + periods.find((item) => item.id === initialPeriodId)?.currency, + )?.id ?? + eligiblePeriods[0]?.id ?? + 0, + ); + const [sourceId, setSourceId] = useState(""); + const [direction, setDirection] = + useState("company_owes"); + const [amount, setAmount] = useState(""); + const [reason, setReason] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const requestId = useRef(null); + const submitting = useRef(false); + const options = useMemo( + () => payrollInputEmployeeOptions(employees, true), + [employees], + ); + const effective = eligiblePeriods.find((period) => period.id === effectiveId); + const sourcePeriods = balanceAdjustmentSources(periods, effective); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (submitting.current || !employeeId || !effective) return; + submitting.current = true; + setBusy(true); + setError(null); + requestId.current ??= crypto.randomUUID(); + try { + await apiClient.createPayrollBalanceAdjustment({ + employee_id: employeeId, + effective_period_id: effectiveId, + source_period_id: sourceId ? Number(sourceId) : null, + direction, + amount, + reason: reason.trim(), + request_id: requestId.current, + }); + await onSaved(effectiveId, employeeId); + } catch (saveError) { + setError( + getPayrollAdminError( + saveError, + "Не удалось сохранить корректировку остатка.", + ), + ); + } finally { + submitting.current = false; + setBusy(false); + } + }; + + return ( + {} : onClose} + closeOnEsc={!busy} + title="Добавить корректировку остатка" + size="md" + stackLevel={1} + > +
void submit(event)}> +

+ Меняется только переходящий остаток с выбранного периода. Зарплата, + закрытые снимки и расчётные листки не изменяются; рассылки нет. +

+
+
+ + matchesPayrollEmployeeSearch(item.name, query) + } + /> +

+ Поиск среди всех сотрудников, включая неактивных. +

+
+ + + + +