"""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