procurement: refine fulfillment workflow
This commit is contained in:
parent
27e412c2e6
commit
da986c0e29
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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="Полученное количество",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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])}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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[] => (
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user