diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 93ff4a08..d81f136d 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -348,6 +348,7 @@ class ProcurementRequestListSerializer( ) comments_count = serializers.IntegerField(read_only=True, default=0) can_current_user_approve = serializers.SerializerMethodField() + can_current_user_submit_for_approval = serializers.SerializerMethodField() @extend_schema_field(serializers.BooleanField()) def get_can_current_user_approve(self, obj): @@ -355,6 +356,15 @@ class ProcurementRequestListSerializer( user = getattr(request, "user", None) return ProcurementApprovalResolver.user_can_approve(user, obj) + @extend_schema_field(serializers.BooleanField()) + def get_can_current_user_submit_for_approval(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return ProcurementApprovalResolver.user_can_submit_for_approval( + user, + obj, + ) + class Meta: model = ProcurementRequest fields = [ @@ -386,6 +396,7 @@ class ProcurementRequestListSerializer( "total_received_quantity", "comments_count", "can_current_user_approve", + "can_current_user_submit_for_approval", "created_at", "submitted_at", "started_at", @@ -415,6 +426,7 @@ class ProcurementRequestListSerializer( "total_received_quantity", "comments_count", "can_current_user_approve", + "can_current_user_submit_for_approval", ] def update(self, instance, validated_data): @@ -470,6 +482,7 @@ class ProcurementRequestDetailSerializer( is_editable = serializers.BooleanField(read_only=True) comments_count = serializers.IntegerField(read_only=True, default=0) can_current_user_approve = serializers.SerializerMethodField() + can_current_user_submit_for_approval = serializers.SerializerMethodField() @extend_schema_field(serializers.BooleanField()) def get_can_current_user_approve(self, obj): @@ -477,6 +490,15 @@ class ProcurementRequestDetailSerializer( user = getattr(request, "user", None) return ProcurementApprovalResolver.user_can_approve(user, obj) + @extend_schema_field(serializers.BooleanField()) + def get_can_current_user_submit_for_approval(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return ProcurementApprovalResolver.user_can_submit_for_approval( + user, + obj, + ) + class Meta: model = ProcurementRequest fields = [ @@ -514,6 +536,7 @@ class ProcurementRequestDetailSerializer( "is_editable", "comments_count", "can_current_user_approve", + "can_current_user_submit_for_approval", "created_at", "updated_at", "submitted_at", @@ -543,6 +566,7 @@ class ProcurementRequestDetailSerializer( "fulfillment_status_display", "comments_count", "can_current_user_approve", + "can_current_user_submit_for_approval", ] diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 0c1bbc82..100b90b2 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -105,6 +105,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): """Выбрать права доступа в зависимости от действия.""" if self.action in ["approve", "reject"]: permission_classes = [CanApproveProcurementRequest] + elif self.action == "submit": + permission_classes = [permissions.IsAuthenticated] else: # Для create, update, delete и остальных действий permission_classes = [CanManageProcurementRequest] @@ -331,25 +333,30 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): procurement_request = self.get_object() - if procurement_request.processing_department_id: + if ( + procurement_request.status == ProcurementStatus.PENDING + or procurement_request.approvals.filter( + status=ApprovalStatus.PENDING, + ).exists() + ): return Response( - { - "error": ( - "Заявка уже направлена в отдел-исполнитель " - "и не требует согласования" - ) - }, + {"error": "Заявка уже находится на согласовании"}, status=status.HTTP_400_BAD_REQUEST, ) - # Проверки - if procurement_request.requestor != request.user: + if not ProcurementApprovalResolver.user_can_submit_for_approval( + request.user, + procurement_request, + ): return Response( - {"error": "Вы не можете отправить чужую заявку"}, + {"error": "Вы не можете отправить эту заявку на согласование"}, status=status.HTTP_403_FORBIDDEN, ) - if not procurement_request.is_editable: + if ( + not procurement_request.processing_department_id + and not procurement_request.is_editable + ): return Response( {"error": "Заявка уже отправлена на согласование"}, status=status.HTTP_400_BAD_REQUEST, @@ -479,12 +486,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): ).count() if pending_approvals == 0: - # Все одобрили - меняем статус заявки - # Сигнал post_save(ProcurementRequest) отправит уведомление - # requestor'у - procurement_request.status = ProcurementStatus.APPROVED + if procurement_request.processing_department_id: + procurement_request.status = ( + ProcurementStatus.IN_PROGRESS + if procurement_request.executor_id + else ProcurementStatus.WAITING + ) + else: + procurement_request.status = ProcurementStatus.APPROVED procurement_request._notification_actor = request.user - procurement_request.save() + procurement_request.save(update_fields=["status", "updated_at"]) procurement_request.refresh_from_db() @@ -930,6 +941,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): user = self.request.user if procurement_request.status in [ + ProcurementStatus.PENDING, ProcurementStatus.CANCELLED, ProcurementStatus.REJECTED, ]: @@ -970,6 +982,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): procurement_request = item.request if procurement_request.status in [ + ProcurementStatus.PENDING, ProcurementStatus.CANCELLED, ProcurementStatus.REJECTED, ]: @@ -1040,11 +1053,17 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): ) if procurement_request.status in [ + ProcurementStatus.PENDING, ProcurementStatus.CANCELLED, ProcurementStatus.REJECTED, ]: return Response( - {"detail": "Нельзя изменить позицию в отменённой или отклонённой заявке"}, + { + "detail": ( + "Нельзя изменить позицию в заявке на согласовании, " + "в отменённой или отклонённой заявке" + ) + }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/backend/procurement/notifications/__init__.py b/backend/procurement/notifications/__init__.py index 24858bc7..7ad83d91 100644 --- a/backend/procurement/notifications/__init__.py +++ b/backend/procurement/notifications/__init__.py @@ -36,6 +36,7 @@ from .handlers import ( notify_request_rejected, notify_request_completed, notify_request_in_progress, + notify_request_returned_to_processing, notify_request_cancelled, notify_stage_approved, notify_stage_rejected, @@ -65,6 +66,7 @@ __all__ = [ 'notify_request_rejected', 'notify_request_completed', 'notify_request_in_progress', + 'notify_request_returned_to_processing', 'notify_request_cancelled', 'notify_stage_approved', 'notify_stage_rejected', diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py index c2359141..9466666a 100644 --- a/backend/procurement/notifications/handlers.py +++ b/backend/procurement/notifications/handlers.py @@ -312,6 +312,39 @@ def notify_request_approved(request): ) +def notify_request_returned_to_processing(request): + """Уведомить исполнителя или отдел, что согласованная заявка вернулась.""" + if not request.processing_department_id: + return + + actor = _request_actor(request) + title = "Заявка вернулась в обработку" + description = ( + f'Заявка "{request.title}" согласована и снова доступна ' + f'для обработки.' + ) + recipients = [] + if request.executor_id: + recipients.append(request.executor) + else: + recipients.extend(_processing_department_recipients(request)) + + _notify_many( + recipients, + verb=NotificationVerbs.APPROVED, + procurement_request=request, + title=title, + description=description, + actor=actor, + extra_data={ + "old_status": getattr(request, "_original_status", None), + "new_status": request.status, + "processing_department_id": request.processing_department_id, + "executor_id": request.executor_id, + }, + ) + + def notify_request_rejected(request): """ Уведомить об отклонении заявки. diff --git a/backend/procurement/notifications/signals.py b/backend/procurement/notifications/signals.py index 6eb7a743..6d28a29f 100644 --- a/backend/procurement/notifications/signals.py +++ b/backend/procurement/notifications/signals.py @@ -23,6 +23,7 @@ from .handlers import ( notify_request_rejected, notify_request_completed, notify_request_in_progress, + notify_request_returned_to_processing, notify_request_cancelled, notify_stage_approved, notify_stage_rejected, @@ -85,6 +86,18 @@ def _handle_status_change(request, new_status): # Заявка отправлена на согласование notify_approvers(request) + elif ( + getattr(request, "_original_status", None) == ProcurementStatus.PENDING + and request.processing_department_id + and new_status in { + ProcurementStatus.WAITING, + ProcurementStatus.IN_PROGRESS, + } + ): + # Адресная заявка после согласования возвращается в тот же отдел. + notify_request_approved(request) + notify_request_returned_to_processing(request) + elif new_status == ProcurementStatus.APPROVED: # Заявка одобрена notify_request_approved(request) diff --git a/backend/procurement/services.py b/backend/procurement/services.py index 2581ea9f..d9f50592 100644 --- a/backend/procurement/services.py +++ b/backend/procurement/services.py @@ -9,6 +9,7 @@ from django.utils import timezone from procurement.constants import ( ApprovalStatus, + ProcurementStatus, get_default_approval_step_name, ) @@ -93,6 +94,60 @@ class ProcurementApprovalResolver: через Department.head. """ + @classmethod + def user_is_processing_department_member(cls, user, procurement_request): + if not procurement_request.processing_department_id: + return False + + from employees.models import EmployeeDepartment + + return ( + EmployeeDepartment.objects.filter( + employee_id=user.id, + department_id=procurement_request.processing_department_id, + is_active=True, + ).exists() + or procurement_request.processing_department.head_id == user.id + ) + + @classmethod + def user_can_submit_for_approval(cls, user, procurement_request) -> bool: + if not user or not user.is_authenticated: + return False + + if ( + procurement_request.status == ProcurementStatus.PENDING + or procurement_request.approvals.filter( + status=ApprovalStatus.PENDING, + ).exists() + ): + return False + + if procurement_request.processing_department_id: + if procurement_request.status not in { + ProcurementStatus.WAITING, + ProcurementStatus.IN_PROGRESS, + }: + return False + if ( + user.is_superuser + or user.is_staff + or user.has_perm("procurement.change_procurementrequest") + or user.has_perm("procurement.execute_procurement") + ): + return True + if procurement_request.executor_id == user.id: + return True + return cls.user_is_processing_department_member( + user, + procurement_request, + ) + + return ( + procurement_request.requestor_id == user.id + and procurement_request.is_editable + ) + @classmethod def resolve_approval_step(cls, procurement_request, route): from .models import ApprovalRoute diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 9578bba7..fbe79a6b 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -982,6 +982,221 @@ class TestProcessingDepartmentWorkflow: ) assert edit_response.status_code == status.HTTP_403_FORBIDDEN + @pytest.fixture + def department_head_approval_route(self, department_head): + return ApprovalRoute.objects.create( + priority=HEAD_PRIORITY, + resolver_type=ApprovalRoute.ResolverType.DEPARTMENT_HEAD, + ) + + def test_processing_department_user_can_submit_waiting_request_for_approval( + self, api_client, supply_user, processing_request, + procurement_item_factory, department_head_approval_route + ): + procurement_item_factory(request=processing_request) + api_client.force_authenticate(user=supply_user) + url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_200_OK + processing_request.refresh_from_db() + assert processing_request.status == ProcurementStatus.PENDING + assert processing_request.processing_department_id is not None + assert processing_request.approvals.count() == 1 + assert response.data["can_current_user_submit_for_approval"] is False + + def test_processing_department_requestor_cannot_submit_for_approval( + self, api_client, user, processing_request, + procurement_item_factory, department_head_approval_route + ): + procurement_item_factory(request=processing_request) + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + processing_request.refresh_from_db() + assert processing_request.status == ProcurementStatus.WAITING + assert processing_request.approvals.count() == 0 + + def test_processing_department_request_cannot_be_submitted_twice_while_pending( + self, api_client, supply_user, processing_request, + procurement_item_factory, department_head_approval_route + ): + procurement_item_factory(request=processing_request) + processing_request.status = ProcurementStatus.PENDING + processing_request.save(update_fields=["status", "updated_at"]) + Approval.objects.create( + request=processing_request, + approver=processing_request.department.head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + ) + + api_client.force_authenticate(user=supply_user) + url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_final_approve_returns_processing_request_to_waiting_without_executor( + self, api_client, supply_user, department_head, processing_request, + procurement_item_factory, department_head_approval_route + ): + procurement_item_factory(request=processing_request) + submit_url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': processing_request.id}, + ) + approve_url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': processing_request.id}, + ) + + api_client.force_authenticate(user=supply_user) + submit_response = api_client.post(submit_url) + assert submit_response.status_code == status.HTTP_200_OK + + api_client.force_authenticate(user=department_head) + approve_response = api_client.post(approve_url, {'comment': 'Можно'}) + + assert approve_response.status_code == status.HTTP_200_OK + processing_request.refresh_from_db() + assert processing_request.status == ProcurementStatus.WAITING + assert processing_request.executor is None + assert processing_request.processing_department is not None + + def test_final_approve_returns_processing_request_to_executor_work( + self, api_client, supply_user, department_head, processing_request, + procurement_item_factory, department_head_approval_route + ): + procurement_item_factory(request=processing_request) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.started_at = timezone.now() + processing_request.save( + update_fields=[ + "executor", + "status", + "started_at", + "updated_at", + ] + ) + submit_url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': processing_request.id}, + ) + approve_url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': processing_request.id}, + ) + + api_client.force_authenticate(user=supply_user) + submit_response = api_client.post(submit_url) + assert submit_response.status_code == status.HTTP_200_OK + + api_client.force_authenticate(user=department_head) + approve_response = api_client.post(approve_url, {'comment': 'Можно'}) + + assert approve_response.status_code == status.HTTP_200_OK + processing_request.refresh_from_db() + assert processing_request.status == ProcurementStatus.IN_PROGRESS + assert processing_request.executor == supply_user + assert processing_request.processing_department is not None + + def test_reject_processing_request_stops_purchase( + self, api_client, supply_user, department_head, processing_request, + procurement_item_factory, department_head_approval_route + ): + procurement_item_factory(request=processing_request) + submit_url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': processing_request.id}, + ) + reject_url = reverse( + 'api:v1:procurement:procurementrequest-reject', + kwargs={'pk': processing_request.id}, + ) + + api_client.force_authenticate(user=supply_user) + submit_response = api_client.post(submit_url) + assert submit_response.status_code == status.HTTP_200_OK + + api_client.force_authenticate(user=department_head) + reject_response = api_client.post(reject_url, {'comment': 'Нельзя'}) + + assert reject_response.status_code == status.HTTP_200_OK + processing_request.refresh_from_db() + assert processing_request.status == ProcurementStatus.REJECTED + + def test_pending_processing_request_blocks_execution_actions( + self, api_client, supply_user, processing_request, + procurement_item_factory, department_head_approval_route + ): + item = procurement_item_factory(request=processing_request) + processing_request.status = ProcurementStatus.PENDING + processing_request.save(update_fields=["status", "updated_at"]) + Approval.objects.create( + request=processing_request, + approver=processing_request.department.head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + ) + api_client.force_authenticate(user=supply_user) + + item_url = reverse( + 'api:v1:procurement:procurementitem-detail', + kwargs={'pk': item.id}, + ) + report_issue_url = reverse( + 'api:v1:procurement:procurementitem-report-issue', + kwargs={'pk': item.id}, + ) + mark_all_url = reverse( + 'api:v1:procurement:procurementrequest-mark-all-received', + kwargs={'pk': processing_request.id}, + ) + complete_url = reverse( + 'api:v1:procurement:procurementrequest-complete', + kwargs={'pk': processing_request.id}, + ) + start_url = reverse( + 'api:v1:procurement:procurementrequest-start-work', + kwargs={'pk': processing_request.id}, + ) + + item_response = api_client.patch( + item_url, + {'execution_status': ProcurementItemExecutionStatus.RECEIVED}, + format='json', + ) + issue_response = api_client.post( + report_issue_url, + {'text': 'Брак'}, + format='json', + ) + 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 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 + def test_item_status_recalculates_request_fulfillment_status( self, api_client, supply_user, processing_request, procurement_item_factory diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 72543029..29819cc0 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -155,6 +155,7 @@ function ProcurementRequestActionButtons({ const canStartWork = (status === "approved" || status === "waiting") && !request.executor; const isInProgress = status === "in_progress"; const canApproveThis = Boolean(request.can_current_user_approve); + const canSubmitForApproval = Boolean(request.can_current_user_submit_for_approval); const buttonClass = (variantClass: string) => `${variantClass} inline-flex h-9 items-center justify-center rounded-lg disabled:opacity-60 ${ showLabels ? "gap-1.5 px-3 text-xs font-medium" : "w-9" @@ -163,16 +164,16 @@ function ProcurementRequestActionButtons({ return (
- {isDraft && isAuthor ? ( + {canSubmitForApproval ? ( ) : null} {showSecondaryActions && isDraft && isAuthor ? ( @@ -310,7 +311,6 @@ export default function ProcurementPage() { handleLoadMore, handleReject, handleSave, - handleSaveAndSubmit, handleStart, handleSubmit, isFinal, @@ -1247,22 +1247,10 @@ export default function ProcurementPage() { type="button" onClick={handleSave} disabled={busyKey === "save" || busyKey === "save-submit"} - className={`${modalMode === "create" && form.requireApproval ? "app-action-secondary" : "app-action-primary"} rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60`} + className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60" > - {modalMode === "create" - ? form.requireApproval ? "Создать черновик" : "Создать и направить в отдел" - : "Сохранить"} + {modalMode === "create" ? "Создать заявку" : "Сохранить"} - {modalMode === "create" && form.requireApproval ? ( - - ) : null}
}> @@ -1283,36 +1271,17 @@ export default function ProcurementPage() { {/* Описание */}