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")}
>