diff --git a/REQUESTS_APP_STRUCTURE.md b/REQUESTS_APP_STRUCTURE.md new file mode 100644 index 00000000..e69de29b diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py index 1a60d435..170484cf 100644 --- a/backend/employees/views_front.py +++ b/backend/employees/views_front.py @@ -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) diff --git a/backend/procurement/admin.py b/backend/procurement/admin.py index e9ce6820..2eb760b8 100644 --- a/backend/procurement/admin.py +++ b/backend/procurement/admin.py @@ -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): diff --git a/backend/procurement/forms.py b/backend/procurement/forms.py index f8441abf..be5c887c 100644 --- a/backend/procurement/forms.py +++ b/backend/procurement/forms.py @@ -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 diff --git a/backend/procurement/migrations/0003_remove_estimated_cost.py b/backend/procurement/migrations/0003_remove_estimated_cost.py new file mode 100644 index 00000000..d96fd618 --- /dev/null +++ b/backend/procurement/migrations/0003_remove_estimated_cost.py @@ -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', + ), + ] diff --git a/backend/procurement/migrations/0004_add_executor_and_started_at.py b/backend/procurement/migrations/0004_add_executor_and_started_at.py new file mode 100644 index 00000000..c1bbbf8e --- /dev/null +++ b/backend/procurement/migrations/0004_add_executor_and_started_at.py @@ -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='Взято в работу'), + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index 88f30f68..e20df7c6 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -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): diff --git a/backend/procurement/permissions.py b/backend/procurement/permissions.py index 385e8095..8c6571dd 100644 --- a/backend/procurement/permissions.py +++ b/backend/procurement/permissions.py @@ -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): """Проверка права на управление оборудованием. diff --git a/backend/procurement/serializers.py b/backend/procurement/serializers.py index 908dc681..71b16772 100644 --- a/backend/procurement/serializers.py +++ b/backend/procurement/serializers.py @@ -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): diff --git a/backend/procurement/signals.py b/backend/procurement/signals.py index 376582d7..a9f76e97 100644 --- a/backend/procurement/signals.py +++ b/backend/procurement/signals.py @@ -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, diff --git a/backend/procurement/urls_frontend.py b/backend/procurement/urls_frontend.py index 7159f557..646d92d1 100644 --- a/backend/procurement/urls_frontend.py +++ b/backend/procurement/urls_frontend.py @@ -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//edit/', views.budget_edit, name='budget_edit'), ] diff --git a/backend/procurement/views.py b/backend/procurement/views.py index 39f93be2..3c574783 100644 --- a/backend/procurement/views.py +++ b/backend/procurement/views.py @@ -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): """Возвращает доступные опции для создания оборудования. diff --git a/backend/procurement/views_frontend.py b/backend/procurement/views_frontend.py index 84fa6a18..abd35c07 100644 --- a/backend/procurement/views_frontend.py +++ b/backend/procurement/views_frontend.py @@ -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, + }) diff --git a/backend/templates/base.html b/backend/templates/base.html index 245fc62e..542a7fa6 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -77,7 +77,7 @@ + crossorigin="anonymous"> diff --git a/backend/templates/employees/components/department_budget.html b/backend/templates/employees/components/department_budget.html new file mode 100644 index 00000000..307a6a83 --- /dev/null +++ b/backend/templates/employees/components/department_budget.html @@ -0,0 +1,68 @@ +{# --- Виджет бюджета отдела --- #} +{% if department_budget or user.is_staff %} +
+
+
+ Бюджет Q{{ budget_quarter }} {{ budget_year }} +
+ {% if user.is_staff %} + + Управление + + {% endif %} +
+
+ {% if department_budget %} + {# Показываем данные бюджета #} +
+ Выделено: + {{ department_budget.allocated_amount|floatformat:0 }} ₽ +
+
+ Потрачено: + {{ department_budget.spent_amount|floatformat:0 }} ₽ +
+
+ Остаток: + {{ department_budget.remaining_amount|floatformat:0 }} ₽ +
+ + {# Прогресс-бар использования #} + {% with util=department_budget.utilization_percentage|default:0 %} +
+
+
+
+ Использовано {{ util|floatformat:1 }}% +
+ {% endwith %} + + {# Ссылка на заявки отдела #} + + {% else %} + {# Бюджет не создан #} +
+ +

Бюджет на {{ budget_quarter }} квартал не задан

+ {% if user.is_staff %} + + Создать бюджет + + {% endif %} +
+ {% endif %} +
+
+{% endif %} diff --git a/backend/templates/employees/department_detail.html b/backend/templates/employees/department_detail.html index a4d0059b..1047bee4 100644 --- a/backend/templates/employees/department_detail.html +++ b/backend/templates/employees/department_detail.html @@ -19,6 +19,7 @@
{% include "employees/components/department_team_circle.html" %} {% include "employees/components/department_info.html" %} + {% include "employees/components/department_budget.html" %}
{# ===== ПРАВАЯ КОЛОНКА: настройки + лента ===== #} diff --git a/backend/templates/procurement/_budget_filters.html b/backend/templates/procurement/_budget_filters.html new file mode 100644 index 00000000..0f427769 --- /dev/null +++ b/backend/templates/procurement/_budget_filters.html @@ -0,0 +1,42 @@ +{# Фильтры для списка бюджетов #} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+
diff --git a/backend/templates/procurement/budget_form.html b/backend/templates/procurement/budget_form.html new file mode 100644 index 00000000..e6164f73 --- /dev/null +++ b/backend/templates/procurement/budget_form.html @@ -0,0 +1,410 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}{% if budget_id %}Редактирование бюджета{% else %}Создание бюджета{% endif %}{% endblock %} + +{% block extra_css %} + + + +{% endblock %} + +{% block content %} + + {# ===== ШАПКА СТРАНИЦЫ ===== #} + + + {# ===== ФОРМА ===== #} +
+
+
+
+
+ Параметры бюджета +
+
+
+
+ {% csrf_token %} + + {# Ошибки формы #} +
+ +
+ {# Отдел #} +
+ + +
+
+ + {# Год #} +
+ + +
+
+ + {# Квартал #} +
+ + +
+
+ + {# Сумма #} +
+ +
+ + ₽ +
+
+
Общая сумма бюджета на указанный квартал
+
+
+ + {# Кнопки #} +
+ + + Отмена + +
+
+
+
+
+ + {# Подсказка #} +
+
+
+
+ Справка +
+
+
+

+ Бюджет отдела — это сумма, выделенная отделу на закупки + в определённом квартале. +

+

+ При создании заявок на закупку система проверяет остаток бюджета + и предупреждает о превышении. +

+

+ Потраченная сумма обновляется автоматически + при завершении заявок. +

+
+
+ +
+
+
Текущее состояние
+
+
+
+ Выделено: + 0 ₽ +
+
+ Потрачено: + 0 ₽ +
+
+ Остаток: + 0 ₽ +
+
+
+ Использовано: + 0% +
+
+
+
+
+
+
+
+ +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/templates/procurement/budget_list.html b/backend/templates/procurement/budget_list.html new file mode 100644 index 00000000..39273bd3 --- /dev/null +++ b/backend/templates/procurement/budget_list.html @@ -0,0 +1,541 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Бюджеты отделов{% endblock %} + +{% block extra_css %} + + + +{% endblock %} + +{% block content %} + + {# ===== ШАПКА СТРАНИЦЫ ===== #} +
+
+

Бюджеты отделов

+
+
+ + + + Добавить бюджет + +
+
+ + {# ===== ФИЛЬТРЫ ===== #} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + {# ===== СВОДКА ===== #} +
+
+
+
+
+
+
+
Всего выделено
+
+
+
+
+
+
+
+
+
+
Потрачено
+
+
+
+
+
+
+
+
+
+
Остаток
+
+
+
+
+
+
+
+
+
+
Бюджетов
+
+
+
+
+ + {# ===== ТАБЛИЦА БЮДЖЕТОВ - Десктоп ===== #} +
+
+
+
Отдел
+
Период
+
Выделено
+
Потрачено
+
Остаток
+
Использование
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {# ===== МОБИЛЬНАЯ ВЕРСИЯ ===== #} +
+ +
+
+
+
+
+
+
+
+ +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/templates/procurement/equipment_list.html b/backend/templates/procurement/equipment_list.html index 86e7e85f..11d9dc24 100644 --- a/backend/templates/procurement/equipment_list.html +++ b/backend/templates/procurement/equipment_list.html @@ -13,240 +13,157 @@ {# ===== ШАПКА СТРАНИЦЫ ===== #} {% include "includes/components/page_header.html" with icon="bi-laptop" title="Оборудование" search_id="eqSearch" search_placeholder="Поиск по названию или инв. номеру..." has_filters=True filters_target="eqFilters" filters_template="procurement/_equipment_filters.html" create_as_card=True create_modal="equipmentCreateModal" create_text="Добавить оборудование" %} - {# ===== КНОПКА УПРАВЛЕНИЯ КАТЕГОРИЯМИ ===== #} - {% if can_manage %} -
- -
- {% endif %} - - {# ===== СПИСОК ОБОРУДОВАНИЯ ===== #} -
-
-
-
Оборудование
-
Кол-во
-
Категория
-
Отдел
-
Статус
-
-
-
- -
- {% if equipment_groups %} - {% for group in equipment_groups %} - {# Основная строка группы #} -
+ {# ===== СЕТКА ОБОРУДОВАНИЯ ===== #} +
+ {% if equipment_list %} + {% for item in equipment_list %} +
+
+
+ {% if item.category.name == 'Компьютеры' or 'ноутбук' in item.name|lower %} + + {% elif 'монитор' in item.name|lower %} + + {% elif 'принтер' in item.name|lower %} + + {% elif 'телефон' in item.name|lower %} + + {% elif 'сервер' in item.name|lower %} + + {% else %} + + {% endif %} +
- {% if group.is_group %} -
+ + +
+
+ {% if item.category %} +
+ + {{ item.category.name }}
-
-
- {% if group.is_group %} - {{ group.main.name }} - {% else %} - {{ group.main.name }} - {% endif %} -
-
- {% if group.is_group %} - {{ group.count }} единиц - {% else %} - {{ group.main.inventory_number }} - {% endif %} -
+ {% endif %} + + {% if item.department %} +
+ + {{ item.department.name }}
-
- -
- {{ group.count }} -
- -
- {% if group.main.category %} - {{ group.main.category.name }} - {% else %} - — {% endif %} -
- -
- {% if group.main.department %} - {{ group.main.department.name }} - {% else %} - — + + {% if item.responsible_person %} +
+ + {{ item.responsible_person.get_full_name }} +
{% endif %} -
- -
- {% include "procurement/_equipment_status.html" with status=group.main.status %} -
- -
- {% if not group.is_group %} -
- {# Развёрнутый список единиц группы #} - {% if group.is_group %} -
- {% for item in group.items %} -
-
-
- -
- {% if item.responsible_person %}{{ item.responsible_person.get_full_name }}{% else %}Не назначен{% endif %} -
-
-
- -
-
-
-
- -
- -
+ - {% endif %} - {% endfor %} - {% else %} +
+ {% endfor %} + {% else %} +
-

Оборудование не найдено

- - Добавить оборудование - +

Оборудование не найдено

- {% endif %} -
- - {# Пагинация #} - {% if page_obj %} - +
{% endif %}
+ + {# Пагинация #} + {% if page_obj %} +
+ +
+ {% endif %} {# ===== МОДАЛКА СОЗДАНИЯ ОБОРУДОВАНИЯ ===== #}