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