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 ? ( + {(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 ? (
-

{item.name}

- {item.description ? ( + + {!detailsOpen ? ( +
+ {item.quantity} {itemUnitLabel} · + {orderedQuantityMarkerClass ? ( +
+ ) : null} + {detailsOpen && item.description ? (

{item.description}

) : null} - {item.supplier_info ? ( + {detailsOpen && item.supplier_info ? (

Поставщик: {item.supplier_info}

) : null}
- {statusLabel} + {statusLabel} +

Исполнение

@@ -409,11 +487,8 @@ function ProcurementItemCard({

Получено

-

- {receivedQuantityMarkerClass ? ( -

+ {receivedQuantity}/{requestedQuantity} {itemUnitLabel}

@@ -550,6 +625,18 @@ function ProcurementItemCard({ ) : null} + {canConfirmOrdered ? ( + + ) : null} + {canCancelReceived ? (
) : null} + + + {orderModalOpen ? ( + setOrderModalOpen(false)} + title="Подтвердить заказ" + size="sm" + footer={ +
+ + +
+ } + > +
+
+

{item.name}

+

+ Требуется {requestedQuantity} {itemUnitLabel}. Уже заказано {orderedQuantity} {itemUnitLabel}. +

+
+ + +
+
+ ) : null} {receiptModalOpen ? ( remainingQuantity + Number(receiptQuantity) < 1 } className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50" > @@ -857,7 +1026,8 @@ function ProcurementItemCard({

{item.name}

- Получено {receivedQuantity}/{requestedQuantity} {itemUnitLabel}. Осталось {remainingQuantity} {itemUnitLabel}. + Заказано {orderedQuantity} {itemUnitLabel}. Получено {receivedQuantity} {itemUnitLabel}. + {remainingQuantity > 0 ? ` Осталось по текущему заказу: ${remainingQuantity} ${itemUnitLabel}.` : ""}