feat(procurement): полный жизненный цикл заявок

Добавлены endpoints:
- start_work: APPROVED → IN_PROGRESS
- complete: IN_PROGRESS → COMPLETED
- cancel: отмена заявки с любого статуса кроме завершённых
- create_equipment: создание оборудования из позиции завершённой заявки

Frontend:
- Модальные окна approve/reject/cancel
- POST handler для создания заявок
- Django формы для заявок

Тесты: 19 новых тестов lifecycle, всего 136 тестов procurement passed
This commit is contained in:
admin i 2025-12-09 16:46:21 +03:00
parent 8b8974e3ef
commit ccbc8c5452
5 changed files with 1087 additions and 20 deletions

View File

@ -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': 'Ссылки на товар, контакты поставщика',
}),
}

View File

@ -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 для категорий оборудования."""

View File

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

View File

@ -201,6 +201,24 @@
</button>
{% endif %}
{% if request_obj.requestor == request.user and request_obj.status == 'approved' %}
<button class="btn btn-primary" id="startWorkBtn">
<i class="bi-play-fill me-2"></i>Начать работу
</button>
{% endif %}
{% if request_obj.requestor == request.user and request_obj.status == 'in_progress' %}
<button class="btn btn-success" id="completeBtn">
<i class="bi-check-circle me-2"></i>Завершить
</button>
{% endif %}
{% if request_obj.requestor == request.user and request_obj.status not in 'completed,cancelled' %}
<button class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#cancelModal">
<i class="bi-x-circle me-2"></i>Отменить заявку
</button>
{% endif %}
<a href="{% url 'procurement_frontend:request_list' %}" class="btn btn-outline-secondary">
<i class="bi-arrow-left me-2"></i>К списку
</a>
@ -244,29 +262,195 @@
{% endblock %}
{# ===== Модальные окна ===== #}
{# Модальное окно одобрения #}
{% if can_approve and request_obj.status == 'pending' %}
<div class="modal fade" id="approveModal" tabindex="-1" aria-labelledby="approveModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="approveModalLabel">
<i class="bi-check-lg me-2"></i>Одобрить заявку
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<p>Вы собираетесь одобрить заявку <strong>"{{ request_obj.title }}"</strong></p>
<p>Сумма: <strong>{{ request_obj.estimated_cost|floatformat:2 }} ₽</strong></p>
<div class="mb-3">
<label for="approveComment" class="form-label">Комментарий (необязательно)</label>
<textarea class="form-control" id="approveComment" rows="3" placeholder="Ваш комментарий..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-success" id="confirmApprove">
<i class="bi-check-lg me-2"></i>Одобрить
</button>
</div>
</div>
</div>
</div>
{# Модальное окно отклонения #}
<div class="modal fade" id="rejectModal" tabindex="-1" aria-labelledby="rejectModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="rejectModalLabel">
<i class="bi-x-lg me-2"></i>Отклонить заявку
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<p>Вы собираетесь отклонить заявку <strong>"{{ request_obj.title }}"</strong></p>
<div class="mb-3">
<label for="rejectComment" class="form-label">Причина отклонения <span class="text-danger">*</span></label>
<textarea class="form-control" id="rejectComment" rows="3" placeholder="Укажите причину отклонения..." required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-danger" id="confirmReject">
<i class="bi-x-lg me-2"></i>Отклонить
</button>
</div>
</div>
</div>
</div>
{% endif %}
{# Модальное окно отмены заявки #}
{% if request_obj.requestor == request.user %}
<div class="modal fade" id="cancelModal" tabindex="-1" aria-labelledby="cancelModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-warning">
<h5 class="modal-title" id="cancelModalLabel">
<i class="bi-x-circle me-2"></i>Отменить заявку
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<p>Вы собираетесь отменить заявку <strong>"{{ request_obj.title }}"</strong></p>
<p class="text-muted">Это действие нельзя отменить.</p>
<div class="mb-3">
<label for="cancelReason" class="form-label">Причина отмены</label>
<textarea class="form-control" id="cancelReason" rows="3" placeholder="Укажите причину отмены..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Назад</button>
<button type="button" class="btn btn-danger" id="confirmCancel">
<i class="bi-x-circle me-2"></i>Отменить заявку
</button>
</div>
</div>
</div>
</div>
{% endif %}
{% block extra_js %}
<script>
const csrfToken = '{{ csrf_token }}';
const requestId = {{ request_obj.id }};
// Утилита для API вызовов
async function apiCall(endpoint, method = 'POST', data = null) {
const options = {
method,
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json'
}
};
if (data) {
options.body = JSON.stringify(data);
}
const resp = await fetch(`/api/procurement/requests/${requestId}/${endpoint}/`, options);
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || 'Ошибка запроса');
}
return resp.json();
}
// Отправка на согласование
document.getElementById('submitRequest')?.addEventListener('click', async () => {
if (!confirm('Отправить заявку на согласование?')) return;
try {
const resp = await fetch('/api/procurement/requests/{{ request_obj.id }}/submit/', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}'
}
});
if (resp.ok) {
window.location.reload();
} else {
const err = await resp.json();
alert(err.error || 'Ошибка отправки');
}
await apiCall('submit');
window.location.reload();
} catch (e) {
console.error(e);
alert('Ошибка сети');
alert(e.message);
}
});
// Одобрение
document.getElementById('confirmApprove')?.addEventListener('click', async () => {
const comment = document.getElementById('approveComment')?.value || '';
try {
await apiCall('approve', 'POST', { comment });
window.location.reload();
} catch (e) {
alert(e.message);
}
});
// Отклонение
document.getElementById('confirmReject')?.addEventListener('click', async () => {
const comment = document.getElementById('rejectComment')?.value || '';
if (!comment.trim()) {
alert('Укажите причину отклонения');
return;
}
try {
await apiCall('reject', 'POST', { comment });
window.location.reload();
} catch (e) {
alert(e.message);
}
});
// Начать работу
document.getElementById('startWorkBtn')?.addEventListener('click', async () => {
if (!confirm('Начать работу над заявкой?')) return;
try {
await apiCall('start_work');
window.location.reload();
} catch (e) {
alert(e.message);
}
});
// Завершить
document.getElementById('completeBtn')?.addEventListener('click', async () => {
if (!confirm('Завершить заявку? Это действие нельзя отменить.')) return;
try {
await apiCall('complete');
window.location.reload();
} catch (e) {
alert(e.message);
}
});
// Отмена заявки
document.getElementById('confirmCancel')?.addEventListener('click', async () => {
const reason = document.getElementById('cancelReason')?.value || '';
try {
await apiCall('cancel', 'POST', { reason });
window.location.reload();
} catch (e) {
alert(e.message);
}
});
</script>

View File

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