feat: add procurement integration with notifications and real-time

Stage 7 - Integration:
- Create notification types migration for procurement
- Fix signals to use pre_save for status tracking
- Add broadcast_request_update function for WebSocket events
- Add procurement_update handler in UserConsumer
- Add 6 integration tests for signals and notifications
- Update README with integration details

All 72 tests passing (66 base + 6 integration).
This commit is contained in:
admin i 2025-12-09 12:23:02 +03:00
parent 53a8f0e6b4
commit d04b5e38c2
5 changed files with 534 additions and 20 deletions

View File

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

View File

@ -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: Базовая структура ✅

View File

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

View File

@ -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", {})
})
# ==================== Обработка действий пользователя ====================

View File

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