156 lines
5.8 KiB
Python
156 lines
5.8 KiB
Python
from decimal import Decimal
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from django.db import IntegrityError, transaction
|
||
from django.db.models import Sum
|
||
from django.utils import timezone
|
||
|
||
from finance.enums import PayrollPeriodStatus
|
||
from finance.models import (
|
||
PayrollAuditEvent,
|
||
PayrollOffCyclePayment,
|
||
PayrollPeriod,
|
||
PayrollStatement,
|
||
)
|
||
from finance.payroll.access import can_access_payroll_management, has_payroll_permission
|
||
from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied
|
||
|
||
ZERO = Decimal("0.00")
|
||
|
||
|
||
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 statement_settlement(statement):
|
||
paid = (
|
||
statement.off_cycle_payments.filter(voided_at__isnull=True).aggregate(
|
||
total=Sum("amount")
|
||
)["total"]
|
||
or ZERO
|
||
)
|
||
confirmed = hasattr(statement, "payment_confirmation")
|
||
return {
|
||
"original_payable": statement.payable,
|
||
"paid_outside_period": paid,
|
||
"remaining": ZERO if confirmed else max(statement.payable - paid, ZERO),
|
||
"confirmed": confirmed,
|
||
}
|
||
|
||
|
||
def _audit(payment, actor, action, **metadata):
|
||
PayrollAuditEvent.objects.create(
|
||
actor=actor,
|
||
action=action,
|
||
object_type=payment._meta.label_lower,
|
||
object_id=str(payment.pk),
|
||
period=payment.source_period,
|
||
metadata={"channel": "off_cycle_payments", **metadata},
|
||
)
|
||
|
||
|
||
@transaction.atomic
|
||
def record_payment(*, actor, employee, payee_name, source_period, **fields):
|
||
_require_write_access(actor)
|
||
# The actor lock serializes retries with the same idempotency key.
|
||
get_user_model().objects.select_for_update().get(pk=actor.pk)
|
||
existing = PayrollOffCyclePayment.objects.filter(
|
||
request_id=fields["request_id"]
|
||
).first()
|
||
if existing:
|
||
same = (
|
||
existing.created_by_id == actor.pk
|
||
and existing.employee_id == (employee.pk if employee else None)
|
||
and existing.payee_name == payee_name
|
||
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())
|
||
)
|
||
if not same:
|
||
raise PayrollOperationError(
|
||
"PAYMENT_REQUEST_CONFLICT",
|
||
"Этот запрос уже использован для другой выплаты.",
|
||
)
|
||
return existing, False
|
||
statement = None
|
||
if source_period:
|
||
source_period = PayrollPeriod.objects.select_for_update().get(
|
||
pk=source_period.pk
|
||
)
|
||
if source_period.status != PayrollPeriodStatus.CLOSED:
|
||
raise PayrollOperationError(
|
||
"PAYMENT_SOURCE_NOT_CLOSED",
|
||
"Для погашения долга выберите закрытый период.",
|
||
)
|
||
if fields["currency"] != source_period.currency:
|
||
raise PayrollOperationError(
|
||
"PAYMENT_CURRENCY_MISMATCH", "Валюта выплаты не совпадает с периодом."
|
||
)
|
||
if employee:
|
||
statement = (
|
||
PayrollStatement.objects.select_for_update()
|
||
.filter(
|
||
run_id=source_period.current_run_id,
|
||
employee=employee,
|
||
)
|
||
.first()
|
||
)
|
||
if statement is None:
|
||
raise PayrollOperationError(
|
||
"PAYMENT_STATEMENT_NOT_FOUND",
|
||
"У сотрудника нет итогового листка в выбранном периоде.",
|
||
)
|
||
settlement = statement_settlement(statement)
|
||
if fields["amount"] > settlement["remaining"]:
|
||
raise PayrollOperationError(
|
||
"PAYMENT_EXCEEDS_REMAINING",
|
||
f"Сумма превышает остаток долга: {settlement['remaining']:.2f} {fields['currency']}.",
|
||
)
|
||
payment = PayrollOffCyclePayment(
|
||
employee=employee,
|
||
payee_name=payee_name,
|
||
source_period=source_period,
|
||
source_statement=statement,
|
||
created_by=actor,
|
||
**fields,
|
||
)
|
||
payment.full_clean(validate_unique=False)
|
||
try:
|
||
with transaction.atomic():
|
||
payment.save()
|
||
except IntegrityError:
|
||
if PayrollOffCyclePayment.objects.filter(
|
||
request_id=fields["request_id"]
|
||
).exists():
|
||
raise PayrollOperationError(
|
||
"PAYMENT_REQUEST_CONFLICT",
|
||
"Этот запрос уже использован для другой выплаты.",
|
||
)
|
||
raise
|
||
_audit(payment, actor, "payroll.off_cycle_payment_created")
|
||
return payment, True
|
||
|
||
|
||
@transaction.atomic
|
||
def void_payment(payment_id, *, actor, reason):
|
||
_require_write_access(actor)
|
||
# Same lock order as creation/confirmation; nullable relations are not joined.
|
||
initial = PayrollOffCyclePayment.objects.get(pk=payment_id)
|
||
if initial.source_period_id:
|
||
PayrollPeriod.objects.select_for_update().get(pk=initial.source_period_id)
|
||
if initial.source_statement_id:
|
||
PayrollStatement.objects.select_for_update().get(pk=initial.source_statement_id)
|
||
payment = PayrollOffCyclePayment.objects.select_for_update().get(pk=payment_id)
|
||
if payment.voided_at:
|
||
return payment
|
||
payment.voided_at = timezone.now()
|
||
payment.voided_by = actor
|
||
payment.void_reason = reason
|
||
payment.full_clean()
|
||
payment.save(update_fields=["voided_at", "voided_by", "void_reason"])
|
||
_audit(payment, actor, "payroll.off_cycle_payment_voided", reason=reason)
|
||
return payment
|