All checks were successful
Deploy production / deploy (push) Successful in 11m16s
143 lines
5.8 KiB
Python
143 lines
5.8 KiB
Python
"""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
|