246 lines
8.2 KiB
Python
246 lines
8.2 KiB
Python
"""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
|