From 8fd5ae105485172d749614079b86f57e0b1a2f6f Mon Sep 17 00:00:00 2001
From: lizerk
Date: Tue, 4 Aug 2026 20:19:12 +0300
Subject: [PATCH] feat: expand document procurement and request workflows
---
backend/api/v1/documents/permissions.py | 42 +-
backend/api/v1/documents/serializers.py | 4 +
backend/api/v1/documents/views.py | 18 +-
backend/api/v1/procurement/permissions.py | 14 +-
backend/api/v1/procurement/serializers.py | 27 +-
backend/api/v1/procurement/views.py | 321 +++++-----
backend/api/v1/requests_app/serializers.py | 45 +-
backend/api/v1/requests_app/validators.py | 7 +-
backend/api/v1/requests_app/views.py | 18 +-
backend/attendance/services.py | 110 +++-
backend/documents/admin.py | 12 +-
backend/documents/audience.py | 10 +
backend/documents/notifications/handlers.py | 33 +-
backend/documents/rules.py | 6 +
backend/employees/services/personnel_state.py | 109 +++-
backend/employees/signals/common.py | 25 +-
backend/eusrr_backend/settings.py | 2 +
backend/finance/payroll/reporting.py | 11 +
backend/finance/payroll/work_norm.py | 57 +-
.../notifications/static/notifications/sw.js | 49 +-
.../migrations/0017_add_item_defective_at.py | 39 ++
backend/procurement/models.py | 48 +-
backend/procurement/notifications/config.py | 1 +
backend/procurement/notifications/handlers.py | 2 +-
backend/procurement/services.py | 108 +++-
backend/procurement/signals.py | 10 +-
backend/requests_app/admin.py | 1 +
backend/requests_app/enums.py | 6 +
.../migrations/0014_add_short_day_off.py | 35 ++
backend/requests_app/models.py | 51 ++
backend/requests_app/tasks.py | 25 +-
backend/requests_app/tests/test_signals.py | 31 +-
.../api/v1/documents/test_documents_api.py | 246 +++++++-
.../procurement/test_procurement_requests.py | 568 +++++++++++++++++-
.../api/v1/requests_app/test_requests_api.py | 95 ++-
.../tests/employees/test_personnel_state.py | 33 +-
.../finance/test_payroll_short_day_off.py | 161 +++++
frontend/public/sw.js | 48 +-
frontend/src/app/departments/[id]/page.tsx | 27 +-
frontend/src/app/documents/page.tsx | 111 ++--
frontend/src/app/page.tsx | 41 +-
frontend/src/app/procurement/page.tsx | 45 +-
.../attendance/EmployeeAttendanceCard.tsx | 1 +
.../documents/DocumentDetailModal.tsx | 14 +-
.../documents/DocumentMetadataEditor.tsx | 40 +-
.../components/documents/DocumentRelated.tsx | 35 +-
.../components/feed/FeedRegulationCard.tsx | 52 +-
.../ProcurementRequestDetailContent.tsx | 246 ++++++--
.../requests/RequestComposeModal.tsx | 41 +-
.../requests/RequestDetailModal.tsx | 8 +-
.../components/requests/RequestListItem.tsx | 4 +-
.../requests/RequestStatisticsPanel.tsx | 2 +
.../components/requests/SwipeApprovalMode.tsx | 4 +-
frontend/src/contexts/PwaContext.tsx | 22 +
frontend/src/hooks/useProcurementPage.ts | 29 +-
frontend/src/hooks/useRequestsPage.ts | 38 +-
frontend/src/lib/api/procurement.ts | 13 +-
frontend/src/lib/verbTranslations.ts | 2 +
frontend/src/types/api.ts | 12 +-
59 files changed, 2697 insertions(+), 518 deletions(-)
create mode 100644 backend/procurement/migrations/0017_add_item_defective_at.py
create mode 100644 backend/requests_app/migrations/0014_add_short_day_off.py
create mode 100644 backend/tests/finance/test_payroll_short_day_off.py
diff --git a/backend/api/v1/documents/permissions.py b/backend/api/v1/documents/permissions.py
index bd6396e0..dd5091b3 100644
--- a/backend/api/v1/documents/permissions.py
+++ b/backend/api/v1/documents/permissions.py
@@ -9,6 +9,21 @@ from rest_framework.views import View
from ..permissions import AdminOrActionOrModelPerms
+def _is_admin(user) -> bool:
+ return bool(
+ user
+ and user.is_authenticated
+ and (getattr(user, "is_staff", False) or getattr(user, "is_superuser", False))
+ )
+
+
+def _request_sets_regulation(request: Request) -> bool:
+ raw_value = request.data.get("is_regulation")
+ if isinstance(raw_value, bool):
+ return raw_value
+ return str(raw_value or "").strip().lower() in {"1", "true", "yes", "on"}
+
+
def _has_any_model_perm(
user,
app_label: str,
@@ -34,9 +49,15 @@ def _user_has_document_access(user, obj) -> bool:
if obj.recipients.filter(pk=user.pk, is_active=True).exists():
return True
- return obj.departments.filter(
+ if obj.departments.filter(
employeedepartment__employee=user,
employeedepartment__is_active=True,
+ ).exists():
+ return True
+
+ return obj.departments.filter(
+ roles__assignments__employee=user,
+ roles__assignments__is_active=True,
).exists()
@@ -68,9 +89,11 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms):
):
return bool(request.user and request.user.is_authenticated)
- # CREATE → разрешаем всем аутентифицированным (документы создаются
- # неопубликованными)
+ # Обычные документы могут создавать все аутентифицированные сотрудники,
+ # регламенты — только администраторы.
if action == "create":
+ if _request_sets_regulation(request):
+ return _is_admin(request.user)
return bool(request.user and request.user.is_authenticated)
# update/destroy и related documents - проверяем на уровне объекта
@@ -99,6 +122,19 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms):
action = getattr(view, "action", None)
+ if action in ("update", "partial_update") and (
+ getattr(obj, "is_regulation", False)
+ or _request_sets_regulation(request)
+ ):
+ return _is_admin(user)
+
+ if getattr(obj, "is_regulation", False) and action in (
+ "add_related",
+ "remove_related",
+ "revert",
+ ):
+ return _is_admin(user)
+
# Полный доступ при наличии ЛЮБОГО модельного права или staff
if user.is_staff or _has_any_model_perm(user, "documents", "document"):
return True
diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py
index a7954934..73f7563a 100644
--- a/backend/api/v1/documents/serializers.py
+++ b/backend/api/v1/documents/serializers.py
@@ -679,6 +679,10 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
departments_links__department_id__in=department_ids,
departments_links__is_active=True,
)
+ | Q(
+ role_assignments__role__department_id__in=department_ids,
+ role_assignments__is_active=True,
+ )
).values_list("pk", flat=True)
invalid_recipients = acknowledgement_recipient_ids - set(
allowed_recipients
diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py
index 6e06fcf1..ff94f600 100644
--- a/backend/api/v1/documents/views.py
+++ b/backend/api/v1/documents/views.py
@@ -128,6 +128,10 @@ class DocumentViewSet(ModelViewSet):
departments__employeedepartment__employee=user,
departments__employeedepartment__is_active=True,
)
+ | Q(
+ departments__roles__assignments__employee=user,
+ departments__roles__assignments__is_active=True,
+ )
| Q(uploaded_by=user)
).distinct()
# Если нет НИКАКИХ модельных прав на документы - ограничиваем queryset
@@ -149,6 +153,10 @@ class DocumentViewSet(ModelViewSet):
departments__employeedepartment__employee=user,
departments__employeedepartment__is_active=True,
)
+ | Q(
+ departments__roles__assignments__employee=user,
+ departments__roles__assignments__is_active=True,
+ )
| Q(uploaded_by=user)
).distinct()
@@ -227,7 +235,7 @@ class DocumentViewSet(ModelViewSet):
return
from communications.models import Message
- from employees.models import EmployeeDepartment
+ from employees.models import EmployeeDepartment, RoleAssignment
document_ids = [document.id for document in documents]
active_user_ids = set(
@@ -253,6 +261,14 @@ class DocumentViewSet(ModelViewSet):
employees_by_department.setdefault(department_id, set()).add(
employee_id
)
+ for department_id, employee_id in RoleAssignment.objects.filter(
+ role__department_id__in=department_ids,
+ is_active=True,
+ employee__is_active=True,
+ ).values_list("role__department_id", "employee_id"):
+ employees_by_department.setdefault(department_id, set()).add(
+ employee_id
+ )
acknowledged_by_document: dict[int, set[int]] = {
document_id: set() for document_id in document_ids
diff --git a/backend/api/v1/procurement/permissions.py b/backend/api/v1/procurement/permissions.py
index 06e51648..c8599809 100644
--- a/backend/api/v1/procurement/permissions.py
+++ b/backend/api/v1/procurement/permissions.py
@@ -238,7 +238,19 @@ class CanManageProcurementRequest(permissions.BasePermission):
if action == "complete":
return obj.executor == user
- if action in ("mark_all_received", "notify_arrival"):
+ if action == "mark_all_received":
+ if obj.requestor == user:
+ return True
+ if obj.executor == user:
+ return True
+ if obj.processing_department_id:
+ return self._is_dept_member(
+ user,
+ obj.processing_department_id,
+ )
+ return False
+
+ if action == "notify_arrival":
if obj.executor == user:
return True
if obj.processing_department_id:
diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py
index 01a67e3b..9ddf0304 100644
--- a/backend/api/v1/procurement/serializers.py
+++ b/backend/api/v1/procurement/serializers.py
@@ -135,17 +135,6 @@ def _expected_delivery_date_values(item):
return dates
-def _validate_item_quantities(attrs, instance=None):
- quantity = attrs.get("quantity", getattr(instance, "quantity", None))
- for field in ("ordered_quantity", "received_quantity"):
- value = attrs.get(field, getattr(instance, field, None))
- if value is not None and quantity is not None and value > quantity:
- raise serializers.ValidationError(
- {field: "Не может быть больше количества позиции"}
- )
- return attrs
-
-
def _validate_processing_department_available(processing_department):
if not processing_department:
return
@@ -183,6 +172,10 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
read_only=True,
allow_null=True,
)
+ last_defective_at = serializers.DateTimeField(
+ read_only=True,
+ allow_null=True,
+ )
next_expected_delivery_date = serializers.SerializerMethodField()
items_total_count = serializers.SerializerMethodField()
items_received_count = serializers.SerializerMethodField()
@@ -441,6 +434,7 @@ class ProcurementItemSerializer(serializers.ModelSerializer):
"actual_unit_price",
"execution_status",
"execution_status_display",
+ "defective_at",
"executor_comment",
"initial_comment",
"comments_count",
@@ -454,14 +448,12 @@ class ProcurementItemSerializer(serializers.ModelSerializer):
"created_at",
"total_price",
"execution_status_display",
+ "defective_at",
"comments_count",
"attachments",
"attachments_count",
]
- def validate(self, attrs):
- return _validate_item_quantities(attrs, self.instance)
-
def validate_expected_delivery_dates(self, value):
return _validate_expected_delivery_dates(value)
@@ -530,9 +522,6 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer):
]
read_only_fields = ["id"]
- def validate(self, attrs):
- return _validate_item_quantities(attrs, self.instance)
-
def validate_expected_delivery_dates(self, value):
return _validate_expected_delivery_dates(value)
@@ -743,6 +732,7 @@ class ProcurementRequestListSerializer(
"fulfillment_status_display",
"is_viewed",
"last_arrival_notice_at",
+ "last_defective_at",
"total_cost",
"items_count",
"next_expected_delivery_date",
@@ -782,6 +772,7 @@ class ProcurementRequestListSerializer(
"fulfillment_status_display",
"is_viewed",
"last_arrival_notice_at",
+ "last_defective_at",
"items_count",
"total_cost",
"next_expected_delivery_date",
@@ -996,6 +987,7 @@ class ProcurementRequestDetailSerializer(
"fulfillment_status_display",
"is_viewed",
"last_arrival_notice_at",
+ "last_defective_at",
"total_cost",
"actual_cost",
"next_expected_delivery_date",
@@ -1034,6 +1026,7 @@ class ProcurementRequestDetailSerializer(
"is_editable",
"is_viewed",
"last_arrival_notice_at",
+ "last_defective_at",
"total_cost",
"next_expected_delivery_date",
"items_total_count",
diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py
index f41cf1ac..9dc158f9 100644
--- a/backend/api/v1/procurement/views.py
+++ b/backend/api/v1/procurement/views.py
@@ -1,5 +1,6 @@
"""ViewSets для API модуля закупок."""
+from datetime import date
from pathlib import Path
from django.contrib.contenttypes.models import ContentType
@@ -55,7 +56,7 @@ from procurement.notifications.read_service import (
from communications import comments_helpers
from communications.models import Message
from ..employees.serializers import EmployeeBriefSerializer
-from procurement.services import ProcurementApprovalResolver
+from procurement.services import ProcurementApprovalResolver, merge_comment_links
from .permissions import (
CanApproveProcurementRequest,
CanManageEquipment,
@@ -112,7 +113,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
}
search_fields = ["title", "description"]
ordering_fields = ["created_at", "status"]
- ordering = ["-created_at"]
+ ordering = None
def get_serializer_class(self):
"""Выбрать сериализатор в зависимости от действия."""
@@ -237,6 +238,14 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
.order_by("-timestamp")
.values("timestamp")[:1]
)
+ last_defective_at_sub = (
+ ProcurementItem.objects.filter(
+ request_id=OuterRef("pk"),
+ defective_at__isnull=False,
+ )
+ .order_by("-defective_at")
+ .values("defective_at")[:1]
+ )
user = self.request.user
viewed_annotation = (
@@ -262,6 +271,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
arrival_notice_sub,
output_field=DateTimeField(),
),
+ last_defective_at=Subquery(
+ last_defective_at_sub,
+ output_field=DateTimeField(),
+ ),
+ ).annotate(
+ default_sort_at=Coalesce(
+ "last_defective_at",
+ "created_at",
+ output_field=DateTimeField(),
+ ),
)
scope = self.request.query_params.get("scope", None)
period = self.request.query_params.get("period", None)
@@ -290,18 +309,9 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(executor=user)
elif scope == "available":
queryset = queryset.filter(
- Q(
- status__in=[
- ProcurementStatus.WAITING,
- ProcurementStatus.APPROVED,
- ],
- executor__isnull=True,
- )
- | Q(
- status=ProcurementStatus.IN_PROGRESS,
- processing_department__isnull=False,
- executor__isnull=False,
- )
+ status__in=ProcurementApprovalResolver.WORK_ASSIGNABLE_STATUSES,
+ ).exclude(
+ executor=user,
)
if not (
user.is_superuser
@@ -311,25 +321,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
):
queryset = queryset.filter(
Q(
- status=ProcurementStatus.WAITING,
processing_department_id__in=participant_department_ids,
)
| Q(
- status=ProcurementStatus.APPROVED,
processing_department__isnull=True,
)
- | (
- Q(
- status=ProcurementStatus.IN_PROGRESS,
- processing_department_id__in=participant_department_ids,
- )
- & ~Q(executor=user)
- )
- )
- else:
- queryset = queryset.exclude(
- status=ProcurementStatus.IN_PROGRESS,
- executor=user,
)
elif scope == "all":
# Все заявки - применяем стандартную логику прав
@@ -389,6 +385,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
if parsed_date_to:
queryset = queryset.filter(created_at__date__lte=parsed_date_to)
+ if not self.request.query_params.get("ordering"):
+ queryset = queryset.order_by("-default_sort_at", "-id")
return queryset.distinct()
def _attach_linked_task_payloads(
@@ -1020,19 +1018,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
"""Получить заявки, доступные для взятия в работу."""
queryset = self.filter_queryset(
self.get_queryset().filter(
- Q(
- status__in=[
- ProcurementStatus.WAITING,
- ProcurementStatus.APPROVED,
- ],
- executor__isnull=True,
- )
- | Q(
- status=ProcurementStatus.IN_PROGRESS,
- processing_department__isnull=False,
- executor__isnull=False,
- )
- )
+ status__in=ProcurementApprovalResolver.WORK_ASSIGNABLE_STATUSES,
+ ).exclude(executor=request.user)
)
if not (
@@ -1048,25 +1035,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
)
queryset = queryset.filter(
Q(
- status=ProcurementStatus.WAITING,
processing_department_id__in=participant_department_ids,
)
| Q(
- status=ProcurementStatus.APPROVED,
processing_department__isnull=True,
)
- | (
- Q(
- status=ProcurementStatus.IN_PROGRESS,
- processing_department_id__in=participant_department_ids,
- )
- & ~Q(executor=request.user)
- )
- )
- else:
- queryset = queryset.exclude(
- status=ProcurementStatus.IN_PROGRESS,
- executor=request.user,
)
page = self.paginate_queryset(queryset)
@@ -1079,38 +1052,39 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"])
def start_work(self, request, pk=None):
- """Начать работу над заявкой (перевод в статус IN_PROGRESS).
+ """Назначить себя исполнителем заявки.
Общий пул может взять любой авторизованный пользователь.
Адресную заявку может взять сотрудник отдела-исполнителя.
- Адресную заявку в работе может забрать другой сотрудник отдела.
+ Другой доступный сотрудник может забрать уже назначенную заявку.
+ Во время согласования статус PENDING сохраняется.
"""
procurement_request = self.get_object()
self.check_object_permissions(request, procurement_request)
- is_reassignment = (
- procurement_request.status == ProcurementStatus.IN_PROGRESS
- and procurement_request.processing_department_id
- and procurement_request.executor_id
+ if procurement_request.executor_id == request.user.id:
+ return Response(
+ {"error": "Вы уже являетесь исполнителем заявки"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ can_start_work = ProcurementApprovalResolver.user_can_start_work(
+ request.user,
+ procurement_request,
)
+ if not can_start_work:
+ message = (
+ "Вы не можете забрать эту заявку в работу"
+ if procurement_request.executor_id
+ else "Вы не можете взять эту заявку в работу"
+ )
+ return Response(
+ {"error": message},
+ status=status.HTTP_403_FORBIDDEN,
+ )
- if is_reassignment:
+ if procurement_request.executor_id:
previous_executor = procurement_request.executor
- if previous_executor == request.user:
- return Response(
- {"error": "Вы уже являетесь исполнителем заявки"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- if not ProcurementApprovalResolver.user_can_start_work(
- request.user,
- procurement_request,
- ):
- return Response(
- {"error": "Вы не можете забрать эту заявку в работу"},
- status=status.HTTP_403_FORBIDDEN,
- )
-
procurement_request.executor = request.user
procurement_request._notification_actor = request.user
update_fields = ["executor", "updated_at"]
@@ -1140,49 +1114,28 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
- if procurement_request.status not in {
- ProcurementStatus.APPROVED,
- ProcurementStatus.WAITING,
- }:
- return Response(
- {
- "error": (
- "Только одобренные или ожидающие заявки "
- "можно взять в работу"
- )
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Проверяем, что заявка ещё не взята кем-то
- if procurement_request.executor:
- return Response(
- {
- "error": (
- f"Заявка уже взята в работу пользователем "
- f"{procurement_request.executor.get_full_name()}"
- )
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- if not ProcurementApprovalResolver.user_can_start_work(
- request.user,
- procurement_request,
- ):
- return Response(
- {"error": "Вы не можете взять эту заявку в работу"},
- status=status.HTTP_403_FORBIDDEN,
- )
-
- # Назначаем исполнителя и меняем статус
- # Сигнал post_save(ProcurementRequest) с IN_PROGRESS отправит
- # уведомления
+ previous_status = procurement_request.status
procurement_request.executor = request.user
procurement_request.started_at = timezone.now()
- procurement_request.status = ProcurementStatus.IN_PROGRESS
+ update_fields = ["executor", "started_at", "updated_at"]
+ if previous_status != ProcurementStatus.PENDING:
+ procurement_request.status = ProcurementStatus.IN_PROGRESS
+ update_fields.append("status")
procurement_request._notification_actor = request.user
- procurement_request.save()
+ procurement_request.save(update_fields=update_fields)
+
+ if previous_status == ProcurementStatus.PENDING:
+ create_procurement_activity(
+ procurement_request,
+ request.user,
+ ProcurementActivityAction.STARTED,
+ object_kind=ProcurementActivityObjectKind.REQUEST,
+ object_id=procurement_request.pk,
+ metadata={
+ "old_status": previous_status,
+ "new_status": procurement_request.status,
+ },
+ )
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@@ -1242,15 +1195,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
if (
item.execution_status
!= ProcurementItemExecutionStatus.RECEIVED
- or item.ordered_quantity != item.quantity
- or item.received_quantity != item.quantity
+ or item.ordered_quantity != item.fulfillment_target_quantity
+ or item.received_quantity != item.fulfillment_target_quantity
)
]
for item in items_to_update:
+ fulfillment_target_quantity = item.fulfillment_target_quantity
item.execution_status = ProcurementItemExecutionStatus.RECEIVED
- item.ordered_quantity = item.quantity
- item.received_quantity = item.quantity
+ item.ordered_quantity = fulfillment_target_quantity
+ item.received_quantity = fulfillment_target_quantity
ProcurementItem.objects.bulk_update(
items_to_update,
["execution_status", "ordered_quantity", "received_quantity"],
@@ -1556,6 +1510,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"comments",
"delete_comment",
"report_issue",
+ "confirm_ordered",
"confirm_received",
"cancel_received",
"cancel_issue",
@@ -1711,7 +1666,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
if received_quantity > 0:
return (
ProcurementItemExecutionStatus.RECEIVED
- if received_quantity >= item.quantity
+ if received_quantity >= max(item.quantity, ordered_quantity)
else ProcurementItemExecutionStatus.ORDERED
)
if ordered_quantity > 0:
@@ -1731,6 +1686,16 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
},
)
+ def _reset_request_view_states(self, procurement_request):
+ ProcurementRequestView.objects.filter(
+ request_id=procurement_request.pk,
+ is_viewed=True,
+ ).update(
+ is_viewed=False,
+ viewed_at=None,
+ updated_at=timezone.now(),
+ )
+
def _reopen_completed_request(self, procurement_request, user):
if procurement_request.status != ProcurementStatus.COMPLETED:
return
@@ -1899,6 +1864,17 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
actor=self.request.user,
changed_fields=changed_fields,
)
+ if (
+ before["execution_status"]
+ != ProcurementItemExecutionStatus.DEFECTIVE
+ and after["execution_status"]
+ == ProcurementItemExecutionStatus.DEFECTIVE
+ ):
+ self._reset_request_view_states(procurement_request)
+ notify_item_issue_reported(
+ updated_item,
+ actor=self.request.user,
+ )
self._record_item_update(
updated_item,
self.request.user,
@@ -1921,6 +1897,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
item.execution_status = ProcurementItemExecutionStatus.DEFECTIVE
item.save(update_fields=["execution_status"])
+ self._reset_request_view_states(procurement_request)
self._reopen_completed_request(procurement_request, user)
@@ -1945,8 +1922,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
changed_fields=["execution_status"],
)
self._record_item_update(item, user, ["execution_status"])
- if procurement_request.requestor_id == user.id:
- notify_item_issue_reported(item, actor=user)
+ notify_item_issue_reported(item, actor=user)
item.refresh_from_db()
serializer = self.get_serializer(item)
@@ -2003,7 +1979,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
effective_ordered_quantity = item.effective_ordered_quantity
item.received_quantity = target_received_quantity
if item.ordered_quantity is None and effective_ordered_quantity > 0:
- item.ordered_quantity = min(effective_ordered_quantity, item.quantity)
+ item.ordered_quantity = effective_ordered_quantity
item.execution_status = self._workflow_status_from_quantities(item)
item.save(
update_fields=[
@@ -2065,6 +2041,77 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(item)
return Response(serializer.data)
+ @action(detail=True, methods=["post"])
+ def confirm_ordered(self, request, pk=None):
+ """Подтвердить заказ дополнительного количества позиции."""
+ item = self.get_object()
+ procurement_request = item.request
+ user = request.user
+
+ error_response = self._validate_item_quick_action(
+ user,
+ procurement_request,
+ )
+ if error_response is not None:
+ return error_response
+
+ current_ordered_quantity = item.effective_ordered_quantity
+ raw_ordered_quantity = request.data.get("ordered_quantity")
+ try:
+ target_ordered_quantity = int(raw_ordered_quantity)
+ except (TypeError, ValueError):
+ return Response(
+ {"ordered_quantity": "Укажите целое число заказанных единиц"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if target_ordered_quantity <= current_ordered_quantity:
+ return Response(
+ {
+ "ordered_quantity": (
+ "Должно быть больше текущего заказанного количества"
+ )
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ expected_delivery_dates = list(item.expected_delivery_dates or [])
+ delivery_date_added = False
+ raw_delivery_date = request.data.get("expected_delivery_date")
+ if raw_delivery_date not in (None, ""):
+ try:
+ delivery_date = date.fromisoformat(str(raw_delivery_date))
+ except ValueError:
+ return Response(
+ {"expected_delivery_date": "Укажите корректную дату"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ delivery_date_value = delivery_date.isoformat()
+ if delivery_date_value not in expected_delivery_dates:
+ expected_delivery_dates.append(delivery_date_value)
+ delivery_date_added = True
+
+ item.ordered_quantity = target_ordered_quantity
+ item.expected_delivery_dates = expected_delivery_dates
+ item.execution_status = self._workflow_status_from_quantities(item)
+ changed_fields = ["execution_status", "ordered_quantity"]
+ update_fields = ["execution_status", "ordered_quantity"]
+ if delivery_date_added:
+ changed_fields.append("expected_delivery_dates")
+ update_fields.append("expected_delivery_dates")
+ item.save(update_fields=update_fields)
+ self._reopen_completed_request(procurement_request, user)
+ notify_item_updated(
+ item,
+ actor=user,
+ changed_fields=changed_fields,
+ )
+ self._record_item_update(item, user, changed_fields)
+
+ item.refresh_from_db()
+ serializer = self.get_serializer(item)
+ return Response(serializer.data)
+
@action(detail=True, methods=["post"])
def confirm_received(self, request, pk=None):
"""Подтвердить получение позиции."""
@@ -2080,7 +2127,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
return error_response
current_received_quantity = item.effective_received_quantity
- target_received_quantity = item.quantity
+ target_received_quantity = item.fulfillment_target_quantity
if "received_quantity" in request.data:
raw_received_quantity = request.data.get("received_quantity")
@@ -2097,15 +2144,6 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
{"received_quantity": "Количество должно быть больше 0"},
status=status.HTTP_400_BAD_REQUEST,
)
- if target_received_quantity > item.quantity:
- return Response(
- {
- "received_quantity": (
- "Не может быть больше количества позиции"
- )
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
if target_received_quantity <= current_received_quantity:
return Response(
{
@@ -2117,9 +2155,9 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
)
item.received_quantity = target_received_quantity
- item.ordered_quantity = min(
- item.quantity,
- max(item.effective_ordered_quantity, target_received_quantity),
+ item.ordered_quantity = max(
+ item.effective_ordered_quantity,
+ target_received_quantity,
)
item.execution_status = self._workflow_status_from_quantities(item)
item.save(
@@ -2196,6 +2234,10 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
author=request.user,
content=text,
)
+ item_links, added_links = merge_comment_links(item.links, text)
+ if added_links:
+ item.links = item_links
+ item.save(update_fields=["links"])
notify_item_comment(
item,
message,
@@ -2211,6 +2253,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"item_id": item.pk,
"item_name": item.name,
"comment_text": message.content,
+ "added_links": added_links,
},
)
author_ser = EmployeeBriefSerializer(message.author)
@@ -2222,6 +2265,8 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"author": author_ser.data,
"text": message.content,
"created_at": message.created_at,
+ "item_links": item_links,
+ "added_links": added_links,
},
status=status.HTTP_201_CREATED,
)
diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py
index 317e8eb7..4064b59e 100644
--- a/backend/api/v1/requests_app/serializers.py
+++ b/backend/api/v1/requests_app/serializers.py
@@ -97,6 +97,9 @@ class RequestReadSerializer(serializers.ModelSerializer):
# Используем аннотированное поле из queryset
comments_count = serializers.IntegerField(read_only=True, allow_null=True)
linked_tasks = serializers.SerializerMethodField()
+ starts_at = serializers.DateTimeField(read_only=True)
+ ends_at = serializers.DateTimeField(read_only=True)
+ duration_minutes = serializers.IntegerField(read_only=True)
class Meta:
model = Request
@@ -110,6 +113,11 @@ class RequestReadSerializer(serializers.ModelSerializer):
"title",
"date_from",
"date_to",
+ "time_from",
+ "time_to",
+ "starts_at",
+ "ends_at",
+ "duration_minutes",
"comment",
"status",
"attachment",
@@ -313,6 +321,8 @@ class RequestWriteSerializer(serializers.ModelSerializer):
"title",
"date_from",
"date_to",
+ "time_from",
+ "time_to",
"comment",
"status",
"attachment",
@@ -395,6 +405,8 @@ class RequestWriteSerializer(serializers.ModelSerializer):
type_ = attrs.get("type", getattr(self.instance, "type", ""))
date_from = attrs.get("date_from", getattr(self.instance, "date_from", None))
date_to = attrs.get("date_to", getattr(self.instance, "date_to", None))
+ time_from = attrs.get("time_from", getattr(self.instance, "time_from", None))
+ time_to = attrs.get("time_to", getattr(self.instance, "time_to", None))
recipient_ids = attrs.get("recipient_ids")
if recipient_ids is None:
@@ -441,7 +453,25 @@ class RequestWriteSerializer(serializers.ModelSerializer):
{"recipient_ids": "Автор не может быть получателем"}
)
- if type_ in (
+ if type_ == RequestType.SHORT_DAY_OFF:
+ if not (date_from and time_from and time_to):
+ raise serializers.ValidationError(
+ {
+ "date_from": "Укажите дату короткого отгула.",
+ "time_from": "Укажите время начала.",
+ "time_to": "Укажите время окончания.",
+ }
+ )
+ if date_to and date_to != date_from:
+ raise serializers.ValidationError(
+ {"date_to": "Короткий отгул должен быть в пределах одного дня."}
+ )
+ if time_to <= time_from:
+ raise serializers.ValidationError(
+ {"time_to": "Время окончания должно быть позже времени начала."}
+ )
+ attrs["date_to"] = date_from
+ elif type_ in (
RequestType.VACATION,
RequestType.SICK_LEAVE,
RequestType.DAY_OFF,
@@ -458,11 +488,24 @@ class RequestWriteSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
{"date_to": "Дата окончания раньше даты начала."}
)
+ attrs["time_from"] = None
+ attrs["time_to"] = None
elif type_ in (RequestType.TRANSFER, RequestType.DISMISSAL):
if not date_from:
raise serializers.ValidationError(
{"date_from": "Требуется дата начала."}
)
+ attrs["time_from"] = None
+ attrs["time_to"] = None
+ else:
+ if bool(time_from) != bool(time_to):
+ raise serializers.ValidationError(
+ {"time_to": "Укажите оба значения времени или оставьте их пустыми."}
+ )
+ if date_from == date_to and time_from and time_to and time_to <= time_from:
+ raise serializers.ValidationError(
+ {"time_to": "Время окончания должно быть позже времени начала."}
+ )
return attrs
diff --git a/backend/api/v1/requests_app/validators.py b/backend/api/v1/requests_app/validators.py
index fbe9f80c..fe1145ff 100644
--- a/backend/api/v1/requests_app/validators.py
+++ b/backend/api/v1/requests_app/validators.py
@@ -5,7 +5,7 @@ from typing import Any, Mapping
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
-# VACATION/SICK_LEAVE/DAY_OFF/MATERNITY/TRANSFER/DISMISSAL/OTHER
+# SHORT_DAY_OFF/VACATION/SICK_LEAVE/DAY_OFF/MATERNITY/TRANSFER/DISMISSAL/OTHER
from requests_app.enums import RequestType
from requests_app.enums import RequestStatus
@@ -57,6 +57,11 @@ class RequestDatesByTypeValidator:
if not (touches_dates or touches_type):
return
+ # Точный внутридневной интервал проверяет сериализатор: здесь старая
+ # валидация диапазона дат к короткому отгулу неприменима.
+ if type_ == RequestType.SHORT_DAY_OFF:
+ return
+
if type_ in (
RequestType.VACATION,
RequestType.SICK_LEAVE,
diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py
index c06dcf1a..24774638 100644
--- a/backend/api/v1/requests_app/views.py
+++ b/backend/api/v1/requests_app/views.py
@@ -10,7 +10,7 @@ from django.db.models import Count, IntegerField, OuterRef, Q, QuerySet, Subquer
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404 # раскомментируйте импорт
from django.utils import timezone
-from requests_app.enums import RequestStatus, RequestType
+from requests_app.enums import DAY_OFF_REQUEST_TYPES, RequestStatus, RequestType
from requests_app.models import Request as EmployeeRequest
from communications import comments_helpers
from drf_spectacular.utils import OpenApiParameter, extend_schema
@@ -182,12 +182,13 @@ def _build_employee_statistics(
sick_leave_days = 0
day_off_days = 0
+ short_day_off_minutes = 0
maternity_days = 0
paid_vacation_days = 0
unpaid_vacation_days = 0
for req in approved_qs.only(
- "type", "date_from", "date_to", "title", "comment"
+ "type", "date_from", "date_to", "time_from", "time_to", "title", "comment"
):
duration = _request_duration_days_in_window(
req,
@@ -196,6 +197,13 @@ def _build_employee_statistics(
)
if req.type == RequestType.SICK_LEAVE:
sick_leave_days += duration
+ elif req.type == RequestType.SHORT_DAY_OFF:
+ if (
+ req.date_from
+ and (start_date is None or start_date <= req.date_from)
+ and (end_date is None or req.date_from <= end_date)
+ ):
+ short_day_off_minutes += req.duration_minutes or 0
elif req.type == RequestType.DAY_OFF:
day_off_days += duration
elif req.type == RequestType.MATERNITY:
@@ -217,13 +225,17 @@ def _build_employee_statistics(
type=RequestType.SICK_LEAVE
).count(),
"day_off_requests_count": base_qs.filter(
- type=RequestType.DAY_OFF
+ type__in=DAY_OFF_REQUEST_TYPES
+ ).count(),
+ "short_day_off_requests_count": base_qs.filter(
+ type=RequestType.SHORT_DAY_OFF
).count(),
"maternity_requests_count": base_qs.filter(
type=RequestType.MATERNITY
).count(),
"sick_leave_days": sick_leave_days,
"day_off_days": day_off_days,
+ "short_day_off_minutes": short_day_off_minutes,
"maternity_days": maternity_days,
"paid_vacation_days": paid_vacation_days,
"unpaid_vacation_days": unpaid_vacation_days,
diff --git a/backend/attendance/services.py b/backend/attendance/services.py
index d725d79f..99b85373 100644
--- a/backend/attendance/services.py
+++ b/backend/attendance/services.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import logging
from io import BytesIO
-from datetime import date, timedelta
+from datetime import date, time, timedelta
from calendar import monthrange
from typing import Any
@@ -34,6 +34,7 @@ from employees.models import Employee
from employees.services.personnel_state import (
EmployeePersonnelState,
PERSONNEL_STATUS_NORMAL,
+ approved_absence_minutes_within_schedule,
resolve_employee_personnel_state,
)
@@ -156,7 +157,9 @@ def save_logstorm_attendance_result(
records = []
personnel_actions = list(
- employee.actions.filter(date__date__lte=period_end).order_by("date", "id")
+ employee.actions.filter(date__date__lte=period_end)
+ .select_related("source_request")
+ .order_by("date", "id")
)
with transaction.atomic():
@@ -187,6 +190,7 @@ def save_logstorm_attendance_result(
record_defaults = build_attendance_record_defaults(
raw_record,
personnel_state,
+ schedule_payload=schedule_payload,
)
existing_record = AttendanceRecord.objects.filter(
employee=employee,
@@ -772,6 +776,8 @@ def resolve_employee_day_state(
def build_attendance_record_defaults(
raw_record: dict[str, Any],
personnel_state: EmployeePersonnelState,
+ *,
+ schedule_payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
is_remote_personnel_state = personnel_state.status == ACTION_REMOTE
work_hours = _nullable_float(raw_record.get("work_hours"))
@@ -799,6 +805,55 @@ def build_attendance_record_defaults(
statuses = _list_value(raw_record.get("statuses"))
employee_issues = _list_value(raw_record.get("employee_issues"))
technical_issues = _list_value(raw_record.get("technical_issues"))
+ approved_absence_minutes = approved_absence_minutes_within_schedule(
+ personnel_state,
+ schedule_payload,
+ )
+ if approved_absence_minutes:
+ approved_hours = approved_absence_minutes / 60
+ statuses = _append_unique(statuses, "approved_short_day_off")
+ excused_late_minutes, excused_early_minutes = (
+ _approved_boundary_absence_minutes(
+ personnel_state,
+ schedule_payload,
+ )
+ )
+ if late_minutes is not None and excused_late_minutes:
+ late_minutes = max(late_minutes - excused_late_minutes, 0)
+ if late_minutes == 0:
+ is_late = False
+ statuses = _remove_issue_markers(statuses, ("late", "опозд"))
+ employee_issues = _remove_issue_markers(
+ employee_issues,
+ ("late", "опозд"),
+ )
+ if early_leave_minutes is not None and excused_early_minutes:
+ early_leave_minutes = max(
+ early_leave_minutes - excused_early_minutes,
+ 0,
+ )
+ if early_leave_minutes == 0:
+ is_early_leave = False
+ statuses = _remove_issue_markers(
+ statuses,
+ ("early", "ранн"),
+ )
+ employee_issues = _remove_issue_markers(
+ employee_issues,
+ ("early", "ранн"),
+ )
+ if underwork_hours is not None:
+ underwork_hours = max(underwork_hours - approved_hours, 0)
+ if underwork_hours == 0:
+ is_underwork = False
+ statuses = _remove_issue_markers(
+ statuses,
+ ("underwork", "недоработ"),
+ )
+ employee_issues = _remove_issue_markers(
+ employee_issues,
+ ("underwork", "недоработ"),
+ )
non_working_reason = _non_working_reason(
is_workday=is_workday,
effective_is_workday=effective_is_workday,
@@ -889,7 +944,18 @@ def build_attendance_record_defaults(
if isinstance(raw_record.get("manual_edit_payload"), dict)
else {}
),
- "raw_data": {**raw_record, "non_working_reason": non_working_reason},
+ "raw_data": {
+ **raw_record,
+ "non_working_reason": non_working_reason,
+ "approved_absence_minutes": approved_absence_minutes,
+ "approved_absence_intervals": [
+ {
+ "time_from": start.isoformat(timespec="minutes"),
+ "time_to": end.isoformat(timespec="minutes"),
+ }
+ for start, end in personnel_state.approved_absence_intervals
+ ],
+ },
}
@@ -978,6 +1044,44 @@ def _has_absence_issue(values: list[Any]) -> bool:
)
+def _remove_issue_markers(
+ values: list[Any],
+ markers: tuple[str, ...],
+) -> list[Any]:
+ return [
+ item
+ for item in values
+ if not any(marker in str(item).lower() for marker in markers)
+ ]
+
+
+def _approved_boundary_absence_minutes(
+ state: EmployeePersonnelState,
+ schedule: dict[str, Any] | None,
+) -> tuple[int, int]:
+ try:
+ schedule_start = time.fromisoformat(str((schedule or {}).get("start_time")))
+ schedule_end = time.fromisoformat(str((schedule or {}).get("end_time")))
+ except ValueError:
+ return 0, 0
+
+ def minutes(value: time) -> int:
+ return value.hour * 60 + value.minute
+
+ start_minutes = minutes(schedule_start)
+ end_minutes = minutes(schedule_end)
+ excused_late = 0
+ excused_early = 0
+ for interval_start, interval_end in state.approved_absence_intervals:
+ interval_start_minutes = minutes(interval_start)
+ interval_end_minutes = minutes(interval_end)
+ if interval_start_minutes <= start_minutes < interval_end_minutes:
+ excused_late += min(interval_end_minutes, end_minutes) - start_minutes
+ if interval_start_minutes < end_minutes <= interval_end_minutes:
+ excused_early += end_minutes - max(interval_start_minutes, start_minutes)
+ return max(excused_late, 0), max(excused_early, 0)
+
+
def _parse_record_date(value: Any) -> date | None:
if isinstance(value, date):
return value
diff --git a/backend/documents/admin.py b/backend/documents/admin.py
index f0d60533..ee880fee 100644
--- a/backend/documents/admin.py
+++ b/backend/documents/admin.py
@@ -287,17 +287,9 @@ class DocumentAdmin(admin.ModelAdmin):
def get_recipients_qs(self, obj):
"""Возвращает список всех получателей документа."""
- if obj.sent_to_all:
- return list(User.objects.filter(is_active=True))
+ from documents.audience import document_access_audience
- # Собираем получателей из recipients и departments
- recipients_set = set(obj.recipients.all())
-
- # Добавляем сотрудников из отделов
- for department in obj.departments.all():
- recipients_set.update(department.active_employees)
-
- return list(recipients_set)
+ return list(document_access_audience(obj))
def get_acknowledgement_recipients_qs(self, obj):
"""Возвращает сотрудников, обязанных ознакомиться."""
diff --git a/backend/documents/audience.py b/backend/documents/audience.py
index ffdbe188..ba7589bd 100644
--- a/backend/documents/audience.py
+++ b/backend/documents/audience.py
@@ -21,6 +21,10 @@ def document_access_audience(document: Document) -> QuerySet:
departments_links__department__in=document.departments.all(),
departments_links__is_active=True,
)
+ | Q(
+ role_assignments__role__department__in=document.departments.all(),
+ role_assignments__is_active=True,
+ )
).distinct()
@@ -40,6 +44,12 @@ def document_acknowledgement_audience(document: Document) -> QuerySet:
),
departments_links__is_active=True,
)
+ | Q(
+ role_assignments__role__department__in=(
+ document.acknowledgement_departments.all()
+ ),
+ role_assignments__is_active=True,
+ )
)
return selected.filter(pk__in=access_audience.values("pk")).distinct()
diff --git a/backend/documents/notifications/handlers.py b/backend/documents/notifications/handlers.py
index 181cd3fb..cc80dfd6 100644
--- a/backend/documents/notifications/handlers.py
+++ b/backend/documents/notifications/handlers.py
@@ -13,6 +13,7 @@
import logging
from django.contrib.auth import get_user_model
+from django.db.models import Q
from notifications.signals import notify
from .config import (
@@ -276,27 +277,21 @@ def notify_department_employees(document, department_ids):
logger.warning("[handlers] No departments provided")
return
- # Получаем всех активных сотрудников выбранных отделов
- from employees.models import Department
-
- all_employees = set()
- for dept_id in department_ids:
- try:
- department = Department.objects.get(id=dept_id)
- dept_employees = department.active_employees
- logger.info(
- f"[handlers] Department '{department.name}' "
- f"has {len(dept_employees)} active employees: "
- f"{[e.id for e in dept_employees]}"
+ # Роль отдела эквивалентна физическому членству в отделе.
+ all_employees = set(
+ Employee.objects.filter(is_active=True)
+ .filter(
+ Q(
+ departments_links__department_id__in=department_ids,
+ departments_links__is_active=True,
)
- all_employees.update(dept_employees)
- logger.info(
- f"[handlers] After update, all_employees has: "
- f"{[e.id for e in all_employees]}"
+ | Q(
+ role_assignments__role__department_id__in=department_ids,
+ role_assignments__is_active=True,
)
- except Department.DoesNotExist:
- logger.warning(f"[handlers] Department {dept_id} not found")
- continue
+ )
+ .distinct()
+ )
# Исключаем загрузившего
if document.uploaded_by and document.uploaded_by in all_employees:
diff --git a/backend/documents/rules.py b/backend/documents/rules.py
index fd55bca6..0d0337a1 100644
--- a/backend/documents/rules.py
+++ b/backend/documents/rules.py
@@ -53,6 +53,12 @@ def has_document_access(user, document):
).exists():
return True
+ if document.departments.filter(
+ roles__assignments__employee=user,
+ roles__assignments__is_active=True,
+ ).exists():
+ return True
+
# Прямой доступ через recipients
if document.recipients.filter(id=user.id).exists():
return True
diff --git a/backend/employees/services/personnel_state.py b/backend/employees/services/personnel_state.py
index cb67cbd2..75bbb809 100644
--- a/backend/employees/services/personnel_state.py
+++ b/backend/employees/services/personnel_state.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from datetime import date, datetime
+from datetime import date, datetime, time
from typing import Iterable
from django.utils import timezone
@@ -35,11 +35,19 @@ class EmployeePersonnelState:
date_from: date | None = None
date_to: date | None = None
expects_attendance: bool = True
+ approved_absence_intervals: tuple[tuple[time, time], ...] = ()
@property
def is_non_working(self) -> bool:
return not self.expects_attendance
+ @property
+ def approved_absence_minutes(self) -> int:
+ return sum(
+ _time_to_minutes(end) - _time_to_minutes(start)
+ for start, end in self.approved_absence_intervals
+ )
+
def resolve_employee_personnel_state(
employee: Employee,
@@ -60,6 +68,14 @@ def resolve_employee_personnel_state(
if not relevant_actions:
return EmployeePersonnelState()
+ partial_day_off_intervals = _short_day_off_intervals(
+ employee_actions,
+ target_date,
+ )
+ partial_kwargs = {
+ "approved_absence_intervals": partial_day_off_intervals,
+ }
+
permanent_action = _resolve_permanent_action(relevant_actions)
if permanent_action and permanent_action.action == ACTION_DISMISSED:
return EmployeePersonnelState(
@@ -68,6 +84,7 @@ def resolve_employee_personnel_state(
action_id=permanent_action.id,
date_from=_action_date(permanent_action),
expects_attendance=False,
+ **partial_kwargs,
)
active_temporary = _active_temporary_actions(employee_actions, target_date)
@@ -80,6 +97,7 @@ def resolve_employee_personnel_state(
date_from=_action_date(action),
date_to=date_to,
expects_attendance=False,
+ **partial_kwargs,
)
marker_action = _active_marker_action(relevant_actions, target_date)
@@ -90,6 +108,7 @@ def resolve_employee_personnel_state(
action_id=marker_action.id,
date_from=_action_date(marker_action),
expects_attendance=True,
+ **partial_kwargs,
)
if permanent_action:
@@ -98,11 +117,13 @@ def resolve_employee_personnel_state(
label=permanent_action.get_action_display(),
action_id=permanent_action.id,
date_from=_action_date(permanent_action),
+ **partial_kwargs,
)
return EmployeePersonnelState(
status=ACTION_WORKING,
label=ACTION_LABELS[ACTION_WORKING],
+ **partial_kwargs,
)
@@ -222,6 +243,8 @@ def _active_temporary_actions(
for action in actions:
if action.action not in TEMPORARY_START_ACTIONS or not action.date:
continue
+ if _is_short_day_off_action(action):
+ continue
date_from = _action_date(action)
if date_from is None:
@@ -253,6 +276,90 @@ def _resolve_temporary_action_date_to(action: EmployeeAction) -> date | None:
return None
+def _is_short_day_off_action(action: EmployeeAction) -> bool:
+ source_request = getattr(action, "source_request", None)
+ if source_request is not None:
+ return getattr(source_request, "type", None) == "short_day_off"
+ return bool(isinstance(action.extra, dict) and action.extra.get("partial_day"))
+
+
+def _short_day_off_intervals(
+ actions: list[EmployeeAction],
+ target_date: date,
+) -> tuple[tuple[time, time], ...]:
+ intervals: list[tuple[time, time]] = []
+ for action in actions:
+ if not _is_short_day_off_action(action) or _action_date(action) != target_date:
+ continue
+ source_request = getattr(action, "source_request", None)
+ if source_request is not None:
+ if getattr(source_request, "status", None) != "approved":
+ continue
+ start = getattr(source_request, "time_from", None)
+ end = getattr(source_request, "time_to", None)
+ else:
+ extra = action.extra if isinstance(action.extra, dict) else {}
+ try:
+ start = time.fromisoformat(str(extra.get("starts_at", "")).split("T")[-1])
+ end = time.fromisoformat(str(extra.get("ends_at", "")).split("T")[-1])
+ except ValueError:
+ continue
+ if start:
+ start = start.replace(tzinfo=None)
+ if end:
+ end = end.replace(tzinfo=None)
+ if start and end and end > start:
+ intervals.append((start, end))
+ return _merge_time_intervals(intervals)
+
+
+def _merge_time_intervals(
+ intervals: list[tuple[time, time]],
+) -> tuple[tuple[time, time], ...]:
+ if not intervals:
+ return ()
+ ordered = sorted(intervals, key=lambda item: _time_to_minutes(item[0]))
+ merged: list[tuple[time, time]] = [ordered[0]]
+ for start, end in ordered[1:]:
+ previous_start, previous_end = merged[-1]
+ if _time_to_minutes(start) <= _time_to_minutes(previous_end):
+ if end > previous_end:
+ merged[-1] = (previous_start, end)
+ else:
+ merged.append((start, end))
+ return tuple(merged)
+
+
+def _time_to_minutes(value: time) -> int:
+ return value.hour * 60 + value.minute
+
+
+def approved_absence_minutes_within_schedule(
+ state: EmployeePersonnelState,
+ schedule: dict | None,
+) -> int:
+ """Return approved partial absence clipped to the employee work schedule."""
+ if not state.approved_absence_intervals:
+ return 0
+ try:
+ schedule_start = time.fromisoformat(str((schedule or {}).get("start_time")))
+ schedule_end = time.fromisoformat(str((schedule or {}).get("end_time")))
+ except ValueError:
+ return state.approved_absence_minutes
+ start_minutes = _time_to_minutes(schedule_start)
+ end_minutes = _time_to_minutes(schedule_end)
+ if end_minutes <= start_minutes:
+ return state.approved_absence_minutes
+ return sum(
+ max(
+ min(_time_to_minutes(end), end_minutes)
+ - max(_time_to_minutes(start), start_minutes),
+ 0,
+ )
+ for start, end in state.approved_absence_intervals
+ )
+
+
def is_temporary_start_action(action: str) -> bool:
return action in TEMPORARY_START_ACTIONS
diff --git a/backend/employees/signals/common.py b/backend/employees/signals/common.py
index 503a0454..58584ff3 100644
--- a/backend/employees/signals/common.py
+++ b/backend/employees/signals/common.py
@@ -41,6 +41,7 @@ IMMEDIATE_ACTION_MAPPING = {
# Интервальные события (создаются сразу при одобрении с датами заявки)
SCHEDULED_ACTION_MAPPING = {
+ "short_day_off": ACTION_ON_DAY_OFF, # Короткий отгул → частичный отгул
"vacation": ACTION_ON_LEAVE, # Отпуск → В отпуске
"sick_leave": ACTION_ON_SICK_LEAVE, # Больничный → На больничном
"day_off": ACTION_ON_DAY_OFF, # Отгул → В отгуле
@@ -86,7 +87,7 @@ def create_action_on_request_approval(sender, instance, created, **kwargs):
Две стратегии:
1. НЕМЕДЛЕННО (transfer, dismissal) - сразу при одобрении
- 2. ИНТЕРВАЛЬНО (vacation, sick_leave, day_off) - сразу с date_to
+ 2. ИНТЕРВАЛЬНО (отпуск, больничный, отгулы, декрет) - сразу с date_to
"""
# Пропускаем если это новая заявка
if created:
@@ -206,16 +207,28 @@ def _create_interval_action(request):
return
try:
+ is_partial_day_off = request.type == "short_day_off"
+ extra = {
+ "approved_by": (request.approver.id if request.approver else None),
+ "interval": True,
+ }
+ if is_partial_day_off:
+ extra.update(
+ {
+ "partial_day": True,
+ "starts_at": request.starts_at.isoformat(),
+ "ends_at": request.ends_at.isoformat(),
+ "duration_minutes": request.duration_minutes,
+ }
+ )
+
action, created = create_request_action(
request=request,
action_type=action_type,
- action_date=request.date_from,
+ action_date=request.starts_at if is_partial_day_off else request.date_from,
date_to=request.date_to,
comment=build_request_action_comment(request),
- extra={
- "approved_by": (request.approver.id if request.approver else None),
- "interval": True,
- },
+ extra=extra,
)
if not created:
logger.warning(f"EmployeeAction already exists for Request #{request.id}")
diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py
index eeb11bed..0efd1b9a 100644
--- a/backend/eusrr_backend/settings.py
+++ b/backend/eusrr_backend/settings.py
@@ -479,9 +479,11 @@ SPECTACULAR_SETTINGS = {
("lost", "Утеряно"),
],
"RequestTypeEnum": [
+ ("short_day_off", "Короткий отгул"),
("vacation", "Отпуск"),
("sick_leave", "Больничный"),
("day_off", "Отгул"),
+ ("maternity", "Декрет"),
("transfer", "Перевод"),
("dismissal", "Увольнение"),
("other", "Другое"),
diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py
index 5775e2e6..739d1e4e 100644
--- a/backend/finance/payroll/reporting.py
+++ b/backend/finance/payroll/reporting.py
@@ -669,6 +669,17 @@ def build_payroll_employee_point_breakdown(
if personnel_request.date_to
else None
),
+ "time_from": (
+ personnel_request.time_from.isoformat(timespec="minutes")
+ if personnel_request.time_from
+ else None
+ ),
+ "time_to": (
+ personnel_request.time_to.isoformat(timespec="minutes")
+ if personnel_request.time_to
+ else None
+ ),
+ "duration_minutes": personnel_request.duration_minutes,
"comment": personnel_request.comment,
}
)
diff --git a/backend/finance/payroll/work_norm.py b/backend/finance/payroll/work_norm.py
index 181d78a3..a0915484 100644
--- a/backend/finance/payroll/work_norm.py
+++ b/backend/finance/payroll/work_norm.py
@@ -12,6 +12,9 @@ from attendance.services import (
)
from employees.constants import ACTIVATING_MARKER_ACTIONS
from employees.services.personnel_state import resolve_employee_personnel_state
+from employees.services.personnel_state import (
+ approved_absence_minutes_within_schedule,
+)
from finance.models import PayrollPeriod, PayrollWorkSettings
@@ -125,22 +128,24 @@ def calculate_period_personnel_points(
workdates = period_workdates(period, schedule)
today = timezone.localdate()
completed_workdates = [workdate for workdate in workdates if workdate < today]
- attended_dates = [
- workdate
- for workdate in completed_workdates
- if (first_employment_date is None or workdate >= first_employment_date)
- and resolve_employee_personnel_state(
+ attendance_factors = []
+ for workdate in completed_workdates:
+ if first_employment_date is not None and workdate < first_employment_date:
+ continue
+ state = resolve_employee_personnel_state(
employee,
workdate,
actions=personnel_actions,
- ).expects_attendance
- ]
+ )
+ factor = _personnel_day_factor(state, schedule)
+ if factor > 0:
+ attendance_factors.append(factor)
points = (
- period_target_points * Decimal(len(attended_dates)) / Decimal(len(workdates))
+ period_target_points * sum(attendance_factors, Decimal("0")) / Decimal(len(workdates))
if workdates
else Decimal("0")
).quantize(QUANTUM)
- return points, len(attended_dates)
+ return points, len(attendance_factors)
def calculate_period_personnel_daily_points(
@@ -179,14 +184,34 @@ def calculate_period_personnel_daily_points(
if workdate >= today:
result[workdate] = None
continue
- expects_attendance = (
- first_employment_date is None or workdate >= first_employment_date
- ) and resolve_employee_personnel_state(
+ state = resolve_employee_personnel_state(
employee,
workdate,
actions=personnel_actions,
- ).expects_attendance
- result[workdate] = (
- daily_point_value if expects_attendance else Decimal("0")
- ).quantize(QUANTUM)
+ )
+ factor = (
+ _personnel_day_factor(state, schedule)
+ if first_employment_date is None or workdate >= first_employment_date
+ else Decimal("0")
+ )
+ result[workdate] = (daily_point_value * factor).quantize(QUANTUM)
return result
+
+
+def _personnel_day_factor(state, schedule: dict) -> Decimal:
+ if not state.expects_attendance:
+ return Decimal("0")
+ try:
+ expected_minutes = Decimal(str(schedule.get("expected_hours", 0))) * Decimal("60")
+ except (TypeError, ValueError):
+ expected_minutes = Decimal("0")
+ if expected_minutes <= 0:
+ return Decimal("1")
+ absence_minutes = Decimal(
+ approved_absence_minutes_within_schedule(state, schedule)
+ )
+ return max(
+ (expected_minutes - min(absence_minutes, expected_minutes))
+ / expected_minutes,
+ Decimal("0"),
+ )
diff --git a/backend/notifications/static/notifications/sw.js b/backend/notifications/static/notifications/sw.js
index 3cde2a95..659af1c5 100644
--- a/backend/notifications/static/notifications/sw.js
+++ b/backend/notifications/static/notifications/sw.js
@@ -7,7 +7,7 @@
*/
// Версия Service Worker (для обновления кэша при изменениях)
-const SW_VERSION = '1.0.0';
+const SW_VERSION = '1.0.1';
// Настройки по умолчанию для уведомлений (без project-specific путей!)
// Все пути к ресурсам (icon, badge, image) должны приходить с сервера
@@ -151,21 +151,44 @@ self.addEventListener('notificationclick', (event) => {
// Открываем или фокусируем окно
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true })
- .then((clientList) => {
- // Ищем уже открытое окно с нашим сайтом
- for (const client of clientList) {
- const clientUrl = new URL(client.url);
- const targetUrl = new URL(urlToOpen, self.location.origin);
-
- // Если найдено окно с тем же origin - фокусируем и переходим
- if (clientUrl.origin === targetUrl.origin && 'focus' in client) {
- return client.navigate(urlToOpen).then(() => client.focus());
- }
+ .then(async (clientList) => {
+ const targetUrl = new URL(urlToOpen, self.location.origin).toString();
+ const exactClient = clientList.find(
+ (client) => client.url === targetUrl && 'focus' in client,
+ );
+ if (exactClient) {
+ return exactClient.focus();
}
-
+
+ const sameOriginClients = clientList.filter(
+ (client) => new URL(client.url).origin === self.location.origin,
+ );
+ const sameOriginClient =
+ sameOriginClients.find((client) => client.focused) ||
+ sameOriginClients.find((client) => client.visibilityState === 'visible') ||
+ sameOriginClients[0];
+
+ if (sameOriginClient) {
+ if ('navigate' in sameOriginClient) {
+ try {
+ const navigatedClient = await sameOriginClient.navigate(targetUrl);
+ return (navigatedClient || sameOriginClient).focus();
+ } catch (error) {
+ console.warn('[SW] Failed to navigate existing client:', error);
+ }
+ }
+
+ sameOriginClient.postMessage({
+ type: 'NOTIFICATION_CLICK',
+ url: targetUrl,
+ notificationId: notificationData.notification_id,
+ });
+ return sameOriginClient.focus();
+ }
+
// Если нет открытого окна - открываем новое
if (self.clients.openWindow) {
- return self.clients.openWindow(urlToOpen);
+ return self.clients.openWindow(targetUrl);
}
})
);
diff --git a/backend/procurement/migrations/0017_add_item_defective_at.py b/backend/procurement/migrations/0017_add_item_defective_at.py
new file mode 100644
index 00000000..0a1bab32
--- /dev/null
+++ b/backend/procurement/migrations/0017_add_item_defective_at.py
@@ -0,0 +1,39 @@
+# Generated by Django 5.2.4 on 2026-08-04 15:31
+
+from django.db import migrations, models
+
+
+def backfill_defective_at(apps, schema_editor):
+ ProcurementItem = apps.get_model('procurement', 'ProcurementItem')
+ pending = []
+ queryset = ProcurementItem.objects.filter(
+ execution_status='defective',
+ defective_at__isnull=True,
+ ).select_related('request')
+ for item in queryset.iterator(chunk_size=500):
+ item.defective_at = item.request.updated_at
+ pending.append(item)
+ if len(pending) >= 500:
+ ProcurementItem.objects.bulk_update(pending, ['defective_at'])
+ pending.clear()
+ if pending:
+ ProcurementItem.objects.bulk_update(pending, ['defective_at'])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('procurement', '0016_procurementrequestactivity'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='procurementitem',
+ name='defective_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Отмечено как брак'),
+ ),
+ migrations.RunPython(
+ backfill_defective_at,
+ migrations.RunPython.noop,
+ ),
+ ]
diff --git a/backend/procurement/models.py b/backend/procurement/models.py
index be2713f8..4f49217e 100644
--- a/backend/procurement/models.py
+++ b/backend/procurement/models.py
@@ -267,6 +267,9 @@ class ProcurementRequest(models.Model):
new_status = ProcurementFulfillmentStatus.ISSUES
else:
total_quantity = sum(item.quantity for item in items)
+ fulfillment_target_quantity = sum(
+ item.fulfillment_target_quantity for item in items
+ )
ordered_quantity = sum(
item.effective_ordered_quantity for item in items
)
@@ -274,7 +277,7 @@ class ProcurementRequest(models.Model):
item.effective_received_quantity for item in items
)
- if received_quantity >= total_quantity:
+ if received_quantity >= fulfillment_target_quantity:
new_status = ProcurementFulfillmentStatus.COMPLETED
elif received_quantity > 0:
new_status = ProcurementFulfillmentStatus.PARTIALLY_RECEIVED
@@ -495,6 +498,12 @@ class ProcurementItem(models.Model):
choices=ProcurementItemExecutionStatus.choices,
default=ProcurementItemExecutionStatus.PENDING,
)
+ defective_at = models.DateTimeField(
+ "Отмечено как брак",
+ null=True,
+ blank=True,
+ db_index=True,
+ )
executor_comment = models.TextField(
"Комментарий исполнителя",
blank=True,
@@ -537,6 +546,10 @@ class ProcurementItem(models.Model):
return self.quantity
return 0
+ @property
+ def fulfillment_target_quantity(self):
+ return max(self.quantity, self.effective_ordered_quantity)
+
@property
def execution_status_display(self):
problem_statuses = {
@@ -548,7 +561,7 @@ class ProcurementItem(models.Model):
if self.execution_status in problem_statuses:
return self.get_execution_status_display()
if self.effective_received_quantity > 0:
- if self.effective_received_quantity < self.quantity:
+ if self.effective_received_quantity < self.fulfillment_target_quantity:
return "Получено частично"
return ProcurementItemExecutionStatus.RECEIVED.label
if self.effective_ordered_quantity > 0:
@@ -567,24 +580,6 @@ class ProcurementItem(models.Model):
return Decimal("0.00")
return unit_price * self.quantity
- def clean(self):
- super().clean()
- if (
- self.ordered_quantity is not None
- and self.ordered_quantity > self.quantity
- ):
- raise ValidationError(
- {"ordered_quantity": "Не может быть больше количества позиции"}
- )
- if (
- self.received_quantity is not None
- and self.received_quantity > self.quantity
- ):
- raise ValidationError(
- {"received_quantity": "Не может быть больше количества позиции"}
- )
-
-
@staticmethod
def _mark_update_field(update_fields, field):
if update_fields is not None:
@@ -603,11 +598,11 @@ class ProcurementItem(models.Model):
received_quantity = self.received_quantity or 0
if received_quantity > 0 and self.ordered_quantity is None:
- self.ordered_quantity = min(received_quantity, self.quantity)
+ self.ordered_quantity = received_quantity
self._mark_update_field(update_fields, "ordered_quantity")
ordered_quantity = self.ordered_quantity
elif received_quantity > 0 and ordered_quantity < received_quantity:
- self.ordered_quantity = min(received_quantity, self.quantity)
+ self.ordered_quantity = received_quantity
self._mark_update_field(update_fields, "ordered_quantity")
ordered_quantity = self.ordered_quantity
@@ -615,7 +610,7 @@ class ProcurementItem(models.Model):
if received_quantity > 0:
new_status = (
ProcurementItemExecutionStatus.RECEIVED
- if received_quantity >= self.quantity
+ if received_quantity >= max(self.quantity, ordered_quantity)
else ProcurementItemExecutionStatus.ORDERED
)
elif ordered_quantity > 0:
@@ -649,6 +644,13 @@ class ProcurementItem(models.Model):
if self.received_quantity is None:
self.received_quantity = self.quantity
self._mark_update_field(update_fields, "received_quantity")
+ if self.execution_status == ProcurementItemExecutionStatus.DEFECTIVE:
+ if self.defective_at is None:
+ self.defective_at = timezone.now()
+ self._mark_update_field(update_fields, "defective_at")
+ elif self.defective_at is not None:
+ self.defective_at = None
+ self._mark_update_field(update_fields, "defective_at")
self._sync_execution_status_from_quantities(update_fields=update_fields)
super().save(*args, **kwargs)
if self.request_id:
diff --git a/backend/procurement/notifications/config.py b/backend/procurement/notifications/config.py
index 96e0f894..482812ab 100644
--- a/backend/procurement/notifications/config.py
+++ b/backend/procurement/notifications/config.py
@@ -29,6 +29,7 @@ class NotificationVerbs:
# Позиции и комментарии
ITEM_UPDATED = 'procurement_item_updated'
+ ITEM_ISSUE_REPORTED = 'procurement_item_issue_reported'
REQUEST_COMMENTED = 'procurement_request_commented'
ITEM_COMMENTED = 'procurement_item_commented'
diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py
index a00edb5d..04afa3c9 100644
--- a/backend/procurement/notifications/handlers.py
+++ b/backend/procurement/notifications/handlers.py
@@ -570,7 +570,7 @@ def notify_item_issue_reported(item, actor=None):
recipients.extend(_processing_department_recipients(procurement_request))
_notify_many(
recipients,
- verb=NotificationVerbs.ITEM_UPDATED,
+ verb=NotificationVerbs.ITEM_ISSUE_REPORTED,
procurement_request=procurement_request,
title=notification_title,
description=description,
diff --git a/backend/procurement/services.py b/backend/procurement/services.py
index d9a1a680..85d8d3d6 100644
--- a/backend/procurement/services.py
+++ b/backend/procurement/services.py
@@ -2,7 +2,9 @@
Сервисы для модуля закупок и инвентаря.
"""
+import re
from typing import Optional
+from urllib.parse import urlsplit, urlunsplit
from django.db import transaction
from django.utils import timezone
@@ -14,6 +16,70 @@ from procurement.constants import (
)
+HTTP_LINK_PATTERN = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE)
+TRAILING_LINK_PUNCTUATION = ".,;:!?)]}"
+
+
+def _normalize_http_link(value: str) -> str | None:
+ candidate = str(value or "").strip().rstrip(TRAILING_LINK_PUNCTUATION)
+ if not candidate:
+ return None
+
+ try:
+ parsed = urlsplit(candidate)
+ hostname = parsed.hostname
+ if parsed.scheme.lower() not in {"http", "https"} or not hostname:
+ return None
+ return urlunsplit(
+ (
+ parsed.scheme.lower(),
+ parsed.netloc.lower(),
+ parsed.path,
+ parsed.query,
+ parsed.fragment,
+ )
+ )
+ except ValueError:
+ return None
+
+
+def _http_link_identity(value: str) -> str | None:
+ candidate = str(value or "").strip()
+ if not re.match(r"^https?://", candidate, re.IGNORECASE):
+ candidate = f"https://{candidate}"
+ normalized = _normalize_http_link(candidate)
+ if normalized is None:
+ return None
+ parsed = urlsplit(normalized)
+ path = "" if parsed.path == "/" else parsed.path
+ return urlunsplit((parsed.scheme, parsed.netloc, path, parsed.query, parsed.fragment))
+
+
+def merge_comment_links(existing_links, comment_text: str) -> tuple[list[str], list[str]]:
+ """Append unique HTTP links extracted from a position comment."""
+
+ merged = [
+ str(link).strip()
+ for link in (existing_links or [])
+ if str(link).strip()
+ ]
+ identities = {
+ identity
+ for link in merged
+ if (identity := _http_link_identity(link)) is not None
+ }
+ added = []
+ for match in HTTP_LINK_PATTERN.finditer(comment_text or ""):
+ link = _normalize_http_link(match.group(0))
+ identity = _http_link_identity(link or "")
+ if link is None or identity is None or identity in identities:
+ continue
+ identities.add(identity)
+ merged.append(link)
+ added.append(link)
+ return merged, added
+
+
class InventoryNumberGenerator:
"""Генератор инвентарных номеров."""
@@ -94,6 +160,15 @@ class ProcurementApprovalResolver:
через Department.head.
"""
+ WORK_ASSIGNABLE_STATUSES = frozenset(
+ {
+ ProcurementStatus.WAITING,
+ ProcurementStatus.PENDING,
+ ProcurementStatus.APPROVED,
+ ProcurementStatus.IN_PROGRESS,
+ }
+ )
+
@classmethod
def user_is_department_participant(cls, user, department_id):
"""Проверяет рабочее участие пользователя в отделе.
@@ -267,6 +342,12 @@ class ProcurementApprovalResolver:
if not user or not user.is_authenticated:
return False
+ if procurement_request.status not in cls.WORK_ASSIGNABLE_STATUSES:
+ return False
+
+ if procurement_request.executor_id == user.id:
+ return False
+
has_execute_access = (
user.is_superuser
or user.is_staff
@@ -274,28 +355,15 @@ class ProcurementApprovalResolver:
or user.has_perm("procurement.execute_procurement")
)
- if (
- procurement_request.status == ProcurementStatus.IN_PROGRESS
- and procurement_request.processing_department_id
- and procurement_request.executor_id
- ):
- if procurement_request.executor_id == user.id:
- return False
+ if procurement_request.executor_id:
if has_execute_access:
return True
- return cls.user_is_processing_department_member(
- user,
- procurement_request,
- )
-
- if procurement_request.status not in {
- ProcurementStatus.APPROVED,
- ProcurementStatus.WAITING,
- }:
- return False
-
- if procurement_request.executor_id:
- return False
+ if procurement_request.processing_department_id:
+ return cls.user_is_processing_department_member(
+ user,
+ procurement_request,
+ )
+ return True
if has_execute_access:
return True
diff --git a/backend/procurement/signals.py b/backend/procurement/signals.py
index 16b5bf3c..ec6e92d2 100644
--- a/backend/procurement/signals.py
+++ b/backend/procurement/signals.py
@@ -42,7 +42,15 @@ def record_procurement_request_activity(sender, instance, created, **kwargs):
if old_status == instance.status:
return
- action = REQUEST_STATUS_ACTIONS.get(instance.status)
+ if (
+ old_status == ProcurementStatus.PENDING
+ and instance.status == ProcurementStatus.IN_PROGRESS
+ and instance.executor_id
+ and instance.started_at
+ ):
+ action = ProcurementActivityAction.APPROVED
+ else:
+ action = REQUEST_STATUS_ACTIONS.get(instance.status)
if action is None:
return
create_procurement_activity(
diff --git a/backend/requests_app/admin.py b/backend/requests_app/admin.py
index 81001310..9bdb5cf9 100644
--- a/backend/requests_app/admin.py
+++ b/backend/requests_app/admin.py
@@ -71,6 +71,7 @@ class RequestAdmin(admin.ModelAdmin):
"type",
"title",
("date_from", "date_to"),
+ ("time_from", "time_to"),
"comment",
"attachment",
)
diff --git a/backend/requests_app/enums.py b/backend/requests_app/enums.py
index 5ead9e5f..be4a3a8f 100644
--- a/backend/requests_app/enums.py
+++ b/backend/requests_app/enums.py
@@ -6,6 +6,7 @@ from django.db.models import TextChoices
class RequestType(TextChoices):
"""Перечень типов заявлений (значение хранится в БД)."""
+ SHORT_DAY_OFF = "short_day_off", "Короткий отгул"
VACATION = "vacation", "Отпуск"
SICK_LEAVE = "sick_leave", "Больничный"
DAY_OFF = "day_off", "Отгул"
@@ -15,6 +16,11 @@ class RequestType(TextChoices):
OTHER = "other", "Другое"
+DAY_OFF_REQUEST_TYPES = frozenset(
+ {RequestType.DAY_OFF, RequestType.SHORT_DAY_OFF}
+)
+
+
class RequestStatus(TextChoices):
"""Статусы жизненного цикла заявления (значение хранится в БД)."""
diff --git a/backend/requests_app/migrations/0014_add_short_day_off.py b/backend/requests_app/migrations/0014_add_short_day_off.py
new file mode 100644
index 00000000..264e75b2
--- /dev/null
+++ b/backend/requests_app/migrations/0014_add_short_day_off.py
@@ -0,0 +1,35 @@
+# Generated by Django 5.2.4 on 2026-08-04 17:03
+
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('employees', '0054_employee_id_guest_range_guard'),
+ ('requests_app', '0013_alter_request_attachment'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='request',
+ name='time_from',
+ field=models.TimeField(blank=True, null=True, verbose_name='Время начала'),
+ ),
+ migrations.AddField(
+ model_name='request',
+ name='time_to',
+ field=models.TimeField(blank=True, null=True, verbose_name='Время окончания'),
+ ),
+ migrations.AlterField(
+ model_name='request',
+ name='type',
+ field=models.CharField(choices=[('short_day_off', 'Короткий отгул'), ('vacation', 'Отпуск'), ('sick_leave', 'Больничный'), ('day_off', 'Отгул'), ('maternity', 'Декрет'), ('transfer', 'Перевод'), ('dismissal', 'Увольнение'), ('other', 'Другое')], max_length=32, verbose_name='Тип заявления'),
+ ),
+ migrations.AddConstraint(
+ model_name='request',
+ constraint=models.CheckConstraint(condition=models.Q(('status', 'draft'), models.Q(('type', 'short_day_off'), _negated=True), models.Q(('date_from__isnull', False), ('date_to', models.F('date_from')), ('time_from__isnull', False), ('time_to__gt', models.F('time_from')), ('time_to__isnull', False)), _connector='OR'), name='request_short_day_off_interval_valid'),
+ ),
+ ]
diff --git a/backend/requests_app/models.py b/backend/requests_app/models.py
index 9fd7f43d..30c8ed17 100644
--- a/backend/requests_app/models.py
+++ b/backend/requests_app/models.py
@@ -121,6 +121,8 @@ class Request(models.Model):
title = models.CharField(_("Тема"), max_length=200, blank=True)
date_from = models.DateField(_("Дата начала"), null=True, blank=True)
date_to = models.DateField(_("Дата окончания"), null=True, blank=True)
+ time_from = models.TimeField(_("Время начала"), null=True, blank=True)
+ time_to = models.TimeField(_("Время окончания"), null=True, blank=True)
comment = models.TextField(_("Комментарий"), blank=True)
status = models.CharField(
@@ -173,6 +175,18 @@ class Request(models.Model):
| Q(date_from__isnull=True)
| Q(date_from__lte=F("date_to")),
),
+ models.CheckConstraint(
+ name="request_short_day_off_interval_valid",
+ condition=Q(status=RequestStatus.DRAFT)
+ | ~Q(type=RequestType.SHORT_DAY_OFF)
+ | Q(
+ date_from__isnull=False,
+ date_to=F("date_from"),
+ time_from__isnull=False,
+ time_to__isnull=False,
+ time_to__gt=F("time_from"),
+ ),
+ ),
models.CheckConstraint(
name="request_approver_required_on_decision",
condition=Q(
@@ -200,6 +214,15 @@ class Request(models.Model):
raise ValidationError(
_("Для выбранного типа укажите даты начала и окончания.")
)
+ if self.type == RequestType.SHORT_DAY_OFF:
+ if not (self.date_from and self.date_to and self.time_from and self.time_to):
+ raise ValidationError(
+ _("Для короткого отгула укажите дату и время начала и окончания.")
+ )
+ if self.date_from != self.date_to:
+ raise ValidationError(_("Короткий отгул должен быть в пределах одного дня."))
+ if self.time_to <= self.time_from:
+ raise ValidationError(_("Время окончания должно быть позже времени начала."))
if (
self.type in {RequestType.TRANSFER, RequestType.DISMISSAL}
and not self.date_from
@@ -209,6 +232,34 @@ class Request(models.Model):
if self.approver_id and self.approver_id == self.employee_id:
raise ValidationError(_("Согласующий не может совпадать с автором заявки."))
+ @property
+ def starts_at(self):
+ if not self.date_from:
+ return None
+ value = timezone.datetime.combine(
+ self.date_from,
+ self.time_from or timezone.datetime.min.time(),
+ )
+ return timezone.make_aware(value, timezone.get_current_timezone())
+
+ @property
+ def ends_at(self):
+ if not self.date_to:
+ return None
+ value = timezone.datetime.combine(
+ self.date_to,
+ self.time_to or timezone.datetime.max.time(),
+ )
+ return timezone.make_aware(value, timezone.get_current_timezone())
+
+ @property
+ def duration_minutes(self) -> int | None:
+ if not (self.time_from and self.time_to):
+ return None
+ start = timezone.datetime.combine(timezone.datetime.min.date(), self.time_from)
+ end = timezone.datetime.combine(timezone.datetime.min.date(), self.time_to)
+ return max(int((end - start).total_seconds() // 60), 0)
+
@property
def display_title(self) -> str:
"""Заголовок для списков: берём title или генерим на лету."""
diff --git a/backend/requests_app/tasks.py b/backend/requests_app/tasks.py
index 88e0e290..c6f17735 100644
--- a/backend/requests_app/tasks.py
+++ b/backend/requests_app/tasks.py
@@ -17,7 +17,7 @@ def create_scheduled_action(self, request_id):
"""
Создать кадровое событие для заявки по расписанию.
- Вызывается в date_from для vacation/sick_leave/day_off.
+ Вызывается в date_from для типов кадрового отсутствия.
Args:
request_id: ID заявки
@@ -48,6 +48,7 @@ def create_scheduled_action(self, request_id):
# Определяем тип события
action_mapping = {
+ "short_day_off": ACTION_ON_DAY_OFF,
"vacation": ACTION_ON_LEAVE,
"sick_leave": ACTION_ON_SICK_LEAVE,
"day_off": ACTION_ON_DAY_OFF,
@@ -62,16 +63,28 @@ def create_scheduled_action(self, request_id):
)
return
+ is_partial_day_off = request.type == "short_day_off"
+ extra = {
+ "approved_by": (request.approver.id if request.approver else None),
+ "scheduled": True,
+ }
+ if is_partial_day_off:
+ extra.update(
+ {
+ "partial_day": True,
+ "starts_at": request.starts_at.isoformat(),
+ "ends_at": request.ends_at.isoformat(),
+ "duration_minutes": request.duration_minutes,
+ }
+ )
+
action, created = create_request_action(
request=request,
action_type=action_type,
- action_date=request.date_from,
+ action_date=request.starts_at if is_partial_day_off else request.date_from,
date_to=request.date_to,
comment=build_request_action_comment(request),
- extra={
- "approved_by": (request.approver.id if request.approver else None),
- "scheduled": True,
- },
+ extra=extra,
)
if not created:
logger.info(f"Action already exists for Request #{request_id}, skipping")
diff --git a/backend/requests_app/tests/test_signals.py b/backend/requests_app/tests/test_signals.py
index 0f2d3c74..5770f4c4 100644
--- a/backend/requests_app/tests/test_signals.py
+++ b/backend/requests_app/tests/test_signals.py
@@ -3,7 +3,7 @@
"""
import pytest
-from datetime import date, timedelta
+from datetime import date, time, timedelta
from unittest.mock import patch
from django.utils import timezone
@@ -134,6 +134,34 @@ class TestRequestSignals:
assert action.date.date() == future_date
assert action.date_to == end_date
+ def test_short_day_off_creates_partial_interval_action(self, user_factory):
+ employee = user_factory()
+ approver = user_factory()
+ request_date = date.today() + timedelta(days=1)
+ request = Request.objects.create(
+ employee=employee,
+ type="short_day_off",
+ status=RequestStatus.PENDING,
+ date_from=request_date,
+ date_to=request_date,
+ time_from=time(14, 0),
+ time_to=time(16, 30),
+ )
+
+ request.status = RequestStatus.APPROVED
+ request.approver = approver
+ request.save()
+
+ action = EmployeeAction.objects.get(
+ employee=employee,
+ action="on_day_off",
+ source_request=request,
+ )
+ assert timezone.localtime(action.date).time().replace(tzinfo=None) == time(14, 0)
+ assert action.date_to == request_date
+ assert action.extra["partial_day"] is True
+ assert action.extra["duration_minutes"] == 150
+
def test_scheduled_action_maternity(self, user_factory):
"""Декрет создаёт интервальное EmployeeAction."""
employee = user_factory()
@@ -317,6 +345,7 @@ class TestRequestMappings:
def test_scheduled_mapping_complete(self):
"""Проверка что все отложенные маппинги правильные."""
assert SCHEDULED_ACTION_MAPPING == {
+ "short_day_off": "on_day_off",
"vacation": "on_leave",
"sick_leave": "on_sick_leave",
"day_off": "on_day_off",
diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py
index 6cbdf00a..7fa5dd0f 100644
--- a/backend/tests/api/v1/documents/test_documents_api.py
+++ b/backend/tests/api/v1/documents/test_documents_api.py
@@ -5,8 +5,10 @@ from __future__ import annotations
Тесты CRUD API документов (/api/v1/documents/ + экшен acknowledge) по чек-листу.
Политика доступа (актуальная):
-- Создавать — все аутентифицированные пользователи (документы создаются в draft).
-- Редактировать/удалять — только создатель, админы или обладатели модельных прав.
+- Обычные документы могут создавать все аутентифицированные пользователи.
+- Регламенты могут создавать и редактировать только staff/superuser.
+- Редактировать/удалять обычные документы могут создатель, админы или
+ обладатели модельных прав.
- Approve/Publish — только пользователи с правом change_document (django-rules).
- Читать/скачивать — только аутентифицированные, и только документы,
предназначенные им (или с sent_to_all=True). Ознакомливаться можно только
@@ -34,7 +36,12 @@ from documents.models import (
DocumentDisplayState,
DocumentTag,
)
-from employees.models import Department, EmployeeDepartment
+from employees.models import (
+ Department,
+ DepartmentRole,
+ EmployeeDepartment,
+ RoleAssignment,
+)
from filer.models import File as FilerFile, Folder
from rest_framework import status
from rest_framework.test import APIClient
@@ -225,6 +232,73 @@ def enable_media_files(settings):
class TestAuthAndPermissions:
"""A. Роли и доступ — статусы для разных ролей и прав."""
+ def test_department_role_grants_document_access_and_acknowledgement(
+ self, auth_client, make_user, api_urls
+ ):
+ author = make_user("role-document-author@example.com", staff=True)
+ role_user = make_user("role-document-viewer@example.com")
+ department = Department.objects.create(name="Role document department")
+ role = DepartmentRole.objects.create(
+ department=department,
+ name="Document role",
+ )
+ RoleAssignment.objects.create(
+ employee=role_user,
+ role=role,
+ is_active=True,
+ )
+ document = make_document(
+ uploaded_by=author,
+ sent_to_all=False,
+ acknowledgement_required=True,
+ acknowledgement_for_all=False,
+ is_regulation=True,
+ )
+ document.departments.add(department)
+ document.acknowledgement_departments.add(department)
+ client = auth_client(role_user)
+
+ list_response = client.get(api_urls["list"], {"is_regulation": True})
+
+ assert list_response.status_code == status.HTTP_200_OK
+ result = next(
+ item
+ for item in list_response.data["results"]
+ if item["id"] == document.id
+ )
+ assert result["acknowledgement_required_for_user"] is True
+ assert result["acknowledgement_total"] == 1
+
+ acknowledge_response = client.post(api_urls["ack"](document.pk), {})
+
+ assert acknowledge_response.status_code == status.HTTP_200_OK
+ assert DocumentAcknowledgement.objects.filter(
+ document=document,
+ user=role_user,
+ ).exists()
+
+ def test_inactive_department_role_does_not_grant_document_access(
+ self, auth_client, make_user, api_urls
+ ):
+ author = make_user("inactive-role-author@example.com", staff=True)
+ role_user = make_user("inactive-role-viewer@example.com")
+ department = Department.objects.create(name="Inactive role department")
+ role = DepartmentRole.objects.create(
+ department=department,
+ name="Inactive document role",
+ )
+ RoleAssignment.objects.create(
+ employee=role_user,
+ role=role,
+ is_active=False,
+ )
+ document = make_document(uploaded_by=author, sent_to_all=False)
+ document.departments.add(department)
+
+ response = auth_client(role_user).get(api_urls["detail"](document.pk))
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
def test_anonymous_all_401(self, api_client: APIClient, api_urls):
"""Аноним: все запросы возвращают 401."""
list_url = api_urls["list"]
@@ -581,6 +655,53 @@ class TestCreate:
detail_r = client.get(api_urls["detail"](doc.pk))
assert detail_r.status_code == 200
+ @pytest.mark.parametrize("is_regulation", [True, "true", "1"])
+ def test_regular_user_cannot_create_regulation(
+ self, auth_client, make_user, api_urls, is_regulation
+ ):
+ regular_user = make_user("regular-regulation@example.com", staff=False)
+ client = auth_client(regular_user)
+
+ response = client.post(
+ api_urls["list"],
+ {
+ "title": "Unauthorized regulation",
+ "sent_to_all": True,
+ "is_regulation": is_regulation,
+ },
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ assert not Document.objects.filter(title="Unauthorized regulation").exists()
+
+ @pytest.mark.parametrize(
+ "staff,superuser",
+ [(True, False), (False, True)],
+ )
+ def test_admin_can_create_regulation(
+ self, auth_client, make_user, api_urls, staff, superuser
+ ):
+ admin = make_user(
+ f"regulation-admin-{staff}-{superuser}@example.com",
+ staff=staff,
+ superuser=superuser,
+ )
+ client = auth_client(admin)
+
+ response = client.post(
+ api_urls["list"],
+ {
+ "title": "Admin regulation",
+ "sent_to_all": True,
+ "is_regulation": True,
+ },
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.data["is_regulation"] is True
+
# -------------------- C. Чтение (GET) --------------------
class TestRead:
@@ -741,6 +862,93 @@ class TestRead:
class TestUpdate:
"""D. Обновление документа (PUT/PATCH)."""
+ def test_non_admin_uploader_cannot_update_regulation(
+ self, auth_client, make_user, api_urls
+ ):
+ author = make_user("regulation-author@example.com", staff=False)
+ regulation = make_document(
+ uploaded_by=author,
+ title="Original regulation",
+ is_regulation=True,
+ )
+
+ response = auth_client(author).patch(
+ api_urls["detail"](regulation.pk),
+ {"title": "Changed regulation"},
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ regulation.refresh_from_db()
+ assert regulation.title == "Original regulation"
+
+ def test_non_admin_with_change_permission_cannot_update_regulation(
+ self, auth_client, make_user, api_urls
+ ):
+ author = make_user("regulation-owner@example.com")
+ editor = make_user("regulation-editor@example.com", staff=False)
+ grant_perms(editor, "change_document")
+ regulation = make_document(
+ uploaded_by=author,
+ title="Protected regulation",
+ is_regulation=True,
+ )
+
+ response = auth_client(editor).patch(
+ api_urls["detail"](regulation.pk),
+ {"description": "Unauthorized change"},
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ regulation.refresh_from_db()
+ assert regulation.description == "desc"
+
+ def test_non_admin_cannot_convert_document_to_regulation(
+ self, auth_client, make_user, api_urls
+ ):
+ author = make_user("document-author@example.com", staff=False)
+ document = make_document(uploaded_by=author, is_regulation=False)
+
+ response = auth_client(author).patch(
+ api_urls["detail"](document.pk),
+ {"is_regulation": True},
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ document.refresh_from_db()
+ assert document.is_regulation is False
+
+ @pytest.mark.parametrize(
+ "staff,superuser",
+ [(True, False), (False, True)],
+ )
+ def test_admin_can_update_regulation(
+ self, auth_client, make_user, api_urls, staff, superuser
+ ):
+ owner = make_user("regulation-legacy-owner@example.com")
+ admin = make_user(
+ f"regulation-editor-{staff}-{superuser}@example.com",
+ staff=staff,
+ superuser=superuser,
+ )
+ regulation = make_document(
+ uploaded_by=owner,
+ title="Old regulation title",
+ is_regulation=True,
+ )
+
+ response = auth_client(admin).patch(
+ api_urls["detail"](regulation.pk),
+ {"title": "New regulation title"},
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ regulation.refresh_from_db()
+ assert regulation.title == "New regulation title"
+
def test_update_title_description(self, auth_client, make_user, api_urls):
"""PUT/PATCH обновляют title/description."""
author = make_user("author@example.com")
@@ -1053,6 +1261,38 @@ class TestFolders:
class TestDocumentNotifications:
"""E3. Уведомления о новых документах и ознакомлении."""
+ def test_department_notification_includes_role_only_employee(
+ self, monkeypatch, make_user
+ ):
+ from documents.notifications.handlers import notify_department_employees
+
+ calls = []
+ monkeypatch.setattr(
+ "documents.notifications.handlers.notify.send",
+ lambda **kwargs: calls.append(kwargs),
+ )
+ author = make_user("role-notification-author@example.com")
+ role_user = make_user("role-notification-viewer@example.com")
+ department = Department.objects.create(name="Role notification department")
+ role = DepartmentRole.objects.create(
+ department=department,
+ name="Notification role",
+ )
+ RoleAssignment.objects.create(
+ employee=role_user,
+ role=role,
+ is_active=True,
+ )
+ document = make_document(
+ uploaded_by=author,
+ sent_to_all=False,
+ is_regulation=True,
+ )
+
+ notify_department_employees(document, {department.id})
+
+ assert [call["recipient"] for call in calls] == [role_user]
+
def test_required_document_notification_mentions_acknowledgement(
self, monkeypatch, make_user
):
diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py
index 3c79b4b0..5ff9b3ab 100644
--- a/backend/tests/api/v1/procurement/test_procurement_requests.py
+++ b/backend/tests/api/v1/procurement/test_procurement_requests.py
@@ -43,6 +43,7 @@ from procurement.models import (
ProcurementItemAttachment,
ProcurementRequest,
ProcurementRequestActivity,
+ ProcurementRequestView,
ProcurementSettings,
)
from procurement.notifications.handlers import notify_new_request
@@ -289,6 +290,58 @@ class TestProcurementRequestList:
assert results[0]['id'] == procurement_request.id
assert results[0]['title'] == "Закупка ноутбуков"
+ def test_default_order_uses_latest_defect_or_creation_date(
+ self, api_client, user, department, procurement_request_factory,
+ procurement_item_factory
+ ):
+ now = timezone.now()
+ old_defective_request = procurement_request_factory(
+ department=department,
+ requestor=user,
+ title="Старая заявка с новым браком",
+ )
+ middle_request = procurement_request_factory(
+ department=department,
+ requestor=user,
+ title="Вчерашняя заявка",
+ )
+ newest_request = procurement_request_factory(
+ department=department,
+ requestor=user,
+ title="Самая новая заявка",
+ )
+ defective_item = procurement_item_factory(
+ request=old_defective_request,
+ execution_status=ProcurementItemExecutionStatus.DEFECTIVE,
+ )
+ ProcurementRequest.objects.filter(pk=old_defective_request.pk).update(
+ created_at=now - timedelta(days=365),
+ )
+ ProcurementRequest.objects.filter(pk=middle_request.pk).update(
+ created_at=now - timedelta(days=1),
+ )
+ ProcurementRequest.objects.filter(pk=newest_request.pk).update(
+ created_at=now,
+ )
+ ProcurementItem.objects.filter(pk=defective_item.pk).update(
+ defective_at=now - timedelta(minutes=1),
+ )
+
+ api_client.force_authenticate(user=user)
+ url = reverse('api:v1:procurement:procurementrequest-list')
+ response = api_client.get(
+ url,
+ {'scope': 'mine', 'status': ProcurementStatus.DRAFT},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert [item['id'] for item in response.data['results'][:3]] == [
+ newest_request.id,
+ old_defective_request.id,
+ middle_request.id,
+ ]
+ assert response.data['results'][1]['last_defective_at'] is not None
+
def test_list_includes_visible_linked_tasks_only(
self,
api_client,
@@ -944,6 +997,55 @@ class TestProcurementItemComments:
]
assert comment_activities.first().metadata["item_id"] == procurement_item.id
+ def test_comment_links_are_added_to_item_without_duplicates(
+ self, api_client, user, procurement_item
+ ):
+ procurement_item.links = [
+ "https://existing.example/path",
+ "example.org/catalog",
+ ]
+ procurement_item.save(update_fields=["links"])
+ api_client.force_authenticate(user=user)
+ comments_url = reverse(
+ 'api:v1:procurement:procurementitem-comments',
+ kwargs={'pk': procurement_item.id},
+ )
+
+ response = api_client.post(
+ comments_url,
+ {
+ 'text': (
+ 'Существующая (HTTPS://EXISTING.EXAMPLE/path), '
+ 'каталог https://example.org/catalog и новая '
+ 'https://shop.example/item?id=7. Повтор: '
+ 'https://shop.example/item?id=7.'
+ )
+ },
+ format='json',
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.data['added_links'] == [
+ 'https://shop.example/item?id=7'
+ ]
+ assert response.data['item_links'] == [
+ 'https://existing.example/path',
+ 'example.org/catalog',
+ 'https://shop.example/item?id=7',
+ ]
+ procurement_item.refresh_from_db()
+ assert procurement_item.links == response.data['item_links']
+
+ duplicate_response = api_client.post(
+ comments_url,
+ {'text': 'Ещё раз https://shop.example/item?id=7'},
+ format='json',
+ )
+
+ assert duplicate_response.status_code == status.HTTP_201_CREATED
+ assert duplicate_response.data['added_links'] == []
+ assert duplicate_response.data['item_links'] == procurement_item.links
+
def test_outsider_cannot_comment_item(
self, api_client, procurement_item
):
@@ -1824,6 +1926,155 @@ class TestProcessingDepartmentWorkflow:
assert reassigned_activity.actor == supply_replacement_user
assert reassigned_activity.metadata["previous_executor_id"] == supply_user.id
+ def test_processing_department_user_can_start_pending_request(
+ self, api_client, department_head, supply_user, processing_request,
+ procurement_item_factory
+ ):
+ procurement_item_factory(request=processing_request)
+ processing_request.status = ProcurementStatus.PENDING
+ processing_request.save(update_fields=["status", "updated_at"])
+ Approval.objects.create(
+ request=processing_request,
+ approver=department_head,
+ priority=HEAD_PRIORITY,
+ status=ApprovalStatus.PENDING,
+ step_name="Руководитель отдела",
+ )
+ start_url = reverse(
+ 'api:v1:procurement:procurementrequest-start-work',
+ kwargs={'pk': processing_request.id},
+ )
+ detail_url = reverse(
+ 'api:v1:procurement:procurementrequest-detail',
+ kwargs={'pk': processing_request.id},
+ )
+
+ api_client.force_authenticate(user=supply_user)
+ detail_response = api_client.get(detail_url)
+ assert detail_response.status_code == status.HTTP_200_OK
+ assert detail_response.data["can_current_user_start_work"] is True
+
+ response = api_client.post(start_url)
+
+ assert response.status_code == status.HTTP_200_OK
+ processing_request.refresh_from_db()
+ assert processing_request.status == ProcurementStatus.PENDING
+ assert processing_request.executor == supply_user
+ assert processing_request.started_at is not None
+ assert ProcurementRequestActivity.objects.filter(
+ request=processing_request,
+ action=ProcurementActivityAction.STARTED,
+ actor=supply_user,
+ ).count() == 1
+
+ api_client.force_authenticate(user=department_head)
+ approve_url = reverse(
+ 'api:v1:procurement:procurementrequest-approve',
+ kwargs={'pk': processing_request.id},
+ )
+ approve_response = api_client.post(approve_url, {'comment': 'Согласовано'})
+
+ assert approve_response.status_code == status.HTTP_200_OK
+ processing_request.refresh_from_db()
+ assert processing_request.status == ProcurementStatus.IN_PROGRESS
+ assert processing_request.executor == supply_user
+ assert ProcurementRequestActivity.objects.filter(
+ request=processing_request,
+ action=ProcurementActivityAction.STARTED,
+ ).count() == 1
+ assert ProcurementRequestActivity.objects.filter(
+ request=processing_request,
+ action=ProcurementActivityAction.APPROVED,
+ ).exists()
+
+ def test_processing_department_user_can_reassign_pending_request(
+ self, api_client, supply_user, supply_replacement_user,
+ processing_request, procurement_item_factory
+ ):
+ procurement_item_factory(request=processing_request)
+ previous_started_at = timezone.now() - timedelta(hours=1)
+ processing_request.status = ProcurementStatus.PENDING
+ processing_request.executor = supply_user
+ processing_request.started_at = previous_started_at
+ processing_request.save(
+ update_fields=[
+ "status",
+ "executor",
+ "started_at",
+ "updated_at",
+ ],
+ )
+ api_client.force_authenticate(user=supply_replacement_user)
+ url = reverse(
+ 'api:v1:procurement:procurementrequest-start-work',
+ kwargs={'pk': processing_request.id},
+ )
+ detail_url = reverse(
+ 'api:v1:procurement:procurementrequest-detail',
+ kwargs={'pk': processing_request.id},
+ )
+
+ detail_response = api_client.get(detail_url)
+ assert detail_response.status_code == status.HTTP_200_OK
+ assert detail_response.data["can_current_user_start_work"] is True
+ response = api_client.post(url)
+
+ assert response.status_code == status.HTTP_200_OK
+ processing_request.refresh_from_db()
+ assert processing_request.status == ProcurementStatus.PENDING
+ assert processing_request.executor == supply_replacement_user
+ assert processing_request.started_at == previous_started_at
+ reassigned_activity = ProcurementRequestActivity.objects.get(
+ request=processing_request,
+ action=ProcurementActivityAction.EXECUTOR_REASSIGNED,
+ )
+ assert reassigned_activity.metadata["previous_executor_id"] == supply_user.id
+
+ def test_available_scope_includes_pending_request_for_processing_department(
+ self, api_client, supply_user, processing_request,
+ procurement_item_factory
+ ):
+ procurement_item_factory(request=processing_request)
+ processing_request.status = ProcurementStatus.PENDING
+ processing_request.save(update_fields=["status", "updated_at"])
+ api_client.force_authenticate(user=supply_user)
+ url = reverse('api:v1:procurement:procurementrequest-list')
+
+ response = api_client.get(url, {'scope': 'available'})
+
+ assert response.status_code == status.HTTP_200_OK
+ assert processing_request.id in {
+ item['id'] for item in response.data['results']
+ }
+
+ @pytest.mark.parametrize(
+ "request_status",
+ [
+ ProcurementStatus.DRAFT,
+ ProcurementStatus.COMPLETED,
+ ProcurementStatus.REJECTED,
+ ProcurementStatus.CANCELLED,
+ ],
+ )
+ def test_processing_department_user_cannot_start_inactive_request(
+ self, api_client, supply_user, processing_request,
+ procurement_item_factory, request_status
+ ):
+ procurement_item_factory(request=processing_request)
+ processing_request.status = request_status
+ processing_request.save(update_fields=["status", "updated_at"])
+ api_client.force_authenticate(user=supply_user)
+ url = reverse(
+ 'api:v1:procurement:procurementrequest-start-work',
+ kwargs={'pk': processing_request.id},
+ )
+
+ response = api_client.post(url)
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ processing_request.refresh_from_db()
+ assert processing_request.executor is None
+
def test_available_scope_includes_reassignable_processing_request(
self, api_client, supply_user, supply_replacement_user,
processing_request, procurement_item_factory
@@ -2020,7 +2271,7 @@ class TestProcessingDepartmentWorkflow:
processing_request.refresh_from_db()
assert processing_request.executor == supply_user
- def test_non_processing_in_progress_request_cannot_be_reassigned(
+ def test_non_processing_in_progress_request_can_be_reassigned(
self, api_client, user, staff_user, procurement_request,
procurement_item
):
@@ -2043,9 +2294,10 @@ class TestProcessingDepartmentWorkflow:
response = api_client.post(url)
- assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert response.status_code == status.HTTP_200_OK
procurement_request.refresh_from_db()
- assert procurement_request.executor == staff_user
+ assert procurement_request.executor == user
+ assert procurement_request.status == ProcurementStatus.IN_PROGRESS
def test_processing_department_role_user_can_manage_items(
self, api_client, supply_role_user, processing_request,
@@ -2722,7 +2974,10 @@ class TestProcessingDepartmentWorkflow:
assert cancel_received_response.status_code == status.HTTP_400_BAD_REQUEST
assert mark_all_response.status_code == status.HTTP_400_BAD_REQUEST
assert complete_response.status_code == status.HTTP_403_FORBIDDEN
- assert start_response.status_code == status.HTTP_400_BAD_REQUEST
+ assert start_response.status_code == status.HTTP_200_OK
+ processing_request.refresh_from_db()
+ assert processing_request.status == ProcurementStatus.PENDING
+ assert processing_request.executor == supply_user
def test_item_status_recalculates_request_fulfillment_status(
self, api_client, supply_user, processing_request,
@@ -2949,7 +3204,7 @@ class TestProcessingDepartmentWorkflow:
item.refresh_from_db()
assert item.expected_delivery_dates == []
- def test_item_quantities_cannot_exceed_requested_quantity(
+ def test_item_quantities_can_exceed_requested_quantity(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
@@ -2967,14 +3222,60 @@ class TestProcessingDepartmentWorkflow:
kwargs={'pk': item.id},
)
- response = api_client.patch(
+ ordered_response = api_client.patch(
item_url,
- {'ordered_quantity': 4},
+ {'ordered_quantity': 5},
format='json',
)
- assert response.status_code == status.HTTP_400_BAD_REQUEST
- assert "ordered_quantity" in response.data
+ assert ordered_response.status_code == status.HTTP_200_OK
+ item.refresh_from_db()
+ processing_request.refresh_from_db()
+ assert item.ordered_quantity == 5
+ assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
+ assert (
+ processing_request.fulfillment_status
+ == ProcurementFulfillmentStatus.ORDERED
+ )
+
+ partial_received_response = api_client.patch(
+ item_url,
+ {'received_quantity': 4},
+ format='json',
+ )
+
+ assert partial_received_response.status_code == status.HTTP_200_OK
+ assert (
+ partial_received_response.data["execution_status_display"]
+ == "Получено частично"
+ )
+ item.refresh_from_db()
+ processing_request.refresh_from_db()
+ assert item.ordered_quantity == 5
+ assert item.received_quantity == 4
+ assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
+ assert (
+ processing_request.fulfillment_status
+ == ProcurementFulfillmentStatus.PARTIALLY_RECEIVED
+ )
+ assert processing_request.status == ProcurementStatus.IN_PROGRESS
+
+ excess_received_response = api_client.patch(
+ item_url,
+ {'received_quantity': 6},
+ format='json',
+ )
+
+ assert excess_received_response.status_code == status.HTTP_200_OK
+ item.refresh_from_db()
+ processing_request.refresh_from_db()
+ assert item.ordered_quantity == 6
+ assert item.received_quantity == 6
+ assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
+ assert (
+ processing_request.fulfillment_status
+ == ProcurementFulfillmentStatus.COMPLETED
+ )
def test_ordered_quantity_moves_pending_item_to_ordered(
self, api_client, supply_user, processing_request,
@@ -3006,6 +3307,99 @@ class TestProcessingDepartmentWorkflow:
assert item.ordered_quantity == 2
assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
+ def test_confirm_ordered_adds_quantity_and_optional_delivery_date(
+ self, api_client, supply_user, processing_request,
+ procurement_item_factory
+ ):
+ item = procurement_item_factory(
+ request=processing_request,
+ quantity=3,
+ ordered_quantity=1,
+ expected_delivery_dates=["2026-08-20"],
+ execution_status=ProcurementItemExecutionStatus.ORDERED,
+ )
+ processing_request.executor = supply_user
+ processing_request.status = ProcurementStatus.IN_PROGRESS
+ processing_request.save()
+
+ api_client.force_authenticate(user=supply_user)
+ url = reverse(
+ 'api:v1:procurement:procurementitem-confirm-ordered',
+ kwargs={'pk': item.id},
+ )
+
+ response = api_client.post(
+ url,
+ {
+ 'ordered_quantity': 5,
+ 'expected_delivery_date': '2026-08-25',
+ },
+ format='json',
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data['ordered_quantity'] == 5
+ assert response.data['expected_delivery_dates'] == [
+ '2026-08-20',
+ '2026-08-25',
+ ]
+ item.refresh_from_db()
+ assert item.ordered_quantity == 5
+ assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
+
+ without_date_response = api_client.post(
+ url,
+ {'ordered_quantity': 6},
+ format='json',
+ )
+
+ assert without_date_response.status_code == status.HTTP_200_OK
+ assert without_date_response.data['ordered_quantity'] == 6
+ assert without_date_response.data['expected_delivery_dates'] == [
+ '2026-08-20',
+ '2026-08-25',
+ ]
+
+ def test_confirm_ordered_rejects_non_increasing_quantity_and_bad_date(
+ self, api_client, supply_user, processing_request,
+ procurement_item_factory
+ ):
+ item = procurement_item_factory(
+ request=processing_request,
+ quantity=3,
+ ordered_quantity=2,
+ execution_status=ProcurementItemExecutionStatus.ORDERED,
+ )
+ processing_request.executor = supply_user
+ processing_request.status = ProcurementStatus.IN_PROGRESS
+ processing_request.save()
+
+ api_client.force_authenticate(user=supply_user)
+ url = reverse(
+ 'api:v1:procurement:procurementitem-confirm-ordered',
+ kwargs={'pk': item.id},
+ )
+
+ same_quantity_response = api_client.post(
+ url,
+ {'ordered_quantity': 2},
+ format='json',
+ )
+ bad_date_response = api_client.post(
+ url,
+ {
+ 'ordered_quantity': 3,
+ 'expected_delivery_date': '31.02.2026',
+ },
+ format='json',
+ )
+
+ assert same_quantity_response.status_code == status.HTTP_400_BAD_REQUEST
+ assert bad_date_response.status_code == status.HTTP_400_BAD_REQUEST
+ item.refresh_from_db()
+ assert item.ordered_quantity == 2
+ assert item.expected_delivery_dates == []
+
def test_partial_quantities_drive_item_display_and_request_fulfillment(
self, api_client, supply_user, processing_request,
procurement_item_factory
@@ -3201,6 +3595,20 @@ class TestProcessingDepartmentWorkflow:
processing_request.status = ProcurementStatus.COMPLETED
processing_request.completed_at = timezone.now()
processing_request.save()
+ ProcurementRequestView.objects.bulk_create([
+ ProcurementRequestView(
+ request=processing_request,
+ user=user,
+ is_viewed=True,
+ viewed_at=timezone.now(),
+ ),
+ ProcurementRequestView(
+ request=processing_request,
+ user=supply_user,
+ is_viewed=True,
+ viewed_at=timezone.now(),
+ ),
+ ])
sent.clear()
api_client.force_authenticate(user=user)
@@ -3219,12 +3627,21 @@ class TestProcessingDepartmentWorkflow:
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.DEFECTIVE
+ assert item.defective_at is not None
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.ISSUES
)
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.completed_at is None
+ assert not ProcurementRequestView.objects.filter(
+ request=processing_request,
+ is_viewed=True,
+ ).exists()
+ assert not ProcurementRequestView.objects.filter(
+ request=processing_request,
+ viewed_at__isnull=False,
+ ).exists()
assert [comment.content for comment in get_comments(item)] == [
"Обнаружен брак"
]
@@ -3247,10 +3664,61 @@ class TestProcessingDepartmentWorkflow:
issue_recipients = [
notification["recipient"].email
for notification in sent
- if notification["verb"] == "procurement_item_updated"
+ if notification["verb"] == "procurement_item_issue_reported"
]
assert supply_user.email in issue_recipients
+ def test_processing_member_defect_notifies_assigned_executor(
+ self, api_client, supply_user, supply_replacement_user,
+ processing_request, procurement_item_factory
+ ):
+ item = procurement_item_factory(request=processing_request)
+ processing_request.executor = supply_user
+ processing_request.status = ProcurementStatus.IN_PROGRESS
+ processing_request.save()
+ ProcurementRequestView.objects.bulk_create([
+ ProcurementRequestView(
+ request=processing_request,
+ user=supply_user,
+ is_viewed=True,
+ viewed_at=timezone.now(),
+ ),
+ ProcurementRequestView(
+ request=processing_request,
+ user=supply_replacement_user,
+ is_viewed=True,
+ viewed_at=timezone.now(),
+ ),
+ ])
+
+ api_client.force_authenticate(user=supply_replacement_user)
+ url = reverse(
+ 'api:v1:procurement:procurementitem-detail',
+ kwargs={'pk': item.id},
+ )
+ response = api_client.patch(
+ url,
+ {'execution_status': ProcurementItemExecutionStatus.DEFECTIVE},
+ format='json',
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ notification = Notification.objects.get(
+ recipient=supply_user,
+ verb='procurement_item_issue_reported',
+ target_object_id=str(processing_request.id),
+ )
+ assert notification.unread is True
+ assert notification.data['item_id'] == item.id
+ assert notification.data['new_status'] == (
+ ProcurementItemExecutionStatus.DEFECTIVE
+ )
+ assert ProcurementRequestView.objects.filter(
+ request=processing_request,
+ is_viewed=False,
+ viewed_at__isnull=True,
+ ).count() == 2
+
def test_executor_issue_reopens_request_for_requestor_without_status_spam(
self, api_client, user, supply_user, processing_request,
procurement_item_factory, monkeypatch
@@ -3325,7 +3793,7 @@ class TestProcessingDepartmentWorkflow:
item = procurement_item_factory(
request=processing_request,
quantity=2,
- ordered_quantity=1,
+ ordered_quantity=3,
received_quantity=0,
execution_status=ProcurementItemExecutionStatus.ORDERED,
)
@@ -3345,8 +3813,8 @@ class TestProcessingDepartmentWorkflow:
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
- assert item.ordered_quantity == item.quantity
- assert item.received_quantity == item.quantity
+ assert item.ordered_quantity == 3
+ assert item.received_quantity == 3
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.COMPLETED
@@ -3394,7 +3862,7 @@ class TestProcessingDepartmentWorkflow:
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.completed_at is None
- def test_confirm_item_received_rejects_invalid_quantity(
+ def test_confirm_item_received_rejects_non_increasing_and_accepts_excess(
self, api_client, user, supply_user, processing_request,
procurement_item_factory
):
@@ -3427,9 +3895,11 @@ class TestProcessingDepartmentWorkflow:
)
assert too_low_response.status_code == status.HTTP_400_BAD_REQUEST
- assert too_high_response.status_code == status.HTTP_400_BAD_REQUEST
+ assert too_high_response.status_code == status.HTTP_200_OK
item.refresh_from_db()
- assert item.received_quantity == 1
+ assert item.ordered_quantity == 3
+ assert item.received_quantity == 3
+ assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
def test_requestor_can_cancel_item_issue(
self, api_client, user, supply_user, processing_request,
@@ -3459,6 +3929,7 @@ class TestProcessingDepartmentWorkflow:
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
+ assert item.defective_at is None
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.COMPLETED
@@ -3909,7 +4380,7 @@ class TestProcessingDepartmentWorkflow:
request=processing_request,
quantity=2,
execution_status=ProcurementItemExecutionStatus.ORDERED,
- ordered_quantity=1,
+ ordered_quantity=3,
)
item_2 = procurement_item_factory(
request=processing_request,
@@ -3934,8 +4405,8 @@ class TestProcessingDepartmentWorkflow:
item_2.refresh_from_db()
assert item_1.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert item_2.execution_status == ProcurementItemExecutionStatus.RECEIVED
- assert item_1.ordered_quantity == item_1.quantity
- assert item_1.received_quantity == item_1.quantity
+ assert item_1.ordered_quantity == 3
+ assert item_1.received_quantity == 3
assert item_2.ordered_quantity == item_2.quantity
assert item_2.received_quantity == item_2.quantity
assert (
@@ -3957,6 +4428,65 @@ class TestProcessingDepartmentWorkflow:
]
assert completed_recipients == [processing_request.requestor.email]
+ def test_requestor_can_mark_all_received_from_request_card(
+ self, api_client, user, supply_user, processing_request,
+ procurement_item_factory, monkeypatch
+ ):
+ monkeypatch.setattr(
+ "procurement.notifications.handlers.notify.send",
+ lambda **kwargs: None,
+ )
+ item = procurement_item_factory(
+ request=processing_request,
+ quantity=2,
+ execution_status=ProcurementItemExecutionStatus.ORDERED,
+ ordered_quantity=3,
+ )
+ processing_request.executor = supply_user
+ processing_request.status = ProcurementStatus.IN_PROGRESS
+ processing_request.save(
+ update_fields=["executor", "status", "updated_at"],
+ )
+ api_client.force_authenticate(user=user)
+ url = reverse(
+ 'api:v1:procurement:procurementrequest-mark-all-received',
+ kwargs={'pk': processing_request.id},
+ )
+
+ response = api_client.post(url)
+
+ assert response.status_code == status.HTTP_200_OK
+ item.refresh_from_db()
+ processing_request.refresh_from_db()
+ assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
+ assert item.received_quantity == 3
+ assert processing_request.status == ProcurementStatus.COMPLETED
+ activity = ProcurementRequestActivity.objects.get(
+ request=processing_request,
+ action=ProcurementActivityAction.ALL_ITEMS_RECEIVED,
+ )
+ assert activity.actor == user
+
+ def test_customer_department_colleague_cannot_mark_all_received(
+ self, api_client, department_head, supply_user, processing_request,
+ procurement_item_factory
+ ):
+ procurement_item_factory(request=processing_request)
+ processing_request.executor = supply_user
+ processing_request.status = ProcurementStatus.IN_PROGRESS
+ processing_request.save(
+ update_fields=["executor", "status", "updated_at"],
+ )
+ api_client.force_authenticate(user=department_head)
+ url = reverse(
+ 'api:v1:procurement:procurementrequest-mark-all-received',
+ kwargs={'pk': processing_request.id},
+ )
+
+ response = api_client.post(url)
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
def test_processing_user_can_notify_requestor_about_arrival_multiple_times(
self, api_client, user, supply_user, processing_request, monkeypatch
):
diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py
index 4f130d23..4f48948b 100644
--- a/backend/tests/api/v1/requests_app/test_requests_api.py
+++ b/backend/tests/api/v1/requests_app/test_requests_api.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from datetime import date, datetime
+from datetime import date, datetime, time
from typing import Any
import pytest
@@ -573,6 +573,99 @@ def test_day_off_request_requires_full_period(
assert "date_to" in resp.json()
+def test_short_day_off_requires_valid_single_day_time_interval(
+ auth_client, regular_user: models.Model, make_user
+) -> None:
+ recipient = make_user(email="short-day-off-recipient@example.com")
+ base_payload = {
+ "type": RequestType.SHORT_DAY_OFF,
+ "date_from": "2026-04-20",
+ "recipient_ids": [recipient.id],
+ }
+
+ missing_time = auth_client(regular_user).post(
+ API_BASE,
+ data=base_payload,
+ format="json",
+ )
+ reversed_time = auth_client(regular_user).post(
+ API_BASE,
+ data={**base_payload, "time_from": "15:00", "time_to": "14:00"},
+ format="json",
+ )
+ different_day = auth_client(regular_user).post(
+ API_BASE,
+ data={
+ **base_payload,
+ "date_to": "2026-04-21",
+ "time_from": "14:00",
+ "time_to": "15:00",
+ },
+ format="json",
+ )
+
+ assert missing_time.status_code == 400
+ assert reversed_time.status_code == 400
+ assert different_day.status_code == 400
+
+
+def test_short_day_off_normalizes_end_date_and_exposes_datetimes(
+ auth_client, regular_user: models.Model, make_user
+) -> None:
+ recipient = make_user(email="short-day-off-ok-recipient@example.com")
+
+ response = auth_client(regular_user).post(
+ API_BASE,
+ data={
+ "type": RequestType.SHORT_DAY_OFF,
+ "date_from": "2026-04-20",
+ "time_from": "14:00",
+ "time_to": "16:30",
+ "recipient_ids": [recipient.id],
+ },
+ format="json",
+ )
+
+ assert response.status_code == 201
+ payload = response.json()
+ assert payload["date_from"] == "2026-04-20"
+ assert payload["date_to"] == "2026-04-20"
+ assert payload["time_from"] == "14:00:00"
+ assert payload["time_to"] == "16:30:00"
+ assert payload["duration_minutes"] == 150
+ assert payload["starts_at"].startswith("2026-04-20T14:00:00")
+ assert payload["ends_at"].startswith("2026-04-20T16:30:00")
+
+
+def test_statistics_counts_short_day_off_as_day_off_without_full_day(
+ auth_client,
+ admin_user: models.Model,
+ make_user,
+) -> None:
+ employee = make_user(email="short-day-off-stats@example.com")
+ EmployeeRequest.objects.create(
+ employee=employee,
+ approver=admin_user,
+ type=RequestType.SHORT_DAY_OFF,
+ status=RequestStatus.APPROVED,
+ date_from=date(2026, 4, 20),
+ date_to=date(2026, 4, 20),
+ time_from=time(14, 0),
+ time_to=time(16, 30),
+ )
+
+ response = auth_client(admin_user).get(
+ f"{API_BASE}statistics/?employee_id={employee.id}&period=all"
+ )
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["day_off_requests_count"] == 1
+ assert payload["short_day_off_requests_count"] == 1
+ assert payload["day_off_days"] == 0
+ assert payload["short_day_off_minutes"] == 150
+
+
def test_create_request_sends_notification_to_cc_users(
auth_client, regular_user: models.Model, make_user
) -> None:
diff --git a/backend/tests/employees/test_personnel_state.py b/backend/tests/employees/test_personnel_state.py
index cc06e729..90be4689 100644
--- a/backend/tests/employees/test_personnel_state.py
+++ b/backend/tests/employees/test_personnel_state.py
@@ -1,4 +1,4 @@
-from datetime import date
+from datetime import date, time
import pytest
from django.utils import timezone
@@ -20,6 +20,8 @@ from employees.services.personnel_state import (
calculate_employee_tenure_days,
resolve_employee_personnel_state,
)
+from requests_app.enums import RequestStatus, RequestType
+from requests_app.models import Request
pytestmark = pytest.mark.django_db
@@ -215,6 +217,35 @@ def test_overlapping_temporary_intervals_use_priority(user_factory):
assert state.action_id == sick.id
+def test_short_day_off_keeps_workday_and_exposes_approved_interval(user_factory):
+ employee = user_factory()
+ request = Request.objects.create(
+ employee=employee,
+ approver=employee,
+ type=RequestType.SHORT_DAY_OFF,
+ status=RequestStatus.APPROVED,
+ date_from=date(2026, 4, 21),
+ date_to=date(2026, 4, 21),
+ time_from=time(14, 0),
+ time_to=time(16, 30),
+ )
+ EmployeeAction.objects.create(
+ employee=employee,
+ source_request=request,
+ action=ACTION_ON_DAY_OFF,
+ date=timezone.make_aware(timezone.datetime(2026, 4, 21, 14, 0)),
+ date_to=date(2026, 4, 21),
+ extra={"partial_day": True},
+ )
+
+ state = resolve_employee_personnel_state(employee, date(2026, 4, 21))
+
+ assert state.expects_attendance is True
+ assert state.status != ACTION_ON_DAY_OFF
+ assert state.approved_absence_intervals == ((time(14, 0), time(16, 30)),)
+ assert state.approved_absence_minutes == 150
+
+
def test_dismissal_overrides_temporary_interval(user_factory):
employee = user_factory()
EmployeeAction.objects.create(
diff --git a/backend/tests/finance/test_payroll_short_day_off.py b/backend/tests/finance/test_payroll_short_day_off.py
new file mode 100644
index 00000000..a7cba062
--- /dev/null
+++ b/backend/tests/finance/test_payroll_short_day_off.py
@@ -0,0 +1,161 @@
+from datetime import date, time
+from decimal import Decimal
+
+import pytest
+from django.utils import timezone
+
+from attendance.services import build_attendance_record_defaults
+from employees.constants import ACTION_HIRED, ACTION_ON_DAY_OFF
+from employees.models import EmployeeAction
+from employees.services.personnel_state import resolve_employee_personnel_state
+from finance.models import PayrollPeriod
+from finance.payroll.work_norm import calculate_period_personnel_daily_points
+from requests_app.enums import RequestStatus, RequestType
+from requests_app.models import Request
+
+
+pytestmark = pytest.mark.django_db
+
+
+def _short_day_off(
+ employee,
+ approver,
+ *,
+ time_from: time = time(9, 0),
+ time_to: time = time(11, 0),
+) -> tuple[Request, EmployeeAction]:
+ request = Request.objects.create(
+ employee=employee,
+ approver=approver,
+ type=RequestType.SHORT_DAY_OFF,
+ status=RequestStatus.APPROVED,
+ date_from=date(2026, 7, 1),
+ date_to=date(2026, 7, 1),
+ time_from=time_from,
+ time_to=time_to,
+ )
+ action = EmployeeAction.objects.create(
+ employee=employee,
+ source_request=request,
+ action=ACTION_ON_DAY_OFF,
+ date=timezone.make_aware(
+ timezone.datetime.combine(date(2026, 7, 1), time_from)
+ ),
+ date_to=date(2026, 7, 1),
+ extra={"partial_day": True},
+ )
+ return request, action
+
+
+def test_short_day_off_reduces_personnel_points_proportionally(user_factory):
+ employee = user_factory(email="short-day-off-finance@example.com")
+ approver = user_factory(email="short-day-off-approver@example.com")
+ employee.actions.all().delete()
+ hired = EmployeeAction.objects.create(
+ employee=employee,
+ action=ACTION_HIRED,
+ date=timezone.make_aware(timezone.datetime(2026, 6, 1, 12, 0)),
+ )
+ _, short_action = _short_day_off(employee, approver)
+ period = PayrollPeriod.objects.create(
+ code="2026-07-short-day-off",
+ name="Короткий отгул",
+ date_from=date(2026, 7, 1),
+ date_to=date(2026, 7, 1),
+ pay_date=date(2026, 7, 5),
+ currency="RUB",
+ created_by=approver,
+ )
+
+ result = calculate_period_personnel_daily_points(
+ period,
+ employee=employee,
+ actions=[hired, short_action],
+ period_target_points=Decimal("9"),
+ schedule={
+ "start_time": "08:00",
+ "end_time": "17:00",
+ "expected_hours": 9,
+ "workdays": ["Wednesday"],
+ "date_overrides": [],
+ },
+ )
+
+ assert result[date(2026, 7, 1)] == Decimal("7.0000")
+
+
+def test_short_day_off_keeps_attendance_workday_and_excuses_underwork(user_factory):
+ employee = user_factory(email="short-day-off-attendance@example.com")
+ approver = user_factory(email="short-day-off-attendance-approver@example.com")
+ _, action = _short_day_off(employee, approver)
+ state = resolve_employee_personnel_state(
+ employee,
+ date(2026, 7, 1),
+ actions=[action],
+ )
+
+ defaults = build_attendance_record_defaults(
+ {
+ "date": "2026-07-01",
+ "is_workday": True,
+ "effective_is_workday": True,
+ "work_hours": 6.5,
+ "expected_hours": 9,
+ "is_underwork": True,
+ "underwork_hours": 2.5,
+ "statuses": ["underwork"],
+ },
+ state,
+ schedule_payload={
+ "start_time": "08:00",
+ "end_time": "17:00",
+ "expected_hours": 9,
+ },
+ )
+
+ assert defaults["effective_is_workday"] is True
+ assert defaults["personnel_status"] == "normal"
+ assert defaults["is_underwork"] is True
+ assert defaults["underwork_hours"] == 0.5
+ assert "approved_short_day_off" in defaults["statuses"]
+ assert defaults["raw_data"]["approved_absence_minutes"] == 120
+
+
+def test_short_day_off_at_shift_start_excuses_lateness(user_factory):
+ employee = user_factory(email="short-day-off-late@example.com")
+ approver = user_factory(email="short-day-off-late-approver@example.com")
+ _, action = _short_day_off(
+ employee,
+ approver,
+ time_from=time(8, 0),
+ time_to=time(10, 0),
+ )
+ state = resolve_employee_personnel_state(
+ employee,
+ date(2026, 7, 1),
+ actions=[action],
+ )
+
+ defaults = build_attendance_record_defaults(
+ {
+ "is_workday": True,
+ "effective_is_workday": True,
+ "arrival_time": "10:00",
+ "work_hours": 7,
+ "expected_hours": 9,
+ "is_late": True,
+ "late_minutes": 120,
+ "statuses": ["late"],
+ },
+ state,
+ schedule_payload={
+ "start_time": "08:00",
+ "end_time": "17:00",
+ "expected_hours": 9,
+ },
+ )
+
+ assert defaults["is_late"] is False
+ assert defaults["late_minutes"] == 0
+ assert "late" not in defaults["statuses"]
+ assert "approved_short_day_off" in defaults["statuses"]
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
index 93baa213..e0615b51 100644
--- a/frontend/public/sw.js
+++ b/frontend/public/sw.js
@@ -6,7 +6,7 @@
* нативные уведомления даже когда страница закрыта.
*/
-const SW_VERSION = '2.1.1';
+const SW_VERSION = '2.1.2';
const STATIC_CACHE = `eusrr-static-${SW_VERSION}`;
const PRECACHE_URLS = [
'/manifest.webmanifest',
@@ -197,8 +197,6 @@ self.addEventListener('notificationclick', (event) => {
self.location.origin,
).toString();
- // Для установленного PWA даём браузеру самому решить, открыть app window
- // или вкладку. Не фокусируем произвольную already-open same-origin вкладку.
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then(async (windowClients) => {
@@ -209,26 +207,34 @@ self.addEventListener('notificationclick', (event) => {
return exactClient.focus();
}
- if (clients.openWindow) {
- return clients.openWindow(urlToOpen);
+ const sameOriginClients = windowClients.filter(
+ (client) => new URL(client.url).origin === self.location.origin,
+ );
+ const sameOriginClient =
+ sameOriginClients.find((client) => client.focused) ||
+ sameOriginClients.find((client) => client.visibilityState === 'visible') ||
+ sameOriginClients[0];
+
+ if (sameOriginClient) {
+ if ('navigate' in sameOriginClient) {
+ try {
+ const navigatedClient = await sameOriginClient.navigate(urlToOpen);
+ return (navigatedClient || sameOriginClient).focus();
+ } catch (error) {
+ console.warn('[SW] Failed to navigate existing client:', error);
+ }
+ }
+
+ sameOriginClient.postMessage({
+ type: 'NOTIFICATION_CLICK',
+ url: urlToOpen,
+ notificationId: event.notification.data?.notification_id
+ });
+ return sameOriginClient.focus();
}
- const sameOriginClient = windowClients.find(
- (client) =>
- client.url.startsWith(self.location.origin) &&
- 'focus' in client,
- );
- if (sameOriginClient) {
- return sameOriginClient.focus().then((client) => {
- if ('navigate' in client) {
- return client.navigate(urlToOpen);
- }
- return client.postMessage({
- type: 'NOTIFICATION_CLICK',
- url: urlToOpen,
- notificationId: event.notification.data?.notification_id
- });
- });
+ if (clients.openWindow) {
+ return clients.openWindow(urlToOpen);
}
})
);
diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx
index 459f8e1c..224b195f 100644
--- a/frontend/src/app/departments/[id]/page.tsx
+++ b/frontend/src/app/departments/[id]/page.tsx
@@ -46,6 +46,7 @@ import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { Modal } from "@/components/ui/Modal";
import { useDepartmentPage } from "@/hooks/useDepartmentPage";
+import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { regulationMatchesAcknowledgementSource } from "@/lib/feed-regulation-filters";
import { displayUserName, formatDate, loadAllPages, userProfileLink } from "@/lib/shared";
@@ -999,6 +1000,10 @@ export default function DepartmentDetailPage() {
const router = useRouter();
const departmentId = Number(params?.id);
const h = useDepartmentPage(departmentId);
+ const { user } = useUser();
+ const canManageRegulations = Boolean(
+ user?.auth?.is_staff || user?.auth?.is_superuser,
+ );
const [managementMode, setManagementMode] = useState(false);
const [membersExpanded, setMembersExpanded] = useState(false);
const [regulationsExpanded, setRegulationsExpanded] = useState(false);
@@ -1458,15 +1463,17 @@ export default function DepartmentDetailPage() {
-
+ {canManageRegulations ? (
+
+ ) : null}
{departmentRegulations.length > 0 ? (
setCreateRegulationOpen(false)}
title="Создать регламент отдела"
size="xl"
diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx
index 9da60be5..0189c785 100644
--- a/frontend/src/app/documents/page.tsx
+++ b/frontend/src/app/documents/page.tsx
@@ -44,6 +44,7 @@ import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { Modal } from "@/components/ui";
import { canPreviewDocument, getDocumentFileExtension } from "@/lib/document-preview";
import { useNotifications } from "@/contexts/NotificationsContext";
+import { useUser } from "@/contexts/UserContext";
import { NAV_NOTIFICATION_CATEGORIES } from "@/lib/navigation-notifications";
import {
regulationMatchesAcknowledgementSource,
@@ -153,6 +154,10 @@ function DocumentsPageContent() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
+ const { user } = useUser();
+ const canManageRegulations = Boolean(
+ user?.auth?.is_staff || user?.auth?.is_superuser,
+ );
const {
unreadCategoryCounts,
unreadRegulationDepartmentCounts,
@@ -451,6 +456,11 @@ function DocumentsPageContent() {
}, [activeSection, documents, search, selectedTags, dateFrom, dateTo, regulationSource, sortBy, sortOrder]);
const selection = useDocumentSelection(filteredDocuments);
+ const canEditSelectedDocuments = canManageRegulations || !selection.selectedIds.some(
+ (documentId) => documents.some(
+ (documentItem) => documentItem.id === documentId && documentItem.is_regulation,
+ ),
+ );
// Find selected folder and build breadcrumb path
const selectedFolder = useMemo(() => {
@@ -683,6 +693,7 @@ function DocumentsPageContent() {
};
const openDocumentMetadataFromMenu = (documentItem: Document) => {
+ if (documentItem.is_regulation && !canManageRegulations) return;
setMetadataDocument(documentItem);
setShowMetadataEditor(true);
setDocumentMenuOpenId(null);
@@ -846,13 +857,15 @@ function DocumentsPageContent() {
{activeSection === "regulations" ? "Регламенты" : "Документы"}
-
+ {(activeSection !== "regulations" || canManageRegulations) ? (
+
+ ) : null}
@@ -1316,8 +1329,8 @@ function DocumentsPageContent() {
selectedIds={selection.selectedIds}
availableFolders={bulkFolderOptions}
availableTags={bulkTagOptions}
- onMove={handleBulkMoveDocuments}
- onAddTags={handleBulkAddTags}
+ onMove={canEditSelectedDocuments ? handleBulkMoveDocuments : undefined}
+ onAddTags={canEditSelectedDocuments ? handleBulkAddTags : undefined}
onDownload={handleBulkDownloadDocuments}
onDelete={handleBulkDeleteDocuments}
onClearSelection={disableSelectionMode}
@@ -1840,25 +1853,29 @@ function DocumentsPageContent() {
Детали
-
+ {(!doc.is_regulation || canManageRegulations) ? (
+ <>
+
-
+
+ >
+ ) : null}
{hasPreview && (
-
+ {(!doc.is_regulation || canManageRegulations) ? (
+
+ ) : null}
@@ -2135,7 +2151,7 @@ function DocumentsPageContent() {
{/* Regulation Modal */}
setShowUploadForm(false)}
title="Создать регламент"
size="xl"
@@ -2162,12 +2178,14 @@ function DocumentsPageContent() {
apiClient.getDocument(selectedDocument.id).then(setSelectedDocument);
}
}}
- onEditMetadata={() => {
- if (selectedDocument) {
- setMetadataDocument(selectedDocument);
- setShowMetadataEditor(true);
- }
- }}
+ onEditMetadata={selectedDocument?.is_regulation && !canManageRegulations
+ ? undefined
+ : () => {
+ if (selectedDocument) {
+ setMetadataDocument(selectedDocument);
+ setShowMetadataEditor(true);
+ }
+ }}
onViewReport={() => {
if (selectedDocument) {
setShowAcknowledgementsReport({
@@ -2373,6 +2391,7 @@ function DocumentsPageContent() {
setMetadataDocument(null);
}}
document={metadataDocument}
+ canManageRegulations={canManageRegulations}
onUpdate={() => {
loadDocuments();
loadFolders();
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index f63ed390..65890c00 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -191,6 +191,7 @@ function HomePageContent() {
};
const canDeleteAnyComments = Boolean(auth?.is_staff || auth?.is_superuser);
+ const canManageRegulations = Boolean(auth?.is_staff || auth?.is_superuser);
const canCreateCompanyPost = Boolean(
auth?.is_staff ||
auth?.is_superuser ||
@@ -792,6 +793,7 @@ function HomePageContent() {
};
const openCreateRegulationModal = () => {
+ if (!canManageRegulations) return;
setCreateMenuOpen(false);
setCreateRegulationOpen(true);
};
@@ -965,9 +967,9 @@ function HomePageContent() {
isAcknowledging={acknowledgingRegulationId === entry.document.id}
onAcknowledge={(document) => void handleAcknowledgeRegulation(document)}
onDelete={(document) => void deleteRegulation(document)}
- onEdit={setMetadataRegulation}
+ onEdit={canManageRegulations ? setMetadataRegulation : undefined}
onLinkTask={setTaskLinkRegulation}
- onMove={setMetadataRegulation}
+ onMove={canManageRegulations ? setMetadataRegulation : undefined}
onOpen={openRegulation}
onOpenComments={openRegulationComments}
onPreview={openRegulationPreview}
@@ -1138,7 +1140,7 @@ function HomePageContent() {
)}
- {user && !createPostOpen && !createRegulationOpen ? (
+ {user && (canCreatePost || canManageRegulations) && !createPostOpen && !createRegulationOpen ? (
) : null}
-
+ {canManageRegulations ? (
+
+ ) : null}
) : null}
) : null}
- {isInProgress && isExecutor ? (
+ {(isInProgress && isExecutor) || canConfirmRequestReceived ? (
) : null}
{canNotifyArrival ? (
@@ -398,6 +416,7 @@ export default function ProcurementPage() {
handleToggleViewed,
handleReportItemIssue,
handleCancelItemIssue,
+ handleConfirmItemOrdered,
handleConfirmItemReceived,
handleCancelItemReceived,
handleUpdateItem,
@@ -1139,11 +1158,13 @@ export default function ProcurementPage() {
const canEditThis = Boolean(isEditableStatus && isAuthor);
const canDeleteThis = Boolean(isEditableStatus && (isAuthor || canManage));
const canCancelThis = Boolean(isAuthor && !isFinal(st) && st !== "draft");
+ const canConfirmAllReceivedThis = canConfirmAllReceived(resolvedDetail, isAuthor);
const hasInlineRequestActions = Boolean(
canSubmitThis ||
canApproveThis ||
(resolvedDetail.can_current_user_start_work ?? req.can_current_user_start_work) ||
- (st === "in_progress" && isExecutor)
+ (st === "in_progress" && isExecutor) ||
+ canConfirmAllReceivedThis
);
const expanded = expandedIds.has(req.id);
const comments = commentsMap[req.id] || [];
@@ -1153,7 +1174,10 @@ export default function ProcurementPage() {
const hasUnreadNotifications = (unreadProcurementRequestCounts[req.id] || 0) > 0;
const rowExpanded = !isViewed && expanded;
const rowCommentsOpen = !isViewed && commentsOpen;
- const showInlineRequestActions = !isViewed && hasInlineRequestActions;
+ const showInlineRequestActions = (
+ (!isViewed && hasInlineRequestActions) ||
+ canConfirmAllReceivedThis
+ );
const showRequestMenu = true;
const requestMenuOpen = showRequestMenu && procurementMenuOpenId === req.id;
const requestorName = displayUserName(req.requestor, req.requestor_name, req.requestor_email);
@@ -1448,6 +1472,7 @@ export default function ProcurementPage() {
onReject={(id) => openRequestActionDialog("reject", id)}
onStart={handleStart}
onComplete={handleComplete}
+ onMarkAllReceived={handleMarkAllReceived}
onNotifyArrival={handleNotifyArrival}
onCancel={handleCancel}
/>
@@ -1509,6 +1534,7 @@ export default function ProcurementPage() {
onUpdateItem={handleUpdateItem}
onReportItemIssue={handleReportItemIssue}
onCancelItemIssue={handleCancelItemIssue}
+ onConfirmItemOrdered={handleConfirmItemOrdered}
onConfirmItemReceived={handleConfirmItemReceived}
onCancelItemReceived={handleCancelItemReceived}
onMarkAllReceived={handleMarkAllReceived}
@@ -1540,6 +1566,7 @@ export default function ProcurementPage() {
onReject={handleReject}
onStart={handleStart}
onComplete={handleComplete}
+ onMarkAllReceived={handleMarkAllReceived}
onNotifyArrival={handleNotifyArrival}
onCancel={handleCancel}
/>
@@ -1590,6 +1617,7 @@ export default function ProcurementPage() {
onReject={(id) => openRequestActionDialog("reject", id)}
onStart={handleStart}
onComplete={handleComplete}
+ onMarkAllReceived={handleMarkAllReceived}
onNotifyArrival={handleNotifyArrival}
onCancel={(id) => openRequestActionDialog("cancel", id)}
/>
@@ -1689,6 +1717,7 @@ export default function ProcurementPage() {
onUpdateItem={handleUpdateItem}
onReportItemIssue={handleReportItemIssue}
onCancelItemIssue={handleCancelItemIssue}
+ onConfirmItemOrdered={handleConfirmItemOrdered}
onConfirmItemReceived={handleConfirmItemReceived}
onCancelItemReceived={handleCancelItemReceived}
onMarkAllReceived={handleMarkAllReceived}
diff --git a/frontend/src/components/attendance/EmployeeAttendanceCard.tsx b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx
index 7b408544..faae36ef 100644
--- a/frontend/src/components/attendance/EmployeeAttendanceCard.tsx
+++ b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx
@@ -165,6 +165,7 @@ const attendanceIssueLabelMap: Record = {
overtime: "Переработка",
technical: "Техническая проблема",
work_outside_personnel_schedule: "Работа вне графика",
+ approved_short_day_off: "Согласованный короткий отгул",
};
function formatAttendanceIssueLabel(label: string) {
diff --git a/frontend/src/components/documents/DocumentDetailModal.tsx b/frontend/src/components/documents/DocumentDetailModal.tsx
index d8c8f05c..cc141582 100644
--- a/frontend/src/components/documents/DocumentDetailModal.tsx
+++ b/frontend/src/components/documents/DocumentDetailModal.tsx
@@ -26,6 +26,7 @@ import {
MessageSquare,
Link2,
ScrollText,
+ X,
} from "lucide-react";
import { DocumentAcknowledgeButton, DocumentAcknowledgement } from "./DocumentAcknowledgement";
import { DocumentComments } from "./DocumentComments";
@@ -154,7 +155,7 @@ export function DocumentDetailModal({
isOpen={isOpen}
onClose={onClose}
size="full"
- showCloseButton
+ noHeader
noPadding
className="relative flex h-[95vh] flex-col sm:h-[90vh]"
>
@@ -200,6 +201,15 @@ export function DocumentDetailModal({
>
)}
+
@@ -556,6 +566,7 @@ export function DocumentDetailModal({
onNavigateToRelated?.(docId)}
/>
@@ -831,6 +842,7 @@ export function DocumentDetailModal({
onNavigateToRelated?.(docId)}
/>
diff --git a/frontend/src/components/documents/DocumentMetadataEditor.tsx b/frontend/src/components/documents/DocumentMetadataEditor.tsx
index 08ed6a5b..12dff2ec 100644
--- a/frontend/src/components/documents/DocumentMetadataEditor.tsx
+++ b/frontend/src/components/documents/DocumentMetadataEditor.tsx
@@ -55,6 +55,7 @@ interface DocumentMetadataEditorProps {
isOpen: boolean;
onClose: () => void;
document: Document;
+ canManageRegulations?: boolean;
onUpdate?: () => void;
}
@@ -108,6 +109,7 @@ export function DocumentMetadataEditor({
isOpen,
onClose,
document,
+ canManageRegulations = false,
onUpdate,
}: DocumentMetadataEditorProps) {
const [title, setTitle] = useState(document.title || '');
@@ -556,25 +558,27 @@ export function DocumentMetadataEditor({
Выбрано: {selectedTags.length}
-
-
setIsRegulation(event.target.checked)}
- disabled={isSaving}
- className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)] disabled:opacity-50"
- />
-
-
-
- Документ будет отображаться в отдельном разделе регламентов.
-
+ {canManageRegulations ? (
+
+
setIsRegulation(event.target.checked)}
+ disabled={isSaving}
+ className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)] disabled:opacity-50"
+ />
+
+
+
+ Документ будет отображаться в отдельном разделе регламентов.
+
+
-
+ ) : null}
diff --git a/frontend/src/components/documents/DocumentRelated.tsx b/frontend/src/components/documents/DocumentRelated.tsx
index 5e4a0fd0..b14f7248 100644
--- a/frontend/src/components/documents/DocumentRelated.tsx
+++ b/frontend/src/components/documents/DocumentRelated.tsx
@@ -14,10 +14,11 @@ interface RelatedDocument {
interface DocumentRelatedProps {
documentId: number;
+ canEdit?: boolean;
onNavigate?: (documentId: number) => void;
}
-export function DocumentRelated({ documentId, onNavigate }: DocumentRelatedProps) {
+export function DocumentRelated({ documentId, canEdit = true, onNavigate }: DocumentRelatedProps) {
const [relatedDocs, setRelatedDocs] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddDialog, setShowAddDialog] = useState(false);
@@ -121,13 +122,15 @@ export function DocumentRelated({ documentId, onNavigate }: DocumentRelatedProps
Связанные документы ({relatedDocs.length})
-
+ {canEdit ? (
+
+ ) : null}
{/* Add Dialog */}
@@ -230,13 +233,15 @@ export function DocumentRelated({ documentId, onNavigate }: DocumentRelatedProps
-
+ {canEdit ? (
+
+ ) : null}
))}
diff --git a/frontend/src/components/feed/FeedRegulationCard.tsx b/frontend/src/components/feed/FeedRegulationCard.tsx
index eda41a53..65a28f47 100644
--- a/frontend/src/components/feed/FeedRegulationCard.tsx
+++ b/frontend/src/components/feed/FeedRegulationCard.tsx
@@ -31,9 +31,9 @@ type FeedRegulationCardProps = {
isAcknowledging?: boolean;
onAcknowledge: (document: Document) => void;
onDelete: (document: Document) => void;
- onEdit: (document: Document) => void;
+ onEdit?: (document: Document) => void;
onLinkTask: (document: Document) => void;
- onMove: (document: Document) => void;
+ onMove?: (document: Document) => void;
onOpen: (document: Document) => void;
onOpenAcknowledgements: (document: Document) => void;
onOpenComments: (document: Document) => void;
@@ -169,28 +169,32 @@ export function FeedRegulationCard({
Детали
-
-
+ {onEdit ? (
+
+ ) : null}
+ {onMove ? (
+
+ ) : null}
{hasPreview ? (