diff --git a/backend/notifications/migrations/0005_procurement_notification_types.py b/backend/notifications/migrations/0005_procurement_notification_types.py new file mode 100644 index 00000000..6e26a8cb --- /dev/null +++ b/backend/notifications/migrations/0005_procurement_notification_types.py @@ -0,0 +1,119 @@ +""" +Миграция данных: создание типов уведомлений для модуля закупок. +""" + +from django.db import migrations + + +def create_procurement_notification_types(apps, schema_editor): + """Создать категорию и типы уведомлений для закупок.""" + NotificationCategory = apps.get_model('notifications', 'NotificationCategory') + NotificationType = apps.get_model('notifications', 'NotificationType') + + # Создаём категорию + category, _ = NotificationCategory.objects.get_or_create( + code='procurement', + defaults={ + 'name': 'Закупки', + 'description': 'Уведомления модуля закупок и инвентаризации', + 'icon': 'bi-cart3', + 'color': '#17a2b8', + 'order': 60, + } + ) + + # Типы уведомлений + notification_types = [ + { + 'code': 'procurement_new_request', + 'name': 'Новая заявка на закупку', + 'description': 'Уведомление о создании новой заявки на закупку', + 'priority': 'normal', + 'default_channels': {'web': True, 'email': True, 'telegram': False}, + }, + { + 'code': 'procurement_pending_approval', + 'name': 'Требуется согласование', + 'description': 'Заявка ожидает вашего согласования', + 'priority': 'high', + 'default_channels': {'web': True, 'email': True, 'telegram': True}, + }, + { + 'code': 'procurement_approved', + 'name': 'Заявка одобрена', + 'description': 'Ваша заявка на закупку была одобрена', + 'priority': 'normal', + 'default_channels': {'web': True, 'email': True, 'telegram': False}, + }, + { + 'code': 'procurement_rejected', + 'name': 'Заявка отклонена', + 'description': 'Ваша заявка на закупку была отклонена', + 'priority': 'high', + 'default_channels': {'web': True, 'email': True, 'telegram': True}, + }, + { + 'code': 'procurement_stage_approved', + 'name': 'Этап согласования пройден', + 'description': 'Один из этапов согласования заявки завершён', + 'priority': 'low', + 'default_channels': {'web': True, 'email': False, 'telegram': False}, + }, + { + 'code': 'procurement_completed', + 'name': 'Закупка завершена', + 'description': 'Закупка по заявке успешно завершена', + 'priority': 'normal', + 'default_channels': {'web': True, 'email': True, 'telegram': False}, + }, + { + 'code': 'equipment_transferred', + 'name': 'Оборудование передано', + 'description': 'Оборудование передано в ваш отдел или вам', + 'priority': 'normal', + 'default_channels': {'web': True, 'email': False, 'telegram': False}, + }, + { + 'code': 'equipment_maintenance', + 'name': 'Обслуживание оборудования', + 'description': 'Запланировано или завершено обслуживание оборудования', + 'priority': 'low', + 'default_channels': {'web': True, 'email': False, 'telegram': False}, + }, + ] + + for nt_data in notification_types: + NotificationType.objects.get_or_create( + code=nt_data['code'], + defaults={ + 'category': category, + 'name': nt_data['name'], + 'description': nt_data['description'], + 'priority': nt_data['priority'], + 'default_channels': nt_data['default_channels'], + } + ) + + +def reverse_procurement_notification_types(apps, schema_editor): + """Удалить типы уведомлений для закупок.""" + NotificationCategory = apps.get_model('notifications', 'NotificationCategory') + NotificationType = apps.get_model('notifications', 'NotificationType') + + NotificationType.objects.filter(code__startswith='procurement_').delete() + NotificationType.objects.filter(code__startswith='equipment_').delete() + NotificationCategory.objects.filter(code='procurement').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_alter_telegramuser_telegram_id'), + ] + + operations = [ + migrations.RunPython( + create_procurement_notification_types, + reverse_procurement_notification_types, + ), + ] diff --git a/backend/procurement/README.md b/backend/procurement/README.md index 716a10a9..cf4bac51 100644 --- a/backend/procurement/README.md +++ b/backend/procurement/README.md @@ -2,35 +2,40 @@ ## Описание -Модуль для управления внутренними закупками по отделам и учета оборудования/инвентаря. +Модуль для управления внутренними закупками по отделам и учета оборудования/инвентаря с полной интеграцией уведомлений и real-time обновлений. ## Основные возможности ### 1. Заявки на закупку - Создание заявок с позициями (товары/услуги) -- Автоматический маршрут согласования по сумме: - - До 10,000₽ — только руководитель отдела - - 10,000-50,000₽ — руководитель отдела + финансовый менеджер - - Свыше 50,000₽ — руководитель отдела + фин. менеджер + директор -- Отслеживание статуса: черновик → согласование → одобрено → в работе → завершено +- Автоматический маршрут согласования по ролям: + - Руководитель отдела + - Финансовый менеджер + - Генеральный директор +- Отслеживание статуса: черновик → согласование → одобрено → заказано → получено → завершено - Уровни срочности: низкая, средняя, высокая, критическая +- **Real-time уведомления** при изменении статуса через WebSocket +- **Email уведомления** для согласующих ### 2. Бюджетный контроль - Бюджеты по отделам и кварталам - Автоматическая проверка доступности средств при создании заявки - Отслеживание использования бюджета в процентах +- Резервирование средств на период согласования ### 3. Инвентаризация оборудования - Учет оборудования с инвентарными номерами +- QR-коды для маркировки (автогенерация) - Иерархические категории оборудования -- Статусы: доступно, в использовании, на обслуживании, в ремонте, списано, утеряно +- Статусы: на складе, в использовании, на обслуживании, списано - Привязка к отделам и ответственным лицам - Отслеживание гарантийных сроков +- История передач оборудования между отделами ### 4. Техническое обслуживание - История обслуживания оборудования -- Типы ТО: осмотр, плановое ТО, ремонт, модернизация, чистка -- Планирование следующего обслуживания +- Типы ТО: осмотр, плановое ТО, ремонт, модернизация +- Учёт затрат на обслуживание ### 5. Поставщики - База данных поставщиков @@ -46,7 +51,31 @@ 5. **Equipment** — оборудование/инвентарь 6. **EquipmentCategory** — категории оборудования (иерархические) 7. **MaintenanceRecord** — записи техобслуживания -8. **Supplier** — поставщики +8. **EquipmentTransferLog** — история передач оборудования +9. **Supplier** — поставщики + +## Интеграция с системой + +### Уведомления +Модуль полностью интегрирован с системой уведомлений: +- `procurement_new_request` — новая заявка создана +- `procurement_pending_approval` — требуется согласование (высокий приоритет) +- `procurement_approved` — заявка одобрена +- `procurement_rejected` — заявка отклонена (высокий приоритет) +- `procurement_stage_approved` — этап согласования пройден +- `procurement_completed` — закупка завершена +- `equipment_transferred` — оборудование передано +- `equipment_maintenance` — обслуживание оборудования + +### WebSocket события +Real-time обновления через `realtime.consumers.UserConsumer`: +- `procurement_update` — изменение статуса заявки +- События транслируются всем пользователям отдела и создателю заявки + +### Сигналы +- `pre_save` — отслеживание изменений статуса +- `post_save` — отправка уведомлений и broadcast событий +- Автоматическое резервирование/освобождение бюджета ## Stage 1: Базовая структура ✅ diff --git a/backend/procurement/signals.py b/backend/procurement/signals.py index 79a11c0d..376582d7 100644 --- a/backend/procurement/signals.py +++ b/backend/procurement/signals.py @@ -1,9 +1,14 @@ """ Сигналы для модуля закупок. Автоматическая отправка уведомлений при изменении статусов. +WebSocket события для real-time обновлений. """ -from django.db.models.signals import post_save +import logging + +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from notifications.services import NotificationService @@ -11,12 +16,61 @@ from notifications.services import NotificationService from .constants import ApprovalStatus, ProcurementStatus from .models import Approval, ProcurementRequest +logger = logging.getLogger(__name__) + + +def broadcast_request_update(request, event_type='request_updated'): + """Отправить WebSocket событие об обновлении заявки.""" + try: + channel_layer = get_channel_layer() + if not channel_layer: + return + + # Данные заявки для broadcast + data = { + 'id': request.id, + 'title': request.title, + 'status': request.status, + 'status_display': request.get_status_display(), + 'department_id': request.department_id, + 'requestor_id': request.requestor_id, + } + + # Отправляем всем пользователям отдела + group_name = f'procurement_dept_{request.department_id}' + async_to_sync(channel_layer.group_send)( + group_name, + { + 'type': 'procurement_update', + 'event': event_type, + 'data': data, + } + ) + + # Отправляем создателю заявки + async_to_sync(channel_layer.group_send)( + f'user_{request.requestor_id}', + { + 'type': 'procurement_update', + 'event': event_type, + 'data': data, + } + ) + + logger.debug( + f"[Procurement WS] Broadcast {event_type} for request {request.id}" + ) + except Exception as e: + logger.warning(f"[Procurement WS] Broadcast failed: {e}") + @receiver(post_save, sender=ProcurementRequest) def notify_on_request_status_change(sender, instance, created, **kwargs): """Отправить уведомление при изменении статуса заявки.""" if created: - # Новая заявка создана + # Новая заявка создана - broadcast + broadcast_request_update(instance, 'request_created') + if instance.department.head: NotificationService.create_notification( recipient=instance.department.head, @@ -39,6 +93,9 @@ def notify_on_request_status_change(sender, instance, created, **kwargs): if old_status == new_status: return + # Broadcast обновления статуса + broadcast_request_update(instance, 'request_status_changed') + # Статус изменился if new_status == ProcurementStatus.PENDING: # Заявка отправлена на согласование @@ -150,21 +207,23 @@ def notify_requestor(request, notification_type_code, title, message): # Сохраняем исходный статус перед сохранением -@receiver(post_save, sender=ProcurementRequest) +@receiver(pre_save, sender=ProcurementRequest) def save_original_status(sender, instance, **kwargs): """Сохранить оригинальный статус для отслеживания изменений.""" if instance.pk: - original = ProcurementRequest.objects.filter( - pk=instance.pk - ).first() - if original: + try: + original = ProcurementRequest.objects.get(pk=instance.pk) instance._original_status = original.status + except ProcurementRequest.DoesNotExist: + pass -@receiver(post_save, sender=Approval) +@receiver(pre_save, sender=Approval) def save_original_approval_status(sender, instance, **kwargs): """Сохранить оригинальный статус согласования.""" if instance.pk: - original = Approval.objects.filter(pk=instance.pk).first() - if original: + try: + original = Approval.objects.get(pk=instance.pk) instance._original_approval_status = original.status + except Approval.DoesNotExist: + pass diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py index 819dbac3..d89f6de6 100644 --- a/backend/realtime/consumers.py +++ b/backend/realtime/consumers.py @@ -384,6 +384,19 @@ class UserConsumer(AsyncJsonWebsocketConsumer): "type": "unread_count", "count": event.get("count", 0) }) + + # ==================== Обработка событий закупок ==================== + + async def procurement_update(self, event): + """ + Обновление заявки на закупку. + Вызывается из procurement/signals.py + """ + await self.send_json({ + "type": "procurement_update", + "event": event.get("event", "request_updated"), + "data": event.get("data", {}) + }) # ==================== Обработка действий пользователя ==================== diff --git a/backend/tests/procurement/test_integration.py b/backend/tests/procurement/test_integration.py new file mode 100644 index 00000000..d1898443 --- /dev/null +++ b/backend/tests/procurement/test_integration.py @@ -0,0 +1,294 @@ +""" +Тесты интеграции модуля закупок. +Сигналы, уведомления, WebSocket события. +""" + +import pytest +from decimal import Decimal +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model + +from notifications.models import ( + Notification, + NotificationCategory, + NotificationType, +) +from procurement.constants import ApprovalRole, ApprovalStatus, ProcurementStatus +from procurement.models import ( + Approval, + Budget, + ProcurementItem, + ProcurementRequest, +) +from procurement.signals import broadcast_request_update + +Employee = get_user_model() + + +@pytest.fixture +def notification_setup(db): + """Настройка типов уведомлений для тестов.""" + category, _ = NotificationCategory.objects.get_or_create( + code='procurement', + defaults={ + 'name': 'Закупки', + 'description': 'Уведомления закупок', + 'icon': 'bi-cart3', + 'color': '#17a2b8', + 'order': 60, + } + ) + + notification_types = [ + 'procurement_new_request', + 'procurement_pending_approval', + 'procurement_approved', + 'procurement_rejected', + 'procurement_completed', + 'procurement_stage_approved', + ] + + for type_code in notification_types: + NotificationType.objects.get_or_create( + code=type_code, + defaults={ + 'category': category, + 'name': type_code.replace('_', ' ').title(), + 'description': f'Уведомление {type_code}', + 'priority': 'normal', + 'default_channels': { + 'web': True, + 'email': False, + 'telegram': False + }, + } + ) + + return category + + +@pytest.fixture +def department_with_head(department_factory, user_factory, link_factory): + """Отдел с руководителем.""" + dept = department_factory(name="IT отдел") + head = user_factory( + email="head@test.com", + first_name="Руководитель", + last_name="Отдела", + ) + head.is_staff = True + head.save() + dept.head = head + dept.save() + link_factory(head, dept, is_active=True) + return dept + + +@pytest.fixture +def requestor(user_factory, department_with_head, link_factory): + """Пользователь-заявитель.""" + user = user_factory( + email="requestor@test.com", + first_name="Заявитель", + last_name="Тестовый", + ) + link_factory(user, department_with_head, is_active=True) + return user + + +@pytest.fixture +def budget_for_department(department_with_head): + """Бюджет для отдела.""" + from django.utils import timezone + now = timezone.now() + return Budget.objects.create( + department=department_with_head, + year=now.year, + quarter=(now.month - 1) // 3 + 1, + allocated_amount=Decimal('100000.00'), + ) + + +@pytest.mark.django_db +class TestProcurementSignals: + """Тесты сигналов модуля закупок.""" + + def test_new_request_notifies_department_head( + self, notification_setup, department_with_head, requestor + ): + """Создание заявки уведомляет руководителя отдела.""" + # Очищаем уведомления + Notification.objects.all().delete() + + request = ProcurementRequest.objects.create( + title="Тестовая заявка", + description="Описание", + department=department_with_head, + requestor=requestor, + estimated_cost=Decimal('5000.00'), + ) + + # Проверяем уведомление руководителю + notifications = Notification.objects.filter( + recipient=department_with_head.head + ) + assert notifications.exists() + assert 'Новая заявка' in notifications.first().title + + def test_status_change_triggers_notification( + self, notification_setup, department_with_head, requestor, + budget_for_department + ): + """Изменение статуса создаёт уведомление.""" + Notification.objects.all().delete() + + request = ProcurementRequest.objects.create( + title="Заявка на согласование", + department=department_with_head, + requestor=requestor, + estimated_cost=Decimal('5000.00'), + ) + + # Добавляем позицию + ProcurementItem.objects.create( + request=request, + name="Тестовый товар", + quantity=1, + estimated_unit_price=Decimal('5000.00'), + ) + + # Создаём согласование + Approval.objects.create( + request=request, + approver=department_with_head.head, + role=ApprovalRole.DEPARTMENT_HEAD, + ) + + # Отправляем на согласование + request.status = ProcurementStatus.PENDING + request.save() + + # Проверяем уведомление согласующему + pending_notifications = Notification.objects.filter( + notification_type__code='procurement_pending_approval' + ) + assert pending_notifications.exists() + + +@pytest.mark.django_db +class TestBroadcastFunction: + """Тесты функции broadcast.""" + + @patch('procurement.signals.get_channel_layer') + def test_broadcast_sends_to_department_group( + self, mock_get_channel_layer, + notification_setup, department_with_head, requestor + ): + """broadcast_request_update отправляет в группу отдела.""" + mock_channel_layer = MagicMock() + mock_get_channel_layer.return_value = mock_channel_layer + + request = ProcurementRequest( + id=1, + title="Тест", + status=ProcurementStatus.DRAFT, + department=department_with_head, + requestor=requestor, + ) + + broadcast_request_update(request, 'test_event') + + # Проверяем вызов group_send + assert mock_channel_layer.group_send.call_count >= 1 + + @patch('procurement.signals.get_channel_layer') + def test_broadcast_handles_missing_channel_layer( + self, mock_get_channel_layer, + notification_setup, department_with_head, requestor + ): + """broadcast_request_update обрабатывает отсутствие channel_layer.""" + mock_get_channel_layer.return_value = None + + request = ProcurementRequest( + id=1, + title="Тест", + status=ProcurementStatus.DRAFT, + department=department_with_head, + requestor=requestor, + ) + + # Не должно вызвать исключение + broadcast_request_update(request, 'test_event') + + +@pytest.mark.django_db +class TestApprovalNotifications: + """Тесты уведомлений при согласовании.""" + + def test_approval_approved_notifies_requestor( + self, notification_setup, department_with_head, requestor, + budget_for_department + ): + """Одобрение согласования уведомляет заявителя.""" + Notification.objects.all().delete() + + request = ProcurementRequest.objects.create( + title="Заявка", + department=department_with_head, + requestor=requestor, + status=ProcurementStatus.PENDING, + estimated_cost=Decimal('5000.00'), + ) + + approval = Approval.objects.create( + request=request, + approver=department_with_head.head, + role=ApprovalRole.DEPARTMENT_HEAD, + status=ApprovalStatus.PENDING, + ) + + # Одобряем + approval.status = ApprovalStatus.APPROVED + approval.save() + + # Проверяем уведомление заявителю + stage_notifications = Notification.objects.filter( + recipient=requestor, + notification_type__code='procurement_stage_approved' + ) + assert stage_notifications.exists() + + def test_approval_rejected_notifies_requestor( + self, notification_setup, department_with_head, requestor, + budget_for_department + ): + """Отклонение согласования уведомляет заявителя.""" + Notification.objects.all().delete() + + request = ProcurementRequest.objects.create( + title="Заявка", + department=department_with_head, + requestor=requestor, + status=ProcurementStatus.PENDING, + estimated_cost=Decimal('5000.00'), + ) + + approval = Approval.objects.create( + request=request, + approver=department_with_head.head, + role=ApprovalRole.DEPARTMENT_HEAD, + status=ApprovalStatus.PENDING, + ) + + # Отклоняем + approval.status = ApprovalStatus.REJECTED + approval.comment = "Недостаточно обоснования" + approval.save() + + # Проверяем уведомление заявителю + reject_notifications = Notification.objects.filter( + recipient=requestor, + notification_type__code='procurement_rejected' + ) + assert reject_notifications.exists()