Corp/backend/finance/payroll/carryovers.py
Игорь Надеин 4f6244ab1f
Some checks failed
Deploy production / deploy (push) Failing after 2s
Fix CORP runtime errors and harden deployment and media access
2026-09-10 16:40:08 +03:00

246 lines
8.2 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.

"""Explicit payroll debts and additional payments carried to the next period."""
from __future__ import annotations
from decimal import Decimal
from django.db import IntegrityError, transaction
from django.utils import timezone
from finance.enums import (
ApprovalStatus,
InputSource,
PayrollCarryoverDirection,
PayrollCarryoverStatus,
PayrollPeriodStatus,
)
from finance.models import (
PayrollAuditEvent,
PayrollCarryover,
PayrollInputLine,
PayrollPeriod,
)
from finance.payroll.exceptions import PayrollOperationError
CARRYOVER_COMPONENT_CODES = {
PayrollCarryoverDirection.COMPANY_OWES: "CORRECTION_CREDIT",
PayrollCarryoverDirection.EMPLOYEE_OWES: "DEDUCTION",
}
CARRYOVER_SOURCE_PREFIX = "payroll-carryover:"
def _operation_error(code: str, message: str, **details):
raise PayrollOperationError(code, message, details=details)
def _audit(*, actor, action, instance, period, metadata=None):
PayrollAuditEvent.objects.create(
actor=actor,
action=action,
object_type=instance._meta.label_lower,
object_id=str(instance.pk),
period=period,
metadata={"channel": "carryover", **(metadata or {})},
)
def _next_period(source_period: PayrollPeriod):
return (
PayrollPeriod.objects.filter(date_from__gt=source_period.date_to)
.order_by("date_from", "pk")
.first()
)
def _target_accepts_transfer(target_period: PayrollPeriod) -> bool:
return (
target_period.status == PayrollPeriodStatus.OPEN
and target_period.current_run_id is None
)
def _materialize_one(carryover: PayrollCarryover, target_period: PayrollPeriod):
if carryover.status != PayrollCarryoverStatus.PENDING:
return carryover
if not _target_accepts_transfer(target_period):
return carryover
if target_period.currency != carryover.source_period.currency:
_operation_error(
"CARRYOVER_CURRENCY_MISMATCH",
"Валюта следующего периода не совпадает с исходным периодом.",
carryover_id=carryover.pk,
target_period_id=target_period.pk,
)
component_code = CARRYOVER_COMPONENT_CODES[carryover.direction]
source_ref = f"{CARRYOVER_SOURCE_PREFIX}{carryover.pk}"
line = PayrollInputLine.objects.filter(
source=InputSource.API,
source_ref=source_ref,
).first()
if line is None:
line = PayrollInputLine(
period=target_period,
employee=carryover.employee,
component_code=component_code,
amount=carryover.amount,
relates_to_period=carryover.source_period,
reason=(
f"Автоперенос из периода {carryover.source_period.name or carryover.source_period.code}. "
f"{carryover.reason.strip()}"
),
status=ApprovalStatus.DRAFT,
source=InputSource.API,
source_ref=source_ref,
created_by=carryover.created_by,
)
line.full_clean()
try:
line.save(force_insert=True)
except IntegrityError:
line = PayrollInputLine.objects.get(
source=InputSource.API,
source_ref=source_ref,
)
carryover.target_period = target_period
carryover.generated_input_line = line
carryover.status = PayrollCarryoverStatus.TRANSFERRED
carryover.transferred_at = timezone.now()
carryover.full_clean()
carryover.save(
update_fields=[
"target_period",
"generated_input_line",
"status",
"transferred_at",
"updated_at",
]
)
_audit(
actor=None,
action="payroll.carryover_transferred",
instance=carryover,
period=target_period,
metadata={
"source_period_id": carryover.source_period_id,
"input_line_id": line.pk,
"direction": carryover.direction,
},
)
return carryover
@transaction.atomic
def materialize_pending_carryovers(target_period: PayrollPeriod):
"""Create draft input lines whose immediate next period is target_period."""
target_period = PayrollPeriod.objects.select_for_update().get(pk=target_period.pk)
if not _target_accepts_transfer(target_period):
return []
pending = list(
# target_period is already locked; joined source/user rows are context.
PayrollCarryover.objects.select_for_update(of=("self",))
.select_related("source_period", "employee", "created_by")
.filter(
status=PayrollCarryoverStatus.PENDING,
source_period__date_to__lt=target_period.date_from,
)
.order_by("source_period__date_to", "pk")
)
transferred = []
for carryover in pending:
next_period = _next_period(carryover.source_period)
if next_period is None or next_period.pk != target_period.pk:
continue
transferred.append(_materialize_one(carryover, target_period))
return transferred
@transaction.atomic
def create_carryover(
*,
source_period: PayrollPeriod,
employee,
direction: str,
amount: Decimal,
reason: str,
actor,
):
source_period = PayrollPeriod.objects.select_for_update().get(pk=source_period.pk)
if source_period.status != PayrollPeriodStatus.CLOSED:
_operation_error(
"CARRYOVER_SOURCE_NOT_CLOSED",
"Долг или доплату можно зафиксировать только для закрытого периода.",
source_period_id=source_period.pk,
)
carryover = PayrollCarryover(
source_period=source_period,
employee=employee,
direction=direction,
amount=amount,
reason=reason.strip(),
created_by=actor,
)
carryover.full_clean()
carryover.save(force_insert=True)
_audit(
actor=actor,
action="payroll.carryover_created",
instance=carryover,
period=source_period,
metadata={"direction": direction, "amount": str(amount)},
)
target_period = _next_period(source_period)
if target_period is not None:
target_period = PayrollPeriod.objects.select_for_update().get(
pk=target_period.pk
)
carryover = _materialize_one(carryover, target_period)
return carryover
@transaction.atomic
def cancel_carryover(carryover_id: int, *, actor):
carryover = PayrollCarryover.objects.select_for_update().get(pk=carryover_id)
if carryover.status == PayrollCarryoverStatus.CANCELLED:
return carryover
# The optional line must be locked separately: PostgreSQL cannot lock the
# nullable side of an outer join. Keep the lock before checking its status
# so a concurrent approval cannot be overwritten by cancellation.
line = (
PayrollInputLine.objects.select_for_update().get(
pk=carryover.generated_input_line_id
)
if carryover.generated_input_line_id is not None
else None
)
if line is not None:
if line.status != ApprovalStatus.DRAFT:
_operation_error(
"CARRYOVER_ALREADY_APPROVED",
"Утверждённый перенос нельзя отменить; создайте обратную корректировку.",
carryover_id=carryover.pk,
input_line_id=line.pk,
)
line.status = ApprovalStatus.VOIDED
line.voided_by = actor
line.voided_at = timezone.now()
line.void_reason = "Отменена запись долга или доплаты"
line.save(update_fields=["status", "voided_by", "voided_at", "void_reason"])
carryover.status = PayrollCarryoverStatus.CANCELLED
carryover.cancelled_by = actor
carryover.cancelled_at = timezone.now()
carryover.save(
update_fields=["status", "cancelled_by", "cancelled_at", "updated_at"]
)
_audit(
actor=actor,
action="payroll.carryover_cancelled",
instance=carryover,
period=carryover.source_period,
metadata={"input_line_id": line.pk if line else None},
)
return carryover