merge: vacation approval finance workflow
This commit is contained in:
commit
4283dc64d4
@ -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": "Колонка отсутствует в расчётной таблице."})
|
||||
|
||||
@ -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"]
|
||||
)
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -20,9 +20,7 @@ class IsRecipientOfRequest(BasePermission):
|
||||
def has_permission(self, request: Request, view: Any) -> bool:
|
||||
return bool(getattr(request.user, "is_authenticated", False))
|
||||
|
||||
def has_object_permission(
|
||||
self, request: Request, view: Any, obj: Any
|
||||
) -> bool:
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
# Решение можно принимать только по заявке в статусе pending
|
||||
# и только прямому получателю (не cc_users).
|
||||
return (
|
||||
@ -39,9 +37,7 @@ class CanViewRequest(BasePermission):
|
||||
def has_permission(self, request: Request, view: Any) -> bool:
|
||||
return bool(getattr(request.user, "is_authenticated", False))
|
||||
|
||||
def has_object_permission(
|
||||
self, request: Request, view: Any, obj: Any
|
||||
) -> bool:
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
user = request.user
|
||||
|
||||
# Черновик - только авторское состояние.
|
||||
@ -71,9 +67,7 @@ class IsRequestAuthor(BasePermission):
|
||||
def has_permission(self, request: Request, view: Any) -> bool:
|
||||
return bool(getattr(request.user, "is_authenticated", False))
|
||||
|
||||
def has_object_permission(
|
||||
self, request: Request, view: Any, obj: Any
|
||||
) -> bool:
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
return getattr(obj, "employee_id", None) == request.user.id
|
||||
|
||||
|
||||
@ -91,22 +85,14 @@ class CommentsPermission(BasePermission):
|
||||
if getattr(obj, "employee_id", None) == user.id:
|
||||
return True
|
||||
# Получатель заявки
|
||||
if (
|
||||
hasattr(obj, "recipients")
|
||||
and obj.recipients.filter(id=user.id).exists()
|
||||
):
|
||||
if hasattr(obj, "recipients") and obj.recipients.filter(id=user.id).exists():
|
||||
return True
|
||||
# В копии
|
||||
if (
|
||||
hasattr(obj, "cc_users")
|
||||
and obj.cc_users.filter(id=user.id).exists()
|
||||
):
|
||||
if hasattr(obj, "cc_users") and obj.cc_users.filter(id=user.id).exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_object_permission(
|
||||
self, request: Request, view: Any, obj: Any
|
||||
) -> bool:
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
if getattr(obj, "status", None) == "draft":
|
||||
return False
|
||||
return self._is_participant(request.user, obj)
|
||||
@ -117,9 +103,19 @@ class NotFinalOrStaff(BasePermission):
|
||||
|
||||
message = "Финальная заявка не может быть удалена."
|
||||
|
||||
def has_object_permission(
|
||||
self, request: Request, view: Any, obj: Any
|
||||
) -> bool:
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
if request.method != "DELETE":
|
||||
return True
|
||||
return not bool(getattr(obj, "is_final", False))
|
||||
|
||||
|
||||
class CanManageVacationTerms(BasePermission):
|
||||
message = "У вас нет права изменять условия этого отпуска."
|
||||
|
||||
def has_permission(self, request: Request, view: Any) -> bool:
|
||||
return bool(getattr(request.user, "is_authenticated", False))
|
||||
|
||||
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
||||
from requests_app.application import can_manage_vacation_terms
|
||||
|
||||
return can_manage_vacation_terms(request.user, obj)
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
from .fields import RecipientIDsField
|
||||
from .read import RequestProfileSummarySerializer, RequestReadSerializer
|
||||
from .write import RequestWriteSerializer
|
||||
from .vacation import (
|
||||
RequestVacationTermsReadSerializer,
|
||||
RequestVacationTermsWriteSerializer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RecipientIDsField",
|
||||
"RequestProfileSummarySerializer",
|
||||
"RequestReadSerializer",
|
||||
"RequestWriteSerializer",
|
||||
"RequestVacationTermsReadSerializer",
|
||||
"RequestVacationTermsWriteSerializer",
|
||||
]
|
||||
|
||||
@ -7,9 +7,12 @@ from django.db import models
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from requests_app.application import can_manage_vacation_terms
|
||||
from requests_app.enums import RequestStatus, RequestType
|
||||
from requests_app.models import Request
|
||||
|
||||
from ...employees.serializers import EmployeeBriefSerializer
|
||||
from .vacation import RequestVacationTermsReadSerializer
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -45,6 +48,9 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
starts_at = serializers.DateTimeField(read_only=True)
|
||||
ends_at = serializers.DateTimeField(read_only=True)
|
||||
duration_minutes = serializers.IntegerField(read_only=True)
|
||||
vacation_terms = serializers.SerializerMethodField()
|
||||
vacation_terms_required = serializers.SerializerMethodField()
|
||||
can_manage_vacation_terms = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Request
|
||||
@ -82,6 +88,9 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
"can_decide",
|
||||
"comments_count",
|
||||
"linked_tasks",
|
||||
"vacation_terms",
|
||||
"vacation_terms_required",
|
||||
"can_manage_vacation_terms",
|
||||
)
|
||||
read_only_fields = (
|
||||
"employee",
|
||||
@ -101,6 +110,9 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
"can_decide",
|
||||
"comments_count",
|
||||
"linked_tasks",
|
||||
"vacation_terms",
|
||||
"vacation_terms_required",
|
||||
"can_manage_vacation_terms",
|
||||
)
|
||||
|
||||
@extend_schema_field(serializers.IntegerField())
|
||||
@ -168,6 +180,23 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
user=user,
|
||||
)
|
||||
|
||||
def get_vacation_terms(self, obj):
|
||||
terms = getattr(obj, "vacation_terms", None)
|
||||
if terms is None:
|
||||
return None
|
||||
return RequestVacationTermsReadSerializer(terms).data
|
||||
|
||||
def get_vacation_terms_required(self, obj):
|
||||
return bool(
|
||||
obj.type == RequestType.VACATION
|
||||
and obj.status == RequestStatus.APPROVED
|
||||
and getattr(obj, "vacation_terms", None) is None
|
||||
)
|
||||
|
||||
def get_can_manage_vacation_terms(self, obj):
|
||||
request = self.context.get("request")
|
||||
return can_manage_vacation_terms(getattr(request, "user", None), obj)
|
||||
|
||||
|
||||
class RequestProfileSummarySerializer(serializers.ModelSerializer):
|
||||
"""Компактное представление заявления для профиля сотрудника."""
|
||||
|
||||
53
backend/api/v1/requests_app/request_serializers/vacation.py
Normal file
53
backend/api/v1/requests_app/request_serializers/vacation.py
Normal 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()
|
||||
@ -9,6 +9,7 @@ from .viewsets import (
|
||||
RequestNotificationsMixin,
|
||||
RequestStatisticsMixin,
|
||||
RequestWorkflowMixin,
|
||||
RequestVacationMixin,
|
||||
)
|
||||
|
||||
|
||||
@ -16,6 +17,7 @@ class RequestViewSet(
|
||||
RequestCoreMixin,
|
||||
RequestCommentsMixin,
|
||||
RequestWorkflowMixin,
|
||||
RequestVacationMixin,
|
||||
RequestStatisticsMixin,
|
||||
RequestNotificationsMixin,
|
||||
viewsets.ModelViewSet,
|
||||
@ -39,5 +41,7 @@ class RequestViewSet(
|
||||
"employee",
|
||||
"approver",
|
||||
"department",
|
||||
"vacation_terms__recorded_by",
|
||||
"vacation_terms__finance_instruction",
|
||||
).order_by("-created_at")
|
||||
parser_classes = (MultiPartParser, FormParser, JSONParser)
|
||||
|
||||
@ -3,6 +3,7 @@ from .core import RequestCoreMixin
|
||||
from .notifications import RequestNotificationsMixin
|
||||
from .statistics import RequestStatisticsMixin
|
||||
from .workflow import RequestWorkflowMixin
|
||||
from .vacation import RequestVacationMixin
|
||||
|
||||
__all__ = [
|
||||
"RequestCommentsMixin",
|
||||
@ -10,4 +11,5 @@ __all__ = [
|
||||
"RequestNotificationsMixin",
|
||||
"RequestStatisticsMixin",
|
||||
"RequestWorkflowMixin",
|
||||
"RequestVacationMixin",
|
||||
]
|
||||
|
||||
@ -17,6 +17,7 @@ from requests_app.models import Request as EmployeeRequest
|
||||
from requests_app.models import RequestSettings
|
||||
|
||||
from ..permissions import (
|
||||
CanManageVacationTerms,
|
||||
CanViewRequest,
|
||||
CommentsPermission,
|
||||
IsRecipientOfRequest,
|
||||
@ -42,6 +43,7 @@ class RequestCoreMixin:
|
||||
"retrieve": CanViewRequest,
|
||||
"employee_statistics": CanViewRequest,
|
||||
"mark_notifications_read": CanViewRequest,
|
||||
"vacation_terms": CanManageVacationTerms,
|
||||
}
|
||||
if self.action == "destroy":
|
||||
return [IsRequestAuthor(), NotFinalOrStaff()]
|
||||
@ -219,12 +221,17 @@ class RequestCoreMixin:
|
||||
"retrieve",
|
||||
"employee_statistics",
|
||||
"mark_notifications_read",
|
||||
"vacation_terms",
|
||||
}
|
||||
if getattr(self, "action", None) in detail_actions:
|
||||
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
||||
lookup_value = self.kwargs[lookup_url_kwarg]
|
||||
queryset = EmployeeRequest.objects.select_related(
|
||||
"employee", "approver", "department"
|
||||
"employee",
|
||||
"approver",
|
||||
"department",
|
||||
"vacation_terms__recorded_by",
|
||||
"vacation_terms__finance_instruction",
|
||||
)
|
||||
obj = get_object_or_404(
|
||||
queryset,
|
||||
|
||||
@ -19,9 +19,9 @@ from ..serializers import RequestProfileSummarySerializer
|
||||
|
||||
def _validated_period(request):
|
||||
period = (request.query_params.get("period") or "all").strip().lower()
|
||||
if period not in {"all", "year", "month", "custom"}:
|
||||
if period not in {"all", "half_year", "year", "month", "custom"}:
|
||||
raise ValidationError(
|
||||
"Параметр period должен быть one of: all, year, month, custom."
|
||||
"Параметр period должен быть one of: all, half_year, year, month, custom."
|
||||
)
|
||||
return period
|
||||
|
||||
@ -48,7 +48,9 @@ class RequestStatisticsMixin:
|
||||
|
||||
page_raw = (request.query_params.get("page") or "1").strip()
|
||||
if not page_raw.isdigit() or int(page_raw) < 1:
|
||||
raise ValidationError("Параметр page должен быть положительным целым числом.")
|
||||
raise ValidationError(
|
||||
"Параметр page должен быть положительным целым числом."
|
||||
)
|
||||
page = int(page_raw)
|
||||
|
||||
period = _validated_period(request)
|
||||
|
||||
92
backend/api/v1/requests_app/viewsets/vacation.py
Normal file
92
backend/api/v1/requests_app/viewsets/vacation.py
Normal file
@ -0,0 +1,92 @@
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from requests_app.application import (
|
||||
build_employee_statistics,
|
||||
resolve_stats_window_from_query,
|
||||
save_vacation_terms,
|
||||
vacation_total_days,
|
||||
)
|
||||
from requests_app.enums import RequestStatus, RequestType
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
|
||||
from ..serializers import (
|
||||
RequestReadSerializer,
|
||||
RequestVacationTermsReadSerializer,
|
||||
RequestVacationTermsWriteSerializer,
|
||||
)
|
||||
|
||||
|
||||
VACATION_STATS_PERIODS = {"half_year", "year", "month", "all", "custom"}
|
||||
|
||||
|
||||
class RequestVacationMixin:
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get", "put", "patch"],
|
||||
url_path="vacation-terms",
|
||||
)
|
||||
def vacation_terms(self, request, pk=None):
|
||||
request_obj = self.get_object()
|
||||
if request_obj.type != RequestType.VACATION:
|
||||
raise ValidationError("Условия оплаты доступны только для отпуска.")
|
||||
if request_obj.status != RequestStatus.APPROVED:
|
||||
raise ValidationError("Сначала одобрите заявление на отпуск.")
|
||||
|
||||
if request.method == "GET":
|
||||
return Response(self._vacation_terms_context(request_obj, request))
|
||||
|
||||
serializer = RequestVacationTermsWriteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
save_vacation_terms(
|
||||
request_obj=request_obj,
|
||||
actor=request.user,
|
||||
paid_days=serializer.validated_data["paid_days"],
|
||||
record_to_finance=serializer.validated_data["record_to_finance"],
|
||||
)
|
||||
refreshed = EmployeeRequest.objects.select_related(
|
||||
"employee",
|
||||
"approver",
|
||||
"department",
|
||||
"vacation_terms__recorded_by",
|
||||
"vacation_terms__finance_instruction",
|
||||
).get(pk=request_obj.pk)
|
||||
return Response(
|
||||
{
|
||||
"request": RequestReadSerializer(
|
||||
refreshed,
|
||||
context=self.get_serializer_context(),
|
||||
).data,
|
||||
**self._vacation_terms_context(refreshed, request),
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _vacation_terms_context(request_obj, request):
|
||||
period = (request.query_params.get("period") or "half_year").strip().lower()
|
||||
if period not in VACATION_STATS_PERIODS:
|
||||
raise ValidationError(
|
||||
"Параметр period должен быть one of: half_year, year, month, all, custom."
|
||||
)
|
||||
start_date, end_date = resolve_stats_window_from_query(
|
||||
period,
|
||||
request.query_params,
|
||||
)
|
||||
terms = getattr(request_obj, "vacation_terms", None)
|
||||
return {
|
||||
"request_id": request_obj.id,
|
||||
"total_days": vacation_total_days(request_obj),
|
||||
"terms": (
|
||||
RequestVacationTermsReadSerializer(terms).data
|
||||
if terms is not None
|
||||
else None
|
||||
),
|
||||
"statistics": build_employee_statistics(
|
||||
employee_id=request_obj.employee_id,
|
||||
employee_name=str(request_obj.employee),
|
||||
period=period,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
),
|
||||
}
|
||||
@ -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"])
|
||||
|
||||
@ -22,6 +22,7 @@ from .models import (
|
||||
PayrollStatementLine,
|
||||
PayrollWorkSettings,
|
||||
PayrollWorkRecord,
|
||||
VacationPayInstruction,
|
||||
)
|
||||
from .payroll.access import has_payroll_permission, has_simple_admin_access
|
||||
from .payroll.exceptions import PayrollOperationError
|
||||
@ -408,9 +409,11 @@ class EmployeePayRateAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin):
|
||||
@transaction.atomic
|
||||
def _delete_revision_chains(self, records):
|
||||
chain_ids = self._revision_chain_ids(records)
|
||||
revisions = EmployeePayRate.objects.select_for_update().filter(
|
||||
pk__in=chain_ids
|
||||
).order_by("-revision", "-pk")
|
||||
revisions = (
|
||||
EmployeePayRate.objects.select_for_update()
|
||||
.filter(pk__in=chain_ids)
|
||||
.order_by("-revision", "-pk")
|
||||
)
|
||||
for revision in revisions:
|
||||
revision.delete()
|
||||
|
||||
@ -602,6 +605,31 @@ class PayrollInputLineAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
@admin.register(VacationPayInstruction)
|
||||
class VacationPayInstructionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"vacation_terms",
|
||||
"paid_days",
|
||||
"period",
|
||||
"amount",
|
||||
"status",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("status", "period")
|
||||
search_fields = (
|
||||
"vacation_terms__request__employee__last_name",
|
||||
"vacation_terms__request__employee__first_name",
|
||||
"vacation_terms__request__employee__email",
|
||||
)
|
||||
autocomplete_fields = (
|
||||
"vacation_terms",
|
||||
"period",
|
||||
"payroll_input_line",
|
||||
"created_by",
|
||||
)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
class PayrollStatementLineInline(admin.TabularInline):
|
||||
model = PayrollStatementLine
|
||||
extra = 0
|
||||
|
||||
@ -16,6 +16,13 @@ class InputSource(models.TextChoices):
|
||||
ATTENDANCE = "attendance", "Посещаемость"
|
||||
|
||||
|
||||
class VacationPayInstructionStatus(models.TextChoices):
|
||||
PENDING_AMOUNT = "pending_amount", "Ожидает расчёта суммы"
|
||||
DRAFT = "draft", "Черновик начисления"
|
||||
POSTED = "posted", "Передано в начисления"
|
||||
VOIDED = "voided", "Отменено"
|
||||
|
||||
|
||||
class PayrollComponentKind(models.TextChoices):
|
||||
EARNING = "earning", "Начисление"
|
||||
ADJUSTMENT_CREDIT = "adjustment_credit", "Коррекция +"
|
||||
@ -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", "Рассчитан"
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
37
backend/finance/migrations/0027_payrollcustomcomponent.py
Normal file
37
backend/finance/migrations/0027_payrollcustomcomponent.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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='Начальный период'),
|
||||
),
|
||||
]
|
||||
@ -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='Название'),
|
||||
),
|
||||
]
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -5,7 +5,7 @@ from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .enums import RequestStatus
|
||||
from .models import Request, RequestSettings
|
||||
from .models import Request, RequestSettings, RequestVacationTerms
|
||||
|
||||
|
||||
@admin.register(RequestSettings)
|
||||
@ -31,6 +31,25 @@ class RequestSettingsAdmin(admin.ModelAdmin):
|
||||
return count or "Все сотрудники"
|
||||
|
||||
|
||||
@admin.register(RequestVacationTerms)
|
||||
class RequestVacationTermsAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"request",
|
||||
"paid_days",
|
||||
"finance_status",
|
||||
"recorded_by",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("finance_status",)
|
||||
search_fields = (
|
||||
"request__employee__last_name",
|
||||
"request__employee__first_name",
|
||||
"request__employee__email",
|
||||
)
|
||||
autocomplete_fields = ("request", "recorded_by")
|
||||
readonly_fields = ("recorded_at", "updated_at")
|
||||
|
||||
|
||||
# Админ-форма: без принуждения вводить approver (мы выставим его автоматически)
|
||||
class RequestAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
|
||||
@ -3,9 +3,17 @@ from .statistics import (
|
||||
can_view_request_statistics,
|
||||
resolve_stats_window_from_query,
|
||||
)
|
||||
from .vacation import (
|
||||
can_manage_vacation_terms,
|
||||
save_vacation_terms,
|
||||
vacation_total_days,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_employee_statistics",
|
||||
"can_manage_vacation_terms",
|
||||
"can_view_request_statistics",
|
||||
"resolve_stats_window_from_query",
|
||||
"save_vacation_terms",
|
||||
"vacation_total_days",
|
||||
]
|
||||
|
||||
@ -28,6 +28,11 @@ def can_view_request_statistics(user) -> bool:
|
||||
|
||||
|
||||
def resolve_stats_window_from_query(period, query_params):
|
||||
if period == "half_year":
|
||||
today = timezone.localdate()
|
||||
month_index = today.year * 12 + today.month - 1 - 5
|
||||
start_year, start_month_index = divmod(month_index, 12)
|
||||
return date(start_year, start_month_index + 1, 1), today
|
||||
if period == "month":
|
||||
today = timezone.localdate()
|
||||
_, last_day = calendar.monthrange(today.year, today.month)
|
||||
@ -73,12 +78,11 @@ def _duration_days_in_window(request_obj, start_date, end_date):
|
||||
if request_obj.date_from and request_obj.date_to:
|
||||
overlap_start = max(request_obj.date_from, start_date)
|
||||
overlap_end = min(request_obj.date_to, end_date)
|
||||
return 0 if overlap_start > overlap_end else (overlap_end - overlap_start).days + 1
|
||||
return int(
|
||||
bool(
|
||||
request_obj.date_from
|
||||
and start_date <= request_obj.date_from <= end_date
|
||||
return (
|
||||
0 if overlap_start > overlap_end else (overlap_end - overlap_start).days + 1
|
||||
)
|
||||
return int(
|
||||
bool(request_obj.date_from and start_date <= request_obj.date_from <= end_date)
|
||||
)
|
||||
|
||||
|
||||
@ -92,6 +96,13 @@ def _is_unpaid_vacation(request_obj):
|
||||
return any(marker in text for marker in UNPAID_VACATION_MARKERS)
|
||||
|
||||
|
||||
def _vacation_paid_days_in_window(request_obj, duration):
|
||||
terms = getattr(request_obj, "vacation_terms", None)
|
||||
if terms is None:
|
||||
return 0 if _is_unpaid_vacation(request_obj) else duration
|
||||
return min(terms.paid_days, duration)
|
||||
|
||||
|
||||
def build_employee_statistics(
|
||||
*,
|
||||
employee_id,
|
||||
@ -117,30 +128,42 @@ def build_employee_statistics(
|
||||
"paid_vacation_days": 0,
|
||||
"unpaid_vacation_days": 0,
|
||||
}
|
||||
approved_requests = EmployeeRequest.objects.filter(
|
||||
employee_id=employee_id,
|
||||
status=RequestStatus.APPROVED,
|
||||
).only("type", "date_from", "date_to", "time_from", "time_to", "title", "comment")
|
||||
approved_requests = (
|
||||
EmployeeRequest.objects.filter(
|
||||
employee_id=employee_id,
|
||||
status=RequestStatus.APPROVED,
|
||||
)
|
||||
.select_related("vacation_terms")
|
||||
.only(
|
||||
"type",
|
||||
"date_from",
|
||||
"date_to",
|
||||
"time_from",
|
||||
"time_to",
|
||||
"title",
|
||||
"comment",
|
||||
"vacation_terms__paid_days",
|
||||
)
|
||||
)
|
||||
for request_obj in approved_requests:
|
||||
duration = _duration_days_in_window(request_obj, start_date, end_date)
|
||||
if request_obj.type == RequestType.SICK_LEAVE:
|
||||
totals["sick_leave_days"] += duration
|
||||
elif request_obj.type == RequestType.SHORT_DAY_OFF:
|
||||
if request_obj.date_from and (
|
||||
start_date is None or start_date <= request_obj.date_from
|
||||
) and (end_date is None or request_obj.date_from <= end_date):
|
||||
if (
|
||||
request_obj.date_from
|
||||
and (start_date is None or start_date <= request_obj.date_from)
|
||||
and (end_date is None or request_obj.date_from <= end_date)
|
||||
):
|
||||
totals["short_day_off_minutes"] += request_obj.duration_minutes or 0
|
||||
elif request_obj.type == RequestType.DAY_OFF:
|
||||
totals["day_off_days"] += duration
|
||||
elif request_obj.type == RequestType.MATERNITY:
|
||||
totals["maternity_days"] += duration
|
||||
elif request_obj.type == RequestType.VACATION:
|
||||
key = (
|
||||
"unpaid_vacation_days"
|
||||
if _is_unpaid_vacation(request_obj)
|
||||
else "paid_vacation_days"
|
||||
)
|
||||
totals[key] += duration
|
||||
paid_days = _vacation_paid_days_in_window(request_obj, duration)
|
||||
totals["paid_vacation_days"] += paid_days
|
||||
totals["unpaid_vacation_days"] += max(duration - paid_days, 0)
|
||||
|
||||
return {
|
||||
"employee_id": employee_id,
|
||||
@ -149,9 +172,17 @@ def build_employee_statistics(
|
||||
"date_from": start_date.isoformat() if start_date else None,
|
||||
"date_to": end_date.isoformat() if end_date else None,
|
||||
"total_submitted_requests": base_queryset.count(),
|
||||
"sick_leave_requests_count": base_queryset.filter(type=RequestType.SICK_LEAVE).count(),
|
||||
"day_off_requests_count": base_queryset.filter(type__in=DAY_OFF_REQUEST_TYPES).count(),
|
||||
"short_day_off_requests_count": base_queryset.filter(type=RequestType.SHORT_DAY_OFF).count(),
|
||||
"maternity_requests_count": base_queryset.filter(type=RequestType.MATERNITY).count(),
|
||||
"sick_leave_requests_count": base_queryset.filter(
|
||||
type=RequestType.SICK_LEAVE
|
||||
).count(),
|
||||
"day_off_requests_count": base_queryset.filter(
|
||||
type__in=DAY_OFF_REQUEST_TYPES
|
||||
).count(),
|
||||
"short_day_off_requests_count": base_queryset.filter(
|
||||
type=RequestType.SHORT_DAY_OFF
|
||||
).count(),
|
||||
"maternity_requests_count": base_queryset.filter(
|
||||
type=RequestType.MATERNITY
|
||||
).count(),
|
||||
**totals,
|
||||
}
|
||||
|
||||
337
backend/requests_app/application/vacation.py
Normal file
337
backend/requests_app/application/vacation.py
Normal 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
|
||||
@ -16,9 +16,7 @@ class RequestType(TextChoices):
|
||||
OTHER = "other", "Другое"
|
||||
|
||||
|
||||
DAY_OFF_REQUEST_TYPES = frozenset(
|
||||
{RequestType.DAY_OFF, RequestType.SHORT_DAY_OFF}
|
||||
)
|
||||
DAY_OFF_REQUEST_TYPES = frozenset({RequestType.DAY_OFF, RequestType.SHORT_DAY_OFF})
|
||||
|
||||
|
||||
class RequestStatus(TextChoices):
|
||||
@ -31,6 +29,15 @@ class RequestStatus(TextChoices):
|
||||
CANCELLED = "cancelled", "Отменено"
|
||||
|
||||
|
||||
class VacationFinanceStatus(TextChoices):
|
||||
"""Состояние передачи согласованных условий отпуска в финансы."""
|
||||
|
||||
NOT_DECIDED = "not_decided", "Не решено"
|
||||
SKIPPED = "skipped", "Не передавать"
|
||||
PENDING = "pending", "Ожидает расчёта"
|
||||
POSTED = "posted", "Передано в начисления"
|
||||
|
||||
|
||||
FINAL_STATUS = {
|
||||
RequestStatus.APPROVED,
|
||||
RequestStatus.REJECTED,
|
||||
|
||||
32
backend/requests_app/migrations/0016_requestvacationterms.py
Normal file
32
backend/requests_app/migrations/0016_requestvacationterms.py
Normal file
@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-26 09:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('requests_app', '0015_add_request_recipient_settings'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RequestVacationTerms',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('paid_days', models.PositiveSmallIntegerField(default=0, verbose_name='Оплачиваемые дни')),
|
||||
('finance_status', models.CharField(choices=[('not_decided', 'Не решено'), ('skipped', 'Не передавать'), ('pending', 'Ожидает расчёта'), ('posted', 'Передано в начисления')], default='not_decided', max_length=16, verbose_name='Передача в финансы')),
|
||||
('recorded_at', models.DateTimeField(auto_now_add=True, verbose_name='Зафиксировано')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||
('recorded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recorded_vacation_terms', to=settings.AUTH_USER_MODEL, verbose_name='Условия зафиксировал')),
|
||||
('request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='vacation_terms', to='requests_app.request', verbose_name='Заявление')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Условия отпуска',
|
||||
'verbose_name_plural': 'Условия отпусков',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -13,7 +13,7 @@ from .constants import (
|
||||
SAFE_EXTENSIONS,
|
||||
SAFE_MIME_TYPES,
|
||||
)
|
||||
from .enums import FINAL_STATUS, RequestStatus, RequestType
|
||||
from .enums import FINAL_STATUS, RequestStatus, RequestType, VacationFinanceStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -404,3 +404,62 @@ class Request(models.Model):
|
||||
return (
|
||||
f"{self.get_type_display()} — {self.employee} ({self.get_status_display()})"
|
||||
)
|
||||
|
||||
|
||||
class RequestVacationTerms(models.Model):
|
||||
"""Согласованные условия оплаты отпуска по заявлению."""
|
||||
|
||||
request = models.OneToOneField(
|
||||
Request,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="vacation_terms",
|
||||
verbose_name="Заявление",
|
||||
)
|
||||
paid_days = models.PositiveSmallIntegerField(
|
||||
"Оплачиваемые дни",
|
||||
default=0,
|
||||
)
|
||||
finance_status = models.CharField(
|
||||
"Передача в финансы",
|
||||
max_length=16,
|
||||
choices=VacationFinanceStatus.choices,
|
||||
default=VacationFinanceStatus.NOT_DECIDED,
|
||||
)
|
||||
recorded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="recorded_vacation_terms",
|
||||
verbose_name="Условия зафиксировал",
|
||||
)
|
||||
recorded_at = models.DateTimeField("Зафиксировано", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Условия отпуска"
|
||||
verbose_name_plural = "Условия отпусков"
|
||||
|
||||
@property
|
||||
def total_days(self) -> int:
|
||||
if not (self.request.date_from and self.request.date_to):
|
||||
return 0
|
||||
return max((self.request.date_to - self.request.date_from).days + 1, 0)
|
||||
|
||||
@property
|
||||
def unpaid_days(self) -> int:
|
||||
return max(self.total_days - self.paid_days, 0)
|
||||
|
||||
def clean(self):
|
||||
errors = {}
|
||||
if self.request.type != RequestType.VACATION:
|
||||
errors["request"] = "Условия оплаты доступны только для отпуска."
|
||||
if self.request.status != RequestStatus.APPROVED:
|
||||
errors["request"] = "Сначала одобрите заявление на отпуск."
|
||||
if self.paid_days > self.total_days:
|
||||
errors["paid_days"] = (
|
||||
"Оплачиваемых дней не может быть больше длительности отпуска."
|
||||
)
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
def __str__(self):
|
||||
return f"Заявление #{self.request_id}: {self.paid_days} оплачиваемых дней"
|
||||
|
||||
@ -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=")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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" ? (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -12,7 +12,7 @@ import { CommentComposer, CommentDeleteButton } from "@/components/shared/Commen
|
||||
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
|
||||
import { displayUserName, formatDate, formatDateTime } from "@/lib/shared";
|
||||
import type { Request, RequestComment } from "@/types/api";
|
||||
import { Ban, Link2, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
|
||||
import { Ban, CalendarDays, Link2, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
|
||||
import { getRequestActionState } from "./requestActions";
|
||||
import { RequestUserBadge } from "./RequestUserBadge";
|
||||
|
||||
@ -36,6 +36,7 @@ type RequestDetailModalProps = {
|
||||
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
|
||||
onReject: (requestId: number) => void | Promise<void>;
|
||||
onSetCommentDraft: (requestId: number, value: string) => void;
|
||||
onVacationTerms: (request: Request) => void;
|
||||
request: Request | null;
|
||||
};
|
||||
|
||||
@ -59,6 +60,7 @@ export function RequestDetailModal({
|
||||
onPreviewAttachment,
|
||||
onReject,
|
||||
onSetCommentDraft,
|
||||
onVacationTerms,
|
||||
request,
|
||||
}: RequestDetailModalProps) {
|
||||
if (!request) return null;
|
||||
@ -76,6 +78,11 @@ export function RequestDetailModal({
|
||||
: "";
|
||||
const commentCount = request.comments_count ?? comments.length;
|
||||
const linkedTasks = request.linked_tasks || [];
|
||||
const canEditVacationTerms = Boolean(
|
||||
String(request.type || request.request_type || "").toLowerCase() === "vacation"
|
||||
&& request.status === "approved"
|
||||
&& request.can_manage_vacation_terms,
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -90,8 +97,18 @@ export function RequestDetailModal({
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
{(canProcess || canCancel || canEdit || canDelete) ? (
|
||||
{(canProcess || canCancel || canEdit || canDelete || canEditVacationTerms) ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{canEditVacationTerms ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onVacationTerms(request)}
|
||||
className={`${request.vacation_terms_required ? "app-feedback-warning" : "app-action-secondary"} inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium`}
|
||||
>
|
||||
<CalendarDays size={16} />
|
||||
{request.vacation_terms_required ? "Заполнить условия отпуска" : "Условия отпуска"}
|
||||
</button>
|
||||
) : null}
|
||||
{canProcess ? (
|
||||
<>
|
||||
<button
|
||||
|
||||
@ -13,6 +13,7 @@ import { displayUserName, formatDate, formatDateTime } from "@/lib/shared";
|
||||
import type { Request, RequestComment } from "@/types/api";
|
||||
import {
|
||||
Ban,
|
||||
CalendarDays,
|
||||
Link2,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
@ -42,6 +43,7 @@ type RequestDetailPanelProps = {
|
||||
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
|
||||
onReject: (requestId: number) => void | Promise<void>;
|
||||
onSetCommentDraft: (requestId: number, value: string) => void;
|
||||
onVacationTerms: (request: Request) => void;
|
||||
request: Request;
|
||||
};
|
||||
|
||||
@ -70,6 +72,7 @@ export function RequestDetailPanel({
|
||||
onPreviewAttachment,
|
||||
onReject,
|
||||
onSetCommentDraft,
|
||||
onVacationTerms,
|
||||
request,
|
||||
}: RequestDetailPanelProps) {
|
||||
const author = request.employee || request.created_by;
|
||||
@ -85,7 +88,12 @@ export function RequestDetailPanel({
|
||||
: "";
|
||||
const commentCount = request.comments_count ?? comments.length;
|
||||
const linkedTasks = request.linked_tasks || [];
|
||||
const hasActions = canProcess || canCancel || canEdit || canDelete;
|
||||
const canEditVacationTerms = Boolean(
|
||||
typeKey === "vacation"
|
||||
&& request.status === "approved"
|
||||
&& request.can_manage_vacation_terms,
|
||||
);
|
||||
const hasActions = canProcess || canCancel || canEdit || canDelete || canEditVacationTerms;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-sm text-[var(--foreground)]">
|
||||
@ -116,6 +124,16 @@ export function RequestDetailPanel({
|
||||
|
||||
{hasActions ? (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-[var(--border-subtle)] pt-3">
|
||||
{canEditVacationTerms ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onVacationTerms(request)}
|
||||
className={`${request.vacation_terms_required ? "app-feedback-warning" : "app-action-secondary"} inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium`}
|
||||
>
|
||||
<CalendarDays size={14} />
|
||||
{request.vacation_terms_required ? "Заполнить условия отпуска" : "Условия отпуска"}
|
||||
</button>
|
||||
) : null}
|
||||
{canProcess ? (
|
||||
<>
|
||||
<button
|
||||
|
||||
269
frontend/src/components/requests/RequestVacationTermsModal.tsx
Normal file
269
frontend/src/components/requests/RequestVacationTermsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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> =>
|
||||
|
||||
@ -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 }) }),
|
||||
|
||||
@ -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",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user