415 lines
13 KiB
Python
415 lines
13 KiB
Python
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
|