From 520abbf4d022c8b073b3b138c06fa0c6ad4fdf50 Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 28 May 2026 14:13:14 +0300 Subject: [PATCH] procurement: refine item fulfillment workflow --- backend/api/v1/procurement/views.py | 206 +++++++++++++--- .../procurement/test_procurement_requests.py | 223 ++++++++++++++++++ frontend/src/app/globals.css | 11 + frontend/src/app/procurement/page.tsx | 68 +++++- .../ProcurementRequestDetailContent.tsx | 113 +++++++-- .../requests/RequestDetailModal.tsx | 6 +- .../components/requests/RequestListItem.tsx | 4 +- .../components/requests/SwipeApprovalMode.tsx | 4 +- frontend/src/hooks/useProcurementPage.ts | 76 +++++- frontend/src/lib/api/procurement.ts | 3 + 10 files changed, 645 insertions(+), 69 deletions(-) diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 7edc5759..23c6e613 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -941,12 +941,73 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "comments", "delete_comment", "report_issue", + "confirm_received", + "cancel_received", + "cancel_issue", ]: permission_classes = [permissions.IsAuthenticated] else: permission_classes = [CanManageProcurementRequest] return [permission() for permission in permission_classes] + def _can_use_item_quick_action(self, user, procurement_request): + return ( + procurement_request.requestor_id == user.id + or self._can_process_request_items(user, procurement_request) + ) + + def _validate_item_quick_action(self, user, procurement_request): + if not self._can_use_item_quick_action(user, procurement_request): + return Response( + {"detail": "Нет прав изменить эту позицию"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if procurement_request.status in [ + ProcurementStatus.PENDING, + ProcurementStatus.CANCELLED, + ProcurementStatus.REJECTED, + ]: + return Response( + { + "detail": ( + "Нельзя изменить позицию в заявке на согласовании, " + "в отменённой или отклонённой заявке" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + return None + + def _workflow_status_from_quantities(self, item): + received_quantity = item.received_quantity or 0 + ordered_quantity = item.ordered_quantity or 0 + + if received_quantity > 0: + return ( + ProcurementItemExecutionStatus.RECEIVED + if received_quantity >= item.quantity + else ProcurementItemExecutionStatus.ORDERED + ) + if ordered_quantity > 0: + return ProcurementItemExecutionStatus.ORDERED + return ProcurementItemExecutionStatus.PENDING + + def _reopen_completed_request(self, procurement_request, user): + if procurement_request.status != ProcurementStatus.COMPLETED: + return + + 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"] + ) + def perform_create(self, serializer): """Проверяем права при создании позиции. @@ -1064,44 +1125,17 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): 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.PENDING, - ProcurementStatus.CANCELLED, - ProcurementStatus.REJECTED, - ]: - return Response( - { - "detail": ( - "Нельзя изменить позицию в заявке на согласовании, " - "в отменённой или отклонённой заявке" - ) - }, - status=status.HTTP_400_BAD_REQUEST, - ) + error_response = self._validate_item_quick_action( + user, + procurement_request, + ) + if error_response is not None: + return error_response 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"] - ) + self._reopen_completed_request(procurement_request, user) text = request.data.get("text", "") if isinstance(text, str): @@ -1130,6 +1164,110 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(item) return Response(serializer.data) + @action(detail=True, methods=["post"]) + def cancel_received(self, request, pk=None): + """Отменить подтверждение получения позиции.""" + item = self.get_object() + procurement_request = item.request + user = request.user + + error_response = self._validate_item_quick_action( + user, + procurement_request, + ) + if error_response is not None: + return error_response + + if (item.received_quantity or 0) <= 0: + return Response( + {"detail": "По позиции нет подтверждённого получения"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + item.received_quantity = 0 + item.execution_status = self._workflow_status_from_quantities(item) + item.save(update_fields=["execution_status", "received_quantity"]) + self._reopen_completed_request(procurement_request, user) + notify_item_updated( + item, + actor=user, + changed_fields=["execution_status", "received_quantity"], + ) + + item.refresh_from_db() + serializer = self.get_serializer(item) + return Response(serializer.data) + + @action(detail=True, methods=["post"]) + def cancel_issue(self, request, pk=None): + """Снять отметку брака/перезаказа с позиции.""" + item = self.get_object() + procurement_request = item.request + user = request.user + + error_response = self._validate_item_quick_action( + user, + procurement_request, + ) + if error_response is not None: + return error_response + + if item.execution_status != ProcurementItemExecutionStatus.DEFECTIVE: + return Response( + {"detail": "Позиция не отмечена как брак"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + item.execution_status = self._workflow_status_from_quantities(item) + item.save(update_fields=["execution_status"]) + notify_item_updated( + item, + actor=user, + changed_fields=["execution_status"], + ) + + item.refresh_from_db() + serializer = self.get_serializer(item) + return Response(serializer.data) + + @action(detail=True, methods=["post"]) + def confirm_received(self, request, pk=None): + """Подтвердить получение позиции.""" + item = self.get_object() + procurement_request = item.request + user = request.user + + error_response = self._validate_item_quick_action( + user, + procurement_request, + ) + if error_response is not None: + return error_response + + item.execution_status = ProcurementItemExecutionStatus.RECEIVED + item.ordered_quantity = item.quantity + item.received_quantity = item.quantity + item.save( + update_fields=[ + "execution_status", + "ordered_quantity", + "received_quantity", + ] + ) + notify_item_updated( + item, + actor=user, + changed_fields=[ + "execution_status", + "ordered_quantity", + "received_quantity", + ], + ) + + item.refresh_from_db() + serializer = self.get_serializer(item) + return Response(serializer.data) + def perform_destroy(self, instance): """Проверяем права при удалении позиции.""" self._check_item_edit_permission(instance) diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index c437aa8f..6154e497 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -1447,6 +1447,14 @@ class TestProcessingDepartmentWorkflow: 'api:v1:procurement:procurementitem-report-issue', kwargs={'pk': item.id}, ) + confirm_received_url = reverse( + 'api:v1:procurement:procurementitem-confirm-received', + kwargs={'pk': item.id}, + ) + cancel_received_url = reverse( + 'api:v1:procurement:procurementitem-cancel-received', + kwargs={'pk': item.id}, + ) mark_all_url = reverse( 'api:v1:procurement:procurementrequest-mark-all-received', kwargs={'pk': processing_request.id}, @@ -1470,12 +1478,16 @@ class TestProcessingDepartmentWorkflow: {'text': 'Брак'}, format='json', ) + confirm_received_response = api_client.post(confirm_received_url) + cancel_received_response = api_client.post(cancel_received_url) mark_all_response = api_client.post(mark_all_url) complete_response = api_client.post(complete_url) start_response = api_client.post(start_url) assert item_response.status_code == status.HTTP_403_FORBIDDEN assert issue_response.status_code == status.HTTP_400_BAD_REQUEST + assert confirm_received_response.status_code == status.HTTP_400_BAD_REQUEST + assert cancel_received_response.status_code == status.HTTP_400_BAD_REQUEST assert mark_all_response.status_code == status.HTTP_400_BAD_REQUEST assert complete_response.status_code == status.HTTP_403_FORBIDDEN assert start_response.status_code == status.HTTP_400_BAD_REQUEST @@ -1886,6 +1898,22 @@ class TestProcessingDepartmentWorkflow: assert [comment.content for comment in get_comments(item)] == [ "Обнаружен брак" ] + + api_client.force_authenticate(user=supply_user) + my_work_url = reverse( + 'api:v1:procurement:procurementrequest-list' + ) + my_work_response = api_client.get( + my_work_url, + {'scope': 'my_work'}, + ) + assert my_work_response.status_code == status.HTTP_200_OK + my_work_item = next( + item for item in my_work_response.data["results"] + if item["id"] == processing_request.id + ) + assert my_work_item["status"] == ProcurementStatus.IN_PROGRESS + issue_recipients = [ notification["recipient"].email for notification in sent @@ -1893,6 +1921,201 @@ class TestProcessingDepartmentWorkflow: ] assert supply_user.email in issue_recipients + def test_executor_issue_reopens_request_for_requestor_and_notifies( + 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, + ordered_quantity=1, + received_quantity=1, + ) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.COMPLETED + processing_request.completed_at = timezone.now() + processing_request.fulfillment_status = ( + ProcurementFulfillmentStatus.COMPLETED + ) + processing_request.save() + sent.clear() + + api_client.force_authenticate(user=supply_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.status == ProcurementStatus.IN_PROGRESS + assert processing_request.completed_at is None + + api_client.force_authenticate(user=user) + mine_url = reverse('api:v1:procurement:procurementrequest-list') + mine_response = api_client.get(mine_url, {'scope': 'mine'}) + assert mine_response.status_code == status.HTTP_200_OK + mine_item = next( + item for item in mine_response.data["results"] + if item["id"] == processing_request.id + ) + assert mine_item["status"] == ProcurementStatus.IN_PROGRESS + + item_updated_recipients = [ + notification["recipient"].email + for notification in sent + if notification["verb"] == "procurement_item_updated" + ] + in_progress_recipients = [ + notification["recipient"].email + for notification in sent + if notification["verb"] == "procurement_in_progress" + ] + assert user.email in item_updated_recipients + assert user.email in in_progress_recipients + + def test_requestor_can_confirm_item_received( + self, api_client, user, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=2, + ordered_quantity=1, + 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, {}, format='json') + + assert response.status_code == status.HTTP_200_OK + item.refresh_from_db() + processing_request.refresh_from_db() + assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED + assert item.ordered_quantity == item.quantity + assert item.received_quantity == item.quantity + assert ( + processing_request.fulfillment_status + == ProcurementFulfillmentStatus.COMPLETED + ) + assert processing_request.status == ProcurementStatus.COMPLETED + assert processing_request.completed_at is not None + + def test_requestor_can_cancel_item_issue( + self, api_client, user, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=1, + ordered_quantity=1, + received_quantity=1, + execution_status=ProcurementItemExecutionStatus.DEFECTIVE, + ) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.fulfillment_status = ProcurementFulfillmentStatus.ISSUES + processing_request.save() + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementitem-cancel-issue', + kwargs={'pk': item.id}, + ) + + response = api_client.post(url, {}, format='json') + + assert response.status_code == status.HTTP_200_OK + item.refresh_from_db() + processing_request.refresh_from_db() + assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED + assert ( + processing_request.fulfillment_status + == ProcurementFulfillmentStatus.COMPLETED + ) + assert processing_request.status == ProcurementStatus.COMPLETED + + def test_requestor_can_cancel_item_received_and_reopens_completed_request( + 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=2, + execution_status=ProcurementItemExecutionStatus.RECEIVED, + ) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.COMPLETED + processing_request.completed_at = timezone.now() + processing_request.fulfillment_status = ProcurementFulfillmentStatus.COMPLETED + 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, {}, 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 == item.quantity + assert item.received_quantity == 0 + assert ( + processing_request.fulfillment_status + == ProcurementFulfillmentStatus.ORDERED + ) + assert processing_request.status == ProcurementStatus.IN_PROGRESS + assert processing_request.completed_at is None + + def test_outsider_cannot_confirm_item_received( + self, api_client, outsider, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + execution_status=ProcurementItemExecutionStatus.ORDERED, + ordered_quantity=1, + ) + + api_client.force_authenticate(user=outsider) + url = reverse( + 'api:v1:procurement:procurementitem-confirm-received', + kwargs={'pk': item.id}, + ) + + response = api_client.post(url, {}, format='json') + + assert response.status_code == status.HTTP_403_FORBIDDEN + def test_list_exposes_procurement_progress_summary( self, api_client, supply_user, processing_request, procurement_item_factory diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 0e968166..cbbcace6 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -447,6 +447,17 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { border-color: color-mix(in srgb, var(--danger-foreground) 24%, var(--border-strong)); } +.app-action-approve { + border: 1px solid color-mix(in srgb, var(--danger-foreground) 18%, var(--border-strong)); + background: var(--surface-elevated); + color: #10b981; +} + +.app-action-approve:hover { + background: var(--success-soft); + border-color: color-mix(in srgb, #10b981 24%, var(--border-strong)); +} + .app-selected { border: 1px solid color-mix(in srgb, var(--accent-primary) 22%, var(--border-subtle)); background: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-primary)); diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index cd64298d..0aedeb20 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -8,7 +8,7 @@ import Link from "next/link"; import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent"; import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel"; import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel"; -import type { ProcurementRequest, ProcurementStatus, UrgencyLevel } from "@/types/api"; +import type { ProcurementFulfillmentStatus, ProcurementRequest, ProcurementStatus, UrgencyLevel } from "@/types/api"; import { AlertTriangle, ArrowUpDown, @@ -59,6 +59,18 @@ const getStatusMeta = (status?: string | null) => ( statusMeta[String(status || "").toLowerCase() as ProcurementStatus] ?? defaultStatusMeta ); +const fulfillmentStatusClasses: Record = { + pending: "app-badge", + partially_ordered: "app-feedback-warning", + ordered: "app-feedback-success", + partially_received: "app-feedback-warning", + completed: "app-feedback-success", + issues: "app-feedback-warning", +}; +const getFulfillmentStatusClass = (status?: string | null) => ( + fulfillmentStatusClasses[String(status || "").toLowerCase() as ProcurementFulfillmentStatus] ?? "app-badge" +); + const urgencyMeta: Record = { low: { label: "Низкая", cls: "text-gray-500" }, medium: { label: "Средняя", cls: "text-sky-600" }, @@ -211,10 +223,10 @@ function ProcurementRequestActionButtons({ onClick={() => onApprove(request.id)} disabled={busyKey === `approve-${request.id}`} title="Одобрить" - className={buttonClass("app-feedback-success")} + className={buttonClass("app-action-approve")} > - - {label("Одобрить")} + + {showLabels ? Одобрить : null}