diff --git a/backend/api/v1/requests_app/request_serializers/vacation.py b/backend/api/v1/requests_app/request_serializers/vacation.py index 0777e7da..4d9edabe 100644 --- a/backend/api/v1/requests_app/request_serializers/vacation.py +++ b/backend/api/v1/requests_app/request_serializers/vacation.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from decimal import Decimal, ROUND_HALF_UP + from requests_app.models import RequestVacationTerms from ...employees.serializers import EmployeeBriefSerializer @@ -28,6 +30,11 @@ class RequestVacationTermsReadSerializer(serializers.ModelSerializer): instruction = getattr(obj, "finance_instruction", None) if instruction is None: return None + daily_rate = None + if instruction.amount is not None and instruction.paid_days: + daily_rate = (instruction.amount / Decimal(instruction.paid_days)).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) return { "id": instruction.id, "status": instruction.status, @@ -35,6 +42,7 @@ class RequestVacationTermsReadSerializer(serializers.ModelSerializer): "amount": str(instruction.amount) if instruction.amount is not None else None, + "daily_rate": str(daily_rate) if daily_rate is not None else None, "period_id": instruction.period_id, "payroll_input_line_id": instruction.payroll_input_line_id, } diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index a66aa19d..439a0b24 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -24,6 +24,7 @@ from finance.enums import ( InputSource, PayrollPeriodStatus, PayrollRunStatus, + VacationPayInstructionStatus, ) from finance.models import ( EmployeePayRate, @@ -37,6 +38,7 @@ from finance.models import ( PayrollStatementLine, PayrollWorkSettings, PayrollWorkRecord, + VacationPayInstruction, ) from .adapter import build_core_request, employee_snapshot @@ -116,6 +118,22 @@ def _operation_error(code: str, message: str, **details): raise PayrollOperationError(code, message, details=details) +def _mark_linked_vacation_instruction_posted(line: PayrollInputLine) -> None: + instruction = ( + VacationPayInstruction.objects.select_for_update() + .select_related("vacation_terms") + .filter(payroll_input_line=line) + .first() + ) + if instruction is None: + return + instruction.status = VacationPayInstructionStatus.POSTED + instruction.save(update_fields=["status", "updated_at"]) + terms = instruction.vacation_terms + terms.finance_status = "posted" + terms.save(update_fields=["finance_status", "updated_at"]) + + def _resolve_rates( period: PayrollPeriod, employee_ids: list[int], @@ -2110,12 +2128,13 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]: expected_lock_version=record.lock_version, ) for line in input_lines: - _approve_record( + approved_line = _approve_record( line, actor=actor, object_label="input_line", expected_lock_version=line.lock_version, ) + _mark_linked_vacation_instruction_posted(approved_line) summary = { "rates": len(rates), @@ -2213,18 +2232,18 @@ def approve_input_line( expected_lock_version: int, ) -> PayrollInputLine: _require_permission(actor, "finance.approve_payroll_inputs") - reference = PayrollInputLine.objects.only("period_id").get( - pk=line_id - ) + reference = PayrollInputLine.objects.only("period_id").get(pk=line_id) period = PayrollPeriod.objects.select_for_update().get(pk=reference.period_id) _ensure_period_accepts_approvals(period) record = PayrollInputLine.objects.select_for_update().get(pk=line_id) - return _approve_record( + approved_line = _approve_record( record, actor=actor, object_label="input_line", expected_lock_version=expected_lock_version, ) + _mark_linked_vacation_instruction_posted(approved_line) + return approved_line @transaction.atomic diff --git a/backend/requests_app/application/vacation.py b/backend/requests_app/application/vacation.py index a69da1f7..f47c743b 100644 --- a/backend/requests_app/application/vacation.py +++ b/backend/requests_app/application/vacation.py @@ -1,11 +1,27 @@ 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 VacationPayInstructionStatus -from finance.models import PayrollPeriod, VacationPayInstruction +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, @@ -14,6 +30,9 @@ from requests_app.enums import ( 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 @@ -44,6 +63,114 @@ def _matching_payroll_period(request_obj: Request): ) +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, *, @@ -75,8 +202,9 @@ def _ensure_instruction_is_mutable( ) -> None: if not existing_instruction: return + line = existing_instruction.payroll_input_line instruction_is_posted = bool( - existing_instruction.payroll_input_line_id + (line is not None and line.status == ApprovalStatus.APPROVED) or existing_instruction.status == VacationPayInstructionStatus.POSTED ) terms_are_changing = bool( @@ -131,7 +259,11 @@ def save_vacation_terms( instruction_is_posted = bool( existing_instruction and ( - existing_instruction.payroll_input_line_id + ( + existing_instruction.payroll_input_line_id + and existing_instruction.payroll_input_line.status + == ApprovalStatus.APPROVED + ) or existing_instruction.status == VacationPayInstructionStatus.POSTED ) ) @@ -159,12 +291,17 @@ def save_vacation_terms( ) 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": _matching_payroll_period(locked_request), + "period": period, "paid_days": paid_days, - "status": VacationPayInstructionStatus.PENDING_AMOUNT, + "amount": amount, + "status": VacationPayInstructionStatus.DRAFT, "created_by": ( existing_instruction.created_by if existing_instruction else actor ), @@ -175,8 +312,26 @@ def save_vacation_terms( except DjangoValidationError as exc: raise ValidationError(exc.message_dict) from exc instruction.save() - elif existing_instruction and not existing_instruction.payroll_input_line_id: + 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=["status", "updated_at"]) + existing_instruction.save( + update_fields=["payroll_input_line", "status", "updated_at"] + ) return terms diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index 489e9d57..460e7566 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import date, datetime, time +from decimal import Decimal from typing import Any import pytest @@ -13,8 +14,13 @@ from rest_framework.test import APIClient from requests_app.enums import RequestStatus, RequestType from requests_app.models import Request as EmployeeRequest from requests_app.models import RequestSettings, RequestVacationTerms -from finance.enums import VacationPayInstructionStatus -from finance.models import PayrollPeriod, VacationPayInstruction +from finance.enums import ApprovalStatus, VacationPayInstructionStatus +from finance.models import ( + EmployeePayRate, + PayrollInputLine, + PayrollPeriod, + VacationPayInstruction, +) from tasks.models import ( Task, TaskBoard, @@ -1242,6 +1248,17 @@ def test_approving_vacation_requests_terms_and_can_save_finance_instruction( date_from=date(2026, 8, 1), date_to=date(2026, 8, 31), ) + EmployeePayRate.objects.create( + employee=owner, + rate_code="BASE", + amount=Decimal("80000.0000"), + currency="RUB", + effective_from=date(2026, 7, 1), + status=ApprovalStatus.APPROVED, + created_by=owner, + approved_by=recipient, + approved_at=timezone.now(), + ) approve_response = auth_client(recipient).post( f"{API_BASE}{vacation.id}/approve/", @@ -1275,7 +1292,16 @@ def test_approving_vacation_requests_terms_and_can_save_finance_instruction( instruction = VacationPayInstruction.objects.get(vacation_terms=terms) assert instruction.period == payroll_period assert instruction.paid_days == 7 - assert instruction.status == VacationPayInstructionStatus.PENDING_AMOUNT + assert instruction.amount == Decimal("26666.64") + assert instruction.status == VacationPayInstructionStatus.DRAFT + line = PayrollInputLine.objects.get(pk=instruction.payroll_input_line_id) + assert line.period == payroll_period + assert line.employee == owner + assert line.component_code == "VACATION_PAY" + assert line.amount == Decimal("26666.64") + assert line.status == ApprovalStatus.DRAFT + assert line.source_ref == f"vacation-request:{vacation.pk}" + assert "7 дн. × 3809.52 ₽" in line.reason def test_unpaid_vacation_can_skip_finance_and_updates_exact_statistics( diff --git a/frontend/src/components/requests/RequestVacationTermsModal.tsx b/frontend/src/components/requests/RequestVacationTermsModal.tsx index b8e1c186..fe056310 100644 --- a/frontend/src/components/requests/RequestVacationTermsModal.tsx +++ b/frontend/src/components/requests/RequestVacationTermsModal.tsx @@ -95,6 +95,7 @@ export function RequestVacationTermsModal({ request, onClose, onSaved }: Props) if (!request) return null; const stats = context?.statistics; + const financeInstruction = context?.terms?.finance_instruction; const historicalVacationDays = (stats?.paid_vacation_days || 0) + (stats?.unpaid_vacation_days || 0); const employeeName = displayUserName(request.employee || request.created_by); @@ -243,7 +244,9 @@ export function RequestVacationTermsModal({ request, onClose, onSaved }: Props) Передать в финансы - Создать поручение на расчёт отпускных за {paidDays} дн. Сумму рассчитает финансовый модуль. + {financeInstruction?.amount + ? `Начисление ${financeInstruction.amount} ₽: ${paidDays} дн. × ${financeInstruction.daily_rate} ₽.` + : `Создать начисление отпускных за ${paidDays} дн. по дневной ставке сотрудника.`} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index a7119cf8..da2ad23c 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1275,6 +1275,7 @@ export interface VacationFinanceInstruction { status: "pending_amount" | "draft" | "posted" | "voided"; paid_days: number; amount: string | null; + daily_rate: string | null; period_id: number | null; payroll_input_line_id: number | null; }