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