diff --git a/myproject/inventory/forms.py b/myproject/inventory/forms.py
index ff98a96..314b148 100644
--- a/myproject/inventory/forms.py
+++ b/myproject/inventory/forms.py
@@ -5,7 +5,7 @@ from decimal import Decimal
from .models import (
Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch,
- TransferBatch, TransferItem
+ TransferBatch, TransferItem, Showcase
)
from products.models import Product
@@ -460,3 +460,4 @@ class TransferBulkForm(forms.Form):
raise ValidationError('Склад-источник и склад-назначение должны быть разными')
return cleaned_data
+
diff --git a/myproject/inventory/forms_showcase.py b/myproject/inventory/forms_showcase.py
new file mode 100644
index 0000000..d1f0bcf
--- /dev/null
+++ b/myproject/inventory/forms_showcase.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+from django import forms
+from django.core.exceptions import ValidationError
+
+from .models import Showcase, Warehouse
+
+
+class ShowcaseForm(forms.ModelForm):
+ """
+ Форма для создания и редактирования витрин.
+ Витрина привязывается к складу и используется для выкладки готовых букетов.
+ """
+ class Meta:
+ model = Showcase
+ fields = ['name', 'warehouse', 'description', 'is_active']
+ widgets = {
+ 'name': forms.TextInput(attrs={
+ 'class': 'form-control',
+ 'placeholder': 'Название витрины (например: Витрина №1, Витрина у входа)'
+ }),
+ 'warehouse': forms.Select(attrs={'class': 'form-control'}),
+ 'description': forms.Textarea(attrs={
+ 'class': 'form-control',
+ 'rows': 3,
+ 'placeholder': 'Описание витрины, её расположение или особенности'
+ }),
+ 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
+ }
+ labels = {
+ 'name': 'Название витрины',
+ 'warehouse': 'Склад',
+ 'description': 'Описание',
+ 'is_active': 'Активна',
+ }
+ help_texts = {
+ 'warehouse': 'Склад, к которому привязана витрина',
+ 'is_active': 'Неактивные витрины скрыты из списка выбора',
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # Фильтруем только активные склады
+ self.fields['warehouse'].queryset = Warehouse.objects.filter(is_active=True).order_by('name')
+
+ # Если создаём новую витрину и есть склад по умолчанию - предвыбираем его
+ if not self.instance.pk and not self.initial.get('warehouse'):
+ default_warehouse = Warehouse.objects.filter(
+ is_active=True,
+ is_default=True
+ ).first()
+ if default_warehouse:
+ self.initial['warehouse'] = default_warehouse.id
+
+ def clean_name(self):
+ """Проверка уникальности названия витрины в рамках склада"""
+ name = self.cleaned_data.get('name')
+ warehouse = self.cleaned_data.get('warehouse')
+
+ if name and warehouse:
+ # Проверяем уникальность названия в рамках склада
+ queryset = Showcase.objects.filter(name=name, warehouse=warehouse)
+
+ # При редактировании исключаем текущий экземпляр
+ if self.instance and self.instance.pk:
+ queryset = queryset.exclude(pk=self.instance.pk)
+
+ if queryset.exists():
+ raise ValidationError(
+ f'Витрина с названием "{name}" уже существует на складе "{warehouse.name}". '
+ 'Пожалуйста, выберите другое название.'
+ )
+
+ return name
diff --git a/myproject/inventory/templates/inventory/showcase/delete.html b/myproject/inventory/templates/inventory/showcase/delete.html
new file mode 100644
index 0000000..6efb8d9
--- /dev/null
+++ b/myproject/inventory/templates/inventory/showcase/delete.html
@@ -0,0 +1,135 @@
+{% extends "base.html" %}
+
+{% block title %}{{ title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ Удаление витрины
+
+
+
+
+
+
+
+
+
+
+
+
+ Вы действительно хотите удалить витрину "{{ object.name }}"
+ на складе "{{ object.warehouse.name }}"?
+
+
+ {% if has_active_reservations %}
+
+
+ Невозможно удалить витрину
+
+
+ На этой витрине есть {{ active_reservations.count }} активных резервов.
+ Сначала необходимо освободить или продать зарезервированные товары.
+
+
+ {% if active_reservations %}
+
+
Активные резервы:
+
+ {% for reservation in active_reservations|slice:":5" %}
+ - {{ reservation.product.name }} — {{ reservation.quantity }} шт.
+ {% endfor %}
+ {% if active_reservations.count > 5 %}
+ - ...и еще {{ active_reservations.count|add:"-5" }} резервов
+ {% endif %}
+
+ {% endif %}
+
+
+
+ {% else %}
+
+
+ Внимание: Это действие нельзя отменить. Все данные о витрине будут удалены безвозвратно.
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+ - Название:
+ - {{ object.name }}
+
+ - Склад:
+ - {{ object.warehouse.name }}
+
+ - Статус:
+ -
+ {% if object.is_active %}
+ Активна
+ {% else %}
+ Неактивна
+ {% endif %}
+
+
+ - Создана:
+ - {{ object.created_at|date:"d.m.Y H:i" }}
+
+ - Обновлена:
+ - {{ object.updated_at|date:"d.m.Y H:i" }}
+
+ - Активные резервы:
+ -
+ {% if has_active_reservations %}
+ {{ active_reservations.count }}
+ {% else %}
+ —
+ {% endif %}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/myproject/inventory/templates/inventory/showcase/form.html b/myproject/inventory/templates/inventory/showcase/form.html
new file mode 100644
index 0000000..2c9d8bd
--- /dev/null
+++ b/myproject/inventory/templates/inventory/showcase/form.html
@@ -0,0 +1,151 @@
+{% extends "base.html" %}
+
+{% block title %}{{ title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {{ form_title }}
+
+
+
+
+
+
+
+
+
+
+ {% if object %}
+
+
+
+
+
+ - Создана:
+ - {{ object.created_at|date:"d.m.Y H:i" }}
+
+ - Обновлена:
+ - {{ object.updated_at|date:"d.m.Y H:i" }}
+
+ {% if active_reservations_count is not None %}
+ - Активные резервы:
+ -
+ {% if active_reservations_count > 0 %}
+ {{ active_reservations_count }}
+ {% else %}
+ —
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/myproject/inventory/templates/inventory/showcase/list.html b/myproject/inventory/templates/inventory/showcase/list.html
new file mode 100644
index 0000000..af93d84
--- /dev/null
+++ b/myproject/inventory/templates/inventory/showcase/list.html
@@ -0,0 +1,162 @@
+{% extends "base.html" %}
+
+{% block title %}{{ title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ Витрины
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if showcases %}
+
+
+
+
+
+
+ | Название |
+ Склад |
+ Описание |
+ Активные резервы |
+ Статус |
+ Действия |
+
+
+
+ {% regroup showcases by warehouse as showcases_by_warehouse %}
+ {% for warehouse_group in showcases_by_warehouse %}
+
+
+ |
+ {{ warehouse_group.grouper.name }}
+ |
+
+
+ {% for showcase in warehouse_group.list %}
+
+ |
+ {{ showcase.name }}
+ |
+
+ {{ showcase.warehouse.name }}
+ |
+
+ {% if showcase.description %}
+ {{ showcase.description|truncatewords:10 }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
+ {% if showcase.active_reservations_count > 0 %}
+ {{ showcase.active_reservations_count }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
+ {% if showcase.is_active %}
+ Активна
+ {% else %}
+ Неактивна
+ {% endif %}
+ |
+
+
+
+
+
+
+
+ |
+
+ {% endfor %}
+ {% endfor %}
+
+
+
+
+
+ {% else %}
+
+
+
+
Витрин не найдено
+
Создайте первую витрину для выкладки букетов
+
+ Создать витрину
+
+
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/myproject/inventory/urls.py b/myproject/inventory/urls.py
index 0b57ee2..750bf04 100644
--- a/myproject/inventory/urls.py
+++ b/myproject/inventory/urls.py
@@ -26,6 +26,8 @@ from .views import (
# StockMovement
StockMovementListView,
)
+# Showcase views
+from .views.showcase import ShowcaseListView, ShowcaseCreateView, ShowcaseUpdateView, ShowcaseDeleteView
from . import views
app_name = 'inventory'
@@ -95,4 +97,10 @@ urlpatterns = [
# ==================== MOVEMENT (READ ONLY) ====================
path('movements/', StockMovementListView.as_view(), name='movement-list'),
+
+ # ==================== SHOWCASE ====================
+ path('showcases/', ShowcaseListView.as_view(), name='showcase-list'),
+ path('showcases/create/', ShowcaseCreateView.as_view(), name='showcase-create'),
+ path('showcases//edit/', ShowcaseUpdateView.as_view(), name='showcase-update'),
+ path('showcases//delete/', ShowcaseDeleteView.as_view(), name='showcase-delete'),
]
diff --git a/myproject/inventory/views/showcase.py b/myproject/inventory/views/showcase.py
new file mode 100644
index 0000000..2d1da6a
--- /dev/null
+++ b/myproject/inventory/views/showcase.py
@@ -0,0 +1,175 @@
+# -*- coding: utf-8 -*-
+from django.contrib.auth.decorators import login_required
+from django.contrib import messages
+from django.shortcuts import render, redirect, get_object_or_404
+from django.db.models import Count, Q
+from django.views.generic import ListView, CreateView, UpdateView, DeleteView
+from django.urls import reverse_lazy
+from django.utils.decorators import method_decorator
+
+from inventory.models import Showcase, Reservation
+from inventory.forms_showcase import ShowcaseForm
+
+
+@method_decorator(login_required, name='dispatch')
+class ShowcaseListView(ListView):
+ """
+ Список всех витрин с группировкой по складам.
+ Отображает информацию о количестве активных резервов на каждой витрине.
+ """
+ model = Showcase
+ template_name = 'inventory/showcase/list.html'
+ context_object_name = 'showcases'
+
+ def get_queryset(self):
+ """
+ Получаем витрины с аннотацией количества активных резервов.
+ Сортируем по складу и названию.
+ """
+ queryset = Showcase.objects.select_related('warehouse').annotate(
+ active_reservations_count=Count(
+ 'reservations',
+ filter=Q(reservations__status='reserved')
+ )
+ ).order_by('warehouse__name', 'name')
+
+ # Фильтрация по складу, если указан GET-параметр
+ warehouse_id = self.request.GET.get('warehouse')
+ if warehouse_id:
+ queryset = queryset.filter(warehouse_id=warehouse_id)
+
+ # Фильтрация по статусу активности
+ is_active = self.request.GET.get('is_active')
+ if is_active == '1':
+ queryset = queryset.filter(is_active=True)
+ elif is_active == '0':
+ queryset = queryset.filter(is_active=False)
+
+ return queryset
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['title'] = 'Витрины'
+
+ # Добавляем информацию для фильтров
+ from inventory.models import Warehouse
+ context['warehouses'] = Warehouse.objects.filter(is_active=True).order_by('name')
+
+ return context
+
+
+@method_decorator(login_required, name='dispatch')
+class ShowcaseCreateView(CreateView):
+ """
+ Создание новой витрины.
+ """
+ model = Showcase
+ form_class = ShowcaseForm
+ template_name = 'inventory/showcase/form.html'
+ success_url = reverse_lazy('inventory:showcase-list')
+
+ def form_valid(self, form):
+ """Сохраняем витрину и показываем сообщение об успехе"""
+ response = super().form_valid(form)
+ messages.success(
+ self.request,
+ f'Витрина "{self.object.name}" успешно создана на складе "{self.object.warehouse.name}"'
+ )
+ return response
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['title'] = 'Создание витрины'
+ context['form_title'] = 'Новая витрина'
+ context['submit_text'] = 'Создать витрину'
+ return context
+
+
+@method_decorator(login_required, name='dispatch')
+class ShowcaseUpdateView(UpdateView):
+ """
+ Редактирование существующей витрины.
+ """
+ model = Showcase
+ form_class = ShowcaseForm
+ template_name = 'inventory/showcase/form.html'
+ success_url = reverse_lazy('inventory:showcase-list')
+
+ def form_valid(self, form):
+ """Сохраняем изменения и показываем сообщение об успехе"""
+ response = super().form_valid(form)
+ messages.success(
+ self.request,
+ f'Витрина "{self.object.name}" успешно обновлена'
+ )
+ return response
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['title'] = f'Редактирование витрины: {self.object.name}'
+ context['form_title'] = f'Редактирование: {self.object.name}'
+ context['submit_text'] = 'Сохранить изменения'
+
+ # Добавляем информацию о резервах на витрине
+ context['active_reservations_count'] = Reservation.objects.filter(
+ showcase=self.object,
+ status='reserved'
+ ).count()
+
+ return context
+
+
+@method_decorator(login_required, name='dispatch')
+class ShowcaseDeleteView(DeleteView):
+ """
+ Удаление витрины с подтверждением.
+ Проверяет наличие активных резервов перед удалением.
+ """
+ model = Showcase
+ template_name = 'inventory/showcase/delete.html'
+ success_url = reverse_lazy('inventory:showcase-list')
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['title'] = f'Удаление витрины: {self.object.name}'
+
+ # Проверяем наличие активных резервов
+ active_reservations = Reservation.objects.filter(
+ showcase=self.object,
+ status='reserved'
+ ).select_related('product')
+
+ context['active_reservations'] = active_reservations
+ context['has_active_reservations'] = active_reservations.exists()
+
+ return context
+
+ def delete(self, request, *args, **kwargs):
+ """Проверяем наличие активных резервов перед удалением"""
+ showcase = self.get_object()
+
+ # Проверка активных резервов
+ active_reservations_count = Reservation.objects.filter(
+ showcase=showcase,
+ status='reserved'
+ ).count()
+
+ if active_reservations_count > 0:
+ messages.error(
+ request,
+ f'Невозможно удалить витрину "{showcase.name}": '
+ f'на ней есть {active_reservations_count} активных резервов. '
+ 'Сначала освободите или продайте зарезервированные товары.'
+ )
+ return redirect('inventory:showcase-delete', pk=showcase.pk)
+
+ # Удаляем витрину
+ showcase_name = showcase.name
+ response = super().delete(request, *args, **kwargs)
+
+ messages.success(
+ request,
+ f'Витрина "{showcase_name}" успешно удалена'
+ )
+
+ return response
diff --git a/myproject/templates/navbar.html b/myproject/templates/navbar.html
index fe2f1ee..35eb179 100644
--- a/myproject/templates/navbar.html
+++ b/myproject/templates/navbar.html
@@ -45,7 +45,10 @@
Касса
- Склад
+ Витрины
+
+
+ Склад
{% endif %}