feat(procurement): frontend templates + API-based rendering; update models, serializers, views and tests

This commit is contained in:
admin i 2025-12-10 14:09:49 +03:00
parent 537e6c4127
commit 427f5ed22f
30 changed files with 3206 additions and 1353 deletions

View File

View File

@ -1090,6 +1090,25 @@ def department_detail(request, pk: int):
"posts": _fetch_department_posts(api, pk),
}
)
# Добавляем бюджет отдела на текущий квартал
from django.utils import timezone
from procurement.models import Budget
now = timezone.now()
current_quarter = (now.month - 1) // 3 + 1
try:
department_budget = Budget.objects.get(
department_id=pk,
year=now.year,
quarter=current_quarter
)
except Budget.DoesNotExist:
department_budget = None
ctx["department_budget"] = department_budget
ctx["budget_year"] = now.year
ctx["budget_quarter"] = current_quarter
return render(request, "employees/department_detail.html", ctx)

View File

@ -50,7 +50,7 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
"requestor",
"status_badge",
"urgency_badge",
"estimated_cost",
"get_total_cost",
"created_at",
]
list_filter = ["status", "urgency", "department", "created_at"]
@ -60,6 +60,7 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
"updated_at",
"submitted_at",
"completed_at",
"get_total_cost",
]
date_hierarchy = "created_at"
inlines = [ProcurementItemInline, ApprovalInline]
@ -75,7 +76,7 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
"fields": (
"status",
"urgency",
"estimated_cost",
"get_total_cost",
"actual_cost",
)
},
@ -133,6 +134,12 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
urgency_badge.short_description = "Срочность"
def get_total_cost(self, obj):
"""Сумма заявки из позиций."""
return f"{obj.total_cost:,.2f} ₽"
get_total_cost.short_description = "Сумма"
@admin.register(ProcurementItem)
class ProcurementItemAdmin(admin.ModelAdmin):

View File

@ -1,10 +1,11 @@
"""
Django Forms для модуля закупок.
"""
from datetime import date
from django import forms
from .models import ProcurementRequest, ProcurementItem
from .models import ProcurementRequest, ProcurementItem, Budget
class ProcurementRequestForm(forms.ModelForm):
@ -84,3 +85,79 @@ class ProcurementItemForm(forms.ModelForm):
'placeholder': 'Ссылки на товар, контакты поставщика',
}),
}
class BudgetForm(forms.ModelForm):
"""Форма создания/редактирования бюджета отдела."""
QUARTER_CHOICES = [
(1, '1 квартал'),
(2, '2 квартал'),
(3, '3 квартал'),
(4, '4 квартал'),
]
class Meta:
model = Budget
fields = ['department', 'year', 'quarter', 'allocated_amount']
widgets = {
'department': forms.Select(attrs={
'class': 'form-select',
}),
'year': forms.NumberInput(attrs={
'class': 'form-control',
'min': 2020,
'max': 2100,
}),
'quarter': forms.Select(attrs={
'class': 'form-select',
}),
'allocated_amount': forms.NumberInput(attrs={
'class': 'form-control',
'min': 0,
'step': '0.01',
'placeholder': '0.00',
}),
}
labels = {
'department': 'Отдел',
'year': 'Год',
'quarter': 'Квартал',
'allocated_amount': 'Выделенная сумма (₽)',
}
def __init__(self, *args, **kwargs):
"""Инициализация формы с текущим годом/кварталом."""
super().__init__(*args, **kwargs)
# Устанавливаем текущий год и квартал по умолчанию для новых бюджетов
if not self.instance.pk:
today = date.today()
self.fields['year'].initial = today.year
self.fields['quarter'].initial = (today.month - 1) // 3 + 1
def clean(self):
"""Проверка уникальности бюджета."""
cleaned_data = super().clean()
department = cleaned_data.get('department')
year = cleaned_data.get('year')
quarter = cleaned_data.get('quarter')
if department and year and quarter:
# Проверяем, нет ли уже такого бюджета
existing = Budget.objects.filter(
department=department,
year=year,
quarter=quarter
)
# Исключаем текущий объект при редактировании
if self.instance.pk:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
raise forms.ValidationError(
f'Бюджет для отдела "{department}" на {year} год, '
f'{quarter} квартал уже существует.'
)
return cleaned_data

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.4 on 2025-12-10 06:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('procurement', '0002_equipmenttransferlog'),
]
operations = [
migrations.RemoveField(
model_name='procurementrequest',
name='estimated_cost',
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 5.2.4 on 2025-12-10 06:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('procurement', '0003_remove_estimated_cost'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='procurementrequest',
name='executor',
field=models.ForeignKey(blank=True, help_text='Кто взял заявку в работу', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='executing_procurements', to=settings.AUTH_USER_MODEL, verbose_name='Исполнитель'),
),
migrations.AddField(
model_name='procurementrequest',
name='started_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Взято в работу'),
),
]

View File

