feat: add vacation approval terms workflow
This commit is contained in:
parent
6276c35659
commit
c6f495fc3c
@ -20,9 +20,7 @@ class IsRecipientOfRequest(BasePermission):
|
||||
def has_permission(self, request: Request, view: Any) -> bool:
|
||||
return bool(getattr(request.user, "is_authenticated", False))
|
||||
|
||||
def has_object_permission(
|
||||
self, request: Request, view: Any, obj: Any
|
||||
) -> bool:
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
# Решение можно принимать только по заявке в статусе pending
|
||||
# и только прямому получателю (не cc_users).
|
||||
return (
|
||||
@ -39,9 +37,7 @@ class CanViewRequest(BasePermission):
|
||||
def has_permission(self, request: Request, view: Any) -> bool:
|
||||
return bool(getattr(request.user, "is_authenticated", False))
|
||||
|
||||
def has_object_permission(
|
||||
self, request: Request, view: Any, obj: Any
|
||||
) -> bool:
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
user = request.user
|
||||
|
||||
# Черновик - только авторское состояние.
|
||||
@ -71,9 +67,7 @@ class IsRequestAuthor(BasePermission):
|
||||
def has_permission(self, request: Request, view: Any) -> bool:
|
||||
return bool(getattr(request.user, "is_authenticated", False))
|
||||
|
||||
def has_object_permission(
|
||||
self, request: Request, view: Any, obj: Any
|
||||
) -> bool:
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
return getattr(obj, "employee_id", None) == request.user.id
|
||||
|
||||
|
||||
@ -91,22 +85,14 @@ class CommentsPermission(BasePermission):
|
||||
if getattr(obj, "employee_id", None) == user.id:
|
||||
return True
|
||||
# Получатель заявки
|
||||
if (
|
||||
hasattr(obj, "recipients")
|
||||
and obj.recipients.filter(id=user.id).exists()
|
||||
):
|
||||
if hasattr(obj, "recipients") and obj.recipients.filter(id=user.id).exists():
|
||||
return True
|
||||
# В копии
|
||||
if (
|
||||
hasattr(obj, "cc_users")
|
||||
and obj.cc_users.filter(id=user.id).exists()
|
||||
):
|
||||
if hasattr(obj, "cc_users") and obj.cc_users.filter(id=user.id).exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_object_permission(
|
||||
self, request: Request, view: Any, obj: Any
|
||||
) -> bool:
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
if getattr(obj, "status", None) == "draft":
|
||||
return False
|
||||
return self._is_participant(request.user, obj)
|
||||
@ -117,9 +103,19 @@ class NotFinalOrStaff(BasePermission):
|
||||
|
||||
message = "Финальная заявка не может быть удалена."
|
||||
|
||||
def has_object_permission(
|
||||
self, request: Request, view: Any, obj: Any
|
||||
) -> bool:
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
if request.method != "DELETE":
|
||||
return True
|
||||
return not bool(getattr(obj, "is_final", False))
|
||||
|
||||
|
||||
class CanManageVacationTerms(BasePermission):
|
||||
message = "У вас нет права изменять условия этого отпуска."
|
||||
|
||||
def has_permission(self, request: Request, view: Any) -> bool:
|
||||
return bool(getattr(request.user, "is_authenticated", False))
|
||||
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
from requests_app.application import can_manage_vacation_terms
|
||||
|
||||
return can_manage_vacation_terms(request.user, obj)
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
from .fields import RecipientIDsField
|
||||
from .read import RequestProfileSummarySerializer, RequestReadSerializer
|
||||
from .write import RequestWriteSerializer
|
||||
from .vacation import (
|
||||
RequestVacationTermsReadSerializer,
|
||||
RequestVacationTermsWriteSerializer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RecipientIDsField",
|
||||
"RequestProfileSummarySerializer",
|
||||
"RequestReadSerializer",
|
||||
"RequestWriteSerializer",
|
||||
"RequestVacationTermsReadSerializer",
|
||||
"RequestVacationTermsWriteSerializer",
|
||||
]
|
||||
|
||||
@ -7,9 +7,12 @@ from django.db import models
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from requests_app.application import can_manage_vacation_terms
|
||||
from requests_app.enums import RequestStatus, RequestType
|
||||
from requests_app.models import Request
|
||||
|
||||
from ...employees.serializers import EmployeeBriefSerializer
|
||||
from .vacation import RequestVacationTermsReadSerializer
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -45,6 +48,9 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
starts_at = serializers.DateTimeField(read_only=True)
|
||||
ends_at = serializers.DateTimeField(read_only=True)
|
||||
duration_minutes = serializers.IntegerField(read_only=True)
|
||||
vacation_terms = serializers.SerializerMethodField()
|
||||
vacation_terms_required = serializers.SerializerMethodField()
|
||||
can_manage_vacation_terms = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Request
|
||||
@ -82,6 +88,9 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
"can_decide",
|
||||
"comments_count",
|
||||
"linked_tasks",
|
||||
"vacation_terms",
|
||||
"vacation_terms_required",
|
||||
"can_manage_vacation_terms",
|
||||
)
|
||||
read_only_fields = (
|
||||
"employee",
|
||||
@ -101,6 +110,9 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
"can_decide",
|
||||
"comments_count",
|
||||
"linked_tasks",
|
||||
"vacation_terms",
|
||||
"vacation_terms_required",
|
||||
"can_manage_vacation_terms",
|
||||
)
|
||||
|
||||
@extend_schema_field(serializers.IntegerField())
|
||||
@ -168,6 +180,23 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
user=user,
|
||||
)
|
||||
|
||||
def get_vacation_terms(self, obj):
|
||||
terms = getattr(obj, "vacation_terms", None)
|
||||
if terms is None:
|
||||
return None
|
||||
return RequestVacationTermsReadSerializer(terms).data
|
||||
|
||||
def get_vacation_terms_required(self, obj):
|
||||
return bool(
|
||||
obj.type == RequestType.VACATION
|
||||
and obj.status == RequestStatus.APPROVED
|
||||
and getattr(obj, "vacation_terms", None) is None
|
||||
)
|
||||
|
||||
def get_can_manage_vacation_terms(self, obj):
|
||||
request = self.context.get("request")
|
||||
return can_manage_vacation_terms(getattr(request, "user", None), obj)
|
||||
|
||||
|
||||
class RequestProfileSummarySerializer(serializers.ModelSerializer):
|
||||
"""Компактное представление заявления для профиля сотрудника."""
|
||||
|
||||
45
backend/api/v1/requests_app/request_serializers/vacation.py
Normal file
45
backend/api/v1/requests_app/request_serializers/vacation.py
Normal file
@ -0,0 +1,45 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from requests_app.models import RequestVacationTerms
|
||||
|
||||
from ...employees.serializers import EmployeeBriefSerializer
|
||||
|
||||
|
||||
class RequestVacationTermsReadSerializer(serializers.ModelSerializer):
|
||||
recorded_by = EmployeeBriefSerializer(read_only=True)
|
||||
total_days = serializers.IntegerField(read_only=True)
|
||||
unpaid_days = serializers.IntegerField(read_only=True)
|
||||
finance_instruction = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RequestVacationTerms
|
||||
fields = (
|
||||
"paid_days",
|
||||
"unpaid_days",
|
||||
"total_days",
|
||||
"finance_status",
|
||||
"recorded_by",
|
||||
"recorded_at",
|
||||
"updated_at",
|
||||
"finance_instruction",
|
||||
)
|
||||
|
||||
def get_finance_instruction(self, obj):
|
||||
instruction = getattr(obj, "finance_instruction", None)
|
||||
if instruction is None:
|
||||
return None
|
||||
return {
|
||||
"id": instruction.id,
|
||||
"status": instruction.status,
|
||||
"paid_days": instruction.paid_days,
|
||||
"amount": str(instruction.amount)
|
||||
if instruction.amount is not None
|
||||
else None,
|
||||
"period_id": instruction.period_id,
|
||||
"payroll_input_line_id": instruction.payroll_input_line_id,
|
||||
}
|
||||
|
||||
|
||||
class RequestVacationTermsWriteSerializer(serializers.Serializer):
|
||||
paid_days = serializers.IntegerField(min_value=0)
|
||||
record_to_finance = serializers.BooleanField()
|
||||
@ -9,6 +9,7 @@ from .viewsets import (
|
||||
RequestNotificationsMixin,
|
||||
RequestStatisticsMixin,
|
||||
RequestWorkflowMixin,
|
||||
RequestVacationMixin,
|
||||
)
|
||||
|
||||
|
||||
@ -16,6 +17,7 @@ class RequestViewSet(
|
||||
RequestCoreMixin,
|
||||
RequestCommentsMixin,
|
||||
RequestWorkflowMixin,
|
||||
RequestVacationMixin,
|
||||
RequestStatisticsMixin,
|
||||
RequestNotificationsMixin,
|
||||
viewsets.ModelViewSet,
|
||||
@ -39,5 +41,7 @@ class RequestViewSet(
|
||||
"employee",
|
||||
"approver",
|
||||
"department",
|
||||
"vacation_terms__recorded_by",
|
||||
"vacation_terms__finance_instruction",
|
||||
).order_by("-created_at")
|
||||
parser_classes = (MultiPartParser, FormParser, JSONParser)
|
||||
|
||||
@ -3,6 +3,7 @@ from .core import RequestCoreMixin
|
||||
from .notifications import RequestNotificationsMixin
|
||||
from .statistics import RequestStatisticsMixin
|
||||
from .workflow import RequestWorkflowMixin
|
||||
from .vacation import RequestVacationMixin
|
||||
|
||||
__all__ = [
|
||||
"RequestCommentsMixin",
|
||||
@ -10,4 +11,5 @@ __all__ = [
|
||||
"RequestNotificationsMixin",
|
||||
"RequestStatisticsMixin",
|
||||
"RequestWorkflowMixin",
|
||||
"RequestVacationMixin",
|
||||
]
|
||||
|
||||
@ -17,6 +17,7 @@ from requests_app.models import Request as EmployeeRequest
|
||||
from requests_app.models import RequestSettings
|
||||
|
||||
from ..permissions import (
|
||||
CanManageVacationTerms,
|
||||
CanViewRequest,
|
||||
CommentsPermission,
|
||||
IsRecipientOfRequest,
|
||||
@ -42,6 +43,7 @@ class RequestCoreMixin:
|
||||
"retrieve": CanViewRequest,
|
||||
"employee_statistics": CanViewRequest,
|
||||
"mark_notifications_read": CanViewRequest,
|
||||
"vacation_terms": CanManageVacationTerms,
|
||||
}
|
||||
if self.action == "destroy":
|
||||
return [IsRequestAuthor(), NotFinalOrStaff()]
|
||||
@ -219,12 +221,17 @@ class RequestCoreMixin:
|
||||
"retrieve",
|
||||
"employee_statistics",
|
||||
"mark_notifications_read",
|
||||
"vacation_terms",
|
||||
}
|
||||
if getattr(self, "action", None) in detail_actions:
|
||||
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
||||
lookup_value = self.kwargs[lookup_url_kwarg]
|
||||
queryset = EmployeeRequest.objects.select_related(
|
||||
"employee", "approver", "department"
|
||||
"employee",
|
||||
"approver",
|
||||
"department",
|
||||
"vacation_terms__recorded_by",
|
||||
"vacation_terms__finance_instruction",
|
||||
)
|
||||
obj = get_object_or_404(
|
||||
queryset,
|
||||
|
||||
@ -19,9 +19,9 @@ from ..serializers import RequestProfileSummarySerializer
|
||||
|
||||
def _validated_period(request):
|
||||
period = (request.query_params.get("period") or "all").strip().lower()
|
||||
if period not in {"all", "year", "month", "custom"}:
|
||||
if period not in {"all", "half_year", "year", "month", "custom"}:
|
||||
raise ValidationError(
|
||||
"Параметр period должен быть one of: all, year, month, custom."
|
||||
"Параметр period должен быть one of: all, half_year, year, month, custom."
|
||||
)
|
||||
return period
|
||||
|
||||
@ -48,7 +48,9 @@ class RequestStatisticsMixin:
|
||||
|
||||
page_raw = (request.query_params.get("page") or "1").strip()
|
||||
if not page_raw.isdigit() or int(page_raw) < 1:
|
||||
raise ValidationError("Параметр page должен быть положительным целым числом.")
|
||||
raise ValidationError(
|
||||
"Параметр page должен быть положительным целым числом."
|
||||
)
|
||||
page = int(page_raw)
|
||||
|
||||
period = _validated_period(request)
|
||||
|
||||
92
backend/api/v1/requests_app/viewsets/vacation.py
Normal file
92
backend/api/v1/requests_app/viewsets/vacation.py
Normal file
@ -0,0 +1,92 @@
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from requests_app.application import (
|
||||
build_employee_statistics,
|
||||
resolve_stats_window_from_query,
|
||||
save_vacation_terms,
|
||||
vacation_total_days,
|
||||
)
|
||||
from requests_app.enums import RequestStatus, RequestType
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
|
||||
from ..serializers import (
|
||||
RequestReadSerializer,
|
||||
RequestVacationTermsReadSerializer,
|
||||
RequestVacationTermsWriteSerializer,
|
||||
)
|
||||
|
||||
|
||||
VACATION_STATS_PERIODS = {"half_year", "year", "month", "all", "custom"}
|
||||
|
||||
|
||||
class RequestVacationMixin:
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get", "put", "patch"],
|
||||
url_path="vacation-terms",
|
||||
)
|
||||
def vacation_terms(self, request, pk=None):
|
||||
request_obj = self.get_object()
|
||||
if request_obj.type != RequestType.VACATION:
|
||||
raise ValidationError("Условия оплаты доступны только для отпуска.")
|
||||
if request_obj.status != RequestStatus.APPROVED:
|
||||
raise ValidationError("Сначала одобрите заявление на отпуск.")
|
||||
|
||||
if request.method == "GET":
|
||||
return Response(self._vacation_terms_context(request_obj, request))
|
||||
|
||||
serializer = RequestVacationTermsWriteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
save_vacation_terms(
|
||||
request_obj=request_obj,
|
||||
actor=request.user,
|
||||
paid_days=serializer.validated_data["paid_days"],
|
||||
record_to_finance=serializer.validated_data["record_to_finance"],
|
||||
)
|
||||
refreshed = EmployeeRequest.objects.select_related(
|
||||
"employee",
|
||||
"approver",
|
||||
"department",
|
||||
"vacation_terms__recorded_by",
|
||||
"vacation_terms__finance_instruction",
|
||||
).get(pk=request_obj.pk)
|
||||
return Response(
|
||||
{
|
||||
"request": RequestReadSerializer(
|
||||
refreshed,
|
||||
context=self.get_serializer_context(),
|
||||
).data,
|
||||
**self._vacation_terms_context(refreshed, request),
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _vacation_terms_context(request_obj, request):
|
||||
period = (request.query_params.get("period") or "half_year").strip().lower()
|
||||
if period not in VACATION_STATS_PERIODS:
|
||||
raise ValidationError(
|
||||
"Параметр period должен быть one of: half_year, year, month, all, custom."
|
||||
)
|
||||
start_date, end_date = resolve_stats_window_from_query(
|
||||
period,
|
||||
request.query_params,
|
||||
)
|
||||
terms = getattr(request_obj, "vacation_terms", None)
|
||||
return {
|
||||
"request_id": request_obj.id,
|
||||
"total_days": vacation_total_days(request_obj),
|
||||
"terms": (
|
||||
RequestVacationTermsReadSerializer(terms).data
|
||||
if terms is not None
|
||||
else None
|
||||
),
|
||||
"statistics": build_employee_statistics(
|
||||
employee_id=request_obj.employee_id,
|
||||
employee_name=str(request_obj.employee),
|
||||
period=period,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
),
|
||||
}
|
||||
@ -22,6 +22,7 @@ from .models import (
|
||||
PayrollStatementLine,
|
||||
PayrollWorkSettings,
|
||||
PayrollWorkRecord,
|
||||
VacationPayInstruction,
|
||||
)
|
||||
from .payroll.access import has_payroll_permission, has_simple_admin_access
|
||||
from .payroll.exceptions import PayrollOperationError
|
||||
@ -408,9 +409,11 @@ class EmployeePayRateAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin):
|
||||
@transaction.atomic
|
||||
def _delete_revision_chains(self, records):
|
||||
chain_ids = self._revision_chain_ids(records)
|
||||
revisions = EmployeePayRate.objects.select_for_update().filter(
|
||||
pk__in=chain_ids
|
||||
).order_by("-revision", "-pk")
|
||||
revisions = (
|
||||
EmployeePayRate.objects.select_for_update()
|
||||
.filter(pk__in=chain_ids)
|
||||
.order_by("-revision", "-pk")
|
||||
)
|
||||
for revision in revisions:
|
||||
revision.delete()
|
||||
|
||||
@ -602,6 +605,31 @@ class PayrollInputLineAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
@admin.register(VacationPayInstruction)
|
||||
class VacationPayInstructionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"vacation_terms",
|
||||
"paid_days",
|
||||
"period",
|
||||
"amount",
|
||||
"status",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("status", "period")
|
||||
search_fields = (
|
||||
"vacation_terms__request__employee__last_name",
|
||||
"vacation_terms__request__employee__first_name",
|
||||
"vacation_terms__request__employee__email",
|
||||
)
|
||||
autocomplete_fields = (
|
||||
"vacation_terms",
|
||||
"period",
|
||||
"payroll_input_line",
|
||||
"created_by",
|
||||
)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
class PayrollStatementLineInline(admin.TabularInline):
|
||||
model = PayrollStatementLine
|
||||
extra = 0
|
||||
|
||||
@ -16,6 +16,13 @@ class InputSource(models.TextChoices):
|
||||
ATTENDANCE = "attendance", "Посещаемость"
|
||||
|
||||
|
||||
class VacationPayInstructionStatus(models.TextChoices):
|
||||
PENDING_AMOUNT = "pending_amount", "Ожидает расчёта суммы"
|
||||
DRAFT = "draft", "Черновик начисления"
|
||||
POSTED = "posted", "Передано в начисления"
|
||||
VOIDED = "voided", "Отменено"
|
||||
|
||||
|
||||
class PayrollComponentKind(models.TextChoices):
|
||||
EARNING = "earning", "Начисление"
|
||||
ADJUSTMENT_CREDIT = "adjustment_credit", "Коррекция +"
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-26 09:39
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0025_payroll_components_in_code'),
|
||||
('requests_app', '0016_requestvacationterms'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VacationPayInstruction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('paid_days', models.PositiveSmallIntegerField(verbose_name='Оплачиваемые дни')),
|
||||
('amount', models.DecimalField(blank=True, decimal_places=2, max_digits=19, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='Сумма отпускных')),
|
||||
('status', models.CharField(choices=[('pending_amount', 'Ожидает расчёта суммы'), ('draft', 'Черновик начисления'), ('posted', 'Передано в начисления'), ('voided', 'Отменено')], default='pending_amount', max_length=20, verbose_name='Статус')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_vacation_pay_instructions', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
|
||||
('payroll_input_line', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacation_pay_instruction', to='finance.payrollinputline', verbose_name='Строка начисления')),
|
||||
('period', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacation_pay_instructions', to='finance.payrollperiod', verbose_name='Расчётный период')),
|
||||
('vacation_terms', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='finance_instruction', to='requests_app.requestvacationterms', verbose_name='Условия отпуска')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Поручение на отпускные',
|
||||
'verbose_name_plural': 'Поручения на отпускные',
|
||||
'ordering': ['-created_at', '-id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -23,6 +23,7 @@ from .enums import (
|
||||
PayrollComponentKind,
|
||||
PayrollPeriodStatus,
|
||||
PayrollRunStatus,
|
||||
VacationPayInstructionStatus,
|
||||
)
|
||||
|
||||
currency_validator = RegexValidator(
|
||||
@ -1112,6 +1113,70 @@ class PayrollInputLine(DraftVersionedModel):
|
||||
)
|
||||
|
||||
|
||||
class VacationPayInstruction(models.Model):
|
||||
"""Поручение финансам рассчитать отпускные по согласованным условиям."""
|
||||
|
||||
vacation_terms = models.OneToOneField(
|
||||
"requests_app.RequestVacationTerms",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="finance_instruction",
|
||||
verbose_name="Условия отпуска",
|
||||
)
|
||||
period = models.ForeignKey(
|
||||
PayrollPeriod,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="vacation_pay_instructions",
|
||||
verbose_name="Расчётный период",
|
||||
)
|
||||
paid_days = models.PositiveSmallIntegerField("Оплачиваемые дни")
|
||||
amount = models.DecimalField(
|
||||
"Сумма отпускных",
|
||||
max_digits=19,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal("0.01"))],
|
||||
)
|
||||
status = models.CharField(
|
||||
"Статус",
|
||||
max_length=20,
|
||||
choices=VacationPayInstructionStatus.choices,
|
||||
default=VacationPayInstructionStatus.PENDING_AMOUNT,
|
||||
)
|
||||
payroll_input_line = models.OneToOneField(
|
||||
PayrollInputLine,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="vacation_pay_instruction",
|
||||
verbose_name="Строка начисления",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="created_vacation_pay_instructions",
|
||||
verbose_name="Создал",
|
||||
)
|
||||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Поручение на отпускные"
|
||||
verbose_name_plural = "Поручения на отпускные"
|
||||
ordering = ["-created_at", "-id"]
|
||||
|
||||
def clean(self):
|
||||
if self.paid_days < 1:
|
||||
raise ValidationError(
|
||||
{"paid_days": "В финансы можно передать только оплачиваемый отпуск."}
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"Отпускные по заявлению #{self.vacation_terms.request_id}"
|
||||
|
||||
|
||||
class PayrollInputCopyConfiguration(models.Model):
|
||||
"""Per-operator defaults for copying input lines between payroll periods."""
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .enums import RequestStatus
|
||||
from .models import Request, RequestSettings
|
||||
from .models import Request, RequestSettings, RequestVacationTerms
|
||||
|
||||
|
||||
@admin.register(RequestSettings)
|
||||
@ -31,6 +31,25 @@ class RequestSettingsAdmin(admin.ModelAdmin):
|
||||
return count or "Все сотрудники"
|
||||
|
||||
|
||||
@admin.register(RequestVacationTerms)
|
||||
class RequestVacationTermsAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"request",
|
||||
"paid_days",
|
||||
"finance_status",
|
||||
"recorded_by",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("finance_status",)
|
||||
search_fields = (
|
||||
"request__employee__last_name",
|
||||
"request__employee__first_name",
|
||||
"request__employee__email",
|
||||
)
|
||||
autocomplete_fields = ("request", "recorded_by")
|
||||
readonly_fields = ("recorded_at", "updated_at")
|
||||
|
||||
|
||||
# Админ-форма: без принуждения вводить approver (мы выставим его автоматически)
|
||||
class RequestAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
|
||||
@ -3,9 +3,17 @@ from .statistics import (
|
||||
can_view_request_statistics,
|
||||
resolve_stats_window_from_query,
|
||||
)
|
||||
from .vacation import (
|
||||
can_manage_vacation_terms,
|
||||
save_vacation_terms,
|
||||
vacation_total_days,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_employee_statistics",
|
||||
"can_manage_vacation_terms",
|
||||
"can_view_request_statistics",
|
||||
"resolve_stats_window_from_query",
|
||||
"save_vacation_terms",
|
||||
"vacation_total_days",
|
||||
]
|
||||
|
||||
@ -28,6 +28,11 @@ def can_view_request_statistics(user) -> bool:
|
||||
|
||||
|
||||
def resolve_stats_window_from_query(period, query_params):
|
||||
if period == "half_year":
|
||||
today = timezone.localdate()
|
||||
month_index = today.year * 12 + today.month - 1 - 5
|
||||
start_year, start_month_index = divmod(month_index, 12)
|
||||
return date(start_year, start_month_index + 1, 1), today
|
||||
if period == "month":
|
||||
today = timezone.localdate()
|
||||
_, last_day = calendar.monthrange(today.year, today.month)
|
||||
@ -73,12 +78,11 @@ def _duration_days_in_window(request_obj, start_date, end_date):
|
||||
if request_obj.date_from and request_obj.date_to:
|
||||
overlap_start = max(request_obj.date_from, start_date)
|
||||
overlap_end = min(request_obj.date_to, end_date)
|
||||
return 0 if overlap_start > overlap_end else (overlap_end - overlap_start).days + 1
|
||||
return int(
|
||||
bool(
|
||||
request_obj.date_from
|
||||
and start_date <= request_obj.date_from <= end_date
|
||||
return (
|
||||
0 if overlap_start > overlap_end else (overlap_end - overlap_start).days + 1
|
||||
)
|
||||
return int(
|
||||
bool(request_obj.date_from and start_date <= request_obj.date_from <= end_date)
|
||||
)
|
||||
|
||||
|
||||
@ -92,6 +96,13 @@ def _is_unpaid_vacation(request_obj):
|
||||
return any(marker in text for marker in UNPAID_VACATION_MARKERS)
|
||||
|
||||
|
||||
def _vacation_paid_days_in_window(request_obj, duration):
|
||||
terms = getattr(request_obj, "vacation_terms", None)
|
||||
if terms is None:
|
||||
return 0 if _is_unpaid_vacation(request_obj) else duration
|
||||
return min(terms.paid_days, duration)
|
||||
|
||||
|
||||
def build_employee_statistics(
|
||||
*,
|
||||
employee_id,
|
||||
@ -117,30 +128,42 @@ def build_employee_statistics(
|
||||
"paid_vacation_days": 0,
|
||||
"unpaid_vacation_days": 0,
|
||||
}
|
||||
approved_requests = EmployeeRequest.objects.filter(
|
||||
employee_id=employee_id,
|
||||
status=RequestStatus.APPROVED,
|
||||
).only("type", "date_from", "date_to", "time_from", "time_to", "title", "comment")
|
||||
approved_requests = (
|
||||
EmployeeRequest.objects.filter(
|
||||
employee_id=employee_id,
|
||||
status=RequestStatus.APPROVED,
|
||||
)
|
||||
.select_related("vacation_terms")
|
||||
.only(
|
||||
"type",
|
||||
"date_from",
|
||||
"date_to",
|
||||
"time_from",
|
||||
"time_to",
|
||||
"title",
|
||||
"comment",
|
||||
"vacation_terms__paid_days",
|
||||
)
|
||||
)
|
||||
for request_obj in approved_requests:
|
||||
duration = _duration_days_in_window(request_obj, start_date, end_date)
|
||||
if request_obj.type == RequestType.SICK_LEAVE:
|
||||
totals["sick_leave_days"] += duration
|
||||
elif request_obj.type == RequestType.SHORT_DAY_OFF:
|
||||
if request_obj.date_from and (
|
||||
start_date is None or start_date <= request_obj.date_from
|
||||
) and (end_date is None or request_obj.date_from <= end_date):
|
||||
if (
|
||||
request_obj.date_from
|
||||
and (start_date is None or start_date <= request_obj.date_from)
|
||||
and (end_date is None or request_obj.date_from <= end_date)
|
||||
):
|
||||
totals["short_day_off_minutes"] += request_obj.duration_minutes or 0
|
||||
elif request_obj.type == RequestType.DAY_OFF:
|
||||
totals["day_off_days"] += duration
|
||||
elif request_obj.type == RequestType.MATERNITY:
|
||||
totals["maternity_days"] += duration
|
||||
elif request_obj.type == RequestType.VACATION:
|
||||
key = (
|
||||
"unpaid_vacation_days"
|
||||
if _is_unpaid_vacation(request_obj)
|
||||
else "paid_vacation_days"
|
||||
)
|
||||
totals[key] += duration
|
||||
paid_days = _vacation_paid_days_in_window(request_obj, duration)
|
||||
totals["paid_vacation_days"] += paid_days
|
||||
totals["unpaid_vacation_days"] += max(duration - paid_days, 0)
|
||||
|
||||
return {
|
||||
"employee_id": employee_id,
|
||||
@ -149,9 +172,17 @@ def build_employee_statistics(
|
||||
"date_from": start_date.isoformat() if start_date else None,
|
||||
"date_to": end_date.isoformat() if end_date else None,
|
||||
"total_submitted_requests": base_queryset.count(),
|
||||
"sick_leave_requests_count": base_queryset.filter(type=RequestType.SICK_LEAVE).count(),
|
||||
"day_off_requests_count": base_queryset.filter(type__in=DAY_OFF_REQUEST_TYPES).count(),
|
||||
"short_day_off_requests_count": base_queryset.filter(type=RequestType.SHORT_DAY_OFF).count(),
|
||||
"maternity_requests_count": base_queryset.filter(type=RequestType.MATERNITY).count(),
|
||||
"sick_leave_requests_count": base_queryset.filter(
|
||||
type=RequestType.SICK_LEAVE
|
||||
).count(),
|
||||
"day_off_requests_count": base_queryset.filter(
|
||||
type__in=DAY_OFF_REQUEST_TYPES
|
||||
).count(),
|
||||
"short_day_off_requests_count": base_queryset.filter(
|
||||
type=RequestType.SHORT_DAY_OFF
|
||||
).count(),
|
||||
"maternity_requests_count": base_queryset.filter(
|
||||
type=RequestType.MATERNITY
|
||||
).count(),
|
||||
**totals,
|
||||
}
|
||||
|
||||
182
backend/requests_app/application/vacation.py
Normal file
182
backend/requests_app/application/vacation.py
Normal file
@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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 requests_app.enums import (
|
||||
RequestStatus,
|
||||
RequestType,
|
||||
VacationFinanceStatus,
|
||||
)
|
||||
from requests_app.models import Request, RequestVacationTerms
|
||||
|
||||
|
||||
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 _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
|
||||
instruction_is_posted = bool(
|
||||
existing_instruction.payroll_input_line_id
|
||||
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
|
||||
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:
|
||||
instruction, _ = VacationPayInstruction.objects.update_or_create(
|
||||
vacation_terms=terms,
|
||||
defaults={
|
||||
"period": _matching_payroll_period(locked_request),
|
||||
"paid_days": paid_days,
|
||||
"status": VacationPayInstructionStatus.PENDING_AMOUNT,
|
||||
"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()
|
||||
elif existing_instruction and not existing_instruction.payroll_input_line_id:
|
||||
existing_instruction.status = VacationPayInstructionStatus.VOIDED
|
||||
existing_instruction.save(update_fields=["status", "updated_at"])
|
||||
|
||||
return terms
|
||||
@ -16,9 +16,7 @@ class RequestType(TextChoices):
|
||||
OTHER = "other", "Другое"
|
||||
|
||||
|
||||
DAY_OFF_REQUEST_TYPES = frozenset(
|
||||
{RequestType.DAY_OFF, RequestType.SHORT_DAY_OFF}
|
||||
)
|
||||
DAY_OFF_REQUEST_TYPES = frozenset({RequestType.DAY_OFF, RequestType.SHORT_DAY_OFF})
|
||||
|
||||
|
||||
class RequestStatus(TextChoices):
|
||||
@ -31,6 +29,15 @@ class RequestStatus(TextChoices):
|
||||
CANCELLED = "cancelled", "Отменено"
|
||||
|
||||
|
||||
class VacationFinanceStatus(TextChoices):
|
||||
"""Состояние передачи согласованных условий отпуска в финансы."""
|
||||
|
||||
NOT_DECIDED = "not_decided", "Не решено"
|
||||
SKIPPED = "skipped", "Не передавать"
|
||||
PENDING = "pending", "Ожидает расчёта"
|
||||
POSTED = "posted", "Передано в начисления"
|
||||
|
||||
|
||||
FINAL_STATUS = {
|
||||
RequestStatus.APPROVED,
|
||||
RequestStatus.REJECTED,
|
||||
|
||||
32
backend/requests_app/migrations/0016_requestvacationterms.py
Normal file
32
backend/requests_app/migrations/0016_requestvacationterms.py
Normal file
@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-26 09:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('requests_app', '0015_add_request_recipient_settings'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RequestVacationTerms',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('paid_days', models.PositiveSmallIntegerField(default=0, verbose_name='Оплачиваемые дни')),
|
||||
('finance_status', models.CharField(choices=[('not_decided', 'Не решено'), ('skipped', 'Не передавать'), ('pending', 'Ожидает расчёта'), ('posted', 'Передано в начисления')], default='not_decided', max_length=16, verbose_name='Передача в финансы')),
|
||||
('recorded_at', models.DateTimeField(auto_now_add=True, verbose_name='Зафиксировано')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||
('recorded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recorded_vacation_terms', to=settings.AUTH_USER_MODEL, verbose_name='Условия зафиксировал')),
|
||||
('request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='vacation_terms', to='requests_app.request', verbose_name='Заявление')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Условия отпуска',
|
||||
'verbose_name_plural': 'Условия отпусков',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -13,7 +13,7 @@ from .constants import (
|
||||
SAFE_EXTENSIONS,
|
||||
SAFE_MIME_TYPES,
|
||||
)
|
||||
from .enums import FINAL_STATUS, RequestStatus, RequestType
|
||||
from .enums import FINAL_STATUS, RequestStatus, RequestType, VacationFinanceStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -404,3 +404,62 @@ class Request(models.Model):
|
||||
return (
|
||||
f"{self.get_type_display()} — {self.employee} ({self.get_status_display()})"
|
||||
)
|
||||
|
||||
|
||||
class RequestVacationTerms(models.Model):
|
||||
"""Согласованные условия оплаты отпуска по заявлению."""
|
||||
|
||||
request = models.OneToOneField(
|
||||
Request,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="vacation_terms",
|
||||
verbose_name="Заявление",
|
||||
)
|
||||
paid_days = models.PositiveSmallIntegerField(
|
||||
"Оплачиваемые дни",
|
||||
default=0,
|
||||
)
|
||||
finance_status = models.CharField(
|
||||
"Передача в финансы",
|
||||
max_length=16,
|
||||
choices=VacationFinanceStatus.choices,
|
||||
default=VacationFinanceStatus.NOT_DECIDED,
|
||||
)
|
||||
recorded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="recorded_vacation_terms",
|
||||
verbose_name="Условия зафиксировал",
|
||||
)
|
||||
recorded_at = models.DateTimeField("Зафиксировано", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Условия отпуска"
|
||||
verbose_name_plural = "Условия отпусков"
|
||||
|
||||
@property
|
||||
def total_days(self) -> int:
|
||||
if not (self.request.date_from and self.request.date_to):
|
||||
return 0
|
||||
return max((self.request.date_to - self.request.date_from).days + 1, 0)
|
||||
|
||||
@property
|
||||
def unpaid_days(self) -> int:
|
||||
return max(self.total_days - self.paid_days, 0)
|
||||
|
||||
def clean(self):
|
||||
errors = {}
|
||||
if self.request.type != RequestType.VACATION:
|
||||
errors["request"] = "Условия оплаты доступны только для отпуска."
|
||||
if self.request.status != RequestStatus.APPROVED:
|
||||
errors["request"] = "Сначала одобрите заявление на отпуск."
|
||||
if self.paid_days > self.total_days:
|
||||
errors["paid_days"] = (
|
||||
"Оплачиваемых дней не может быть больше длительности отпуска."
|
||||
)
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
def __str__(self):
|
||||
return f"Заявление #{self.request_id}: {self.paid_days} оплачиваемых дней"
|
||||
|
||||
@ -12,7 +12,9 @@ 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
|
||||
from requests_app.models import RequestSettings, RequestVacationTerms
|
||||
from finance.enums import VacationPayInstructionStatus
|
||||
from finance.models import PayrollPeriod, VacationPayInstruction
|
||||
from tasks.models import (
|
||||
Task,
|
||||
TaskBoard,
|
||||
@ -224,8 +226,7 @@ def test_employee_profile_paginates_requests(
|
||||
assert second_payload["has_next"] is False
|
||||
assert len(second_payload["results"]) == 1
|
||||
result_ids = {
|
||||
item["id"]
|
||||
for item in first_payload["results"] + second_payload["results"]
|
||||
item["id"] for item in first_payload["results"] + second_payload["results"]
|
||||
}
|
||||
assert result_ids == {request.id for request in created}
|
||||
|
||||
@ -294,9 +295,7 @@ def test_statistics_custom_period_filters_request_counts_and_absence_days(
|
||||
)
|
||||
matching.date_from = datetime(2026, 4, 5).date()
|
||||
matching.date_to = datetime(2026, 4, 12).date()
|
||||
matching.save(
|
||||
update_fields=["created_at", "date_from", "date_to", "comment"]
|
||||
)
|
||||
matching.save(update_fields=["created_at", "date_from", "date_to", "comment"])
|
||||
|
||||
outside_count = make_request(
|
||||
employee=employee,
|
||||
@ -356,9 +355,7 @@ def test_list_only_participants_see_requests(
|
||||
employee=foreign_user, type_=RequestType.SICK_LEAVE
|
||||
)
|
||||
cc_request = make_request(employee=foreign_user, type_=RequestType.DAY_OFF)
|
||||
hidden_request = make_request(
|
||||
employee=foreign_user, type_=RequestType.OTHER
|
||||
)
|
||||
hidden_request = make_request(employee=foreign_user, type_=RequestType.OTHER)
|
||||
|
||||
recipient_request.recipients.add(regular_user, recipient)
|
||||
cc_request.cc_users.add(regular_user, cc_user)
|
||||
@ -429,9 +426,7 @@ def test_list_returns_scope_counts_and_filters_decisions(
|
||||
|
||||
decision_response = client.get(f"{API_BASE}?decision_required=true")
|
||||
assert decision_response.status_code == 200
|
||||
decision_ids = {
|
||||
item["id"] for item in _results(decision_response.json())
|
||||
}
|
||||
decision_ids = {item["id"] for item in _results(decision_response.json())}
|
||||
assert decision_ids == {decision_request.id}
|
||||
assert own_request.id not in decision_ids
|
||||
assert copied_request.id not in decision_ids
|
||||
@ -733,9 +728,7 @@ def test_empty_recipient_settings_allow_all_active_employees(
|
||||
recipient = make_user(email="request-open-recipient@example.com")
|
||||
RequestSettings.get_solo()
|
||||
|
||||
options_resp = auth_client(regular_user).get(
|
||||
f"{API_BASE}recipient-options/"
|
||||
)
|
||||
options_resp = auth_client(regular_user).get(f"{API_BASE}recipient-options/")
|
||||
create_resp = auth_client(regular_user).post(
|
||||
API_BASE,
|
||||
data={
|
||||
@ -760,9 +753,7 @@ def test_hidden_employee_is_absent_from_request_recipients_and_cc(
|
||||
hidden.save(update_fields=["is_hidden_in_ui"])
|
||||
RequestSettings.get_solo()
|
||||
|
||||
options_response = auth_client(regular_user).get(
|
||||
f"{API_BASE}recipient-options/"
|
||||
)
|
||||
options_response = auth_client(regular_user).get(f"{API_BASE}recipient-options/")
|
||||
create_response = auth_client(regular_user).post(
|
||||
API_BASE,
|
||||
data={
|
||||
@ -817,9 +808,7 @@ def test_configured_recipient_settings_restrict_primary_recipients(
|
||||
settings = RequestSettings.get_solo()
|
||||
settings.allowed_recipients.add(allowed)
|
||||
|
||||
options_resp = auth_client(regular_user).get(
|
||||
f"{API_BASE}recipient-options/"
|
||||
)
|
||||
options_resp = auth_client(regular_user).get(f"{API_BASE}recipient-options/")
|
||||
allowed_resp = auth_client(regular_user).post(
|
||||
API_BASE,
|
||||
data={
|
||||
@ -879,9 +868,7 @@ def test_inactive_configured_recipient_does_not_disable_restriction(
|
||||
settings = RequestSettings.get_solo()
|
||||
settings.allowed_recipients.add(inactive)
|
||||
|
||||
response = auth_client(regular_user).get(
|
||||
f"{API_BASE}recipient-options/"
|
||||
)
|
||||
response = auth_client(regular_user).get(f"{API_BASE}recipient-options/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["restricted"] is True
|
||||
@ -1101,8 +1088,7 @@ def test_create_request_sends_notification_to_cc_users(
|
||||
author_name = regular_user.get_full_name() or regular_user.username
|
||||
assert cc_notification.data["title"] == "Новое заявление"
|
||||
assert (
|
||||
f"{author_name} поставил вас в копию заявления"
|
||||
in cc_notification.description
|
||||
f"{author_name} поставил вас в копию заявления" in cc_notification.description
|
||||
)
|
||||
|
||||
|
||||
@ -1149,7 +1135,11 @@ def test_submit_requires_full_validation_even_for_existing_draft(
|
||||
|
||||
|
||||
def test_update_and_delete_require_author_even_for_admin(
|
||||
auth_client, regular_user: models.Model, admin_user: models.Model, make_user, make_request
|
||||
auth_client,
|
||||
regular_user: models.Model,
|
||||
admin_user: models.Model,
|
||||
make_user,
|
||||
make_request,
|
||||
) -> None:
|
||||
"""Редактирование и удаление через API доступны только автору."""
|
||||
owner = make_user(email="update-owner@example.com")
|
||||
@ -1189,11 +1179,14 @@ def test_update_and_delete_require_author_even_for_admin(
|
||||
own_final.recipients.add(own_recipient)
|
||||
own_client = auth_client(regular_user)
|
||||
|
||||
assert own_client.patch(
|
||||
f"{API_BASE}{own_pending.id}/",
|
||||
data={"comment": "обновлено"},
|
||||
format="json",
|
||||
).status_code == 200
|
||||
assert (
|
||||
own_client.patch(
|
||||
f"{API_BASE}{own_pending.id}/",
|
||||
data={"comment": "обновлено"},
|
||||
format="json",
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert own_client.patch(
|
||||
f"{API_BASE}{own_final.id}/",
|
||||
data={"comment": "нельзя"},
|
||||
@ -1229,6 +1222,123 @@ def test_multiple_recipients_can_decide_until_request_becomes_final(
|
||||
assert second_resp.status_code in (400, 403)
|
||||
|
||||
|
||||
def test_approving_vacation_requests_terms_and_can_save_finance_instruction(
|
||||
auth_client, make_user, make_request
|
||||
) -> None:
|
||||
owner = make_user(email="vacation-terms-owner@example.com")
|
||||
recipient = make_user(email="vacation-terms-approver@example.com")
|
||||
vacation = make_request(
|
||||
employee=owner,
|
||||
type_=RequestType.VACATION,
|
||||
status=RequestStatus.PENDING,
|
||||
)
|
||||
vacation.date_from = date(2026, 8, 10)
|
||||
vacation.date_to = date(2026, 8, 19)
|
||||
vacation.save(update_fields=["date_from", "date_to"])
|
||||
vacation.recipients.add(recipient)
|
||||
payroll_period = PayrollPeriod.objects.create(
|
||||
code="2026-08-vacation-test",
|
||||
name="Август 2026",
|
||||
date_from=date(2026, 8, 1),
|
||||
date_to=date(2026, 8, 31),
|
||||
)
|
||||
|
||||
approve_response = auth_client(recipient).post(
|
||||
f"{API_BASE}{vacation.id}/approve/",
|
||||
data={},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert approve_response.status_code == 200
|
||||
assert approve_response.json()["vacation_terms_required"] is True
|
||||
assert approve_response.json()["can_manage_vacation_terms"] is True
|
||||
|
||||
context_response = auth_client(recipient).get(
|
||||
f"{API_BASE}{vacation.id}/vacation-terms/"
|
||||
)
|
||||
assert context_response.status_code == 200
|
||||
assert context_response.json()["total_days"] == 10
|
||||
assert context_response.json()["statistics"]["period"] == "half_year"
|
||||
|
||||
save_response = auth_client(recipient).put(
|
||||
f"{API_BASE}{vacation.id}/vacation-terms/?period=half_year",
|
||||
data={"paid_days": 7, "record_to_finance": True},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert save_response.status_code == 200
|
||||
payload = save_response.json()
|
||||
assert payload["request"]["vacation_terms_required"] is False
|
||||
assert payload["terms"]["paid_days"] == 7
|
||||
assert payload["terms"]["unpaid_days"] == 3
|
||||
terms = RequestVacationTerms.objects.get(request=vacation)
|
||||
instruction = VacationPayInstruction.objects.get(vacation_terms=terms)
|
||||
assert instruction.period == payroll_period
|
||||
assert instruction.paid_days == 7
|
||||
assert instruction.status == VacationPayInstructionStatus.PENDING_AMOUNT
|
||||
|
||||
|
||||
def test_unpaid_vacation_can_skip_finance_and_updates_exact_statistics(
|
||||
auth_client, make_user, make_request
|
||||
) -> None:
|
||||
owner = make_user(email="unpaid-vacation-owner@example.com")
|
||||
recipient = make_user(email="unpaid-vacation-approver@example.com")
|
||||
vacation = make_request(
|
||||
employee=owner,
|
||||
type_=RequestType.VACATION,
|
||||
status=RequestStatus.PENDING,
|
||||
comment="обычный отпуск без текстовых маркеров",
|
||||
)
|
||||
vacation.date_from = date(2026, 8, 1)
|
||||
vacation.date_to = date(2026, 8, 5)
|
||||
vacation.save(update_fields=["date_from", "date_to"])
|
||||
vacation.recipients.add(recipient)
|
||||
auth_client(recipient).post(f"{API_BASE}{vacation.id}/approve/")
|
||||
|
||||
save_response = auth_client(recipient).put(
|
||||
f"{API_BASE}{vacation.id}/vacation-terms/?period=all",
|
||||
data={"paid_days": 0, "record_to_finance": False},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert save_response.status_code == 200
|
||||
payload = save_response.json()
|
||||
assert payload["terms"]["finance_status"] == "skipped"
|
||||
assert payload["statistics"]["paid_vacation_days"] == 0
|
||||
assert payload["statistics"]["unpaid_vacation_days"] == 5
|
||||
assert VacationPayInstruction.objects.count() == 0
|
||||
|
||||
|
||||
def test_vacation_terms_reject_invalid_days_and_foreign_editor(
|
||||
auth_client, make_user, make_request
|
||||
) -> None:
|
||||
owner = make_user(email="invalid-vacation-owner@example.com")
|
||||
recipient = make_user(email="invalid-vacation-approver@example.com")
|
||||
outsider = make_user(email="invalid-vacation-outsider@example.com")
|
||||
vacation = make_request(
|
||||
employee=owner,
|
||||
type_=RequestType.VACATION,
|
||||
status=RequestStatus.PENDING,
|
||||
)
|
||||
vacation.date_from = date(2026, 8, 1)
|
||||
vacation.date_to = date(2026, 8, 3)
|
||||
vacation.save(update_fields=["date_from", "date_to"])
|
||||
vacation.recipients.add(recipient)
|
||||
auth_client(recipient).post(f"{API_BASE}{vacation.id}/approve/")
|
||||
|
||||
invalid_response = auth_client(recipient).put(
|
||||
f"{API_BASE}{vacation.id}/vacation-terms/",
|
||||
data={"paid_days": 4, "record_to_finance": True},
|
||||
format="json",
|
||||
)
|
||||
foreign_response = auth_client(outsider).get(
|
||||
f"{API_BASE}{vacation.id}/vacation-terms/"
|
||||
)
|
||||
|
||||
assert invalid_response.status_code == 400
|
||||
assert foreign_response.status_code == 403
|
||||
|
||||
|
||||
def test_only_direct_recipients_can_decide(
|
||||
auth_client, admin_user: models.Model, make_user, make_request
|
||||
) -> None:
|
||||
@ -1243,7 +1353,9 @@ def test_only_direct_recipients_can_decide(
|
||||
assert auth_client(owner).post(f"{API_BASE}{req.id}/approve/").status_code == 403
|
||||
assert auth_client(cc_user).post(f"{API_BASE}{req.id}/approve/").status_code == 403
|
||||
assert auth_client(outsider).post(f"{API_BASE}{req.id}/approve/").status_code == 403
|
||||
assert auth_client(admin_user).post(f"{API_BASE}{req.id}/approve/").status_code == 403
|
||||
assert (
|
||||
auth_client(admin_user).post(f"{API_BASE}{req.id}/approve/").status_code == 403
|
||||
)
|
||||
|
||||
|
||||
def test_admin_can_decide_only_if_explicitly_added_to_recipients(
|
||||
@ -1303,12 +1415,10 @@ def test_draft_is_visible_only_to_author(
|
||||
req.cc_users.add(cc_user)
|
||||
|
||||
owner_ids = {
|
||||
item["id"]
|
||||
for item in _results(auth_client(owner).get(API_BASE).json())
|
||||
item["id"] for item in _results(auth_client(owner).get(API_BASE).json())
|
||||
}
|
||||
recipient_ids = {
|
||||
item["id"]
|
||||
for item in _results(auth_client(recipient).get(API_BASE).json())
|
||||
item["id"] for item in _results(auth_client(recipient).get(API_BASE).json())
|
||||
}
|
||||
cc_ids = {
|
||||
item["id"] for item in _results(auth_client(cc_user).get(API_BASE).json())
|
||||
@ -1317,28 +1427,18 @@ def test_draft_is_visible_only_to_author(
|
||||
assert req.id in owner_ids
|
||||
assert req.id not in recipient_ids
|
||||
assert req.id not in cc_ids
|
||||
assert (
|
||||
req.id
|
||||
not in {
|
||||
item["id"]
|
||||
for item in _results(
|
||||
auth_client(recipient).get(
|
||||
f"{API_BASE}?addressed_to_me=true"
|
||||
).json()
|
||||
)
|
||||
}
|
||||
)
|
||||
assert (
|
||||
req.id
|
||||
not in {
|
||||
item["id"]
|
||||
for item in _results(
|
||||
auth_client(cc_user).get(
|
||||
f"{API_BASE}?addressed_to_me=true"
|
||||
).json()
|
||||
)
|
||||
}
|
||||
)
|
||||
assert req.id not in {
|
||||
item["id"]
|
||||
for item in _results(
|
||||
auth_client(recipient).get(f"{API_BASE}?addressed_to_me=true").json()
|
||||
)
|
||||
}
|
||||
assert req.id not in {
|
||||
item["id"]
|
||||
for item in _results(
|
||||
auth_client(cc_user).get(f"{API_BASE}?addressed_to_me=true").json()
|
||||
)
|
||||
}
|
||||
|
||||
assert auth_client(owner).get(f"{API_BASE}{req.id}/").status_code == 200
|
||||
assert auth_client(recipient).get(f"{API_BASE}{req.id}/").status_code == 403
|
||||
@ -1360,29 +1460,32 @@ def test_comments_and_decisions_are_forbidden_for_draft(
|
||||
req.cc_users.add(cc_user)
|
||||
|
||||
assert auth_client(owner).get(f"{API_BASE}{req.id}/comments/").status_code == 403
|
||||
assert auth_client(owner).post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "owner draft comment"},
|
||||
format="json",
|
||||
).status_code == 403
|
||||
assert (
|
||||
auth_client(recipient).post(
|
||||
f"{API_BASE}{req.id}/approve/", data={}, format="json"
|
||||
).status_code
|
||||
auth_client(owner)
|
||||
.post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "owner draft comment"},
|
||||
format="json",
|
||||
)
|
||||
.status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
auth_client(recipient).post(
|
||||
f"{API_BASE}{req.id}/reject/", data={}, format="json"
|
||||
).status_code
|
||||
auth_client(recipient)
|
||||
.post(f"{API_BASE}{req.id}/approve/", data={}, format="json")
|
||||
.status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
auth_client(recipient)
|
||||
.post(f"{API_BASE}{req.id}/reject/", data={}, format="json")
|
||||
.status_code
|
||||
== 403
|
||||
)
|
||||
assert auth_client(cc_user).get(f"{API_BASE}{req.id}/comments/").status_code == 403
|
||||
|
||||
|
||||
def test_author_can_submit_draft_explicitly_to_pending(
|
||||
auth_client, make_user
|
||||
) -> None:
|
||||
def test_author_can_submit_draft_explicitly_to_pending(auth_client, make_user) -> None:
|
||||
"""Черновик переходит в pending только по явному save_as=submit."""
|
||||
owner = make_user(email="draft-submit-owner@example.com")
|
||||
recipient = make_user(email="draft-submit-recipient@example.com")
|
||||
@ -1433,34 +1536,55 @@ def test_comments_available_to_participants_only(
|
||||
assert owner_client.get(f"{API_BASE}{req.id}/comments/").status_code == 200
|
||||
assert recipient_client.get(f"{API_BASE}{req.id}/comments/").status_code == 200
|
||||
assert cc_client.get(f"{API_BASE}{req.id}/comments/").status_code == 200
|
||||
assert owner_client.post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "owner"},
|
||||
format="json",
|
||||
).status_code == 201
|
||||
assert recipient_client.post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "recipient"},
|
||||
format="json",
|
||||
).status_code == 201
|
||||
assert cc_client.post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "cc"},
|
||||
format="json",
|
||||
).status_code == 201
|
||||
assert (
|
||||
owner_client.post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "owner"},
|
||||
format="json",
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
assert (
|
||||
recipient_client.post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "recipient"},
|
||||
format="json",
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
assert (
|
||||
cc_client.post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "cc"},
|
||||
format="json",
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
|
||||
assert auth_client(outsider).get(f"{API_BASE}{req.id}/comments/").status_code == 403
|
||||
assert auth_client(outsider).post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "outsider"},
|
||||
format="json",
|
||||
).status_code == 403
|
||||
assert auth_client(admin_user).get(f"{API_BASE}{req.id}/comments/").status_code == 403
|
||||
assert auth_client(admin_user).post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "admin"},
|
||||
format="json",
|
||||
).status_code == 403
|
||||
assert (
|
||||
auth_client(outsider)
|
||||
.post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "outsider"},
|
||||
format="json",
|
||||
)
|
||||
.status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
auth_client(admin_user).get(f"{API_BASE}{req.id}/comments/").status_code == 403
|
||||
)
|
||||
assert (
|
||||
auth_client(admin_user)
|
||||
.post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "admin"},
|
||||
format="json",
|
||||
)
|
||||
.status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_cc_user_receives_comment_notification(
|
||||
@ -1582,12 +1706,12 @@ def test_filters_apply_within_participant_scope(
|
||||
assert item["type"] == expected_status
|
||||
else:
|
||||
assert item["status"] == expected_status
|
||||
assert my_request.id in {item["id"] for item in _results(auth_client(regular_user).get(API_BASE).json())}
|
||||
assert my_request.id in {
|
||||
item["id"] for item in _results(auth_client(regular_user).get(API_BASE).json())
|
||||
}
|
||||
|
||||
|
||||
def test_invalid_filter_does_not_crash(
|
||||
auth_client, regular_user: models.Model
|
||||
) -> None:
|
||||
def test_invalid_filter_does_not_crash(auth_client, regular_user: models.Model) -> None:
|
||||
"""Странные значения фильтров не приводят к 500."""
|
||||
client = auth_client(regular_user)
|
||||
resp = client.get(f"{API_BASE}?type=__sql__' OR 1=1 --&status=")
|
||||
|
||||
@ -10,6 +10,7 @@ import { RequestListSection } from "@/components/requests/RequestListSection";
|
||||
import { RequestStatisticsPanel } from "@/components/requests/RequestStatisticsPanel";
|
||||
import { RequestSwipeModePanel } from "@/components/requests/RequestSwipeModePanel";
|
||||
import { RequestTaskLinks } from "@/components/requests/RequestTaskLinks";
|
||||
import { RequestVacationTermsModal } from "@/components/requests/RequestVacationTermsModal";
|
||||
import { RequestWideListItem } from "@/components/requests/RequestWideListItem";
|
||||
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
@ -237,6 +238,7 @@ function RequestsPageContent() {
|
||||
onPreviewAttachment={h.setAttachmentPreview}
|
||||
onReject={h.handleReject}
|
||||
onSetCommentDraft={h.setCommentDraft}
|
||||
onVacationTerms={h.openVacationTerms}
|
||||
request={h.detailsRequest}
|
||||
/>
|
||||
) : (
|
||||
@ -310,6 +312,7 @@ function RequestsPageContent() {
|
||||
onPreviewAttachment={h.setAttachmentPreview}
|
||||
onReject={h.handleReject}
|
||||
onSetCommentDraft={h.setCommentDraft}
|
||||
onVacationTerms={h.openVacationTerms}
|
||||
request={desktopWideLayout ? null : h.detailsRequest}
|
||||
/>
|
||||
|
||||
@ -344,6 +347,11 @@ function RequestsPageContent() {
|
||||
preview={h.attachmentPreview}
|
||||
onClose={() => h.setAttachmentPreview(null)}
|
||||
/>
|
||||
<RequestVacationTermsModal
|
||||
request={h.vacationTermsRequest}
|
||||
onClose={h.closeVacationTerms}
|
||||
onSaved={h.handleVacationTermsSaved}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import { CommentComposer, CommentDeleteButton } from "@/components/shared/Commen
|
||||
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
|
||||
import { displayUserName, formatDate, formatDateTime } from "@/lib/shared";
|
||||
import type { Request, RequestComment } from "@/types/api";
|
||||
import { Ban, Link2, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
|
||||
import { Ban, CalendarDays, Link2, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
|
||||
import { getRequestActionState } from "./requestActions";
|
||||
import { RequestUserBadge } from "./RequestUserBadge";
|
||||
|
||||
@ -36,6 +36,7 @@ type RequestDetailModalProps = {
|
||||
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
|
||||
onReject: (requestId: number) => void | Promise<void>;
|
||||
onSetCommentDraft: (requestId: number, value: string) => void;
|
||||
onVacationTerms: (request: Request) => void;
|
||||
request: Request | null;
|
||||
};
|
||||
|
||||
@ -59,6 +60,7 @@ export function RequestDetailModal({
|
||||
onPreviewAttachment,
|
||||
onReject,
|
||||
onSetCommentDraft,
|
||||
onVacationTerms,
|
||||
request,
|
||||
}: RequestDetailModalProps) {
|
||||
if (!request) return null;
|
||||
@ -76,6 +78,11 @@ export function RequestDetailModal({
|
||||
: "";
|
||||
const commentCount = request.comments_count ?? comments.length;
|
||||
const linkedTasks = request.linked_tasks || [];
|
||||
const canEditVacationTerms = Boolean(
|
||||
String(request.type || request.request_type || "").toLowerCase() === "vacation"
|
||||
&& request.status === "approved"
|
||||
&& request.can_manage_vacation_terms,
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -90,8 +97,18 @@ export function RequestDetailModal({
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
{(canProcess || canCancel || canEdit || canDelete) ? (
|
||||
{(canProcess || canCancel || canEdit || canDelete || canEditVacationTerms) ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{canEditVacationTerms ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onVacationTerms(request)}
|
||||
className={`${request.vacation_terms_required ? "app-feedback-warning" : "app-action-secondary"} inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium`}
|
||||
>
|
||||
<CalendarDays size={16} />
|
||||
{request.vacation_terms_required ? "Заполнить условия отпуска" : "Условия отпуска"}
|
||||
</button>
|
||||
) : null}
|
||||
{canProcess ? (
|
||||
<>
|
||||
<button
|
||||
|
||||
@ -13,6 +13,7 @@ import { displayUserName, formatDate, formatDateTime } from "@/lib/shared";
|
||||
import type { Request, RequestComment } from "@/types/api";
|
||||
import {
|
||||
Ban,
|
||||
CalendarDays,
|
||||
Link2,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
@ -42,6 +43,7 @@ type RequestDetailPanelProps = {
|
||||
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
|
||||
onReject: (requestId: number) => void | Promise<void>;
|
||||
onSetCommentDraft: (requestId: number, value: string) => void;
|
||||
onVacationTerms: (request: Request) => void;
|
||||
request: Request;
|
||||
};
|
||||
|
||||
@ -70,6 +72,7 @@ export function RequestDetailPanel({
|
||||
onPreviewAttachment,
|
||||
onReject,
|
||||
onSetCommentDraft,
|
||||
onVacationTerms,
|
||||
request,
|
||||
}: RequestDetailPanelProps) {
|
||||
const author = request.employee || request.created_by;
|
||||
@ -85,7 +88,12 @@ export function RequestDetailPanel({
|
||||
: "";
|
||||
const commentCount = request.comments_count ?? comments.length;
|
||||
const linkedTasks = request.linked_tasks || [];
|
||||
const hasActions = canProcess || canCancel || canEdit || canDelete;
|
||||
const canEditVacationTerms = Boolean(
|
||||
typeKey === "vacation"
|
||||
&& request.status === "approved"
|
||||
&& request.can_manage_vacation_terms,
|
||||
);
|
||||
const hasActions = canProcess || canCancel || canEdit || canDelete || canEditVacationTerms;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-sm text-[var(--foreground)]">
|
||||
@ -116,6 +124,16 @@ export function RequestDetailPanel({
|
||||
|
||||
{hasActions ? (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-[var(--border-subtle)] pt-3">
|
||||
{canEditVacationTerms ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onVacationTerms(request)}
|
||||
className={`${request.vacation_terms_required ? "app-feedback-warning" : "app-action-secondary"} inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium`}
|
||||
>
|
||||
<CalendarDays size={14} />
|
||||
{request.vacation_terms_required ? "Заполнить условия отпуска" : "Условия отпуска"}
|
||||
</button>
|
||||
) : null}
|
||||
{canProcess ? (
|
||||
<>
|
||||
<button
|
||||
|
||||
266
frontend/src/components/requests/RequestVacationTermsModal.tsx
Normal file
266
frontend/src/components/requests/RequestVacationTermsModal.tsx
Normal file
@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Banknote,
|
||||
CalendarDays,
|
||||
Check,
|
||||
CircleDollarSign,
|
||||
LoaderCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu";
|
||||
import { formatRequestPeriod } from "@/hooks/useRequestsPage";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { displayUserName } from "@/lib/shared";
|
||||
import type {
|
||||
Request,
|
||||
RequestVacationStatsPeriod,
|
||||
RequestVacationTermsContext,
|
||||
} from "@/types/api";
|
||||
|
||||
type PaidMode = "full" | "partial" | "unpaid";
|
||||
|
||||
const periodOptions = [
|
||||
{ value: "half_year", label: "Последние 6 месяцев" },
|
||||
{ value: "year", label: "Текущий год" },
|
||||
{ value: "month", label: "Текущий месяц" },
|
||||
{ value: "all", label: "Всё время" },
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
request: Request | null;
|
||||
onClose: () => void;
|
||||
onSaved: (request: Request) => void;
|
||||
};
|
||||
|
||||
export function RequestVacationTermsModal({ request, onClose, onSaved }: Props) {
|
||||
const [period, setPeriod] = useState<RequestVacationStatsPeriod>("half_year");
|
||||
const [context, setContext] = useState<RequestVacationTermsContext | null>(null);
|
||||
const [mode, setMode] = useState<PaidMode>("full");
|
||||
const [partialDays, setPartialDays] = useState(1);
|
||||
const [recordToFinance, setRecordToFinance] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!request) return;
|
||||
let cancelled = false;
|
||||
void apiClient.getRequestVacationTerms(request.id, period)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setContext(payload);
|
||||
if (payload.terms) {
|
||||
const paidDays = payload.terms.paid_days;
|
||||
setMode(
|
||||
paidDays === 0
|
||||
? "unpaid"
|
||||
: paidDays >= payload.total_days
|
||||
? "full"
|
||||
: "partial",
|
||||
);
|
||||
setPartialDays(Math.max(1, paidDays));
|
||||
setRecordToFinance(
|
||||
payload.terms.finance_status === "pending"
|
||||
|| payload.terms.finance_status === "posted",
|
||||
);
|
||||
} else {
|
||||
setMode("full");
|
||||
setPartialDays(Math.max(1, Math.min(payload.total_days, 1)));
|
||||
setRecordToFinance(true);
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(String((reason as Error)?.message || "Не удалось загрузить условия отпуска"));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [period, request]);
|
||||
|
||||
const paidDays = useMemo(() => {
|
||||
const totalDays = context?.total_days || 0;
|
||||
if (mode === "full") return totalDays;
|
||||
if (mode === "unpaid") return 0;
|
||||
return Math.min(Math.max(partialDays, 1), Math.max(totalDays - 1, 1));
|
||||
}, [context?.total_days, mode, partialDays]);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const stats = context?.statistics;
|
||||
const historicalVacationDays = (stats?.paid_vacation_days || 0) + (stats?.unpaid_vacation_days || 0);
|
||||
const employeeName = displayUserName(request.employee || request.created_by);
|
||||
|
||||
const save = async () => {
|
||||
if (!context) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const payload = await apiClient.saveRequestVacationTerms(
|
||||
request.id,
|
||||
{ paid_days: paidDays, record_to_finance: recordToFinance },
|
||||
period,
|
||||
);
|
||||
if (payload.request) onSaved(payload.request);
|
||||
onClose();
|
||||
} catch (reason: unknown) {
|
||||
setError(String((reason as Error)?.message || "Не удалось сохранить условия отпуска"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title="Условия отпуска"
|
||||
size="md"
|
||||
stackLevel={1}
|
||||
closeOnEsc={!saving}
|
||||
footer={(
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="app-action-secondary rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Заполнить позже
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={!context || loading || saving}
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{saving ? <LoaderCircle size={16} className="animate-spin" /> : <Check size={16} />}
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="app-text-muted flex min-h-56 items-center justify-center gap-2 text-sm">
|
||||
<LoaderCircle size={18} className="animate-spin" /> Загрузка условий…
|
||||
</div>
|
||||
) : error && !context ? (
|
||||
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{error}</div>
|
||||
) : context ? (
|
||||
<div className="space-y-4">
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarDays size={18} className="app-accent-text mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{employeeName}</p>
|
||||
<p className="app-text-muted mt-1 text-xs">{formatRequestPeriod(request)}</p>
|
||||
</div>
|
||||
<span className="app-badge ml-auto shrink-0 rounded-full px-2.5 py-1 text-xs">
|
||||
{context.total_days} дн.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p className="app-card-caption mb-2">Оплата отпуска</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
["full", "Оплачиваемый"],
|
||||
["partial", "Частично"],
|
||||
["unpaid", "Без оплаты"],
|
||||
] as const).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(value);
|
||||
if (value === "unpaid") setRecordToFinance(false);
|
||||
}}
|
||||
className={`${mode === value ? "app-selected app-accent-text" : "app-action-secondary"} min-w-0 rounded-xl px-2 py-2.5 text-xs font-medium sm:text-sm`}
|
||||
>
|
||||
<span className="block truncate">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{mode === "partial" ? (
|
||||
<label className="mt-3 block">
|
||||
<span className="app-text-muted mb-1.5 block text-xs">Оплачиваемых дней</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(context.total_days - 1, 1)}
|
||||
value={partialDays}
|
||||
onChange={(event) => setPartialDays(Number(event.target.value) || 1)}
|
||||
className="app-input w-full rounded-xl px-3 py-2.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<p className="app-text-muted mt-2 text-xs">
|
||||
Оплачиваемых: {paidDays} · без оплаты: {Math.max(context.total_days - paidDays, 0)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="app-card-caption">Статистика сотрудника</p>
|
||||
<ProjectSelectMenu
|
||||
value={period}
|
||||
options={[...periodOptions]}
|
||||
onChange={(value) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPeriod(value as RequestVacationStatsPeriod);
|
||||
}}
|
||||
ariaLabel="Период статистики отпусков"
|
||||
variant="badge"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Stat label="Всего" value={historicalVacationDays} />
|
||||
<Stat label="Оплачено" value={stats?.paid_vacation_days || 0} />
|
||||
<Stat label="Без оплаты" value={stats?.unpaid_vacation_days || 0} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={recordToFinance}
|
||||
disabled={mode === "unpaid"}
|
||||
onClick={() => setRecordToFinance((value) => !value)}
|
||||
className={`${recordToFinance ? "app-selected" : "app-surface-muted"} flex w-full items-start gap-3 rounded-xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
<span className={`${recordToFinance ? "app-action-primary" : "app-action-secondary"} inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg`}>
|
||||
<CircleDollarSign size={18} />
|
||||
</span>
|
||||
<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} дн. Сумму рассчитает финансовый модуль.
|
||||
</span>
|
||||
</span>
|
||||
<Banknote size={17} className="app-text-muted ml-auto mt-1 shrink-0" />
|
||||
</button>
|
||||
|
||||
{error ? <div className="app-feedback-danger rounded-xl px-3 py-2 text-sm">{error}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="app-surface rounded-lg px-2 py-2.5 text-center">
|
||||
<p className="text-base font-semibold text-[var(--foreground)]">{value}</p>
|
||||
<p className="app-text-muted mt-0.5 truncate text-[10px] sm:text-xs">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -169,6 +169,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
/* form */
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingRequest, setEditingRequest] = useState<Request | null>(null);
|
||||
const [vacationTermsRequest, setVacationTermsRequest] = useState<Request | null>(null);
|
||||
const [form, setForm] = useState<RequestFormState>(createEmptyForm);
|
||||
|
||||
/* filters */
|
||||
@ -485,11 +486,25 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
}, [nextPage, loadingMore, buildRequestParams]);
|
||||
|
||||
const handleApprove = async (id: number) => {
|
||||
try { setBusyKey(`approve-${id}`); setActionError(null); const updated = await apiClient.approveRequest(id); applyUpdatedRequest(updated); await refreshRequests(); }
|
||||
try {
|
||||
setBusyKey(`approve-${id}`);
|
||||
setActionError(null);
|
||||
const updated = await apiClient.approveRequest(id) as Request;
|
||||
applyUpdatedRequest(updated);
|
||||
if (updated.type === "vacation" && updated.vacation_terms_required) {
|
||||
setVacationTermsRequest(updated);
|
||||
}
|
||||
await refreshRequests();
|
||||
}
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось одобрить")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
|
||||
const handleVacationTermsSaved = useCallback((updated: Request) => {
|
||||
applyUpdatedRequest(updated);
|
||||
void refreshRequests();
|
||||
}, [applyUpdatedRequest, refreshRequests]);
|
||||
|
||||
const handleReject = async (id: number) => {
|
||||
try { setBusyKey(`reject-${id}`); setActionError(null); const updated = await apiClient.rejectRequest(id); applyUpdatedRequest(updated); await refreshRequests(); }
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отклонить")); }
|
||||
@ -662,6 +677,10 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
|
||||
/* UI */
|
||||
attachmentPreview, setAttachmentPreview,
|
||||
vacationTermsRequest,
|
||||
openVacationTerms: setVacationTermsRequest,
|
||||
closeVacationTerms: () => setVacationTermsRequest(null),
|
||||
handleVacationTermsSaved,
|
||||
detailsRequest, setDetailsRequest,
|
||||
loading, refreshing, loadingMore, error,
|
||||
actionError, actionSuccess,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { EmployeeRequestProfileSummary, RequestEmployeeStatistics, User } from '@/types/api';
|
||||
import type { EmployeeRequestProfileSummary, RequestEmployeeStatistics, RequestVacationStatsPeriod, RequestVacationTermsContext, User } from '@/types/api';
|
||||
import type { RequestFn } from './utils';
|
||||
|
||||
export function createRequestsApi(request: RequestFn) {
|
||||
@ -42,6 +42,10 @@ export function createRequestsApi(request: RequestFn) {
|
||||
getRequestComments: (requestId: number) => request(`/api/v1/requests/${requestId}/comments/`),
|
||||
deleteRequestComment: (requestId: number, commentId: number): Promise<void> => request(`/api/v1/requests/${requestId}/comments/${commentId}/`, { method: 'DELETE' }),
|
||||
approveRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/approve/`, { method: 'POST' }),
|
||||
getRequestVacationTerms: (requestId: number, period: RequestVacationStatsPeriod = 'half_year'): Promise<RequestVacationTermsContext> =>
|
||||
request(`/api/v1/requests/${requestId}/vacation-terms/?period=${period}`),
|
||||
saveRequestVacationTerms: (requestId: number, payload: { paid_days: number; record_to_finance: boolean }, period: RequestVacationStatsPeriod = 'half_year'): Promise<RequestVacationTermsContext> =>
|
||||
request(`/api/v1/requests/${requestId}/vacation-terms/?period=${period}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
rejectRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/reject/`, { method: 'POST' }),
|
||||
cancelRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/cancel/`, { method: 'POST' }),
|
||||
addRequestComment: (requestId: number, text: string) => request(`/api/v1/requests/${requestId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
|
||||
@ -1265,6 +1265,39 @@ export interface Request {
|
||||
decided_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
vacation_terms?: RequestVacationTerms | null;
|
||||
vacation_terms_required?: boolean;
|
||||
can_manage_vacation_terms?: boolean;
|
||||
}
|
||||
|
||||
export interface VacationFinanceInstruction {
|
||||
id: number;
|
||||
status: "pending_amount" | "draft" | "posted" | "voided";
|
||||
paid_days: number;
|
||||
amount: string | null;
|
||||
period_id: number | null;
|
||||
payroll_input_line_id: number | null;
|
||||
}
|
||||
|
||||
export interface RequestVacationTerms {
|
||||
paid_days: number;
|
||||
unpaid_days: number;
|
||||
total_days: number;
|
||||
finance_status: "not_decided" | "skipped" | "pending" | "posted";
|
||||
recorded_by: User;
|
||||
recorded_at: string;
|
||||
updated_at: string;
|
||||
finance_instruction: VacationFinanceInstruction | null;
|
||||
}
|
||||
|
||||
export type RequestVacationStatsPeriod = "half_year" | "year" | "month" | "all";
|
||||
|
||||
export interface RequestVacationTermsContext {
|
||||
request_id: number;
|
||||
total_days: number;
|
||||
terms: RequestVacationTerms | null;
|
||||
statistics: RequestEmployeeStatistics;
|
||||
request?: Request;
|
||||
}
|
||||
|
||||
export interface RequestComment {
|
||||
@ -1278,7 +1311,7 @@ export interface RequestComment {
|
||||
export interface RequestEmployeeStatistics {
|
||||
employee_id: number;
|
||||
employee_name: string;
|
||||
period: "all" | "year" | "month" | "custom";
|
||||
period: "all" | "half_year" | "year" | "month" | "custom";
|
||||
date_from: string | null;
|
||||
date_to: string | null;
|
||||
total_submitted_requests: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user