338 lines
10 KiB
Python
338 lines
10 KiB
Python
from __future__ import annotations
|
||
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
|
||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||
from django.db import transaction
|
||
from rest_framework.exceptions import ValidationError
|
||
|
||
from finance.enums import (
|
||
ApprovalStatus,
|
||
InputSource,
|
||
VacationPayInstructionStatus,
|
||
)
|
||
from finance.models import (
|
||
EmployeePayRate,
|
||
PayrollInputLine,
|
||
PayrollPeriod,
|
||
VacationPayInstruction,
|
||
)
|
||
from finance.payroll.config import base_rate_code
|
||
from finance.payroll.work_norm import (
|
||
count_period_workdays,
|
||
resolve_employee_schedule,
|
||
)
|
||
from requests_app.enums import (
|
||
RequestStatus,
|
||
RequestType,
|
||
VacationFinanceStatus,
|
||
)
|
||
from requests_app.models import Request, RequestVacationTerms
|
||
|
||
|
||
MONEY_QUANTUM = Decimal("0.01")
|
||
|
||
|
||
def vacation_total_days(request_obj: Request) -> int:
|
||
if not (request_obj.date_from and request_obj.date_to):
|
||
return 0
|
||
return max((request_obj.date_to - request_obj.date_from).days + 1, 0)
|
||
|
||
|
||
def can_manage_vacation_terms(user, request_obj: Request) -> bool:
|
||
return bool(
|
||
getattr(user, "is_authenticated", False)
|
||
and (
|
||
request_obj.approver_id == user.id
|
||
or getattr(user, "is_superuser", False)
|
||
or user.has_perm("requests_app.can_process_requests")
|
||
)
|
||
)
|
||
|
||
|
||
def _matching_payroll_period(request_obj: Request):
|
||
if not request_obj.date_from:
|
||
return None
|
||
return (
|
||
PayrollPeriod.objects.filter(
|
||
date_from__lte=request_obj.date_from,
|
||
date_to__gte=request_obj.date_from,
|
||
)
|
||
.order_by("date_from", "id")
|
||
.first()
|
||
)
|
||
|
||
|
||
def _approved_rate_for_vacation(request_obj: Request) -> EmployeePayRate:
|
||
rate = (
|
||
EmployeePayRate.objects.filter(
|
||
employee_id=request_obj.employee_id,
|
||
rate_code=base_rate_code(),
|
||
status=ApprovalStatus.APPROVED,
|
||
effective_from__lte=request_obj.date_from,
|
||
)
|
||
.order_by("-effective_from", "-revision", "-id")
|
||
.first()
|
||
)
|
||
if rate is None:
|
||
raise ValidationError(
|
||
{
|
||
"record_to_finance": (
|
||
"Для сотрудника нет утверждённой ставки на дату начала отпуска."
|
||
)
|
||
}
|
||
)
|
||
return rate
|
||
|
||
|
||
def _calculate_vacation_pay(
|
||
request_obj: Request,
|
||
*,
|
||
paid_days: int,
|
||
) -> tuple[PayrollPeriod, Decimal, Decimal]:
|
||
period = _matching_payroll_period(request_obj)
|
||
if period is None:
|
||
raise ValidationError(
|
||
{
|
||
"record_to_finance": (
|
||
"Для даты начала отпуска не найден расчётный период."
|
||
)
|
||
}
|
||
)
|
||
rate = _approved_rate_for_vacation(request_obj)
|
||
if rate.currency != period.currency:
|
||
raise ValidationError(
|
||
{
|
||
"record_to_finance": (
|
||
"Валюта ставки сотрудника не совпадает с валютой расчётного периода."
|
||
)
|
||
}
|
||
)
|
||
schedule, _ = resolve_employee_schedule(request_obj.employee)
|
||
workdays = count_period_workdays(period, schedule)
|
||
if workdays < 1:
|
||
raise ValidationError(
|
||
{"record_to_finance": "В расчётном периоде нет плановых рабочих дней."}
|
||
)
|
||
daily_rate = (rate.amount / Decimal(workdays)).quantize(
|
||
MONEY_QUANTUM,
|
||
rounding=ROUND_HALF_UP,
|
||
)
|
||
amount = (daily_rate * paid_days).quantize(
|
||
MONEY_QUANTUM,
|
||
rounding=ROUND_HALF_UP,
|
||
)
|
||
return period, daily_rate, amount
|
||
|
||
|
||
def _vacation_line_reason(
|
||
request_obj: Request,
|
||
*,
|
||
paid_days: int,
|
||
daily_rate: Decimal,
|
||
) -> str:
|
||
return (
|
||
f"Отпуск по заявлению #{request_obj.pk}: {paid_days} дн. × {daily_rate:.2f} ₽"
|
||
)
|
||
|
||
|
||
def _upsert_payroll_input_line(
|
||
*,
|
||
request_obj: Request,
|
||
instruction: VacationPayInstruction,
|
||
actor,
|
||
period: PayrollPeriod,
|
||
paid_days: int,
|
||
daily_rate: Decimal,
|
||
amount: Decimal,
|
||
) -> PayrollInputLine:
|
||
line = instruction.payroll_input_line
|
||
if line is None:
|
||
line = PayrollInputLine(
|
||
created_by=actor,
|
||
status=ApprovalStatus.DRAFT,
|
||
source=InputSource.API,
|
||
source_ref=f"vacation-request:{request_obj.pk}",
|
||
)
|
||
line.period = period
|
||
line.employee = request_obj.employee
|
||
line.component_code = "VACATION_PAY"
|
||
line.amount = amount
|
||
line.reason = _vacation_line_reason(
|
||
request_obj,
|
||
paid_days=paid_days,
|
||
daily_rate=daily_rate,
|
||
)
|
||
try:
|
||
line.full_clean()
|
||
except DjangoValidationError as exc:
|
||
raise ValidationError(exc.message_dict) from exc
|
||
line.save()
|
||
return line
|
||
|
||
|
||
def _validate_vacation_decision(
|
||
request_obj: Request,
|
||
*,
|
||
paid_days: int,
|
||
record_to_finance: bool,
|
||
) -> None:
|
||
if request_obj.type != RequestType.VACATION:
|
||
raise ValidationError({"request": "Это заявление не является отпуском."})
|
||
if request_obj.status != RequestStatus.APPROVED:
|
||
raise ValidationError({"request": "Сначала одобрите заявление на отпуск."})
|
||
|
||
total_days = vacation_total_days(request_obj)
|
||
if paid_days < 0 or paid_days > total_days:
|
||
raise ValidationError(
|
||
{"paid_days": f"Укажите значение от 0 до {total_days} включительно."}
|
||
)
|
||
if record_to_finance and paid_days == 0:
|
||
raise ValidationError(
|
||
{"record_to_finance": "Неоплачиваемый отпуск нечего передавать в финансы."}
|
||
)
|
||
|
||
|
||
def _ensure_instruction_is_mutable(
|
||
existing_terms,
|
||
existing_instruction,
|
||
*,
|
||
paid_days,
|
||
record_to_finance,
|
||
) -> None:
|
||
if not existing_instruction:
|
||
return
|
||
line = existing_instruction.payroll_input_line
|
||
instruction_is_posted = bool(
|
||
(line is not None and line.status == ApprovalStatus.APPROVED)
|
||
or existing_instruction.status == VacationPayInstructionStatus.POSTED
|
||
)
|
||
terms_are_changing = bool(
|
||
existing_terms.paid_days != paid_days or not record_to_finance
|
||
)
|
||
if instruction_is_posted and terms_are_changing:
|
||
raise ValidationError(
|
||
{
|
||
"record_to_finance": (
|
||
"Начисление уже создано. Измените или сторнируйте его в финансах."
|
||
)
|
||
}
|
||
)
|
||
|
||
|
||
@transaction.atomic
|
||
def save_vacation_terms(
|
||
*,
|
||
request_obj: Request,
|
||
actor,
|
||
paid_days: int,
|
||
record_to_finance: bool,
|
||
) -> RequestVacationTerms:
|
||
"""Сохраняет решение по отпуску и идемпотентно обновляет поручение финансам."""
|
||
|
||
locked_request = Request.objects.select_for_update().get(pk=request_obj.pk)
|
||
_validate_vacation_decision(
|
||
locked_request,
|
||
paid_days=paid_days,
|
||
record_to_finance=record_to_finance,
|
||
)
|
||
|
||
existing_terms = (
|
||
RequestVacationTerms.objects.select_for_update()
|
||
.filter(request=locked_request)
|
||
.first()
|
||
)
|
||
existing_instruction = None
|
||
if existing_terms:
|
||
existing_instruction = (
|
||
VacationPayInstruction.objects.select_for_update()
|
||
.filter(vacation_terms=existing_terms)
|
||
.first()
|
||
)
|
||
_ensure_instruction_is_mutable(
|
||
existing_terms,
|
||
existing_instruction,
|
||
paid_days=paid_days,
|
||
record_to_finance=record_to_finance,
|
||
)
|
||
|
||
instruction_is_posted = bool(
|
||
existing_instruction
|
||
and (
|
||
(
|
||
existing_instruction.payroll_input_line_id
|
||
and existing_instruction.payroll_input_line.status
|
||
== ApprovalStatus.APPROVED
|
||
)
|
||
or existing_instruction.status == VacationPayInstructionStatus.POSTED
|
||
)
|
||
)
|
||
finance_status = VacationFinanceStatus.SKIPPED
|
||
if record_to_finance:
|
||
finance_status = (
|
||
VacationFinanceStatus.POSTED
|
||
if instruction_is_posted
|
||
else VacationFinanceStatus.PENDING
|
||
)
|
||
terms, _ = RequestVacationTerms.objects.update_or_create(
|
||
request=locked_request,
|
||
defaults={
|
||
"paid_days": paid_days,
|
||
"finance_status": finance_status,
|
||
"recorded_by": actor,
|
||
},
|
||
)
|
||
try:
|
||
terms.full_clean()
|
||
except DjangoValidationError as exc:
|
||
raise ValidationError(exc.message_dict) from exc
|
||
terms.save(
|
||
update_fields=["paid_days", "finance_status", "recorded_by", "updated_at"]
|
||
)
|
||
|
||
if record_to_finance and not instruction_is_posted:
|
||
period, daily_rate, amount = _calculate_vacation_pay(
|
||
locked_request,
|
||
paid_days=paid_days,
|
||
)
|
||
instruction, _ = VacationPayInstruction.objects.update_or_create(
|
||
vacation_terms=terms,
|
||
defaults={
|
||
"period": period,
|
||
"paid_days": paid_days,
|
||
"amount": amount,
|
||
"status": VacationPayInstructionStatus.DRAFT,
|
||
"created_by": (
|
||
existing_instruction.created_by if existing_instruction else actor
|
||
),
|
||
},
|
||
)
|
||
try:
|
||
instruction.full_clean()
|
||
except DjangoValidationError as exc:
|
||
raise ValidationError(exc.message_dict) from exc
|
||
instruction.save()
|
||
line = _upsert_payroll_input_line(
|
||
request_obj=locked_request,
|
||
instruction=instruction,
|
||
actor=actor,
|
||
period=period,
|
||
paid_days=paid_days,
|
||
daily_rate=daily_rate,
|
||
amount=amount,
|
||
)
|
||
instruction.payroll_input_line = line
|
||
instruction.save(
|
||
update_fields=["payroll_input_line", "status", "amount", "updated_at"]
|
||
)
|
||
elif existing_instruction and not instruction_is_posted:
|
||
if existing_instruction.payroll_input_line_id:
|
||
existing_instruction.payroll_input_line.delete()
|
||
existing_instruction.payroll_input_line = None
|
||
existing_instruction.status = VacationPayInstructionStatus.VOIDED
|
||
existing_instruction.save(
|
||
update_fields=["payroll_input_line", "status", "updated_at"]
|
||
)
|
||
|
||
return terms
|