2026-09-07 19:43:48 +03:00

415 lines
13 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.

from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import F
from rest_framework.exceptions import ValidationError
from finance.enums import (
ApprovalStatus,
InputSource,
VacationPayInstructionStatus,
)
from finance.models import (
EmployeePayRate,
PayrollInputLine,
PayrollPeriod,
PayrollStatement,
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")
VACATION_AVERAGE_PERIOD_LIMIT = 3
VACATION_AVERAGE_EXCLUDED_CODES = frozenset({"VACATION_PAY", "ONE_TIME_PAYMENT"})
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 _historical_average_daily_income(
request_obj: Request,
*,
currency: str,
) -> tuple[Decimal, int] | None:
"""Average eligible daily income over up to three available past periods."""
statements = list(
PayrollStatement.objects.filter(
employee_id=request_obj.employee_id,
currency=currency,
run__period__date_to__lt=request_obj.date_from,
run__period__current_run_id=F("run_id"),
)
.select_related("run__period")
.prefetch_related("lines")
.order_by("-run__period__date_to", "-run__period__date_from", "-id")[
:VACATION_AVERAGE_PERIOD_LIMIT
]
)
if not statements:
return None
schedule, _ = resolve_employee_schedule(request_obj.employee)
income_total = Decimal("0")
workday_total = 0
period_count = 0
for statement in statements:
workdays = count_period_workdays(statement.run.period, schedule)
excluded_income = sum(
(
line.amount
for line in statement.lines.all()
if line.code in VACATION_AVERAGE_EXCLUDED_CODES
),
Decimal("0"),
)
eligible_income = statement.gross_total - excluded_income
if workdays < 1 or eligible_income <= 0:
continue
income_total += eligible_income
workday_total += workdays
period_count += 1
if period_count == 0 or workday_total == 0:
return None
return (
(income_total / Decimal(workday_total)).quantize(
MONEY_QUANTUM,
rounding=ROUND_HALF_UP,
),
period_count,
)
def _calculate_vacation_pay(
request_obj: Request,
*,
paid_days: int,
) -> tuple[PayrollPeriod, Decimal, Decimal, int]:
period = _matching_payroll_period(request_obj)
if period is None:
raise ValidationError(
{
"record_to_finance": (
"Для даты начала отпуска не найден расчётный период."
)
}
)
historical_average = _historical_average_daily_income(
request_obj,
currency=period.currency,
)
average_period_count = 0
if historical_average is not None:
daily_rate, average_period_count = historical_average
else:
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, average_period_count
def _vacation_line_reason(
request_obj: Request,
*,
paid_days: int,
daily_rate: Decimal,
average_period_count: int,
) -> str:
basis = (
f"среднее за {average_period_count} расч. мес."
if average_period_count
else "текущая ставка; история отсутствует"
)
return (
f"Отпуск по заявлению #{request_obj.pk}: {paid_days} дн. × "
f"{daily_rate:.2f} ₽ ({basis})"
)
def _upsert_payroll_input_line(
*,
request_obj: Request,
instruction: VacationPayInstruction,
actor,
period: PayrollPeriod,
paid_days: int,
daily_rate: Decimal,
amount: Decimal,
average_period_count: int,
) -> 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,
average_period_count=average_period_count,
)
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, average_period_count = _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,
average_period_count=average_period_count,
)
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