Corp/backend/finance/payroll/automatic_input_copy.py
Игорь Надеин abef43136b
All checks were successful
Deploy production / deploy (push) Successful in 11m16s
Apply automatic payroll input transfer when closing periods
2026-10-19 10:47:57 +03:00

143 lines
5.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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