fix: create vacation pay finance drafts

This commit is contained in:
lizerk 2026-08-26 13:09:03 +03:00
parent c6f495fc3c
commit e184796b86
6 changed files with 229 additions and 17 deletions

View File

@ -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,
}

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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)
<span className="min-w-0">
<span className="block text-sm font-medium">Передать в финансы</span>
<span className="app-text-muted mt-1 block text-xs leading-relaxed">
Создать поручение на расчёт отпускных за {paidDays} дн. Сумму рассчитает финансовый модуль.
{financeInstruction?.amount
? `Начисление ${financeInstruction.amount} ₽: ${paidDays} дн. × ${financeInstruction.daily_rate} ₽.`
: `Создать начисление отпускных за ${paidDays} дн. по дневной ставке сотрудника.`}
</span>
</span>
<Banknote size={17} className="app-text-muted ml-auto mt-1 shrink-0" />

View File

@ -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;
}