diff --git a/backend/api/v1/procurement/permissions.py b/backend/api/v1/procurement/permissions.py index 96836c55..26d2059c 100644 --- a/backend/api/v1/procurement/permissions.py +++ b/backend/api/v1/procurement/permissions.py @@ -230,7 +230,7 @@ class CanManageProcurementRequest(permissions.BasePermission): if action == "complete": return obj.executor == user - if action == "mark_all_received": + if action in ("mark_all_received", "notify_arrival"): if obj.executor == user: return True if obj.processing_department_id: diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 5c3b5394..a01d5f3b 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -32,6 +32,7 @@ from procurement.notifications.handlers import ( notify_item_comment, notify_item_issue_reported, notify_item_updated, + notify_request_arrival, notify_request_comment, ) from communications import comments_helpers @@ -897,6 +898,31 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(procurement_request) return Response(serializer.data) + @action(detail=True, methods=["post"]) + def notify_arrival(self, request, pk=None): + """Вручную уведомить заказчика о поступлении по заявке.""" + procurement_request = self.get_object() + self.check_object_permissions(request, procurement_request) + + if procurement_request.status not in [ + ProcurementStatus.WAITING, + ProcurementStatus.IN_PROGRESS, + ]: + return Response( + { + "error": ( + "Уведомить о поступлении можно только по ожидающей " + "или взятой в работу заявке" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + notify_request_arrival(procurement_request, actor=request.user) + + serializer = self.get_serializer(procurement_request) + return Response(serializer.data) + @action(detail=True, methods=["post"]) def set_viewed(self, request, pk=None): """Установить отметку просмотра заявки.""" diff --git a/backend/procurement/notifications/__init__.py b/backend/procurement/notifications/__init__.py index d63a5d02..0c36e608 100644 --- a/backend/procurement/notifications/__init__.py +++ b/backend/procurement/notifications/__init__.py @@ -35,6 +35,7 @@ from .handlers import ( notify_request_approved, notify_request_rejected, notify_request_completed, + notify_request_arrival, notify_request_in_progress, notify_executor_reassigned, notify_request_returned_to_processing, @@ -66,6 +67,7 @@ __all__ = [ 'notify_request_approved', 'notify_request_rejected', 'notify_request_completed', + 'notify_request_arrival', 'notify_request_in_progress', 'notify_executor_reassigned', 'notify_request_returned_to_processing', diff --git a/backend/procurement/notifications/config.py b/backend/procurement/notifications/config.py index 92eb3503..dd539343 100644 --- a/backend/procurement/notifications/config.py +++ b/backend/procurement/notifications/config.py @@ -23,6 +23,7 @@ class NotificationVerbs: REJECTED = 'procurement_rejected' IN_PROGRESS = 'procurement_in_progress' EXECUTOR_REASSIGNED = 'procurement_executor_reassigned' + ARRIVAL_NOTICE = 'procurement_arrival_notice' COMPLETED = 'procurement_completed' CANCELLED = 'procurement_cancelled' @@ -210,6 +211,15 @@ class MessageTemplates: description = f'Заявка "{title}" успешно завершена.' return notification_title, description + @staticmethod + def arrival_notice(title: str) -> tuple[str, str]: + notification_title = 'Поступление по закупке' + description = ( + f'По заявке "{title}" есть поступление. ' + f'Проверьте позиции и подтвердите получение.' + ) + return notification_title, description + @staticmethod def cancelled(title: str, reason: str = 'не указана') -> tuple[str, str]: """ diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py index b99edc78..edb988ef 100644 --- a/backend/procurement/notifications/handlers.py +++ b/backend/procurement/notifications/handlers.py @@ -390,34 +390,36 @@ def notify_request_completed(request): ) +def notify_request_arrival(request, actor=None): + """Вручную уведомить заказчика о поступлении по заявке.""" + notification_title, description = MessageTemplates.arrival_notice( + request.title, + ) + notify_requestor( + request, + NotificationVerbs.ARRIVAL_NOTICE, + notification_title, + description, + actor=actor, + extra_data={ + "status": request.status, + "fulfillment_status": request.fulfillment_status, + }, + ) + + def notify_request_in_progress(request, executor): """ Уведомить о взятии заявки в работу. - Отправляет уведомления: - - Создателю заявки (если он не сам взял в работу) + Заказчика не уведомляем: это промежуточный статус обработки. + Финальные статусы и комментарии остаются основными сигналами. Args: request: Объект ProcurementRequest executor: Пользователь, взявший заявку в работу """ - executor_name = executor.get_full_name() if executor else 'Сотрудник' - - notification_title, description = MessageTemplates.in_progress_requestor( - request.title, executor_name - ) - notify_requestor( - request, - NotificationVerbs.IN_PROGRESS, - notification_title, - description, - actor=executor, - extra_data={ - "old_status": getattr(request, "_original_status", None), - "new_status": request.status, - "executor_id": executor.id if executor else None, - }, - ) + return def notify_executor_reassigned(request, previous_executor, actor): @@ -488,22 +490,8 @@ def notify_stage_approved(approval): Args: approval: Объект Approval """ - approver_name = approval.approver.get_full_name() - notification_title, description = MessageTemplates.stage_approved( - approver_name, approval.request.title - ) - - notify_requestor( - approval.request, - NotificationVerbs.STAGE_APPROVED, - notification_title, - description, - actor=approval.approver, - extra_data={ - "approval_id": approval.id, - "approval_priority": approval.priority, - }, - ) + # Не уведомляем заказчика о каждом промежуточном этапе согласования. + # Следующий согласующий всё ещё должен получить pending-уведомление. notify_approvers(approval.request) @@ -534,26 +522,8 @@ def notify_stage_rejected(approval): def notify_item_updated(item, actor=None, changed_fields=None): - """Уведомить автора заявки об изменении позиции.""" - procurement_request = item.request - actor_name = _actor_name(actor) - notification_title, description = MessageTemplates.item_updated( - procurement_request.title, - item.name, - actor_name, - ) - notify_requestor( - procurement_request, - NotificationVerbs.ITEM_UPDATED, - notification_title, - description, - actor=actor, - extra_data={ - "item_id": item.id, - "item_name": item.name, - "changed_fields": changed_fields or [], - }, - ) + """Не уведомлять заказчика о технических изменениях позиции.""" + return def notify_item_issue_reported(item, actor=None): diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 7803e48f..ffad429b 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -1915,7 +1915,7 @@ class TestProcessingDepartmentWorkflow: assert completed_notifications[0]["sender"] == supply_user assert completed_notifications[0]["data"]["actor_id"] == supply_user.id - def test_item_update_notifies_requestor( + def test_item_update_does_not_notify_requestor( self, api_client, supply_user, processing_request, procurement_item_factory, monkeypatch ): @@ -1959,19 +1959,10 @@ class TestProcessingDepartmentWorkflow: item for item in sent if item["verb"] == "procurement_item_updated" ] - assert len(item_notifications) == 1 - assert item_notifications[0]["recipient"] == processing_request.requestor - assert item_notifications[0]["data"]["item_id"] == item.id - assert sorted(item_notifications[0]["data"]["changed_fields"]) == [ - "actual_unit_price", - "execution_status", - "expected_delivery_dates", - "links", - "ordered_quantity", - ] + assert item_notifications == [] @pytest.mark.django_db(transaction=True) - def test_item_order_update_routes_push_to_requestor( + def test_item_order_update_does_not_route_push_to_requestor( self, api_client, supply_user, processing_request, procurement_item_factory ): @@ -2016,16 +2007,12 @@ class TestProcessingDepartmentWorkflow: ) assert response.status_code == status.HTTP_200_OK - notification = Notification.objects.get( + assert not Notification.objects.filter( recipient=processing_request.requestor, verb="procurement_item_updated", data__item_id=item.id, - ) - assert notification.data["changed_fields"] == [ - "ordered_quantity", - "execution_status", - ] - send_push.assert_called_once_with(notification.id) + ).exists() + send_push.assert_not_called() def test_item_expected_delivery_dates_must_be_valid_dates( self, api_client, supply_user, processing_request, @@ -2381,7 +2368,7 @@ class TestProcessingDepartmentWorkflow: ] assert supply_user.email in issue_recipients - def test_executor_issue_reopens_request_for_requestor_and_notifies( + def test_executor_issue_reopens_request_for_requestor_without_status_spam( self, api_client, user, supply_user, processing_request, procurement_item_factory, monkeypatch ): @@ -2445,8 +2432,8 @@ class TestProcessingDepartmentWorkflow: for notification in sent if notification["verb"] == "procurement_in_progress" ] - assert user.email in item_updated_recipients - assert user.email in in_progress_recipients + assert user.email not in item_updated_recipients + assert user.email not in in_progress_recipients def test_requestor_can_confirm_item_received( self, api_client, user, supply_user, processing_request, @@ -3079,10 +3066,7 @@ class TestProcessingDepartmentWorkflow: for item in sent if item["verb"] == "procurement_item_updated" ] - assert item_update_recipients == [ - processing_request.requestor.email, - processing_request.requestor.email, - ] + assert item_update_recipients == [] completed_recipients = [ item["recipient"].email for item in sent @@ -3090,6 +3074,109 @@ class TestProcessingDepartmentWorkflow: ] assert completed_recipients == [processing_request.requestor.email] + def test_processing_user_can_notify_requestor_about_arrival_multiple_times( + self, api_client, user, supply_user, processing_request, monkeypatch + ): + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.save() + sent.clear() + + api_client.force_authenticate(user=supply_user) + url = reverse( + 'api:v1:procurement:procurementrequest-notify-arrival', + kwargs={'pk': processing_request.id}, + ) + + first_response = api_client.post(url) + second_response = api_client.post(url) + + assert first_response.status_code == status.HTTP_200_OK + assert second_response.status_code == status.HTTP_200_OK + arrival_notifications = [ + item for item in sent + if item["verb"] == "procurement_arrival_notice" + ] + assert len(arrival_notifications) == 2 + assert [ + item["recipient"].email for item in arrival_notifications + ] == [user.email, user.email] + assert all(item["sender"] == supply_user for item in arrival_notifications) + assert all( + item["data"]["request_id"] == processing_request.id + for item in arrival_notifications + ) + + def test_notify_arrival_is_not_available_after_request_completed( + self, api_client, supply_user, processing_request, monkeypatch + ): + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.COMPLETED + processing_request.completed_at = timezone.now() + processing_request.save() + sent.clear() + + api_client.force_authenticate(user=supply_user) + url = reverse( + 'api:v1:procurement:procurementrequest-notify-arrival', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert [ + item for item in sent + if item["verb"] == "procurement_arrival_notice" + ] == [] + + def test_requestor_cannot_notify_self_about_arrival( + self, api_client, user, processing_request, monkeypatch + ): + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-notify-arrival', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert [ + item for item in sent + if item["verb"] == "procurement_arrival_notice" + ] == [] + # ============================================================================== # ТЕСТЫ WORKFLOW ДЕЙСТВИЙ @@ -3217,12 +3304,11 @@ class TestProcurementRequestWorkflow: if item["verb"] == "procurement_pending_approval" ] assert pending_recipients == ["user@example.com"] - stage_recipients = [ - item["recipient"].email - for item in sent + stage_notifications = [ + item for item in sent if item["verb"] == "procurement_stage_approved" ] - assert stage_recipients == [procurement_request.requestor.email] + assert stage_notifications == [] def test_final_approve_does_not_notify_future_approvers( self, api_client, approver_with_permission, procurement_request, procurement_item, monkeypatch @@ -3368,10 +3454,10 @@ class TestProcurementRequestWorkflow: ] assert cancelled_recipients == ["head@example.com"] - def test_start_work_notifies_requestor( + def test_start_work_does_not_notify_requestor( self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch ): - """При взятии в работу уведомляется автор заявки.""" + """При взятии в работу автор заявки не получает шумное уведомление.""" sent = [] def fake_notify_send(**kwargs): @@ -3416,9 +3502,7 @@ class TestProcurementRequestWorkflow: for item in sent if item["verb"] == "procurement_in_progress" ] - assert in_progress_recipients == [ - procurement_request.requestor.email, - ] + assert procurement_request.requestor.email not in in_progress_recipients def test_complete_notifies_requestor( self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 6b554c10..abe5bdf7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -449,6 +449,17 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { border-color: color-mix(in srgb, #f97316 40%, var(--border-strong)); } +.app-action-notify { + border: 1px solid color-mix(in srgb, #8b5cf6 28%, var(--border-strong)); + background: color-mix(in srgb, #8b5cf6 16%, transparent); + color: #7c3aed; +} + +.app-action-notify:hover { + background: color-mix(in srgb, #8b5cf6 22%, transparent); + border-color: color-mix(in srgb, #8b5cf6 40%, var(--border-strong)); +} + .app-action-secondary { border: 1px solid var(--border-strong); background: var(--surface-elevated); diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index f08432f6..83ebdf08 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -166,6 +166,7 @@ function ProcurementRequestActionButtons({ onReject, onStart, onComplete, + onNotifyArrival, onCancel, }: { request: ProcurementRequest; @@ -184,6 +185,7 @@ function ProcurementRequestActionButtons({ onReject: (id: number) => void | Promise; onStart: (id: number) => void | Promise; onComplete: (id: number) => void | Promise; + onNotifyArrival: (id: number) => void | Promise; onCancel: (id: number) => void | Promise; }) { const status = String(request.status || "").toLowerCase(); @@ -191,6 +193,10 @@ function ProcurementRequestActionButtons({ const isPending = status === "pending"; const canStartWork = Boolean(request.can_current_user_start_work); const isInProgress = status === "in_progress"; + const canNotifyArrival = Boolean( + request.can_current_user_process_items && + (status === "waiting" || status === "in_progress") + ); const startWorkLabel = isInProgress ? "Забрать в работу" : "Взять в работу"; const canApproveThis = Boolean(request.can_current_user_approve); const canSubmitForApproval = Boolean(request.can_current_user_submit_for_approval); @@ -286,6 +292,18 @@ function ProcurementRequestActionButtons({ {label("Закрыть заявку")} ) : null} + {canNotifyArrival ? ( + + ) : null} {showSecondaryActions && isAuthor && !isFinal(status) && status !== "draft" ? (