diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index d71b458c..5c3b5394 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -1330,14 +1330,42 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): if error_response is not None: return error_response - if item.effective_received_quantity <= 0: + current_received_quantity = item.effective_received_quantity + if current_received_quantity <= 0: return Response( {"detail": "По позиции нет подтверждённого получения"}, status=status.HTTP_400_BAD_REQUEST, ) + target_received_quantity = 0 + if "cancel_quantity" in request.data: + raw_cancel_quantity = request.data.get("cancel_quantity") + try: + cancel_quantity = int(raw_cancel_quantity) + except (TypeError, ValueError): + return Response( + {"cancel_quantity": "Укажите целое число отменяемых единиц"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if cancel_quantity < 1: + return Response( + {"cancel_quantity": "Количество должно быть больше 0"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if cancel_quantity > current_received_quantity: + return Response( + { + "cancel_quantity": ( + "Не может быть больше текущего полученного количества" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + target_received_quantity = current_received_quantity - cancel_quantity + effective_ordered_quantity = item.effective_ordered_quantity - item.received_quantity = 0 + item.received_quantity = target_received_quantity if item.ordered_quantity is None and effective_ordered_quantity > 0: item.ordered_quantity = min(effective_ordered_quantity, item.quantity) item.execution_status = self._workflow_status_from_quantities(item) @@ -1409,9 +1437,49 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): if error_response is not None: return error_response - item.execution_status = ProcurementItemExecutionStatus.RECEIVED - item.ordered_quantity = item.quantity - item.received_quantity = item.quantity + current_received_quantity = item.effective_received_quantity + target_received_quantity = item.quantity + + if "received_quantity" in request.data: + raw_received_quantity = request.data.get("received_quantity") + try: + target_received_quantity = int(raw_received_quantity) + except (TypeError, ValueError): + return Response( + {"received_quantity": "Укажите целое число полученных единиц"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if target_received_quantity < 1: + return Response( + {"received_quantity": "Количество должно быть больше 0"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if target_received_quantity > item.quantity: + return Response( + { + "received_quantity": ( + "Не может быть больше количества позиции" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + if target_received_quantity <= current_received_quantity: + return Response( + { + "received_quantity": ( + "Должно быть больше текущего полученного количества" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + item.received_quantity = target_received_quantity + item.ordered_quantity = min( + item.quantity, + max(item.effective_ordered_quantity, target_received_quantity), + ) + item.execution_status = self._workflow_status_from_quantities(item) item.save( update_fields=[ "execution_status", diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 3362886a..7803e48f 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -2484,6 +2484,83 @@ class TestProcessingDepartmentWorkflow: assert processing_request.status == ProcurementStatus.COMPLETED assert processing_request.completed_at is not None + def test_requestor_can_confirm_partial_item_received( + self, api_client, user, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=3, + ordered_quantity=3, + received_quantity=0, + execution_status=ProcurementItemExecutionStatus.ORDERED, + ) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.save() + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementitem-confirm-received', + kwargs={'pk': item.id}, + ) + + response = api_client.post( + url, + {'received_quantity': 2}, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + item.refresh_from_db() + processing_request.refresh_from_db() + assert item.execution_status == ProcurementItemExecutionStatus.ORDERED + assert item.ordered_quantity == 3 + assert item.received_quantity == 2 + assert ( + processing_request.fulfillment_status + == ProcurementFulfillmentStatus.PARTIALLY_RECEIVED + ) + assert processing_request.status == ProcurementStatus.IN_PROGRESS + assert processing_request.completed_at is None + + def test_confirm_item_received_rejects_invalid_quantity( + self, api_client, user, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=2, + ordered_quantity=2, + received_quantity=1, + execution_status=ProcurementItemExecutionStatus.ORDERED, + ) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.save() + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementitem-confirm-received', + kwargs={'pk': item.id}, + ) + + too_low_response = api_client.post( + url, + {'received_quantity': 1}, + format='json', + ) + too_high_response = api_client.post( + url, + {'received_quantity': 3}, + format='json', + ) + + assert too_low_response.status_code == status.HTTP_400_BAD_REQUEST + assert too_high_response.status_code == status.HTTP_400_BAD_REQUEST + item.refresh_from_db() + assert item.received_quantity == 1 + def test_requestor_can_cancel_item_issue( self, api_client, user, supply_user, processing_request, procurement_item_factory @@ -2556,6 +2633,82 @@ class TestProcessingDepartmentWorkflow: assert processing_request.status == ProcurementStatus.IN_PROGRESS assert processing_request.completed_at is None + def test_requestor_can_cancel_partial_item_received( + self, api_client, user, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=4, + ordered_quantity=4, + received_quantity=3, + execution_status=ProcurementItemExecutionStatus.ORDERED, + ) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.save() + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementitem-cancel-received', + kwargs={'pk': item.id}, + ) + + response = api_client.post( + url, + {'cancel_quantity': 1}, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + item.refresh_from_db() + processing_request.refresh_from_db() + assert item.execution_status == ProcurementItemExecutionStatus.ORDERED + assert item.ordered_quantity == 4 + assert item.received_quantity == 2 + assert ( + processing_request.fulfillment_status + == ProcurementFulfillmentStatus.PARTIALLY_RECEIVED + ) + assert processing_request.status == ProcurementStatus.IN_PROGRESS + + def test_cancel_item_received_rejects_invalid_quantity( + self, api_client, user, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=2, + ordered_quantity=2, + received_quantity=1, + execution_status=ProcurementItemExecutionStatus.ORDERED, + ) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.save() + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementitem-cancel-received', + kwargs={'pk': item.id}, + ) + + too_low_response = api_client.post( + url, + {'cancel_quantity': 0}, + format='json', + ) + too_high_response = api_client.post( + url, + {'cancel_quantity': 2}, + format='json', + ) + + assert too_low_response.status_code == status.HTTP_400_BAD_REQUEST + assert too_high_response.status_code == status.HTTP_400_BAD_REQUEST + item.refresh_from_db() + assert item.received_quantity == 1 + def test_cancel_received_uses_received_status_as_effective_quantity( self, api_client, user, supply_user, processing_request, procurement_item_factory diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 0dbf0b1b..38844fcb 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -1,6 +1,7 @@ "use client"; import Image from "next/image"; +import dynamic from "next/dynamic"; import { ArrowDown, ArrowUp, @@ -41,6 +42,10 @@ const themeIcons = { Monitor, } as const; +const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), { + ssr: false, +}); + const mobileNavCards = [ { value: "top", @@ -133,6 +138,7 @@ export default function SettingsPage() { const { canInstall, install, isInstalled, isRegistrationReady } = usePwa(); const { activeVerbCount, + avatarCropperImage, avatarInputRef, changingPassword, contactsDirty, @@ -167,6 +173,8 @@ export default function SettingsPage() { user, verbTypes, handleAvatarChange, + handleAvatarCropCancel, + handleAvatarCropComplete, handleChangePassword, handleDndToggle, handleLogoutOthers, @@ -201,8 +209,9 @@ export default function SettingsPage() { } return ( - -
+ <> + +
{/*
-
-
+ + + + {avatarCropperImage ? ( + + ) : null} + ); } diff --git a/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx b/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx index a2887106..e9976094 100644 --- a/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx +++ b/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; 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"; +import { Modal } from "@/components/ui"; import { cleanLinkRows, linkHref, toLinkRows } from "@/lib/procurementLinks"; import { getProcurementUnitLabel } from "@/lib/procurementUnits"; @@ -25,8 +26,8 @@ interface ProcurementRequestDetailContentProps { onUpdateItem?: (requestId: number, itemId: number, patch: Record) => void | Promise; onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise; onCancelItemIssue?: (requestId: number, itemId: number) => void | Promise; - onConfirmItemReceived?: (requestId: number, itemId: number) => void | Promise; - onCancelItemReceived?: (requestId: number, itemId: number) => void | Promise; + onConfirmItemReceived?: (requestId: number, itemId: number, receivedQuantity?: number) => void | Promise; + onCancelItemReceived?: (requestId: number, itemId: number, cancelQuantity?: number) => void | Promise; onMarkAllReceived?: (requestId: number) => void | Promise; itemCommentsMap?: Record; itemCommentDrafts?: Record; @@ -159,8 +160,8 @@ interface ProcurementItemCardProps { onUpdateItem?: (requestId: number, itemId: number, patch: Record) => void | Promise; onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise; onCancelItemIssue?: (requestId: number, itemId: number) => void | Promise; - onConfirmItemReceived?: (requestId: number, itemId: number) => void | Promise; - onCancelItemReceived?: (requestId: number, itemId: number) => void | Promise; + onConfirmItemReceived?: (requestId: number, itemId: number, receivedQuantity?: number) => void | Promise; + onCancelItemReceived?: (requestId: number, itemId: number, cancelQuantity?: number) => void | Promise; comments?: ProcurementItemComment[]; commentDraft?: string; commentsOpen?: boolean; @@ -197,12 +198,17 @@ function ProcurementItemCard({ }: ProcurementItemCardProps) { const [draft, setDraft] = useState(() => normalizeItemDraft(item)); const [processingOpen, setProcessingOpen] = useState(false); + const [receiptModalOpen, setReceiptModalOpen] = useState(false); + const [receiptQuantity, setReceiptQuantity] = useState("1"); + const [cancelReceiptModalOpen, setCancelReceiptModalOpen] = useState(false); + const [cancelReceiptQuantity, setCancelReceiptQuantity] = useState("1"); const links = Array.isArray(item.links) ? item.links.filter(Boolean) : []; const expectedDeliveryDates = toDateRows(item.expected_delivery_dates); const commentsTotal = item.comments_count ?? comments.length; const requestedQuantity = Math.max(0, Math.trunc(toNumber(item.quantity))); const orderedQuantity = effectiveOrderedQuantity(item, requestedQuantity); const receivedQuantity = effectiveReceivedQuantity(item, requestedQuantity); + const remainingQuantity = Math.max(0, requestedQuantity - receivedQuantity); const itemUnitLabel = getProcurementUnitLabel(item.unit); const status = item.execution_status || "pending"; const statusLabel = item.execution_status_display || executionStatusOptions.find((option) => option.value === status)?.label || "Не выполнено"; @@ -244,6 +250,79 @@ function ProcurementItemCard({ }); }; + const adjustReceiptQuantity = (delta: number) => { + setReceiptQuantity((current) => { + const parsed = toOptionalInteger(current) ?? 1; + return String(Math.min(remainingQuantity, Math.max(1, parsed + delta))); + }); + }; + + const adjustCancelReceiptQuantity = (delta: number) => { + setCancelReceiptQuantity((current) => { + const parsed = toOptionalInteger(current) ?? 1; + return String(Math.min(receivedQuantity, Math.max(1, parsed + delta))); + }); + }; + + const openReceiptModal = () => { + setReceiptQuantity(String(remainingQuantity)); + setReceiptModalOpen(true); + }; + + const openCancelReceiptModal = () => { + setCancelReceiptQuantity(String(receivedQuantity)); + setCancelReceiptModalOpen(true); + }; + + const confirmReceipt = (quantityReceivedNow: number) => { + const normalizedQuantity = Math.min(remainingQuantity, Math.max(1, Math.trunc(quantityReceivedNow))); + const nextReceivedQuantity = receivedQuantity + normalizedQuantity; + return onConfirmItemReceived?.(requestId, item.id, nextReceivedQuantity); + }; + + const handleConfirmReceiptClick = () => { + if (remainingQuantity > 1) { + openReceiptModal(); + return; + } + + void confirmReceipt(remainingQuantity); + }; + + const cancelReceipt = (quantityToCancel: number) => { + const normalizedQuantity = Math.min(receivedQuantity, Math.max(1, Math.trunc(quantityToCancel))); + return onCancelItemReceived?.(requestId, item.id, normalizedQuantity); + }; + + const handleCancelReceiptClick = () => { + if (receivedQuantity > 1) { + openCancelReceiptModal(); + return; + } + + void cancelReceipt(receivedQuantity); + }; + + const submitReceiptModal = async () => { + const parsedQuantity = toOptionalInteger(receiptQuantity); + if (!parsedQuantity || parsedQuantity < 1 || parsedQuantity > remainingQuantity) return; + + const result = await confirmReceipt(parsedQuantity); + if (result !== false) { + setReceiptModalOpen(false); + } + }; + + const submitCancelReceiptModal = async () => { + const parsedQuantity = toOptionalInteger(cancelReceiptQuantity); + if (!parsedQuantity || parsedQuantity < 1 || parsedQuantity > receivedQuantity) return; + + const result = await cancelReceipt(parsedQuantity); + if (result !== false) { + setCancelReceiptModalOpen(false); + } + }; + const saveItemProcessing = () => { return onUpdateItem?.(requestId, item.id, { execution_status: draft.execution_status, @@ -386,7 +465,7 @@ function ProcurementItemCard({ {canCancelReceived ? ( + + + } + > +
+
+

{item.name}

+

+ Получено {receivedQuantity}/{requestedQuantity} {itemUnitLabel}. Осталось {remainingQuantity} {itemUnitLabel}. +

+
+ +
+ + ) : null} + + {cancelReceiptModalOpen ? ( + setCancelReceiptModalOpen(false)} + title="Отменить получение" + size="sm" + footer={ +
+ + +
+ } + > +
+
+

{item.name}

+

+ Сейчас получено {receivedQuantity}/{requestedQuantity} {itemUnitLabel}. +

+
+ +
+
+ ) : null} ); } diff --git a/frontend/src/hooks/useProcurementPage.ts b/frontend/src/hooks/useProcurementPage.ts index e273508f..12f86f7c 100644 --- a/frontend/src/hooks/useProcurementPage.ts +++ b/frontend/src/hooks/useProcurementPage.ts @@ -1001,11 +1001,11 @@ export function useProcurementPage(user: User | null) { } }, [refreshAfterMutation]); - const handleConfirmItemReceived = useCallback(async (requestId: number, itemId: number) => { + const handleConfirmItemReceived = useCallback(async (requestId: number, itemId: number, receivedQuantity?: number) => { try { setBusyKey(`item-confirm-received-${itemId}`); setActionError(null); - await apiClient.confirmProcurementItemReceived(itemId); + await apiClient.confirmProcurementItemReceived(itemId, receivedQuantity); setActionSuccess("Получение позиции подтверждено."); await refreshAfterMutation(requestId); return true; @@ -1017,11 +1017,11 @@ export function useProcurementPage(user: User | null) { } }, [refreshAfterMutation]); - const handleCancelItemReceived = useCallback(async (requestId: number, itemId: number) => { + const handleCancelItemReceived = useCallback(async (requestId: number, itemId: number, cancelQuantity?: number) => { try { setBusyKey(`item-cancel-received-${itemId}`); setActionError(null); - await apiClient.cancelProcurementItemReceived(itemId); + await apiClient.cancelProcurementItemReceived(itemId, cancelQuantity); setActionSuccess("Получение позиции отменено."); await refreshAfterMutation(requestId); return true; diff --git a/frontend/src/hooks/useSettingsPage.ts b/frontend/src/hooks/useSettingsPage.ts index 25a295b4..1c0cd865 100644 --- a/frontend/src/hooks/useSettingsPage.ts +++ b/frontend/src/hooks/useSettingsPage.ts @@ -80,6 +80,28 @@ const preferencesSignature = (value: NotificationPreferences | null) => { const isApiNotFoundError = (error: unknown) => error instanceof Error && error.message.includes("API Error: 404"); +function buildCroppedAvatarFileName(fileName: string) { + const baseName = fileName.trim().replace(/\.[^.]+$/, "") || "avatar"; + return `${baseName}.jpg`; +} + +function dataUrlToFile(dataUrl: string, fileName: string) { + const [header, base64Data] = dataUrl.split(","); + if (!base64Data) { + throw new Error("Invalid image data URL"); + } + + const mime = header.match(/^data:(.*?);base64$/)?.[1] || "image/jpeg"; + const binary = window.atob(base64Data); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return new File([bytes], fileName, { type: mime }); +} + export function settingsInitials(firstName?: string, lastName?: string) { return `${lastName?.[0] || ""}${firstName?.[0] || ""}`.trim() || "П"; } @@ -138,6 +160,8 @@ export function useSettingsPage() { }); const [avatarFile, setAvatarFile] = useState(null); const [avatarPreview, setAvatarPreview] = useState(null); + const [avatarCropperImage, setAvatarCropperImage] = useState(null); + const [avatarCropperFileName, setAvatarCropperFileName] = useState("avatar.jpg"); const [savingProfile, setSavingProfile] = useState(false); const [savingContacts, setSavingContacts] = useState(false); @@ -356,6 +380,10 @@ export function useSettingsPage() { const handleAvatarChange = (file: File | null) => { if (!file) return; + if (avatarInputRef.current) { + avatarInputRef.current.value = ""; + } + if (!file.type.startsWith("image/")) { toast.error("Можно загрузить только изображение"); return; @@ -365,14 +393,37 @@ export function useSettingsPage() { return; } - setAvatarFile(file); const reader = new FileReader(); reader.onload = () => { - setAvatarPreview(typeof reader.result === "string" ? reader.result : null); + if (typeof reader.result !== "string") { + toast.error("Не удалось прочитать изображение"); + return; + } + + setAvatarCropperFileName(buildCroppedAvatarFileName(file.name)); + setAvatarCropperImage(reader.result); }; reader.readAsDataURL(file); }; + const handleAvatarCropComplete = (croppedImage: string) => { + try { + const croppedFile = dataUrlToFile(croppedImage, avatarCropperFileName); + setAvatarFile(croppedFile); + setAvatarPreview(croppedImage); + setAvatarCropperImage(null); + setAvatarCropperFileName("avatar.jpg"); + } catch (error) { + console.error(error); + toast.error("Не удалось обработать обрезанное фото"); + } + }; + + const handleAvatarCropCancel = () => { + setAvatarCropperImage(null); + setAvatarCropperFileName("avatar.jpg"); + }; + const handlePushToggle = async (enabled: boolean) => { setPreferences((current) => ({ ...current, push_enabled: enabled })); @@ -617,8 +668,11 @@ export function useSettingsPage() { unreadVerbCount, user, verbTypes, + avatarCropperImage, avatarPreview, handleAvatarChange, + handleAvatarCropCancel, + handleAvatarCropComplete, handleChangePassword, handleDndToggle, handleLogoutOthers, diff --git a/frontend/src/lib/api/procurement.ts b/frontend/src/lib/api/procurement.ts index 3e627b8b..2da8a89e 100644 --- a/frontend/src/lib/api/procurement.ts +++ b/frontend/src/lib/api/procurement.ts @@ -34,8 +34,18 @@ export function createProcurementApi(request: RequestFn) { 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 || '' }) }), cancelProcurementItemIssue: (id: number) => request(`/api/v1/procurement/items/${id}/cancel_issue/`, { method: 'POST' }), - confirmProcurementItemReceived: (id: number) => request(`/api/v1/procurement/items/${id}/confirm_received/`, { method: 'POST' }), - cancelProcurementItemReceived: (id: number) => request(`/api/v1/procurement/items/${id}/cancel_received/`, { method: 'POST' }), + confirmProcurementItemReceived: (id: number, receivedQuantity?: number) => + request(`/api/v1/procurement/items/${id}/confirm_received/`, { + method: 'POST', + headers: receivedQuantity === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: receivedQuantity === undefined ? undefined : JSON.stringify({ received_quantity: receivedQuantity }), + }), + cancelProcurementItemReceived: (id: number, cancelQuantity?: number) => + request(`/api/v1/procurement/items/${id}/cancel_received/`, { + method: 'POST', + headers: cancelQuantity === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: cancelQuantity === undefined ? undefined : JSON.stringify({ cancel_quantity: cancelQuantity }), + }), deleteProcurementItem: (id: number): Promise => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }), getProcurementItemComments: (itemId: number) => request(`/api/v1/procurement/items/${itemId}/comments/`), addProcurementItemComment: (itemId: number, text: string) =>