"""A single saved automatic policy, applied transactionally on month closure.""" import uuid from datetime import date from django.core.exceptions import ValidationError from django.db import connection, transaction from finance.enums import PayrollPeriodStatus from finance.models import ( FinanceSettings, PayrollAuditEvent, PayrollInputCopyConfiguration, PayrollPeriod, ) from finance.payroll.exceptions import PayrollOperationError from finance.payroll.input_copy import apply_input_copy from finance.payroll.periods import ensure_calendar_payroll_period from finance.payroll.scheduled_changes import materialize_scheduled_changes def lock_automatic_input_copy_policy(): """Take this lock before period locks in both configuration and close paths.""" if connection.vendor == "postgresql": # A transaction-scoped advisory lock also works before the settings row # exists. Do not depend on racing first-time get_solo() inserts. with connection.cursor() as cursor: cursor.execute( "SELECT pg_advisory_xact_lock(%s, %s)", [0x50415952, 0x434F5059] ) return settings = FinanceSettings.objects.first() if settings is None: settings, _ = FinanceSettings.objects.get_or_create(pk=1) FinanceSettings.objects.select_for_update().get(pk=settings.pk) def save_input_copy_configuration(*, actor, data, update_automatic_policy=True): """The caller holds the policy lock and performs the manual copy atomically.""" if update_automatic_policy: PayrollInputCopyConfiguration.objects.filter( auto_apply_on_period_closure=True ).update(auto_apply_on_period_closure=False) if not data["remember_configuration"]: PayrollInputCopyConfiguration.objects.filter(owner=actor).delete() return defaults = { field: data[field] for field in ( "component_codes", "employee_scope", "employee_ids", "existing_mode", ) } # A serializer default is not an explicit request to switch automation off. # Omitting this field also preserves it on an existing owner's configuration. if update_automatic_policy: defaults["auto_apply_on_period_closure"] = data["auto_apply_on_period_closure"] PayrollInputCopyConfiguration.objects.update_or_create( owner=actor, defaults=defaults, ) @transaction.atomic def copy_inputs_after_period_close(*, source_period, actor): """Only called in the closing transaction, with policy and source locked.""" configuration = PayrollInputCopyConfiguration.objects.filter( auto_apply_on_period_closure=True ).first() if configuration is None: return None if source_period.status != PayrollPeriodStatus.CLOSED: raise PayrollOperationError( "SOURCE_PERIOD_NOT_CLOSED", "Автоперенос выполняется только при закрытии периода.", ) year, month = source_period.date_to.year, source_period.date_to.month target_start = date(year + (month == 12), 1 if month == 12 else month + 1, 1) try: target, created = ensure_calendar_payroll_period( target_start, currency=source_period.currency, actor=actor, channel="period_closure", metadata={"source_period_id": source_period.pk}, ) except ValidationError as exc: raise PayrollOperationError( "NEXT_PERIOD_CONFLICT", "Не удалось создать следующий месяц для автопереноса. Проверьте даты и валюту существующих периодов.", ) from exc target = PayrollPeriod.objects.select_for_update().get(pk=target.pk) if target.status != PayrollPeriodStatus.OPEN or target.current_run_id is not None: raise PayrollOperationError( "NEXT_PERIOD_INPUTS_LOCKED", "Следующий период уже рассчитан или закрыт. Его данные не изменены. Отключите автоматический перенос, чтобы закрыть исходный месяц; при необходимости выполните перенос вручную после возврата следующего периода на исправление.", details={"target_period_id": target.pk, "target_period_code": target.code}, ) data = { field: getattr(configuration, field) for field in ( "component_codes", "employee_scope", "employee_ids", "existing_mode", ) } data["idempotency_key"] = uuid.uuid5( uuid.NAMESPACE_URL, f"eusrr:payroll-close-copy:{source_period.pk}:{target.pk}" ) result = apply_input_copy( target_period=target, source_period=source_period, actor=actor, data=data, approved_only=True, channel="period_closure", ) materialize_scheduled_changes(source_period=source_period, target_period=target) result.update( target_period_code=target.code, target_period_created=created, configuration_owner_id=configuration.owner_id, ) PayrollAuditEvent.objects.create( actor=actor, action="payroll.period_input_copy_auto_applied", object_type=source_period._meta.label_lower, object_id=str(source_period.pk), period=source_period, metadata={ "channel": "period_closure", "trigger": "period_closed", "target_period_id": target.pk, "target_period_created": created, "configuration_owner_id": configuration.owner_id, **result["summary"], }, ) return result