Fix procurement unit selection and labels

This commit is contained in:
lizerk 2026-06-08 15:46:42 +03:00
parent 9319eb8171
commit d6a7ce530d
7 changed files with 202 additions and 14 deletions

View File

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

View File

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

View File

@ -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 && (
<span className={`${getOrderedProgressClass(totalOrderedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
Заказано {totalOrderedQuantity}/{totalRequestedQuantity} шт.
Заказано {totalOrderedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
</span>
)}
{totalRequestedQuantity > 0 && (
<span className={`${getReceivedProgressClass(totalReceivedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
Получено {totalReceivedQuantity}/{totalRequestedQuantity} шт.
Получено {totalReceivedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
</span>
)}
{itemsPendingCount > 0 && (
@ -1525,7 +1530,7 @@ export default function ProcurementPage() {
</div>
</div>
) : null}
<div className="grid grid-cols-4 gap-2">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<input
type="number"
value={it.quantity}
@ -1534,12 +1539,18 @@ export default function ProcurementPage() {
min={1}
className="app-input rounded-lg px-3 py-2 text-sm"
/>
<input
<select
value={it.unit}
onChange={(e) => updateItemRow(idx, { unit: e.target.value })}
placeholder="Ед."
className="app-input rounded-lg px-3 py-2 text-sm"
/>
aria-label="Единица измерения"
className="app-select min-w-0 rounded-lg px-3 py-2 text-sm"
>
{getProcurementUnitOptions(it.unit).map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
<input
type="number"
step="0.01"

View File

@ -7,6 +7,7 @@ import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { cleanLinkRows, linkHref, toLinkRows } from "@/lib/procurementLinks";
import { getProcurementUnitLabel } from "@/lib/procurementUnits";
import { formatDate, formatMoney, userProfileLink } from "@/lib/shared";
import type { ProcurementItem, ProcurementItemComment, ProcurementItemExecutionStatus, ProcurementRequest, User } from "@/types/api";
@ -182,6 +183,7 @@ function ProcurementItemCard({
const requestedQuantity = Math.max(0, Math.trunc(toNumber(item.quantity)));
const orderedQuantity = item.ordered_quantity ?? 0;
const receivedQuantity = item.received_quantity ?? 0;
const itemUnitLabel = getProcurementUnitLabel(item.unit);
const status = item.execution_status || "pending";
const statusLabel = item.execution_status_display || executionStatusOptions.find((option) => option.value === status)?.label || "Не выполнено";
const canCancelReceived = Boolean(
@ -258,7 +260,7 @@ function ProcurementItemCard({
<div className="min-w-0">
<p className="app-text-muted text-[11px]">Количество</p>
<p className="app-text-wrap mt-0.5 font-medium text-[var(--foreground)]">
{item.quantity} {item.unit}
{item.quantity} {itemUnitLabel}
</p>
</div>
<div className="min-w-0">
@ -278,13 +280,13 @@ function ProcurementItemCard({
<div className="min-w-0">
<p className="app-text-muted text-[11px]">Заказано</p>
<p className="app-text-wrap mt-0.5 font-medium text-[var(--foreground)]">
{orderedQuantity}/{requestedQuantity}
{orderedQuantity}/{requestedQuantity} {itemUnitLabel}
</p>
</div>
<div className="min-w-0">
<p className="app-text-muted text-[11px]">Получено</p>
<p className="app-text-wrap mt-0.5 font-medium text-[var(--foreground)]">
{receivedQuantity}/{requestedQuantity}
{receivedQuantity}/{requestedQuantity} {itemUnitLabel}
</p>
</div>
</div>
@ -517,7 +519,7 @@ function ProcurementItemCard({
/>
</div>
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Заказано, шт.</label>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Заказано, {itemUnitLabel}</label>
<div className="flex items-center gap-2">
<button
type="button"
@ -550,7 +552,7 @@ function ProcurementItemCard({
</div>
</div>
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Получено, шт.</label>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Получено, {itemUnitLabel}</label>
<div className="flex items-center gap-2">
<button
type="button"

View File

@ -477,7 +477,7 @@ export function useProcurementPage(user: User | null) {
name: item.name || "",
description: item.description || "",
quantity: String(item.quantity || "1"),
unit: item.unit || "шт",
unit: String(item.unit || "").trim() || "шт",
estimated_unit_price: String(item.estimated_unit_price || ""),
supplier_info: item.supplier_info || "",
links: toLinkRows(item.links),
@ -589,7 +589,7 @@ export function useProcurementPage(user: User | null) {
name: item.name,
description: item.description || undefined,
quantity: item.quantity,
unit: item.unit || "шт",
unit: item.unit.trim() || "шт",
estimated_unit_price: item.estimated_unit_price || null,
supplier_info: item.supplier_info || undefined,
links: cleanLinkRows(item.links),

View File

@ -0,0 +1,86 @@
export const procurementUnitOptions = [
{ value: "шт", label: "шт" },
{ value: "компл", label: "компл" },
{ value: "упак", label: "упак" },
{ value: "короб", label: "короб" },
{ value: "пачка", label: "пачка" },
{ value: "кг", label: "кг" },
{ value: "г", label: "г" },
{ value: "л", label: "л" },
{ value: "мл", label: "мл" },
{ value: "м", label: "м" },
{ value: "кв. м", label: "кв. м" },
{ value: "куб. м", label: "куб. м" },
{ value: "рулон", label: "рулон" },
{ value: "пара", label: "пара" },
{ value: "услуга", label: "услуга" },
{ value: "час", label: "час" },
];
const procurementUnitLabels: Record<string, string> = {
"шт": "штуки",
"компл": "комплекты",
"упак": "упаковки",
"короб": "короба",
"пачка": "пачки",
"кг": "килограммы",
"г": "граммы",
"л": "литры",
"мл": "миллилитры",
"м": "метры",
"кв. м": "кв. метры",
"куб. м": "куб. метры",
"рулон": "рулоны",
"пара": "пары",
"услуга": "услуги",
"час": "часы",
};
type UnitAwareItem = {
unit?: string | null;
};
const cleanUnitValue = (value?: string | null) => (
String(value || "").trim().replace(/\s+/g, " ")
);
const normalizeUnit = (value?: string | null) => (
cleanUnitValue(value).toLowerCase()
);
export const getProcurementUnitOptions = (currentUnit: string) => {
const currentValue = cleanUnitValue(currentUnit);
if (!currentValue || procurementUnitOptions.some((unit) => unit.value === currentValue)) {
return procurementUnitOptions;
}
return [
...procurementUnitOptions,
{ value: currentValue, label: `${currentValue} (текущее)` },
];
};
export const getProcurementUnitLabel = (unit?: string | null) => {
const normalizedUnit = normalizeUnit(unit);
if (!normalizedUnit) {
return "единиц";
}
return procurementUnitLabels[normalizedUnit] ?? normalizedUnit;
};
export const getProcurementQuantityUnitLabel = (
items: UnitAwareItem[],
fallbackLabel?: string | null,
) => {
const normalizedFallback = String(fallbackLabel || "").trim();
if (normalizedFallback) {
return normalizedFallback;
}
const units = new Set(items.map((item) => normalizeUnit(item.unit)).filter(Boolean));
if (units.size === 1) {
return getProcurementUnitLabel([...units][0]);
}
return "единиц";
};

View File

@ -602,6 +602,7 @@ export interface ProcurementRequest {
total_requested_quantity?: number;
total_ordered_quantity?: number;
total_received_quantity?: number;
quantity_unit_label?: string;
comments_count?: number;
can_current_user_approve?: boolean;
can_current_user_submit_for_approval?: boolean;