""" ViewSets для API модуля закупок. """ from django.db.models import Q from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, permissions, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response from employees.models import Department from .constants import ApprovalStatus, ProcurementStatus, EquipmentStatus from .models import ( Approval, Budget, Equipment, EquipmentCategory, EquipmentTransferLog, MaintenanceRecord, ProcurementItem, ProcurementRequest, Supplier, ) from .services import QRCodeGenerator from .permissions import ( CanApproveProcurementRequest, CanCreateProcurementRequest, CanEditOwnProcurementRequest, CanManageBudget, CanManageEquipment, CanManageSupplier, ) from .serializers import ( BudgetSerializer, EquipmentCategorySerializer, EquipmentDetailSerializer, EquipmentListSerializer, MaintenanceRecordSerializer, ProcurementItemSerializer, ProcurementRequestCreateSerializer, ProcurementRequestDetailSerializer, ProcurementRequestListSerializer, SupplierSerializer, ) from notifications.services import NotificationService class ProcurementRequestViewSet(viewsets.ModelViewSet): """ViewSet для заявок на закупку.""" queryset = ProcurementRequest.objects.select_related( 'department', 'requestor' ).prefetch_related('items', 'approvals') filter_backends = [ DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter, ] filterset_fields = ['status', 'urgency', 'department'] search_fields = ['title', 'description'] ordering_fields = ['created_at', 'estimated_cost', 'status'] ordering = ['-created_at'] def get_serializer_class(self): """Выбрать сериализатор в зависимости от действия.""" if self.action == 'create': return ProcurementRequestCreateSerializer elif self.action in ['retrieve', 'submit', 'approve', 'reject']: return ProcurementRequestDetailSerializer return ProcurementRequestListSerializer def get_permissions(self): """Выбрать права доступа в зависимости от действия.""" if self.action == 'create': permission_classes = [CanCreateProcurementRequest] elif self.action in ['update', 'partial_update', 'destroy']: permission_classes = [CanEditOwnProcurementRequest] elif self.action in ['approve', 'reject']: permission_classes = [CanApproveProcurementRequest] else: permission_classes = [CanCreateProcurementRequest] return [permission() for permission in permission_classes] def get_queryset(self): """Фильтровать заявки в зависимости от роли пользователя.""" queryset = super().get_queryset() user = self.request.user if user.is_superuser or user.is_staff: return queryset # Показываем: свои заявки + заявки отдела + где я approver return queryset.filter( Q(requestor=user) | Q(department__in=user.departments.all()) | Q(approvals__approver=user) ).distinct() def _check_budget_alert(self, procurement_request): """Проверяет бюджет и отправляет алерт если низкий остаток.""" from django.utils import timezone from decimal import Decimal department = procurement_request.department now = timezone.now() quarter = (now.month - 1) // 3 + 1 try: budget = Budget.objects.get( department=department, year=now.year, quarter=quarter ) except Budget.DoesNotExist: return # Нет бюджета - не отправляем алерт # Пороговые значения для алертов LOW_BUDGET_THRESHOLD = Decimal('0.20') # 20% остатка CRITICAL_BUDGET_THRESHOLD = Decimal('0.10') # 10% остатка utilization = budget.utilization_percentage / 100 remaining_ratio = 1 - utilization # Получаем руководителя отдела dept_head = department.head if not dept_head: return if remaining_ratio <= CRITICAL_BUDGET_THRESHOLD: # Критический уровень - менее 10% NotificationService.create_notification( recipient=dept_head, notification_type_code='budget_critical', title="⚠️ Критически низкий бюджет!", message=( f'Бюджет отдела "{department.name}" почти исчерпан! ' f'Остаток: {budget.remaining_amount}₽ ' f'({remaining_ratio*100:.1f}%)' ), action_url='/procurement/budgets/', send_immediately=True, ) elif remaining_ratio <= LOW_BUDGET_THRESHOLD: # Низкий уровень - менее 20% NotificationService.create_notification( recipient=dept_head, notification_type_code='budget_low', title="Низкий остаток бюджета", message=( f'Бюджет отдела "{department.name}" снижается. ' f'Остаток: {budget.remaining_amount}₽ ' f'({remaining_ratio*100:.1f}%)' ), action_url='/procurement/budgets/', send_immediately=True, ) @action(detail=True, methods=['post']) def submit(self, request, pk=None): """Отправить заявку на согласование.""" procurement_request = self.get_object() # Проверки if procurement_request.requestor != request.user: return Response( {'error': 'Вы не можете отправить чужую заявку'}, status=status.HTTP_403_FORBIDDEN ) if not procurement_request.is_editable: return Response( {'error': 'Заявка уже отправлена на согласование'}, status=status.HTTP_400_BAD_REQUEST ) if procurement_request.items.count() == 0: return Response( {'error': 'Добавьте хотя бы одну позицию в заявку'}, status=status.HTTP_400_BAD_REQUEST ) # Проверка бюджета available, remaining = procurement_request.check_budget_available() if not available: return Response( { 'error': 'Недостаточно бюджета', 'remaining': float(remaining), 'required': float(procurement_request.estimated_cost), }, status=status.HTTP_400_BAD_REQUEST ) # Меняем статус procurement_request.status = ProcurementStatus.PENDING procurement_request.save() # Создаем записи согласований required_approvals = procurement_request.get_required_approvals() from procurement.constants import ApprovalRole created_approvals = [] for role in required_approvals: # Определяем согласующего approver = None if role == ApprovalRole.DEPARTMENT_HEAD: approver = procurement_request.department.head elif role == ApprovalRole.FINANCE_MANAGER: # Найдем первого пользователя с правом на бюджеты from employees.models import Employee from django.db.models import Q approver = Employee.objects.filter( is_active=True ).filter( Q(groups__permissions__codename='change_budget') | Q(user_permissions__codename='change_budget') ).distinct().first() elif role == ApprovalRole.DIRECTOR: # Найдем директора (суперпользователя) from employees.models import Employee approver = Employee.objects.filter( is_superuser=True ).first() if approver: approval = Approval.objects.create( request=procurement_request, approver=approver, role=role, status=ApprovalStatus.PENDING ) created_approvals.append(approval) # Отправляем уведомления согласующим через NotificationService for approval in created_approvals: NotificationService.create_notification( recipient=approval.approver, notification_type_code='procurement_pending_approval', title="Требуется согласование заявки", message=( f'Заявка "{procurement_request.title}" ' f'ожидает вашего согласования. ' f'Сумма: {procurement_request.estimated_cost}₽' ), action_url=f'/procurement/requests/{procurement_request.id}/', send_immediately=True, ) serializer = self.get_serializer(procurement_request) return Response(serializer.data) @action(detail=True, methods=['post']) def approve(self, request, pk=None): """Одобрить заявку.""" procurement_request = self.get_object() # Проверяем права self.check_object_permissions(request, procurement_request) # Находим согласование текущего пользователя approval = procurement_request.approvals.filter( approver=request.user, status=ApprovalStatus.PENDING ).first() if not approval: return Response( {'error': 'У вас нет прав на согласование этой заявки'}, status=status.HTTP_403_FORBIDDEN ) # Одобряем approval.status = ApprovalStatus.APPROVED approval.comment = request.data.get('comment', '') approval.save() # Отправляем уведомление о согласовании NotificationService.create_notification( recipient=procurement_request.requestor, notification_type_code='procurement_stage_approved', title="Этап согласования пройден", message=( f'{request.user.get_full_name()} одобрил ' f'заявку "{procurement_request.title}".' ), action_url=f'/procurement/requests/{procurement_request.id}/', send_immediately=True, ) # Проверяем, все ли одобрили pending_approvals = procurement_request.approvals.filter( status=ApprovalStatus.PENDING ).count() if pending_approvals == 0: # Все одобрили - меняем статус заявки procurement_request.status = ProcurementStatus.APPROVED procurement_request.save() # Отправляем уведомление о полном одобрении NotificationService.create_notification( recipient=procurement_request.requestor, notification_type_code='procurement_approved', title="Заявка одобрена", message=( f'Ваша заявка "{procurement_request.title}" ' f'была полностью одобрена. Можно приступать к закупке.' ), action_url=f'/procurement/requests/{procurement_request.id}/', send_immediately=True, ) # Проверяем бюджет и отправляем алерт если низкий self._check_budget_alert(procurement_request) serializer = self.get_serializer(procurement_request) return Response(serializer.data) @action(detail=True, methods=['post']) def reject(self, request, pk=None): """Отклонить заявку.""" procurement_request = self.get_object() # Проверяем права self.check_object_permissions(request, procurement_request) # Находим согласование текущего пользователя approval = procurement_request.approvals.filter( approver=request.user, status=ApprovalStatus.PENDING ).first() if not approval: return Response( {'error': 'У вас нет прав на согласование этой заявки'}, status=status.HTTP_403_FORBIDDEN ) # Отклоняем approval.status = ApprovalStatus.REJECTED approval.comment = request.data.get('comment', '') approval.save() # Меняем статус заявки procurement_request.status = ProcurementStatus.REJECTED procurement_request.save() # Отправляем уведомление об отклонении NotificationService.create_notification( recipient=procurement_request.requestor, notification_type_code='procurement_rejected', title="Заявка отклонена", message=( f'{request.user.get_full_name()} отклонил ' f'заявку "{procurement_request.title}". ' f'Причина: {approval.comment}' ), action_url=f'/procurement/requests/{procurement_request.id}/', send_immediately=True, ) serializer = self.get_serializer(procurement_request) return Response(serializer.data) @action(detail=False, methods=['get']) def my_requests(self, request): """Получить заявки текущего пользователя.""" queryset = self.get_queryset().filter(requestor=request.user) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @action(detail=False, methods=['get']) def pending_approvals(self, request): """Получить заявки, ожидающие согласования текущим польз.""" # Находим заявки где есть pending approval для текущего юзера queryset = self.get_queryset().filter( approvals__approver=request.user, approvals__status=ApprovalStatus.PENDING ) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) class ProcurementItemViewSet(viewsets.ModelViewSet): """ViewSet для позиций заявок.""" queryset = ProcurementItem.objects.select_related('request') serializer_class = ProcurementItemSerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ['request'] search_fields = ['name', 'description'] def get_permissions(self): """Только создатель заявки может редактировать позиции.""" if self.action in ['create', 'update', 'partial_update', 'destroy']: permission_classes = [CanEditOwnProcurementRequest] else: permission_classes = [CanCreateProcurementRequest] return [permission() for permission in permission_classes] class EquipmentCategoryViewSet(viewsets.ModelViewSet): """ViewSet для категорий оборудования.""" queryset = EquipmentCategory.objects.prefetch_related('children') serializer_class = EquipmentCategorySerializer filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ['name', 'description'] ordering = ['name'] @action(detail=True, methods=['get']) def children(self, request, pk=None): """Получить подкатегории.""" category = self.get_object() serializer = self.get_serializer( category.children.all(), many=True ) return Response(serializer.data) @action(detail=False, methods=['get']) def tree(self, request): """Получить дерево категорий.""" # Получаем только корневые категории root_categories = self.get_queryset().filter(parent=None) serializer = self.get_serializer(root_categories, many=True) return Response(serializer.data) class EquipmentViewSet(viewsets.ModelViewSet): """ViewSet для оборудования.""" queryset = Equipment.objects.select_related( 'category', 'department', 'responsible_person' ) filter_backends = [ DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter, ] filterset_fields = ['status', 'category', 'department'] search_fields = [ 'name', 'inventory_number', 'serial_number', 'location', ] ordering_fields = ['purchase_date', 'name'] ordering = ['-purchase_date'] def get_serializer_class(self): """Выбрать сериализатор.""" if self.action == 'retrieve': return EquipmentDetailSerializer return EquipmentListSerializer def get_permissions(self): """Права доступа.""" permission_classes = [CanManageEquipment] return [permission() for permission in permission_classes] @action(detail=False, methods=['get']) def my_equipment(self, request): """Оборудование, за которое отвечает текущий пользователь.""" queryset = self.get_queryset().filter( responsible_person=request.user ) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @action(detail=False, methods=['get']) def warranty_expiring(self, request): """Оборудование с истекающей гарантией (< 30 дней).""" from datetime import date, timedelta threshold = date.today() + timedelta(days=30) queryset = self.get_queryset().filter( warranty_until__lte=threshold, warranty_until__gte=date.today() ) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @action(detail=True, methods=['post']) def transfer(self, request, pk=None): """Перевод оборудования в другой отдел или другому пользователю.""" equipment = self.get_object() to_department_id = request.data.get('to_department') to_person_id = request.data.get('to_person') reason = request.data.get('reason', '') if not to_department_id and not to_person_id: return Response( {'error': 'Укажите отдел или ответственного'}, status=status.HTTP_400_BAD_REQUEST ) # Сохраняем старые значения from_department = equipment.department from_person = equipment.responsible_person # Обновляем оборудование if to_department_id: try: to_department = Department.objects.get(pk=to_department_id) equipment.department = to_department except Department.DoesNotExist: return Response( {'error': 'Отдел не найден'}, status=status.HTTP_404_NOT_FOUND ) else: to_department = from_department if to_person_id: from django.contrib.auth import get_user_model User = get_user_model() try: to_person = User.objects.get(pk=to_person_id) equipment.responsible_person = to_person except User.DoesNotExist: return Response( {'error': 'Пользователь не найден'}, status=status.HTTP_404_NOT_FOUND ) else: to_person = from_person equipment.save() # Создаём лог перевода EquipmentTransferLog.objects.create( equipment=equipment, from_department=from_department, to_department=to_department, from_person=from_person, to_person=to_person, reason=reason, created_by=request.user ) return Response({ 'status': 'transferred', 'equipment_id': equipment.id, 'from_department': str(from_department), 'to_department': str(to_department) }) @action(detail=True, methods=['post']) def write_off(self, request, pk=None): """Списание оборудования.""" equipment = self.get_object() reason = request.data.get('reason', '') if equipment.status == EquipmentStatus.RETIRED: return Response( {'error': 'Оборудование уже списано'}, status=status.HTTP_400_BAD_REQUEST ) equipment.status = EquipmentStatus.RETIRED equipment.notes = (equipment.notes or '') + f'\n\nСписано: {reason}' equipment.save() return Response({ 'status': 'written_off', 'equipment_id': equipment.id }) @action(detail=True, methods=['post']) def add_maintenance(self, request, pk=None): """Добавить запись об обслуживании.""" from datetime import date equipment = self.get_object() maintenance_type = request.data.get('type', 'repair') description = request.data.get('description', '') cost = request.data.get('cost') maintenance_date = request.data.get('date', date.today()) record = MaintenanceRecord.objects.create( equipment=equipment, type=maintenance_type, description=description, cost=cost, date=maintenance_date, performed_by=request.user ) return Response({ 'status': 'created', 'maintenance_id': record.id, 'equipment_id': equipment.id }, status=status.HTTP_201_CREATED) @action(detail=True, methods=['get']) def qr_code(self, request, pk=None): """Получить QR-код для оборудования.""" equipment = self.get_object() qr_file = QRCodeGenerator.generate_for_equipment(equipment) from django.http import HttpResponse response = HttpResponse(qr_file.read(), content_type='image/png') response['Content-Disposition'] = ( f'inline; filename="equipment_{equipment.id}_qr.png"' ) return response @action(detail=True, methods=['get']) def transfer_history(self, request, pk=None): """История переводов оборудования.""" equipment = self.get_object() logs = EquipmentTransferLog.objects.filter( equipment=equipment ).order_by('-created_at') data = [ { 'id': log.id, 'from_department': str(log.from_department), 'to_department': str(log.to_department), 'from_person': ( str(log.from_person) if log.from_person else None ), 'to_person': ( str(log.to_person) if log.to_person else None ), 'reason': log.reason, 'created_by': str(log.created_by) if log.created_by else None, 'date': log.created_at.isoformat() } for log in logs ] return Response(data) class MaintenanceRecordViewSet(viewsets.ModelViewSet): """ViewSet для записей обслуживания.""" queryset = MaintenanceRecord.objects.select_related( 'equipment', 'performed_by' ) serializer_class = MaintenanceRecordSerializer filter_backends = [ DjangoFilterBackend, filters.OrderingFilter, ] filterset_fields = ['equipment', 'type', 'performed_by'] ordering_fields = ['date'] ordering = ['-date'] def perform_create(self, serializer): """При создании записи устанавливаем performed_by.""" serializer.save(performed_by=self.request.user) class BudgetViewSet(viewsets.ModelViewSet): """ViewSet для бюджетов.""" queryset = Budget.objects.select_related('department') serializer_class = BudgetSerializer filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filterset_fields = ['department', 'year', 'quarter'] ordering_fields = ['year', 'quarter'] ordering = ['-year', '-quarter'] def get_permissions(self): """Права доступа.""" if self.action == 'my_department': # my_department доступен любому авторизованному пользователю permission_classes = [permissions.IsAuthenticated] else: permission_classes = [CanManageBudget] return [permission() for permission in permission_classes] def get_queryset(self): """Фильтровать бюджеты.""" queryset = super().get_queryset() user = self.request.user if user.is_superuser or user.is_staff: return queryset # Руководители видят бюджеты своих отделов if user.headed_departments.exists(): return queryset.filter( department__in=user.headed_departments.all() ) return queryset.none() @action(detail=False, methods=['get']) def current_quarter(self, request): """Получить бюджеты текущего квартала.""" from django.utils import timezone now = timezone.now() quarter = (now.month - 1) // 3 + 1 queryset = self.get_queryset().filter( year=now.year, quarter=quarter ) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @action(detail=False, methods=['get'], url_path='my-department') def my_department(self, request): """Получить бюджет текущего квартала для отдела пользователя.""" from django.utils import timezone from .serializers import BudgetDetailSerializer user = request.user now = timezone.now() quarter = (now.month - 1) // 3 + 1 # Получаем отдел пользователя user_departments = user.departments.all() if not user_departments.exists(): return Response( {'detail': 'Вы не состоите ни в одном отделе.'}, status=status.HTTP_404_NOT_FOUND ) # Берём первый отдел (основной) department = user_departments.first() try: budget = Budget.objects.get( department=department, year=now.year, quarter=quarter ) except Budget.DoesNotExist: msg = ( f'Бюджет для {department.name} ' f'на Q{quarter} {now.year} не найден.' ) return Response( {'detail': msg}, status=status.HTTP_404_NOT_FOUND ) serializer = BudgetDetailSerializer(budget) return Response(serializer.data) class SupplierViewSet(viewsets.ModelViewSet): """ViewSet для поставщиков.""" queryset = Supplier.objects.all() serializer_class = SupplierSerializer filter_backends = [ DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter, ] filterset_fields = ['is_active'] search_fields = ['name', 'contact_person', 'inn'] ordering_fields = ['name', 'rating'] ordering = ['name'] def get_permissions(self): """Права доступа.""" permission_classes = [CanManageSupplier] return [permission() for permission in permission_classes] @action(detail=False, methods=['get']) def top_rated(self, request): """Получить поставщиков с лучшим рейтингом.""" queryset = self.get_queryset().filter( is_active=True, rating__gte=4.0 ).order_by('-rating')[:10] serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) class ProcurementStatsViewSet(viewsets.ViewSet): """ViewSet для статистики закупок.""" permission_classes = [permissions.IsAuthenticated] @action(detail=False, methods=['get']) def overview(self, request): """Общая статистика закупок.""" from django.db.models import Sum, Count from django.utils import timezone now = timezone.now() month_start = now.replace(day=1, hour=0, minute=0, second=0) year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0) user = request.user # Базовый queryset зависит от прав пользователя if user.is_superuser or user.is_staff: base_qs = ProcurementRequest.objects.all() else: base_qs = ProcurementRequest.objects.filter( Q(requestor=user) | Q(department__in=user.departments.all()) ) # Подсчёт по статусам by_status = dict( base_qs.values('status').annotate( count=Count('id') ).values_list('status', 'count') ) # Подсчёт по срочности by_urgency = dict( base_qs.values('urgency').annotate( count=Count('id') ).values_list('urgency', 'count') ) # Общие метрики total = base_qs.count() pending = base_qs.filter(status=ProcurementStatus.PENDING).count() approved_month = base_qs.filter( status=ProcurementStatus.APPROVED, updated_at__gte=month_start ).count() completed_month = base_qs.filter( status=ProcurementStatus.COMPLETED, completed_at__gte=month_start ).count() # Сумма потраченного за год spent_year = base_qs.filter( status=ProcurementStatus.COMPLETED, completed_at__gte=year_start ).aggregate(total=Sum('actual_cost'))['total'] or 0 return Response({ 'total_requests': total, 'pending_requests': pending, 'approved_this_month': approved_month, 'completed_this_month': completed_month, 'total_spent_this_year': str(spent_year), 'by_status': by_status, 'by_urgency': by_urgency, }) @action(detail=False, methods=['get'], url_path='by-department') def by_department(self, request): """Статистика по отделам.""" from django.db.models import Sum user = request.user # Только для staff/superuser или руководителей if not (user.is_superuser or user.is_staff): if not user.headed_departments.exists(): return Response( {'detail': 'Нет доступа к статистике по отделам.'}, status=status.HTTP_403_FORBIDDEN ) departments = user.headed_departments.all() else: departments = Department.objects.all() result = [] for dept in departments: requests = ProcurementRequest.objects.filter(department=dept) total = requests.count() spent = requests.filter( status=ProcurementStatus.COMPLETED ).aggregate(total=Sum('actual_cost'))['total'] or 0 # Получаем текущий бюджет from django.utils import timezone now = timezone.now() quarter = (now.month - 1) // 3 + 1 try: budget = Budget.objects.get( department=dept, year=now.year, quarter=quarter ) utilization = float(budget.utilization_percentage) except Budget.DoesNotExist: utilization = 0.0 result.append({ 'department': {'id': dept.id, 'name': dept.name}, 'total_requests': total, 'total_spent': str(spent), 'budget_utilization': utilization, }) return Response(result)