merge: vacation approval finance workflow

This commit is contained in:
lizerk 2026-08-26 14:50:58 +03:00
commit 4283dc64d4
50 changed files with 2578 additions and 225 deletions

View File

@ -23,9 +23,7 @@ from finance.models import (
PayrollAuditEvent,
PayrollPeriod,
)
from finance.payroll.components import (
PAYROLL_COMPONENTS_BY_CODE,
)
from finance.payroll.components import get_payroll_component
from finance.payroll.access import (
can_access_payroll_management,
has_payroll_permission,
@ -146,15 +144,19 @@ def _payroll_history_action_label(action):
return "Состояние записи изменено"
def _validate_payroll_table_column(column_id):
def _validate_payroll_table_column(column_id, *, period=None):
if column_id in PAYROLL_TABLE_FIXED_COLUMN_IDS:
return column_id
prefix, separator, component_code = column_id.partition(":")
try:
component = get_payroll_component(component_code, period=period)
except ValueError:
component = None
if (
prefix == "component"
and separator
and component_code not in SYSTEM_COMPONENT_CODES
and component_code in PAYROLL_COMPONENTS_BY_CODE
and component is not None
):
return column_id
raise ValidationError({"column_id": "Колонка отсутствует в расчётной таблице."})

View File

@ -15,7 +15,7 @@ from finance.models import (
PayrollInputCopyConfiguration,
PayrollInputLine,
)
from finance.payroll.components import PAYROLL_COMPONENTS, PAYROLL_COMPONENTS_BY_CODE
from finance.payroll.components import get_payroll_components
from ..admin_serializers import (
PayrollComponentSerializer,
@ -42,13 +42,20 @@ def _validate_input_copy_periods(target_period, source_period):
)
def _copyable_input_lines(source_period):
def _copyable_input_lines(source_period, *, target_period=None):
component_codes = {
component.code for component in get_payroll_components(source_period)
}
if target_period is not None:
component_codes &= {
component.code for component in get_payroll_components(target_period)
}
return (
PayrollInputLine.objects.filter(
period=source_period,
employee__is_hidden_in_ui=False,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
component_code__in=PAYROLL_COMPONENTS_BY_CODE,
component_code__in=component_codes,
)
.exclude(
source=InputSource.API,
@ -109,7 +116,9 @@ def _input_copy_configuration_payload(actor):
def _input_copy_preview(target_period, source_period, *, actor):
_validate_input_copy_periods(target_period, source_period)
lines = list(_copyable_input_lines(source_period))
lines = list(
_copyable_input_lines(source_period, target_period=target_period)
)
component_rows = {
component.code: {
"component": component,
@ -117,7 +126,7 @@ def _input_copy_preview(target_period, source_period, *, actor):
"employee_ids": set(),
"amount_total": Decimal("0.00"),
}
for component in PAYROLL_COMPONENTS
for component in get_payroll_components(target_period)
}
employee_rows = {}
for line in lines:
@ -228,7 +237,10 @@ def _apply_input_copy(*, target_period, source_period, actor, data):
"idempotent_replay": True,
}
lines = list(
_copyable_input_lines(source_period).filter(
_copyable_input_lines(
source_period,
target_period=target_period,
).filter(
component_code__in=data["component_codes"]
)
)

View File

@ -18,15 +18,17 @@ from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from finance.models import (
PayrollCustomComponent,
PayrollInputCopyConfiguration,
PayrollPeriod,
)
from finance.payroll.components import (
PAYROLL_COMPONENTS,
get_payroll_components,
)
from ...admin_serializers import (
PayrollComponentSerializer,
PayrollCustomComponentWriteSerializer,
PayrollEmployeeMiniSerializer,
PayrollPeriodSerializer,
PayrollPeriodWriteSerializer,
@ -39,6 +41,7 @@ from ..common import (
PayrollAdminAPIView,
_audit_api_mutation,
_conflict,
_optional_int,
)
from ..input_copy import _apply_input_copy
@ -61,7 +64,54 @@ class PayrollAdminComponentListView(PayrollAdminAPIView):
allowed_permissions = INPUT_VIEW_PERMISSIONS
def get(self, request):
return Response(PayrollComponentSerializer(PAYROLL_COMPONENTS, many=True).data)
period_id = _optional_int(request, "period_id")
period = (
get_object_or_404(PayrollPeriod, pk=period_id)
if period_id is not None
else None
)
return Response(
PayrollComponentSerializer(
get_payroll_components(period),
many=True,
).data
)
@transaction.atomic
def post(self, request):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
serializer = PayrollCustomComponentWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
next_order = (
PayrollCustomComponent.objects.order_by("-display_order")
.values_list("display_order", flat=True)
.first()
or 890
) + 10
component = PayrollCustomComponent(
**serializer.validated_data,
display_order=next_order,
created_by=request.user,
)
try:
component.full_clean()
component.save(force_insert=True)
except DjangoValidationError as exc:
raise ValidationError(_validation_detail(exc)) from exc
except IntegrityError as exc:
raise ValidationError(
{"non_field_errors": "Не удалось сохранить колонку."}
) from exc
_audit_api_mutation(
actor=request.user,
action="payroll.component_created",
instance=component,
metadata={"kind": component.kind},
)
return Response(
PayrollComponentSerializer(component).data,
status=status.HTTP_201_CREATED,
)
@extend_schema_view(

View File

@ -43,7 +43,10 @@ class PayrollCellCommentsView(PayrollAdminAPIView):
def get_context(self, pk, employee_id, column_id):
period = get_object_or_404(PayrollPeriod, pk=pk)
employee = get_object_or_404(Employee, pk=employee_id)
return period, employee, _validate_payroll_table_column(column_id)
return period, employee, _validate_payroll_table_column(
column_id,
period=period,
)
def get(self, request, pk, employee_id, column_id):
period, employee, column_id = self.get_context(
@ -94,7 +97,7 @@ class PayrollCellCommentDetailView(PayrollAdminAPIView):
def delete(self, request, pk, employee_id, column_id, comment_id):
period = get_object_or_404(PayrollPeriod, pk=pk)
employee = get_object_or_404(Employee, pk=employee_id)
column_id = _validate_payroll_table_column(column_id)
column_id = _validate_payroll_table_column(column_id, period=period)
thread = get_object_or_404(
PayrollCellCommentThread,
period=period,

View File

@ -13,7 +13,7 @@ from finance.payroll.access import (
has_payroll_permission,
has_simple_admin_access,
)
from finance.payroll.components import PAYROLL_COMPONENTS
from finance.payroll.components import get_payroll_components
from ...employees.serializers import EmployeeBriefSerializer
from ..admin_serializers import (
@ -119,7 +119,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
many=True,
).data,
"components": PayrollComponentSerializer(
PAYROLL_COMPONENTS,
get_payroll_components(selected_period),
many=True,
).data,
"periods": PayrollPeriodSerializer(periods, many=True).data,

View File

@ -14,6 +14,7 @@ from .common import (
PayrollCellCommentSerializer,
PayrollCellCommentWriteSerializer,
PayrollComponentSerializer,
PayrollCustomComponentWriteSerializer,
PayrollEmployeeMiniSerializer,
PayrollUserRefSerializer,
)
@ -79,6 +80,7 @@ __all__ = [
"PayrollCellCommentWriteSerializer",
"PayrollCompleteWorkflowSerializer",
"PayrollComponentSerializer",
"PayrollCustomComponentWriteSerializer",
"PayrollEmployeeMiniSerializer",
"PayrollInputLineSerializer",
"PayrollInputLineWriteSerializer",

View File

@ -12,9 +12,7 @@ from decimal import Decimal
from django.contrib.auth import get_user_model
from rest_framework import serializers
from finance.payroll.components import (
PAYROLL_COMPONENT_CODE_CHOICES,
)
from .common import PayrollComponentCodeField
Employee = get_user_model()
@ -25,13 +23,11 @@ class ApprovalCommandSerializer(serializers.Serializer):
class ClearComponentCellCommandSerializer(serializers.Serializer):
employee_id = serializers.IntegerField(min_value=1)
component_code = serializers.ChoiceField(
choices=PAYROLL_COMPONENT_CODE_CHOICES,
component_code = PayrollComponentCodeField(
required=False,
)
component_id = serializers.ChoiceField(
component_id = PayrollComponentCodeField(
source="component_code",
choices=PAYROLL_COMPONENT_CODE_CHOICES,
required=False,
write_only=True,
)
@ -57,13 +53,13 @@ class PayrollPaymentConfirmationCommandSerializer(serializers.Serializer):
class CopyPayrollInputLinesCommandSerializer(serializers.Serializer):
source_period_id = serializers.IntegerField(min_value=1)
component_codes = serializers.ListField(
child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES),
child=PayrollComponentCodeField(),
allow_empty=False,
required=False,
)
component_ids = serializers.ListField(
source="component_codes",
child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES),
child=PayrollComponentCodeField(),
allow_empty=False,
required=False,
write_only=True,

View File

@ -15,7 +15,10 @@ from api.v1.employees.serializers import EmployeeBriefSerializer
from finance.enums import (
ApprovalStatus,
InputSource,
PayrollComponentPeriodScope,
)
from finance.models import PayrollCustomComponent, PayrollPeriod
from finance.payroll.components import get_payroll_component
Employee = get_user_model()
@ -93,7 +96,7 @@ class PayrollCellCommentWriteSerializer(serializers.Serializer):
class PayrollComponentSerializer(serializers.Serializer):
id = serializers.CharField(read_only=True)
id = serializers.SerializerMethodField()
code = serializers.CharField(read_only=True)
name = serializers.CharField(read_only=True)
kind = serializers.CharField(read_only=True)
@ -101,6 +104,60 @@ class PayrollComponentSerializer(serializers.Serializer):
requires_reason = serializers.BooleanField(read_only=True)
is_active = serializers.BooleanField(read_only=True)
display_order = serializers.IntegerField(read_only=True)
period_scope = serializers.CharField(read_only=True)
scope_period_id = serializers.IntegerField(read_only=True, allow_null=True)
def get_id(self, obj) -> str:
return obj.code
class PayrollComponentCodeField(serializers.CharField):
default_error_messages = {
"unknown": "Неизвестный вид зарплатной операции.",
}
def to_internal_value(self, data):
value = super().to_internal_value(data)
try:
get_payroll_component(value)
except ValueError:
self.fail("unknown")
return value
class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
name = serializers.CharField(max_length=80, trim_whitespace=True)
period_scope = serializers.ChoiceField(
choices=PayrollComponentPeriodScope.choices,
default=PayrollComponentPeriodScope.CURRENT_AND_FUTURE,
)
period_id = serializers.PrimaryKeyRelatedField(
source="scope_period",
queryset=PayrollPeriod.objects.all(),
required=False,
write_only=True,
)
class Meta:
model = PayrollCustomComponent
fields = [
"name",
"kind",
"requires_reason",
"period_scope",
"period_id",
]
def validate(self, attrs):
attrs = super().validate(attrs)
scope = attrs["period_scope"]
if scope == PayrollComponentPeriodScope.ALL:
attrs.pop("scope_period", None)
elif attrs.get("scope_period") is None:
raise serializers.ValidationError(
{"period_id": "Для выбранной области укажите расчётный период."}
)
return attrs
class DraftWriteSerializerMixin:

View File

@ -17,16 +17,14 @@ from finance.models import (
PayrollInputLine,
PayrollPeriod,
)
from finance.payroll.components import (
PAYROLL_COMPONENT_CODE_CHOICES,
)
from .common import (
DraftWriteSerializerMixin,
PayrollComponentCodeField,
PayrollComponentSerializer,
PayrollEmployeeMiniSerializer,
PayrollUserRefSerializer,
)
from finance.payroll.components import get_payroll_component
from .periods import PayrollCarryoverPeriodSerializer
Employee = get_user_model()
@ -106,13 +104,11 @@ class PayrollInputLineWriteSerializer(
source="employee",
queryset=Employee.objects.visible_in_ui(),
)
component_code = serializers.ChoiceField(
choices=PAYROLL_COMPONENT_CODE_CHOICES,
component_code = PayrollComponentCodeField(
required=False,
)
component_id = serializers.ChoiceField(
component_id = PayrollComponentCodeField(
source="component_code",
choices=PAYROLL_COMPONENT_CODE_CHOICES,
required=False,
write_only=True,
)
@ -137,6 +133,14 @@ class PayrollInputLineWriteSerializer(
]
def validate(self, attrs):
if not attrs.get("component_code"):
component_code = attrs.get("component_code")
if not component_code:
raise serializers.ValidationError({"component_code": "Обязательное поле."})
period = attrs.get("period") or getattr(self.instance, "period", None)
try:
get_payroll_component(component_code, period=period)
except ValueError as exc:
raise serializers.ValidationError(
{"component_code": "Колонка недоступна в выбранном периоде."}
) from exc
return attrs

View File

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

View File

@ -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",
]

View File

@ -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):
"""Компактное представление заявления для профиля сотрудника."""

View File

@ -0,0 +1,53 @@
from rest_framework import serializers
from decimal import Decimal, ROUND_HALF_UP
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
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,
"paid_days": instruction.paid_days,
"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,
}
class RequestVacationTermsWriteSerializer(serializers.Serializer):
paid_days = serializers.IntegerField(min_value=0)
record_to_finance = serializers.BooleanField()

View File

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

View File

@ -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",
]

View File

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

View File

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

View 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,
),
}

View File

@ -3,11 +3,18 @@ from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.response import Response
from requests_app.enums import RequestStatus
from requests_app.notifications.read_service import RequestNotificationReadService
from ..serializers import RequestReadSerializer
class RequestWorkflowMixin:
def _mark_decision_notifications_read(self, request, request_obj):
RequestNotificationReadService().mark_request_notifications_as_read(
user=request.user,
employee_request=request_obj,
)
def _serialized_request(self, request_obj):
return Response(
RequestReadSerializer(
@ -31,6 +38,7 @@ class RequestWorkflowMixin:
if request_obj.is_final:
raise ValidationError("Заявка уже в финальном статусе.")
request_obj.approve(by_user=request.user)
self._mark_decision_notifications_read(request, request_obj)
return self._serialized_request(request_obj)
@action(detail=True, methods=["post"])
@ -48,6 +56,7 @@ class RequestWorkflowMixin:
if request_obj.is_final:
raise ValidationError("Заявка уже в финальном статусе.")
request_obj.reject(by_user=request.user)
self._mark_decision_notifications_read(request, request_obj)
return self._serialized_request(request_obj)
@action(detail=True, methods=["post"])

View File

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

View File

@ -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", "Коррекция +"
@ -24,6 +31,12 @@ class PayrollComponentKind(models.TextChoices):
PAYMENT = "payment", "Выплата"
class PayrollComponentPeriodScope(models.TextChoices):
CURRENT_ONLY = "current_only", "Только выбранный период"
CURRENT_AND_FUTURE = "current_and_future", "Выбранный и будущие периоды"
ALL = "all", "Все периоды"
class PayrollPeriodStatus(models.TextChoices):
OPEN = "open", "Открыт"
CALCULATED = "calculated", "Рассчитан"

View File

@ -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'],
},
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 5.2.4 on 2026-08-26 10:37
import django.core.validators
import django.db.models.deletion
import finance.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0026_alter_employeepayrate_source_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PayrollCustomComponent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(default=finance.models.generate_custom_payroll_component_code, editable=False, max_length=64, unique=True, validators=[django.core.validators.RegexValidator(message='Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-', regex='^[A-Z][A-Z0-9_.-]{0,63}$')], verbose_name='Код')),
('name', models.CharField(max_length=80, unique=True, verbose_name='Название')),
('kind', models.CharField(choices=[('earning', 'Начисление'), ('adjustment_credit', 'Коррекция +'), ('adjustment_debit', 'Коррекция −'), ('deduction', 'Удержание'), ('payment', 'Выплата')], max_length=24, verbose_name='Влияние на расчёт')),
('requires_reason', models.BooleanField(default=False, verbose_name='Требовать основание')),
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
('display_order', models.PositiveIntegerField(default=900, verbose_name='Порядок')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создана')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_payroll_components', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
],
options={
'verbose_name': 'Пользовательская зарплатная колонка',
'verbose_name_plural': 'Пользовательские зарплатные колонки',
'ordering': ['display_order', 'name', 'id'],
},
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.2.4 on 2026-08-26 11:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0027_payrollcustomcomponent'),
]
operations = [
migrations.AddField(
model_name='payrollcustomcomponent',
name='period_scope',
field=models.CharField(choices=[('current_only', 'Только выбранный период'), ('current_and_future', 'Выбранный и будущие периоды'), ('all', 'Все периоды')], default='all', max_length=24, verbose_name='Периоды отображения'),
),
migrations.AddField(
model_name='payrollcustomcomponent',
name='scope_period',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='scoped_custom_components', to='finance.payrollperiod', verbose_name='Начальный период'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2026-08-26 11:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0028_payrollcustomcomponent_period_scope_and_more'),
]
operations = [
migrations.AlterField(
model_name='payrollcustomcomponent',
name='name',
field=models.CharField(max_length=80, verbose_name='Название'),
),
]

View File

@ -21,8 +21,10 @@ from .enums import (
PayrollCarryoverDirection,
PayrollCarryoverStatus,
PayrollComponentKind,
PayrollComponentPeriodScope,
PayrollPeriodStatus,
PayrollRunStatus,
VacationPayInstructionStatus,
)
currency_validator = RegexValidator(
@ -41,6 +43,10 @@ payroll_cell_column_validator = RegexValidator(
DEFAULT_DAILY_TARGET_POINTS = Decimal("5")
def generate_custom_payroll_component_code() -> str:
return f"CUSTOM_{uuid.uuid4().hex.upper()}"
class FinanceSettings(models.Model):
"""Global access settings for the native finance management workspace."""
@ -919,6 +925,132 @@ class PayrollComponent:
objects = _PayrollComponentRegistryManager()
class PayrollCustomComponent(models.Model):
"""User-created payroll column with immutable calculation semantics."""
code = models.CharField(
"Код",
max_length=64,
unique=True,
default=generate_custom_payroll_component_code,
editable=False,
validators=[component_code_validator],
)
name = models.CharField("Название", max_length=80)
kind = models.CharField(
"Влияние на расчёт",
max_length=24,
choices=PayrollComponentKind.choices,
)
requires_reason = models.BooleanField(
"Требовать основание",
default=False,
)
is_active = models.BooleanField("Активна", default=True)
display_order = models.PositiveIntegerField("Порядок", default=900)
period_scope = models.CharField(
"Периоды отображения",
max_length=24,
choices=PayrollComponentPeriodScope.choices,
default=PayrollComponentPeriodScope.ALL,
)
scope_period = models.ForeignKey(
PayrollPeriod,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="scoped_custom_components",
verbose_name="Начальный период",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_payroll_components",
verbose_name="Создал",
)
created_at = models.DateTimeField("Создана", auto_now_add=True)
class Meta:
verbose_name = "Пользовательская зарплатная колонка"
verbose_name_plural = "Пользовательские зарплатные колонки"
ordering = ["display_order", "name", "id"]
def __str__(self):
return self.name
def clean(self):
super().clean()
self.name = self.name.strip()
if not self.name:
raise ValidationError({"name": "Укажите название колонки."})
if self.period_scope == PayrollComponentPeriodScope.ALL:
self.scope_period = None
elif self.scope_period_id is None:
raise ValidationError(
{"scope_period": "Для выбранной области укажите расчётный период."}
)
if not self.is_active:
return
from finance.payroll.components import PAYROLL_COMPONENTS
normalized_name = self.name.casefold()
has_builtin_conflict = any(
component.name.casefold() == normalized_name
for component in PAYROLL_COMPONENTS
)
custom_components = (
PayrollCustomComponent.objects.filter(is_active=True)
.exclude(pk=self.pk)
.select_related("scope_period")
)
has_custom_conflict = any(
component.name.casefold() == normalized_name
and self.period_scope_overlaps(component)
for component in custom_components
)
if has_builtin_conflict or has_custom_conflict:
raise ValidationError(
{
"name": (
"Колонка с таким названием уже существует "
"в одном из выбранных периодов."
)
}
)
def period_scope_overlaps(self, other):
"""Return whether two component columns can appear in the same period."""
if (
self.period_scope == PayrollComponentPeriodScope.ALL
or other.period_scope == PayrollComponentPeriodScope.ALL
):
return True
if self.scope_period_id is None or other.scope_period_id is None:
return False
if (
self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY
and other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY
):
return self.scope_period_id == other.scope_period_id
if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
return self.scope_period.date_from >= other.scope_period.date_from
if other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
return other.scope_period.date_from >= self.scope_period.date_from
return True
def applies_to_period(self, period):
if self.period_scope == PayrollComponentPeriodScope.ALL:
return True
if period is None or self.scope_period_id is None:
return False
if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
return period.pk == self.scope_period_id
return period.date_from >= self.scope_period.date_from
class PayrollInputLine(DraftVersionedModel):
"""Append-only bonus, correction, deduction or recorded payment input."""
@ -1097,7 +1229,12 @@ class PayrollInputLine(DraftVersionedModel):
}
)
try:
component = self.component
from finance.payroll.components import get_payroll_component
component = get_payroll_component(
self.component_code,
period=self.period if self.period_id else None,
)
except ValueError as exc:
raise ValidationError({"component_code": str(exc)}) from exc
if component.requires_reason and not self.reason:
@ -1112,6 +1249,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."""

View File

@ -3,6 +3,7 @@
from dataclasses import dataclass
from finance.enums import PayrollComponentKind
from finance.enums import PayrollComponentPeriodScope
@dataclass(frozen=True, slots=True)
@ -25,6 +26,14 @@ class PayrollComponentDefinition:
def is_active(self) -> bool:
return True
@property
def period_scope(self) -> str:
return PayrollComponentPeriodScope.ALL
@property
def scope_period_id(self) -> None:
return None
def get_kind_display(self) -> str:
return PayrollComponentKind(self.kind).label
@ -92,11 +101,54 @@ class _PayrollComponentCollection(tuple):
class PayrollComponent:
"""Compatibility facade over the code registry; this is not a database model."""
objects = _PayrollComponentCollection(PAYROLL_COMPONENTS)
class _Manager:
def all(self):
return get_payroll_components()
def get(self, **criteria):
return self.all().get(**criteria)
def filter(self, **criteria):
return self.all().filter(**criteria)
def exclude(self, **criteria):
return self.all().exclude(**criteria)
def order_by(self, *fields):
return self.all().order_by(*fields)
objects = _Manager()
def get_payroll_component(code: str) -> PayrollComponentDefinition:
try:
return PAYROLL_COMPONENTS_BY_CODE[code]
except KeyError as exc:
raise ValueError(f"Неизвестный вид зарплатной операции: {code}") from exc
def get_payroll_components(period=None) -> _PayrollComponentCollection:
"""Return built-ins plus active user-created component columns."""
from finance.models import PayrollCustomComponent
custom = list(
PayrollCustomComponent.objects.filter(is_active=True)
.select_related("scope_period")
.order_by("display_order", "name", "id")
)
if period is not None:
custom = [component for component in custom if component.applies_to_period(period)]
return _PayrollComponentCollection((*PAYROLL_COMPONENTS, *custom))
def get_payroll_component(code: str, *, period=None) -> PayrollComponentDefinition:
component = PAYROLL_COMPONENTS_BY_CODE.get(code)
if component is not None:
return component
from finance.models import PayrollCustomComponent
component = (
PayrollCustomComponent.objects.filter(code=code, is_active=True)
.select_related("scope_period")
.first()
)
if component is not None and (
period is None or component.applies_to_period(period)
):
return component
raise ValueError(f"Неизвестный вид зарплатной операции: {code}")

View File

@ -29,7 +29,7 @@ from finance.models import (
PayrollWorkRecord,
PayrollWorkSettings,
)
from finance.payroll.components import PAYROLL_COMPONENTS
from finance.payroll.components import get_payroll_components
from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request
from .balances import opening_balances
@ -371,7 +371,7 @@ def build_payroll_period_table(period: PayrollPeriod):
work_records = _latest_work_by_employee(period, employee_ids)
input_lines = _input_lines_by_employee(period, employee_ids)
balances = opening_balances(period, employee_ids)
components = PAYROLL_COMPONENTS
components = get_payroll_components(period)
component_definitions = {
component.code: _component_definition(
component.code,

View File

@ -24,6 +24,7 @@ from finance.enums import (
InputSource,
PayrollPeriodStatus,
PayrollRunStatus,
VacationPayInstructionStatus,
)
from finance.models import (
EmployeePayRate,
@ -37,6 +38,7 @@ from finance.models import (
PayrollStatementLine,
PayrollWorkSettings,
PayrollWorkRecord,
VacationPayInstruction,
)
from .adapter import build_core_request, employee_snapshot
@ -53,6 +55,7 @@ from .config import (
ruleset_not_effective_message,
ruleset_period_details,
)
from .components import get_payroll_component
from .exceptions import PayrollOperationError, PayrollPermissionDenied
from .work_norm import calculate_period_target_points
@ -116,6 +119,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],
@ -1976,13 +1995,21 @@ def set_component_input_line_value(
) -> PayrollInputLine:
"""Replace the aggregate value of one editable spreadsheet component cell."""
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
try:
get_payroll_component(component_code, period=period)
except ValueError as exc:
raise PayrollOperationError(
"COMPONENT_NOT_AVAILABLE",
"Колонка недоступна в выбранном расчётном периоде.",
details={"component_code": component_code},
) from exc
clear_component_input_lines(
period_id,
employee_id=employee_id,
component_code=component_code,
actor=actor,
)
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
line = PayrollInputLine(
period=period,
employee_id=employee_id,
@ -2110,12 +2137,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 +2241,18 @@ def approve_input_line(
expected_lock_version: int,
) -> PayrollInputLine:
_require_permission(actor, "finance.approve_payroll_inputs")
reference = PayrollInputLine.objects.only("period_id").get(
pk=line_id
)
reference = PayrollInputLine.objects.only("period_id").get(pk=line_id)
period = PayrollPeriod.objects.select_for_update().get(pk=reference.period_id)
_ensure_period_accepts_approvals(period)
record = PayrollInputLine.objects.select_for_update().get(pk=line_id)
return _approve_record(
approved_line = _approve_record(
record,
actor=actor,
object_label="input_line",
expected_lock_version=expected_lock_version,
)
_mark_linked_vacation_instruction_posted(approved_line)
return approved_line
@transaction.atomic

View File

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

View File

@ -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",
]

View File

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

View File

@ -0,0 +1,337 @@
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 (
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,
VacationFinanceStatus,
)
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
return max((request_obj.date_to - request_obj.date_from).days + 1, 0)
def can_manage_vacation_terms(user, request_obj: Request) -> bool:
return bool(
getattr(user, "is_authenticated", False)
and (
request_obj.approver_id == user.id
or getattr(user, "is_superuser", False)
or user.has_perm("requests_app.can_process_requests")
)
)
def _matching_payroll_period(request_obj: Request):
if not request_obj.date_from:
return None
return (
PayrollPeriod.objects.filter(
date_from__lte=request_obj.date_from,
date_to__gte=request_obj.date_from,
)
.order_by("date_from", "id")
.first()
)
def _approved_rate_for_vacation(request_obj: Request) -> EmployeePayRate:
rate = (
EmployeePayRate.objects.filter(
employee_id=request_obj.employee_id,
rate_code=base_rate_code(),
status=ApprovalStatus.APPROVED,
effective_from__lte=request_obj.date_from,
)
.order_by("-effective_from", "-revision", "-id")
.first()
)
if rate is None:
raise ValidationError(
{
"record_to_finance": (
"Для сотрудника нет утверждённой ставки на дату начала отпуска."
)
}
)
return rate
def _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,
*,
paid_days: int,
record_to_finance: bool,
) -> None:
if request_obj.type != RequestType.VACATION:
raise ValidationError({"request": "Это заявление не является отпуском."})
if request_obj.status != RequestStatus.APPROVED:
raise ValidationError({"request": "Сначала одобрите заявление на отпуск."})
total_days = vacation_total_days(request_obj)
if paid_days < 0 or paid_days > total_days:
raise ValidationError(
{"paid_days": f"Укажите значение от 0 до {total_days} включительно."}
)
if record_to_finance and paid_days == 0:
raise ValidationError(
{"record_to_finance": "Неоплачиваемый отпуск нечего передавать в финансы."}
)
def _ensure_instruction_is_mutable(
existing_terms,
existing_instruction,
*,
paid_days,
record_to_finance,
) -> None:
if not existing_instruction:
return
line = existing_instruction.payroll_input_line
instruction_is_posted = bool(
(line is not None and line.status == ApprovalStatus.APPROVED)
or existing_instruction.status == VacationPayInstructionStatus.POSTED
)
terms_are_changing = bool(
existing_terms.paid_days != paid_days or not record_to_finance
)
if instruction_is_posted and terms_are_changing:
raise ValidationError(
{
"record_to_finance": (
"Начисление уже создано. Измените или сторнируйте его в финансах."
)
}
)
@transaction.atomic
def save_vacation_terms(
*,
request_obj: Request,
actor,
paid_days: int,
record_to_finance: bool,
) -> RequestVacationTerms:
"""Сохраняет решение по отпуску и идемпотентно обновляет поручение финансам."""
locked_request = Request.objects.select_for_update().get(pk=request_obj.pk)
_validate_vacation_decision(
locked_request,
paid_days=paid_days,
record_to_finance=record_to_finance,
)
existing_terms = (
RequestVacationTerms.objects.select_for_update()
.filter(request=locked_request)
.first()
)
existing_instruction = None
if existing_terms:
existing_instruction = (
VacationPayInstruction.objects.select_for_update()
.filter(vacation_terms=existing_terms)
.first()
)
_ensure_instruction_is_mutable(
existing_terms,
existing_instruction,
paid_days=paid_days,
record_to_finance=record_to_finance,
)
instruction_is_posted = bool(
existing_instruction
and (
(
existing_instruction.payroll_input_line_id
and existing_instruction.payroll_input_line.status
== ApprovalStatus.APPROVED
)
or existing_instruction.status == VacationPayInstructionStatus.POSTED
)
)
finance_status = VacationFinanceStatus.SKIPPED
if record_to_finance:
finance_status = (
VacationFinanceStatus.POSTED
if instruction_is_posted
else VacationFinanceStatus.PENDING
)
terms, _ = RequestVacationTerms.objects.update_or_create(
request=locked_request,
defaults={
"paid_days": paid_days,
"finance_status": finance_status,
"recorded_by": actor,
},
)
try:
terms.full_clean()
except DjangoValidationError as exc:
raise ValidationError(exc.message_dict) from exc
terms.save(
update_fields=["paid_days", "finance_status", "recorded_by", "updated_at"]
)
if record_to_finance and not instruction_is_posted:
period, daily_rate, amount = _calculate_vacation_pay(
locked_request,
paid_days=paid_days,
)
instruction, _ = VacationPayInstruction.objects.update_or_create(
vacation_terms=terms,
defaults={
"period": period,
"paid_days": paid_days,
"amount": amount,
"status": VacationPayInstructionStatus.DRAFT,
"created_by": (
existing_instruction.created_by if existing_instruction else actor
),
},
)
try:
instruction.full_clean()
except DjangoValidationError as exc:
raise ValidationError(exc.message_dict) from exc
instruction.save()
line = _upsert_payroll_input_line(
request_obj=locked_request,
instruction=instruction,
actor=actor,
period=period,
paid_days=paid_days,
daily_rate=daily_rate,
amount=amount,
)
instruction.payroll_input_line = line
instruction.save(
update_fields=["payroll_input_line", "status", "amount", "updated_at"]
)
elif existing_instruction and not instruction_is_posted:
if existing_instruction.payroll_input_line_id:
existing_instruction.payroll_input_line.delete()
existing_instruction.payroll_input_line = None
existing_instruction.status = VacationPayInstructionStatus.VOIDED
existing_instruction.save(
update_fields=["payroll_input_line", "status", "updated_at"]
)
return terms

View File

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

View 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': 'Условия отпусков',
},
),
]

View File

@ -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} оплачиваемых дней"

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import date, datetime, time
from decimal import Decimal
from typing import Any
import pytest
@ -12,7 +13,14 @@ 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 ApprovalStatus, VacationPayInstructionStatus
from finance.models import (
EmployeePayRate,
PayrollInputLine,
PayrollPeriod,
VacationPayInstruction,
)
from tasks.models import (
Task,
TaskBoard,
@ -100,6 +108,55 @@ def test_marks_only_opened_request_notifications_read(
assert unrelated_notification.unread is True
@pytest.mark.parametrize("decision", ["approve", "reject"])
def test_decision_marks_only_actors_request_notifications_read(
auth_client,
make_user,
make_request,
decision: str,
) -> None:
"""Решение читает уведомления по заявлению только у принявшего его сотрудника."""
owner = make_user(email=f"decision-{decision}-owner@example.com")
actor = make_user(email=f"decision-{decision}-actor@example.com")
other_recipient = make_user(email=f"decision-{decision}-other@example.com")
employee_request = make_request(
employee=owner,
status=RequestStatus.PENDING,
)
employee_request.recipients.add(actor, other_recipient)
actor_notification = Notification.objects.create(
recipient=actor,
verb="request_new",
data={"request_id": employee_request.id},
)
other_recipient_notification = Notification.objects.create(
recipient=other_recipient,
verb="request_new",
data={"request_id": employee_request.id},
)
unrelated_notification = Notification.objects.create(
recipient=actor,
verb="request_new",
data={"request_id": employee_request.id + 1},
)
response = auth_client(actor).post(
f"{API_BASE}{employee_request.id}/{decision}/",
data={},
format="json",
)
assert response.status_code == 200
actor_notification.refresh_from_db()
other_recipient_notification.refresh_from_db()
unrelated_notification.refresh_from_db()
assert actor_notification.unread is False
assert actor_notification.timestamp_read is not None
assert other_recipient_notification.unread is True
assert unrelated_notification.unread is True
def test_statistics_requires_privileged_access(
auth_client, regular_user: models.Model, make_user
) -> None:
@ -224,8 +281,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 +350,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 +410,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 +481,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 +783,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 +808,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 +863,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 +923,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 +1143,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 +1190,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 +1234,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 +1277,143 @@ 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),
)
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/",
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.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(
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 +1428,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 +1490,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 +1502,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 +1535,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 +1611,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 +1781,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=")

View File

@ -23,6 +23,7 @@ from finance.models import (
PayrollAuditEvent,
PayrollCellCommentThread,
PayrollComponent,
PayrollCustomComponent,
PayrollDailyWorkEntry,
PayrollInputLine,
PayrollPeriod,
@ -98,6 +99,236 @@ def make_approved_period_inputs(
)
def test_manager_can_add_custom_component_column(
user_factory,
auth_client_factory,
):
manager = user_factory(email="component.manager@example.test")
employee = user_factory(email="component.employee@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
period = make_period(creator=manager)
client = auth_client_factory(manager)
created = client.post(
api_url("admin-component-list"),
{
"name": "Компенсация питания",
"kind": "earning",
"requires_reason": True,
"period_scope": "current_and_future",
"period_id": period.pk,
},
format="json",
)
assert created.status_code == 201, created.content
component = created.json()
assert component["id"] == component["code"]
assert component["code"].startswith("CUSTOM_")
assert component["name"] == "Компенсация питания"
assert component["kind"] == "earning"
assert component["requires_reason"] is True
assert component["period_scope"] == "current_and_future"
assert component["scope_period_id"] == period.pk
assert PayrollCustomComponent.objects.filter(code=component["code"]).exists()
saved = client.post(
api_url("admin-component-cell-set", pk=period.pk),
{
"employee_id": employee.pk,
"component_code": component["code"],
"amount": "1250.00",
},
format="json",
)
assert saved.status_code == 201, saved.content
line = PayrollInputLine.objects.get(pk=saved.json()["id"])
assert line.component.name == "Компенсация питания"
assert line.reason == "Перезаписано в итоговой таблице"
table = client.get(api_url("admin-period-table", pk=period.pk))
assert table.status_code == 200, table.content
assert component["code"] in {
item["code"] for item in table.json()["component_columns"]
}
row = next(
item for item in table.json()["rows"] if item["employee"]["id"] == employee.pk
)
assert row["component_amounts"] == {component["code"]: "1250.00"}
assert row["gross_before_adjustments"] == "1250.00"
assert row["gross_total"] == "1250.00"
assert row["net_pay"] == "1250.00"
assert row["payable"] == "1250.00"
@pytest.mark.parametrize(
("period_scope", "expected_visibility"),
[
("current_only", (False, True, False)),
("current_and_future", (False, True, True)),
("all", (True, True, True)),
],
)
def test_custom_component_period_scope_controls_table_visibility(
user_factory,
auth_client_factory,
period_scope,
expected_visibility,
):
manager = user_factory(email=f"scope.{period_scope}@example.test")
employee = user_factory(email=f"scope.employee.{period_scope}@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
periods = [
PayrollPeriod.objects.create(
code="2026-05",
name="Май 2026",
date_from="2026-05-01",
date_to="2026-05-31",
pay_date="2026-06-05",
currency="RUB",
created_by=manager,
),
make_period(creator=manager),
PayrollPeriod.objects.create(
code="2026-07",
name="Июль 2026",
date_from="2026-07-01",
date_to="2026-07-31",
pay_date="2026-08-05",
currency="RUB",
created_by=manager,
),
]
client = auth_client_factory(manager)
created = client.post(
api_url("admin-component-list"),
{
"name": f"Колонка {period_scope}",
"kind": "payment",
"requires_reason": False,
"period_scope": period_scope,
"period_id": periods[1].pk,
},
format="json",
)
assert created.status_code == 201, created.content
component_code = created.json()["code"]
visibility = []
for period in periods:
response = client.get(api_url("admin-period-table", pk=period.pk))
assert response.status_code == 200, response.content
visibility.append(
component_code
in {item["code"] for item in response.json()["component_columns"]}
)
assert tuple(visibility) == expected_visibility
unavailable_period = next(
(
period
for period, visible in zip(periods, expected_visibility, strict=True)
if not visible
),
None,
)
if unavailable_period is not None:
rejected = client.post(
api_url("admin-component-cell-set", pk=unavailable_period.pk),
{
"employee_id": employee.pk,
"component_code": component_code,
"amount": "500.00",
},
format="json",
)
assert rejected.status_code == 409, rejected.content
assert rejected.json()["code"] == "COMPONENT_NOT_AVAILABLE"
assert not PayrollInputLine.objects.filter(
period=unavailable_period,
employee=employee,
component_code=component_code,
).exists()
def test_custom_component_name_is_unique_only_in_overlapping_periods(
user_factory,
auth_client_factory,
):
manager = user_factory(email="scope.name.manager@example.test")
grant(manager, "manage_payroll_inputs")
periods = [
PayrollPeriod.objects.create(
code="2026-05",
name="Май 2026",
date_from="2026-05-01",
date_to="2026-05-31",
pay_date="2026-06-05",
currency="RUB",
created_by=manager,
),
make_period(creator=manager),
PayrollPeriod.objects.create(
code="2026-07",
name="Июль 2026",
date_from="2026-07-01",
date_to="2026-07-31",
pay_date="2026-08-05",
currency="RUB",
created_by=manager,
),
]
client = auth_client_factory(manager)
def create_component(*, period, period_scope, name="Компенсация"):
return client.post(
api_url("admin-component-list"),
{
"name": name,
"kind": "earning",
"requires_reason": False,
"period_scope": period_scope,
"period_id": period.pk,
},
format="json",
)
may_only = create_component(
period=periods[0],
period_scope="current_only",
)
june_only = create_component(
period=periods[1],
period_scope="current_only",
name="КОМПЕНСАЦИЯ",
)
from_july = create_component(
period=periods[2],
period_scope="current_and_future",
)
assert may_only.status_code == 201, may_only.content
assert june_only.status_code == 201, june_only.content
assert from_july.status_code == 201, from_july.content
overlapping_requests = [
create_component(period=periods[2], period_scope="current_only"),
create_component(period=periods[1], period_scope="current_and_future"),
create_component(period=periods[0], period_scope="all"),
]
for response in overlapping_requests:
assert response.status_code == 400, response.content
assert "выбранных периодов" in response.json()["name"][0]
builtin_conflict = create_component(
period=periods[0],
period_scope="current_only",
name="ПРЕМИЯ",
)
assert builtin_conflict.status_code == 400, builtin_conflict.content
assert "выбранных периодов" in builtin_conflict.json()["name"][0]
def test_copy_input_lines_previews_scope_and_handles_existing_values(
user_factory,
auth_client_factory,

View File

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

View File

@ -72,6 +72,7 @@ import type {
PayrollBulkPointRateResult,
PayrollBulkTargetPointsResult,
PayrollCalculationBlocker,
PayrollCustomComponentWrite,
PayrollInputLineWrite,
PayrollInputCopyResult,
PayrollInputHistoryEvent,
@ -902,6 +903,19 @@ export function PayrollAdminWorkspace({
}
};
const addComponentColumn = async (payload: PayrollCustomComponentWrite) => {
if (!canManageSourceData || !selectedPeriod) return;
await apiClient.createPayrollAdminComponent({
...payload,
period_id: selectedPeriod.id,
});
await Promise.all([
loadWorkspace(selectedPeriod.id, true),
refreshInlineTable("inputs"),
]);
setNotice(`Колонка «${payload.name}» добавлена.`);
};
const saveRateCell = async (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => {
if (!canManageSourceData || !selectedPeriod) return;
const rawValue = value.trim().replace(",", ".");
@ -1254,7 +1268,7 @@ export function PayrollAdminWorkspace({
{tab === "inputs" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Начисления и выплаты" description="Премии, корректировки, удержания и авансы вводятся отдельными строками. Утверждённые строки исправляются новой корректировкой." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<PayrollInputHistory events={inputHistory} loading={historyLoading} error={historyError} /><TableToolbar search={search} status={status} sort={inputSort} onSearch={setSearch} onStatus={setStatus} onSort={setInputSort} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /><PayrollInputLinesTable records={filteredInputs} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} /></section> : null}
{tab === "summary" ? (
<section className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
<PayrollPeriodTableView compact data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
<PayrollPeriodTableView compact data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onAddComponent={canManageSourceData ? addComponentColumn : undefined} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
</section>
) : null}
{tab === "approval" ? (

View File

@ -16,8 +16,8 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { AlertTriangle, ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, RotateCcw, Search, Table2, X } from "lucide-react";
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { AlertTriangle, ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, Plus, RotateCcw, Search, Table2, X } from "lucide-react";
import { Fragment, type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { PayrollPointBreakdownModal } from "@/components/finance/admin/PayrollPointBreakdownModal";
@ -27,6 +27,9 @@ import {
} from "@/components/finance/admin/PayrollCellCommentsModal";
import { useMiddleMousePan } from "@/hooks/useMiddleMousePan";
import type {
PayrollComponentPeriodScope,
PayrollCustomComponentWrite,
PayrollLineKind,
PayrollPeriodTable,
PayrollPeriodTableComponent,
PayrollPeriodTableRow,
@ -52,6 +55,44 @@ const kindLabels = {
payment: "Выплата",
};
const kindHelpItems: Array<{
kind: PayrollLineKind;
description: string;
formula: string;
}> = [
{
kind: "earning",
description: "Основная часть дохода: например, премия, отпускные или компенсация. Входит в сумму до корректировок.",
formula: "Увеличивает «Начислено» и «К выплате».",
},
{
kind: "adjustment_credit",
description: "Доплата, которая исправляет или уточняет уже рассчитанные начисления.",
formula: "Прибавляется к «Корректировкам», «Начислено» и «К выплате».",
},
{
kind: "adjustment_debit",
description: "Уменьшение, которое исправляет или уточняет уже рассчитанные начисления.",
formula: "Вычитается из «Корректировок», «Начислено» и «К выплате».",
},
{
kind: "deduction",
description: "Сумма, которую удерживают из начисленного дохода. Само начисление при этом не меняется.",
formula: "Уменьшает «После удержаний» и «К выплате».",
},
{
kind: "payment",
description: "Деньги, которые уже выданы сотруднику: например, аванс или фактическая выплата.",
formula: "Уменьшает только остаток «К выплате», не меняя начисления и удержания.",
},
];
const periodScopeLabels: Record<PayrollComponentPeriodScope, string> = {
current_only: "Только текущий период",
current_and_future: "Текущий и будущие периоды",
all: "Все периоды",
};
const headerCellClass = "app-surface-muted overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 text-left text-[10px] font-semibold text-[var(--muted-foreground)]";
const bodyCellClass = "overflow-hidden whitespace-normal border-b border-r border-[var(--border-subtle)] px-1.5 py-2 text-right text-[11px] leading-tight text-[var(--foreground)] tabular-nums";
const HIDDEN_COLUMNS_STORAGE_PREFIX = "finances.payroll.period-table.hidden-columns";
@ -415,6 +456,177 @@ function CalculationHelpModal({ help, rules, onClose }: { help: CalculationHelp;
);
}
function AddPayrollComponentModal({
onCreate,
onClose,
}: {
onCreate: (payload: PayrollCustomComponentWrite) => Promise<void>;
onClose: () => void;
}) {
const [name, setName] = useState("");
const [kind, setKind] = useState<PayrollLineKind>("earning");
const [requiresReason, setRequiresReason] = useState(false);
const [periodScope, setPeriodScope] = useState<PayrollComponentPeriodScope>("current_and_future");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
const helpOpenRef = useRef(false);
useEffect(() => {
helpOpenRef.current = helpOpen;
}, [helpOpen]);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
nameRef.current?.focus();
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
if (helpOpenRef.current) {
setHelpOpen(false);
return;
}
if (!pending) onClose();
};
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("keydown", closeOnEscape);
previouslyFocused?.focus();
};
}, [onClose, pending]);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedName = name.trim();
if (!normalizedName) {
setError("Укажите название колонки.");
return;
}
setPending(true);
setError(null);
try {
await onCreate({
name: normalizedName,
kind,
requires_reason: requiresReason,
period_scope: periodScope,
});
onClose();
} catch (createError) {
setError(getPayrollAdminError(createError, "Не удалось добавить колонку."));
} finally {
setPending(false);
}
};
if (typeof document === "undefined") return null;
return createPortal(
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4" role="presentation">
<button type="button" className="absolute inset-0 bg-black/60" onClick={pending ? undefined : onClose} aria-label="Закрыть добавление колонки" />
<section className="app-surface relative z-10 w-full max-w-md rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="payroll-add-component-title">
<div className="flex items-start justify-between gap-4">
<div>
<p className="app-text-muted text-xs uppercase tracking-wide">Начисления и выплаты</p>
<h3 id="payroll-add-component-title" className="mt-1 inline-flex items-center gap-1.5 text-lg font-semibold text-[var(--foreground)]">
<span>Добавить колонку</span>
<button
type="button"
onClick={() => setHelpOpen(true)}
className="app-accent-text inline-flex h-5 w-5 items-center justify-center rounded-full transition hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
aria-label="Открыть справку о типах колонок"
aria-haspopup="dialog"
aria-expanded={helpOpen}
title="Как тип колонки влияет на расчёт"
>
<CircleHelp size={14} strokeWidth={2} />
</button>
</h3>
</div>
<button type="button" className="app-action-ghost inline-flex h-8 w-8 items-center justify-center rounded-lg" onClick={onClose} disabled={pending} aria-label="Закрыть"><X size={17} /></button>
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
<label className="block">
<span className="app-field-label">Название</span>
<input ref={nameRef} value={name} onChange={(event) => setName(event.target.value)} maxLength={80} className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm" placeholder="Например, Компенсация питания" disabled={pending} />
</label>
<label className="block">
<span className="app-field-label">Как влияет на расчёт</span>
<select value={kind} onChange={(event) => setKind(event.target.value as PayrollLineKind)} className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm" disabled={pending}>
{Object.entries(kindLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
<label className="block">
<span className="app-field-label">В каких периодах показывать</span>
<select value={periodScope} onChange={(event) => setPeriodScope(event.target.value as PayrollComponentPeriodScope)} className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm" disabled={pending}>
{Object.entries(periodScopeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<span className="app-text-muted mt-1.5 block text-xs leading-relaxed">
Новая колонка появится согласно выбранной области; суммы между периодами автоматически не копируются.
</span>
</label>
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
<input type="checkbox" checked={requiresReason} onChange={(event) => setRequiresReason(event.target.checked)} className="mt-0.5 h-4 w-4 rounded" disabled={pending} />
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">Требовать основание</span>
<span className="app-text-muted mt-0.5 block text-xs">При вводе операции пользователь должен будет указать причину.</span>
</span>
</label>
{error ? <div className="app-feedback-danger rounded-lg px-3 py-2.5 text-sm">{error}</div> : null}
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium" onClick={onClose} disabled={pending}>Отмена</button>
<button type="submit" className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold" disabled={pending}>
{pending ? <Loader2 className="animate-spin" size={15} /> : <Plus size={15} />}
Добавить
</button>
</div>
</form>
</section>
{helpOpen ? <PayrollComponentKindsHelpModal onClose={() => setHelpOpen(false)} /> : null}
</div>,
document.body,
);
}
function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
closeButtonRef.current?.focus();
return () => previouslyFocused?.focus();
}, []);
return (
<div className="fixed inset-0 z-[1010] flex items-center justify-center p-4" role="presentation">
<button type="button" className="absolute inset-0 bg-black/60" onClick={onClose} aria-label="Закрыть справку о типах колонок" />
<section className="app-surface relative z-10 w-full max-w-lg rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="payroll-component-kinds-help-title">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="app-text-muted text-xs uppercase tracking-wide">Начисления и выплаты</p>
<h3 id="payroll-component-kinds-help-title" className="mt-1 text-lg font-semibold text-[var(--foreground)]">Как выбрать тип колонки</h3>
</div>
<button ref={closeButtonRef} type="button" className="app-action-ghost inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg" onClick={onClose} aria-label="Закрыть"><X size={17} /></button>
</div>
<div className="mt-5 space-y-2.5">
{kindHelpItems.map((item) => (
<section key={item.kind} className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<h4 className="text-sm font-semibold text-[var(--foreground)]">{kindLabels[item.kind]}</h4>
<p className="app-text-muted mt-1 text-xs leading-relaxed">{item.description}</p>
<p className="mt-1.5 text-xs font-medium text-[var(--foreground)]">{item.formula}</p>
</section>
))}
</div>
<div className="app-selected mt-4 rounded-xl px-3 py-2.5 text-xs leading-relaxed">
<span className="font-semibold">Итоговая формула: </span>
К выплате = Начислено − Удержано − Выплачено.
</div>
</section>
</div>
);
}
function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["point_policy"]): Record<string, CalculationHelp> {
const pointRecalculationLogic = pointPolicy === "proportional_with_excess"
? "Повторяет исходную Excel-таблицу: баллы изменяют общую базу из оклада и премии. Недовыполнение даёт отрицательный перерасчёт, перевыполнение — положительный."
@ -834,6 +1046,7 @@ export function PayrollPeriodTableView({
onSaveRate,
onSaveWork,
onSaveComponent,
onAddComponent,
onOpenCarryovers,
onSetPaymentConfirmed,
compact = false,
@ -851,6 +1064,7 @@ export function PayrollPeriodTableView({
onSaveRate?: (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => Promise<void>;
onSaveWork?: (row: PayrollPeriodTableRow, field: "target_points" | "actual_points", value: string) => Promise<void>;
onSaveComponent?: (row: PayrollPeriodTableRow, componentCode: string, value: string) => Promise<void>;
onAddComponent?: (payload: PayrollCustomComponentWrite) => Promise<void>;
onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void;
onSetPaymentConfirmed?: (row: PayrollPeriodTableRow, confirmed: boolean) => Promise<void>;
compact?: boolean;
@ -872,6 +1086,7 @@ export function PayrollPeriodTableView({
displayName: string;
} | null>(null);
const [columnsMenuOpen, setColumnsMenuOpen] = useState(false);
const [addComponentOpen, setAddComponentOpen] = useState(false);
const [rowsMenuOpen, setRowsMenuOpen] = useState(false);
const [compactPanel, setCompactPanel] = useState<CompactPeriodPanel | null>("table");
const [paymentConfirmationBusyIds, setPaymentConfirmationBusyIds] = useState<Set<number>>(new Set());
@ -1580,7 +1795,20 @@ export function PayrollPeriodTableView({
<th rowSpan={2} className={`${headerCellClass} sticky left-0 z-40 align-middle`}>Сотрудник</th>
{groupSegments.map((segment, index) => (
<th key={`${segment.group}-${index}`} colSpan={segment.columns.length} className={`${headerCellClass} text-center uppercase tracking-wide ${index > 0 ? "!border-l-2 !border-l-[var(--border-strong)]" : ""}`}>
{segment.group}
<span className="inline-flex items-center justify-center gap-1.5">
<span>{segment.group}</span>
{onAddComponent && segment.group === "Начисления и выплаты" && groupSegments.findIndex((item) => item.group === segment.group) === index ? (
<button
type="button"
className="app-action-ghost inline-flex h-5 w-5 items-center justify-center rounded-md text-[var(--accent-primary)]"
onClick={() => setAddComponentOpen(true)}
aria-label="Добавить колонку начисления или выплаты"
title="Добавить колонку"
>
<Plus size={13} strokeWidth={2.5} />
</button>
) : null}
</span>
</th>
))}
</tr>
@ -1660,6 +1888,7 @@ export function PayrollPeriodTableView({
</div>
</DndContext> : null}
{activeHelp ? <CalculationHelpModal help={activeHelp} rules={data.calculation_rules} onClose={() => setActiveHelp(null)} /> : null}
{addComponentOpen && onAddComponent ? <AddPayrollComponentModal onCreate={onAddComponent} onClose={() => setAddComponentOpen(false)} /> : null}
{breakdownEmployee ? (
<PayrollPointBreakdownModal
periodId={data.period_id}

View File

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

View File

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

View File

@ -0,0 +1,269 @@
"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 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);
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">
{financeInstruction?.amount
? `Начисление ${financeInstruction.amount} ₽: ${paidDays} дн. × ${financeInstruction.daily_rate} ₽.`
: `Создать начисление отпускных за ${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>
);
}

View File

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

View File

@ -7,6 +7,11 @@ export type PayrollLineKind =
| "deduction"
| "payment";
export type PayrollComponentPeriodScope =
| "current_only"
| "current_and_future"
| "all";
export type PayrollPeriodSummary = {
code: string;
name: string;
@ -581,6 +586,15 @@ export type PayrollComponent = {
requires_reason: boolean;
is_active: boolean;
display_order: number;
period_scope: PayrollComponentPeriodScope;
scope_period_id: number | null;
};
export type PayrollCustomComponentWrite = {
name: string;
kind: PayrollLineKind;
requires_reason: boolean;
period_scope: PayrollComponentPeriodScope;
};
export type PayrollAdminPayRate = {
@ -1029,6 +1043,10 @@ export function createFinanceApi(request: RequestFn) {
),
getPayrollAdminWorkspace: (periodId?: number): Promise<PayrollAdminWorkspace> =>
request(`${adminPrefix}/workspace/${buildQuery({ period_id: periodId })}`),
createPayrollAdminComponent: (
payload: PayrollCustomComponentWrite & { period_id: number },
): Promise<PayrollComponent> =>
request(`${adminPrefix}/components/`, jsonOptions("POST", payload)),
getPayrollAdminPeriods: (): Promise<PayrollAdminListPayload<PayrollAdminPeriod>> =>
request(`${adminPrefix}/periods/`),
getPayrollAdminPeriodTable: (periodId: number): Promise<PayrollPeriodTable> =>

View File

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

View File

@ -6,13 +6,37 @@ import {
normalizePayrollColumnOrder,
} from "./payroll-columns.ts";
test("payroll column order keeps saved columns and appends newly available ones", () => {
test("payroll column order keeps saved columns and places newly available ones by default neighbors", () => {
assert.deepEqual(
normalizePayrollColumnOrder(
["payable", "actual_points", "removed", "payable"],
["rate_amount", "actual_points", "payable", "status"],
),
["payable", "actual_points", "rate_amount", "status"],
["payable", "rate_amount", "actual_points", "status"],
);
});
test("new payroll component stays before the calculation group", () => {
assert.deepEqual(
normalizePayrollColumnOrder(
["rate_amount", "component:BONUS", "gross_total", "payable", "status"],
[
"rate_amount",
"component:BONUS",
"component:CUSTOM_ADVANCE",
"gross_total",
"payable",
"status",
],
),
[
"rate_amount",
"component:BONUS",
"component:CUSTOM_ADVANCE",
"gross_total",
"payable",
"status",
],
);
});

View File

@ -9,8 +9,18 @@ export function normalizePayrollColumnOrder(
seen.add(columnId);
return true;
});
for (const columnId of availableIds) {
if (!seen.has(columnId)) normalized.push(columnId);
for (const [availableIndex, columnId] of availableIds.entries()) {
if (seen.has(columnId)) continue;
const nextKnownId = availableIds
.slice(availableIndex + 1)
.find((candidateId) => seen.has(candidateId));
if (nextKnownId) {
normalized.splice(normalized.indexOf(nextKnownId), 0, columnId);
} else {
normalized.push(columnId);
}
seen.add(columnId);
}
return normalized;
}

View File

@ -1265,6 +1265,40 @@ 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;
daily_rate: 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 +1312,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;