fix: create vacation pay finance drafts
This commit is contained in:
parent
c6f495fc3c
commit
e184796b86
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user