156 lines
5.8 KiB
Python
Raw 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.

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