procurement: add workflow notifications

This commit is contained in:
lizerk 2026-05-22 16:53:04 +03:00
parent a9d9b6ae3d
commit b40f32e849
7 changed files with 815 additions and 135 deletions

View File

@ -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:

View File

@ -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(
{

View File

@ -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',

View File

@ -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 для действий =====

View File

@ -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,
},
)

View File

@ -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:
# Заявка завершена

View File

@ -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(