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
{actionSuccess}
} {activeSection === "stats" &&