diff --git a/backend/api/v1/procurement/permissions.py b/backend/api/v1/procurement/permissions.py index 974c935c..83cd8bf0 100644 --- a/backend/api/v1/procurement/permissions.py +++ b/backend/api/v1/procurement/permissions.py @@ -153,6 +153,17 @@ class CanManageProcurementRequest(permissions.BasePermission): """Проверяет, является ли пользователь начальником отдела.""" return Department.objects.filter(id=dept_id, head_id=user.id).exists() + def _is_dept_member(self, user, dept_id: int) -> bool: + """Проверяет активную принадлежность пользователя к отделу.""" + return ( + EmployeeDepartment.objects.filter( + employee_id=user.id, + department_id=dept_id, + is_active=True, + ).exists() + or self._is_dept_head(user, dept_id) + ) + def has_permission(self, request, view) -> bool: """Проверка доступа на уровне запроса.""" user = request.user @@ -235,14 +246,29 @@ class CanManageProcurementRequest(permissions.BasePermission): if action == "delete_comment": return True - # Start_work (взять в работу) - любой авторизованный + # Start_work: общий пул может взять любой, адресную заявку - отдел. if action == "start_work": + if obj.processing_department_id: + return self._is_dept_member( + user, + obj.processing_department_id, + ) return True # Complete (завершить) - только исполнитель if action == "complete": return obj.executor == user + if action == "mark_all_received": + 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 == "destroy": # Автор может удалить свою заявку в DRAFT @@ -255,6 +281,12 @@ class CanManageProcurementRequest(permissions.BasePermission): # Изменение if action in ("update", "partial_update"): + if obj.processing_department_id and self._is_dept_member( + user, + obj.processing_department_id, + ): + return True + # Заявка должна быть редактируемой (DRAFT) if not obj.is_editable: return False diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index d38c145f..1ad264be 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -5,7 +5,10 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from procurement.constants import get_default_approval_step_name +from procurement.constants import ( + ProcurementStatus, + get_default_approval_step_name, +) from procurement.services import ProcurementApprovalResolver from procurement.models import ( Approval, @@ -26,6 +29,9 @@ class ProcurementItemSerializer(serializers.ModelSerializer): total_price = serializers.DecimalField( max_digits=12, decimal_places=2, read_only=True ) + execution_status_display = serializers.CharField( + source="get_execution_status_display", read_only=True + ) class Meta: model = ProcurementItem @@ -39,10 +45,21 @@ class ProcurementItemSerializer(serializers.ModelSerializer): "estimated_unit_price", "total_price", "supplier_info", + "links", + "expected_delivery_date", + "actual_unit_price", + "execution_status", + "execution_status_display", + "executor_comment", "equipment", "created_at", ] - read_only_fields = ["id", "created_at", "total_price"] + read_only_fields = [ + "id", + "created_at", + "total_price", + "execution_status_display", + ] class ProcurementItemCreateSerializer(serializers.ModelSerializer): @@ -57,6 +74,11 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer): "unit", "estimated_unit_price", "supplier_info", + "links", + "expected_delivery_date", + "actual_unit_price", + "execution_status", + "executor_comment", ] @@ -109,6 +131,9 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): department_name = serializers.CharField( source="department.name", read_only=True ) + processing_department_name = serializers.CharField( + source="processing_department.name", read_only=True, allow_null=True + ) requestor_name = serializers.CharField( source="requestor.get_full_name", read_only=True ) @@ -118,6 +143,9 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): urgency_display = serializers.CharField( source="get_urgency_display", read_only=True ) + fulfillment_status_display = serializers.CharField( + source="get_fulfillment_status_display", read_only=True + ) items_count = serializers.IntegerField(read_only=True) total_cost = serializers.DecimalField( max_digits=12, decimal_places=2, read_only=True @@ -141,6 +169,8 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): "title", "department", "department_name", + "processing_department", + "processing_department_name", "requestor", "requestor_name", "executor", @@ -149,6 +179,8 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): "status_display", "urgency", "urgency_display", + "fulfillment_status", + "fulfillment_status_display", "total_cost", "items_count", "comments_count", @@ -163,10 +195,13 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): "submitted_at", "started_at", "department_name", + "processing_department_name", "requestor_name", "executor_name", "status_display", "urgency_display", + "fulfillment_status", + "fulfillment_status_display", "items_count", "total_cost", "comments_count", @@ -191,6 +226,9 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): department_name = serializers.CharField( source="department.name", read_only=True ) + processing_department_name = serializers.CharField( + source="processing_department.name", read_only=True, allow_null=True + ) requestor_name = serializers.CharField( source="requestor.get_full_name", read_only=True ) @@ -206,6 +244,9 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): urgency_display = serializers.CharField( source="get_urgency_display", read_only=True ) + fulfillment_status_display = serializers.CharField( + source="get_fulfillment_status_display", read_only=True + ) total_cost = serializers.DecimalField( max_digits=12, decimal_places=2, read_only=True ) @@ -232,6 +273,8 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): "description", "department", "department_name", + "processing_department", + "processing_department_name", "requestor", "requestor_name", "requestor_email", @@ -241,6 +284,8 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): "status_display", "urgency", "urgency_display", + "fulfillment_status", + "fulfillment_status_display", "total_cost", "actual_cost", "items", @@ -265,6 +310,9 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): "is_editable", "total_cost", "executor_name", + "fulfillment_status", + "processing_department_name", + "fulfillment_status_display", "comments_count", "can_current_user_approve", ] @@ -282,6 +330,7 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer): "title", "description", "department", + "processing_department", "requestor", "urgency", "items", @@ -295,7 +344,9 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer): items_data = validated_data.pop("items", []) request = self.context["request"] - # Создаем заявку + if validated_data.get("processing_department"): + validated_data["status"] = ProcurementStatus.WAITING + procurement_request = ProcurementRequest.objects.create( requestor=request.user, **validated_data ) diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 1aaabb39..b48b84b8 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -8,11 +8,12 @@ from rest_framework import filters, permissions, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response -from employees.models import Department +from employees.models import Department, EmployeeDepartment from procurement.constants import ( ApprovalStatus, ProcurementStatus, EquipmentStatus, + ProcurementItemExecutionStatus, ) from procurement.models import ( Approval, @@ -55,14 +56,24 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): """ViewSet для заявок на закупку.""" queryset = ProcurementRequest.objects.select_related( - "department", "requestor", "executor" + "department", + "processing_department", + "requestor", + "executor", ).prefetch_related("items", "approvals__approver") filter_backends = [ DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter, ] - filterset_fields = ["status", "urgency", "department", "executor"] + filterset_fields = [ + "status", + "urgency", + "department", + "processing_department", + "executor", + "fulfillment_status", + ] search_fields = ["title", "description"] ordering_fields = ["created_at", "status"] ordering = ["-created_at"] @@ -79,6 +90,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): "start_work", "complete", "cancel", + "mark_all_received", ]: return ProcurementRequestDetailSerializer return ProcurementRequestListSerializer @@ -138,8 +150,29 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): # Заявки, которые я взял в работу (где я исполнитель) queryset = queryset.filter(executor=user) elif scope == "available": - # Доступные заявки - со статусом "Согласовано" - queryset = queryset.filter(status="approved") + queryset = queryset.filter( + status__in=[ + ProcurementStatus.WAITING, + ProcurementStatus.APPROVED, + ], + executor__isnull=True, + ) + if not ( + user.is_superuser + or user.is_staff + or user.has_perm("procurement.change_procurementrequest") + or user.has_perm("procurement.execute_procurement") + ): + queryset = queryset.filter( + Q( + status=ProcurementStatus.WAITING, + processing_department__in=user.departments.all(), + ) + | Q( + status=ProcurementStatus.APPROVED, + processing_department__isnull=True, + ) + ) elif scope == "all": # Все заявки - применяем стандартную логику прав pass @@ -160,7 +193,12 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): queryset = queryset.filter( Q(requestor=user) | Q(department__in=user.departments.all()) + | Q(processing_department__in=user.departments.all()) | Q(approvals__approver=user) + | Q( + status=ProcurementStatus.APPROVED, + processing_department__isnull=True, + ) ) # Фильтрация по периоду @@ -282,6 +320,17 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): procurement_request = self.get_object() + if procurement_request.processing_department_id: + return Response( + { + "error": ( + "Заявка уже направлена в отдел-исполнитель " + "и не требует согласования" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Проверки if procurement_request.requestor != request.user: return Response( @@ -509,13 +558,34 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): @action(detail=False, methods=["get"]) def available(self, request): - """Получить одобренные заявки, доступные для взятия в работу.""" + """Получить заявки, доступные для взятия в работу.""" queryset = self.filter_queryset( self.get_queryset().filter( - status=ProcurementStatus.APPROVED, executor__isnull=True + status__in=[ + ProcurementStatus.WAITING, + ProcurementStatus.APPROVED, + ], + executor__isnull=True, ) ) + if not ( + request.user.is_superuser + or request.user.is_staff + or request.user.has_perm("procurement.change_procurementrequest") + or request.user.has_perm("procurement.execute_procurement") + ): + queryset = queryset.filter( + Q( + status=ProcurementStatus.WAITING, + processing_department__in=request.user.departments.all(), + ) + | Q( + status=ProcurementStatus.APPROVED, + processing_department__isnull=True, + ) + ) + page = self.paginate_queryset(queryset) if page is not None: serializer = ProcurementRequestListSerializer(page, many=True) @@ -528,15 +598,24 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): def start_work(self, request, pk=None): """Начать работу над заявкой (перевод в статус IN_PROGRESS). - Любой авторизованный пользователь может взять заявку в работу. - TODO: Позже добавить проверку роли "Закупщик". + Общий пул может взять любой авторизованный пользователь. + Адресную заявку может взять сотрудник отдела-исполнителя. """ procurement_request = self.get_object() + self.check_object_permissions(request, procurement_request) # Проверяем текущий статус - if procurement_request.status != ProcurementStatus.APPROVED: + if procurement_request.status not in [ + ProcurementStatus.APPROVED, + ProcurementStatus.WAITING, + ]: return Response( - {"error": "Только одобренные заявки можно взять в работу"}, + { + "error": ( + "Только одобренные или ожидающие заявки " + "можно взять в работу" + ) + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -592,6 +671,35 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(procurement_request) return Response(serializer.data) + @action(detail=True, methods=["post"]) + def mark_all_received(self, request, pk=None): + """Отметить все позиции заявки как полученные.""" + procurement_request = self.get_object() + self.check_object_permissions(request, procurement_request) + + if procurement_request.status not in [ + ProcurementStatus.WAITING, + ProcurementStatus.IN_PROGRESS, + ]: + return Response( + { + "error": ( + "Отмечать позиции можно только в ожидающей " + "или взятой в работу заявке" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + procurement_request.items.update( + execution_status=ProcurementItemExecutionStatus.RECEIVED, + ) + procurement_request.recalculate_fulfillment_status(save=True) + procurement_request.refresh_from_db() + + serializer = self.get_serializer(procurement_request) + return Response(serializer.data) + @action(detail=True, methods=["post"]) def cancel(self, request, pk=None): """Отменить заявку.""" @@ -631,12 +739,41 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): class ProcurementItemViewSet(viewsets.ModelViewSet): """ViewSet для позиций заявок.""" - queryset = ProcurementItem.objects.select_related("request") + queryset = ProcurementItem.objects.select_related( + "request", + "request__department", + "request__processing_department", + ) serializer_class = ProcurementItemSerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ["request"] search_fields = ["name", "description"] + def _is_processing_department_member(self, user, procurement_request): + if not procurement_request.processing_department_id: + return False + return ( + EmployeeDepartment.objects.filter( + employee_id=user.id, + department_id=procurement_request.processing_department_id, + is_active=True, + ).exists() + or procurement_request.processing_department.head_id == user.id + ) + + def _can_process_request_items(self, user, procurement_request): + if user.is_superuser or user.is_staff: + return True + if ( + user.has_perm("procurement.change_procurementrequest") + or user.has_perm("procurement.execute_procurement") + ): + return True + return self._is_processing_department_member( + user, + procurement_request, + ) + def get_permissions(self): """Только создатель заявки может редактировать позиции.""" if self.action in [ @@ -666,31 +803,30 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): procurement_request = serializer.validated_data.get("request") user = self.request.user - # Проверяем, что заявка в DRAFT - if not procurement_request.is_editable: + if procurement_request.status in [ + ProcurementStatus.CANCELLED, + ProcurementStatus.REJECTED, + ]: raise PermissionDenied( "Нельзя добавлять позиции в заявку со статусом '{}'".format( procurement_request.get_status_display() ) ) - # Админы могут редактировать - if user.is_superuser or user.is_staff: - serializer.save() - return - - # Модельные права - if user.has_perm("procurement.change_procurementrequest"): + if self._can_process_request_items(user, procurement_request): serializer.save() return # Автор заявки - if procurement_request.requestor == user: + if procurement_request.requestor == user and procurement_request.is_editable: serializer.save() return # Начальник отдела - if procurement_request.department.head == user: + if ( + procurement_request.department.head == user + and procurement_request.is_editable + ): serializer.save() return @@ -707,28 +843,28 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): user = self.request.user procurement_request = item.request - # Проверяем, что заявка в DRAFT - if not procurement_request.is_editable: + if procurement_request.status in [ + ProcurementStatus.CANCELLED, + ProcurementStatus.REJECTED, + ]: raise PermissionDenied( "Нельзя изменять позиции в заявке со статусом '{}'".format( procurement_request.get_status_display() ) ) - # Админы могут редактировать - if user.is_superuser or user.is_staff: - return True - - # Модельные права - if user.has_perm("procurement.change_procurementrequest"): + if self._can_process_request_items(user, procurement_request): return True # Автор заявки - if procurement_request.requestor == user: + if procurement_request.requestor == user and procurement_request.is_editable: return True # Начальник отдела - if procurement_request.department.head == user: + if ( + procurement_request.department.head == user + and procurement_request.is_editable + ): return True # Нет прав diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index fe0dce8b..85ecf912 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -459,6 +459,7 @@ SPECTACULAR_SETTINGS = { "ENUM_NAME_OVERRIDES": { "ProcurementStatusEnum": [ ("draft", "Черновик"), + ("waiting", "Ожидает"), ("pending", "На согласовании"), ("approved", "Одобрено"), ("rejected", "Отклонено"), diff --git a/backend/procurement/admin.py b/backend/procurement/admin.py index ff937d5b..374b940a 100644 --- a/backend/procurement/admin.py +++ b/backend/procurement/admin.py @@ -26,6 +26,9 @@ class ProcurementItemInline(admin.TabularInline): "quantity", "unit", "estimated_unit_price", + "actual_unit_price", + "execution_status", + "expected_delivery_date", "supplier_info", ] @@ -35,7 +38,14 @@ class ApprovalInline(admin.TabularInline): model = Approval extra = 0 - fields = ["approver", "role", "status", "comment", "created_at"] + fields = [ + "approver", + "priority", + "step_name", + "status", + "comment", + "created_at", + ] readonly_fields = ["created_at"] can_delete = False @@ -48,13 +58,22 @@ class ProcurementRequestAdmin(admin.ModelAdmin): "id", "title", "department", + "processing_department", "requestor", "status_badge", + "fulfillment_status", "urgency_badge", "get_total_cost", "created_at", ] - list_filter = ["status", "urgency", "department", "created_at"] + list_filter = [ + "status", + "fulfillment_status", + "urgency", + "department", + "processing_department", + "created_at", + ] search_fields = ["title", "description", "requestor__username"] readonly_fields = [ "created_at", @@ -69,13 +88,22 @@ class ProcurementRequestAdmin(admin.ModelAdmin): fieldsets = ( ( "Основная информация", - {"fields": ("title", "description", "department", "requestor")}, + { + "fields": ( + "title", + "description", + "department", + "processing_department", + "requestor", + ) + }, ), ( "Параметры", { "fields": ( "status", + "fulfillment_status", "urgency", "get_total_cost", "actual_cost", @@ -100,6 +128,7 @@ class ProcurementRequestAdmin(admin.ModelAdmin): """Цветной бадж статуса.""" colors = { "draft": "#6c757d", + "waiting": "#ffc107", "pending": "#0dcaf0", "approved": "#198754", "rejected": "#dc3545", @@ -153,9 +182,12 @@ class ProcurementItemAdmin(admin.ModelAdmin): "quantity", "unit", "estimated_unit_price", + "actual_unit_price", + "execution_status", + "expected_delivery_date", "total_price", ] - list_filter = ["unit", "created_at"] + list_filter = ["unit", "execution_status", "created_at"] search_fields = ["name", "description", "request__title"] readonly_fields = ["created_at", "total_price"] diff --git a/backend/procurement/constants.py b/backend/procurement/constants.py index 899a83f5..f4aef4b9 100644 --- a/backend/procurement/constants.py +++ b/backend/procurement/constants.py @@ -9,6 +9,7 @@ from django.db import models class ProcurementStatus(models.TextChoices): """Статусы заявки на закупку.""" DRAFT = 'draft', 'Черновик' + WAITING = 'waiting', 'Ожидает' PENDING = 'pending', 'На согласовании' APPROVED = 'approved', 'Одобрено' REJECTED = 'rejected', 'Отклонено' @@ -25,6 +26,26 @@ class UrgencyLevel(models.TextChoices): CRITICAL = 'critical', 'Критическая' +class ProcurementFulfillmentStatus(models.TextChoices): + """Рабочий статус исполнения заявки по позициям.""" + PENDING = 'pending', 'Ожидает обработки' + PARTIALLY_ORDERED = 'partially_ordered', 'Заказано частично' + ORDERED = 'ordered', 'Заказано' + PARTIALLY_RECEIVED = 'partially_received', 'Прибыл частично' + COMPLETED = 'completed', 'Выполнено' + ISSUES = 'issues', 'Выполнено с проблемами' + + +class ProcurementItemExecutionStatus(models.TextChoices): + """Рабочий статус отдельной позиции заявки.""" + PENDING = 'pending', 'Не выполнено' + ORDERED = 'ordered', 'Заказано' + REJECTED = 'rejected', 'Отказано' + RECEIVED = 'received', 'Получено' + COMPLETED_WITH_ISSUE = 'completed_with_issue', 'Выполнено с замечанием' + EDITED = 'edited', 'Отредактировано' + + def get_default_approval_step_name(priority: int, resolver_type: str | None = None) -> str: if resolver_type == 'department_head': diff --git a/backend/procurement/migrations/0010_processing_department_and_execution_statuses.py b/backend/procurement/migrations/0010_processing_department_and_execution_statuses.py new file mode 100644 index 00000000..02a1d08a --- /dev/null +++ b/backend/procurement/migrations/0010_processing_department_and_execution_statuses.py @@ -0,0 +1,124 @@ +# Generated by Codex on 2026-05-22 + +import django.core.validators +from decimal import Decimal +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("employees", "0052_employee_attendance_aliases"), + ("procurement", "0009_approvalroute_min_amount"), + ] + + operations = [ + migrations.AlterField( + model_name="procurementrequest", + name="status", + field=models.CharField( + choices=[ + ("draft", "Черновик"), + ("waiting", "Ожидает"), + ("pending", "На согласовании"), + ("approved", "Одобрено"), + ("rejected", "Отклонено"), + ("in_progress", "В работе"), + ("completed", "Завершено"), + ("cancelled", "Отменено"), + ], + default="draft", + max_length=20, + verbose_name="Статус", + ), + ), + migrations.AddField( + model_name="procurementrequest", + name="fulfillment_status", + field=models.CharField( + choices=[ + ("pending", "Ожидает обработки"), + ("partially_ordered", "Заказано частично"), + ("ordered", "Заказано"), + ("partially_received", "Прибыл частично"), + ("completed", "Выполнено"), + ("issues", "Выполнено с проблемами"), + ], + default="pending", + max_length=30, + verbose_name="Статус исполнения", + ), + ), + migrations.AddField( + model_name="procurementrequest", + name="processing_department", + field=models.ForeignKey( + blank=True, + help_text="Отдел, который должен обработать заявку", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="processing_procurement_requests", + to="employees.department", + verbose_name="Отдел-исполнитель", + ), + ), + migrations.AddField( + model_name="procurementitem", + name="actual_unit_price", + field=models.DecimalField( + blank=True, + decimal_places=2, + max_digits=12, + null=True, + validators=[ + django.core.validators.MinValueValidator(Decimal("0.01")) + ], + verbose_name="Фактическая цена за единицу", + ), + ), + migrations.AddField( + model_name="procurementitem", + name="execution_status", + field=models.CharField( + choices=[ + ("pending", "Не выполнено"), + ("ordered", "Заказано"), + ("rejected", "Отказано"), + ("received", "Получено"), + ("completed_with_issue", "Выполнено с замечанием"), + ("edited", "Отредактировано"), + ], + default="pending", + max_length=30, + verbose_name="Статус выполнения", + ), + ), + migrations.AddField( + model_name="procurementitem", + name="expected_delivery_date", + field=models.DateField( + blank=True, + null=True, + verbose_name="Ожидаемая дата поступления", + ), + ), + migrations.AddField( + model_name="procurementitem", + name="executor_comment", + field=models.TextField( + blank=True, + verbose_name="Комментарий исполнителя", + ), + ), + migrations.AddField( + model_name="procurementitem", + name="links", + field=models.JSONField( + blank=True, + default=list, + help_text="Список ссылок на позицию", + verbose_name="Ссылки", + ), + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index c6b34f7d..6cc1cc26 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -27,6 +27,8 @@ from .constants import ( ApprovalStatus, EquipmentStatus, MaintenanceType, + ProcurementFulfillmentStatus, + ProcurementItemExecutionStatus, ProcurementStatus, UrgencyLevel, get_default_approval_step_name, @@ -55,6 +57,15 @@ class ProcurementRequest(models.Model): related_name="procurement_requests", verbose_name="Отдел", ) + processing_department = models.ForeignKey( + Department, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="processing_procurement_requests", + verbose_name="Отдел-исполнитель", + help_text="Отдел, который должен обработать заявку", + ) requestor = models.ForeignKey( User, on_delete=models.PROTECT, @@ -82,6 +93,12 @@ class ProcurementRequest(models.Model): choices=UrgencyLevel.choices, default=UrgencyLevel.MEDIUM, ) + fulfillment_status = models.CharField( + "Статус исполнения", + max_length=30, + choices=ProcurementFulfillmentStatus.choices, + default=ProcurementFulfillmentStatus.PENDING, + ) actual_cost = models.DecimalField( "Фактическая стоимость", max_digits=12, @@ -144,7 +161,67 @@ class ProcurementRequest(models.Model): @property def is_editable(self): """Можно ли редактировать заявку.""" - return self.status == ProcurementStatus.DRAFT + return self.status in [ + ProcurementStatus.DRAFT, + ProcurementStatus.WAITING, + ] and self.executor_id is None + + def recalculate_fulfillment_status(self, save=True): + """Пересчитать рабочий статус заявки по статусам позиций.""" + item_statuses = list( + self.items.values_list("execution_status", flat=True) + ) + if not item_statuses: + new_status = ProcurementFulfillmentStatus.PENDING + elif any( + item_status + in { + ProcurementItemExecutionStatus.REJECTED, + ProcurementItemExecutionStatus.COMPLETED_WITH_ISSUE, + ProcurementItemExecutionStatus.EDITED, + } + for item_status in item_statuses + ): + new_status = ProcurementFulfillmentStatus.ISSUES + elif all( + item_status == ProcurementItemExecutionStatus.RECEIVED + for item_status in item_statuses + ): + new_status = ProcurementFulfillmentStatus.COMPLETED + elif any( + item_status == ProcurementItemExecutionStatus.RECEIVED + for item_status in item_statuses + ): + new_status = ProcurementFulfillmentStatus.PARTIALLY_RECEIVED + elif all( + item_status == ProcurementItemExecutionStatus.ORDERED + for item_status in item_statuses + ): + new_status = ProcurementFulfillmentStatus.ORDERED + elif any( + item_status == ProcurementItemExecutionStatus.ORDERED + for item_status in item_statuses + ): + new_status = ProcurementFulfillmentStatus.PARTIALLY_ORDERED + else: + new_status = ProcurementFulfillmentStatus.PENDING + + if self.fulfillment_status == new_status: + return new_status + + self.fulfillment_status = new_status + if save: + update_fields = ["fulfillment_status", "updated_at"] + if ( + new_status == ProcurementFulfillmentStatus.COMPLETED + and self.status + in [ProcurementStatus.WAITING, ProcurementStatus.IN_PROGRESS] + ): + self.status = ProcurementStatus.COMPLETED + self.completed_at = timezone.now() + update_fields.extend(["status", "completed_at"]) + self.save(update_fields=update_fields) + return new_status class ProcurementItem(models.Model): @@ -184,6 +261,35 @@ class ProcurementItem(models.Model): blank=True, help_text="Ссылки на товар, контакты поставщика", ) + links = models.JSONField( + "Ссылки", + default=list, + blank=True, + help_text="Список ссылок на позицию", + ) + expected_delivery_date = models.DateField( + "Ожидаемая дата поступления", + null=True, + blank=True, + ) + actual_unit_price = models.DecimalField( + "Фактическая цена за единицу", + max_digits=12, + decimal_places=2, + null=True, + blank=True, + validators=[MinValueValidator(Decimal("0.01"))], + ) + execution_status = models.CharField( + "Статус выполнения", + max_length=30, + choices=ProcurementItemExecutionStatus.choices, + default=ProcurementItemExecutionStatus.PENDING, + ) + executor_comment = models.TextField( + "Комментарий исполнителя", + blank=True, + ) equipment = models.OneToOneField( "Equipment", on_delete=models.SET_NULL, @@ -208,6 +314,17 @@ class ProcurementItem(models.Model): """Общая стоимость позиции.""" return self.estimated_unit_price * self.quantity + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.request_id: + self.request.recalculate_fulfillment_status(save=True) + + def delete(self, *args, **kwargs): + request = self.request + result = super().delete(*args, **kwargs) + request.recalculate_fulfillment_status(save=True) + return result + class ApprovalRoute(models.Model): """Правило получения согласующего для этапа с заданным приоритетом.""" diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index fa478b07..50cd2ce8 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -13,6 +13,8 @@ from rest_framework.test import APIClient from employees.models import Department, Employee, EmployeeDepartment from procurement.constants import ( ApprovalStatus, + ProcurementFulfillmentStatus, + ProcurementItemExecutionStatus, ProcurementStatus, UrgencyLevel, ) @@ -332,6 +334,46 @@ class TestProcurementRequestCreate: status.HTTP_403_FORBIDDEN ] + def test_create_processing_department_request_skips_approvals( + self, api_client, user, department, db + ): + """Заявка в отдел-исполнитель сразу попадает в очередь.""" + supply_department = Department.objects.create( + name="Снабжение", + description="Отдел снабжения", + ) + + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + + response = api_client.post( + url, + { + 'title': 'Расходники для производства', + 'description': 'Нужны материалы для заказа', + 'department': department.id, + 'processing_department': supply_department.id, + 'urgency': UrgencyLevel.MEDIUM, + 'items': [ + { + 'name': 'Перчатки', + 'quantity': 10, + 'unit': 'упак', + 'estimated_unit_price': '500.00', + 'links': ['https://example.com/gloves'], + }, + ], + }, + format='json', + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['status'] == ProcurementStatus.WAITING + request_id = response.data['id'] + procurement_request = ProcurementRequest.objects.get(id=request_id) + assert procurement_request.processing_department == supply_department + assert procurement_request.approvals.count() == 0 + # ============================================================================== # ТЕСТЫ ДЕТАЛЬНОЙ ИНФОРМАЦИИ @@ -499,6 +541,190 @@ class TestProcurementRequestUpdate: assert response.status_code == status.HTTP_403_FORBIDDEN +# ============================================================================== +# ТЕСТЫ АДРЕСНЫХ ЗАЯВОК В ОТДЕЛ-ИСПОЛНИТЕЛЬ +# ============================================================================== + + +class TestProcessingDepartmentWorkflow: + """Workflow для заявок, направленных в отдел-исполнитель.""" + + @pytest.fixture + def supply_department(self, db): + return Department.objects.create( + name="Снабжение", + description="Отдел снабжения", + ) + + @pytest.fixture + def supply_user(self, db, supply_department): + employee = Employee.objects.create_user( + email="supply@example.com", + password="testpass123", + phone_number="+79998888888", + first_name="Снабжение", + last_name="Исполнитель", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + EmployeeDepartment.objects.create( + employee=employee, + department=supply_department, + is_active=True, + ) + return employee + + @pytest.fixture + def outsider(self, db): + return Employee.objects.create_user( + email="outsider@example.com", + password="testpass123", + phone_number="+79998888889", + first_name="Чужой", + last_name="Сотрудник", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + + @pytest.fixture + def processing_request( + self, department, user, supply_department + ): + return ProcurementRequest.objects.create( + title="Заявка на расходники", + description="Нужны расходные материалы", + department=department, + processing_department=supply_department, + requestor=user, + status=ProcurementStatus.WAITING, + urgency=UrgencyLevel.MEDIUM, + ) + + def test_available_shows_processing_department_requests( + self, api_client, supply_user, processing_request, procurement_item_factory + ): + procurement_item_factory(request=processing_request) + api_client.force_authenticate(user=supply_user) + url = reverse('api:v1:procurement:procurementrequest-available') + + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + ids = [item['id'] for item in response.data['results']] + assert processing_request.id in ids + + def test_outsider_cannot_start_processing_department_request( + self, api_client, outsider, processing_request, procurement_item_factory + ): + procurement_item_factory(request=processing_request) + api_client.force_authenticate(user=outsider) + url = reverse( + 'api:v1:procurement:procurementrequest-start-work', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + processing_request.refresh_from_db() + assert processing_request.executor is None + assert processing_request.status == ProcurementStatus.WAITING + + def test_processing_department_user_can_start_work_and_blocks_author_edit( + self, api_client, user, supply_user, processing_request, + procurement_item_factory + ): + procurement_item_factory(request=processing_request) + 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) + start_response = api_client.post(start_url) + assert start_response.status_code == status.HTTP_200_OK + + processing_request.refresh_from_db() + assert processing_request.executor == supply_user + assert processing_request.status == ProcurementStatus.IN_PROGRESS + + api_client.force_authenticate(user=user) + edit_response = api_client.patch( + detail_url, + {'title': 'Нельзя менять'}, + format='json', + ) + assert edit_response.status_code == status.HTTP_403_FORBIDDEN + + def test_item_status_recalculates_request_fulfillment_status( + self, api_client, supply_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() + + api_client.force_authenticate(user=supply_user) + item_url = reverse( + 'api:v1:procurement:procurementitem-detail', + kwargs={'pk': item.id}, + ) + + response = api_client.patch( + item_url, + {'execution_status': ProcurementItemExecutionStatus.RECEIVED}, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + processing_request.refresh_from_db() + assert ( + processing_request.fulfillment_status + == ProcurementFulfillmentStatus.COMPLETED + ) + assert processing_request.status == ProcurementStatus.COMPLETED + + def test_mark_all_received_completes_processing_request( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + item_1 = procurement_item_factory(request=processing_request) + item_2 = procurement_item_factory( + request=processing_request, + name="Лампы", + ) + 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:procurementrequest-mark-all-received', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_200_OK + processing_request.refresh_from_db() + item_1.refresh_from_db() + item_2.refresh_from_db() + assert item_1.execution_status == ProcurementItemExecutionStatus.RECEIVED + assert item_2.execution_status == ProcurementItemExecutionStatus.RECEIVED + assert ( + processing_request.fulfillment_status + == ProcurementFulfillmentStatus.COMPLETED + ) + assert processing_request.status == ProcurementStatus.COMPLETED + + # ============================================================================== # ТЕСТЫ WORKFLOW ДЕЙСТВИЙ # ==============================================================================