procurement: refine fulfillment workflow

This commit is contained in:
lizerk 2026-05-26 17:02:30 +03:00
parent 27e412c2e6
commit da986c0e29
15 changed files with 1187 additions and 52 deletions

View File

@ -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

View File

@ -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:

View File

@ -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",

View File

@ -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,

View File

@ -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="Полученное количество",
),
),
]

View File

@ -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)

View File

@ -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',

View File

@ -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

View File

@ -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

View File

@ -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")}
>
<ClipboardCheck size={14} />
{label("Завершить")}
{label("Закрыть заявку")}
</button>
) : 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() {
</div>
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>Отдел-заказчик: <span className="font-medium text-[var(--foreground)]">{getDeptName(req)}</span></span>
{req.processing_department_name ? (
<span>Отдел-исполнитель: <span className="font-medium text-[var(--foreground)]">{req.processing_department_name}</span></span>
) : null}
<span>{fmt(req.created_at)}</span>
{getRequestAmount(req) && <span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(req))}</span>}
</div>
@ -809,16 +819,46 @@ export default function ProcurementPage() {
<span>не назначен</span>
)}
</div>
{(fulfillmentStatusDisplay || itemsCount > 0 || approvalsCount > 0) && (
{(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && (
<div className="col-span-2 flex flex-wrap items-center gap-2 pt-0.5">
{fulfillmentStatusDisplay ? (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
{fulfillmentStatusDisplay}
</span>
) : null}
{itemsCount > 0 && (
{nextExpectedDeliveryDate ? (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Package size={11} /> {itemsCount} поз.
<CalendarDays size={11} /> {fmt(nextExpectedDeliveryDate)}
</span>
) : null}
{itemsTotalCount > 0 && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Package size={11} /> {itemsTotalCount} поз.
</span>
)}
{itemsTotalCount > 0 && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Check size={11} /> Получено {itemsReceivedCount}/{itemsTotalCount} поз.
</span>
)}
{itemsProblemCount > 0 && (
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<AlertTriangle size={11} /> Проблемы {itemsProblemCount}/{itemsTotalCount} поз.
</span>
)}
{totalRequestedQuantity > 0 && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
Заказано {totalOrderedQuantity}/{totalRequestedQuantity} шт.
</span>
)}
{totalRequestedQuantity > 0 && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
Получено {totalReceivedQuantity}/{totalRequestedQuantity} шт.
</span>
)}
{itemsPendingCount > 0 && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
Не обработано {itemsPendingCount}
</span>
)}
{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() {
{/* Описание */}
<div>
<label className="app-text-muted mb-1 block text-xs font-medium">Описание и обоснование *</label>
<label className="app-text-muted mb-1 block text-xs font-medium">
Описание и обоснование{form.requireApproval ? " *" : " (необязательно)"}
</label>
<textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Обоснуйте необходимость закупки..."
placeholder={form.requireApproval ? "Обоснуйте необходимость закупки..." : "Можно оставить пустым и описать детали в позициях..."}
rows={3}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
@ -1300,6 +1344,7 @@ export default function ProcurementPage() {
items={departments.map((d) => ({ id: d.id, name: d.name }))}
selectedId={form.processing_department}
onSelect={(id) => setForm((f) => ({ ...f, processing_department: id }))}
disabled={Boolean(defaultProcessingDepartmentId)}
/>
) : null}
@ -1314,7 +1359,7 @@ export default function ProcurementPage() {
<div className="space-y-3">
{form.items.map((it, idx) => (
<div key={idx} className="rounded-xl border border-gray-200 bg-gray-50 p-3">
<div key={idx} className="app-surface-muted rounded-xl p-3">
<div className="grid gap-2">
<div className="flex items-center gap-2">
<input
@ -1334,6 +1379,13 @@ export default function ProcurementPage() {
</button>
)}
</div>
<textarea
value={it.initial_comment}
onChange={(e) => updateItemRow(idx, { initial_comment: e.target.value })}
placeholder="Комментарий к позиции"
rows={2}
className="app-input app-text-wrap w-full rounded-lg px-3 py-2 text-sm"
/>
<div className="grid grid-cols-4 gap-2">
<input
type="number"
@ -1354,7 +1406,7 @@ export default function ProcurementPage() {
step="0.01"
value={it.estimated_unit_price}
onChange={(e) => updateItemRow(idx, { estimated_unit_price: e.target.value })}
placeholder="Цена/ед."
placeholder="Цена/ед. (необяз.)"
className="app-input rounded-lg px-3 py-2 text-sm"
/>
<div className="flex items-center text-xs text-gray-500">

View File

@ -2,7 +2,7 @@
import { useState } from "react";
import Link from "next/link";
import { Check, CheckCircle2, CircleDot, ExternalLink, MessageSquare, Plus, Save, SlidersHorizontal, X } from "lucide-react";
import { AlertTriangle, Check, CheckCircle2, CircleDot, ExternalLink, MessageSquare, Minus, Plus, RotateCcw, Save, SlidersHorizontal, X } from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
@ -22,6 +22,7 @@ interface ProcurementRequestDetailContentProps {
busyKey?: string | null;
canDeleteAnyComment?: boolean;
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise<unknown>;
onMarkAllReceived?: (requestId: number) => void | Promise<unknown>;
itemCommentsMap?: Record<number, ProcurementItemComment[]>;
itemCommentDrafts?: Record<number, string>;
@ -38,6 +39,8 @@ type ItemProcessingDraft = {
expected_delivery_date: string;
actual_unit_price: string;
links: string[];
ordered_quantity: string;
received_quantity: string;
};
const executionStatusOptions: { value: ProcurementItemExecutionStatus; label: string }[] = [
@ -47,8 +50,33 @@ const executionStatusOptions: { value: ProcurementItemExecutionStatus; label: st
{ value: "received", label: "Получено" },
{ value: "completed_with_issue", label: "Выполнено с замечанием" },
{ value: "edited", label: "Отредактировано" },
{ value: "defective", label: "Брак / перезаказ" },
];
const problemExecutionStatuses = new Set<ProcurementItemExecutionStatus>([
"completed_with_issue",
"edited",
"defective",
]);
const executionStatusBadgeClass = (status?: ProcurementItemExecutionStatus) => {
if (status === "ordered" || status === "received") return "app-feedback-success";
if (status === "rejected") return "app-feedback-danger";
if (status && problemExecutionStatuses.has(status)) return "app-feedback-warning";
return "app-badge";
};
const toNumber = (value?: string | number | null): number => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const toOptionalInteger = (value: string): number | null => {
if (value.trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : null;
};
const approvalIconByStatus = (status?: string) => {
const normalized = String(status || "").toLowerCase();
if (normalized === "approved") {
@ -73,6 +101,8 @@ const normalizeItemDraft = (item: ProcurementItem): ItemProcessingDraft => ({
expected_delivery_date: item.expected_delivery_date || "",
actual_unit_price: item.actual_unit_price ? String(item.actual_unit_price) : "",
links: toLinkRows(item.links),
ordered_quantity: item.ordered_quantity === null || item.ordered_quantity === undefined ? "" : String(item.ordered_quantity),
received_quantity: item.received_quantity === null || item.received_quantity === undefined ? "" : String(item.received_quantity),
});
interface ProcurementItemCardProps {
@ -84,10 +114,12 @@ interface ProcurementItemCardProps {
fallbackEmail?: string | null,
) => string;
canEditItemProcessing: boolean;
canReportIssue: boolean;
currentUserId?: number | null;
canDeleteAnyComment?: boolean;
busyKey?: string | null;
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise<unknown>;
comments?: ProcurementItemComment[];
commentDraft?: string;
commentsOpen?: boolean;
@ -102,10 +134,12 @@ function ProcurementItemCard({
requestId,
displayUserName,
canEditItemProcessing,
canReportIssue,
currentUserId,
canDeleteAnyComment = false,
busyKey,
onUpdateItem,
onReportItemIssue,
comments = [],
commentDraft = "",
commentsOpen = false,
@ -118,20 +152,51 @@ function ProcurementItemCard({
const [processingOpen, setProcessingOpen] = useState(false);
const links = Array.isArray(item.links) ? item.links.filter(Boolean) : [];
const commentsTotal = item.comments_count ?? comments.length;
const requestedQuantity = Math.max(0, Math.trunc(toNumber(item.quantity)));
const orderedQuantity = item.ordered_quantity ?? 0;
const receivedQuantity = item.received_quantity ?? 0;
const status = item.execution_status || "pending";
const statusLabel = item.execution_status_display || executionStatusOptions.find((option) => option.value === status)?.label || "Не выполнено";
const canCancelReceived = canEditItemProcessing && status === "received";
const canMarkIssue = Boolean(
canReportIssue &&
onReportItemIssue &&
status !== "rejected" &&
status !== "defective" &&
(canEditItemProcessing || status === "received")
);
const updateDraft = (patch: Partial<ItemProcessingDraft>) => {
setDraft((previous) => ({ ...previous, ...patch }));
};
const adjustDraftQuantity = (field: "ordered_quantity" | "received_quantity", delta: number) => {
setDraft((previous) => {
const current = toOptionalInteger(previous[field]) ?? 0;
const next = Math.min(requestedQuantity, Math.max(0, current + delta));
return { ...previous, [field]: String(next) };
});
};
const saveItemProcessing = () => {
return onUpdateItem?.(requestId, item.id, {
execution_status: draft.execution_status,
expected_delivery_date: draft.expected_delivery_date || null,
actual_unit_price: draft.actual_unit_price || null,
ordered_quantity: toOptionalInteger(draft.ordered_quantity),
received_quantity: toOptionalInteger(draft.received_quantity),
links: cleanLinkRows(draft.links),
});
};
const cancelReceived = () => {
const nextStatus: ProcurementItemExecutionStatus = Number(item.ordered_quantity || 0) > 0 ? "ordered" : "pending";
return onUpdateItem?.(requestId, item.id, {
execution_status: nextStatus,
received_quantity: 0,
});
};
return (
<div className="app-surface-muted rounded-lg px-3 py-3 text-xs">
<div className="flex items-start justify-between gap-3">
@ -169,11 +234,27 @@ function ProcurementItemCard({
</p>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<span className={`app-status-pill ${executionStatusBadgeClass(status)}`}>{statusLabel}</span>
{item.expected_delivery_date ? (
<span className="app-badge inline-flex rounded-full px-2 py-1 text-[11px] font-medium">
Ожидается: {formatDate(item.expected_delivery_date)}
</span>
) : null}
{item.actual_unit_price ? (
<span className="app-badge inline-flex rounded-full px-2 py-1 text-[11px] font-medium">
Факт: {formatMoney(item.actual_unit_price)}
</span>
) : null}
<span className="app-badge inline-flex rounded-full px-2 py-1 text-[11px] font-medium">
Заказано {orderedQuantity}/{requestedQuantity}
</span>
<span className="app-badge inline-flex rounded-full px-2 py-1 text-[11px] font-medium">
Получено {receivedQuantity}/{requestedQuantity}
</span>
</div>
<div className="app-text-muted mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span>Цена/ед.: {formatMoney(item.estimated_unit_price)}</span>
{item.actual_unit_price ? <span>Факт/ед.: {formatMoney(item.actual_unit_price)}</span> : null}
<span>Статус: {item.execution_status_display || "Не выполнено"}</span>
{item.expected_delivery_date ? <span>Ожидается: {formatDate(item.expected_delivery_date)}</span> : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<div>
@ -202,6 +283,30 @@ function ProcurementItemCard({
<span>Исполнение</span>
</button>
) : null}
{canCancelReceived ? (
<button
type="button"
onClick={() => void cancelReceived()}
disabled={busyKey === `item-${item.id}`}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
>
<RotateCcw size={13} />
<span>Отменить получение</span>
</button>
) : null}
{canMarkIssue ? (
<button
type="button"
onClick={() => void onReportItemIssue?.(requestId, item.id)}
disabled={busyKey === `item-issue-${item.id}`}
className="app-feedback-warning inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
>
<AlertTriangle size={13} />
<span>Брак</span>
</button>
) : null}
</div>
{commentsOpen ? (
@ -278,6 +383,72 @@ function ProcurementItemCard({
className="app-input w-full rounded-lg px-3 py-2 text-xs"
/>
</div>
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Заказано, шт.</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => adjustDraftQuantity("ordered_quantity", -1)}
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
>
<Minus size={12} />
</button>
<input
type="number"
min={0}
max={requestedQuantity}
value={draft.ordered_quantity}
onChange={(event) => {
const parsed = toOptionalInteger(event.target.value);
updateDraft({
ordered_quantity: parsed === null ? "" : String(Math.min(requestedQuantity, parsed)),
});
}}
placeholder={`0-${requestedQuantity}`}
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-xs"
/>
<button
type="button"
onClick={() => adjustDraftQuantity("ordered_quantity", 1)}
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
>
<Plus size={12} />
</button>
</div>
</div>
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Получено, шт.</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => adjustDraftQuantity("received_quantity", -1)}
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
>
<Minus size={12} />
</button>
<input
type="number"
min={0}
max={requestedQuantity}
value={draft.received_quantity}
onChange={(event) => {
const parsed = toOptionalInteger(event.target.value);
updateDraft({
received_quantity: parsed === null ? "" : String(Math.min(requestedQuantity, parsed)),
});
}}
placeholder={`0-${requestedQuantity}`}
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-xs"
/>
<button
type="button"
onClick={() => adjustDraftQuantity("received_quantity", 1)}
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
>
<Plus size={12} />
</button>
</div>
</div>
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Ссылки</label>
<div className="space-y-2">
@ -323,7 +494,7 @@ function ProcurementItemCard({
disabled={busyKey === `item-${item.id}`}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60"
>
<Save size={13} /> Сохранить позицию
<Save size={13} /> Сохранить исполнение
</button>
</div>
</div>
@ -340,6 +511,7 @@ export function ProcurementRequestDetailContent({
busyKey,
canDeleteAnyComment = false,
onUpdateItem,
onReportItemIssue,
onMarkAllReceived,
itemCommentsMap = {},
itemCommentDrafts = {},
@ -351,6 +523,15 @@ export function ProcurementRequestDetailContent({
footer,
}: ProcurementRequestDetailContentProps) {
const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem);
const requestorId = typeof request.requestor === "number" ? request.requestor : request.requestor?.id ?? null;
const executorId = typeof request.executor === "number" ? request.executor : request.executor?.id ?? null;
const canReportIssues = Boolean(
onReportItemIssue && currentUserId && (
canEditItemProcessing ||
currentUserId === requestorId ||
currentUserId === executorId
),
);
return (
<div className="space-y-3">
@ -403,16 +584,20 @@ export function ProcurementRequestDetailContent({
item.execution_status || "",
item.expected_delivery_date || "",
item.actual_unit_price || "",
item.ordered_quantity ?? "",
item.received_quantity ?? "",
Array.isArray(item.links) ? item.links.join("|") : "",
].join(":")}
item={item}
requestId={request.id}
displayUserName={displayUserName}
canEditItemProcessing={canEditItemProcessing}
canReportIssue={canReportIssues}
currentUserId={currentUserId}
canDeleteAnyComment={canDeleteAnyComment}
busyKey={busyKey}
onUpdateItem={onUpdateItem}
onReportItemIssue={onReportItemIssue}
comments={itemCommentsMap[item.id] || []}
commentDraft={itemCommentDrafts[item.id] || ""}
commentsOpen={Boolean(expandedItemComments[item.id])}

View File

@ -23,6 +23,7 @@ type ItemDraft = {
estimated_unit_price: string;
supplier_info: string;
links: string[];
initial_comment: string;
};
const emptyItem: ItemDraft = {
@ -32,7 +33,8 @@ const emptyItem: ItemDraft = {
unit: "шт",
estimated_unit_price: "",
supplier_info: "",
links: [""],
links: [],
initial_comment: "",
};
type FormState = {
@ -325,6 +327,32 @@ export function useProcurementPage(user: User | null) {
};
}, []);
const defaultProcessingDepartmentId = useMemo(() => {
const procurementDepartment = departments.find(
(department) => department.name.trim().toLocaleLowerCase("ru-RU") === "снабжение",
);
return procurementDepartment?.id ?? null;
}, [departments]);
useEffect(() => {
if (form.requireApproval || !defaultProcessingDepartmentId) {
return;
}
setForm((previous) => {
if (
previous.requireApproval ||
previous.processing_department === defaultProcessingDepartmentId
) {
return previous;
}
return {
...previous,
processing_department: defaultProcessingDepartmentId,
};
});
}, [defaultProcessingDepartmentId, form.requireApproval]);
const filteredRequests = useMemo(() => {
const urgencyRank: Record<string, number> = {
low: 1,
@ -385,6 +413,7 @@ export function useProcurementPage(user: User | null) {
estimated_unit_price: String(item.estimated_unit_price || ""),
supplier_info: item.supplier_info || "",
links: toLinkRows(item.links),
initial_comment: "",
}))
: [{ ...emptyItem }],
});
@ -434,7 +463,7 @@ export function useProcurementPage(user: User | null) {
setActionError("Укажите название заявки.");
return;
}
if (!form.description.trim()) {
if (form.requireApproval && !form.description.trim()) {
setActionError("Укажите описание и обоснование.");
return;
}
@ -458,10 +487,6 @@ export function useProcurementPage(user: User | null) {
setActionError(`Позиция «${item.name}»: укажите количество.`);
return;
}
if (!item.estimated_unit_price || Number(item.estimated_unit_price) <= 0) {
setActionError(`Позиция «${item.name}»: укажите цену за единицу.`);
return;
}
const linksError = validateLinkRows(item.links, `Позиция «${item.name}», ссылка`);
if (linksError) {
setActionError(linksError);
@ -471,7 +496,7 @@ export function useProcurementPage(user: User | null) {
const payload: Record<string, unknown> = {
title: form.title,
description: form.description,
description: form.description.trim(),
department: form.department,
processing_department: form.requireApproval ? null : form.processing_department,
urgency: form.urgency,
@ -480,9 +505,10 @@ export function useProcurementPage(user: User | null) {
description: item.description || undefined,
quantity: item.quantity,
unit: item.unit || "шт",
estimated_unit_price: item.estimated_unit_price,
estimated_unit_price: item.estimated_unit_price || null,
supplier_info: item.supplier_info || undefined,
links: cleanLinkRows(item.links),
initial_comment: item.initial_comment.trim() || undefined,
})),
};
@ -785,7 +811,7 @@ export function useProcurementPage(user: User | null) {
`complete-${id}`,
() => apiClient.completeProcurementRequest(id),
id,
"Заявка завершена.",
"Заявка закрыта.",
), [doAction]);
const handleMarkAllReceived = useCallback((id: number) => doAction(
@ -818,6 +844,22 @@ export function useProcurementPage(user: User | null) {
}
}, [refreshOne]);
const handleReportItemIssue = useCallback(async (requestId: number, itemId: number, text = "") => {
try {
setBusyKey(`item-issue-${itemId}`);
setActionError(null);
await apiClient.reportProcurementItemIssue(itemId, text);
setActionSuccess("Позиция отмечена как проблемная.");
await refreshOne(requestId);
return true;
} catch (issueError) {
setActionError(getReadableError(issueError, "Не удалось отметить проблему"));
return false;
} finally {
setBusyKey(null);
}
}, [refreshOne]);
const handleCancel = useCallback((id: number, reason = "") => {
return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена.");
}, [doAction]);
@ -867,6 +909,7 @@ export function useProcurementPage(user: User | null) {
canSupplierManage,
requests,
departments,
defaultProcessingDepartmentId,
loading,
loadingMore,
error,
@ -928,6 +971,7 @@ export function useProcurementPage(user: User | null) {
handleComplete,
handleMarkAllReceived,
handleUpdateItem,
handleReportItemIssue,
handleCancel,
handleDelete,
handleAddComment,

View File

@ -29,6 +29,8 @@ export function createProcurementApi(request: RequestFn) {
getProcurementItems: (params?: Record<string, string | number>) => request(`/api/v1/procurement/items/${buildQuery(params)}`),
createProcurementItem: (data: Record<string, any>) => request('/api/v1/procurement/items/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateProcurementItem: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/items/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
reportProcurementItemIssue: (id: number, text?: string) =>
request(`/api/v1/procurement/items/${id}/report_issue/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text || '' }) }),
deleteProcurementItem: (id: number): Promise<void> => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }),
getProcurementItemComments: (itemId: number) => request(`/api/v1/procurement/items/${itemId}/comments/`),
addProcurementItemComment: (itemId: number, text: string) =>

View File

@ -1,8 +1,7 @@
export const toLinkRows = (links?: string[] | null): string[] => {
const rows = Array.isArray(links)
return Array.isArray(links)
? links.map((link) => String(link || "").trim()).filter(Boolean)
: [];
return rows.length > 0 ? rows : [""];
};
export const cleanLinkRows = (links: string[]): string[] => (

View File

@ -483,7 +483,7 @@ export interface MaintenanceRecord {
// Procurement Request types
export type ProcurementStatus = 'draft' | 'waiting' | 'pending' | 'approved' | 'in_progress' | 'completed' | 'rejected' | 'cancelled';
export type ProcurementFulfillmentStatus = 'pending' | 'partially_ordered' | 'ordered' | 'partially_received' | 'completed' | 'issues';
export type ProcurementItemExecutionStatus = 'pending' | 'ordered' | 'rejected' | 'received' | 'completed_with_issue' | 'edited';
export type ProcurementItemExecutionStatus = 'pending' | 'ordered' | 'rejected' | 'received' | 'completed_with_issue' | 'edited' | 'defective';
export type UrgencyLevel = 'low' | 'medium' | 'high' | 'critical';
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
@ -494,7 +494,7 @@ export interface ProcurementItem {
description?: string;
quantity: string | number;
unit: string;
estimated_unit_price: string | number;
estimated_unit_price?: string | number | null;
total_price?: string | number;
supplier_info?: string;
links?: string[];
@ -502,7 +502,9 @@ export interface ProcurementItem {
actual_unit_price?: string | number | null;
execution_status?: ProcurementItemExecutionStatus;
execution_status_display?: string;
executor_comment?: string;
ordered_quantity?: number | null;
received_quantity?: number | null;
initial_comment?: string;
comments_count?: number;
equipment?: number | null;
}
@ -549,6 +551,14 @@ export interface ProcurementRequest {
total_cost?: string | number;
total_estimated_cost?: string | number;
items_count?: number;
next_expected_delivery_date?: string | null;
items_total_count?: number;
items_received_count?: number;
items_problem_count?: number;
items_pending_count?: number;
total_requested_quantity?: number;
total_ordered_quantity?: number;
total_received_quantity?: number;
comments_count?: number;
can_current_user_approve?: boolean;
created_at: string;