Fix procurement unit selection and labels
This commit is contained in:
parent
9319eb8171
commit
d6a7ce530d
@ -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",
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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),
|
||||
|
||||
86
frontend/src/lib/procurementUnits.ts
Normal file
86
frontend/src/lib/procurementUnits.ts
Normal 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 "единиц";
|
||||
};
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user