feat(procurement): frontend templates + API-based rendering; update models, serializers, views and tests
This commit is contained in:
parent
537e6c4127
commit
427f5ed22f
0
REQUESTS_APP_STRUCTURE.md
Normal file
0
REQUESTS_APP_STRUCTURE.md
Normal 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)
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
17
backend/procurement/migrations/0003_remove_estimated_cost.py
Normal file
17
backend/procurement/migrations/0003_remove_estimated_cost.py
Normal 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',
|
||||
),
|
||||
]
|
||||
@ -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='Взято в работу'),
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
"""Проверка права на управление оборудованием.
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
@ -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):
|
||||
"""Возвращает доступные опции для создания оборудования.
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 %}
|
||||
@ -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>
|
||||
|
||||
{# ===== ПРАВАЯ КОЛОНКА: настройки + лента ===== #}
|
||||
|
||||
42
backend/templates/procurement/_budget_filters.html
Normal file
42
backend/templates/procurement/_budget_filters.html
Normal 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>
|
||||
410
backend/templates/procurement/budget_form.html
Normal file
410
backend/templates/procurement/budget_form.html
Normal 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 %}
|
||||
541
backend/templates/procurement/budget_list.html
Normal file
541
backend/templates/procurement/budget_list.html
Normal 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
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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': 'Ноутбук',
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user