From da986c0e2963245dc5eeb197484f13cbb2074277 Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 26 May 2026 17:02:30 +0300 Subject: [PATCH] procurement: refine fulfillment workflow --- backend/api/v1/procurement/serializers.py | 242 +++++++++++++- backend/api/v1/procurement/views.py | 103 +++++- backend/procurement/admin.py | 4 + backend/procurement/constants.py | 1 + ...tem_quantities_defective_optional_price.py | 70 ++++ backend/procurement/models.py | 132 +++++++- backend/procurement/notifications/__init__.py | 2 + backend/procurement/notifications/handlers.py | 30 ++ .../procurement/test_procurement_requests.py | 301 +++++++++++++++++- frontend/src/app/procurement/page.tsx | 76 ++++- .../ProcurementRequestDetailContent.tsx | 195 +++++++++++- frontend/src/hooks/useProcurementPage.ts | 62 +++- frontend/src/lib/api/procurement.ts | 2 + frontend/src/lib/procurementLinks.ts | 3 +- frontend/src/types/api.ts | 16 +- 15 files changed, 1187 insertions(+), 52 deletions(-) create mode 100644 backend/procurement/migrations/0011_item_quantities_defective_optional_price.py diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 59bd776e..93ff4a08 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -5,7 +5,9 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from communications import comments_helpers from procurement.constants import ( + ProcurementItemExecutionStatus, ProcurementStatus, get_default_approval_step_name, ) @@ -23,9 +25,137 @@ from procurement.models import ( from ..employees.serializers import EmployeeBriefSerializer +PROBLEM_ITEM_STATUSES = { + ProcurementItemExecutionStatus.REJECTED, + ProcurementItemExecutionStatus.COMPLETED_WITH_ISSUE, + ProcurementItemExecutionStatus.EDITED, + ProcurementItemExecutionStatus.DEFECTIVE, +} + + +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 _request_items(obj): + return list(obj.items.all()) + + +def _effective_ordered_quantity(item): + if item.ordered_quantity is not None: + return item.ordered_quantity + if item.execution_status in { + ProcurementItemExecutionStatus.ORDERED, + ProcurementItemExecutionStatus.RECEIVED, + }: + return item.quantity + return 0 + + +def _effective_received_quantity(item): + if item.received_quantity is not None: + return item.received_quantity + if item.execution_status == ProcurementItemExecutionStatus.RECEIVED: + return item.quantity + return 0 + + +class ProcurementRequestSummaryMixin(serializers.Serializer): + next_expected_delivery_date = serializers.SerializerMethodField() + items_total_count = serializers.SerializerMethodField() + items_received_count = serializers.SerializerMethodField() + items_problem_count = serializers.SerializerMethodField() + items_pending_count = serializers.SerializerMethodField() + total_requested_quantity = serializers.SerializerMethodField() + total_ordered_quantity = serializers.SerializerMethodField() + total_received_quantity = serializers.SerializerMethodField() + + @extend_schema_field(serializers.DateField(allow_null=True)) + def get_next_expected_delivery_date(self, obj): + dated_items = [ + item for item in _request_items(obj) + if item.expected_delivery_date + ] + if not dated_items: + return None + + pending_dates = [ + item.expected_delivery_date for item in dated_items + if item.execution_status != ProcurementItemExecutionStatus.RECEIVED + ] + if pending_dates: + return min(pending_dates).isoformat() + return max(item.expected_delivery_date for item in dated_items).isoformat() + + @extend_schema_field(serializers.IntegerField()) + def get_items_total_count(self, obj): + return len(_request_items(obj)) + + @extend_schema_field(serializers.IntegerField()) + def get_items_received_count(self, obj): + return sum( + 1 for item in _request_items(obj) + if item.execution_status == ProcurementItemExecutionStatus.RECEIVED + ) + + @extend_schema_field(serializers.IntegerField()) + def get_items_problem_count(self, obj): + return sum( + 1 for item in _request_items(obj) + if item.execution_status in PROBLEM_ITEM_STATUSES + ) + + @extend_schema_field(serializers.IntegerField()) + def get_items_pending_count(self, obj): + return sum( + 1 for item in _request_items(obj) + if item.execution_status == ProcurementItemExecutionStatus.PENDING + ) + + @extend_schema_field(serializers.IntegerField()) + def get_total_requested_quantity(self, obj): + return sum(item.quantity for item in _request_items(obj)) + + @extend_schema_field(serializers.IntegerField()) + def get_total_ordered_quantity(self, obj): + return sum(_effective_ordered_quantity(item) for item in _request_items(obj)) + + @extend_schema_field(serializers.IntegerField()) + def get_total_received_quantity(self, obj): + return sum(_effective_received_quantity(item) for item in _request_items(obj)) + + class ProcurementItemSerializer(serializers.ModelSerializer): """Сериализатор для позиций заявки.""" + estimated_unit_price = serializers.DecimalField( + max_digits=12, + decimal_places=2, + required=False, + allow_null=True, + ) + ordered_quantity = serializers.IntegerField( + required=False, + allow_null=True, + min_value=0, + ) + received_quantity = serializers.IntegerField( + required=False, + allow_null=True, + min_value=0, + ) + initial_comment = serializers.CharField( + write_only=True, + required=False, + allow_blank=True, + ) total_price = serializers.DecimalField( max_digits=12, decimal_places=2, read_only=True ) @@ -55,6 +185,8 @@ class ProcurementItemSerializer(serializers.ModelSerializer): "unit", "estimated_unit_price", "total_price", + "ordered_quantity", + "received_quantity", "supplier_info", "links", "expected_delivery_date", @@ -62,6 +194,7 @@ class ProcurementItemSerializer(serializers.ModelSerializer): "execution_status", "execution_status_display", "executor_comment", + "initial_comment", "comments_count", "equipment", "created_at", @@ -74,10 +207,48 @@ class ProcurementItemSerializer(serializers.ModelSerializer): "comments_count", ] + def validate(self, attrs): + return _validate_item_quantities(attrs, self.instance) + + def create(self, validated_data): + initial_comment = validated_data.pop("initial_comment", "").strip() + item = super().create(validated_data) + request = self.context.get("request") + author = getattr(request, "user", None) + if initial_comment and author and author.is_authenticated: + comments_helpers.create_comment( + obj=item, + author=author, + content=initial_comment, + ) + return item + class ProcurementItemCreateSerializer(serializers.ModelSerializer): """Сериализатор для создания позиций (без request).""" + estimated_unit_price = serializers.DecimalField( + max_digits=12, + decimal_places=2, + required=False, + allow_null=True, + ) + ordered_quantity = serializers.IntegerField( + required=False, + allow_null=True, + min_value=0, + ) + received_quantity = serializers.IntegerField( + required=False, + allow_null=True, + min_value=0, + ) + initial_comment = serializers.CharField( + write_only=True, + required=False, + allow_blank=True, + ) + class Meta: model = ProcurementItem fields = [ @@ -86,14 +257,20 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer): "quantity", "unit", "estimated_unit_price", + "ordered_quantity", + "received_quantity", "supplier_info", "links", "expected_delivery_date", "actual_unit_price", "execution_status", "executor_comment", + "initial_comment", ] + def validate(self, attrs): + return _validate_item_quantities(attrs, self.instance) + class ApprovalSerializer(serializers.ModelSerializer): """Сериализатор для согласований.""" @@ -138,7 +315,10 @@ class ApprovalSerializer(serializers.ModelSerializer): ] -class ProcurementRequestListSerializer(serializers.ModelSerializer): +class ProcurementRequestListSerializer( + ProcurementRequestSummaryMixin, + serializers.ModelSerializer, +): """Сериализатор для списка заявок (краткий).""" department_name = serializers.CharField( @@ -196,6 +376,14 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): "fulfillment_status_display", "total_cost", "items_count", + "next_expected_delivery_date", + "items_total_count", + "items_received_count", + "items_problem_count", + "items_pending_count", + "total_requested_quantity", + "total_ordered_quantity", + "total_received_quantity", "comments_count", "can_current_user_approve", "created_at", @@ -217,6 +405,14 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): "fulfillment_status_display", "items_count", "total_cost", + "next_expected_delivery_date", + "items_total_count", + "items_received_count", + "items_problem_count", + "items_pending_count", + "total_requested_quantity", + "total_ordered_quantity", + "total_received_quantity", "comments_count", "can_current_user_approve", ] @@ -233,7 +429,10 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): return super().update(instance, validated_data) -class ProcurementRequestDetailSerializer(serializers.ModelSerializer): +class ProcurementRequestDetailSerializer( + ProcurementRequestSummaryMixin, + serializers.ModelSerializer, +): """Сериализатор для детальной информации о заявке.""" department_name = serializers.CharField( @@ -301,6 +500,14 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): "fulfillment_status_display", "total_cost", "actual_cost", + "next_expected_delivery_date", + "items_total_count", + "items_received_count", + "items_problem_count", + "items_pending_count", + "total_requested_quantity", + "total_ordered_quantity", + "total_received_quantity", "items", "approvals", "required_approval_priorities", @@ -322,6 +529,14 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): "completed_at", "is_editable", "total_cost", + "next_expected_delivery_date", + "items_total_count", + "items_received_count", + "items_problem_count", + "items_pending_count", + "total_requested_quantity", + "total_ordered_quantity", + "total_received_quantity", "executor_name", "fulfillment_status", "processing_department_name", @@ -334,6 +549,10 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): class ProcurementRequestCreateSerializer(serializers.ModelSerializer): """Сериализатор для создания заявки с позициями.""" + description = serializers.CharField( + required=False, + allow_blank=True, + ) items = ProcurementItemCreateSerializer(many=True, required=False) class Meta: @@ -352,6 +571,16 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer): ] read_only_fields = ["id", "requestor", "status", "created_at"] + def validate(self, attrs): + if ( + not attrs.get("processing_department") + and not attrs.get("description", "").strip() + ): + raise serializers.ValidationError( + {"description": "Укажите описание и обоснование."} + ) + return attrs + def create(self, validated_data): """Создать заявку с позициями.""" items_data = validated_data.pop("items", []) @@ -372,9 +601,16 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer): # Создаем позиции (если они переданы) for item_data in items_data: - ProcurementItem.objects.create( + initial_comment = item_data.pop("initial_comment", "").strip() + item = ProcurementItem.objects.create( request=procurement_request, **item_data ) + if initial_comment: + comments_helpers.create_comment( + obj=item, + author=request.user, + content=initial_comment, + ) return procurement_request diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index cc3c5dbf..0c1bbc82 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -28,6 +28,7 @@ from procurement.models import ( ) from procurement.notifications.handlers import ( notify_item_comment, + notify_item_issue_reported, notify_item_updated, notify_request_comment, ) @@ -722,15 +723,25 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): ) ) - procurement_request.items.update( - execution_status=ProcurementItemExecutionStatus.RECEIVED, - ) for item in items_to_update: item.execution_status = ProcurementItemExecutionStatus.RECEIVED + if item.ordered_quantity is None: + item.ordered_quantity = item.quantity + if item.received_quantity is None: + item.received_quantity = item.quantity + ProcurementItem.objects.bulk_update( + items_to_update, + ["execution_status", "ordered_quantity", "received_quantity"], + ) + for item in items_to_update: notify_item_updated( item, actor=request.user, - changed_fields=["execution_status"], + changed_fields=[ + "execution_status", + "ordered_quantity", + "received_quantity", + ], ) procurement_request._notification_actor = request.user procurement_request.recalculate_fulfillment_status(save=True) @@ -784,6 +795,8 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "quantity", "unit", "estimated_unit_price", + "ordered_quantity", + "received_quantity", "links", "expected_delivery_date", "actual_unit_price", @@ -895,6 +908,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "link_equipment", "comments", "delete_comment", + "report_issue", ]: permission_classes = [permissions.IsAuthenticated] else: @@ -1009,6 +1023,73 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): changed_fields=changed_fields, ) + @action(detail=True, methods=["post"]) + def report_issue(self, request, pk=None): + """Отметить позицию как проблемную/бракованную.""" + item = self.get_object() + procurement_request = item.request + user = request.user + + if not ( + procurement_request.requestor_id == user.id + or self._can_process_request_items(user, procurement_request) + ): + return Response( + {"detail": "Нет прав отметить проблему по этой позиции"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if procurement_request.status in [ + ProcurementStatus.CANCELLED, + ProcurementStatus.REJECTED, + ]: + return Response( + {"detail": "Нельзя изменить позицию в отменённой или отклонённой заявке"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + item.execution_status = ProcurementItemExecutionStatus.DEFECTIVE + item.save(update_fields=["execution_status"]) + + if procurement_request.status == ProcurementStatus.COMPLETED: + procurement_request.status = ( + ProcurementStatus.IN_PROGRESS + if procurement_request.executor_id + else ProcurementStatus.WAITING + ) + procurement_request.completed_at = None + procurement_request._notification_actor = user + procurement_request.save( + update_fields=["status", "completed_at", "updated_at"] + ) + + text = request.data.get("text", "") + if isinstance(text, str): + text = text.strip() + else: + text = "" + + message = None + if text: + message = comments_helpers.create_comment( + obj=item, + author=user, + content=text, + ) + notify_item_comment(item, message, actor=user) + + notify_item_updated( + item, + actor=user, + changed_fields=["execution_status"], + ) + if procurement_request.requestor_id == user.id: + notify_item_issue_reported(item, actor=user) + + item.refresh_from_db() + serializer = self.get_serializer(item) + return Response(serializer.data) + def perform_destroy(self, instance): """Проверяем права при удалении позиции.""" self._check_item_edit_permission(instance) @@ -1194,6 +1275,18 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): # Создаем оборудование from django.utils import timezone + purchase_cost = item.actual_unit_price or item.estimated_unit_price + if purchase_cost is None: + return Response( + { + "error": ( + "Для создания оборудования укажите фактическую " + "или ориентировочную цену позиции" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + try: equipment = Equipment.objects.create( name=item.name, @@ -1206,7 +1299,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): location=location, purchase_date=timezone.now().date(), warranty_until=warranty_until, - purchase_cost=item.estimated_unit_price, + purchase_cost=purchase_cost, notes=item.description, # description -> notes ) except Exception as e: diff --git a/backend/procurement/admin.py b/backend/procurement/admin.py index 374b940a..16e6ce54 100644 --- a/backend/procurement/admin.py +++ b/backend/procurement/admin.py @@ -27,6 +27,8 @@ class ProcurementItemInline(admin.TabularInline): "unit", "estimated_unit_price", "actual_unit_price", + "ordered_quantity", + "received_quantity", "execution_status", "expected_delivery_date", "supplier_info", @@ -183,6 +185,8 @@ class ProcurementItemAdmin(admin.ModelAdmin): "unit", "estimated_unit_price", "actual_unit_price", + "ordered_quantity", + "received_quantity", "execution_status", "expected_delivery_date", "total_price", diff --git a/backend/procurement/constants.py b/backend/procurement/constants.py index f4aef4b9..6b1c2278 100644 --- a/backend/procurement/constants.py +++ b/backend/procurement/constants.py @@ -44,6 +44,7 @@ class ProcurementItemExecutionStatus(models.TextChoices): RECEIVED = 'received', 'Получено' COMPLETED_WITH_ISSUE = 'completed_with_issue', 'Выполнено с замечанием' EDITED = 'edited', 'Отредактировано' + DEFECTIVE = 'defective', 'Брак / перезаказ' def get_default_approval_step_name(priority: int, diff --git a/backend/procurement/migrations/0011_item_quantities_defective_optional_price.py b/backend/procurement/migrations/0011_item_quantities_defective_optional_price.py new file mode 100644 index 00000000..930a043e --- /dev/null +++ b/backend/procurement/migrations/0011_item_quantities_defective_optional_price.py @@ -0,0 +1,70 @@ +# Generated by Codex on 2026-05-26 + +from decimal import Decimal + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("procurement", "0010_processing_department_and_execution_statuses"), + ] + + operations = [ + migrations.AlterField( + model_name="procurementitem", + name="estimated_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.AlterField( + model_name="procurementitem", + name="execution_status", + field=models.CharField( + choices=[ + ("pending", "Не выполнено"), + ("ordered", "Заказано"), + ("rejected", "Отказано"), + ("received", "Получено"), + ("completed_with_issue", "Выполнено с замечанием"), + ("edited", "Отредактировано"), + ("defective", "Брак / перезаказ"), + ], + default="pending", + max_length=30, + verbose_name="Статус выполнения", + ), + ), + migrations.AddField( + model_name="procurementitem", + name="ordered_quantity", + field=models.PositiveIntegerField( + blank=True, + help_text="Сколько единиц заказал исполнитель", + null=True, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Заказанное количество", + ), + ), + migrations.AddField( + model_name="procurementitem", + name="received_quantity", + field=models.PositiveIntegerField( + blank=True, + help_text="Сколько единиц фактически получено", + null=True, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Полученное количество", + ), + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index 6cc1cc26..1f3ec029 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -20,6 +20,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models from django.db.models import Sum +from django.db.models.functions import Coalesce from django.utils import timezone from employees.models import Department @@ -147,7 +148,11 @@ class ProcurementRequest(models.Model): """Общая стоимость заявки (сумма всех позиций).""" total = self.items.aggregate( total=Sum( - models.F("estimated_unit_price") * models.F("quantity"), + Coalesce( + models.F("estimated_unit_price"), + Decimal("0.00"), + output_field=models.DecimalField(), + ) * models.F("quantity"), output_field=models.DecimalField(), ) )["total"] @@ -179,6 +184,7 @@ class ProcurementRequest(models.Model): ProcurementItemExecutionStatus.REJECTED, ProcurementItemExecutionStatus.COMPLETED_WITH_ISSUE, ProcurementItemExecutionStatus.EDITED, + ProcurementItemExecutionStatus.DEFECTIVE, } for item_status in item_statuses ): @@ -206,20 +212,25 @@ class ProcurementRequest(models.Model): else: new_status = ProcurementFulfillmentStatus.PENDING - if self.fulfillment_status == new_status: + should_close = ( + new_status == ProcurementFulfillmentStatus.COMPLETED + and self.status in [ + ProcurementStatus.WAITING, + ProcurementStatus.IN_PROGRESS, + ] + ) + + if self.fulfillment_status == new_status and not should_close: return new_status self.fulfillment_status = new_status + update_fields = ["fulfillment_status", "updated_at"] + if should_close: + self.status = ProcurementStatus.COMPLETED + self.completed_at = timezone.now() + update_fields.extend(["status", "completed_at"]) + 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 @@ -254,6 +265,8 @@ class ProcurementItem(models.Model): "Цена за единицу", max_digits=12, decimal_places=2, + null=True, + blank=True, validators=[MinValueValidator(Decimal("0.01"))], ) supplier_info = models.TextField( @@ -280,6 +293,20 @@ class ProcurementItem(models.Model): blank=True, validators=[MinValueValidator(Decimal("0.01"))], ) + ordered_quantity = models.PositiveIntegerField( + "Заказанное количество", + null=True, + blank=True, + validators=[MinValueValidator(0)], + help_text="Сколько единиц заказал исполнитель", + ) + received_quantity = models.PositiveIntegerField( + "Полученное количество", + null=True, + blank=True, + validators=[MinValueValidator(0)], + help_text="Сколько единиц фактически получено", + ) execution_status = models.CharField( "Статус выполнения", max_length=30, @@ -312,9 +339,92 @@ class ProcurementItem(models.Model): @property def total_price(self): """Общая стоимость позиции.""" + if self.estimated_unit_price is None: + return Decimal("0.00") return self.estimated_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: + update_fields.add(field) + + def _sync_execution_status_from_quantities(self, update_fields=None): + workflow_statuses = { + ProcurementItemExecutionStatus.PENDING, + ProcurementItemExecutionStatus.ORDERED, + ProcurementItemExecutionStatus.RECEIVED, + } + if self.execution_status not in workflow_statuses: + return + + ordered_quantity = self.ordered_quantity or 0 + 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._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._mark_update_field(update_fields, "ordered_quantity") + ordered_quantity = self.ordered_quantity + + new_status = self.execution_status + if received_quantity > 0: + new_status = ( + ProcurementItemExecutionStatus.RECEIVED + if received_quantity >= self.quantity + else ProcurementItemExecutionStatus.ORDERED + ) + elif ordered_quantity > 0: + new_status = ProcurementItemExecutionStatus.ORDERED + elif self.execution_status in { + ProcurementItemExecutionStatus.ORDERED, + ProcurementItemExecutionStatus.RECEIVED, + }: + new_status = ProcurementItemExecutionStatus.PENDING + + if new_status != self.execution_status: + self.execution_status = new_status + self._mark_update_field(update_fields, "execution_status") + def save(self, *args, **kwargs): + update_fields = kwargs.get("update_fields") + if update_fields is not None: + update_fields = set(update_fields) + kwargs["update_fields"] = update_fields + + if ( + self.execution_status == ProcurementItemExecutionStatus.ORDERED + and self.ordered_quantity is None + ): + self.ordered_quantity = self.quantity + self._mark_update_field(update_fields, "ordered_quantity") + if self.execution_status == ProcurementItemExecutionStatus.RECEIVED: + if self.ordered_quantity is None: + self.ordered_quantity = self.quantity + self._mark_update_field(update_fields, "ordered_quantity") + if self.received_quantity is None: + self.received_quantity = self.quantity + self._mark_update_field(update_fields, "received_quantity") + self._sync_execution_status_from_quantities(update_fields=update_fields) super().save(*args, **kwargs) if self.request_id: self.request.recalculate_fulfillment_status(save=True) diff --git a/backend/procurement/notifications/__init__.py b/backend/procurement/notifications/__init__.py index 30b63ca8..24858bc7 100644 --- a/backend/procurement/notifications/__init__.py +++ b/backend/procurement/notifications/__init__.py @@ -40,6 +40,7 @@ from .handlers import ( notify_stage_approved, notify_stage_rejected, notify_item_updated, + notify_item_issue_reported, notify_request_comment, notify_item_comment, ) @@ -68,6 +69,7 @@ __all__ = [ 'notify_stage_approved', 'notify_stage_rejected', 'notify_item_updated', + 'notify_item_issue_reported', 'notify_request_comment', 'notify_item_comment', diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py index 9a20b5a9..c2359141 100644 --- a/backend/procurement/notifications/handlers.py +++ b/backend/procurement/notifications/handlers.py @@ -491,6 +491,36 @@ def notify_item_updated(item, actor=None, changed_fields=None): ) +def notify_item_issue_reported(item, actor=None): + """Уведомить исполнителей о проблеме, которую отметил заявитель.""" + procurement_request = item.request + actor_name = _actor_name(actor) + notification_title = 'Позиция закупки требует внимания' + description = ( + f'{actor_name} отметил проблему по позиции "{item.name}" ' + f'в заявке "{procurement_request.title}".' + ) + recipients = [] + if procurement_request.executor_id: + recipients.append(procurement_request.executor) + recipients.extend(_processing_department_recipients(procurement_request)) + _notify_many( + recipients, + verb=NotificationVerbs.ITEM_UPDATED, + procurement_request=procurement_request, + title=notification_title, + description=description, + actor=actor, + action_object=item, + extra_data={ + "item_id": item.id, + "item_name": item.name, + "changed_fields": ["execution_status"], + "new_status": item.execution_status, + }, + ) + + def notify_request_comment(request, message, actor=None): """Уведомить автора заявки о комментарии к заявке.""" actor = actor or message.author diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 781cc8dc..9578bba7 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -7,9 +7,11 @@ from decimal import Decimal import pytest from django.contrib.auth.models import Permission from django.urls import reverse +from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient +from communications.comments_helpers import get_comments from employees.models import ( Department, DepartmentRole, @@ -356,7 +358,6 @@ class TestProcurementRequestCreate: url, { 'title': 'Расходники для производства', - 'description': 'Нужны материалы для заказа', 'department': department.id, 'processing_department': supply_department.id, 'urgency': UrgencyLevel.MEDIUM, @@ -377,6 +378,7 @@ class TestProcurementRequestCreate: assert response.data['status'] == ProcurementStatus.WAITING request_id = response.data['id'] procurement_request = ProcurementRequest.objects.get(id=request_id) + assert procurement_request.description == "" assert procurement_request.processing_department == supply_department assert procurement_request.approvals.count() == 0 @@ -803,6 +805,43 @@ class TestProcessingDepartmentWorkflow: assert response.data["status"] == ProcurementStatus.DRAFT assert sent == [] + def test_create_request_allows_item_without_price_and_links_with_comment( + self, api_client, user, department + ): + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + + response = api_client.post( + url, + { + "title": "Нужен расходник", + "description": "Не знаю где купить", + "department": department.id, + "urgency": UrgencyLevel.MEDIUM, + "items": [ + { + "name": "Паяльник", + "quantity": 1, + "unit": "шт", + "initial_comment": "Нужен для ремонта, ссылок нет", + } + ], + }, + format='json', + ) + + assert response.status_code == status.HTTP_201_CREATED + procurement_request = ProcurementRequest.objects.get( + id=response.data["id"] + ) + item = procurement_request.items.get() + assert item.estimated_unit_price is None + assert item.links == [] + assert procurement_request.total_cost == Decimal("0.00") + comments = list(get_comments(item)) + assert len(comments) == 1 + assert comments[0].content == "Нужен для ремонта, ссылок нет" + def test_create_processing_department_request_notifies_department_members( self, api_client, user, department, supply_department, supply_user, monkeypatch @@ -971,6 +1010,10 @@ class TestProcessingDepartmentWorkflow: == ProcurementFulfillmentStatus.COMPLETED ) assert processing_request.status == ProcurementStatus.COMPLETED + assert processing_request.completed_at is not None + item.refresh_from_db() + assert item.ordered_quantity == item.quantity + assert item.received_quantity == item.quantity def test_item_update_notifies_requestor( self, api_client, supply_user, processing_request, @@ -1022,8 +1065,255 @@ class TestProcessingDepartmentWorkflow: "execution_status", "expected_delivery_date", "links", + "ordered_quantity", ] + def test_item_quantities_cannot_exceed_requested_quantity( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=3, + ) + 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, + {'ordered_quantity': 4}, + format='json', + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "ordered_quantity" in response.data + + def test_ordered_quantity_moves_pending_item_to_ordered( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=3, + execution_status=ProcurementItemExecutionStatus.PENDING, + ) + 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, + {'ordered_quantity': 2}, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + item.refresh_from_db() + assert item.ordered_quantity == 2 + assert item.execution_status == ProcurementItemExecutionStatus.ORDERED + + def test_received_quantity_sets_ordered_or_received_status( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=3, + execution_status=ProcurementItemExecutionStatus.PENDING, + ) + 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}, + ) + + partial_response = api_client.patch( + item_url, + {'received_quantity': 2}, + format='json', + ) + + assert partial_response.status_code == status.HTTP_200_OK + item.refresh_from_db() + assert item.ordered_quantity == 2 + assert item.received_quantity == 2 + assert item.execution_status == ProcurementItemExecutionStatus.ORDERED + + full_response = api_client.patch( + item_url, + {'received_quantity': 3}, + format='json', + ) + + assert full_response.status_code == status.HTTP_200_OK + item.refresh_from_db() + assert item.ordered_quantity == 3 + assert item.received_quantity == 3 + assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED + + def test_zero_received_quantity_returns_received_item_to_workflow( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=3, + ordered_quantity=2, + received_quantity=3, + execution_status=ProcurementItemExecutionStatus.RECEIVED, + ) + 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}, + ) + + ordered_response = api_client.patch( + item_url, + {'received_quantity': 0}, + format='json', + ) + + assert ordered_response.status_code == status.HTTP_200_OK + item.refresh_from_db() + assert item.received_quantity == 0 + assert item.execution_status == ProcurementItemExecutionStatus.ORDERED + + pending_response = api_client.patch( + item_url, + {'ordered_quantity': 0}, + format='json', + ) + + assert pending_response.status_code == status.HTTP_200_OK + item.refresh_from_db() + assert item.ordered_quantity == 0 + assert item.execution_status == ProcurementItemExecutionStatus.PENDING + + def test_report_issue_marks_item_defective_and_reopens_completed_request( + self, api_client, user, supply_user, processing_request, + procurement_item_factory, monkeypatch + ): + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + item = procurement_item_factory( + request=processing_request, + execution_status=ProcurementItemExecutionStatus.RECEIVED, + received_quantity=1, + ) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.COMPLETED + processing_request.completed_at = timezone.now() + processing_request.save() + sent.clear() + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementitem-report-issue', + kwargs={'pk': item.id}, + ) + + response = api_client.post( + url, + {'text': 'Обнаружен брак'}, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + item.refresh_from_db() + processing_request.refresh_from_db() + assert item.execution_status == ProcurementItemExecutionStatus.DEFECTIVE + assert ( + processing_request.fulfillment_status + == ProcurementFulfillmentStatus.ISSUES + ) + assert processing_request.status == ProcurementStatus.IN_PROGRESS + assert processing_request.completed_at is None + assert [comment.content for comment in get_comments(item)] == [ + "Обнаружен брак" + ] + issue_recipients = [ + notification["recipient"].email + for notification in sent + if notification["verb"] == "procurement_item_updated" + ] + assert supply_user.email in issue_recipients + + def test_list_exposes_procurement_progress_summary( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + procurement_item_factory( + request=processing_request, + quantity=5, + execution_status=ProcurementItemExecutionStatus.RECEIVED, + ordered_quantity=5, + received_quantity=3, + expected_delivery_date="2026-05-28", + ) + procurement_item_factory( + request=processing_request, + name="Проблемная позиция", + quantity=2, + execution_status=ProcurementItemExecutionStatus.DEFECTIVE, + ordered_quantity=1, + expected_delivery_date="2026-05-27", + ) + procurement_item_factory( + request=processing_request, + name="Не обработано", + quantity=4, + execution_status=ProcurementItemExecutionStatus.PENDING, + ) + + api_client.force_authenticate(user=supply_user) + url = reverse('api:v1:procurement:procurementrequest-list') + + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + result = next( + item for item in response.data["results"] + if item["id"] == processing_request.id + ) + assert result["next_expected_delivery_date"] == "2026-05-27" + assert result["items_total_count"] == 3 + assert result["items_received_count"] == 0 + assert result["items_problem_count"] == 1 + assert result["items_pending_count"] == 1 + assert result["total_requested_quantity"] == 11 + assert result["total_ordered_quantity"] == 6 + assert result["total_received_quantity"] == 3 + def test_requestor_item_update_does_not_notify_self( self, api_client, user, processing_request, procurement_item_factory, monkeypatch @@ -1166,7 +1456,7 @@ class TestProcessingDepartmentWorkflow: } ] == [] - def test_mark_all_received_completes_processing_request( + def test_mark_all_received_updates_items_and_closes_request( self, api_client, supply_user, processing_request, procurement_item_factory, monkeypatch ): @@ -1204,11 +1494,16 @@ 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_2.ordered_quantity == item_2.quantity + assert item_2.received_quantity == item_2.quantity assert ( processing_request.fulfillment_status == ProcurementFulfillmentStatus.COMPLETED ) assert processing_request.status == ProcurementStatus.COMPLETED + assert processing_request.completed_at is not None item_update_recipients = [ item["recipient"].email for item in sent @@ -1597,6 +1892,8 @@ class TestProcurementRequestWorkflow: response = api_client.post(url) assert response.status_code == status.HTTP_200_OK + procurement_item.refresh_from_db() + assert procurement_item.execution_status == ProcurementItemExecutionStatus.PENDING completed_recipients = [ item["recipient"].email diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index e84ad209..72543029 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -10,7 +10,9 @@ import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPane import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel"; import type { ProcurementRequest, UrgencyLevel } from "@/types/api"; import { + AlertTriangle, ArrowUpDown, + CalendarDays, Check, ChevronDown, ChevronRight, @@ -237,11 +239,11 @@ function ProcurementRequestActionButtons({ type="button" onClick={() => onComplete(request.id)} disabled={busyKey === `complete-${request.id}`} - title="Завершить" + title="Закрыть заявку" className={buttonClass("app-action-primary")} > - {label("Завершить")} + {label("Закрыть заявку")} ) : null} {showSecondaryActions && isAuthor && !isFinal(status) && status !== "draft" ? ( @@ -278,6 +280,7 @@ export default function ProcurementPage() { closeModal, departmentFilter, departments, + defaultProcessingDepartmentId, detailsCache, displayUserName, ensureRequestDetail, @@ -301,6 +304,7 @@ export default function ProcurementPage() { handleCancel, handleComplete, handleMarkAllReceived, + handleReportItemIssue, handleUpdateItem, handleDelete, handleLoadMore, @@ -666,6 +670,15 @@ export default function ProcurementPage() { const executorLink = userLink(req.executor); const fulfillmentStatusDisplay = resolvedDetail.fulfillment_status_display ?? req.fulfillment_status_display; const itemsCount = resolvedDetail.items?.length ?? resolvedDetail.items_count ?? req.items_count ?? 0; + const detailItems = resolvedDetail.items || req.items || []; + const itemsTotalCount = resolvedDetail.items_total_count ?? req.items_total_count ?? itemsCount; + const itemsReceivedCount = resolvedDetail.items_received_count ?? req.items_received_count ?? detailItems.filter((item) => item.execution_status === "received").length; + const itemsProblemCount = resolvedDetail.items_problem_count ?? req.items_problem_count ?? detailItems.filter((item) => ["rejected", "completed_with_issue", "edited", "defective"].includes(String(item.execution_status || ""))).length; + const itemsPendingCount = resolvedDetail.items_pending_count ?? req.items_pending_count ?? detailItems.filter((item) => !item.execution_status || item.execution_status === "pending").length; + const totalRequestedQuantity = resolvedDetail.total_requested_quantity ?? req.total_requested_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0); + const totalOrderedQuantity = resolvedDetail.total_ordered_quantity ?? req.total_ordered_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.ordered_quantity || 0), 0); + const totalReceivedQuantity = resolvedDetail.total_received_quantity ?? req.total_received_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.received_quantity || 0), 0); + const nextExpectedDeliveryDate = resolvedDetail.next_expected_delivery_date ?? req.next_expected_delivery_date ?? null; const approvalsCount = resolvedDetail.approvals?.length ?? 0; return ( @@ -710,9 +723,6 @@ export default function ProcurementPage() {
Отдел-заказчик: {getDeptName(req)} - {req.processing_department_name ? ( - Отдел-исполнитель: {req.processing_department_name} - ) : null} {fmt(req.created_at)} {getRequestAmount(req) && {money(getRequestAmount(req))}}
@@ -809,16 +819,46 @@ export default function ProcurementPage() { не назначен )} - {(fulfillmentStatusDisplay || itemsCount > 0 || approvalsCount > 0) && ( + {(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && (
{fulfillmentStatusDisplay ? ( {fulfillmentStatusDisplay} ) : null} - {itemsCount > 0 && ( + {nextExpectedDeliveryDate ? ( - {itemsCount} поз. + {fmt(nextExpectedDeliveryDate)} + + ) : null} + {itemsTotalCount > 0 && ( + + {itemsTotalCount} поз. + + )} + {itemsTotalCount > 0 && ( + + Получено {itemsReceivedCount}/{itemsTotalCount} поз. + + )} + {itemsProblemCount > 0 && ( + + Проблемы {itemsProblemCount}/{itemsTotalCount} поз. + + )} + {totalRequestedQuantity > 0 && ( + + Заказано {totalOrderedQuantity}/{totalRequestedQuantity} шт. + + )} + {totalRequestedQuantity > 0 && ( + + Получено {totalReceivedQuantity}/{totalRequestedQuantity} шт. + + )} + {itemsPendingCount > 0 && ( + + Не обработано {itemsPendingCount} )} {approvalsCount > 0 && ( @@ -908,6 +948,7 @@ export default function ProcurementPage() { busyKey={busyKey} canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)} onUpdateItem={handleUpdateItem} + onReportItemIssue={handleReportItemIssue} onMarkAllReceived={handleMarkAllReceived} itemCommentsMap={itemCommentsMap} itemCommentDrafts={itemCommentDrafts} @@ -1035,6 +1076,7 @@ export default function ProcurementPage() { busyKey={busyKey} canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)} onUpdateItem={handleUpdateItem} + onReportItemIssue={handleReportItemIssue} onMarkAllReceived={handleMarkAllReceived} itemCommentsMap={itemCommentsMap} itemCommentDrafts={itemCommentDrafts} @@ -1240,11 +1282,13 @@ export default function ProcurementPage() { {/* Описание */}
- +