From b40f32e849bb27df3d2049430d11bdc4a259ef0b Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 22 May 2026 16:53:04 +0300 Subject: [PATCH] procurement: add workflow notifications --- backend/api/v1/procurement/serializers.py | 12 +- backend/api/v1/procurement/views.py | 74 +++- backend/procurement/notifications/__init__.py | 12 +- backend/procurement/notifications/config.py | 49 +++ backend/procurement/notifications/handlers.py | 397 +++++++++++++----- backend/procurement/notifications/signals.py | 27 +- .../procurement/test_procurement_requests.py | 379 ++++++++++++++++- 7 files changed, 815 insertions(+), 135 deletions(-) diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index ed4f1e77..59bd776e 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -356,13 +356,19 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer): """Создать заявку с позициями.""" items_data = validated_data.pop("items", []) request = self.context["request"] + has_processing_department = bool( + validated_data.get("processing_department") + ) - if validated_data.get("processing_department"): + if has_processing_department: validated_data["status"] = ProcurementStatus.WAITING - procurement_request = ProcurementRequest.objects.create( - requestor=request.user, **validated_data + procurement_request = ProcurementRequest( + requestor=request.user, + **validated_data, ) + procurement_request._notification_actor = request.user + procurement_request.save() # Создаем позиции (если они переданы) for item_data in items_data: diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index fd303548..cc3c5dbf 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -26,6 +26,11 @@ from procurement.models import ( ProcurementRequest, Supplier, ) +from procurement.notifications.handlers import ( + notify_item_comment, + notify_item_updated, + notify_request_comment, +) from communications import comments_helpers from communications.models import Message from procurement.services import ProcurementApprovalResolver @@ -258,6 +263,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): author=request.user, content=text, ) + notify_request_comment( + procurement_request, + message, + actor=request.user, + ) author_ser = EmployeeBriefSerializer(message.author) return Response( { @@ -417,6 +427,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): procurement_request.status = ProcurementStatus.PENDING procurement_request.submitted_at = timezone.now() + procurement_request._notification_actor = request.user procurement_request.save( update_fields=["status", "submitted_at", "updated_at"] ) @@ -471,6 +482,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): # Сигнал post_save(ProcurementRequest) отправит уведомление # requestor'у procurement_request.status = ProcurementStatus.APPROVED + procurement_request._notification_actor = request.user procurement_request.save() procurement_request.refresh_from_db() @@ -506,6 +518,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): # Меняем статус заявки; сигнал post_save(ProcurementRequest) уведомит # requestor'а procurement_request.status = ProcurementStatus.REJECTED + procurement_request._notification_actor = request.user + procurement_request._requestor_rejected_notified = True procurement_request.save() procurement_request.refresh_from_db() @@ -647,6 +661,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): procurement_request.executor = request.user procurement_request.started_at = timezone.now() procurement_request.status = ProcurementStatus.IN_PROGRESS + procurement_request._notification_actor = request.user procurement_request.save() serializer = self.get_serializer(procurement_request) @@ -674,9 +689,9 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): # Меняем статус procurement_request.status = ProcurementStatus.COMPLETED procurement_request.completed_at = timezone.now() + procurement_request._notification_actor = request.user procurement_request.save() - # Сигнал post_save(ProcurementRequest) с COMPLETED отправит уведомления - # requestor'у и согласующим + # Сигнал post_save(ProcurementRequest) с COMPLETED уведомит автора serializer = self.get_serializer(procurement_request) return Response(serializer.data) @@ -701,9 +716,23 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) + items_to_update = list( + procurement_request.items.exclude( + execution_status=ProcurementItemExecutionStatus.RECEIVED, + ) + ) + procurement_request.items.update( execution_status=ProcurementItemExecutionStatus.RECEIVED, ) + for item in items_to_update: + item.execution_status = ProcurementItemExecutionStatus.RECEIVED + notify_item_updated( + item, + actor=request.user, + changed_fields=["execution_status"], + ) + procurement_request._notification_actor = request.user procurement_request.recalculate_fulfillment_status(save=True) procurement_request.refresh_from_db() @@ -736,6 +765,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): # Сохраняем причину на экземпляре, чтобы сигнал мог её использовать procurement_request.cancellation_reason = reason or "не указана" + procurement_request._notification_actor = request.user # Меняем статус; сигнал post_save(ProcurementRequest) с CANCELLED # уведомит согласующих с указанием причины @@ -749,6 +779,18 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): class ProcurementItemViewSet(viewsets.ModelViewSet): """ViewSet для позиций заявок.""" + NOTIFICATION_TRACKED_FIELDS = [ + "name", + "quantity", + "unit", + "estimated_unit_price", + "links", + "expected_delivery_date", + "actual_unit_price", + "execution_status", + "executor_comment", + ] + queryset = ProcurementItem.objects.select_related( "request", "request__department", @@ -944,7 +986,28 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): """Проверяем права при обновлении позиции.""" item = self.get_object() self._check_item_edit_permission(item) - serializer.save() + procurement_request = item.request + before = { + field: getattr(item, field) + for field in self.NOTIFICATION_TRACKED_FIELDS + } + procurement_request._notification_actor = self.request.user + updated_item = serializer.save() + after = { + field: getattr(updated_item, field) + for field in self.NOTIFICATION_TRACKED_FIELDS + } + changed_fields = [ + field + for field in self.NOTIFICATION_TRACKED_FIELDS + if before[field] != after[field] + ] + if changed_fields: + notify_item_updated( + updated_item, + actor=self.request.user, + changed_fields=changed_fields, + ) def perform_destroy(self, instance): """Проверяем права при удалении позиции.""" @@ -994,6 +1057,11 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): author=request.user, content=text, ) + notify_item_comment( + item, + message, + actor=request.user, + ) author_ser = EmployeeBriefSerializer(message.author) return Response( { diff --git a/backend/procurement/notifications/__init__.py b/backend/procurement/notifications/__init__.py index fd0bcda2..30b63ca8 100644 --- a/backend/procurement/notifications/__init__.py +++ b/backend/procurement/notifications/__init__.py @@ -17,8 +17,8 @@ Usage: # Сигналы подключаются автоматически через AppConfig.ready() # Для ручной отправки: - from procurement.notifications import notify_new_request - notify_new_request(request_obj) + from procurement.notifications import notify_request_comment + notify_request_comment(request_obj, message_obj, actor=user) # WebSocket broadcast происходит автоматически через: # notify.send() → channels.py → Celery → WebSocketNotificationSender @@ -30,6 +30,7 @@ from .handlers import ( notify_new_request, notify_approvers, notify_approver, + notify_processing_department_request, notify_requestor, notify_request_approved, notify_request_rejected, @@ -38,6 +39,9 @@ from .handlers import ( notify_request_cancelled, notify_stage_approved, notify_stage_rejected, + notify_item_updated, + notify_request_comment, + notify_item_comment, ) # Сигналы импортируются для регистрации через AppConfig.ready() @@ -54,6 +58,7 @@ __all__ = [ 'notify_new_request', 'notify_approvers', 'notify_approver', + 'notify_processing_department_request', 'notify_requestor', 'notify_request_approved', 'notify_request_rejected', @@ -62,6 +67,9 @@ __all__ = [ 'notify_request_cancelled', 'notify_stage_approved', 'notify_stage_rejected', + 'notify_item_updated', + 'notify_request_comment', + 'notify_item_comment', # Signals module (для импорта в AppConfig) 'signals', diff --git a/backend/procurement/notifications/config.py b/backend/procurement/notifications/config.py index 11e48594..88445a30 100644 --- a/backend/procurement/notifications/config.py +++ b/backend/procurement/notifications/config.py @@ -12,6 +12,7 @@ class NotificationVerbs: # Новая заявка NEW_REQUEST = 'procurement_new_request' + DEPARTMENT_REQUEST = 'procurement_department_request' # Согласование PENDING_APPROVAL = 'procurement_pending_approval' @@ -24,6 +25,11 @@ class NotificationVerbs: COMPLETED = 'procurement_completed' CANCELLED = 'procurement_cancelled' + # Позиции и комментарии + ITEM_UPDATED = 'procurement_item_updated' + REQUEST_COMMENTED = 'procurement_request_commented' + ITEM_COMMENTED = 'procurement_item_commented' + # ===== Шаблоны сообщений ===== @@ -42,6 +48,17 @@ class MessageTemplates: description = f'Создана заявка "{title}" на сумму {total_cost}₽' return notification_title, description + @staticmethod + def department_request( + title: str, department_name: str + ) -> tuple[str, str]: + notification_title = 'Новая заявка для отдела' + description = ( + f'Заявка "{title}" направлена в отдел ' + f'{department_name}.' + ) + return notification_title, description + @staticmethod def pending_approval( title: str, total_cost: float @@ -197,6 +214,38 @@ class MessageTemplates: ) return notification_title, description + @staticmethod + def item_updated( + title: str, item_name: str, actor_name: str + ) -> tuple[str, str]: + notification_title = 'Позиция закупки изменена' + description = ( + f'{actor_name} изменил позицию "{item_name}" ' + f'в заявке "{title}".' + ) + return notification_title, description + + @staticmethod + def request_commented( + title: str, actor_name: str + ) -> tuple[str, str]: + notification_title = 'Новый комментарий к закупке' + description = ( + f'{actor_name} оставил комментарий к заявке "{title}".' + ) + return notification_title, description + + @staticmethod + def item_commented( + title: str, item_name: str, actor_name: str + ) -> tuple[str, str]: + notification_title = 'Новый комментарий к позиции' + description = ( + f'{actor_name} оставил комментарий к позиции "{item_name}" ' + f'в заявке "{title}".' + ) + return notification_title, description + # ===== URL для действий ===== diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py index 1060819c..9a20b5a9 100644 --- a/backend/procurement/notifications/handlers.py +++ b/backend/procurement/notifications/handlers.py @@ -13,6 +13,114 @@ from .config import NotificationVerbs, MessageTemplates, ActionURLs logger = logging.getLogger(__name__) +def _actor_id(actor): + return getattr(actor, "id", None) + + +def _actor_name(actor): + if not actor: + return "Система" + return actor.get_full_name() or getattr(actor, "username", "") or str(actor) + + +def _request_actor(request): + return getattr(request, "_notification_actor", None) + + +def _send_procurement_notification( + *, + recipient, + verb, + procurement_request, + title, + description, + actor=None, + action_object=None, + extra_data=None, +): + """Отправить procurement-уведомление одному получателю.""" + if not recipient: + return + + data = { + "title": title, + "request_id": procurement_request.id, + } + if actor: + data["actor_id"] = actor.id + if extra_data: + data.update(extra_data) + + notify.send( + sender=actor, + recipient=recipient, + verb=verb, + action_object=action_object or procurement_request, + target=procurement_request, + description=description, + action_url=ActionURLs.request_detail(procurement_request.id), + data=data, + ) + + +def _notify_many( + recipients, + *, + verb, + procurement_request, + title, + description, + actor=None, + action_object=None, + extra_data=None, +): + """Отправить уведомление списку получателей с дедупликацией.""" + seen = set() + actor_id = _actor_id(actor) + for recipient in recipients: + if not recipient or recipient.id in seen: + continue + seen.add(recipient.id) + if actor_id and recipient.id == actor_id: + continue + _send_procurement_notification( + recipient=recipient, + verb=verb, + procurement_request=procurement_request, + title=title, + description=description, + actor=actor, + action_object=action_object, + extra_data=extra_data, + ) + + +def _processing_department_recipients(procurement_request): + """Активные участники отдела-исполнителя, включая роли и руководителя.""" + if not procurement_request.processing_department_id: + return [] + + from employees.models import Employee, EmployeeDepartment, RoleAssignment + + department = procurement_request.processing_department + recipient_ids = set( + EmployeeDepartment.objects.filter( + department_id=department.id, + is_active=True, + ).values_list("employee_id", flat=True) + ) + recipient_ids.update( + RoleAssignment.objects.filter( + role__department_id=department.id, + is_active=True, + ).values_list("employee_id", flat=True) + ) + if department.head_id: + recipient_ids.add(department.head_id) + + return list(Employee.objects.filter(id__in=recipient_ids, is_active=True)) + + def get_current_pending_approvals(request): """Вернуть pending approvals только текущего этапа.""" from ..constants import ApprovalStatus @@ -28,9 +136,9 @@ def get_current_pending_approvals(request): def notify_new_request(request): """ - Уведомить о новой заявке на закупку. + Legacy-helper для старого сценария уведомления руководителя отдела. - Отправляет уведомление руководителю отдела. + Новая логика адресных заявок использует notify_processing_department_request(). Args: request: Объект ProcurementRequest @@ -65,6 +173,36 @@ def notify_new_request(request): ) +def notify_processing_department_request(request, actor=None): + """Уведомить отдел-исполнитель о новой адресной заявке.""" + if not request.processing_department_id: + return + + department_name = request.processing_department.name + notification_title, description = MessageTemplates.department_request( + request.title, + department_name, + ) + recipients = _processing_department_recipients(request) + _notify_many( + recipients, + verb=NotificationVerbs.DEPARTMENT_REQUEST, + procurement_request=request, + title=notification_title, + description=description, + actor=actor, + extra_data={ + "processing_department_id": request.processing_department_id, + "total_cost": float(request.total_cost), + }, + ) + + logger.info( + f"[Procurement] Отправлено уведомление о заявке #{request.id} " + f"в отдел {department_name}" + ) + + def notify_approvers(request): """ Уведомить согласующих только текущего этапа. @@ -96,18 +234,15 @@ def notify_approver(approval): approval.request.title, approval.request.total_cost ) - notify.send( - sender=None, + _send_procurement_notification( recipient=approval.approver, verb=NotificationVerbs.PENDING_APPROVAL, - action_object=approval.request, + procurement_request=approval.request, + title=notification_title, description=description, - action_url=ActionURLs.request_detail(approval.request.id), - data={ - 'title': notification_title, - 'request_id': approval.request.id, - 'approval_id': approval.id, - 'total_cost': float(approval.request.total_cost), + extra_data={ + "approval_id": approval.id, + "total_cost": float(approval.request.total_cost), }, ) @@ -117,7 +252,16 @@ def notify_approver(approval): ) -def notify_requestor(request, verb, title, message): +def notify_requestor( + request, + verb, + title, + message, + *, + actor=None, + action_object=None, + extra_data=None, +): """ Уведомить создателя заявки. @@ -127,17 +271,19 @@ def notify_requestor(request, verb, title, message): title: Заголовок уведомления message: Текст сообщения """ - notify.send( - sender=None, - recipient=request.requestor, + actor = actor or _request_actor(request) + if actor and actor.id == request.requestor_id: + return + + _send_procurement_notification( + actor=actor, verb=verb, - action_object=request, + recipient=request.requestor, + procurement_request=request, + title=title, description=message, - action_url=ActionURLs.request_detail(request.id), - data={ - 'title': title, - 'request_id': request.id, - }, + action_object=action_object, + extra_data=extra_data, ) logger.info( @@ -158,7 +304,11 @@ def notify_request_approved(request): request, NotificationVerbs.APPROVED, notification_title, - description + description, + extra_data={ + "old_status": getattr(request, "_original_status", None), + "new_status": request.status, + }, ) @@ -174,7 +324,11 @@ def notify_request_rejected(request): request, NotificationVerbs.REJECTED, notification_title, - description + description, + extra_data={ + "old_status": getattr(request, "_original_status", None), + "new_status": request.status, + }, ) @@ -184,41 +338,22 @@ def notify_request_completed(request): Отправляет уведомления: - Создателю заявки - - Всем одобрившим согласующим Args: request: Объект ProcurementRequest """ - from ..constants import ApprovalStatus - - # Уведомляем создателя notification_title, description = MessageTemplates.completed(request.title) notify_requestor( request, NotificationVerbs.COMPLETED, notification_title, - description + description, + extra_data={ + "old_status": getattr(request, "_original_status", None), + "new_status": request.status, + }, ) - # Уведомляем всех одобривших согласующих - notification_title, description = MessageTemplates.completed_approver( - request.title - ) - - for approval in request.approvals.filter(status=ApprovalStatus.APPROVED): - notify.send( - sender=None, - recipient=approval.approver, - verb=NotificationVerbs.COMPLETED, - action_object=request, - description=description, - action_url=ActionURLs.request_detail(request.id), - data={ - 'title': notification_title, - 'request_id': request.id, - }, - ) - def notify_request_in_progress(request, executor): """ @@ -226,57 +361,28 @@ def notify_request_in_progress(request, executor): Отправляет уведомления: - Создателю заявки (если он не сам взял в работу) - - Всем одобрившим согласующим Args: request: Объект ProcurementRequest executor: Пользователь, взявший заявку в работу """ - from ..constants import ApprovalStatus - executor_name = executor.get_full_name() if executor else 'Сотрудник' - # Уведомляем создателя (если он не сам взял в работу) - if executor and request.requestor != executor: - notification_title, description = ( - MessageTemplates.in_progress_requestor( - request.title, executor_name - ) - ) - - notify.send( - sender=executor, - recipient=request.requestor, - verb=NotificationVerbs.IN_PROGRESS, - action_object=request, - description=description, - action_url=ActionURLs.request_detail(request.id), - data={ - 'title': notification_title, - 'request_id': request.id, - 'executor_id': executor.id, - }, - ) - - # Уведомляем всех одобривших согласующих - notification_title, description = MessageTemplates.in_progress( + notification_title, description = MessageTemplates.in_progress_requestor( request.title, executor_name ) - - for approval in request.approvals.filter(status=ApprovalStatus.APPROVED): - notify.send( - sender=executor, - recipient=approval.approver, - verb=NotificationVerbs.IN_PROGRESS, - action_object=request, - description=description, - action_url=ActionURLs.request_detail(request.id), - data={ - 'title': notification_title, - 'request_id': request.id, - 'executor_id': executor.id if executor else None, - }, - ) + 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, + }, + ) def notify_request_cancelled(request): @@ -293,20 +399,21 @@ def notify_request_cancelled(request): request.title, reason ) - for approval in request.approvals.all(): - notify.send( - sender=None, - recipient=approval.approver, - verb=NotificationVerbs.CANCELLED, - action_object=request, - description=description, - action_url=ActionURLs.request_detail(request.id), - data={ - 'title': notification_title, - 'request_id': request.id, - 'reason': reason, - }, - ) + actor = _request_actor(request) + recipients = [approval.approver for approval in request.approvals.all()] + _notify_many( + recipients, + verb=NotificationVerbs.CANCELLED, + procurement_request=request, + title=notification_title, + description=description, + actor=actor, + extra_data={ + "reason": reason, + "old_status": getattr(request, "_original_status", None), + "new_status": request.status, + }, + ) def notify_stage_approved(approval): @@ -325,7 +432,12 @@ def notify_stage_approved(approval): approval.request, NotificationVerbs.STAGE_APPROVED, notification_title, - description + description, + actor=approval.approver, + extra_data={ + "approval_id": approval.id, + "approval_priority": approval.priority, + }, ) notify_approvers(approval.request) @@ -346,5 +458,80 @@ def notify_stage_rejected(approval): approval.request, NotificationVerbs.REJECTED, notification_title, - description + description, + actor=approval.approver, + extra_data={ + "approval_id": approval.id, + "approval_priority": approval.priority, + "new_status": "rejected", + }, + ) + + +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 [], + }, + ) + + +def notify_request_comment(request, message, actor=None): + """Уведомить автора заявки о комментарии к заявке.""" + actor = actor or message.author + notification_title, description = MessageTemplates.request_commented( + request.title, + _actor_name(actor), + ) + notify_requestor( + request, + NotificationVerbs.REQUEST_COMMENTED, + notification_title, + description, + actor=actor, + action_object=message, + extra_data={ + "comment_id": message.id, + "author_id": message.author_id, + }, + ) + + +def notify_item_comment(item, message, actor=None): + """Уведомить автора заявки о комментарии к позиции.""" + actor = actor or message.author + procurement_request = item.request + notification_title, description = MessageTemplates.item_commented( + procurement_request.title, + item.name, + _actor_name(actor), + ) + notify_requestor( + procurement_request, + NotificationVerbs.ITEM_COMMENTED, + notification_title, + description, + actor=actor, + action_object=message, + extra_data={ + "comment_id": message.id, + "author_id": message.author_id, + "item_id": item.id, + "item_name": item.name, + }, ) diff --git a/backend/procurement/notifications/signals.py b/backend/procurement/notifications/signals.py index c068591f..6eb7a743 100644 --- a/backend/procurement/notifications/signals.py +++ b/backend/procurement/notifications/signals.py @@ -7,7 +7,7 @@ Django сигналы для автоматической генерации у - Изменение статуса согласования Все уведомления отправляются через универсальную систему notifications: -noti fy.send() → channels.py → Celery → WebSocket/Email/Push +notify.send() → channels.py → Celery → WebSocket/Email/Push """ import logging @@ -17,8 +17,8 @@ from django.dispatch import receiver from ..constants import ApprovalStatus, ProcurementStatus from ..models import Approval, ProcurementRequest from .handlers import ( - notify_new_request, notify_approvers, + notify_processing_department_request, notify_request_approved, notify_request_rejected, notify_request_completed, @@ -37,16 +37,26 @@ def create_request_notifications(sender, instance, created, **kwargs): Создает уведомления при создании или изменении заявки. Обрабатывает: - 1. Новая заявка - уведомление руководителю отдела + 1. Новая адресная заявка - уведомление отделу-исполнителю 2. Изменение статуса - уведомления соответствующим получателям """ try: request = instance if created: - # Новая заявка создана - # WebSocket broadcast будет автоматически через notify.send() - notify_new_request(request) + if ( + request.status == ProcurementStatus.WAITING + and request.processing_department_id + and not getattr( + request, + "_suppress_created_procurement_notifications", + False, + ) + ): + notify_processing_department_request( + request, + actor=getattr(request, "_notification_actor", None), + ) return # Проверяем изменение статуса @@ -57,8 +67,6 @@ def create_request_notifications(sender, instance, created, **kwargs): if old_status == new_status: return - # Обработка изменения статуса - # WebSocket broadcast будет автоматически через notify.send() _handle_status_change(request, new_status) except Exception as e: @@ -83,7 +91,8 @@ def _handle_status_change(request, new_status): elif new_status == ProcurementStatus.REJECTED: # Заявка отклонена - notify_request_rejected(request) + if not getattr(request, "_requestor_rejected_notified", False): + notify_request_rejected(request) elif new_status == ProcurementStatus.COMPLETED: # Заявка завершена diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 13430158..781cc8dc 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -10,7 +10,13 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from employees.models import Department, Employee, EmployeeDepartment +from employees.models import ( + Department, + DepartmentRole, + Employee, + EmployeeDepartment, + RoleAssignment, +) from procurement.constants import ( ApprovalStatus, ProcurementFulfillmentStatus, @@ -758,6 +764,125 @@ class TestProcessingDepartmentWorkflow: urgency=UrgencyLevel.MEDIUM, ) + def test_create_draft_request_does_not_notify( + self, api_client, user, department, 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-list') + + response = api_client.post( + url, + { + "title": "Черновик закупки", + "description": "Без отдела-исполнителя", + "department": department.id, + "urgency": UrgencyLevel.MEDIUM, + "items": [ + { + "name": "Перчатки", + "quantity": 1, + "unit": "шт", + "estimated_unit_price": "100.00", + } + ], + }, + format='json', + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["status"] == ProcurementStatus.DRAFT + assert sent == [] + + def test_create_processing_department_request_notifies_department_members( + self, api_client, user, department, supply_department, supply_user, + monkeypatch + ): + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + supply_head = Employee.objects.create_user( + email="supply-head@example.com", + password="testpass123", + phone_number="+79998888890", + first_name="Начальник", + last_name="Снабжения", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + supply_department.head = supply_head + supply_department.save() + + role_user = Employee.objects.create_user( + email="role-supply@example.com", + password="testpass123", + phone_number="+79998888891", + first_name="Ролевой", + last_name="Снабженец", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + role = DepartmentRole.objects.create( + department=supply_department, + name="Закупки", + ) + RoleAssignment.objects.create(employee=role_user, role=role) + + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + + response = api_client.post( + url, + { + "title": "Расходники", + "description": "Нужно для производства", + "department": department.id, + "processing_department": supply_department.id, + "urgency": UrgencyLevel.MEDIUM, + "items": [ + { + "name": "Лампы", + "quantity": 2, + "unit": "шт", + "estimated_unit_price": "300.00", + } + ], + }, + format='json', + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["status"] == ProcurementStatus.WAITING + recipients = sorted( + item["recipient"].email + for item in sent + if item["verb"] == "procurement_department_request" + ) + assert recipients == [ + "role-supply@example.com", + "supply-head@example.com", + "supply@example.com", + ] + assert user.email not in recipients + def test_available_shows_processing_department_requests( self, api_client, supply_user, processing_request, procurement_item_factory ): @@ -847,10 +972,214 @@ class TestProcessingDepartmentWorkflow: ) assert processing_request.status == ProcurementStatus.COMPLETED + def test_item_update_notifies_requestor( + self, api_client, 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) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.save() + sent.clear() + + api_client.force_authenticate(user=supply_user) + item_url = reverse( + 'api:v1:procurement:procurementitem-detail', + kwargs={'pk': item.id}, + ) + + response = api_client.patch( + item_url, + { + 'execution_status': ProcurementItemExecutionStatus.ORDERED, + 'actual_unit_price': '120.00', + 'expected_delivery_date': '2026-05-25', + 'links': ['https://example.com/item'], + }, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + item_notifications = [ + 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_date", + "links", + ] + + def test_requestor_item_update_does_not_notify_self( + self, api_client, 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) + + api_client.force_authenticate(user=user) + item_url = reverse( + 'api:v1:procurement:procurementitem-detail', + kwargs={'pk': item.id}, + ) + + response = api_client.patch( + item_url, + {'name': 'Перчатки нитриловые'}, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + assert [ + item for item in sent + if item["verb"] == "procurement_item_updated" + ] == [] + + def test_request_comment_notifies_requestor( + 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, + ) + + api_client.force_authenticate(user=supply_user) + url = reverse( + 'api:v1:procurement:procurementrequest-comments', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url, {'text': 'Взяли в анализ'}) + + assert response.status_code == status.HTTP_201_CREATED + comment_notifications = [ + item for item in sent + if item["verb"] == "procurement_request_commented" + ] + assert len(comment_notifications) == 1 + assert comment_notifications[0]["recipient"] == processing_request.requestor + assert comment_notifications[0]["data"]["comment_id"] == response.data["id"] + + def test_item_comment_notifies_requestor( + self, api_client, 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) + api_client.force_authenticate(user=supply_user) + url = reverse( + 'api:v1:procurement:procurementitem-comments', + kwargs={'pk': item.id}, + ) + + response = api_client.post(url, {'text': 'Заказали аналог'}) + + assert response.status_code == status.HTTP_201_CREATED + comment_notifications = [ + item for item in sent + if item["verb"] == "procurement_item_commented" + ] + assert len(comment_notifications) == 1 + assert comment_notifications[0]["recipient"] == processing_request.requestor + assert comment_notifications[0]["data"]["item_id"] == item.id + assert comment_notifications[0]["data"]["comment_id"] == response.data["id"] + + def test_requestor_comments_do_not_notify_self( + self, api_client, 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) + + api_client.force_authenticate(user=user) + request_comment_url = reverse( + 'api:v1:procurement:procurementrequest-comments', + kwargs={'pk': processing_request.id}, + ) + item_comment_url = reverse( + 'api:v1:procurement:procurementitem-comments', + kwargs={'pk': item.id}, + ) + + request_response = api_client.post( + request_comment_url, + {'text': 'Мой комментарий'}, + ) + item_response = api_client.post( + item_comment_url, + {'text': 'Мой комментарий к позиции'}, + ) + + assert request_response.status_code == status.HTTP_201_CREATED + assert item_response.status_code == status.HTTP_201_CREATED + assert [ + item for item in sent + if item["verb"] in { + "procurement_request_commented", + "procurement_item_commented", + } + ] == [] + def test_mark_all_received_completes_processing_request( self, api_client, supply_user, processing_request, - procurement_item_factory + procurement_item_factory, monkeypatch ): + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + item_1 = procurement_item_factory(request=processing_request) item_2 = procurement_item_factory( request=processing_request, @@ -859,6 +1188,7 @@ class TestProcessingDepartmentWorkflow: 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( @@ -879,6 +1209,21 @@ class TestProcessingDepartmentWorkflow: == ProcurementFulfillmentStatus.COMPLETED ) assert processing_request.status == ProcurementStatus.COMPLETED + item_update_recipients = [ + item["recipient"].email + for item in sent + if item["verb"] == "procurement_item_updated" + ] + assert item_update_recipients == [ + processing_request.requestor.email, + processing_request.requestor.email, + ] + completed_recipients = [ + item["recipient"].email + for item in sent + if item["verb"] == "procurement_completed" + ] + assert completed_recipients == [processing_request.requestor.email] # ============================================================================== @@ -1007,6 +1352,12 @@ class TestProcurementRequestWorkflow: if item["verb"] == "procurement_pending_approval" ] assert pending_recipients == ["user@example.com"] + stage_recipients = [ + item["recipient"].email + for item in sent + if item["verb"] == "procurement_stage_approved" + ] + assert stage_recipients == [procurement_request.requestor.email] def test_final_approve_does_not_notify_future_approvers( self, api_client, approver_with_permission, procurement_request, procurement_item, monkeypatch @@ -1048,6 +1399,12 @@ class TestProcurementRequestWorkflow: if item["verb"] == "procurement_pending_approval" ] assert pending_notifications == [] + approved_recipients = [ + item["recipient"].email + for item in sent + if item["verb"] == "procurement_approved" + ] + assert approved_recipients == [procurement_request.requestor.email] def test_reject_notifies_requestor_without_next_stage_notification( self, api_client, department_head, procurement_request, procurement_item, monkeypatch @@ -1093,8 +1450,8 @@ class TestProcurementRequestWorkflow: verbs = [item["verb"] for item in sent] recipients = [item["recipient"].email for item in sent] - assert "procurement_rejected" in verbs - assert procurement_request.requestor.email in recipients + assert verbs.count("procurement_rejected") == 1 + assert recipients.count(procurement_request.requestor.email) == 1 assert "procurement_pending_approval" not in verbs def test_cancel_notifies_all_related_approvers( @@ -1144,12 +1501,12 @@ class TestProcurementRequestWorkflow: for item in sent if item["verb"] == "procurement_cancelled" ] - assert cancelled_recipients == ["head@example.com", "user@example.com"] + assert cancelled_recipients == ["head@example.com"] - def test_start_work_notifies_requestor_and_approved_approvers( + def test_start_work_notifies_requestor( self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch ): - """При взятии в работу уведомляются создатель и уже одобрившие этапы.""" + """При взятии в работу уведомляется автор заявки.""" sent = [] def fake_notify_send(**kwargs): @@ -1196,14 +1553,12 @@ class TestProcurementRequestWorkflow: ] assert in_progress_recipients == [ procurement_request.requestor.email, - "head@example.com", - "user@example.com", ] - def test_complete_notifies_requestor_and_approved_approvers( + def test_complete_notifies_requestor( self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch ): - """При завершении уведомляются создатель и уже одобрившие этапы.""" + """При завершении уведомляется автор заявки.""" sent = [] def fake_notify_send(**kwargs): @@ -1250,8 +1605,6 @@ class TestProcurementRequestWorkflow: ] assert completed_recipients == [ procurement_request.requestor.email, - "head@example.com", - "user@example.com", ] def test_submit_request(