Игорь Надеин af26bfc6de
All checks were successful
Deploy production / deploy (push) Successful in 10m8s
Add balance-only payroll carryover adjustments
2026-10-15 16:29:32 +03:00

151 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.

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