"""Explicit payroll debts and additional payments carried to the next period.""" from __future__ import annotations from decimal import Decimal from django.db import IntegrityError, transaction from django.utils import timezone from finance.enums import ( ApprovalStatus, InputSource, PayrollCarryoverDirection, PayrollCarryoverStatus, PayrollPeriodStatus, ) from finance.models import ( PayrollAuditEvent, PayrollCarryover, PayrollInputLine, PayrollPeriod, ) from finance.payroll.exceptions import PayrollOperationError CARRYOVER_COMPONENT_CODES = { PayrollCarryoverDirection.COMPANY_OWES: "CORRECTION_CREDIT", PayrollCarryoverDirection.EMPLOYEE_OWES: "DEDUCTION", } CARRYOVER_SOURCE_PREFIX = "payroll-carryover:" def _operation_error(code: str, message: str, **details): raise PayrollOperationError(code, message, details=details) def _audit(*, actor, action, instance, period, metadata=None): PayrollAuditEvent.objects.create( actor=actor, action=action, object_type=instance._meta.label_lower, object_id=str(instance.pk), period=period, metadata={"channel": "carryover", **(metadata or {})}, ) def _next_period(source_period: PayrollPeriod): return ( PayrollPeriod.objects.filter(date_from__gt=source_period.date_to) .order_by("date_from", "pk") .first() ) def _target_accepts_transfer(target_period: PayrollPeriod) -> bool: return ( target_period.status == PayrollPeriodStatus.OPEN and target_period.current_run_id is None ) def _materialize_one(carryover: PayrollCarryover, target_period: PayrollPeriod): if carryover.status != PayrollCarryoverStatus.PENDING: return carryover if not _target_accepts_transfer(target_period): return carryover if target_period.currency != carryover.source_period.currency: _operation_error( "CARRYOVER_CURRENCY_MISMATCH", "Валюта следующего периода не совпадает с исходным периодом.", carryover_id=carryover.pk, target_period_id=target_period.pk, ) component_code = CARRYOVER_COMPONENT_CODES[carryover.direction] source_ref = f"{CARRYOVER_SOURCE_PREFIX}{carryover.pk}" line = PayrollInputLine.objects.filter( source=InputSource.API, source_ref=source_ref, ).first() if line is None: line = PayrollInputLine( period=target_period, employee=carryover.employee, component_code=component_code, amount=carryover.amount, relates_to_period=carryover.source_period, reason=( f"Автоперенос из периода {carryover.source_period.name or carryover.source_period.code}. " f"{carryover.reason.strip()}" ), status=ApprovalStatus.DRAFT, source=InputSource.API, source_ref=source_ref, created_by=carryover.created_by, ) line.full_clean() try: line.save(force_insert=True) except IntegrityError: line = PayrollInputLine.objects.get( source=InputSource.API, source_ref=source_ref, ) carryover.target_period = target_period carryover.generated_input_line = line carryover.status = PayrollCarryoverStatus.TRANSFERRED carryover.transferred_at = timezone.now() carryover.full_clean() carryover.save( update_fields=[ "target_period", "generated_input_line", "status", "transferred_at", "updated_at", ] ) _audit( actor=None, action="payroll.carryover_transferred", instance=carryover, period=target_period, metadata={ "source_period_id": carryover.source_period_id, "input_line_id": line.pk, "direction": carryover.direction, }, ) return carryover @transaction.atomic def materialize_pending_carryovers(target_period: PayrollPeriod): """Create draft input lines whose immediate next period is target_period.""" target_period = PayrollPeriod.objects.select_for_update().get(pk=target_period.pk) if not _target_accepts_transfer(target_period): return [] pending = list( # target_period is already locked; joined source/user rows are context. PayrollCarryover.objects.select_for_update(of=("self",)) .select_related("source_period", "employee", "created_by") .filter( status=PayrollCarryoverStatus.PENDING, source_period__date_to__lt=target_period.date_from, ) .order_by("source_period__date_to", "pk") ) transferred = [] for carryover in pending: next_period = _next_period(carryover.source_period) if next_period is None or next_period.pk != target_period.pk: continue transferred.append(_materialize_one(carryover, target_period)) return transferred @transaction.atomic def create_carryover( *, source_period: PayrollPeriod, employee, direction: str, amount: Decimal, reason: str, actor, ): source_period = PayrollPeriod.objects.select_for_update().get(pk=source_period.pk) if source_period.status != PayrollPeriodStatus.CLOSED: _operation_error( "CARRYOVER_SOURCE_NOT_CLOSED", "Долг или доплату можно зафиксировать только для закрытого периода.", source_period_id=source_period.pk, ) carryover = PayrollCarryover( source_period=source_period, employee=employee, direction=direction, amount=amount, reason=reason.strip(), created_by=actor, ) carryover.full_clean() carryover.save(force_insert=True) _audit( actor=actor, action="payroll.carryover_created", instance=carryover, period=source_period, metadata={"direction": direction, "amount": str(amount)}, ) target_period = _next_period(source_period) if target_period is not None: target_period = PayrollPeriod.objects.select_for_update().get( pk=target_period.pk ) carryover = _materialize_one(carryover, target_period) return carryover @transaction.atomic def cancel_carryover(carryover_id: int, *, actor): carryover = PayrollCarryover.objects.select_for_update().get(pk=carryover_id) if carryover.status == PayrollCarryoverStatus.CANCELLED: return carryover # The optional line must be locked separately: PostgreSQL cannot lock the # nullable side of an outer join. Keep the lock before checking its status # so a concurrent approval cannot be overwritten by cancellation. line = ( PayrollInputLine.objects.select_for_update().get( pk=carryover.generated_input_line_id ) if carryover.generated_input_line_id is not None else None ) if line is not None: if line.status != ApprovalStatus.DRAFT: _operation_error( "CARRYOVER_ALREADY_APPROVED", "Утверждённый перенос нельзя отменить; создайте обратную корректировку.", carryover_id=carryover.pk, input_line_id=line.pk, ) line.status = ApprovalStatus.VOIDED line.voided_by = actor line.voided_at = timezone.now() line.void_reason = "Отменена запись долга или доплаты" line.save(update_fields=["status", "voided_by", "voided_at", "void_reason"]) carryover.status = PayrollCarryoverStatus.CANCELLED carryover.cancelled_by = actor carryover.cancelled_at = timezone.now() carryover.save( update_fields=["status", "cancelled_by", "cancelled_at", "updated_at"] ) _audit( actor=actor, action="payroll.carryover_cancelled", instance=carryover, period=carryover.source_period, metadata={"input_line_id": line.pk if line else None}, ) return carryover