diff --git a/backend/procurement/forms.py b/backend/procurement/forms.py new file mode 100644 index 00000000..f8441abf --- /dev/null +++ b/backend/procurement/forms.py @@ -0,0 +1,86 @@ +""" +Django Forms для модуля закупок. +""" + +from django import forms + +from .models import ProcurementRequest, ProcurementItem + + +class ProcurementRequestForm(forms.ModelForm): + """Форма создания/редактирования заявки на закупку.""" + + class Meta: + model = ProcurementRequest + fields = ['title', 'description', 'department', 'urgency'] + widgets = { + 'title': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Название заявки', + }), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 4, + 'placeholder': 'Описание и обоснование закупки', + }), + 'department': forms.Select(attrs={ + 'class': 'form-select', + }), + 'urgency': forms.Select(attrs={ + 'class': 'form-select', + }), + } + + def __init__(self, *args, user=None, **kwargs): + """Инициализация формы с фильтрацией отделов пользователя.""" + super().__init__(*args, **kwargs) + if user: + # Ограничиваем выбор отделов теми, в которых состоит юзер + user_departments = user.departments.filter( + employeedepartment__is_active=True + ) + self.fields['department'].queryset = user_departments + + # Если у пользователя один отдел - выбираем его по умолчанию + if user_departments.count() == 1: + self.fields['department'].initial = user_departments.first() + + +class ProcurementItemForm(forms.ModelForm): + """Форма добавления позиции в заявку.""" + + class Meta: + model = ProcurementItem + fields = [ + 'name', 'description', 'quantity', 'unit', + 'estimated_unit_price', 'supplier_info' + ] + widgets = { + 'name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Наименование', + }), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 2, + 'placeholder': 'Описание позиции', + }), + 'quantity': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': 1, + }), + 'unit': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'шт.', + }), + 'estimated_unit_price': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': 0.01, + 'step': '0.01', + }), + 'supplier_info': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 2, + 'placeholder': 'Ссылки на товар, контакты поставщика', + }), + } diff --git a/backend/procurement/views.py b/backend/procurement/views.py index 5a4cf7ac..39f93be2 100644 --- a/backend/procurement/views.py +++ b/backend/procurement/views.py @@ -65,7 +65,10 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): """Выбрать сериализатор в зависимости от действия.""" if self.action == 'create': return ProcurementRequestCreateSerializer - elif self.action in ['retrieve', 'submit', 'approve', 'reject']: + elif self.action in [ + 'retrieve', 'submit', 'approve', 'reject', + 'start_work', 'complete', 'cancel' + ]: return ProcurementRequestDetailSerializer return ProcurementRequestListSerializer @@ -388,6 +391,137 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + @action(detail=True, methods=['post']) + def start_work(self, request, pk=None): + """Начать работу над заявкой (перевод в статус IN_PROGRESS).""" + procurement_request = self.get_object() + + # Только автор заявки может начать работу + if procurement_request.requestor != request.user: + return Response( + {'error': 'Только автор заявки может начать работу'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Проверяем текущий статус + if procurement_request.status != ProcurementStatus.APPROVED: + return Response( + {'error': 'Только одобренные заявки можно взять в работу'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Меняем статус + procurement_request.status = ProcurementStatus.IN_PROGRESS + procurement_request.save() + + # Уведомляем согласующих о начале работы + for approval in procurement_request.approvals.filter( + status=ApprovalStatus.APPROVED + ): + NotificationService.create_notification( + recipient=approval.approver, + notification_type_code='procurement_in_progress', + title="Заявка взята в работу", + message=( + f'Заявка "{procurement_request.title}" ' + f'взята в работу пользователем ' + f'{request.user.get_full_name()}.' + ), + 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 complete(self, request, pk=None): + """Завершить заявку (перевод в статус COMPLETED).""" + procurement_request = self.get_object() + + # Только автор заявки может завершить её + if procurement_request.requestor != request.user: + return Response( + {'error': 'Только автор заявки может завершить её'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Проверяем текущий статус + if procurement_request.status != ProcurementStatus.IN_PROGRESS: + return Response( + {'error': 'Только заявки в работе можно завершить'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Меняем статус + procurement_request.status = ProcurementStatus.COMPLETED + procurement_request.save() + + # Уведомляем согласующих о завершении + for approval in procurement_request.approvals.filter( + status=ApprovalStatus.APPROVED + ): + NotificationService.create_notification( + recipient=approval.approver, + notification_type_code='procurement_completed', + title="Заявка завершена", + message=( + f'Заявка "{procurement_request.title}" ' + f'успешно завершена.' + ), + 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 cancel(self, request, pk=None): + """Отменить заявку.""" + procurement_request = self.get_object() + + # Только автор заявки может отменить её + if procurement_request.requestor != request.user: + return Response( + {'error': 'Только автор заявки может отменить её'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Нельзя отменить уже завершённую или отменённую заявку + if procurement_request.status in [ + ProcurementStatus.COMPLETED, + ProcurementStatus.CANCELLED, + ]: + return Response( + {'error': 'Эту заявку нельзя отменить'}, + status=status.HTTP_400_BAD_REQUEST + ) + + reason = request.data.get('reason', '') + + # Меняем статус + procurement_request.status = ProcurementStatus.CANCELLED + procurement_request.save() + + # Уведомляем согласующих об отмене + for approval in procurement_request.approvals.all(): + NotificationService.create_notification( + recipient=approval.approver, + notification_type_code='procurement_cancelled', + title="Заявка отменена", + message=( + f'Заявка "{procurement_request.title}" ' + f'была отменена автором. ' + f'Причина: {reason or "не указана"}' + ), + action_url=f'/procurement/requests/{procurement_request.id}/', + send_immediately=True, + ) + + serializer = self.get_serializer(procurement_request) + return Response(serializer.data) + class ProcurementItemViewSet(viewsets.ModelViewSet): """ViewSet для позиций заявок.""" @@ -406,6 +540,106 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): permission_classes = [CanCreateProcurementRequest] return [permission() for permission in permission_classes] + @action(detail=True, methods=['post']) + def create_equipment(self, request, pk=None): + """Создать оборудование из позиции закупки. + + Эндпоинт для ручного создания оборудования после получения товара. + Требуется: inventory_number, category, department. + Опционально: serial_number, location, warranty_until, responsible. + """ + item = self.get_object() + + # Проверяем, что заявка завершена + if item.request.status != ProcurementStatus.COMPLETED: + return Response( + {'error': 'Оборудование можно создать только из ' + 'завершённой заявки'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Проверяем, что оборудование еще не создано + if item.equipment is not None: + return Response( + {'error': 'Оборудование для этой позиции уже создано'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Получаем данные из запроса + inventory_number = request.data.get('inventory_number') + category_id = request.data.get('category') + department_id = request.data.get('department') + serial_number = request.data.get('serial_number', '') + location = request.data.get('location', '') + warranty_until = request.data.get('warranty_until') + responsible_person_id = request.data.get('responsible_person') + + # Валидация обязательных полей + if not inventory_number: + return Response( + {'error': 'Укажите инвентарный номер'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not category_id: + return Response( + {'error': 'Укажите категорию оборудования'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not department_id: + return Response( + {'error': 'Укажите отдел'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Проверяем уникальность инвентарного номера + exists = Equipment.objects.filter( + inventory_number=inventory_number + ).exists() + if exists: + return Response( + {'error': f'Инвентарный номер "{inventory_number}" ' + 'уже используется'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Создаем оборудование + from django.utils import timezone + try: + equipment = Equipment.objects.create( + name=item.name, + inventory_number=inventory_number, + serial_number=serial_number, + category_id=category_id, + department_id=department_id, + status=EquipmentStatus.AVAILABLE, + responsible_person_id=responsible_person_id, + location=location, + purchase_date=timezone.now().date(), + warranty_until=warranty_until, + purchase_cost=item.estimated_unit_price, + notes=item.description, # description -> notes + ) + except Exception as e: + return Response( + {'error': f'Ошибка создания оборудования: {str(e)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Связываем оборудование с позицией закупки + item.equipment = equipment + item.save() + + return Response({ + 'message': 'Оборудование успешно создано', + 'equipment': { + 'id': equipment.id, + 'name': equipment.name, + 'inventory_number': equipment.inventory_number, + } + }, status=status.HTTP_201_CREATED) + class EquipmentCategoryViewSet(viewsets.ModelViewSet): """ViewSet для категорий оборудования.""" diff --git a/backend/procurement/views_frontend.py b/backend/procurement/views_frontend.py index 673d2673..25834f11 100644 --- a/backend/procurement/views_frontend.py +++ b/backend/procurement/views_frontend.py @@ -2,6 +2,7 @@ HTML Views для модуля закупок (Frontend). """ +from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator @@ -9,6 +10,7 @@ from django.shortcuts import get_object_or_404, render, redirect from django.utils import timezone from employees.models import Department +from .forms import ProcurementRequestForm from .models import ( Budget, Equipment, @@ -204,11 +206,32 @@ def request_detail(request, pk): def request_create(request): """Страница/обработка создания заявки.""" if request.method == 'POST': - # Обработка формы - для простоты перенаправляем на API - # В реальности можно обработать здесь или использовать Django Forms - pass + form = ProcurementRequestForm(request.POST, user=request.user) + if form.is_valid(): + # Создаём заявку + procurement_request = form.save(commit=False) + procurement_request.requestor = request.user + procurement_request.status = ProcurementStatus.DRAFT + procurement_request.save() - context = {} + messages.success( + request, + f'Заявка "{procurement_request.title}" создана. ' + 'Добавьте позиции и отправьте на согласование.' + ) + return redirect( + 'procurement:request_detail', + pk=procurement_request.pk + ) + else: + messages.error(request, 'Пожалуйста, исправьте ошибки в форме.') + else: + form = ProcurementRequestForm(user=request.user) + + context = { + 'form': form, + 'page_title': 'Новая заявка', + } return render(request, 'procurement/request_form.html', context) diff --git a/backend/templates/procurement/request_detail.html b/backend/templates/procurement/request_detail.html index e4f1107b..c42141e9 100644 --- a/backend/templates/procurement/request_detail.html +++ b/backend/templates/procurement/request_detail.html @@ -201,6 +201,24 @@ {% endif %} + {% if request_obj.requestor == request.user and request_obj.status == 'approved' %} + + {% endif %} + + {% if request_obj.requestor == request.user and request_obj.status == 'in_progress' %} + + {% endif %} + + {% if request_obj.requestor == request.user and request_obj.status not in 'completed,cancelled' %} + + {% endif %} + К списку @@ -244,29 +262,195 @@ {% endblock %} +{# ===== Модальные окна ===== #} + +{# Модальное окно одобрения #} +{% if can_approve and request_obj.status == 'pending' %} +
+ +{# Модальное окно отклонения #} + +{% endif %} + +{# Модальное окно отмены заявки #} +{% if request_obj.requestor == request.user %} + +{% endif %} + {% block extra_js %} diff --git a/backend/tests/procurement/test_lifecycle.py b/backend/tests/procurement/test_lifecycle.py new file mode 100644 index 00000000..67f6d239 --- /dev/null +++ b/backend/tests/procurement/test_lifecycle.py @@ -0,0 +1,540 @@ +""" +Тесты для полного жизненного цикла заявок на закупку. +Тестируются endpoints: start_work, complete, cancel, create_equipment. +""" + +import pytest +from django.utils import timezone +from rest_framework import status + +from notifications.models import NotificationCategory, NotificationType +from procurement.constants import ( + ApprovalStatus, + EquipmentStatus, + ProcurementStatus, + UrgencyLevel, +) +from procurement.models import ( + Approval, + Budget, + Equipment, + EquipmentCategory, + ProcurementItem, + ProcurementRequest, +) + + +@pytest.mark.django_db +class TestRequestLifecycle: + """Тесты жизненного цикла заявок: start_work, complete, cancel.""" + + @pytest.fixture(autouse=True) + def setup( + self, api_client, department_factory, user_factory, link_factory + ): + """Подготовка данных для тестов.""" + # Создаем категорию и типы уведомлений + category, _ = NotificationCategory.objects.get_or_create( + code='procurement', + defaults={ + 'name': 'Закупки', + 'icon': 'bi-cart', + 'color': 'success', + 'order': 10, + } + ) + + notification_types = [ + 'procurement_pending_approval', + 'procurement_approved', + 'procurement_rejected', + 'procurement_in_progress', + 'procurement_completed', + 'procurement_cancelled', + '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 + }, + } + ) + + # Создаем отдел + self.department = department_factory(name="IT отдел") + + # Создаем пользователей + self.requestor = user_factory( + email="requestor@test.com", + first_name="Иван", + last_name="Иванов", + ) + link_factory(self.requestor, self.department, is_active=True) + + self.other_user = user_factory( + email="other@test.com", + first_name="Петр", + last_name="Петров", + ) + link_factory(self.other_user, self.department, is_active=True) + + self.dept_head = user_factory( + email="head@test.com", + first_name="Анна", + last_name="Сидорова", + ) + self.department.head = self.dept_head + self.department.save() + + # Создаем бюджет + Budget.objects.create( + department=self.department, + year=timezone.now().year, + quarter=(timezone.now().month - 1) // 3 + 1, + allocated_amount=100000, + spent_amount=0, + ) + + self.client = api_client + + def _create_request(self, request_status=ProcurementStatus.DRAFT): + """Хелпер для создания заявки.""" + request = ProcurementRequest.objects.create( + title='Тестовая заявка', + description='Описание', + department=self.department, + requestor=self.requestor, + status=request_status, + urgency=UrgencyLevel.MEDIUM, + estimated_cost=5000, + ) + ProcurementItem.objects.create( + request=request, + name='Тестовый товар', + quantity=1, + unit='шт', + estimated_unit_price=5000, + ) + return request + + # ===== start_work tests ===== + + def test_start_work_success(self): + """Тест начала работы над одобренной заявкой.""" + request = self._create_request(ProcurementStatus.APPROVED) + + # Добавляем approval (для уведомления) + Approval.objects.create( + request=request, + approver=self.dept_head, + role='department_head', + status=ApprovalStatus.APPROVED, + ) + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/start_work/' + ) + + assert response.status_code == status.HTTP_200_OK + request.refresh_from_db() + assert request.status == ProcurementStatus.IN_PROGRESS + + def test_start_work_forbidden_for_non_requestor(self): + """Только автор заявки может начать работу.""" + request = self._create_request(ProcurementStatus.APPROVED) + + self.client.force_authenticate(user=self.other_user) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/start_work/' + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_start_work_wrong_status(self): + """Нельзя начать работу над неодобренной заявкой.""" + request = self._create_request(ProcurementStatus.PENDING) + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/start_work/' + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'одобрен' in response.data['error'].lower() + + # ===== complete tests ===== + + def test_complete_success(self): + """Тест завершения заявки в работе.""" + request = self._create_request(ProcurementStatus.IN_PROGRESS) + + Approval.objects.create( + request=request, + approver=self.dept_head, + role='department_head', + status=ApprovalStatus.APPROVED, + ) + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/complete/' + ) + + assert response.status_code == status.HTTP_200_OK + request.refresh_from_db() + assert request.status == ProcurementStatus.COMPLETED + + def test_complete_forbidden_for_non_requestor(self): + """Только автор заявки может завершить её.""" + request = self._create_request(ProcurementStatus.IN_PROGRESS) + + self.client.force_authenticate(user=self.other_user) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/complete/' + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_complete_wrong_status(self): + """Нельзя завершить заявку, которая не в работе.""" + request = self._create_request(ProcurementStatus.APPROVED) + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/complete/' + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'в работе' in response.data['error'].lower() + + # ===== cancel tests ===== + + def test_cancel_draft_success(self): + """Тест отмены черновика.""" + request = self._create_request(ProcurementStatus.DRAFT) + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/cancel/', + {'reason': 'Передумал'} + ) + + assert response.status_code == status.HTTP_200_OK + request.refresh_from_db() + assert request.status == ProcurementStatus.CANCELLED + + def test_cancel_pending_success(self): + """Тест отмены заявки на согласовании.""" + request = self._create_request(ProcurementStatus.PENDING) + + Approval.objects.create( + request=request, + approver=self.dept_head, + role='department_head', + status=ApprovalStatus.PENDING, + ) + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/cancel/', + {'reason': 'Нашли другое решение'} + ) + + assert response.status_code == status.HTTP_200_OK + request.refresh_from_db() + assert request.status == ProcurementStatus.CANCELLED + + def test_cancel_in_progress_success(self): + """Тест отмены заявки в работе.""" + request = self._create_request(ProcurementStatus.IN_PROGRESS) + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/cancel/' + ) + + assert response.status_code == status.HTTP_200_OK + request.refresh_from_db() + assert request.status == ProcurementStatus.CANCELLED + + def test_cannot_cancel_completed(self): + """Нельзя отменить завершённую заявку.""" + request = self._create_request(ProcurementStatus.COMPLETED) + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/cancel/' + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_cannot_cancel_already_cancelled(self): + """Нельзя отменить уже отменённую заявку.""" + request = self._create_request(ProcurementStatus.CANCELLED) + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/cancel/' + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_cancel_forbidden_for_non_requestor(self): + """Только автор заявки может отменить её.""" + request = self._create_request(ProcurementStatus.PENDING) + + self.client.force_authenticate(user=self.other_user) + + response = self.client.post( + f'/api/procurement/requests/{request.id}/cancel/' + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +class TestCreateEquipmentFromItem: + """Тесты создания оборудования из позиции закупки.""" + + @pytest.fixture(autouse=True) + def setup( + self, api_client, department_factory, user_factory, link_factory + ): + """Подготовка данных для тестов.""" + # Создаем отдел + self.department = department_factory(name="IT отдел") + + # Создаем пользователя + self.requestor = user_factory( + email="requestor@test.com", + first_name="Иван", + last_name="Иванов", + ) + link_factory(self.requestor, self.department, is_active=True) + + # Создаем категорию оборудования + self.category = EquipmentCategory.objects.create( + name='Компьютерное оборудование', + description='Компьютеры и периферия' + ) + + self.client = api_client + + def _create_completed_request_with_item(self): + """Хелпер: создаёт завершённую заявку с позицией.""" + request = ProcurementRequest.objects.create( + title='Закупка оборудования', + description='Описание', + department=self.department, + requestor=self.requestor, + status=ProcurementStatus.COMPLETED, + urgency=UrgencyLevel.MEDIUM, + estimated_cost=50000, + ) + item = ProcurementItem.objects.create( + request=request, + name='Ноутбук Dell XPS', + description='Мощный ноутбук для разработки', + quantity=1, + unit='шт', + estimated_unit_price=50000, + ) + return request, item + + def test_create_equipment_success(self): + """Тест успешного создания оборудования из позиции.""" + _, item = self._create_completed_request_with_item() + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/items/{item.id}/create_equipment/', + { + 'inventory_number': 'INV-2025-0001', + 'category': self.category.id, + 'department': self.department.id, + 'serial_number': 'SN123456', + 'location': 'Офис 3.14', + }, + format='json' + ) + + assert response.status_code == status.HTTP_201_CREATED + assert 'equipment' in response.data + assert response.data['equipment']['name'] == 'Ноутбук Dell XPS' + + # Проверяем связь item -> equipment + item.refresh_from_db() + assert item.equipment is not None + assert item.equipment.inventory_number == 'INV-2025-0001' + assert item.equipment.category == self.category + + def test_create_equipment_requires_inventory_number(self): + """Инвентарный номер обязателен.""" + _, item = self._create_completed_request_with_item() + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/items/{item.id}/create_equipment/', + { + 'category': self.category.id, + 'department': self.department.id, + }, + format='json' + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'инвентарн' in response.data['error'].lower() + + def test_create_equipment_requires_category(self): + """Категория обязательна.""" + _, item = self._create_completed_request_with_item() + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/items/{item.id}/create_equipment/', + { + 'inventory_number': 'INV-2025-0002', + 'department': self.department.id, + }, + format='json' + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'категор' in response.data['error'].lower() + + def test_create_equipment_requires_department(self): + """Отдел обязателен.""" + _, item = self._create_completed_request_with_item() + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/items/{item.id}/create_equipment/', + { + 'inventory_number': 'INV-2025-0003', + 'category': self.category.id, + }, + format='json' + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'отдел' in response.data['error'].lower() + + def test_cannot_create_equipment_from_non_completed_request(self): + """Нельзя создать оборудование из незавершённой заявки.""" + request = ProcurementRequest.objects.create( + title='Заявка в работе', + description='Ещё не завершена', + department=self.department, + requestor=self.requestor, + status=ProcurementStatus.IN_PROGRESS, + urgency=UrgencyLevel.MEDIUM, + estimated_cost=10000, + ) + item = ProcurementItem.objects.create( + request=request, + name='Монитор', + quantity=1, + unit='шт', + estimated_unit_price=10000, + ) + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/items/{item.id}/create_equipment/', + { + 'inventory_number': 'INV-2025-0004', + 'category': self.category.id, + 'department': self.department.id, + }, + format='json' + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'завершённ' in response.data['error'].lower() + + def test_cannot_create_equipment_twice(self): + """Нельзя создать оборудование дважды для одной позиции.""" + _, item = self._create_completed_request_with_item() + + # Создаём оборудование вручную + equipment = Equipment.objects.create( + name='Существующее', + inventory_number='INV-EXISTING', + category=self.category, + department=self.department, + status=EquipmentStatus.AVAILABLE, + purchase_date=timezone.now().date(), + purchase_cost=50000, + ) + item.equipment = equipment + item.save() + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/items/{item.id}/create_equipment/', + { + 'inventory_number': 'INV-2025-0005', + 'category': self.category.id, + 'department': self.department.id, + }, + format='json' + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'уже создано' in response.data['error'].lower() + + def test_duplicate_inventory_number_rejected(self): + """Дублирующийся инвентарный номер отклоняется.""" + _, item = self._create_completed_request_with_item() + + # Создаём существующее оборудование с этим номером + Equipment.objects.create( + name='Существующее оборудование', + inventory_number='INV-DUPLICATE', + category=self.category, + department=self.department, + status=EquipmentStatus.AVAILABLE, + purchase_date=timezone.now().date(), + purchase_cost=10000, + ) + + self.client.force_authenticate(user=self.requestor) + + response = self.client.post( + f'/api/procurement/items/{item.id}/create_equipment/', + { + 'inventory_number': 'INV-DUPLICATE', + 'category': self.category.id, + 'department': self.department.id, + }, + format='json' + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'используется' in response.data['error'].lower()