@ -18,6 +18,7 @@ from decimal import Decimal
from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Sum
from django.utils import timezone
from employees.models import Department
@ -63,6 +64,15 @@ class ProcurementRequest(models.Model):
related_name='procurement_requests',
verbose_name='Заявитель'
)
executor = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='executing_procurements',
verbose_name='Исполнитель',
help_text='Кто взял заявку в работу'
)
status = models.CharField(
'Статус',
max_length=20,
@ -75,13 +85,6 @@ class ProcurementRequest(models.Model):
choices=UrgencyLevel.choices,
default=UrgencyLevel.MEDIUM
)
estimated_cost = models.DecimalField(
'Предполагаемая стоимость',
max_digits=12,
decimal_places=2,
default=Decimal('0.00'),
validators=[MinValueValidator(Decimal('0.01'))]
)
actual_cost = models.DecimalField(
'Фактическая стоимость',
max_digits=12,
@ -105,6 +108,11 @@ class ProcurementRequest(models.Model):
null=True,
blank=True
)
started_at = models.DateTimeField(
'Взято в работу',
null=True,
blank=True
)
completed_at = models.DateTimeField(
'Завершено',
null=True,
@ -132,7 +140,7 @@ class ProcurementRequest(models.Model):
- 10,000 - 100,000₽: руководитель + финансовый менеджер
- > 100,000₽: руководитель + финансы + директор
"""
cost = self.estimated_cost or Decimal('0')
cost = self.total_cost
if cost < APPROVAL_THRESHOLD_LOW:
return [ApprovalRole.DEPARTMENT_HEAD]
@ -165,10 +173,21 @@ class ProcurementRequest(models.Model):
quarter=quarter
)
remaining = budget.remaining_amount
return (remaining >= self.estimated_cost, remaining)
return (remaining >= self.total_cost, remaining)
except Budget.DoesNotExist:
return (False, Decimal('0'))
@property
def total_cost(self):
"""Общая стоимость заявки (сумма всех позиций)."""
total = self.items.aggregate(
total=Sum(
models.F('estimated_unit_price') * models.F('quantity'),
output_field=models.DecimalField()
)
)['total']
return total or Decimal('0.00')
@property
def items_count(self):
"""Количество позиций в заявке."""
@ -624,13 +643,17 @@ class Budget(models.Model):
@property
def reserved_amount(self):
"""Сумма зарезервированная pending заявками."""
from django.db.models import Sum
pending_sum = self.department.procurement_requests.filter(
# Суммируем total_cost всех pending заявок через их позиции
pending_requests = self.department.procurement_requests.filter(
status=ProcurementStatus.PENDING,
created_at__year=self.year,
created_at__month__in=self._quarter_months()
).aggregate(total=Sum('estimated_cost'))['total']
return pending_sum or Decimal('0.00')
)
total = Decimal('0.00')
for req in pending_requests:
total += req.total_cost
return total
@property
def available_amount(self):

View File

@ -130,6 +130,183 @@ class CanApproveProcurementRequest(permissions.BasePermission):
return False
class CanManageProcurementRequest(permissions.BasePermission):
"""Комплексная проверка прав на заявки закупок.
Логика доступа:
- Просмотр (SAFE_METHODS): любой аутентифицированный (фильтрация в queryset)
- Создание:
- admin/staff/superuser → любой отдел
- модельные права (add_procurementrequest) → любой отдел
- сотрудник отдела → только свой отдел
- без отдела → запрещено
- Изменение:
- admin/staff/superuser → любая заявка в DRAFT
- модельные права (change_procurementrequest) → любая заявка в DRAFT
- автор заявки → своя заявка в DRAFT
- начальник отдела → заявки своего отдела в DRAFT
- Удаление:
- admin/staff/superuser → любая заявка
- модельные права (delete_procurementrequest) → любая заявка
- автор заявки → своя заявка в DRAFT
- начальник отдела → заявки своего отдела в DRAFT
"""
def _is_admin(self, user) -> bool:
"""Проверяет, является ли пользователь админом."""
return (
getattr(user, 'is_superuser', False) or
getattr(user, 'is_staff', False)
)
def _has_model_perm(self, user, action: str) -> bool:
"""Проверяет модельные права на заявки."""
perm_map = {
'create': 'procurement.add_procurementrequest',
'update': 'procurement.change_procurementrequest',
'partial_update': 'procurement.change_procurementrequest',
'destroy': 'procurement.delete_procurementrequest',
}
perm = perm_map.get(action)
return perm and user.has_perm(perm)
def _get_user_departments(self, user) -> list[int]:
"""Возвращает список ID отделов, где пользователь состоит."""
return list(
EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True
).values_list('department_id', flat=True)
)
def _get_dept_id_from_request(self, request) -> int | None:
"""Извлекает ID отдела из данных запроса."""
dept_id = request.data.get('department')
if dept_id is not None:
try:
return int(dept_id)
except (ValueError, TypeError):
pass
return None
def _is_dept_head(self, user, dept_id: int) -> bool:
"""Проверяет, является ли пользователь начальником отдела."""
return Department.objects.filter(id=dept_id, head_id=user.id).exists()
def has_permission(self, request, view) -> bool:
"""Проверка доступа на уровне запроса."""
user = request.user
if not user or not user.is_authenticated:
return False
# Чтение доступно всем аутентифицированным
if request.method in permissions.SAFE_METHODS:
return True
# Админы могут всё
if self._is_admin(user):
return True
action = getattr(view, 'action', None)
# Модельные права дают полный доступ
if self._has_model_perm(user, action):
return True
# Для создания проверяем отдел
if action == 'create':
dept_id = self._get_dept_id_from_request(request)
if dept_id is None:
return False
# Пользователь должен состоять в указанном отделе
user_depts = self._get_user_departments(user)
if not user_depts:
# Пользователь без отдела не может создавать
return False
if dept_id not in user_depts:
# Нельзя создавать заявку в чужом отделе
return False
return True
# Для update/delete проверка на уровне объекта
if action in ('update', 'partial_update', 'destroy'):
return True
return True
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту."""
user = request.user
if not user or not user.is_authenticated:
return False
# Чтение доступно всем
if request.method in permissions.SAFE_METHODS:
return True
# Админы могут всё
if self._is_admin(user):
return True
action = getattr(view, 'action', None)
# Модельные права
if self._has_model_perm(user, action):
return True
dept_id = obj.department_id
# Submit (отправка на согласование) - только владелец
if action == 'submit':
return obj.requestor == user
# Cancel (отмена заявки) - только владелец
if action == 'cancel':
return obj.requestor == user
# Start_work (взять в работу) - любой авторизованный
if action == 'start_work':
return True
# Complete (завершить) - только исполнитель
if action == 'complete':
return obj.executor == user
# Удаление
if action == 'destroy':
# Автор может удалить свою заявку в DRAFT
if obj.requestor == user and obj.is_editable:
return True
# Начальник отдела может удалить заявку отдела в DRAFT
if self._is_dept_head(user, dept_id) and obj.is_editable:
return True
return False
# Изменение
if action in ('update', 'partial_update'):
# Заявка должна быть редактируемой (DRAFT)
if not obj.is_editable:
return False
# Автор может редактировать свою заявку
if obj.requestor == user:
return True
# Начальник отдела может редактировать заявки отдела
if self._is_dept_head(user, dept_id):
return True
return False
return False
class CanManageEquipment(permissions.BasePermission):
"""Проверка права на управление оборудованием.

View File

@ -119,6 +119,16 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer):
read_only=True
)
items_count = serializers.IntegerField(read_only=True)
total_cost = serializers.DecimalField(
max_digits=12,
decimal_places=2,
read_only=True
)
executor_name = serializers.CharField(
source='executor.get_full_name',
read_only=True,
allow_null=True
)
class Meta:
model = ProcurementRequest
@ -129,26 +139,43 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer):
'department_name',
'requestor',
'requestor_name',
'executor',
'executor_name',
'status',
'status_display',
'urgency',
'urgency_display',
'estimated_cost',
'total_cost',
'items_count',
'created_at',
'submitted_at',
'started_at',
]
read_only_fields = [
'id',
'created_at',
'submitted_at',
'started_at',
'department_name',
'requestor_name',
'executor_name',
'status_display',
'urgency_display',
'items_count',
'total_cost',
]
def update(self, instance, validated_data):
"""
При обновлении заявки запрещаем изменение requestor и department.
Эти поля игнорируются при обновлении.
"""
# Игнорируем попытки изменить requestor
validated_data.pop('requestor', None)
# Игнорируем попытки изменить department
validated_data.pop('department', None)
return super().update(instance, validated_data)
class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
"""Сериализатор для детальной информации о заявке."""
@ -165,6 +192,11 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
source='requestor.email',
read_only=True
)
executor_name = serializers.CharField(
source='executor.get_full_name',
read_only=True,
allow_null=True
)
status_display = serializers.CharField(
source='get_status_display',
read_only=True
@ -173,6 +205,11 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
source='get_urgency_display',
read_only=True
)
total_cost = serializers.DecimalField(
max_digits=12,
decimal_places=2,
read_only=True
)
items = ProcurementItemSerializer(many=True, read_only=True)
approvals = ApprovalSerializer(many=True, read_only=True)
required_approvals = serializers.ListField(
@ -193,11 +230,13 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
'requestor',
'requestor_name',
'requestor_email',
'executor',
'executor_name',
'status',
'status_display',
'urgency',
'urgency_display',
'estimated_cost',
'total_cost',
'actual_cost',
'items',
'approvals',
@ -207,6 +246,7 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
'created_at',
'updated_at',
'submitted_at',
'started_at',
'completed_at',
]
read_only_fields = [
@ -214,9 +254,12 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
'created_at',
'updated_at',
'submitted_at',
'started_at',
'completed_at',
'is_editable',
'budget_available',
'total_cost',
'executor_name',
]
def get_budget_available(self, obj):
@ -242,7 +285,6 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
'department',
'requestor',
'urgency',
'estimated_cost',
'items',
'status',
'created_at',
@ -744,8 +786,16 @@ class BudgetDetailSerializer(BudgetSerializer):
status='pending',
created_at__year=obj.year,
created_at__month__in=obj._quarter_months()
).values('id', 'title', 'estimated_cost', 'status')[:10]
return list(pending)
)[:10]
return [
{
'id': req.id,
'title': req.title,
'total_cost': str(req.total_cost),
'status': req.status
}
for req in pending
]
class SupplierSerializer(serializers.ModelSerializer):

View File

@ -78,7 +78,7 @@ def notify_on_request_status_change(sender, instance, created, **kwargs):
title="Новая заявка на закупку",
message=(
f'Создана заявка "{instance.title}" '
f'на сумму {instance.estimated_cost}₽'
f'на сумму {instance.total_cost}₽'
),
action_url=f'/procurement/requests/{instance.id}/',
send_immediately=True,
@ -187,7 +187,7 @@ def notify_approver(approval):
message=(
f'Заявка "{approval.request.title}" '
f'ожидает вашего согласования. '
f'Сумма: {approval.request.estimated_cost}₽'
f'Сумма: {approval.request.total_cost}₽'
),
action_url=f'/procurement/requests/{approval.request.id}/',
send_immediately=True,

View File

@ -30,4 +30,9 @@ urlpatterns = [
views.equipment_detail,
name='equipment_detail'
),
# Бюджеты
path('budgets/', views.budget_list, name='budget_list'),
path('budgets/create/', views.budget_create, name='budget_create'),
path('budgets/<int:pk>/edit/', views.budget_edit, name='budget_edit'),
]

View File

@ -2,7 +2,10 @@
ViewSets для API модуля закупок.
"""
from decimal import Decimal
from django.db.models import Q
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions, status, viewsets
from rest_framework.decorators import action
@ -24,10 +27,9 @@ from .models import (
from .services import QRCodeGenerator
from .permissions import (
CanApproveProcurementRequest,
CanCreateProcurementRequest,
CanEditOwnProcurementRequest,
CanManageBudget,
CanManageEquipment,
CanManageProcurementRequest,
CanManageSupplier,
)
from .serializers import (
@ -49,16 +51,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
"""ViewSet для заявок на закупку."""
queryset = ProcurementRequest.objects.select_related(
'department', 'requestor'
'department', 'requestor', 'executor'
).prefetch_related('items', 'approvals')
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = ['status', 'urgency', 'department']
filterset_fields = ['status', 'urgency', 'department', 'executor']
search_fields = ['title', 'description']
ordering_fields = ['created_at', 'estimated_cost', 'status']
ordering_fields = ['created_at', 'status']
ordering = ['-created_at']
def get_serializer_class(self):
@ -74,14 +76,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
def get_permissions(self):
"""Выбрать права доступа в зависимости от действия."""
if self.action == 'create':
permission_classes = [CanCreateProcurementRequest]
elif self.action in ['update', 'partial_update', 'destroy']:
permission_classes = [CanEditOwnProcurementRequest]
elif self.action in ['approve', 'reject']:
if self.action in ['approve', 'reject']:
permission_classes = [CanApproveProcurementRequest]
else:
permission_classes = [CanCreateProcurementRequest]
# Для create, update, delete и остальных действий
permission_classes = [CanManageProcurementRequest]
return [permission() for permission in permission_classes]
def get_queryset(self):
@ -89,8 +88,15 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
queryset = super().get_queryset()
user = self.request.user
# Админы и пользователи с модельными правами видят все
if user.is_superuser or user.is_staff:
return queryset
# Пользователи с любыми модельными правами на заявки видят все
if (user.has_perm('procurement.view_procurementrequest') or
user.has_perm('procurement.change_procurementrequest') or
user.has_perm('procurement.delete_procurementrequest')):
return queryset
# Показываем: свои заявки + заявки отдела + где я approver
return queryset.filter(
@ -101,9 +107,6 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
def _check_budget_alert(self, procurement_request):
"""Проверяет бюджет и отправляет алерт если низкий остаток."""
from django.utils import timezone
from decimal import Decimal
department = procurement_request.department
now = timezone.now()
quarter = (now.month - 1) // 3 + 1
@ -189,7 +192,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
{
'error': 'Недостаточно бюджета',
'remaining': float(remaining),
'required': float(procurement_request.estimated_cost),
'required': float(procurement_request.total_cost),
},
status=status.HTTP_400_BAD_REQUEST
)
@ -243,7 +246,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
message=(
f'Заявка "{procurement_request.title}" '
f'ожидает вашего согласования. '
f'Сумма: {procurement_request.estimated_cost}₽'
f'Сумма: {procurement_request.total_cost}₽'
),
action_url=f'/procurement/requests/{procurement_request.id}/',
send_immediately=True,
@ -391,29 +394,88 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def my_work(self, request):
"""Получить заявки, взятые текущим пользователем в работу."""
queryset = self.filter_queryset(
self.get_queryset().filter(executor=request.user)
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = ProcurementRequestListSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = ProcurementRequestListSerializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def available(self, request):
"""Получить одобренные заявки, доступные для взятия в работу."""
queryset = self.filter_queryset(
self.get_queryset().filter(
status=ProcurementStatus.APPROVED,
executor__isnull=True
)
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = ProcurementRequestListSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = ProcurementRequestListSerializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def start_work(self, request, pk=None):
"""Начать работу над заявкой (перевод в статус IN_PROGRESS)."""
"""Начать работу над заявкой (перевод в статус IN_PROGRESS).
Любой авторизованный пользователь может взять заявку в работу.
TODO: Позже добавить проверку роли "Закупщик".
"""
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
)
# Проверяем, что заявка ещё не взята кем-то
if procurement_request.executor:
return Response(
{
'error': (
f'Заявка уже взята в работу пользователем '
f'{procurement_request.executor.get_full_name()}'
)
},
status=status.HTTP_400_BAD_REQUEST
)
# Меняем статус
# Назначаем исполнителя и меняем статус
procurement_request.executor = request.user
procurement_request.started_at = timezone.now()
procurement_request.status = ProcurementStatus.IN_PROGRESS
procurement_request.save()
# Уведомляем автора заявки
if procurement_request.requestor != request.user:
NotificationService.create_notification(
recipient=procurement_request.requestor,
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,
)
# Уведомляем согласующих о начале работы
for approval in procurement_request.approvals.filter(
status=ApprovalStatus.APPROVED
@ -439,10 +501,10 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
"""Завершить заявку (перевод в статус COMPLETED)."""
procurement_request = self.get_object()
# Только автор заявки может завершить её
if procurement_request.requestor != request.user:
# Только исполнитель может завершить заявку
if procurement_request.executor != request.user:
return Response(
{'error': 'Только автор заявки может завершить её'},
{'error': 'Только исполнитель заявки может завершить её'},
status=status.HTTP_403_FORBIDDEN
)
@ -455,8 +517,12 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
# Меняем статус
procurement_request.status = ProcurementStatus.COMPLETED
procurement_request.completed_at = timezone.now()
procurement_request.save()
# Списываем бюджет отдела
self._deduct_budget(procurement_request)
# Уведомляем согласующих о завершении
for approval in procurement_request.approvals.filter(
status=ApprovalStatus.APPROVED
@ -476,6 +542,43 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
def _deduct_budget(self, procurement_request):
"""Списывает сумму заявки из бюджета отдела."""
department = procurement_request.department
now = timezone.now()
quarter = (now.month - 1) // 3 + 1
# Используем фактическую сумму если есть, иначе общую стоимость позиций
amount = (
procurement_request.actual_cost or
procurement_request.total_cost
)
if not amount or amount <= 0:
return # Нечего списывать
try:
budget = Budget.objects.get(
department=department,
year=now.year,
quarter=quarter
)
except Budget.DoesNotExist:
# Нет бюджета - создаём с нулём, чтобы зафиксировать траты
budget = Budget.objects.create(
department=department,
year=now.year,
quarter=quarter,
allocated_amount=Decimal('0'),
spent_amount=Decimal('0')
)
# Увеличиваем потраченную сумму
budget.spent_amount = (budget.spent_amount or Decimal('0')) + amount
budget.save()
# Проверяем и отправляем алерт если бюджет низкий
self._check_budget_alert(procurement_request)
@action(detail=True, methods=['post'])
def cancel(self, request, pk=None):
"""Отменить заявку."""
@ -534,11 +637,68 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
def get_permissions(self):
"""Только создатель заявки может редактировать позиции."""
if self.action in ['create', 'update', 'partial_update', 'destroy']:
permission_classes = [CanEditOwnProcurementRequest]
if self.action in [
'create', 'update', 'partial_update', 'destroy',
'create_equipment', 'link_equipment'
]:
permission_classes = [permissions.IsAuthenticated]
else:
permission_classes = [CanCreateProcurementRequest]
permission_classes = [CanManageProcurementRequest]
return [permission() for permission in permission_classes]
def perform_create(self, serializer):
"""Проверяем права при создании позиции."""
procurement_request = serializer.validated_data.get('request')
# Проверяем, что пользователь - создатель заявки
if procurement_request.requestor != self.request.user:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(
"Вы можете добавлять позиции только в свои заявки"
)
# Проверяем, что заявка редактируема
if not procurement_request.is_editable:
from rest_framework.exceptions import ValidationError
raise ValidationError(
"Нельзя добавлять позиции в заявку не в статусе черновика"
)
serializer.save()
def perform_update(self, serializer):
"""Проверяем права при обновлении позиции."""
item = self.get_object()
if item.request.requestor != self.request.user:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(
"Вы можете изменять только позиции своих заявок"
)
if not item.request.is_editable:
from rest_framework.exceptions import ValidationError
raise ValidationError(
"Нельзя изменять позиции в заявке не в статусе черновика"
)
serializer.save()
def perform_destroy(self, instance):
"""Проверяем права при удалении позиции."""
if instance.request.requestor != self.request.user:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(
"Вы можете удалять только позиции своих заявок"
)
if not instance.request.is_editable:
from rest_framework.exceptions import ValidationError
raise ValidationError(
"Нельзя удалять позиции из заявки не в статусе черновика"
)
instance.delete()
@action(detail=True, methods=['post'])
def create_equipment(self, request, pk=None):
@ -640,6 +800,65 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
}
}, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'])
def link_equipment(self, request, pk=None):
"""Связать существующее оборудование с позицией закупки.
Используется после создания оборудования через основной API.
Требуется: equipment_id.
"""
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
)
equipment_id = request.data.get('equipment_id')
if not equipment_id:
return Response(
{'error': 'Укажите ID оборудования'},
status=status.HTTP_400_BAD_REQUEST
)
# Находим оборудование
try:
equipment = Equipment.objects.get(pk=equipment_id)
except Equipment.DoesNotExist:
return Response(
{'error': 'Оборудование не найдено'},
status=status.HTTP_404_NOT_FOUND
)
# Проверяем, что оборудование ещё не привязано к другой позиции
if hasattr(equipment, 'procurement_item') and equipment.procurement_item:
return Response(
{'error': 'Это оборудование уже связано с другой позицией'},
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_200_OK)
class EquipmentCategoryViewSet(viewsets.ModelViewSet):
"""ViewSet для категорий оборудования."""
@ -769,6 +988,14 @@ class EquipmentViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], url_path='generate-inventory-number')
def generate_inventory_number(self, request):
"""Генерация уникального инвентарного номера."""
from procurement.services import InventoryNumberGenerator
inventory_number = InventoryNumberGenerator.generate()
return Response({'inventory_number': inventory_number})
@action(detail=False, methods=['get'], url_path='create-options')
def create_options(self, request):
"""Возвращает доступные опции для создания оборудования.

View File

@ -1,344 +1,156 @@
"""
HTML Views для модуля закупок (Frontend).
Все данные загружаются через API на клиенте.
Views только рендерят layout и передают минимальный контекст.
"""
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
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,
EquipmentCategory,
ProcurementRequest,
)
from .constants import ProcurementStatus
User = get_user_model()
from django.shortcuts import render
@login_required
def dashboard(request):
"""Главная страница модуля закупок."""
user = request.user
now = timezone.now()
quarter = (now.month - 1) // 3 + 1
# Получаем отделы пользователя
user_departments = user.departments.filter(
employeedepartment__is_active=True
)
# Статистика
stats = {
'pending': ProcurementRequest.objects.filter(
status=ProcurementStatus.PENDING
).count(),
'approved': ProcurementRequest.objects.filter(
status=ProcurementStatus.APPROVED
).count(),
'equipment': Equipment.objects.count(),
}
# Бюджет отдела (берём первый отдел пользователя)
budget = None
if user_departments.exists():
budget = Budget.objects.filter(
department=user_departments.first(),
year=now.year,
quarter=quarter
).first()
# Последние заявки (мои или моего отдела)
recent_requests = ProcurementRequest.objects.filter(
department__in=user_departments
).select_related(
'department', 'requestor'
).order_by('-created_at')[:5]
# Права на согласование
can_approve = user.is_staff or user.headed_departments.exists()
context = {
'stats': stats,
'budget': budget,
'recent_requests': recent_requests,
'can_approve': can_approve,
'departments': Department.objects.all().order_by('name'),
}
return render(request, 'procurement/dashboard.html', context)
"""Главная страница модуля закупок.
Данные загружаются через API:
- /api/procurement/stats/overview/ — статистика
- /api/procurement/budgets/my-department/ — бюджет отдела
- /api/procurement/requests/?ordering=-created_at&page_size=5 — последние заявки
"""
return render(request, 'procurement/dashboard.html', {})
@login_required
def request_list(request):
"""Список заявок на закупку."""
user = request.user
"""Список заявок на закупку.
Данные загружаются через API:
- /api/procurement/requests/ — с фильтрами
- /api/v1/departments/ — для фильтра по отделам
"""
scope = request.GET.get('scope', 'mine')
# Базовый queryset
queryset = ProcurementRequest.objects.select_related(
'department', 'requestor'
).prefetch_related('items')
# Фильтрация по области видимости
if scope == 'mine':
queryset = queryset.filter(requestor=user)
page_title = 'Мои заявки'
elif scope == 'department':
user_departments = user.departments.filter(
employeedepartment__is_active=True
)
queryset = queryset.filter(department__in=user_departments)
page_title = 'Заявки отдела'
elif scope == 'all' and user.is_staff:
page_title = 'Все заявки'
else:
queryset = queryset.filter(requestor=user)
page_title = 'Мои заявки'
scope = 'mine'
# Фильтры
filters = {}
if status := request.GET.get('status'):
queryset = queryset.filter(status=status)
filters['status'] = status
if urgency := request.GET.get('urgency'):
queryset = queryset.filter(urgency=urgency)
filters['urgency'] = urgency
# Сортировка
queryset = queryset.order_by('-created_at')
# Пагинация
paginator = Paginator(queryset, 20)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
context = {
'requests': page_obj,
'page_obj': page_obj,
'page_title': page_title,
return render(request, 'procurement/request_list.html', {
'scope': scope,
'filters': filters,
'can_view_all': user.is_staff or user.headed_departments.exists(),
'departments': Department.objects.all().order_by('name'),
}
return render(request, 'procurement/request_list.html', context)
'show_tabs': True,
})
@login_required
def my_requests(request):
"""Мои заявки."""
return redirect('procurement:request_list', permanent=False)
"""Мои заявки — редирект на request_list."""
from django.shortcuts import redirect
return redirect('procurement_frontend:request_list')
@login_required
def pending_approvals(request):
"""Заявки на согласовании (для руководителей)."""
user = request.user
# Получаем отделы, которыми руководит пользователь
headed_depts = user.headed_departments.all()
if not headed_depts.exists() and not user.is_staff:
return redirect('procurement:dashboard')
queryset = ProcurementRequest.objects.filter(
status=ProcurementStatus.PENDING
).select_related(
'department', 'requestor'
)
# Для не-staff показываем только заявки своих отделов
if not user.is_staff:
queryset = queryset.filter(department__in=headed_depts)
queryset = queryset.order_by('-created_at')
paginator = Paginator(queryset, 20)
page_obj = paginator.get_page(request.GET.get('page'))
context = {
'requests': page_obj,
'page_obj': page_obj,
'page_title': 'На согласовании',
"""Заявки на согласовании.
Данные загружаются через API:
- /api/procurement/requests/pending_approvals/
"""
return render(request, 'procurement/request_list.html', {
'scope': 'pending',
'can_view_all': user.is_staff,
}
return render(request, 'procurement/request_list.html', context)
'page_title': 'На согласовании',
'show_tabs': True,
})
@login_required
def request_detail(request, pk):
"""Детальная страница заявки."""
procurement_request = get_object_or_404(
ProcurementRequest.objects.select_related(
'department', 'requestor'
).prefetch_related(
'items',
'approvals__approver'
),
pk=pk
)
context = {
'request_obj': procurement_request,
'can_edit': (
procurement_request.requestor == request.user and
procurement_request.status == ProcurementStatus.DRAFT
),
'can_approve': (
request.user.is_staff or
request.user.headed_departments.filter(
pk=procurement_request.department_id
).exists()
),
}
return render(request, 'procurement/request_detail.html', context)
"""Детальная страница заявки.
Данные загружаются через API:
- /api/procurement/requests/{pk}/
- /api/procurement/equipment/create-options/ — при создании оборудования
- /api/procurement/equipment-categories/ — категории
"""
return render(request, 'procurement/request_detail.html', {
'request_id': pk,
})
@login_required
def request_create(request):
"""Страница/обработка создания заявки."""
if request.method == 'POST':
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()
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,
"""Страница создания заявки.
Данные загружаются через API:
- /api/v1/departments/ — список отделов
Создание через:
- POST /api/procurement/requests/
"""
return render(request, 'procurement/request_form.html', {
'page_title': 'Новая заявка',
}
return render(request, 'procurement/request_form.html', context)
'request_id': None,
})
@login_required
def equipment_list(request):
"""Список оборудования с группировкой одинаковых позиций."""
from collections import defaultdict
"""Список оборудования.
queryset = Equipment.objects.select_related(
'category', 'department', 'responsible_person'
)
# Фильтры
filters = {}
if category := request.GET.get('category'):
queryset = queryset.filter(category_id=category)
filters['category'] = category
if status := request.GET.get('status'):
queryset = queryset.filter(status=status)
filters['status'] = status
if department := request.GET.get('department'):
queryset = queryset.filter(department_id=department)
filters['department'] = department
# Поиск
if search := request.GET.get('q'):
queryset = queryset.filter(
name__icontains=search
) | queryset.filter(
inventory_number__icontains=search
)
queryset = queryset.order_by('name', '-purchase_date')
# Группировка по (название, категория, отдел, статус)
grouped_equipment = defaultdict(list)
for item in queryset:
key = (
item.name,
item.category_id,
item.department_id,
item.status
)
grouped_equipment[key].append(item)
# Преобразуем в список групп
equipment_groups = []
for key, items in grouped_equipment.items():
# Первый элемент — представитель группы
main_item = items[0]
equipment_groups.append({
'main': main_item,
'items': items,
'count': len(items),
'is_group': len(items) > 1,
})
# Сортировка по имени
equipment_groups.sort(key=lambda x: x['main'].name.lower())
# Пагинация по группам
paginator = Paginator(equipment_groups, 24)
page_obj = paginator.get_page(request.GET.get('page'))
# Получаем первый активный отдел пользователя
user_department = request.user.departments.filter(
employeedepartment__is_active=True
).first()
# Получаем руководителя отдела (если есть)
department_head = None
if user_department:
department_head = user_department.head
context = {
'equipment_groups': page_obj,
'page_obj': page_obj,
'filters': filters,
'categories': EquipmentCategory.objects.all(),
'departments': Department.objects.all(),
'users': User.objects.filter(
is_active=True
).order_by('last_name', 'first_name'),
'can_manage': request.user.is_staff,
'user_department': user_department,
'department_head': department_head,
}
return render(request, 'procurement/equipment_list.html', context)
Данные загружаются через API:
- /api/procurement/equipment/ — список с фильтрами
- /api/procurement/equipment-categories/ — категории для фильтра
- /api/v1/departments/ — отделы для фильтра
- /api/procurement/equipment/create-options/ — при создании
"""
return render(request, 'procurement/equipment_list.html', {})
@login_required
def equipment_detail(request, pk):
"""Детальная страница оборудования."""
equipment = get_object_or_404(
Equipment.objects.select_related(
'category', 'department', 'responsible_person'
).prefetch_related(
'maintenance_history', 'transfer_logs'
),
pk=pk
)
"""Детальная страница оборудования.
Данные загружаются через API:
- /api/procurement/equipment/{pk}/
"""
return render(request, 'procurement/equipment_detail.html', {
'equipment_id': pk,
})
context = {
'equipment': equipment,
'can_manage': (
request.user.is_staff or
equipment.responsible_person == request.user
),
}
return render(request, 'procurement/equipment_detail.html', context)
# =============================================================================
# БЮДЖЕТЫ
# =============================================================================
@login_required
def budget_list(request):
"""Список бюджетов.
Данные загружаются через API:
- /api/procurement/budgets/ — список с фильтрами
- /api/v1/departments/ — отделы для фильтра
"""
return render(request, 'procurement/budget_list.html', {})
@login_required
def budget_create(request):
"""Создание бюджета.
Данные загружаются через API:
- /api/v1/departments/ — список отделов
Создание через:
- POST /api/procurement/budgets/
"""
return render(request, 'procurement/budget_form.html', {
'budget_id': None,
})
@login_required
def budget_edit(request, pk):
"""Редактирование бюджета.
Данные загружаются через API:
- /api/procurement/budgets/{pk}/ — текущие данные
- /api/v1/departments/ — список отделов
Обновление через:
- PATCH /api/procurement/budgets/{pk}/
"""
return render(request, 'procurement/budget_form.html', {
'budget_id': pk,
})

View File

@ -77,7 +77,7 @@
<!-- JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous" defer></script>
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.8/locales-all.global.min.js" defer></script>

View File

@ -0,0 +1,68 @@
{# --- Виджет бюджета отдела --- #}
{% if department_budget or user.is_staff %}
<section class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0">
<i class="bi-wallet2 me-2 text-primary"></i>Бюджет Q{{ budget_quarter }} {{ budget_year }}
</h6>
{% if user.is_staff %}
<a href="{% url 'procurement_frontend:budget_list' %}?department={{ dept.id }}"
class="btn btn-sm btn-outline-primary">
<i class="bi-gear me-1"></i>Управление
</a>
{% endif %}
</div>
<div class="card-body">
{% if department_budget %}
{# Показываем данные бюджета #}
<div class="d-flex justify-content-between mb-2">
<span class="text-muted small">Выделено:</span>
<span class="fw-bold">{{ department_budget.allocated_amount|floatformat:0 }} ₽</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted small">Потрачено:</span>
<span class="fw-bold text-danger">{{ department_budget.spent_amount|floatformat:0 }} ₽</span>
</div>
<div class="d-flex justify-content-between mb-3">
<span class="text-muted small">Остаток:</span>
<span class="fw-bold text-success">{{ department_budget.remaining_amount|floatformat:0 }} ₽</span>
</div>
{# Прогресс-бар использования #}
{% with util=department_budget.utilization_percentage|default:0 %}
<div class="progress" style="height: 8px;">
<div class="progress-bar {% if util > 90 %}bg-danger{% elif util > 70 %}bg-warning{% else %}bg-success{% endif %}"
role="progressbar"
style="width: {{ util }}%"
aria-valuenow="{{ util }}"
aria-valuemin="0"
aria-valuemax="100"></div>
</div>
<div class="text-center small text-muted mt-1">
Использовано {{ util|floatformat:1 }}%
</div>
{% endwith %}
{# Ссылка на заявки отдела #}
<div class="mt-3 pt-2 border-top">
<a href="{% url 'procurement_frontend:request_list' %}?department={{ dept.id }}"
class="btn btn-sm btn-outline-secondary w-100">
<i class="bi-file-text me-1"></i>Заявки отдела
</a>
</div>
{% else %}
{# Бюджет не создан #}
<div class="text-center text-muted py-2">
<i class="bi-exclamation-circle fs-4 d-block mb-2"></i>
<p class="small mb-2">Бюджет на {{ budget_quarter }} квартал не задан</p>
{% if user.is_staff %}
<a href="{% url 'procurement_frontend:budget_create' %}?department={{ dept.id }}"
class="btn btn-sm btn-primary">
<i class="bi-plus-lg me-1"></i>Создать бюджет
</a>
{% endif %}
</div>
{% endif %}
</div>
</section>
{% endif %}

View File

@ -19,6 +19,7 @@
<div class="col-md-4">
{% include "employees/components/department_team_circle.html" %}
{% include "employees/components/department_info.html" %}
{% include "employees/components/department_budget.html" %}
</div>
{# ===== ПРАВАЯ КОЛОНКА: настройки + лента ===== #}

View File

@ -0,0 +1,42 @@
{# Фильтры для списка бюджетов #}
<form method="get" class="row g-2">
<div class="col-md-3">
<label class="form-label small text-muted">Год</label>
<select name="year" class="form-select form-select-sm" id="filterYear">
{% for y in available_years %}
<option value="{{ y }}" {% if y == filter_year %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label small text-muted">Квартал</label>
<select name="quarter" class="form-select form-select-sm" id="filterQuarter">
<option value="">Все</option>
<option value="1" {% if filter_quarter == 1 %}selected{% endif %}>1 квартал</option>
<option value="2" {% if filter_quarter == 2 %}selected{% endif %}>2 квартал</option>
<option value="3" {% if filter_quarter == 3 %}selected{% endif %}>3 квартал</option>
<option value="4" {% if filter_quarter == 4 %}selected{% endif %}>4 квартал</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">Отдел</label>
<select name="department" class="form-select form-select-sm" id="filterDepartment">
<option value="">Все отделы</option>
{% for dept in departments %}
<option value="{{ dept.id }}" {% if dept.id == filter_department %}selected{% endif %}>{{ dept.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2 d-flex align-items-end gap-2">
<button type="submit" class="btn btn-primary btn-sm flex-grow-1">
<i class="bi-funnel me-1"></i>Применить
</button>
<a href="{% url 'procurement_frontend:budget_list' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi-x-lg"></i>
</a>
</div>
</form>

View File

@ -0,0 +1,410 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{% if budget_id %}Редактирование бюджета{% else %}Создание бюджета{% endif %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
<link rel="stylesheet" href="{% static 'css/components/procurement.css' %}">
<style>
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: 4px;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
{% endblock %}
{% block content %}
{# ===== ШАПКА СТРАНИЦЫ ===== #}
<div class="page-header d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center gap-3">
<a href="/procurement/budgets/" class="btn btn-outline-secondary btn-icon">
<i class="bi-arrow-left"></i>
</a>
<div class="page-header-icon">
<i class="bi-wallet2"></i>
</div>
<div>
<h1 class="page-header-title mb-0" id="pageTitle">
{% if budget_id %}Редактирование бюджета{% else %}Создание бюджета{% endif %}
</h1>
<p class="text-muted mb-0" id="pageSubtitle">
{% if budget_id %}Загрузка...{% else %}Новый бюджет для отдела{% endif %}
</p>
</div>
</div>
</div>
{# ===== ФОРМА ===== #}
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi-cash-stack me-2"></i>Параметры бюджета
</h5>
</div>
<div class="card-body">
<form id="budgetForm">
{% csrf_token %}
{# Ошибки формы #}
<div id="formErrors" class="alert alert-danger d-none"></div>
<div class="row g-3">
{# Отдел #}
<div class="col-md-12">
<label for="department" class="form-label">
Отдел <span class="text-danger">*</span>
</label>
<select id="department" name="department" class="form-select" required>
<option value="">Выберите отдел...</option>
</select>
<div id="departmentError" class="invalid-feedback"></div>
</div>
{# Год #}
<div class="col-md-6">
<label for="year" class="form-label">
Год <span class="text-danger">*</span>
</label>
<input type="number" id="year" name="year" class="form-control"
min="2020" max="2100" required>
<div id="yearError" class="invalid-feedback"></div>
</div>
{# Квартал #}
<div class="col-md-6">
<label for="quarter" class="form-label">
Квартал <span class="text-danger">*</span>
</label>
<select id="quarter" name="quarter" class="form-select" required>
<option value="1">Q1 (Январь - Март)</option>
<option value="2">Q2 (Апрель - Июнь)</option>
<option value="3">Q3 (Июль - Сентябрь)</option>
<option value="4">Q4 (Октябрь - Декабрь)</option>
</select>
<div id="quarterError" class="invalid-feedback"></div>
</div>
{# Сумма #}
<div class="col-md-12">
<label for="allocated_amount" class="form-label">
Выделенная сумма (₽) <span class="text-danger">*</span>
</label>
<div class="input-group">
<input type="number" id="allocated_amount" name="allocated_amount"
class="form-control" min="0" step="0.01" required>
<span class="input-group-text">₽</span>
</div>
<div id="allocated_amountError" class="invalid-feedback"></div>
<div class="form-text">Общая сумма бюджета на указанный квартал</div>
</div>
</div>
{# Кнопки #}
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="bi-check-lg me-2"></i>
<span id="submitBtnText">{% if budget_id %}Сохранить{% else %}Создать{% endif %}</span>
</button>
<a href="/procurement/budgets/" class="btn btn-outline-secondary">
Отмена
</a>
</div>
</form>
</div>
</div>
</div>
{# Подсказка #}
<div class="col-lg-4">
<div class="card border-info">
<div class="card-header bg-info bg-opacity-10">
<h6 class="card-title mb-0 text-info">
<i class="bi-info-circle me-2"></i>Справка
</h6>
</div>
<div class="card-body small">
<p class="mb-2">
<strong>Бюджет отдела</strong> — это сумма, выделенная отделу на закупки
в определённом квартале.
</p>
<p class="mb-2">
При создании заявок на закупку система проверяет остаток бюджета
и предупреждает о превышении.
</p>
<p class="mb-0">
<strong>Потраченная сумма</strong> обновляется автоматически
при завершении заявок.
</p>
</div>
</div>
<div id="budgetStatusCard" class="card mt-3 d-none">
<div class="card-header">
<h6 class="card-title mb-0">Текущее состояние</h6>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Выделено:</span>
<span id="statusAllocated" class="fw-bold">0 ₽</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Потрачено:</span>
<span id="statusSpent" class="fw-bold text-danger">0 ₽</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Остаток:</span>
<span id="statusRemaining" class="fw-bold text-success">0 ₽</span>
</div>
<hr>
<div class="d-flex justify-content-between">
<span class="text-muted">Использовано:</span>
<span id="statusPercent" class="fw-bold">0%</span>
</div>
<div class="progress mt-2" style="height: 8px;">
<div id="statusProgress" class="progress-bar bg-success" style="width: 0%"></div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// === СОСТОЯНИЕ ===
const budgetId = {{ budget_id|default:"null" }};
let departments = [];
let budgetData = null;
// === ИНИЦИАЛИЗАЦИЯ ===
document.addEventListener('DOMContentLoaded', async () => {
await loadDepartments();
if (budgetId) {
await loadBudget();
} else {
// Установить текущий год и квартал по умолчанию
const now = new Date();
document.getElementById('year').value = now.getFullYear();
document.getElementById('quarter').value = Math.ceil((now.getMonth() + 1) / 3);
}
setupEventListeners();
});
// === ЗАГРУЗКА ОТДЕЛОВ ===
async function loadDepartments() {
try {
const resp = await fetch('/api/employees/departments/');
if (resp.ok) {
departments = await resp.json();
const select = document.getElementById('department');
departments.forEach(dept => {
select.insertAdjacentHTML('beforeend',
`<option value="${dept.id}">${dept.name}</option>`);
});
}
} catch (e) {
console.error('Error loading departments:', e);
}
}
// === ЗАГРУЗКА БЮДЖЕТА ===
async function loadBudget() {
try {
const resp = await fetch(`/api/procurement/budgets/${budgetId}/`);
if (!resp.ok) {
if (resp.status === 404) {
showError('Бюджет не найден');
return;
}
throw new Error('Ошибка загрузки');
}
budgetData = await resp.json();
// Заполняем форму
const deptId = typeof budgetData.department === 'object'
? budgetData.department.id
: budgetData.department;
const deptName = typeof budgetData.department === 'object'
? budgetData.department.name
: (departments.find(d => d.id == deptId)?.name || 'Отдел');
document.getElementById('department').value = deptId;
document.getElementById('year').value = budgetData.year;
document.getElementById('quarter').value = budgetData.quarter;
document.getElementById('allocated_amount').value = budgetData.allocated_amount;
// Обновляем заголовок
document.getElementById('pageSubtitle').textContent =
`${deptName} — ${budgetData.year} Q${budgetData.quarter}`;
// Показываем карточку статуса
renderStatusCard();
} catch (e) {
console.error('Error loading budget:', e);
showError('Ошибка загрузки бюджета');
}
}
// === РЕНДЕРИНГ СТАТУСА ===
function renderStatusCard() {
if (!budgetData) return;
const card = document.getElementById('budgetStatusCard');
card.classList.remove('d-none');
document.getElementById('statusAllocated').textContent =
formatNumber(budgetData.allocated_amount) + ' ₽';
document.getElementById('statusSpent').textContent =
formatNumber(budgetData.spent_amount) + ' ₽';
document.getElementById('statusRemaining').textContent =
formatNumber(budgetData.remaining_amount) + ' ₽';
const pct = budgetData.utilization_percentage || 0;
document.getElementById('statusPercent').textContent = Math.round(pct * 10) / 10 + '%';
const progressBar = document.getElementById('statusProgress');
progressBar.style.width = pct + '%';
progressBar.className = 'progress-bar ' +
(pct > 90 ? 'bg-danger' : pct > 70 ? 'bg-warning' : 'bg-success');
}
// === ОТПРАВКА ФОРМЫ ===
async function submitForm(e) {
e.preventDefault();
const form = e.target;
const submitBtn = document.getElementById('submitBtn');
const originalText = document.getElementById('submitBtnText').textContent;
// Сброс ошибок
clearErrors();
submitBtn.disabled = true;
document.getElementById('submitBtnText').textContent = 'Сохранение...';
try {
const data = {
department: document.getElementById('department').value,
year: parseInt(document.getElementById('year').value),
quarter: parseInt(document.getElementById('quarter').value),
allocated_amount: parseFloat(document.getElementById('allocated_amount').value)
};
const url = budgetId
? `/api/procurement/budgets/${budgetId}/`
: '/api/procurement/budgets/';
const method = budgetId ? 'PATCH' : 'POST';
const resp = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify(data)
});
if (!resp.ok) {
const err = await resp.json();
handleErrors(err);
return;
}
// Успех - перенаправляем на список
window.location.href = '/procurement/budgets/';
} catch (e) {
console.error('Error saving budget:', e);
showError('Ошибка сохранения: ' + e.message);
} finally {
submitBtn.disabled = false;
document.getElementById('submitBtnText').textContent = originalText;
}
}
// === ОБРАБОТКА ОШИБОК ===
function handleErrors(errors) {
if (typeof errors === 'string') {
showError(errors);
return;
}
if (errors.detail) {
showError(errors.detail);
return;
}
// Ошибки полей
const fields = ['department', 'year', 'quarter', 'allocated_amount'];
fields.forEach(field => {
if (errors[field]) {
const input = document.getElementById(field);
const errorDiv = document.getElementById(field + 'Error');
input.classList.add('is-invalid');
errorDiv.textContent = Array.isArray(errors[field])
? errors[field].join(', ')
: errors[field];
errorDiv.classList.add('d-block');
}
});
// Общие ошибки
if (errors.non_field_errors) {
showError(errors.non_field_errors.join(', '));
}
}
function showError(message) {
const errorDiv = document.getElementById('formErrors');
errorDiv.textContent = message;
errorDiv.classList.remove('d-none');
}
function clearErrors() {
document.getElementById('formErrors').classList.add('d-none');
const fields = ['department', 'year', 'quarter', 'allocated_amount'];
fields.forEach(field => {
const input = document.getElementById(field);
const errorDiv = document.getElementById(field + 'Error');
input.classList.remove('is-invalid');
errorDiv.textContent = '';
errorDiv.classList.remove('d-block');
});
}
// === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ===
function formatNumber(num) {
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2 }).format(num);
}
function getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='))?.split('=')[1];
}
// === ОБРАБОТЧИКИ СОБЫТИЙ ===
function setupEventListeners() {
document.getElementById('budgetForm').addEventListener('submit', submitForm);
}
</script>
{% endblock %}

View File

@ -0,0 +1,541 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Бюджеты отделов{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
<link rel="stylesheet" href="{% static 'css/components/procurement.css' %}">
<style>
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: 4px;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Стили для строк бюджета - десктоп */
.budget-row {
display: grid;
grid-template-columns: 2fr 80px 1fr 1fr 1fr 2fr;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--bs-border-color);
transition: background 0.15s ease;
}
.budget-row:hover {
background: var(--bs-tertiary-bg);
}
.budget-row:last-child {
border-bottom: none;
}
/* Мобильная версия - карточки */
@media (max-width: 991.98px) {
.budget-list-content {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.budget-row {
display: block;
padding: 1rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
background: var(--bs-body-bg);
}
.budget-row:hover {
background: var(--bs-body-bg);
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
}
.budget-mobile-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--bs-border-color);
}
.budget-mobile-stats {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.budget-mobile-stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: var(--bs-tertiary-bg);
border-radius: 0.375rem;
}
.budget-mobile-stat-label {
font-size: 0.875rem;
color: var(--bs-secondary-color);
}
.budget-mobile-stat-value {
font-weight: 600;
font-size: 1rem;
}
}
</style>
{% endblock %}
{% block content %}
{# ===== ШАПКА СТРАНИЦЫ ===== #}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-0"><i class="bi-wallet2 me-2"></i>Бюджеты отделов</h4>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" type="button"
data-bs-toggle="collapse" data-bs-target="#budgetFilters">
<i class="bi-funnel"></i>
</button>
<button id="createNextQuarterBtn" class="btn btn-sm btn-outline-secondary d-none">
<i class="bi-arrow-repeat me-2"></i>Создать на сл. квартал
</button>
<a id="createBudgetBtn" href="/procurement/budgets/create/" class="btn btn-sm btn-primary d-none">
<i class="bi-plus-lg me-1"></i>Добавить бюджет
</a>
</div>
</div>
{# ===== ФИЛЬТРЫ ===== #}
<div class="collapse mb-3" id="budgetFilters">
<div class="card card-body">
<div class="row g-2">
<div class="col-md-3">
<label class="form-label form-label-sm">Год</label>
<select id="filterYear" class="form-select form-select-sm">
<option value="">Все годы</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label form-label-sm">Квартал</label>
<select id="filterQuarter" class="form-select form-select-sm">
<option value="">Все кварталы</option>
<option value="1">Q1</option>
<option value="2">Q2</option>
<option value="3">Q3</option>
<option value="4">Q4</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label form-label-sm">Отдел</label>
<select id="filterDepartment" class="form-select form-select-sm">
<option value="">Все отделы</option>
</select>
</div>
<div class="col-md-3 d-flex align-items-end">
<button id="applyFiltersBtn" class="btn btn-sm btn-primary me-2">
<i class="bi-check-lg me-1"></i>Применить
</button>
<button id="clearFiltersBtn" class="btn btn-sm btn-outline-secondary">
<i class="bi-x-lg"></i>
</button>
</div>
</div>
</div>
</div>
{# ===== СВОДКА ===== #}
<div class="row mb-4" id="summaryCards">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<div id="totalAllocated" class="fs-4 fw-bold text-primary">
<div class="skeleton" style="height:28px;width:80%;margin:0 auto;"></div>
</div>
<div class="small text-muted">Всего выделено</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<div id="totalSpent" class="fs-4 fw-bold text-danger">
<div class="skeleton" style="height:28px;width:80%;margin:0 auto;"></div>
</div>
<div class="small text-muted">Потрачено</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<div id="totalRemaining" class="fs-4 fw-bold text-success">
<div class="skeleton" style="height:28px;width:80%;margin:0 auto;"></div>
</div>
<div class="small text-muted">Остаток</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<div id="totalCount" class="fs-4 fw-bold">
<div class="skeleton" style="height:28px;width:40%;margin:0 auto;"></div>
</div>
<div class="small text-muted">Бюджетов</div>
</div>
</div>
</div>
</div>
{# ===== ТАБЛИЦА БЮДЖЕТОВ - Десктоп ===== #}
<div class="card d-none d-lg-block">
<div class="proc-table-header">
<div class="budget-row" style="padding: 0.75rem 1.25rem; border-bottom: none;">
<div><h6 class="mb-0 text-secondary small text-uppercase">Отдел</h6></div>
<div><h6 class="mb-0 text-secondary small text-uppercase">Период</h6></div>
<div class="text-end"><h6 class="mb-0 text-secondary small text-uppercase">Выделено</h6></div>
<div class="text-end"><h6 class="mb-0 text-secondary small text-uppercase">Потрачено</h6></div>
<div class="text-end"><h6 class="mb-0 text-secondary small text-uppercase">Остаток</h6></div>
<div class="text-center"><h6 class="mb-0 text-secondary small text-uppercase">Использование</h6></div>
</div>
</div>
<div id="budgetListDesktop" class="budget-list-content">
<!-- Skeleton loader -->
<div class="budget-row">
<div class="skeleton" style="height:20px;width:60%;"></div>
<div class="skeleton" style="height:20px;width:50px;"></div>
<div class="skeleton" style="height:20px;width:80%;"></div>
<div class="skeleton" style="height:20px;width:80%;"></div>
<div class="skeleton" style="height:20px;width:80%;"></div>
<div class="skeleton" style="height:8px;width:100%;"></div>
</div>
<div class="budget-row">
<div class="skeleton" style="height:20px;width:60%;"></div>
<div class="skeleton" style="height:20px;width:50px;"></div>
<div class="skeleton" style="height:20px;width:80%;"></div>
<div class="skeleton" style="height:20px;width:80%;"></div>
<div class="skeleton" style="height:20px;width:80%;"></div>
<div class="skeleton" style="height:8px;width:100%;"></div>
</div>
</div>
</div>
{# ===== МОБИЛЬНАЯ ВЕРСИЯ ===== #}
<div id="budgetListMobile" class="d-lg-none budget-list-content">
<!-- Skeleton loader -->
<div class="card p-3">
<div class="skeleton mb-2" style="height:24px;width:70%;"></div>
<div class="skeleton mb-3" style="height:20px;width:40%;"></div>
<div class="skeleton mb-2" style="height:40px;"></div>
<div class="skeleton mb-2" style="height:40px;"></div>
<div class="skeleton" style="height:8px;"></div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// === СОСТОЯНИЕ ===
let isStaff = false;
let currentFilters = {};
let departments = [];
// === ИНИЦИАЛИЗАЦИЯ ===
document.addEventListener('DOMContentLoaded', async () => {
await loadUserInfo();
await loadDepartments();
loadBudgets();
setupEventListeners();
});
// === ЗАГРУЗКА ИНФОРМАЦИИ О ПОЛЬЗОВАТЕЛЕ ===
async function loadUserInfo() {
try {
const resp = await fetch('/api/v1/employees/me/');
if (resp.ok) {
const user = await resp.json();
isStaff = user.is_staff || user.is_superuser;
// Показываем кнопки для staff
if (isStaff) {
document.getElementById('createBudgetBtn').classList.remove('d-none');
document.getElementById('createNextQuarterBtn').classList.remove('d-none');
}
}
} catch (e) {
console.error('Error loading user info:', e);
}
}
// === ЗАГРУЗКА ОТДЕЛОВ ===
async function loadDepartments() {
try {
const resp = await fetch('/api/employees/departments/');
if (resp.ok) {
departments = await resp.json();
const filterSelect = document.getElementById('filterDepartment');
departments.forEach(dept => {
filterSelect.insertAdjacentHTML('beforeend',
`<option value="${dept.id}">${dept.name}</option>`);
});
}
} catch (e) {
console.error('Error loading departments:', e);
}
}
// === ЗАГРУЗКА БЮДЖЕТОВ ===
async function loadBudgets() {
try {
// Формируем URL с параметрами
const params = new URLSearchParams();
if (currentFilters.year) params.set('year', currentFilters.year);
if (currentFilters.quarter) params.set('quarter', currentFilters.quarter);
if (currentFilters.department) params.set('department', currentFilters.department);
const resp = await fetch(`/api/procurement/budgets/?${params}`);
if (!resp.ok) throw new Error('Ошибка загрузки');
const data = await resp.json();
const budgets = data.results || data;
renderBudgets(budgets);
renderSummary(budgets);
populateYearFilter(budgets);
} catch (e) {
console.error('Error loading budgets:', e);
document.getElementById('budgetListDesktop').innerHTML = `
<div class="text-center py-5 text-danger">
<i class="bi-exclamation-triangle fs-1 d-block mb-2"></i>
<p>Ошибка загрузки бюджетов</p>
<button class="btn btn-outline-primary btn-sm" onclick="loadBudgets()">
<i class="bi-arrow-clockwise me-1"></i>Повторить
</button>
</div>`;
}
}
// === РЕНДЕРИНГ БЮДЖЕТОВ ===
function renderBudgets(budgets) {
const desktopContainer = document.getElementById('budgetListDesktop');
const mobileContainer = document.getElementById('budgetListMobile');
if (!budgets || budgets.length === 0) {
const emptyHtml = `
<div class="text-center py-5 text-muted">
<i class="bi-wallet2 fs-1 d-block mb-3"></i>
<p class="mb-2">Бюджеты не найдены</p>
${isStaff ? `
<a href="/procurement/budgets/create/" class="btn btn-primary">
<i class="bi-plus-lg me-2"></i>Добавить бюджет
</a>
` : ''}
</div>`;
desktopContainer.innerHTML = emptyHtml;
mobileContainer.innerHTML = emptyHtml;
return;
}
// Десктоп-версия
let desktopHtml = '';
budgets.forEach(budget => {
const deptName = typeof budget.department === 'object'
? budget.department.name
: (departments.find(d => d.id == budget.department)?.name || 'Отдел');
const deptId = typeof budget.department === 'object' ? budget.department.id : budget.department;
const pct = budget.utilization_percentage || 0;
const progressClass = pct < 50 ? 'bg-success' : pct < 75 ? 'bg-warning' : pct < 90 ? 'bg-orange' : 'bg-danger';
desktopHtml += `
<div class="budget-row">
<div>
<a href="/employees/departments/${deptId}/" class="text-decoration-none fw-medium">
<i class="bi-building text-muted me-1"></i>${escapeHtml(deptName)}
</a>
</div>
<div>
<span class="badge bg-secondary">${budget.year} Q${budget.quarter}</span>
</div>
<div class="text-end">
<span class="fw-bold">${formatNumber(budget.allocated_amount)} ₽</span>
</div>
<div class="text-end">
<span class="text-danger">${formatNumber(budget.spent_amount)} ₽</span>
</div>
<div class="text-end">
<span class="text-success fw-medium">${formatNumber(budget.remaining_amount)} ₽</span>
</div>
<div>
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height: 8px;">
<div class="progress-bar ${progressClass}" role="progressbar" style="width: ${pct}%"></div>
</div>
<span class="small text-muted" style="min-width: 40px;">${Math.round(pct)}%</span>
${isStaff ? `
<a href="/procurement/budgets/${budget.id}/edit/" class="btn btn-sm btn-link p-0" title="Редактировать">
<i class="bi-pencil"></i>
</a>
` : ''}
</div>
</div>
</div>`;
});
desktopContainer.innerHTML = desktopHtml;
// Мобильная версия
let mobileHtml = '';
budgets.forEach(budget => {
const deptName = typeof budget.department === 'object'
? budget.department.name
: (departments.find(d => d.id == budget.department)?.name || 'Отдел');
const deptId = typeof budget.department === 'object' ? budget.department.id : budget.department;
const pct = budget.utilization_percentage || 0;
const progressClass = pct < 50 ? 'bg-success' : pct < 75 ? 'bg-warning' : pct < 90 ? 'bg-orange' : 'bg-danger';
mobileHtml += `
<div class="budget-row">
<div class="budget-mobile-header">
<div>
<a href="/employees/departments/${deptId}/" class="text-decoration-none fw-medium d-block mb-1">
<i class="bi-building text-muted me-1"></i>${escapeHtml(deptName)}
</a>
<span class="badge bg-secondary">${budget.year} Q${budget.quarter}</span>
</div>
${isStaff ? `
<a href="/procurement/budgets/${budget.id}/edit/" class="btn btn-sm btn-outline-secondary" title="Редактировать">
<i class="bi-pencil"></i>
</a>
` : ''}
</div>
<div class="budget-mobile-stats">
<div class="budget-mobile-stat">
<span class="budget-mobile-stat-label">Выделено</span>
<span class="budget-mobile-stat-value text-primary">${formatNumber(budget.allocated_amount)} ₽</span>
</div>
<div class="budget-mobile-stat">
<span class="budget-mobile-stat-label">Потрачено</span>
<span class="budget-mobile-stat-value text-danger">${formatNumber(budget.spent_amount)} ₽</span>
</div>
<div class="budget-mobile-stat">
<span class="budget-mobile-stat-label">Остаток</span>
<span class="budget-mobile-stat-value text-success">${formatNumber(budget.remaining_amount)} ₽</span>
</div>
<div class="budget-mobile-stat">
<span class="budget-mobile-stat-label">Использовано</span>
<span class="budget-mobile-stat-value">${Math.round(pct)}%</span>
</div>
</div>
<div>
<div class="progress" style="height: 8px;">
<div class="progress-bar ${progressClass}" role="progressbar" style="width: ${pct}%"></div>
</div>
</div>
</div>`;
});
mobileContainer.innerHTML = mobileHtml;
}
// === РЕНДЕРИНГ СВОДКИ ===
function renderSummary(budgets) {
const totalAllocated = budgets.reduce((sum, b) => sum + parseFloat(b.allocated_amount || 0), 0);
const totalSpent = budgets.reduce((sum, b) => sum + parseFloat(b.spent_amount || 0), 0);
const totalRemaining = budgets.reduce((sum, b) => sum + parseFloat(b.remaining_amount || 0), 0);
document.getElementById('totalAllocated').textContent = formatNumber(totalAllocated) + ' ₽';
document.getElementById('totalSpent').textContent = formatNumber(totalSpent) + ' ₽';
document.getElementById('totalRemaining').textContent = formatNumber(totalRemaining) + ' ₽';
document.getElementById('totalCount').textContent = budgets.length;
}
// === ЗАПОЛНЕНИЕ ФИЛЬТРА ГОДОВ ===
function populateYearFilter(budgets) {
const years = [...new Set(budgets.map(b => b.year))].sort((a, b) => b - a);
const filterYear = document.getElementById('filterYear');
// Очистим и заполним заново
filterYear.innerHTML = '<option value="">Все годы</option>';
years.forEach(year => {
filterYear.insertAdjacentHTML('beforeend', `<option value="${year}">${year}</option>`);
});
}
// === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ===
function formatNumber(num) {
return new Intl.NumberFormat('ru-RU').format(Math.round(num));
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='))?.split('=')[1];
}
// === ОБРАБОТЧИКИ СОБЫТИЙ ===
function setupEventListeners() {
// Фильтры
document.getElementById('applyFiltersBtn').addEventListener('click', () => {
currentFilters.year = document.getElementById('filterYear').value;
currentFilters.quarter = document.getElementById('filterQuarter').value;
currentFilters.department = document.getElementById('filterDepartment').value;
loadBudgets();
});
document.getElementById('clearFiltersBtn').addEventListener('click', () => {
document.getElementById('filterYear').value = '';
document.getElementById('filterQuarter').value = '';
document.getElementById('filterDepartment').value = '';
currentFilters = {};
loadBudgets();
});
// Создать бюджеты на следующий квартал
document.getElementById('createNextQuarterBtn').addEventListener('click', async () => {
if (!confirm('Создать бюджеты на следующий квартал для всех отделов?\n\nБудут скопированы суммы из текущего квартала.')) {
return;
}
try {
const resp = await fetch('/api/procurement/budgets/create_next_quarter/', {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
}
});
if (resp.ok) {
const data = await resp.json();
alert(`Создано бюджетов: ${data.created}\nПропущено: ${data.skipped}`);
loadBudgets();
} else {
const err = await resp.json();
alert(err.error || 'Ошибка создания бюджетов');
}
} catch (e) {
console.error(e);
alert('Ошибка сети');
}
});
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,134 @@
{#
Модал создания оборудования (включаемый шаблон)
Данные загружаются через API:
- /api/procurement/categories/ - категории
- /api/procurement/equipment/create-options/ - отделы и права
- /api/employees/users/ - пользователи (если нужен выбор)
Опциональный контекст для предзаполнения:
- prefill_item_id: ID позиции заявки
- prefill_name: название
- prefill_price: стоимость
- prefill_dept_id: ID отдела
#}
<div class="modal fade" id="equipmentCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<form class="modal-content" id="equipmentCreateForm">
{% csrf_token %}
{# Скрытое поле для связи с позицией заявки #}
<input type="hidden" name="from_item_id" id="createFromItemId" value="{{ prefill_item_id|default:'' }}">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi-plus-circle me-2"></i>Добавить оборудование
{% if prefill_item_id %}
<small class="text-muted ms-2">(из заявки на закупку)</small>
{% endif %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
{# Индикатор загрузки #}
<div id="equipmentModalLoader" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<p class="text-muted mt-2 mb-0">Загрузка данных...</p>
</div>
{# Форма (скрыта до загрузки) #}
<div id="equipmentModalContent" style="display: none;">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Название <span class="text-danger">*</span></label>
<input type="text" name="name" id="createName" class="form-control"
placeholder="Например: Ноутбук Dell Latitude 5420" required maxlength="255"
value="{{ prefill_name|default:'' }}">
</div>
<div class="col-md-4">
<label class="form-label">Категория <span class="text-danger">*</span></label>
<select name="category" id="createCategory" class="form-select" required>
<option value="">Загрузка...</option>
</select>
</div>
{% if not prefill_item_id %}
<div class="col-md-4">
<label class="form-label">Количество</label>
<input type="number" name="quantity" id="createQuantity" class="form-control"
value="1" min="1" max="100">
<small class="text-muted">При значении > 1 создаётся несколько единиц</small>
</div>
{% endif %}
<div class="col-md-6">
<label class="form-label">Серийный номер</label>
<input type="text" name="serial_number" id="createSerialNumber" class="form-control"
placeholder="SN123456789" maxlength="100">
</div>
<div class="col-md-6">
<label class="form-label">Отдел <span class="text-danger">*</span></label>
<select name="department" id="createDepartment" class="form-select" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Ответственный</label>
<select name="responsible_person" id="createResponsiblePerson" class="form-select">
<option value="">Загрузка...</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Расположение</label>
<input type="text" name="location" id="createLocation" class="form-control"
placeholder="Например: Офис 3.14, Стеллаж А-5" maxlength="255">
</div>
<div class="col-md-4">
<label class="form-label">Дата покупки <span class="text-danger">*</span></label>
<input type="date" name="purchase_date" id="createPurchaseDate" class="form-control" required>
</div>
<div class="col-md-4">
<label class="form-label">Гарантия до</label>
<input type="date" name="warranty_until" id="createWarrantyUntil" class="form-control">
</div>
<div class="col-md-4">
<label class="form-label">Стоимость <span class="text-danger">*</span></label>
<div class="input-group">
<input type="number" name="purchase_cost" id="createPurchaseCost" class="form-control"
placeholder="0.00" min="0.01" step="0.01" required
value="{{ prefill_price|default:'' }}">
<span class="input-group-text">₽</span>
</div>
</div>
<div class="col-12">
<label class="form-label">Примечания</label>
<textarea name="notes" id="createNotes" class="form-control" rows="2"
placeholder="Дополнительная информация..."></textarea>
</div>
</div>
</div>
{# Сообщение об ошибке #}
<div id="equipmentModalError" class="alert alert-danger" style="display: none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary" id="equipmentModalSubmitBtn" disabled>
<i class="bi-check-lg me-1"></i>Добавить
</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,217 @@
{#
JavaScript для модала создания оборудования (включаемый шаблон)
Требуемый контекст:
- user_department: отдел текущего пользователя (опционально)
- dept_head_id: ID руководителя отдела (опционально)
Параметры из Django шаблона:
- prefill_item_id: для связи с позицией заявки
#}
<script>
(function() {
'use strict';
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;
const form = document.getElementById('equipmentCreateForm');
const modal = document.getElementById('equipmentCreateModal');
if (!form || !modal) {
console.warn('[Equipment Create] Modal or form not found');
return;
}
// Данные из Django шаблона для предзаполнения
const userDeptId = {{ user_department.id|default:'null' }};
const deptHeadId = {{ dept_head_id|default:'null' }};
// Инициализация при открытии модала
modal.addEventListener('show.bs.modal', async () => {
console.log('[Equipment Create] Modal opening...');
// Устанавливаем сегодняшнюю дату покупки если не заполнена
const purchaseDateInput = form.querySelector('[name="purchase_date"]');
if (purchaseDateInput && !purchaseDateInput.value) {
purchaseDateInput.value = new Date().toISOString().split('T')[0];
}
// Сбрасываем количество на 1
const qtyInput = form.querySelector('[name="quantity"]');
if (qtyInput) qtyInput.value = '1';
// Устанавливаем отдел пользователя если не выбран
const deptSelect = form.querySelector('[name="department"]');
if (deptSelect && !deptSelect.value && userDeptId) {
deptSelect.value = userDeptId;
// Устанавливаем ответственного - руководителя отдела
if (deptHeadId) {
const responsibleSelect = form.querySelector('[name="responsible_person"]');
if (responsibleSelect) {
responsibleSelect.value = deptHeadId;
}
}
}
});
// Обработчик смены отдела - загружаем руководителя
const deptSelect = form.querySelector('[name="department"]');
if (deptSelect) {
deptSelect.addEventListener('change', async (e) => {
const departmentId = e.target.value;
if (!departmentId) return;
try {
const resp = await fetch(`/api/employees/departments/${departmentId}/`);
if (resp.ok) {
const dept = await resp.json();
if (dept.head) {
const responsibleSelect = form.querySelector('[name="responsible_person"]');
if (responsibleSelect) {
responsibleSelect.value = dept.head;
console.log('[Equipment Create] Set responsible to dept head:', dept.head);
}
}
}
} catch (err) {
console.error('[Equipment Create] Error loading department:', err);
}
});
}
// Обработка отправки формы
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
const quantity = parseInt(data.quantity) || 1;
const fromItemId = data.from_item_id || null;
// Удаляем служебные поля из данных
delete data.csrfmiddlewaretoken;
delete data.quantity;
delete data.from_item_id;
// Преобразуем пустые строки в null
Object.keys(data).forEach(key => {
if (data[key] === '') data[key] = null;
});
console.log('[Equipment Create] Submitting:', data, 'Quantity:', quantity, 'FromItem:', fromItemId);
const submitBtn = form.querySelector('[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Создание...';
try {
let resp;
let result;
if (fromItemId) {
// Создание из позиции заявки - используем специальный endpoint
// Сначала генерируем инвентарный номер
const invResp = await fetch('/api/procurement/equipment/generate-inventory-number/');
const invData = await invResp.json();
resp = await fetch(`/api/procurement/items/${fromItemId}/create_equipment/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
inventory_number: invData.inventory_number,
category: data.category,
department: data.department,
serial_number: data.serial_number,
location: data.location,
responsible_person: data.responsible_person,
warranty_until: data.warranty_until,
purchase_date: data.purchase_date,
purchase_cost: data.purchase_cost,
notes: data.notes,
}),
});
result = await resp.json();
if (resp.ok) {
// Закрываем модал и перенаправляем на оборудование
bootstrap.Modal.getInstance(modal).hide();
if (result.equipment && result.equipment.id) {
window.location.href = `/procurement/equipment/${result.equipment.id}/`;
} else {
// Обновляем текущую страницу
window.location.reload();
}
return;
}
} else {
// Обычное создание оборудования
for (let i = 0; i < quantity; i++) {
resp = await fetch('/api/procurement/equipment/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(data),
});
result = await resp.json();
if (!resp.ok) break;
}
if (resp.ok) {
bootstrap.Modal.getInstance(modal).hide();
form.reset();
// Обновляем страницу или таблицу
if (typeof window.loadEquipmentTable === 'function') {
window.loadEquipmentTable();
} else {
window.location.reload();
}
// Показываем уведомление
const message = quantity > 1
? `Создано ${quantity} единиц оборудования`
: 'Оборудование успешно добавлено';
if (typeof showToast === 'function') {
showToast(message, 'success');
}
return;
}
}
// Обработка ошибок
let errorMsg = 'Ошибка создания оборудования';
if (result.error) {
errorMsg = result.error;
} else if (result.detail) {
errorMsg = result.detail;
} else if (typeof result === 'object') {
errorMsg = Object.entries(result)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
.join('; ');
}
alert(errorMsg);
} catch (err) {
console.error('[Equipment Create] Error:', err);
alert('Ошибка сети при создании оборудования');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
console.log('[Equipment Create] Module initialized');
})();
</script>

View File

@ -201,24 +201,6 @@
</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>
@ -262,195 +244,29 @@
{% 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 {
await apiCall('submit');
window.location.reload();
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 || 'Ошибка отправки');
}
} catch (e) {
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);
console.error(e);
alert('Ошибка сети');
}
});
</script>

View File

@ -72,7 +72,6 @@ def procurement_request(db, department, user):
requestor=user,
status=ProcurementStatus.DRAFT,
urgency=UrgencyLevel.MEDIUM,
estimated_cost=Decimal("50000.00"),
)
@ -98,8 +97,13 @@ class TestProcurementRequestAPI:
assert len(response.data['results']) == 1
assert response.data['results'][0]['title'] == "Тестовая заявка"
def test_create_request(self, api_client, user, department):
def test_create_request(
self, api_client, user, department, link_factory
):
"""Тест: создание заявки."""
# Связываем пользователя с отделом (необходимо для создания заявки)
link_factory(user, department, is_active=True)
api_client.force_authenticate(user=user)
url = reverse('procurement:procurementrequest-list')
@ -108,7 +112,6 @@ class TestProcurementRequestAPI:
'description': 'Нужны компьютеры',
'department': department.id,
'urgency': UrgencyLevel.HIGH,
'estimated_cost': '100000.00',
'items': [
{
'name': 'Ноутбук',

View File

@ -11,6 +11,7 @@ from rest_framework import status
from procurement.models import (
Budget,
ProcurementRequest,
ProcurementItem,
)
from procurement.constants import ProcurementStatus, UrgencyLevel
@ -47,24 +48,37 @@ class TestBudgetReservedAmount:
def test_reserved_amount_with_pending(self):
"""Тест: reserved_amount считает сумму pending заявок."""
# Создаём pending заявку
ProcurementRequest.objects.create(
# Создаём pending заявки с позициями
request1 = ProcurementRequest.objects.create(
title='Заявка 1',
description='Описание',
department=self.department,
requestor=self.user,
status=ProcurementStatus.PENDING,
urgency=UrgencyLevel.LOW,
estimated_cost=Decimal('5000.00'),
)
ProcurementRequest.objects.create(
ProcurementItem.objects.create(
request=request1,
name='Товар 1',
quantity=1,
unit='шт',
estimated_unit_price=Decimal('5000.00'),
)
request2 = ProcurementRequest.objects.create(
title='Заявка 2',
description='Описание',
department=self.department,
requestor=self.user,
status=ProcurementStatus.PENDING,
urgency=UrgencyLevel.MEDIUM,
estimated_cost=Decimal('3000.00'),
)
ProcurementItem.objects.create(
request=request2,
name='Товар 2',
quantity=1,
unit='шт',
estimated_unit_price=Decimal('3000.00'),
)
# reserved должен быть 8000
@ -72,14 +86,20 @@ class TestBudgetReservedAmount:
def test_available_amount(self):
"""Тест: available_amount = remaining - reserved."""
ProcurementRequest.objects.create(
request = ProcurementRequest.objects.create(
title='Pending заявка',
description='Описание',
department=self.department,
requestor=self.user,
status=ProcurementStatus.PENDING,
urgency=UrgencyLevel.LOW,
estimated_cost=Decimal('10000.00'),
)
ProcurementItem.objects.create(
request=request,
name='Товар',
quantity=1,
unit='шт',
estimated_unit_price=Decimal('10000.00'),
)
# remaining = 100000 - 20000 = 80000
@ -201,7 +221,6 @@ class TestProcurementStatsEndpoints:
requestor=self.user,
status=ProcurementStatus.PENDING,
urgency=UrgencyLevel.MEDIUM,
estimated_cost=Decimal('5000.00'),
)
def test_stats_overview(self):

View File

@ -126,7 +126,6 @@ class TestProcurementSignals:
description="Описание",
department=department_with_head,
requestor=requestor,
estimated_cost=Decimal('5000.00'),
)
# Проверяем уведомление руководителю
@ -147,7 +146,6 @@ class TestProcurementSignals:
title="Заявка на согласование",
department=department_with_head,
requestor=requestor,
estimated_cost=Decimal('5000.00'),
)
# Добавляем позицию
@ -238,7 +236,6 @@ class TestApprovalNotifications:
department=department_with_head,
requestor=requestor,
status=ProcurementStatus.PENDING,
estimated_cost=Decimal('5000.00'),
)
approval = Approval.objects.create(
@ -271,7 +268,6 @@ class TestApprovalNotifications:
department=department_with_head,
requestor=requestor,
status=ProcurementStatus.PENDING,
estimated_cost=Decimal('5000.00'),
)
approval = Approval.objects.create(

View File

@ -114,7 +114,6 @@ class TestRequestLifecycle:
requestor=self.requestor,
status=request_status,
urgency=UrgencyLevel.MEDIUM,
estimated_cost=5000,
)
ProcurementItem.objects.create(
request=request,
@ -149,8 +148,8 @@ class TestRequestLifecycle:
request.refresh_from_db()
assert request.status == ProcurementStatus.IN_PROGRESS
def test_start_work_forbidden_for_non_requestor(self):
"""Только автор заявки может начать работу."""
def test_start_work_by_another_user(self):
"""Любой авторизованный пользователь может взять заявку в работу."""
request = self._create_request(ProcurementStatus.APPROVED)
self.client.force_authenticate(user=self.other_user)
@ -159,7 +158,11 @@ class TestRequestLifecycle:
f'/api/procurement/requests/{request.id}/start_work/'
)
assert response.status_code == status.HTTP_403_FORBIDDEN
# Любой авторизованный может взять в работу
assert response.status_code == status.HTTP_200_OK
request.refresh_from_db()
assert request.status == ProcurementStatus.IN_PROGRESS
assert request.executor == self.other_user
def test_start_work_wrong_status(self):
"""Нельзя начать работу над неодобренной заявкой."""
@ -177,8 +180,11 @@ class TestRequestLifecycle:
# ===== complete tests =====
def test_complete_success(self):
"""Тест завершения заявки в работе."""
"""Тест завершения заявки исполнителем."""
request = self._create_request(ProcurementStatus.IN_PROGRESS)
# Устанавливаем исполнителя
request.executor = self.requestor
request.save()
Approval.objects.create(
request=request,
@ -197,9 +203,12 @@ class TestRequestLifecycle:
request.refresh_from_db()
assert request.status == ProcurementStatus.COMPLETED
def test_complete_forbidden_for_non_requestor(self):
"""Только автор заявки может завершить её."""
def test_complete_forbidden_for_non_executor(self):
"""Только исполнитель может завершить заявку."""
request = self._create_request(ProcurementStatus.IN_PROGRESS)
# Исполнитель - requestor, но пытается завершить other_user
request.executor = self.requestor
request.save()
self.client.force_authenticate(user=self.other_user)
@ -212,6 +221,9 @@ class TestRequestLifecycle:
def test_complete_wrong_status(self):
"""Нельзя завершить заявку, которая не в работе."""
request = self._create_request(ProcurementStatus.APPROVED)
# Даже с исполнителем, если статус неверный - ошибка
request.executor = self.requestor
request.save()
self.client.force_authenticate(user=self.requestor)
@ -349,7 +361,6 @@ class TestCreateEquipmentFromItem:
requestor=self.requestor,
status=ProcurementStatus.COMPLETED,
urgency=UrgencyLevel.MEDIUM,
estimated_cost=50000,
)
item = ProcurementItem.objects.create(
request=request,
@ -452,7 +463,6 @@ class TestCreateEquipmentFromItem:
requestor=self.requestor,
status=ProcurementStatus.IN_PROGRESS,
urgency=UrgencyLevel.MEDIUM,
estimated_cost=10000,
)
item = ProcurementItem.objects.create(
request=request,

View File

@ -78,7 +78,6 @@ class TestProcurementRequest:
requestor=user,
status=ProcurementStatus.DRAFT,
urgency=UrgencyLevel.MEDIUM,
estimated_cost=Decimal("250000.00"),
)
assert request.id is not None
@ -92,7 +91,6 @@ class TestProcurementRequest:
title="Канцтовары",
department=department,
requestor=user,
estimated_cost=Decimal("5000.00"),
)
approvals = request.get_required_approvals()
@ -100,12 +98,19 @@ class TestProcurementRequest:
assert ApprovalRole.DEPARTMENT_HEAD in approvals
def test_get_required_approvals_medium(self, department, user):
"""Тест получения уровней согласования для средней суммы."""
"""Тест получения уровней согласования для средней суммы (10k-50k)."""
request = ProcurementRequest.objects.create(
title="Оргтехника",
department=department,
requestor=user,
estimated_cost=Decimal("30000.00"),
)
# Добавляем позицию для средней суммы (30000)
ProcurementItem.objects.create(
request=request,
name='Принтер',
quantity=1,
unit='шт',
estimated_unit_price=Decimal('30000.00'),
)
approvals = request.get_required_approvals()
@ -114,12 +119,19 @@ class TestProcurementRequest:
assert ApprovalRole.FINANCE_MANAGER in approvals
def test_get_required_approvals_high(self, department, user):
"""Тест получения уровней согласования для большой суммы."""
"""Тест получения уровней согласования для большой суммы (>50k)."""
request = ProcurementRequest.objects.create(
title="Серверное оборудование",
department=department,
requestor=user,
estimated_cost=Decimal("500000.00"),
)
# Добавляем позицию для большой суммы (500000)
ProcurementItem.objects.create(
request=request,
name='Сервер',
quantity=1,
unit='шт',
estimated_unit_price=Decimal('500000.00'),
)
approvals = request.get_required_approvals()
@ -134,7 +146,6 @@ class TestProcurementRequest:
title="Покупка",
department=department,
requestor=user,
estimated_cost=Decimal("50000.00"),
)
available, remaining = request.check_budget_available()

View File

@ -0,0 +1,761 @@
"""
Тесты прав доступа для API заявок на закупку.
Сценарии:
1. Создание заявки:
- admin/staff/superuser → любой отдел
- модельные права (add_procurementrequest) → любой отдел
- сотрудник отдела → только свой отдел
- сотрудник без отдела → запрещено
2. Изменение заявки:
- admin/staff/superuser → любая заявка в DRAFT
- модельные права → любая заявка в DRAFT
- автор заявки → своя заявка в DRAFT
- начальник отдела → заявки своего отдела в DRAFT
- другой сотрудник → запрещено
3. Удаление заявки:
- admin/staff/superuser → любая заявка
- модельные права → любая заявка в DRAFT
- автор заявки → своя заявка в DRAFT
- начальник отдела → заявки своего отдела в DRAFT
- другой сотрудник → запрещено
4. Просмотр заявок:
- сотрудник видит: свои + своего отдела + где approver
- admin/staff → все заявки
"""
import pytest
from decimal import Decimal
from datetime import date
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from rest_framework import status
from rest_framework.test import APIClient
from employees.models import Department, EmployeeDepartment
from procurement.models import ProcurementRequest
from procurement.constants import ProcurementStatus, UrgencyLevel
User = get_user_model()
# =============================================================================
# Helpers
# =============================================================================
_phone_counter = 0
def unique_phone() -> str:
"""Генерирует уникальный телефон."""
global _phone_counter
_phone_counter += 1
return f"+7888{_phone_counter:07d}"
def make_user(
email: str,
staff: bool = False,
superuser: bool = False,
verified: bool = True,
) -> User:
"""Создаёт пользователя."""
return User.objects.create_user(
email=email,
password="testpass123",
phone_number=unique_phone(),
first_name="Test",
last_name="User",
is_staff=staff,
is_superuser=superuser,
is_active=True,
email_verified=verified,
send_activation_email=False,
)
def make_department(name: str, head: User = None) -> Department:
"""Создаёт отдел."""
return Department.objects.create(name=name, head=head)
def add_user_to_dept(
user: User,
dept: Department,
is_active: bool = True
) -> EmployeeDepartment:
"""Добавляет пользователя в отдел."""
link, _ = EmployeeDepartment.objects.update_or_create(
employee=user,
department=dept,
defaults={"is_active": is_active}
)
return link
def grant_model_permission(user: User, perm_codename: str, model_class) -> None:
"""Выдаёт модельное право пользователю."""
ct = ContentType.objects.get_for_model(model_class)
perm = Permission.objects.get(content_type=ct, codename=perm_codename)
user.user_permissions.add(perm)
# Очищаем кэш прав пользователя
if hasattr(user, '_perm_cache'):
del user._perm_cache
if hasattr(user, '_user_perm_cache'):
del user._user_perm_cache
def make_procurement_request(
title: str,
dept: Department,
requestor: User,
status: str = ProcurementStatus.DRAFT,
) -> ProcurementRequest:
"""Создаёт заявку на закупку."""
return ProcurementRequest.objects.create(
title=title,
description="Тестовое описание",
department=dept,
requestor=requestor,
status=status,
urgency=UrgencyLevel.MEDIUM,
)
def request_list_url() -> str:
"""URL для списка/создания заявок."""
return reverse("procurement:procurementrequest-list")
def request_detail_url(pk: int) -> str:
"""URL для конкретной заявки."""
return reverse("procurement:procurementrequest-detail", args=[pk])
def valid_request_data(dept: Department, title: str = "Тестовая заявка") -> dict:
"""Валидные данные для создания заявки."""
return {
"title": title,
"description": "Описание закупки",
"department": dept.id,
"urgency": UrgencyLevel.MEDIUM,
"items": [
{
"name": "Тестовый товар",
"quantity": 1,
"unit": "шт",
"estimated_unit_price": "1000.00",
}
]
}
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def api_client() -> APIClient:
return APIClient()
@pytest.fixture
def admin_user(db) -> User:
"""Администратор (staff)."""
return make_user("admin_proc@example.com", staff=True)
@pytest.fixture
def superuser(db) -> User:
"""Суперпользователь."""
return make_user("super_proc@example.com", superuser=True)
@pytest.fixture
def model_perm_user(db) -> User:
"""Пользователь с модельными правами на заявки."""
user = make_user("modelperm_proc@example.com")
grant_model_permission(user, "add_procurementrequest", ProcurementRequest)
grant_model_permission(user, "change_procurementrequest", ProcurementRequest)
grant_model_permission(user, "delete_procurementrequest", ProcurementRequest)
# Перезагружаем из БД для обновления кэша прав
user = User.objects.get(pk=user.pk)
return user
@pytest.fixture
def dept_it(db) -> Department:
"""IT отдел (без начальника изначально)."""
return make_department("IT отдел для заявок")
@pytest.fixture
def dept_hr(db) -> Department:
"""HR отдел."""
return make_department("HR отдел для заявок")
@pytest.fixture
def dept_head(db, dept_it) -> User:
"""Начальник IT отдела."""
user = make_user("head_proc@example.com")
dept_it.head = user
dept_it.save()
add_user_to_dept(user, dept_it)
return user
@pytest.fixture
def dept_member(db, dept_it) -> User:
"""Обычный сотрудник IT отдела."""
user = make_user("member_proc@example.com")
add_user_to_dept(user, dept_it)
return user
@pytest.fixture
def other_dept_member(db, dept_hr) -> User:
"""Сотрудник HR отдела."""
user = make_user("hr_member_proc@example.com")
add_user_to_dept(user, dept_hr)
return user
@pytest.fixture
def user_without_dept(db) -> User:
"""Пользователь без отдела."""
return make_user("nodept_proc@example.com")
# =============================================================================
# Tests: Создание заявки - кто может
# =============================================================================
@pytest.mark.django_db
class TestProcurementRequestCreateAccess:
"""Тесты: кто может создавать заявки."""
def test_unauthenticated_cannot_create(self, api_client, dept_it):
"""Неаутентифицированный пользователь не может создавать."""
data = valid_request_data(dept_it)
response = api_client.post(request_list_url(), data, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_admin_can_create_in_any_dept(
self, api_client, admin_user, dept_it, dept_hr
):
"""Админ может создавать заявку в любом отделе."""
api_client.force_authenticate(user=admin_user)
# В IT отдел
data = valid_request_data(dept_it, "Заявка в IT")
response = api_client.post(request_list_url(), data, format="json")
assert response.status_code == status.HTTP_201_CREATED
# В HR отдел
data = valid_request_data(dept_hr, "Заявка в HR")
response = api_client.post(request_list_url(), data, format="json")
assert response.status_code == status.HTTP_201_CREATED
def test_superuser_can_create_in_any_dept(
self, api_client, superuser, dept_it
):
"""Суперпользователь может создавать заявку в любом отделе."""
api_client.force_authenticate(user=superuser)
data = valid_request_data(dept_it)
response = api_client.post(request_list_url(), data, format="json")
assert response.status_code == status.HTTP_201_CREATED
def test_model_perm_user_can_create_in_any_dept(
self, api_client, model_perm_user, dept_it, dept_hr
):
"""Пользователь с модельными правами может создавать в любом отделе."""
api_client.force_authenticate(user=model_perm_user)
data = valid_request_data(dept_hr, "Заявка с модельными правами")
response = api_client.post(request_list_url(), data, format="json")
assert response.status_code == status.HTTP_201_CREATED
def test_dept_member_can_create_in_own_dept(
self, api_client, dept_member, dept_it
):
"""Сотрудник отдела может создать заявку в СВОЁМ отделе."""
api_client.force_authenticate(user=dept_member)
data = valid_request_data(dept_it)
response = api_client.post(request_list_url(), data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data['department'] == dept_it.id
def test_dept_member_cannot_create_in_other_dept(
self, api_client, dept_member, dept_hr
):
"""Сотрудник отдела НЕ может создать заявку в ЧУЖОМ отделе."""
api_client.force_authenticate(user=dept_member)
data = valid_request_data(dept_hr)
response = api_client.post(request_list_url(), data, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_dept_head_can_create_in_own_dept(
self, api_client, dept_head, dept_it
):
"""Начальник отдела может создать заявку в своём отделе."""
api_client.force_authenticate(user=dept_head)
data = valid_request_data(dept_it)
response = api_client.post(request_list_url(), data, format="json")
assert response.status_code == status.HTTP_201_CREATED
def test_dept_head_cannot_create_in_other_dept(
self, api_client, dept_head, dept_hr
):
"""Начальник отдела НЕ может создать заявку в чужом отделе."""
api_client.force_authenticate(user=dept_head)
data = valid_request_data(dept_hr)
response = api_client.post(request_list_url(), data, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_user_without_dept_cannot_create(
self, api_client, user_without_dept, dept_it
):
"""Пользователь без отдела НЕ может создавать заявки."""
api_client.force_authenticate(user=user_without_dept)
data = valid_request_data(dept_it)
response = api_client.post(request_list_url(), data, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_requestor_is_set_to_current_user(
self, api_client, dept_member, dept_it
):
"""Заявитель автоматически устанавливается как текущий пользователь."""
api_client.force_authenticate(user=dept_member)
data = valid_request_data(dept_it)
response = api_client.post(request_list_url(), data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data['requestor'] == dept_member.id
# =============================================================================
# Tests: Изменение заявки
# =============================================================================
@pytest.mark.django_db
class TestProcurementRequestUpdateAccess:
"""Тесты: кто может редактировать заявки."""
def test_admin_can_update_any_draft(
self, api_client, admin_user, dept_member, dept_it
):
"""Админ может редактировать любую заявку в DRAFT."""
request = make_procurement_request("Заявка", dept_it, dept_member)
api_client.force_authenticate(user=admin_user)
response = api_client.patch(
request_detail_url(request.id),
{"title": "Изменено админом"},
format="json"
)
assert response.status_code == status.HTTP_200_OK
assert response.data['title'] == "Изменено админом"
def test_superuser_can_update_any_draft(
self, api_client, superuser, dept_member, dept_it
):
"""Суперпользователь может редактировать любую заявку в DRAFT."""
request = make_procurement_request("Заявка", dept_it, dept_member)
api_client.force_authenticate(user=superuser)
response = api_client.patch(
request_detail_url(request.id),
{"title": "Изменено суперюзером"},
format="json"
)
assert response.status_code == status.HTTP_200_OK
def test_model_perm_user_can_update_any_draft(
self, api_client, model_perm_user, dept_member, dept_it
):
"""Пользователь с модельными правами может редактировать любую заявку."""
request = make_procurement_request("Заявка", dept_it, dept_member)
api_client.force_authenticate(user=model_perm_user)
response = api_client.patch(
request_detail_url(request.id),
{"title": "Изменено с модельными правами"},
format="json"
)
assert response.status_code == status.HTTP_200_OK
def test_owner_can_update_own_draft(
self, api_client, dept_member, dept_it
):
"""Автор может редактировать свою заявку в DRAFT."""
request = make_procurement_request("Моя заявка", dept_it, dept_member)
api_client.force_authenticate(user=dept_member)
response = api_client.patch(
request_detail_url(request.id),
{"title": "Моя обновлённая заявка"},
format="json"
)
assert response.status_code == status.HTTP_200_OK
assert response.data['title'] == "Моя обновлённая заявка"
def test_owner_cannot_update_pending(
self, api_client, dept_member, dept_it
):
"""Автор НЕ может редактировать заявку в статусе PENDING."""
request = make_procurement_request(
"Заявка на согласовании",
dept_it,
dept_member,
status=ProcurementStatus.PENDING
)
api_client.force_authenticate(user=dept_member)
response = api_client.patch(
request_detail_url(request.id),
{"title": "Попытка изменить"},
format="json"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_owner_cannot_update_approved(
self, api_client, dept_member, dept_it
):
"""Автор НЕ может редактировать заявку в статусе APPROVED."""
request = make_procurement_request(
"Одобренная заявка",
dept_it,
dept_member,
status=ProcurementStatus.APPROVED
)
api_client.force_authenticate(user=dept_member)
response = api_client.patch(
request_detail_url(request.id),
{"title": "Попытка изменить"},
format="json"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_dept_head_can_update_dept_draft(
self, api_client, dept_head, dept_member, dept_it
):
"""Начальник отдела может редактировать заявки своего отдела в DRAFT."""
request = make_procurement_request("Заявка сотрудника", dept_it, dept_member)
api_client.force_authenticate(user=dept_head)
response = api_client.patch(
request_detail_url(request.id),
{"title": "Изменено начальником"},
format="json"
)
assert response.status_code == status.HTTP_200_OK
def test_dept_head_cannot_update_other_dept(
self, api_client, dept_head, other_dept_member, dept_hr
):
"""Начальник отдела НЕ может редактировать заявки другого отдела."""
request = make_procurement_request(
"Заявка HR", dept_hr, other_dept_member
)
api_client.force_authenticate(user=dept_head)
response = api_client.patch(
request_detail_url(request.id),
{"title": "Попытка изменить HR"},
format="json"
)
# 404 - не видит в queryset, 403 - видит но нет прав
assert response.status_code in [
status.HTTP_403_FORBIDDEN,
status.HTTP_404_NOT_FOUND
]
def test_other_user_cannot_update(
self, api_client, dept_member, other_dept_member, dept_it
):
"""Другой сотрудник НЕ может редактировать чужую заявку."""
request = make_procurement_request("Заявка IT", dept_it, dept_member)
api_client.force_authenticate(user=other_dept_member)
response = api_client.patch(
request_detail_url(request.id),
{"title": "Попытка изменить"},
format="json"
)
# 404 - не видит в queryset, 403 - видит но нет прав
assert response.status_code in [
status.HTTP_403_FORBIDDEN,
status.HTTP_404_NOT_FOUND
]
# =============================================================================
# Tests: Удаление заявки
# =============================================================================
@pytest.mark.django_db
class TestProcurementRequestDeleteAccess:
"""Тесты: кто может удалять заявки."""
def test_admin_can_delete_any(
self, api_client, admin_user, dept_member, dept_it
):
"""Админ может удалить любую заявку."""
request = make_procurement_request("Заявка", dept_it, dept_member)
api_client.force_authenticate(user=admin_user)
response = api_client.delete(request_detail_url(request.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_superuser_can_delete_any(
self, api_client, superuser, dept_member, dept_it
):
"""Суперпользователь может удалить любую заявку."""
request = make_procurement_request("Заявка", dept_it, dept_member)
api_client.force_authenticate(user=superuser)
response = api_client.delete(request_detail_url(request.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_model_perm_user_can_delete(
self, api_client, model_perm_user, dept_member, dept_it
):
"""Пользователь с модельными правами может удалить заявку."""
request = make_procurement_request("Заявка", dept_it, dept_member)
api_client.force_authenticate(user=model_perm_user)
response = api_client.delete(request_detail_url(request.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_owner_can_delete_own_draft(
self, api_client, dept_member, dept_it
):
"""Автор может удалить свою заявку в DRAFT."""
request = make_procurement_request("Моя заявка", dept_it, dept_member)
api_client.force_authenticate(user=dept_member)
response = api_client.delete(request_detail_url(request.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_owner_cannot_delete_pending(
self, api_client, dept_member, dept_it
):
"""Автор НЕ может удалить заявку в статусе PENDING."""
request = make_procurement_request(
"Заявка на согласовании",
dept_it,
dept_member,
status=ProcurementStatus.PENDING
)
api_client.force_authenticate(user=dept_member)
response = api_client.delete(request_detail_url(request.id))
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_dept_head_can_delete_dept_draft(
self, api_client, dept_head, dept_member, dept_it
):
"""Начальник отдела может удалить заявку своего отдела в DRAFT."""
request = make_procurement_request("Заявка сотрудника", dept_it, dept_member)
api_client.force_authenticate(user=dept_head)
response = api_client.delete(request_detail_url(request.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_dept_head_cannot_delete_other_dept(
self, api_client, dept_head, other_dept_member, dept_hr
):
"""Начальник отдела НЕ может удалить заявку другого отдела."""
request = make_procurement_request(
"Заявка HR", dept_hr, other_dept_member
)
api_client.force_authenticate(user=dept_head)
response = api_client.delete(request_detail_url(request.id))
# 404 - не видит в queryset, 403 - видит но нет прав
assert response.status_code in [
status.HTTP_403_FORBIDDEN,
status.HTTP_404_NOT_FOUND
]
def test_other_user_cannot_delete(
self, api_client, other_dept_member, dept_member, dept_it
):
"""Другой сотрудник НЕ может удалить чужую заявку."""
request = make_procurement_request("Заявка IT", dept_it, dept_member)
api_client.force_authenticate(user=other_dept_member)
response = api_client.delete(request_detail_url(request.id))
# 404 - не видит в queryset, 403 - видит но нет прав
assert response.status_code in [
status.HTTP_403_FORBIDDEN,
status.HTTP_404_NOT_FOUND
]
# =============================================================================
# Tests: Просмотр заявок
# =============================================================================
@pytest.mark.django_db
class TestProcurementRequestViewAccess:
"""Тесты: видимость заявок."""
def test_unauthenticated_cannot_view_list(self, api_client):
"""Неаутентифицированный пользователь не видит список."""
response = api_client.get(request_list_url())
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_unauthenticated_cannot_view_detail(
self, api_client, dept_member, dept_it
):
"""Неаутентифицированный пользователь не видит детали."""
request = make_procurement_request("Заявка", dept_it, dept_member)
response = api_client.get(request_detail_url(request.id))
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_user_sees_own_requests(
self, api_client, dept_member, dept_it
):
"""Пользователь видит свои заявки."""
my_request = make_procurement_request("Моя заявка", dept_it, dept_member)
api_client.force_authenticate(user=dept_member)
response = api_client.get(request_list_url())
assert response.status_code == status.HTTP_200_OK
request_ids = [r['id'] for r in response.data['results']]
assert my_request.id in request_ids
def test_user_sees_dept_requests(
self, api_client, dept_member, dept_head, dept_it
):
"""Пользователь видит заявки своего отдела."""
# Заявка начальника
head_request = make_procurement_request(
"Заявка начальника", dept_it, dept_head
)
api_client.force_authenticate(user=dept_member)
response = api_client.get(request_list_url())
assert response.status_code == status.HTTP_200_OK
request_ids = [r['id'] for r in response.data['results']]
assert head_request.id in request_ids
def test_user_does_not_see_other_dept_requests(
self, api_client, dept_member, other_dept_member, dept_hr
):
"""Пользователь НЕ видит заявки другого отдела."""
hr_request = make_procurement_request(
"Заявка HR", dept_hr, other_dept_member
)
api_client.force_authenticate(user=dept_member)
response = api_client.get(request_list_url())
assert response.status_code == status.HTTP_200_OK
request_ids = [r['id'] for r in response.data['results']]
assert hr_request.id not in request_ids
def test_admin_sees_all_requests(
self, api_client, admin_user, dept_member, other_dept_member, dept_it, dept_hr
):
"""Админ видит все заявки."""
it_request = make_procurement_request("Заявка IT", dept_it, dept_member)
hr_request = make_procurement_request("Заявка HR", dept_hr, other_dept_member)
api_client.force_authenticate(user=admin_user)
response = api_client.get(request_list_url())
assert response.status_code == status.HTTP_200_OK
request_ids = [r['id'] for r in response.data['results']]
assert it_request.id in request_ids
assert hr_request.id in request_ids
def test_superuser_sees_all_requests(
self, api_client, superuser, dept_member, other_dept_member, dept_it, dept_hr
):
"""Суперпользователь видит все заявки."""
it_request = make_procurement_request("Заявка IT", dept_it, dept_member)
hr_request = make_procurement_request("Заявка HR", dept_hr, other_dept_member)
api_client.force_authenticate(user=superuser)
response = api_client.get(request_list_url())
assert response.status_code == status.HTTP_200_OK
request_ids = [r['id'] for r in response.data['results']]
assert it_request.id in request_ids
assert hr_request.id in request_ids
# =============================================================================
# Tests: Валидация данных
# =============================================================================
@pytest.mark.django_db
class TestProcurementRequestValidation:
"""Тесты валидации данных заявки."""
def test_cannot_create_without_items(
self, api_client, dept_member, dept_it
):
"""Нельзя создать заявку без позиций."""
api_client.force_authenticate(user=dept_member)
data = {
"title": "Заявка без позиций",
"description": "Описание",
"department": dept_it.id,
"urgency": UrgencyLevel.MEDIUM,
"items": []
}
response = api_client.post(request_list_url(), data, format="json")
# Может вернуть 400 или позволить создать пустую заявку (зависит от логики)
# Проверяем, что хотя бы не 500
assert response.status_code in [
status.HTTP_201_CREATED,
status.HTTP_400_BAD_REQUEST
]
def test_cannot_change_requestor(
self, api_client, dept_member, dept_head, dept_it
):
"""Нельзя изменить заявителя при редактировании."""
request = make_procurement_request("Заявка", dept_it, dept_member)
api_client.force_authenticate(user=dept_member)
response = api_client.patch(
request_detail_url(request.id),
{"requestor": dept_head.id},
format="json"
)
# Либо игнорирует поле, либо возвращает ошибку
if response.status_code == status.HTTP_200_OK:
# Проверяем, что requestor не изменился
assert response.data['requestor'] == dept_member.id
def test_cannot_change_department_after_creation(
self, api_client, dept_member, dept_it, dept_hr
):
"""Нельзя изменить отдел после создания (для обычного пользователя)."""
request = make_procurement_request("Заявка", dept_it, dept_member)
api_client.force_authenticate(user=dept_member)
response = api_client.patch(
request_detail_url(request.id),
{"department": dept_hr.id},
format="json"
)
# Либо игнорирует поле, либо возвращает ошибку
if response.status_code == status.HTTP_200_OK:
# Проверяем, что department не изменился
assert response.data['department'] == dept_it.id

View File

@ -160,7 +160,6 @@ class TestProcurementWorkflow:
requestor=self.requestor,
status=ProcurementStatus.DRAFT,
urgency=UrgencyLevel.MEDIUM,
estimated_cost=15000,
)
ProcurementItem.objects.create(
request=request,
@ -197,7 +196,6 @@ class TestProcurementWorkflow:
requestor=self.requestor,
status=ProcurementStatus.DRAFT,
urgency=UrgencyLevel.LOW,
estimated_cost=0,
)
self.client.force_authenticate(user=self.requestor)
@ -219,7 +217,6 @@ class TestProcurementWorkflow:
requestor=self.requestor,
status=ProcurementStatus.DRAFT,
urgency=UrgencyLevel.HIGH,
estimated_cost=200000,
)
ProcurementItem.objects.create(
request=request,
@ -248,7 +245,6 @@ class TestProcurementWorkflow:
requestor=self.requestor,
status=ProcurementStatus.PENDING,
urgency=UrgencyLevel.MEDIUM,
estimated_cost=3000,
)
ProcurementItem.objects.create(
request=request,
@ -293,7 +289,6 @@ class TestProcurementWorkflow:
requestor=self.requestor,
status=ProcurementStatus.PENDING,
urgency=UrgencyLevel.LOW,
estimated_cost=5000,
)
Approval.objects.create(
@ -331,7 +326,6 @@ class TestProcurementWorkflow:
requestor=self.requestor,
status=ProcurementStatus.PENDING,
urgency=UrgencyLevel.MEDIUM,
estimated_cost=45000,
)
ProcurementItem.objects.create(
request=request,
@ -391,7 +385,6 @@ class TestProcurementWorkflow:
requestor=self.requestor,
status=ProcurementStatus.PENDING,
urgency=UrgencyLevel.LOW,
estimated_cost=5000,
)
Approval.objects.create(
@ -425,7 +418,6 @@ class TestProcurementWorkflow:
requestor=self.requestor,
status=ProcurementStatus.PENDING,
urgency=UrgencyLevel.MEDIUM,
estimated_cost=10000,
)
assert request.is_editable is False