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:
parent
8b8974e3ef
commit
ccbc8c5452
86
backend/procurement/forms.py
Normal file
86
backend/procurement/forms.py
Normal 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': 'Ссылки на товар, контакты поставщика',
|
||||
}),
|
||||
}
|
||||
@ -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 для категорий оборудования."""
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
540
backend/tests/procurement/test_lifecycle.py
Normal file
540
backend/tests/procurement/test_lifecycle.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user