diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 123af356..10e9fb07 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -37,6 +37,35 @@ PROBLEM_ITEM_STATUSES = { } DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +UNIT_DISPLAY_LABELS = { + "шт": "штуки", + "компл": "комплекты", + "упак": "упаковки", + "короб": "короба", + "пачка": "пачки", + "кг": "килограммы", + "г": "граммы", + "л": "литры", + "мл": "миллилитры", + "м": "метры", + "кв. м": "кв. метры", + "куб. м": "куб. метры", + "рулон": "рулоны", + "пара": "пары", + "услуга": "услуги", + "час": "часы", +} + + +def _normalize_unit(value): + return re.sub(r"\s+", " ", str(value or "").strip()).lower() + + +def _unit_display_label(value): + unit = _normalize_unit(value) + if not unit: + return "единиц" + return UNIT_DISPLAY_LABELS.get(unit, unit) def _validate_expected_delivery_dates(value): @@ -108,6 +137,7 @@ class ProcurementRequestSummaryMixin(serializers.Serializer): total_requested_quantity = serializers.SerializerMethodField() total_ordered_quantity = serializers.SerializerMethodField() total_received_quantity = serializers.SerializerMethodField() + quantity_unit_label = serializers.SerializerMethodField() @extend_schema_field(serializers.DateField(allow_null=True)) def get_next_expected_delivery_date(self, obj): @@ -172,6 +202,17 @@ class ProcurementRequestSummaryMixin(serializers.Serializer): def get_total_received_quantity(self, obj): return sum(_effective_received_quantity(item) for item in _request_items(obj)) + @extend_schema_field(serializers.CharField()) + def get_quantity_unit_label(self, obj): + units = { + _normalize_unit(item.unit) + for item in _request_items(obj) + if _normalize_unit(item.unit) + } + if len(units) == 1: + return _unit_display_label(next(iter(units))) + return "единиц" + class ProcurementItemSerializer(serializers.ModelSerializer): """Сериализатор для позиций заявки.""" @@ -494,6 +535,7 @@ class ProcurementRequestListSerializer( "total_requested_quantity", "total_ordered_quantity", "total_received_quantity", + "quantity_unit_label", "comments_count", "can_current_user_approve", "can_current_user_submit_for_approval", @@ -527,6 +569,7 @@ class ProcurementRequestListSerializer( "total_requested_quantity", "total_ordered_quantity", "total_received_quantity", + "quantity_unit_label", "comments_count", "can_current_user_approve", "can_current_user_submit_for_approval", @@ -728,6 +771,7 @@ class ProcurementRequestDetailSerializer( "total_requested_quantity", "total_ordered_quantity", "total_received_quantity", + "quantity_unit_label", "items", "approvals", "required_approval_priorities", @@ -760,6 +804,7 @@ class ProcurementRequestDetailSerializer( "total_requested_quantity", "total_ordered_quantity", "total_received_quantity", + "quantity_unit_label", "executor_name", "fulfillment_status", "processing_department_name", diff --git a/backend/tests/procurement/test_api.py b/backend/tests/procurement/test_api.py index a48ecbf7..517eddff 100644 --- a/backend/tests/procurement/test_api.py +++ b/backend/tests/procurement/test_api.py @@ -15,6 +15,7 @@ from procurement.models import ( Budget, Equipment, EquipmentCategory, + ProcurementItem, ProcurementRequest, Supplier, ) @@ -145,6 +146,48 @@ class TestProcurementRequestAPI: assert 'required_approval_priorities' in response.data assert 'is_editable' in response.data + def test_list_request_quantity_unit_label_single_unit( + self, api_client, user, procurement_request + ): + """Тест: для одной единицы измерения возвращается ее подпись.""" + ProcurementItem.objects.create( + request=procurement_request, + name="Кабель", + quantity=20, + unit="м", + ) + + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['results'][0]['quantity_unit_label'] == "метры" + + def test_list_request_quantity_unit_label_mixed_units( + self, api_client, user, procurement_request + ): + """Тест: для смешанных единиц возвращается обобщенная подпись.""" + ProcurementItem.objects.create( + request=procurement_request, + name="Кабель", + quantity=20, + unit="м", + ) + ProcurementItem.objects.create( + request=procurement_request, + name="Крепеж", + quantity=10, + unit="шт", + ) + + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['results'][0]['quantity_unit_label'] == "единиц" + def test_update_own_request_draft( self, api_client, user, procurement_request ): diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index d79ade73..0776c62c 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -38,6 +38,7 @@ import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; import { formatDate, formatMoney } from "@/lib/shared"; import { useProcurementPage } from "@/hooks/useProcurementPage"; import { Modal } from "@/components/ui"; +import { getProcurementQuantityUnitLabel, getProcurementUnitOptions } from "@/lib/procurementUnits"; /* ══════════════════════════════════════════════════════ Constants & helpers @@ -817,6 +818,10 @@ export default function ProcurementPage() { 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 quantityUnitLabel = getProcurementQuantityUnitLabel( + detailItems, + resolvedDetail.quantity_unit_label ?? req.quantity_unit_label, + ); const nextExpectedDeliveryDate = resolvedDetail.next_expected_delivery_date ?? req.next_expected_delivery_date ?? null; const approvalsCount = resolvedDetail.approvals?.length ?? 0; @@ -987,12 +992,12 @@ export default function ProcurementPage() { )} {totalRequestedQuantity > 0 && ( - Заказано {totalOrderedQuantity}/{totalRequestedQuantity} шт. + Заказано {totalOrderedQuantity}/{totalRequestedQuantity} {quantityUnitLabel} )} {totalRequestedQuantity > 0 && ( - Получено {totalReceivedQuantity}/{totalRequestedQuantity} шт. + Получено {totalReceivedQuantity}/{totalRequestedQuantity} {quantityUnitLabel} )} {itemsPendingCount > 0 && ( @@ -1525,7 +1530,7 @@ export default function ProcurementPage() { ) : null} -
Количество
- {item.quantity} {item.unit} + {item.quantity} {itemUnitLabel}
Заказано
- {orderedQuantity}/{requestedQuantity} + {orderedQuantity}/{requestedQuantity} {itemUnitLabel}
Получено
- {receivedQuantity}/{requestedQuantity} + {receivedQuantity}/{requestedQuantity} {itemUnitLabel}