Initial commit: Django inventory system

This commit is contained in:
2025-10-22 01:11:06 +03:00
commit d78c43d9a9
93 changed files with 9204 additions and 0 deletions

View File

310
myproject/products/admin.py Normal file
View File

@@ -0,0 +1,310 @@
from django.contrib import admin
from django.utils.html import format_html
import nested_admin
from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem
from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
from .models import ProductVariantGroup, KitItemPriority, SKUCounter
@admin.register(ProductVariantGroup)
class ProductVariantGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'get_products_count', 'created_at']
search_fields = ['name', 'description']
list_filter = ['created_at']
readonly_fields = ['created_at', 'updated_at']
def get_products_count(self, obj):
return obj.products.count()
get_products_count.short_description = 'Товаров'
class ProductCategoryAdmin(admin.ModelAdmin):
list_display = ('photo_preview', 'name', 'sku', 'slug', 'parent', 'is_active')
list_filter = ('is_active', 'parent')
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name', 'sku')
readonly_fields = ('photo_preview_large',)
def photo_preview(self, obj):
"""Превью фото в списке категорий"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview.short_description = "Фото"
def photo_preview_large(self, obj):
"""Большое превью фото в форме редактирования"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview_large.short_description = "Превью основного фото"
class ProductTagAdmin(admin.ModelAdmin):
list_display = ('name', 'slug')
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name',)
class ProductAdmin(admin.ModelAdmin):
list_display = ('photo_preview', 'name', 'sku', 'get_categories_display', 'cost_price', 'sale_price', 'get_variant_groups_display', 'is_active')
list_filter = ('is_active', 'categories', 'tags', 'variant_groups')
search_fields = ('name', 'sku', 'description', 'search_keywords')
filter_horizontal = ('categories', 'tags', 'variant_groups')
readonly_fields = ('photo_preview_large',)
autocomplete_fields = []
fieldsets = (
('Основная информация', {
'fields': ('name', 'sku', 'variant_suffix', 'description', 'categories', 'unit')
}),
('Цены', {
'fields': ('cost_price', 'sale_price')
}),
('Дополнительно', {
'fields': ('tags', 'variant_groups', 'is_active')
}),
('Поиск', {
'fields': ('search_keywords',),
'classes': ('collapse',),
'description': 'Поле для улучшенного поиска. Автоматически генерируется при сохранении, но вы можете добавить дополнительные ключевые слова (синонимы, альтернативные названия и т.д.).'
}),
('Фото', {
'fields': ('photo_preview_large',),
'classes': ('collapse',),
}),
)
def get_categories_display(self, obj):
categories = obj.categories.all()[:3]
if not categories:
return "-"
result = ", ".join([cat.name for cat in categories])
if obj.categories.count() > 3:
result += f" (+{obj.categories.count() - 3})"
return result
get_categories_display.short_description = 'Категории'
def get_variant_groups_display(self, obj):
groups = obj.variant_groups.all()[:3]
if not groups:
return "-"
result = ", ".join([g.name for g in groups])
if obj.variant_groups.count() > 3:
result += f" (+{obj.variant_groups.count() - 3})"
return result
get_variant_groups_display.short_description = 'Группы вариантов'
def photo_preview(self, obj):
"""Превью фото в списке товаров"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview.short_description = "Фото"
def photo_preview_large(self, obj):
"""Большое превью фото в форме редактирования"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview_large.short_description = "Превью основного фото"
class ProductKitAdmin(admin.ModelAdmin):
list_display = ('photo_preview', 'name', 'slug', 'get_categories_display', 'pricing_method', 'is_active')
list_filter = ('is_active', 'pricing_method', 'categories', 'tags')
prepopulated_fields = {'slug': ('name',)}
filter_horizontal = ('categories', 'tags')
readonly_fields = ('photo_preview_large',)
def get_categories_display(self, obj):
categories = obj.categories.all()[:3]
if not categories:
return "-"
result = ", ".join([cat.name for cat in categories])
if obj.categories.count() > 3:
result += f" (+{obj.categories.count() - 3})"
return result
get_categories_display.short_description = 'Категории'
def photo_preview(self, obj):
"""Превью фото в списке комплектов"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview.short_description = "Фото"
def photo_preview_large(self, obj):
"""Большое превью фото в форме редактирования"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview_large.short_description = "Превью основного фото"
class KitItemPriorityInline(nested_admin.NestedTabularInline):
model = KitItemPriority
extra = 0 # Не показывать пустые формы
fields = ['product', 'priority']
autocomplete_fields = ['product']
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('product')
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Показывать только товары из выбранной группы вариантов"""
if db_field.name == "product":
# Получаем kit_item из родительского объекта через request
# Это будет работать автоматически с nested_admin
pass
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class KitItemInline(nested_admin.NestedStackedInline):
model = KitItem
extra = 0 # Не показывать пустые формы
fields = ['product', 'variant_group', 'quantity', 'notes']
autocomplete_fields = ['product']
inlines = [KitItemPriorityInline]
class Media:
css = {
'all': ('admin/css/custom_nested.css',)
}
class ProductPhotoInline(admin.TabularInline):
model = ProductPhoto
extra = 1
readonly_fields = ('image_preview',)
fields = ('image', 'image_preview', 'order')
def image_preview(self, obj):
"""Превью загруженного фото"""
if obj.image:
return format_html(
'<img src="{}" style="max-width: 200px; max-height: 200px; border-radius: 4px;" />',
obj.image.url
)
return "Нет изображения"
image_preview.short_description = "Превью"
class ProductKitPhotoInline(nested_admin.NestedTabularInline):
model = ProductKitPhoto
extra = 0 # Не показывать пустые формы
readonly_fields = ('image_preview',)
fields = ('image', 'image_preview', 'order')
def image_preview(self, obj):
"""Превью загруженного фото"""
if obj.image:
return format_html(
'<img src="{}" style="max-width: 200px; max-height: 200px; border-radius: 4px;" />',
obj.image.url
)
return "Нет изображения"
image_preview.short_description = "Превью"
class ProductCategoryPhotoInline(admin.TabularInline):
model = ProductCategoryPhoto
extra = 1
readonly_fields = ('image_preview',)
fields = ('image', 'image_preview', 'order')
def image_preview(self, obj):
"""Превью загруженного фото"""
if obj.image:
return format_html(
'<img src="{}" style="max-width: 200px; max-height: 200px; border-radius: 4px;" />',
obj.image.url
)
return "Нет изображения"
image_preview.short_description = "Превью"
class ProductKitAdminWithItems(ProductKitAdmin):
inlines = [KitItemInline]
# Update admin classes to include photo inlines
class ProductAdminWithPhotos(ProductAdmin):
inlines = [ProductPhotoInline]
class ProductKitAdminWithItemsAndPhotos(nested_admin.NestedModelAdmin, ProductKitAdmin):
inlines = [KitItemInline, ProductKitPhotoInline]
class ProductCategoryAdminWithPhotos(ProductCategoryAdmin):
inlines = [ProductCategoryPhotoInline]
@admin.register(KitItem)
class KitItemAdmin(admin.ModelAdmin):
list_display = ['__str__', 'kit', 'get_type', 'quantity', 'has_priorities']
list_filter = ['kit']
list_select_related = ['kit', 'product', 'variant_group']
inlines = [KitItemPriorityInline]
fields = ['kit', 'product', 'variant_group', 'quantity', 'notes']
def get_type(self, obj):
if obj.variant_group:
return format_html('<span style="color: #0066cc;">Группа: {}</span>', obj.variant_group.name)
return f"Товар: {obj.product.name if obj.product else '-'}"
get_type.short_description = 'Тип'
def has_priorities(self, obj):
return obj.priorities.exists()
has_priorities.boolean = True
has_priorities.short_description = 'Приоритеты настроены'
@admin.register(SKUCounter)
class SKUCounterAdmin(admin.ModelAdmin):
list_display = ['counter_type', 'current_value', 'get_next_preview']
list_filter = ['counter_type']
readonly_fields = ['get_next_preview']
def get_next_preview(self, obj):
"""Показывает, каким будет следующий артикул"""
next_val = obj.current_value + 1
if obj.counter_type == 'product':
return format_html('<strong>PROD-{:06d}</strong>', next_val)
elif obj.counter_type == 'kit':
return format_html('<strong>KIT-{:06d}</strong>', next_val)
elif obj.counter_type == 'category':
return format_html('<strong>CAT-{:04d}</strong>', next_val)
return str(next_val)
get_next_preview.short_description = 'Следующий артикул'
def has_delete_permission(self, request, obj=None):
# Запрещаем удаление счетчиков
return False
admin.site.register(ProductCategory, ProductCategoryAdminWithPhotos)
admin.site.register(ProductTag, ProductTagAdmin)
admin.site.register(Product, ProductAdminWithPhotos)
admin.site.register(ProductKit, ProductKitAdminWithItemsAndPhotos)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ProductsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'products'

259
myproject/products/forms.py Normal file
View File

@@ -0,0 +1,259 @@
from django import forms
from django.forms import inlineformset_factory
from .models import Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, ProductKitPhoto, ProductCategoryPhoto
class ProductForm(forms.ModelForm):
"""
Форма для создания и редактирования товара.
Поле photos НЕ включено в форму - файлы обрабатываются отдельно в view.
"""
categories = forms.ModelMultipleChoiceField(
queryset=ProductCategory.objects.filter(is_active=True),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Категории"
)
tags = forms.ModelMultipleChoiceField(
queryset=ProductTag.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Теги"
)
class Meta:
model = Product
fields = [
'name', 'sku', 'description', 'categories',
'tags', 'unit', 'cost_price', 'sale_price', 'is_active'
]
labels = {
'name': 'Название',
'sku': 'Артикул',
'description': 'Описание',
'categories': 'Категории',
'tags': 'Теги',
'unit': 'Единица измерения',
'cost_price': 'Себестоимость',
'sale_price': 'Цена продажи',
'is_active': 'Активен'
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make fields more user-friendly
self.fields['name'].widget.attrs.update({
'class': 'form-control form-control-lg fw-semibold',
'placeholder': 'Введите название товара'
})
self.fields['sku'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'Артикул (необязательно, будет сгенерирован автоматически)'
})
self.fields['description'].widget.attrs.update({
'class': 'form-control',
'rows': 3
})
self.fields['cost_price'].widget.attrs.update({'class': 'form-control'})
self.fields['sale_price'].widget.attrs.update({'class': 'form-control'})
self.fields['unit'].widget.attrs.update({'class': 'form-control'})
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
class ProductKitForm(forms.ModelForm):
"""
Форма для создания и редактирования комплекта.
"""
categories = forms.ModelMultipleChoiceField(
queryset=ProductCategory.objects.filter(is_active=True),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Категории"
)
tags = forms.ModelMultipleChoiceField(
queryset=ProductTag.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Теги"
)
class Meta:
model = ProductKit
fields = [
'name', 'description', 'categories',
'tags', 'pricing_method', 'fixed_price', 'markup_percent', 'markup_amount', 'is_active'
]
labels = {
'name': 'Название',
'description': 'Описание',
'categories': 'Категории',
'tags': 'Теги',
'pricing_method': 'Метод ценообразования',
'fixed_price': 'Фиксированная цена',
'markup_percent': 'Процент наценки',
'markup_amount': 'Фиксированная наценка',
'is_active': 'Активен'
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make fields more user-friendly
self.fields['name'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'Введите название комплекта'
})
self.fields['description'].widget.attrs.update({
'class': 'form-control',
'rows': 3
})
self.fields['pricing_method'].widget.attrs.update({'class': 'form-control'})
self.fields['fixed_price'].widget.attrs.update({'class': 'form-control'})
self.fields['markup_percent'].widget.attrs.update({'class': 'form-control'})
self.fields['markup_amount'].widget.attrs.update({'class': 'form-control'})
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
class KitItemForm(forms.ModelForm):
"""
Форма для одного компонента комплекта.
Валидирует, что указан либо product, либо variant_group (но не оба).
Если обе поля пусты - это пустая форма, которая будет удалена.
"""
class Meta:
model = KitItem
fields = ['product', 'variant_group', 'quantity', 'notes']
labels = {
'product': 'Конкретный товар',
'variant_group': 'Группа вариантов',
'quantity': 'Количество',
'notes': 'Примечание'
}
widgets = {
'product': forms.Select(attrs={'class': 'form-control'}),
'variant_group': forms.Select(attrs={'class': 'form-control'}),
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0'}),
'notes': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Опциональное примечание'}),
}
def clean(self):
cleaned_data = super().clean()
product = cleaned_data.get('product')
variant_group = cleaned_data.get('variant_group')
# Если оба поля пусты - это пустая форма (не валидируем, она будет удалена)
if not product and not variant_group:
return cleaned_data
# Валидация: должен быть указан либо product, либо variant_group (но не оба)
if product and variant_group:
raise forms.ValidationError(
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
)
return cleaned_data
# Формсет для создания комплектов (с пустой формой для удобства)
KitItemFormSetCreate = inlineformset_factory(
ProductKit,
KitItem,
form=KitItemForm,
fields=['id', 'product', 'variant_group', 'quantity', 'notes'],
extra=1, # Показать 1 пустую форму для первого компонента
can_delete=True, # Разрешить удаление компонентов
min_num=0, # Минимум 0 компонентов (можно создать пустой комплект)
validate_min=False, # Не требовать минимум компонентов
can_delete_extra=True, # Разрешить удалять дополнительные формы
)
# Формсет для редактирования комплектов (без пустых форм, только существующие компоненты)
KitItemFormSetUpdate = inlineformset_factory(
ProductKit,
KitItem,
form=KitItemForm,
fields=['id', 'product', 'variant_group', 'quantity', 'notes'],
extra=0, # НЕ показывать пустые формы при редактировании
can_delete=True, # Разрешить удаление компонентов
min_num=0, # Минимум 0 компонентов
validate_min=False, # Не требовать минимум компонентов
can_delete_extra=True, # Разрешить удалять дополнительные формы
)
# Для обратной совместимости (если где-то еще используется KitItemFormSet)
KitItemFormSet = KitItemFormSetCreate
class ProductCategoryForm(forms.ModelForm):
"""
Форма для создания и редактирования категории товаров.
Поле photos НЕ включено в форму - файлы обрабатываются отдельно в view.
"""
parent = forms.ModelChoiceField(
queryset=ProductCategory.objects.filter(is_active=True),
required=False,
empty_label="Нет (корневая категория)",
label="Родительская категория",
widget=forms.Select(attrs={'class': 'form-control'})
)
class Meta:
model = ProductCategory
fields = ['name', 'sku', 'slug', 'parent', 'is_active']
labels = {
'name': 'Название',
'sku': 'Артикул',
'slug': 'URL-идентификатор',
'parent': 'Родительская категория',
'is_active': 'Активна'
}
help_texts = {
'sku': 'Оставьте пустым для автоматической генерации (CAT-XXXX)',
'slug': 'Оставьте пустым для автоматической генерации из названия',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make fields more user-friendly
self.fields['name'].widget.attrs.update({
'class': 'form-control form-control-lg fw-semibold',
'placeholder': 'Введите название категории'
})
self.fields['sku'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'CAT-XXXX (автоматически)'
})
self.fields['slug'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'url-identifier (автоматически)'
})
self.fields['slug'].required = False # Делаем поле необязательным
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
# Исключаем текущую категорию и её потомков из списка родительских
# (чтобы не создать циклическую зависимость)
if self.instance and self.instance.pk:
# Получаем все потомки текущей категории
descendants = self._get_descendants(self.instance)
# Исключаем текущую категорию и все её потомки
exclude_ids = [self.instance.pk] + [cat.pk for cat in descendants]
self.fields['parent'].queryset = ProductCategory.objects.filter(
is_active=True
).exclude(pk__in=exclude_ids)
def clean_slug(self):
"""Преобразуем пустую строку в None для автогенерации slug"""
slug = self.cleaned_data.get('slug')
if slug == '' or slug is None:
return None
return slug
def _get_descendants(self, category):
"""Рекурсивно получает всех потомков категории"""
descendants = []
children = category.children.all()
for child in children:
descendants.append(child)
descendants.extend(self._get_descendants(child))
return descendants

View File

@@ -0,0 +1,245 @@
"""
Management команда для демонстрации работы системы вариантов товаров.
Использование:
python manage.py demo_variants
"""
from decimal import Decimal
from django.core.management.base import BaseCommand
from products.models import (
Product, ProductKit, KitItem, ProductCategory,
ProductVariantGroup, KitItemPriority
)
from products.utils.stock_manager import StockManager
class Command(BaseCommand):
help = 'Демонстрация работы системы вариантов товаров'
def handle(self, *args, **options):
self.stdout.write("\n" + "="*60)
self.stdout.write(self.style.SUCCESS("ДЕМОНСТРАЦИЯ СИСТЕМЫ ВАРИАНТОВ ТОВАРОВ"))
self.stdout.write("="*60 + "\n")
# Создаём демо-данные
group, rose_50, rose_60, rose_70 = self.create_variant_group()
# Создаём букеты
premium_kit = self.create_premium_bouquet(group, rose_50, rose_60, rose_70)
economy_kit = self.create_economy_bouquet(group, rose_50, rose_60, rose_70)
# Проверяем доступность
self.check_availability(premium_kit, economy_kit)
# Получаем лучший товар
self.show_best_product(premium_kit)
self.stdout.write("\n" + "="*60)
self.stdout.write(self.style.SUCCESS("ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА"))
self.stdout.write("="*60 + "\n")
def create_variant_group(self):
"""Создание группы вариантов"""
self.stdout.write("\n" + "="*60)
self.stdout.write(self.style.HTTP_INFO("ШАГ 1: Создание группы вариантов"))
self.stdout.write("="*60)
# Создаём категорию
category, _ = ProductCategory.objects.get_or_create(
name="Цветы",
defaults={'slug': 'cvety'}
)
# Создаём товары - розы разной длины
rose_50, _ = Product.objects.get_or_create(
name="Роза Freedom 50см красная",
defaults={
'cost_price': Decimal('80.00'),
'sale_price': Decimal('100.00'),
'category': category
}
)
rose_60, _ = Product.objects.get_or_create(
name="Роза Freedom 60см красная",
defaults={
'cost_price': Decimal('120.00'),
'sale_price': Decimal('150.00'),
'category': category
}
)
rose_70, _ = Product.objects.get_or_create(
name="Роза Freedom 70см красная",
defaults={
'cost_price': Decimal('160.00'),
'sale_price': Decimal('200.00'),
'category': category
}
)
# Создаём группу вариантов
group, created = ProductVariantGroup.objects.get_or_create(
name="Роза красная Freedom",
defaults={
'description': 'Красная роза Freedom различной длины (50-70см)'
}
)
# Добавляем товары в группу
rose_50.variant_groups.add(group)
rose_60.variant_groups.add(group)
rose_70.variant_groups.add(group)
self.stdout.write(self.style.SUCCESS(f"[OK] Создана группа: {group.name}"))
self.stdout.write(f" Товаров в группе: {group.get_products_count()}")
self.stdout.write(" Товары:")
for product in group.products.all():
self.stdout.write(f" - {product.name} ({product.sale_price} руб.)")
return group, rose_50, rose_60, rose_70
def create_premium_bouquet(self, group, rose_50, rose_60, rose_70):
"""Создание премиум букета"""
self.stdout.write("\n" + "="*60)
self.stdout.write(self.style.HTTP_INFO("ШАГ 2: Создание премиум букета"))
self.stdout.write("="*60)
# Создаём букет
kit, _ = ProductKit.objects.get_or_create(
name="Ранчо Виталия Премиум",
defaults={
'slug': 'rancho-vitaliya-premium',
'pricing_method': 'from_sale_prices'
}
)
# Создаём позицию с группой вариантов
kit_item, _ = KitItem.objects.get_or_create(
kit=kit,
variant_group=group,
defaults={
'quantity': Decimal('15.000'),
'notes': 'Использовать самые длинные розы'
}
)
# Настраиваем приоритеты (для премиум букета - сначала длинные)
KitItemPriority.objects.get_or_create(
kit_item=kit_item,
product=rose_70,
defaults={'priority': 0}
)
KitItemPriority.objects.get_or_create(
kit_item=kit_item,
product=rose_60,
defaults={'priority': 1}
)
KitItemPriority.objects.get_or_create(
kit_item=kit_item,
product=rose_50,
defaults={'priority': 2}
)
self.stdout.write(self.style.SUCCESS(f"[OK] Создан букет: {kit.name}"))
self.stdout.write(f" Позиций: {kit.get_total_components_count()}")
self.stdout.write(f" С вариантами: {kit.get_components_with_variants_count()}")
self.stdout.write(f"\n Приоритеты для позиции '{kit_item.get_display_name()}':")
for priority in kit_item.priorities.all().order_by('priority'):
self.stdout.write(f" {priority.priority}. {priority.product.name} - {priority.product.sale_price} руб.")
return kit
def create_economy_bouquet(self, group, rose_50, rose_60, rose_70):
"""Создание эконом букета"""
self.stdout.write("\n" + "="*60)
self.stdout.write(self.style.HTTP_INFO("ШАГ 3: Создание эконом букета"))
self.stdout.write("="*60)
kit, _ = ProductKit.objects.get_or_create(
name="Ранчо Виталия Эконом",
defaults={
'slug': 'rancho-vitaliya-econom',
'pricing_method': 'from_sale_prices'
}
)
kit_item, _ = KitItem.objects.get_or_create(
kit=kit,
variant_group=group,
defaults={
'quantity': Decimal('15.000'),
'notes': 'Эконом вариант'
}
)
# Для эконом букета - сначала короткие (дешевые)
KitItemPriority.objects.get_or_create(
kit_item=kit_item,
product=rose_50,
defaults={'priority': 0}
)
KitItemPriority.objects.get_or_create(
kit_item=kit_item,
product=rose_60,
defaults={'priority': 1}
)
KitItemPriority.objects.get_or_create(
kit_item=kit_item,
product=rose_70,
defaults={'priority': 2}
)
self.stdout.write(self.style.SUCCESS(f"[OK] Создан букет: {kit.name}"))
self.stdout.write(f"\n Приоритеты для позиции '{kit_item.get_display_name()}':")
for priority in kit_item.priorities.all().order_by('priority'):
self.stdout.write(f" {priority.priority}. {priority.product.name} - {priority.product.sale_price} руб.")
return kit
def check_availability(self, premium_kit, economy_kit):
"""Проверка доступности"""
self.stdout.write("\n" + "="*60)
self.stdout.write(self.style.HTTP_INFO("ШАГ 4: Проверка доступности букетов"))
self.stdout.write("="*60)
stock_manager = StockManager()
# Проверяем премиум букет
self.stdout.write(f"\nПремиум букет: {premium_kit.name}")
if premium_kit.check_availability(stock_manager):
self.stdout.write(self.style.SUCCESS(" [OK] Доступен для сборки"))
price = premium_kit.calculate_price_with_substitutions(stock_manager)
self.stdout.write(f" Цена: {price} руб.")
else:
self.stdout.write(self.style.ERROR(" [ERROR] Недоступен"))
# Проверяем эконом букет
self.stdout.write(f"\nЭконом букет: {economy_kit.name}")
if economy_kit.check_availability(stock_manager):
self.stdout.write(self.style.SUCCESS(" [OK] Доступен для сборки"))
price = economy_kit.calculate_price_with_substitutions(stock_manager)
self.stdout.write(f" Цена: {price} руб.")
else:
self.stdout.write(self.style.ERROR(" [ERROR] Недоступен"))
def show_best_product(self, kit):
"""Показать лучший доступный товар"""
self.stdout.write("\n" + "="*60)
self.stdout.write(self.style.HTTP_INFO("ШАГ 5: Выбор лучшего доступного товара"))
self.stdout.write("="*60)
stock_manager = StockManager()
for kit_item in kit.kit_items.all():
self.stdout.write(f"\nПозиция: {kit_item.get_display_name()}")
self.stdout.write(f"Количество: {kit_item.quantity}")
best_product = kit_item.get_best_available_product(stock_manager)
if best_product:
self.stdout.write(self.style.SUCCESS(f"[OK] Лучший доступный товар: {best_product.name}"))
self.stdout.write(f" Цена: {best_product.sale_price} руб.")
self.stdout.write(f" Стоимость позиции: {best_product.sale_price * kit_item.quantity} руб.")
else:
self.stdout.write(self.style.ERROR("[ERROR] Нет доступных товаров"))

View File

@@ -0,0 +1,34 @@
from django.core.management.base import BaseCommand
from django.utils.text import slugify
from unidecode import unidecode
from products.models import ProductCategory
class Command(BaseCommand):
help = 'Fixes category slugs by converting Cyrillic to Latin transliteration'
def handle(self, *args, **options):
categories = ProductCategory.objects.all()
fixed_count = 0
for category in categories:
old_slug = category.slug
# Generate new slug with Latin transliteration
transliterated_name = unidecode(category.name)
new_slug = slugify(transliterated_name)
if old_slug != new_slug:
category.slug = new_slug
category.save()
self.stdout.write(
self.style.SUCCESS(
f'Fixed: "{category.name}" | {old_slug} -> {new_slug}'
)
)
fixed_count += 1
else:
self.stdout.write(f'OK: "{category.name}" | {old_slug}')
self.stdout.write(
self.style.SUCCESS(f'\nTotal fixed: {fixed_count} categories')
)

View File

@@ -0,0 +1,206 @@
# Generated by Django 5.2.7 on 2025-10-21 14:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ProductTag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True, verbose_name='Название')),
('slug', models.SlugField(max_length=100, unique=True, verbose_name='URL-идентификатор')),
],
options={
'verbose_name': 'Тег товара',
'verbose_name_plural': 'Теги товаров',
},
),
migrations.CreateModel(
name='ProductVariantGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Группа вариантов',
'verbose_name_plural': 'Группы вариантов',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='SKUCounter',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('counter_type', models.CharField(choices=[('product', 'Product Counter'), ('kit', 'Kit Counter')], max_length=20, unique=True, verbose_name='Тип счетчика')),
('current_value', models.IntegerField(default=0, verbose_name='Текущее значение')),
],
options={
'verbose_name': 'Счетчик артикулов',
'verbose_name_plural': 'Счетчики артикулов',
},
),
migrations.CreateModel(
name='ProductCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL-идентификатор')),
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='products.productcategory', verbose_name='Родительская категория')),
],
options={
'verbose_name': 'Категория товара',
'verbose_name_plural': 'Категории товаров',
},
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')),
('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')),
('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Себестоимость')),
('sale_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Розничная цена')),
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('search_keywords', models.TextField(blank=True, help_text='Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную.', verbose_name='Ключевые слова для поиска')),
('categories', models.ManyToManyField(blank=True, related_name='products', to='products.productcategory', verbose_name='Категории')),
('tags', models.ManyToManyField(blank=True, related_name='products', to='products.producttag', verbose_name='Теги')),
('variant_groups', models.ManyToManyField(blank=True, related_name='products', to='products.productvariantgroup', verbose_name='Группы вариантов')),
],
options={
'verbose_name': 'Товар',
'verbose_name_plural': 'Товары',
},
),
migrations.CreateModel(
name='ProductCategoryPhoto',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='categories/', verbose_name='Фото')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productcategory', verbose_name='Категория')),
],
options={
'verbose_name': 'Фото категории',
'verbose_name_plural': 'Фото категорий',
'ordering': ['order', '-created_at'],
},
),
migrations.CreateModel(
name='ProductKit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('sku', models.CharField(blank=True, max_length=100, null=True, verbose_name='Артикул')),
('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL-идентификатор')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
('pricing_method', models.CharField(choices=[('fixed', 'Фиксированная цена'), ('from_sale_prices', 'По ценам продажи компонентов'), ('from_cost_plus_percent', 'Себестоимость + процент наценки'), ('from_cost_plus_amount', 'Себестоимость + фикс. наценка')], default='from_sale_prices', max_length=30, verbose_name='Метод ценообразования')),
('fixed_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Фиксированная цена')),
('markup_percent', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='Процент наценки')),
('markup_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Фиксированная наценка')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('categories', models.ManyToManyField(blank=True, related_name='kits', to='products.productcategory', verbose_name='Категории')),
('tags', models.ManyToManyField(blank=True, related_name='kits', to='products.producttag', verbose_name='Теги')),
],
options={
'verbose_name': 'Комплект',
'verbose_name_plural': 'Комплекты',
},
),
migrations.CreateModel(
name='ProductKitPhoto',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='kits/', verbose_name='Фото')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productkit', verbose_name='Комплект')),
],
options={
'verbose_name': 'Фото комплекта',
'verbose_name_plural': 'Фото комплектов',
'ordering': ['order', '-created_at'],
},
),
migrations.CreateModel(
name='ProductPhoto',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='products/', verbose_name='Фото')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.product', verbose_name='Товар')),
],
options={
'verbose_name': 'Фото товара',
'verbose_name_plural': 'Фото товаров',
'ordering': ['order', '-created_at'],
},
),
migrations.CreateModel(
name='KitItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('notes', models.CharField(blank=True, max_length=200, verbose_name='Примечание')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kit_items_direct', to='products.product', verbose_name='Конкретный товар')),
('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productkit', verbose_name='Комплект')),
('variant_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productvariantgroup', verbose_name='Группа вариантов')),
],
options={
'verbose_name': 'Компонент комплекта',
'verbose_name_plural': 'Компоненты комплектов',
},
),
migrations.CreateModel(
name='KitItemPriority',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('priority', models.PositiveIntegerField(default=0, help_text='Меньше = выше приоритет (0 - наивысший)')),
('kit_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='priorities', to='products.kititem', verbose_name='Позиция в букете')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.product', verbose_name='Товар')),
],
options={
'verbose_name': 'Приоритет варианта',
'verbose_name_plural': 'Приоритеты вариантов',
'ordering': ['priority', 'id'],
'unique_together': {('kit_item', 'product')},
},
),
migrations.AddIndex(
model_name='productcategory',
index=models.Index(fields=['is_active'], name='products_pr_is_acti_226e74_idx'),
),
migrations.AddIndex(
model_name='productkit',
index=models.Index(fields=['is_active'], name='products_pr_is_acti_214d4f_idx'),
),
migrations.AddIndex(
model_name='productkit',
index=models.Index(fields=['slug'], name='products_pr_slug_b5e185_idx'),
),
migrations.AddIndex(
model_name='product',
index=models.Index(fields=['is_active'], name='products_pr_is_acti_ca4d9a_idx'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2025-10-21 17:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='productcategory',
name='sku',
field=models.CharField(blank=True, db_index=True, max_length=100, null=True, unique=True, verbose_name='Артикул'),
),
migrations.AlterField(
model_name='skucounter',
name='counter_type',
field=models.CharField(choices=[('product', 'Product Counter'), ('kit', 'Kit Counter'), ('category', 'Category Counter')], max_length=20, unique=True, verbose_name='Тип счетчика'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-10-21 19:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0002_productcategory_sku_alter_skucounter_counter_type'),
]
operations = [
migrations.AlterField(
model_name='productcategory',
name='slug',
field=models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор'),
),
]

View File

@@ -0,0 +1,618 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.core.exceptions import ValidationError
from django.db import transaction
from .utils.sku_generator import generate_product_sku, generate_kit_sku, generate_category_sku
class SKUCounter(models.Model):
"""
Глобальные счетчики для генерации уникальных номеров артикулов.
Используется для товаров (product), комплектов (kit) и категорий (category).
"""
COUNTER_TYPE_CHOICES = [
('product', 'Product Counter'),
('kit', 'Kit Counter'),
('category', 'Category Counter'),
]
counter_type = models.CharField(
max_length=20,
unique=True,
choices=COUNTER_TYPE_CHOICES,
verbose_name="Тип счетчика"
)
current_value = models.IntegerField(
default=0,
verbose_name="Текущее значение"
)
class Meta:
verbose_name = "Счетчик артикулов"
verbose_name_plural = "Счетчики артикулов"
def __str__(self):
return f"{self.get_counter_type_display()}: {self.current_value}"
@classmethod
def get_next_value(cls, counter_type):
"""
Получить следующее значение счетчика (thread-safe).
Использует select_for_update для предотвращения race conditions.
"""
with transaction.atomic():
counter, created = cls.objects.select_for_update().get_or_create(
counter_type=counter_type,
defaults={'current_value': 0}
)
counter.current_value += 1
counter.save()
return counter.current_value
class ActiveManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
class ProductCategory(models.Model):
"""
Категории товаров и комплектов (поддержка нескольких уровней не обязательна, но возможна позже).
"""
name = models.CharField(max_length=200, verbose_name="Название")
sku = models.CharField(max_length=100, blank=True, null=True, unique=True, verbose_name="Артикул", db_index=True)
slug = models.SlugField(max_length=200, unique=True, blank=True, verbose_name="URL-идентификатор")
parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True,
related_name='children', verbose_name="Родительская категория")
is_active = models.BooleanField(default=True, verbose_name="Активна")
objects = models.Manager() # Менеджер по умолчанию
active = ActiveManager() # Кастомный менеджер для активных категорий
class Meta:
verbose_name = "Категория товара"
verbose_name_plural = "Категории товаров"
indexes = [
models.Index(fields=['is_active']),
]
def __str__(self):
return self.name
def clean(self):
"""Валидация категории перед сохранением"""
from django.core.exceptions import ValidationError
# 1. Защита от самоссылки
if self.parent and self.parent.pk == self.pk:
raise ValidationError({
'parent': 'Категория не может быть родителем самой себя.'
})
# 2. Защита от циклических ссылок (только для существующих категорий)
if self.parent and self.pk:
self._check_parent_chain()
# 3. Проверка активности родителя
if self.parent and not self.parent.is_active:
raise ValidationError({
'parent': 'Нельзя выбрать неактивную категорию в качестве родителя.'
})
def _check_parent_chain(self):
"""Проверяет цепочку родителей на циклы и глубину вложенности"""
from django.core.exceptions import ValidationError
from django.conf import settings
current = self.parent
depth = 0
max_depth = getattr(settings, 'MAX_CATEGORY_DEPTH', 10)
while current:
if current.pk == self.pk:
raise ValidationError({
'parent': f'Обнаружена циклическая ссылка. '
f'Категория "{self.name}" не может быть потомком самой себя.'
})
depth += 1
if depth > max_depth:
raise ValidationError({
'parent': f'Слишком глубокая вложенность категорий '
f'(максимум {max_depth} уровней).'
})
current = current.parent
def save(self, *args, **kwargs):
# Вызываем валидацию перед сохранением
self.full_clean()
# Автоматическая генерация slug из названия с транслитерацией
if not self.slug or self.slug.strip() == '':
from unidecode import unidecode
# Транслитерируем кириллицу в латиницу, затем применяем slugify
transliterated_name = unidecode(self.name)
self.slug = slugify(transliterated_name)
# Автоматическая генерация артикула при создании новой категории
if not self.sku and not self.pk:
from .utils.sku_generator import generate_category_sku
self.sku = generate_category_sku()
super().save(*args, **kwargs)
class ProductTag(models.Model):
"""
Свободные теги для фильтрации и поиска.
"""
name = models.CharField(max_length=100, unique=True, verbose_name="Название")
slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор")
class Meta:
verbose_name = "Тег товара"
verbose_name_plural = "Теги товаров"
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class ProductVariantGroup(models.Model):
"""
Группа вариантов товара (взаимозаменяемые товары).
Например: "Роза красная Freedom" включает розы 50см, 60см, 70см.
"""
name = models.CharField(max_length=200, verbose_name="Название")
description = models.TextField(blank=True, verbose_name="Описание")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
verbose_name = "Группа вариантов"
verbose_name_plural = "Группы вариантов"
ordering = ['name']
def __str__(self):
return self.name
def get_products_count(self):
"""Возвращает количество товаров в группе"""
return self.products.count()
class Product(models.Model):
"""
Базовый товар (цветок, упаковка, аксессуар).
"""
UNIT_CHOICES = [
('шт', 'Штука'),
('м', 'Метр'),
('г', 'Грамм'),
('л', 'Литр'),
('кг', 'Килограмм'),
]
name = models.CharField(max_length=200, verbose_name="Название")
sku = models.CharField(max_length=100, blank=True, null=True, verbose_name="Артикул", db_index=True)
variant_suffix = models.CharField(
max_length=20,
blank=True,
null=True,
verbose_name="Суффикс варианта",
help_text="Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия."
)
description = models.TextField(blank=True, null=True, verbose_name="Описание")
categories = models.ManyToManyField(
ProductCategory,
blank=True,
related_name='products',
verbose_name="Категории"
)
tags = models.ManyToManyField(ProductTag, blank=True, related_name='products', verbose_name="Теги")
variant_groups = models.ManyToManyField(
ProductVariantGroup,
blank=True,
related_name='products',
verbose_name="Группы вариантов"
)
unit = models.CharField(max_length=10, choices=UNIT_CHOICES, default='шт', verbose_name="Единица измерения")
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Себестоимость")
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Розничная цена")
is_active = models.BooleanField(default=True, verbose_name="Активен")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
# Поле для улучшенного поиска (задел на будущее)
search_keywords = models.TextField(
blank=True,
verbose_name="Ключевые слова для поиска",
help_text="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную."
)
objects = models.Manager() # Менеджер по умолчанию
active = ActiveManager() # Кастомный менеджер для активных товаров
class Meta:
verbose_name = "Товар"
verbose_name_plural = "Товары"
indexes = [
models.Index(fields=['is_active']),
]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
# Автоматическое извлечение variant_suffix из названия
# (только если не задан вручную и товар еще не сохранен с суффиксом)
if not self.variant_suffix and self.name:
from .utils.sku_generator import parse_variant_suffix
parsed_suffix = parse_variant_suffix(self.name)
if parsed_suffix:
self.variant_suffix = parsed_suffix
# Генерация артикула для новых товаров
if not self.sku:
self.sku = generate_product_sku(self)
# Автоматическая генерация ключевых слов для поиска
# Собираем все релевантные данные в одну строку
keywords_parts = [
self.name or '',
self.sku or '',
self.description or '',
]
# Генерируем строку для поиска (только если поле пустое)
# Это позволит администратору добавлять кастомные ключевые слова вручную
if not self.search_keywords:
self.search_keywords = ' '.join(filter(None, keywords_parts))
super().save(*args, **kwargs)
# Добавляем названия категорий в search_keywords после сохранения
# (ManyToMany требует, чтобы объект уже существовал в БД)
if self.pk and self.categories.exists():
category_names = ' '.join([cat.name for cat in self.categories.all()])
if category_names and category_names not in self.search_keywords:
self.search_keywords = f"{self.search_keywords} {category_names}".strip()
# Используем update чтобы избежать рекурсии
Product.objects.filter(pk=self.pk).update(search_keywords=self.search_keywords)
def get_variant_groups(self):
"""Возвращает все группы вариантов товара"""
return self.variant_groups.all()
def get_similar_products(self):
"""Возвращает все товары из тех же групп вариантов (исключая себя)"""
return Product.objects.filter(
variant_groups__in=self.variant_groups.all()
).exclude(id=self.id).distinct()
class ProductKit(models.Model):
"""
Шаблон комплекта / букета (рецепт).
"""
PRICING_METHOD_CHOICES = [
('fixed', 'Фиксированная цена'),
('from_sale_prices', 'По ценам продажи компонентов'),
('from_cost_plus_percent', 'Себестоимость + процент наценки'),
('from_cost_plus_amount', 'Себестоимость + фикс. наценка'),
]
name = models.CharField(max_length=200, verbose_name="Название")
sku = models.CharField(max_length=100, blank=True, null=True, verbose_name="Артикул")
slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-идентификатор")
description = models.TextField(blank=True, null=True, verbose_name="Описание")
categories = models.ManyToManyField(
ProductCategory,
blank=True,
related_name='kits',
verbose_name="Категории"
)
tags = models.ManyToManyField(ProductTag, blank=True, related_name='kits', verbose_name="Теги")
is_active = models.BooleanField(default=True, verbose_name="Активен")
pricing_method = models.CharField(max_length=30, choices=PRICING_METHOD_CHOICES,
default='from_sale_prices', verbose_name="Метод ценообразования")
fixed_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True,
verbose_name="Фиксированная цена")
markup_percent = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True,
verbose_name="Процент наценки")
markup_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True,
verbose_name="Фиксированная наценка")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
objects = models.Manager() # Менеджер по умолчанию
active = ActiveManager() # Кастомный менеджер для активных комплектов
class Meta:
verbose_name = "Комплект"
verbose_name_plural = "Комплекты"
indexes = [
models.Index(fields=['is_active']),
models.Index(fields=['slug']),
]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
# Убеждаемся, что slug уникален
original_slug = self.slug
counter = 1
while ProductKit.objects.filter(slug=self.slug).exclude(pk=self.pk).exists():
self.slug = f"{original_slug}-{counter}"
counter += 1
if not self.sku:
self.sku = generate_kit_sku()
super().save(*args, **kwargs)
def get_total_components_count(self):
"""Возвращает количество позиций в букете"""
return self.kit_items.count()
def get_components_with_variants_count(self):
"""Возвращает количество позиций с группами вариантов"""
return self.kit_items.filter(variant_group__isnull=False).count()
def get_sale_price(self):
"""Возвращает рассчитанную цену продажи комплекта"""
try:
return self.calculate_price_with_substitutions()
except Exception:
# Если что-то пошло не так, возвращаем фиксированную цену если есть
if self.pricing_method == 'fixed' and self.fixed_price:
return self.fixed_price
return 0
def check_availability(self, stock_manager=None):
"""
Проверяет доступность всего букета.
Букет доступен, если для каждой позиции есть хотя бы один доступный вариант.
"""
from .utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
for kit_item in self.kit_items.all():
best_product = kit_item.get_best_available_product(stock_manager)
if not best_product:
return False
return True
def calculate_price_with_substitutions(self, stock_manager=None):
"""
Расчёт цены букета с учётом доступных замен.
Использует цены фактически доступных товаров.
"""
from decimal import Decimal
from .utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
# Если указана фиксированная цена, используем её
if self.pricing_method == 'fixed' and self.fixed_price:
return self.fixed_price
total_cost = Decimal('0.00')
total_sale = Decimal('0.00')
for kit_item in self.kit_items.select_related('product', 'variant_group'):
best_product = kit_item.get_best_available_product(stock_manager)
if not best_product:
# Если товар недоступен, используем цену первого в списке
available_products = kit_item.get_available_products()
best_product = available_products[0] if available_products else None
if best_product:
total_cost += best_product.cost_price * kit_item.quantity
total_sale += best_product.sale_price * kit_item.quantity
# Применяем метод ценообразования
if self.pricing_method == 'from_sale_prices':
return total_sale
elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent:
return total_cost * (Decimal('1') + self.markup_percent / Decimal('100'))
elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount:
return total_cost + self.markup_amount
elif self.pricing_method == 'fixed' and self.fixed_price:
return self.fixed_price
return total_sale
class KitItem(models.Model):
"""
Состав комплекта: связь между ProductKit и Product или ProductVariantGroup.
Позиция может быть либо конкретным товаром, либо группой вариантов.
"""
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
verbose_name="Комплект")
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='kit_items_direct',
verbose_name="Конкретный товар"
)
variant_group = models.ForeignKey(
ProductVariantGroup,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='kit_items',
verbose_name="Группа вариантов"
)
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
notes = models.CharField(
max_length=200,
blank=True,
verbose_name="Примечание"
)
class Meta:
verbose_name = "Компонент комплекта"
verbose_name_plural = "Компоненты комплектов"
def __str__(self):
return f"{self.kit.name} - {self.get_display_name()}"
def clean(self):
"""Валидация: должен быть указан либо product, либо variant_group (но не оба)"""
if self.product and self.variant_group:
raise ValidationError(
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
)
if not self.product and not self.variant_group:
raise ValidationError(
"Необходимо указать либо товар, либо группу вариантов."
)
def get_display_name(self):
"""Возвращает название для отображения (товар или группа)"""
if self.variant_group:
return f"[Варианты] {self.variant_group.name}"
return self.product.name if self.product else "Не указан"
def has_priorities_set(self):
"""Проверяет, настроены ли приоритеты"""
return self.priorities.exists()
def get_available_products(self):
"""Возвращает список доступных товаров с учётом приоритетов"""
if self.product:
# Если указан конкретный товар, возвращаем только его
return [self.product]
if self.variant_group:
# Если есть настроенные приоритеты, используем их
if self.has_priorities_set():
return [
priority.product
for priority in self.priorities.select_related('product').order_by('priority', 'id')
]
# Иначе возвращаем все товары из группы
return list(self.variant_group.products.filter(is_active=True))
return []
def get_best_available_product(self, stock_manager=None):
"""Возвращает первый доступный товар по приоритету"""
from .utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
available_products = self.get_available_products()
for product in available_products:
if stock_manager.check_stock(product, self.quantity):
return product
return None
class KitItemPriority(models.Model):
"""
Приоритеты товаров для конкретной позиции букета.
Позволяет настроить индивидуальные приоритеты замен для каждого букета.
"""
kit_item = models.ForeignKey(
KitItem,
on_delete=models.CASCADE,
related_name='priorities',
verbose_name="Позиция в букете"
)
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
verbose_name="Товар"
)
priority = models.PositiveIntegerField(
default=0,
help_text="Меньше = выше приоритет (0 - наивысший)"
)
class Meta:
verbose_name = "Приоритет варианта"
verbose_name_plural = "Приоритеты вариантов"
ordering = ['priority', 'id']
unique_together = ['kit_item', 'product']
def __str__(self):
return f"{self.product.name} (приоритет {self.priority})"
class ProductPhoto(models.Model):
"""
Модель для хранения фото товара (один товар может иметь несколько фото).
"""
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='photos',
verbose_name="Товар")
image = models.ImageField(upload_to='products/', verbose_name="Фото")
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
verbose_name = "Фото товара"
verbose_name_plural = "Фото товаров"
ordering = ['order', '-created_at']
def __str__(self):
return f"Фото для {self.product.name}"
class ProductKitPhoto(models.Model):
"""
Модель для хранения фото комплекта (один комплект может иметь несколько фото).
"""
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='photos',
verbose_name="Комплект")
image = models.ImageField(upload_to='kits/', verbose_name="Фото")
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
verbose_name = "Фото комплекта"
verbose_name_plural = "Фото комплектов"
ordering = ['order', '-created_at']
def __str__(self):
return f"Фото для {self.kit.name}"
class ProductCategoryPhoto(models.Model):
"""
Модель для хранения фото категории (одна категория может иметь несколько фото).
"""
category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, related_name='photos',
verbose_name="Категория")
image = models.ImageField(upload_to='categories/', verbose_name="Фото")
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
verbose_name = "Фото категории"
verbose_name_plural = "Фото категорий"
ordering = ['order', '-created_at']
def __str__(self):
return f"Фото для {self.category.name}"

View File

@@ -0,0 +1,46 @@
{% extends 'base.html' %}
{% block title %}Удалить категорию{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-danger text-white">
<h4>Подтверждение удаления</h4>
</div>
<div class="card-body">
<p>Вы уверены, что хотите удалить категорию <strong>"{{ category.name }}"</strong>?</p>
{% if products_count > 0 %}
<div class="alert alert-danger">
<strong>Внимание!</strong> В этой категории есть <strong>{{ products_count }}</strong> товар(ов).
<br>Удаление невозможно. Сначала удалите или переместите товары.
</div>
{% endif %}
{% if children_count > 0 %}
<div class="alert alert-warning">
<strong>Внимание!</strong> У этой категории есть <strong>{{ children_count }}</strong> подкатегорий.
<br>Удаление невозможно. Сначала удалите или переместите подкатегории.
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between mt-4">
<a href="{% url 'products:category-detail' category.pk %}" class="btn btn-secondary">Отмена</a>
{% if products_count == 0 and children_count == 0 %}
<button type="submit" class="btn btn-danger">Да, удалить</button>
{% else %}
<a href="{% url 'products:category-detail' category.pk %}" class="btn btn-primary">Вернуться к категории</a>
{% endif %}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,105 @@
{% extends 'base.html' %}
{% block title %}{{ category.name }}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row">
<div class="col-md-8">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h3>{{ category.name }}</h3>
<div>
{% if category.is_active %}
<span class="badge bg-success">Активна</span>
{% else %}
<span class="badge bg-secondary">Неактивна</span>
{% endif %}
</div>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4"><strong>Артикул:</strong></div>
<div class="col-md-8"><code>{{ category.sku|default:"—" }}</code></div>
</div>
<div class="row mb-3">
<div class="col-md-4"><strong>URL-идентификатор:</strong></div>
<div class="col-md-8"><code>{{ category.slug }}</code></div>
</div>
<div class="row mb-3">
<div class="col-md-4"><strong>Родительская категория:</strong></div>
<div class="col-md-8">
{% if category.parent %}
<a href="{% url 'products:category-detail' category.parent.pk %}">{{ category.parent.name }}</a>
{% else %}
<span class="text-muted">Корневая категория</span>
{% endif %}
</div>
</div>
<!-- Фотографии -->
{% if category_photos %}
<div class="mt-4">
<h5>Фотографии ({{ photos_count }})</h5>
<div class="row g-2">
{% for photo in category_photos %}
<div class="col-md-3 col-sm-4 col-6">
<div class="card">
<img src="{{ photo.image.url }}" class="card-img-top" alt="Фото категории" style="height: 150px; object-fit: cover;">
{% if photo.order == 0 %}
<div class="card-body p-1 text-center">
<span class="badge bg-success">Главное</span>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Подкатегории -->
{% if children_categories %}
<div class="mt-4">
<h5>Подкатегории ({{ children_categories.count }})</h5>
<ul class="list-group">
{% for child in children_categories %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<a href="{% url 'products:category-detail' child.pk %}">{{ child.name }}</a>
<span class="badge bg-primary">{{ child.sku|default:"—" }}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Товары в категории -->
{% if products %}
<div class="mt-4">
<h5>Товары в категории ({{ products_count }})</h5>
<div class="list-group">
{% for product in products %}
<a href="{% url 'products:product-detail' product.pk %}" class="list-group-item list-group-item-action">
{{ product.name }} <span class="text-muted">({{ product.sku }})</span>
</a>
{% endfor %}
{% if products_count > 20 %}
<div class="list-group-item text-center text-muted">
... и еще {{ products_count|add:"-20" }} товар(ов)
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
<div class="d-flex gap-2">
<a href="{% url 'products:category-list' %}" class="btn btn-secondary">Назад к списку</a>
<a href="{% url 'products:category-update' category.pk %}" class="btn btn-primary">Редактировать</a>
<a href="{% url 'products:category-delete' category.pk %}" class="btn btn-danger">Удалить</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,191 @@
{% extends 'base.html' %}
{% block title %}{% if object %}Редактировать категорию{% else %}Создать категорию{% endif %}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<!-- Блок 1: Основная информация -->
<div class="mb-4">
<!-- Название -->
<div class="mb-3">
<label for="id_name" class="form-label fw-bold fs-5">Название</label>
{{ form.name }}
{% if form.name.help_text %}
<small class="form-text text-muted">{{ form.name.help_text }}</small>
{% endif %}
{% if form.name.errors %}
<div class="text-danger">{{ form.name.errors }}</div>
{% endif %}
</div>
<!-- Артикул и Slug в один ряд -->
<div class="row mb-3">
<div class="col-md-6">
{{ form.sku.label_tag }}
{{ form.sku }}
{% if form.sku.help_text %}
<small class="form-text text-muted">{{ form.sku.help_text }}</small>
{% endif %}
{% if form.sku.errors %}
<div class="text-danger">{{ form.sku.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
{{ form.slug.label_tag }}
{{ form.slug }}
{% if form.slug.help_text %}
<small class="form-text text-muted">{{ form.slug.help_text }}</small>
{% endif %}
{% if form.slug.errors %}
<div class="text-danger">{{ form.slug.errors }}</div>
{% endif %}
</div>
</div>
<!-- Родитель и Статус в один ряд -->
<div class="row mb-3">
<div class="col-md-8">
{{ form.parent.label_tag }}
{{ form.parent }}
{% if form.parent.help_text %}
<small class="form-text text-muted">{{ form.parent.help_text }}</small>
{% endif %}
{% if form.parent.errors %}
<div class="text-danger">{{ form.parent.errors }}</div>
{% endif %}
</div>
<div class="col-md-4">
<div class="form-check mt-4">
{{ form.is_active }}
{{ form.is_active.label_tag }}
</div>
{% if form.is_active.help_text %}
<small class="form-text text-muted">{{ form.is_active.help_text }}</small>
{% endif %}
{% if form.is_active.errors %}
<div class="text-danger">{{ form.is_active.errors }}</div>
{% endif %}
</div>
</div>
</div>
<hr class="my-4">
<!-- Блок 2: Фотографии -->
<div class="mb-4 p-3 bg-light rounded">
<h5 class="mb-3">Фотографии</h5>
<!-- Существующие фотографии (только при редактировании) -->
{% if object and category_photos %}
<div class="mb-3">
<h6 class="mb-3">Текущие фотографии ({{ photos_count }})</h6>
<div class="row g-2 mb-3">
{% for photo in category_photos %}
<div class="col-md-3 col-sm-4 col-6">
<div class="card shadow-sm h-100">
<div style="width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa; cursor: pointer; overflow: hidden;"
data-bs-toggle="modal"
data-bs-target="#photoModal{{ photo.pk }}"
title="Нажмите для увеличения">
<img src="{{ photo.image.url }}"
alt="Фото категории"
style="max-width: 100%; max-height: 100%; object-fit: contain;">
</div>
<div class="card-body p-2">
{% if photo.order == 0 %}
<div class="badge bg-success w-100 mb-1">Главное</div>
{% else %}
<a href="{% url 'products:category-photo-set-main' photo.pk %}"
class="btn btn-outline-primary btn-sm w-100 mb-1 py-0"
title="Сделать главным">
Главным
</a>
{% endif %}
<div class="btn-group w-100 mb-1" role="group">
<a href="{% url 'products:category-photo-move-up' photo.pk %}"
class="btn btn-outline-secondary btn-sm py-0">
</a>
<a href="{% url 'products:category-photo-move-down' photo.pk %}"
class="btn btn-outline-secondary btn-sm py-0">
</a>
</div>
<a href="{% url 'products:category-photo-delete' photo.pk %}"
class="btn btn-danger btn-sm w-100 py-0"
onclick="return confirm('Удалить это фото?');">
Удалить
</a>
<small class="text-muted d-block mt-1 text-center">Позиция: {{ photo.order }}</small>
</div>
</div>
</div>
<!-- Модальное окно для просмотра фото -->
<div class="modal fade" id="photoModal{{ photo.pk }}" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Фото категории</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<img src="{{ photo.image.url }}" class="img-fluid" alt="Фото категории" style="max-height: 70vh;">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
<a href="{% url 'products:category-photo-delete' photo.pk %}"
class="btn btn-danger"
onclick="return confirm('Удалить это фото?');">
Удалить фото
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Поле для загрузки новых фотографий -->
<div class="mb-0">
<label for="id_photos" class="form-label fw-bold">
{% if object %}Добавить новые фото{% else %}Загрузить фото{% endif %}
</label>
<input type="file" name="photos" accept="image/*" multiple class="form-control" id="id_photos">
<small class="form-text text-muted">
Выберите фото для категории (можно выбрать несколько, до 10 штук)
</small>
</div>
</div>
<div class="d-flex justify-content-between mt-4 gap-2 flex-wrap">
<a href="{% url 'products:category-list' %}" class="btn btn-secondary">Отмена</a>
<button type="submit" class="btn btn-primary">{% if object %}Сохранить изменения{% else %}Создать категорию{% endif %}</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,214 @@
{% extends 'base.html' %}
{% block title %}Список категорий{% endblock %}
{% block content %}
<div class="container mt-5">
<h2 class="mb-4">Категории товаров</h2>
<!-- Панель фильтрации -->
{% include 'components/filter_panel.html' with title="Категории" filters=filters action_buttons=action_buttons %}
<!-- Кнопка "Развернуть/Свернуть все" -->
<div class="mb-3">
<button id="expandAllBtn" class="btn btn-sm btn-outline-secondary" onclick="expandAll()">
Развернуть все
</button>
<button id="collapseAllBtn" class="btn btn-sm btn-outline-secondary" onclick="collapseAll()">
Свернуть все
</button>
</div>
{% if category_tree %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Название</th>
<th>Артикул</th>
<th>Цена</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for item in category_tree %}
<tr data-category-id="{{ item.pk }}"
data-item-type="{{ item.item_type }}"
data-parent-id="{{ item.parent_id|default:'null' }}"
data-depth="{{ item.depth }}"
{% if item.depth > 0 %}style="display: none;"{% endif %}
class="category-row">
<!-- Колонка "Название" -->
<td style="padding-left: calc(20px * {{ item.depth }} + 12px);">
{% if item.item_type == 'category' %}
{% if item.has_children %}
<button class="btn btn-sm btn-outline-secondary p-0 toggle-btn"
data-target="{{ item.pk }}"
onclick="toggleCategory({{ item.pk }}); return false;"
style="width: 24px; height: 24px; text-align: center; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; border-radius: 4px; font-weight: bold; font-size: 16px; line-height: 1;">
+
</button>
{% else %}
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
{% endif %}
<a href="{% url 'products:category-detail' item.pk %}"
style="font-weight: 600; color: #212529;">{{ item.name }}</a>
{% if item.has_children %}
<span class="badge bg-info text-dark ms-1">{{ item.obj.children.count }} подкат.</span>
{% endif %}
{% elif item.item_type == 'product' %}
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
<a href="{% url 'products:product-detail' item.pk %}"
style="color: #6c757d;">{{ item.name }}</a>
{% elif item.item_type == 'kit' %}
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
<a href="{% url 'products:kit-detail' item.pk %}"
style="color: #6c757d;">{{ item.name }}</a>
{% endif %}
</td>
<!-- Колонка "Артикул" -->
<td>
{% if item.sku %}
<code>{{ item.sku }}</code>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<!-- Колонка "Цена" -->
<td>
{% if item.price %}
{{ item.price|floatformat:0 }} ₽
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<!-- Колонка "Статус" (только для категорий) -->
<td>
{% if item.item_type == 'category' %}
{% if item.obj.is_active %}
<span class="badge bg-success">Активна</span>
{% else %}
<span class="badge bg-secondary">Неактивна</span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<!-- Колонка "Действия" -->
<td>
{% if item.item_type == 'category' %}
<a href="{% url 'products:category-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
<a href="{% url 'products:category-delete' item.pk %}" class="btn btn-sm btn-outline-danger">Удалить</a>
{% elif item.item_type == 'product' %}
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
{% elif item.item_type == 'kit' %}
<a href="{% url 'products:kit-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">
<p>Категории не найдены.</p>
<a href="{% url 'products:category-create' %}" class="btn btn-primary">Создать первую категорию</a>
</div>
{% endif %}
</div>
<script>
// Функция для раскрытия/сворачивания категории
function toggleCategory(categoryId) {
// Находим все прямые дочерние элементы
const children = document.querySelectorAll(`tr[data-parent-id="${categoryId}"]`);
const toggleBtn = document.querySelector(`button[data-target="${categoryId}"]`);
if (!toggleBtn) return;
const isExpanded = toggleBtn.textContent.trim() === '-';
children.forEach(child => {
const childId = child.getAttribute('data-category-id');
const childToggleBtn = child.querySelector(`button[data-target="${childId}"]`);
if (isExpanded) {
// Сворачиваем
child.style.display = 'none';
// Рекурсивно сворачиваем всех потомков
collapseAllChildren(childId);
if (childToggleBtn) {
childToggleBtn.textContent = '+';
}
} else {
// Раскрываем только прямых детей
child.style.display = 'table-row';
}
});
// Переключаем кнопку
toggleBtn.textContent = isExpanded ? '+' : '-';
}
// Рекурсивно сворачивает всех потомков
function collapseAllChildren(categoryId) {
const children = document.querySelectorAll(`tr[data-parent-id="${categoryId}"]`);
children.forEach(child => {
const childId = child.getAttribute('data-category-id');
child.style.display = 'none';
const childToggleBtn = child.querySelector(`button[data-target="${childId}"]`);
if (childToggleBtn) {
childToggleBtn.textContent = '+';
}
collapseAllChildren(childId);
});
}
// Развернуть все категории
function expandAll() {
const allRows = document.querySelectorAll('.category-row');
const allToggleBtns = document.querySelectorAll('.toggle-btn');
allRows.forEach(row => {
row.style.display = 'table-row';
});
allToggleBtns.forEach(btn => {
btn.textContent = '-';
});
}
// Свернуть все категории (показать только корневые)
function collapseAll() {
const allRows = document.querySelectorAll('.category-row');
const allToggleBtns = document.querySelectorAll('.toggle-btn');
allRows.forEach(row => {
const depth = parseInt(row.getAttribute('data-depth'));
if (depth > 0) {
row.style.display = 'none';
}
});
allToggleBtns.forEach(btn => {
btn.textContent = '+';
});
}
// При поиске автоматически раскрываем все категории
{% if has_search %}
document.addEventListener('DOMContentLoaded', function() {
expandAll();
});
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends 'base.html' %}
{% block title %}Удалить товар{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-danger text-white">
<h3>Подтверждение удаления</h3>
</div>
<div class="card-body">
<p>Вы уверены, что хотите удалить товар <strong>"{{ product.name }}"</strong>?</p>
<p class="text-muted">Артикул: {{ product.sku }}</p>
<p class="text-danger">Внимание! Это действие нельзя будет отменить.</p>
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between">
<a href="{% url 'products:product-list' %}" class="btn btn-secondary">Отмена</a>
<button type="submit" class="btn btn-danger">Удалить товар</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,261 @@
{% extends 'base.html' %}
{% block title %}{{ product.name }}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h3>{{ product.name }}</h3>
<div>
{% if perms.products.change_product %}
<a href="{% url 'products:product-update' product.pk %}" class="btn btn-outline-primary btn-sm">Редактировать</a>
{% endif %}
{% if perms.products.delete_product %}
<a href="{% url 'products:product-delete' product.pk %}" class="btn btn-outline-danger btn-sm">Удалить</a>
{% endif %}
</div>
</div>
</div>
<div class="card-body">
<!-- Секция фотографий в начале -->
{% if product_photos %}
<div class="mb-4">
<h5 class="mb-3">Фотографии товара ({{ photos_count }})</h5>
<div class="row g-2">
{% for photo in product_photos %}
<div class="col-md-3 col-sm-4 col-6">
<div class="card shadow-sm h-100">
<!-- Кликабельное фото для открытия галереи -->
<div style="width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa; cursor: pointer; overflow: hidden;"
data-bs-toggle="modal"
data-bs-target="#photoGalleryModal"
data-bs-slide-to="{{ forloop.counter0 }}"
title="Нажмите для увеличения">
<img src="{{ photo.image.url }}"
alt="Фото товара"
style="max-width: 100%; max-height: 100%; object-fit: contain;">
</div>
<div class="card-body p-2 text-center">
{% if photo.order == 0 %}
<div class="badge bg-success w-100">Главное</div>
{% else %}
<small class="text-muted">Позиция: {{ photo.order }}</small>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Модальное окно с галереей (Bootstrap Carousel) -->
<div class="modal fade" id="photoGalleryModal" tabindex="-1" aria-labelledby="photoGalleryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="photoGalleryModalLabel">Галерея фотографий товара</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div id="photoCarousel" class="carousel slide" data-bs-ride="false">
<div class="carousel-inner">
{% for photo in product_photos %}
<div class="carousel-item {% if forloop.first %}active{% endif %}">
<div class="text-center" style="min-height: 60vh; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa;">
<img src="{{ photo.image.url }}" class="d-block" alt="Фото товара" style="max-height: 70vh; max-width: 100%; object-fit: contain;">
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Навигация и индикаторы под фото -->
{% if photos_count > 1 %}
<div class="d-flex justify-content-center align-items-center mt-3 gap-3">
<button class="btn btn-outline-secondary" type="button" data-bs-target="#photoCarousel" data-bs-slide="prev">
<i class="bi bi-chevron-left"></i> Предыдущее
</button>
<div class="carousel-indicators position-static m-0">
{% for photo in product_photos %}
<button type="button" data-bs-target="#photoCarousel" data-bs-slide-to="{{ forloop.counter0 }}"
{% if forloop.first %}class="active" aria-current="true"{% endif %}
aria-label="Слайд {{ forloop.counter }}"
style="background-color: #6c757d;"></button>
{% endfor %}
</div>
<button class="btn btn-outline-secondary" type="button" data-bs-target="#photoCarousel" data-bs-slide="next">
Следующее <i class="bi bi-chevron-right"></i>
</button>
</div>
<div class="text-center mt-2">
<small class="text-muted">
<span id="currentSlide">1</span> из {{ photos_count }}
<span id="mainBadge" {% if not product_photos.0.order == 0 %}style="display: none;"{% endif %} class="badge bg-success ms-2">Главное</span>
</small>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
<hr class="my-4">
{% endif %}
<!-- Основная информация о товаре -->
<h5 class="mb-3">Основная информация</h5>
<table class="table">
<tr>
<th>Артикул:</th>
<td>{{ product.sku }}</td>
</tr>
<tr>
<th>Описание:</th>
<td>{{ product.description|default:"-" }}</td>
</tr>
<tr>
<th>Категории:</th>
<td>
{% if product.categories.all %}
{% for category in product.categories.all %}
<span class="badge bg-primary">{{ category.name }}</span>
{% endfor %}
{% else %}
-
{% endif %}
</td>
</tr>
<tr>
<th>Теги:</th>
<td>
{% if product.tags.all %}
{% for tag in product.tags.all %}
<span class="badge bg-secondary">{{ tag.name }}</span>
{% endfor %}
{% else %}
-
{% endif %}
</td>
</tr>
<tr>
<th>Единица измерения:</th>
<td>{{ product.unit }}</td>
</tr>
<tr>
<th>Себестоимость:</th>
<td>{{ product.cost_price }} руб.</td>
</tr>
<tr>
<th>Цена продажи:</th>
<td>{{ product.sale_price }} руб.</td>
</tr>
<tr>
<th>Статус:</th>
<td>
{% if product.is_active %}
<span class="badge bg-success">Активен</span>
{% else %}
<span class="badge bg-secondary">Неактивен</span>
{% endif %}
</td>
</tr>
<tr>
<th>Дата создания:</th>
<td>{{ product.created_at }}</td>
</tr>
<tr>
<th>Дата обновления:</th>
<td>{{ product.updated_at }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h4>Действия</h4>
</div>
<div class="card-body">
<a href="{% url 'products:product-list' %}" class="btn btn-secondary btn-block">Назад к списку</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Обработка галереи фотографий
document.addEventListener('DOMContentLoaded', function() {
const photoGalleryModal = document.getElementById('photoGalleryModal');
const photoCarousel = document.getElementById('photoCarousel');
if (photoGalleryModal && photoCarousel) {
const carousel = bootstrap.Carousel.getOrCreateInstance(photoCarousel);
const currentSlideEl = document.getElementById('currentSlide');
const mainBadgeEl = document.getElementById('mainBadge');
// Массив с информацией о фотографиях
const photos = [
{% for photo in product_photos %}
{ order: {{ photo.order }}, index: {{ forloop.counter0 }} }{% if not forloop.last %},{% endif %}
{% endfor %}
];
// Обработка клика на миниатюру для открытия галереи на нужном слайде
photoGalleryModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const slideIndex = button.getAttribute('data-bs-slide-to');
if (slideIndex !== null) {
carousel.to(parseInt(slideIndex));
}
});
// Обновление счетчика и бейджа при переключении слайдов
photoCarousel.addEventListener('slid.bs.carousel', function (event) {
const activeIndex = event.to;
if (currentSlideEl) {
currentSlideEl.textContent = activeIndex + 1;
}
if (mainBadgeEl && photos[activeIndex]) {
mainBadgeEl.style.display = photos[activeIndex].order === 0 ? 'inline' : 'none';
}
});
// Обработка нажатий клавиш-стрелок для навигации
const handleKeydown = function(event) {
if (event.key === 'ArrowLeft') {
event.preventDefault();
carousel.prev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
carousel.next();
}
};
// Добавляем обработчик клавиш при открытии модального окна
photoGalleryModal.addEventListener('shown.bs.modal', function () {
document.addEventListener('keydown', handleKeydown);
});
// Удаляем обработчик клавиш при закрытии модального окна
photoGalleryModal.addEventListener('hidden.bs.modal', function () {
document.removeEventListener('keydown', handleKeydown);
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,270 @@
{% extends 'base.html' %}
{% block title %}{% if object %}Редактировать товар{% else %}Создать товар{% endif %}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<!-- Блок 1: Основная информация -->
<div class="mb-4">
<!-- Название -->
<div class="mb-3">
<label for="id_name" class="form-label fw-bold fs-5">Название</label>
{{ form.name }}
{% if form.name.help_text %}
<small class="form-text text-muted">{{ form.name.help_text }}</small>
{% endif %}
{% if form.name.errors %}
<div class="text-danger">{{ form.name.errors }}</div>
{% endif %}
</div>
<!-- Артикул -->
<div class="mb-3">
{{ form.sku.label_tag }}
{{ form.sku }}
{% if form.sku.help_text %}
<small class="form-text text-muted">{{ form.sku.help_text }}</small>
{% endif %}
{% if form.sku.errors %}
<div class="text-danger">{{ form.sku.errors }}</div>
{% endif %}
</div>
<!-- Описание -->
<div class="mb-3">
{{ form.description.label_tag }}
{{ form.description }}
{% if form.description.help_text %}
<small class="form-text text-muted">{{ form.description.help_text }}</small>
{% endif %}
{% if form.description.errors %}
<div class="text-danger">{{ form.description.errors }}</div>
{% endif %}
</div>
<!-- Единица измерения и Статус в один ряд -->
<div class="row mb-3">
<div class="col-md-8">
{{ form.unit.label_tag }}
{{ form.unit }}
{% if form.unit.help_text %}
<small class="form-text text-muted">{{ form.unit.help_text }}</small>
{% endif %}
{% if form.unit.errors %}
<div class="text-danger">{{ form.unit.errors }}</div>
{% endif %}
</div>
<div class="col-md-4">
<div class="form-check mt-4">
{{ form.is_active }}
{{ form.is_active.label_tag }}
</div>
{% if form.is_active.help_text %}
<small class="form-text text-muted">{{ form.is_active.help_text }}</small>
{% endif %}
{% if form.is_active.errors %}
<div class="text-danger">{{ form.is_active.errors }}</div>
{% endif %}
</div>
</div>
</div>
<hr class="my-4">
<!-- Блок 2: Ценообразование -->
<div class="mb-4">
<h5 class="mb-3">Ценообразование</h5>
<div class="row mb-3">
<div class="col-md-6">
{{ form.cost_price.label_tag }}
{{ form.cost_price }}
{% if form.cost_price.help_text %}
<small class="form-text text-muted">{{ form.cost_price.help_text }}</small>
{% endif %}
{% if form.cost_price.errors %}
<div class="text-danger">{{ form.cost_price.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
{{ form.sale_price.label_tag }}
{{ form.sale_price }}
{% if form.sale_price.help_text %}
<small class="form-text text-muted">{{ form.sale_price.help_text }}</small>
{% endif %}
{% if form.sale_price.errors %}
<div class="text-danger">{{ form.sale_price.errors }}</div>
{% endif %}
</div>
</div>
</div>
<hr class="my-4">
<!-- Блок 3: Фотографии -->
<div class="mb-4 p-3 bg-light rounded">
<h5 class="mb-3">Фотографии</h5>
<!-- Существующие фотографии (только при редактировании) -->
{% if object and product_photos %}
<div class="mb-3">
<h6 class="mb-3">Текущие фотографии ({{ photos_count }})</h6>
<div class="row g-2 mb-3">
{% for photo in product_photos %}
<div class="col-md-3 col-sm-4 col-6">
<div class="card shadow-sm h-100">
<!-- Кликабельное фото для открытия модального окна -->
<div style="width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa; cursor: pointer; overflow: hidden;"
data-bs-toggle="modal"
data-bs-target="#photoModal{{ photo.pk }}"
title="Нажмите для увеличения">
<img src="{{ photo.image.url }}"
alt="Фото товара"
style="max-width: 100%; max-height: 100%; object-fit: contain;">
</div>
<div class="card-body p-2">
{% if photo.order == 0 %}
<div class="badge bg-success w-100 mb-1">⭐ Главное</div>
{% else %}
<a href="{% url 'products:product-photo-set-main' photo.pk %}"
class="btn btn-outline-primary btn-sm w-100 mb-1 py-0"
title="Сделать главным">
⭐ Главным
</a>
{% endif %}
<div class="btn-group w-100 mb-1" role="group">
<a href="{% url 'products:product-photo-move-up' photo.pk %}"
class="btn btn-outline-secondary btn-sm py-0"
title="Переместить вверх">
⬆️
</a>
<a href="{% url 'products:product-photo-move-down' photo.pk %}"
class="btn btn-outline-secondary btn-sm py-0"
title="Переместить вниз">
⬇️
</a>
</div>
<a href="{% url 'products:product-photo-delete' photo.pk %}"
class="btn btn-danger btn-sm w-100 py-0"
onclick="return confirm('Удалить это фото?');">
🗑️ Удалить
</a>
<small class="text-muted d-block mt-1 text-center">Позиция: {{ photo.order }}</small>
</div>
</div>
</div>
<!-- Модальное окно для просмотра фото -->
<div class="modal fade" id="photoModal{{ photo.pk }}" tabindex="-1" aria-labelledby="photoModalLabel{{ photo.pk }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="photoModalLabel{{ photo.pk }}">Фото товара</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body text-center">
<img src="{{ photo.image.url }}" class="img-fluid" alt="Фото товара" style="max-height: 70vh;">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
<a href="{% url 'products:product-photo-delete' photo.pk %}"
class="btn btn-danger"
onclick="return confirm('Удалить это фото?');">
🗑️ Удалить фото
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Поле для загрузки новых фотографий -->
<div class="mb-0">
<label for="id_photos" class="form-label fw-bold">
{% if object %}Добавить новые фото{% else %}Загрузить фото{% endif %}
</label>
<input type="file" name="photos" accept="image/*" multiple class="form-control" id="id_photos">
<small class="form-text text-muted">
{% if object %}
Выберите фото для добавления к товару (можно выбрать несколько, до 10 штук всего)
{% else %}
Выберите фото для товара (можно выбрать несколько, до 10 штук)
{% endif %}
</small>
</div>
</div>
<hr class="my-4">
<!-- Блок 4: Классификация -->
<div class="mb-4">
<h5 class="mb-3">Классификация</h5>
<!-- Категории -->
<div class="mb-3">
{{ form.categories.label_tag }}
<div class="p-3 bg-light rounded">
{{ form.categories }}
</div>
{% if form.categories.help_text %}
<small class="form-text text-muted">{{ form.categories.help_text }}</small>
{% endif %}
{% if form.categories.errors %}
<div class="text-danger">{{ form.categories.errors }}</div>
{% endif %}
</div>
<!-- Теги -->
<div class="mb-3">
{{ form.tags.label_tag }}
<div class="p-3 bg-light rounded">
{{ form.tags }}
</div>
{% if form.tags.help_text %}
<small class="form-text text-muted">{{ form.tags.help_text }}</small>
{% endif %}
{% if form.tags.errors %}
<div class="text-danger">{{ form.tags.errors }}</div>
{% endif %}
</div>
</div>
<div class="d-flex justify-content-between mt-4 gap-2 flex-wrap">
<div>
<a href="{% url 'products:product-list' %}" class="btn btn-secondary">Отмена</a>
{% if perms.products.add_productkit %}
<a href="{% url 'products:productkit-create' %}" class="btn btn-outline-primary">
<i class="bi bi-box-seam"></i> Создать комплект
</a>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">{% if object %}Сохранить изменения{% else %}Создать товар{% endif %}</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends 'base.html' %}
{% block title %}Список товаров{% endblock %}
{% block content %}
<div class="container mt-5">
<h2 class="mb-4">Список товаров</h2>
<!-- Панель фильтрации -->
{% include 'components/filter_panel.html' with title="Товары" filters=filters action_buttons=action_buttons %}
{% if products %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Фото</th>
<th>Название</th>
<th>Артикул</th>
<th>Категория</th>
<th>Цена продажи</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for product in products %}
<tr>
<td>
{% if product.photos.all %}
{% with photo=product.photos.first %}
<img src="{{ photo.image.url }}" alt="{{ product.name }}" style="max-width: 50px; max-height: 50px;" class="img-thumbnail">
{% endwith %}
{% else %}
<span class="text-muted">Нет фото</span>
{% endif %}
</td>
<td>
<a href="{% url 'products:product-detail' product.pk %}">{{ product.name }}</a>
</td>
<td>{{ product.sku }}</td>
<td>
{% if product.categories.all %}
{% for category in product.categories.all %}
{{ category.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
-
{% endif %}
</td>
<td>{{ product.sale_price }} руб.</td>
<td>
{% if product.is_active %}
<span class="badge bg-success">Активен</span>
{% else %}
<span class="badge bg-secondary">Неактивен</span>
{% endif %}
</td>
<td>
{% if perms.products.change_product %}
<a href="{% url 'products:product-update' product.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
{% endif %}
{% if perms.products.delete_product %}
<a href="{% url 'products:product-delete' product.pk %}" class="btn btn-sm btn-outline-danger">Удалить</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if is_paginated %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">Страница {{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-info">
<p>Товары не найдены.</p>
{% if perms.products.add_product %}
<a href="{% url 'products:product-create' %}" class="btn btn-primary">Создать первый товар</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% extends 'base.html' %}
{% block title %}Удалить комплект - {{ kit.name }}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Подтверждение удаления</h4>
</div>
<div class="card-body">
<p class="lead">Вы действительно хотите удалить комплект?</p>
<div class="alert alert-warning mb-3">
<strong>{{ kit.name }}</strong>
<br>
<small class="text-muted">Артикул: {{ kit.sku }}</small>
</div>
<div class="alert alert-danger mb-4">
<i class="bi bi-exclamation-circle"></i>
<strong>Внимание!</strong> Это действие нельзя отменить. Комплект будет удален с все его компоненты и фотографии.
</div>
<form method="post" class="d-flex gap-2">
{% csrf_token %}
<a href="{% url 'products:productkit-detail' kit.pk %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отмена
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Удалить комплект
</button>
</form>
</div>
</div>
<!-- Информация о комплекте -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">Информация о комплекте</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-5">Название:</dt>
<dd class="col-sm-7">{{ kit.name }}</dd>
<dt class="col-sm-5">Артикул:</dt>
<dd class="col-sm-7">{{ kit.sku }}</dd>
{% if kit.categories.all %}
<dt class="col-sm-5">Категории:</dt>
<dd class="col-sm-7">
{% for category in kit.categories.all %}
{{ category.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</dd>
{% endif %}
<dt class="col-sm-5">Компонентов:</dt>
<dd class="col-sm-7">{{ kit.get_total_components_count }}</dd>
<dt class="col-sm-5">Статус:</dt>
<dd class="col-sm-7">
{% if kit.is_active %}
<span class="badge bg-success">Активен</span>
{% else %}
<span class="badge bg-secondary">Неактивен</span>
{% endif %}
</dd>
<dt class="col-sm-5">Создан:</dt>
<dd class="col-sm-7">{{ kit.created_at|date:"d.m.Y" }}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,239 @@
{% extends 'base.html' %}
{% block title %}{{ kit.name }} - Комплект{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row mb-4">
<div class="col-md-8">
<h2>{{ kit.name }}</h2>
<p class="text-muted">Артикул: <strong>{{ kit.sku }}</strong></p>
</div>
<div class="col-md-4 text-end">
{% if perms.products.change_productkit %}
<a href="{% url 'products:productkit-update' kit.pk %}" class="btn btn-primary">
<i class="bi bi-pencil"></i> Редактировать
</a>
{% endif %}
{% if perms.products.delete_productkit %}
<a href="{% url 'products:productkit-delete' kit.pk %}" class="btn btn-danger">
<i class="bi bi-trash"></i> Удалить
</a>
{% endif %}
</div>
</div>
<div class="row">
<!-- Основная информация -->
<div class="col-md-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Основная информация</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-4">Название:</dt>
<dd class="col-sm-8">{{ kit.name }}</dd>
<dt class="col-sm-4">Артикул:</dt>
<dd class="col-sm-8">{{ kit.sku }}</dd>
{% if kit.categories.all %}
<dt class="col-sm-4">Категории:</dt>
<dd class="col-sm-8">
{% for category in kit.categories.all %}
<span class="badge bg-primary">{{ category.name }}</span>
{% endfor %}
</dd>
{% endif %}
<dt class="col-sm-4">Цена продажи:</dt>
<dd class="col-sm-8">
<strong class="text-success fs-5">{{ kit.get_sale_price|floatformat:2 }} ₽</strong>
</dd>
<dt class="col-sm-4">Ценообразование:</dt>
<dd class="col-sm-8">
<span class="badge bg-info text-dark">{{ kit.get_pricing_method_display }}</span>
</dd>
{% if kit.fixed_price %}
<dt class="col-sm-4">Фиксированная цена:</dt>
<dd class="col-sm-8">{{ kit.fixed_price }} ₽</dd>
{% endif %}
{% if kit.markup_percent %}
<dt class="col-sm-4">Процент наценки:</dt>
<dd class="col-sm-8">{{ kit.markup_percent }}%</dd>
{% endif %}
{% if kit.markup_amount %}
<dt class="col-sm-4">Фиксированная наценка:</dt>
<dd class="col-sm-8">{{ kit.markup_amount }} ₽</dd>
{% endif %}
<dt class="col-sm-4">Статус:</dt>
<dd class="col-sm-8">
{% if kit.is_active %}
<span class="badge bg-success">Активен</span>
{% else %}
<span class="badge bg-secondary">Неактивен</span>
{% endif %}
</dd>
</dl>
{% if kit.description %}
<div class="mt-3">
<h6>Описание:</h6>
<p>{{ kit.description|linebreaks }}</p>
</div>
{% endif %}
{% if kit.tags.all %}
<div class="mt-3">
<h6>Теги:</h6>
<div class="d-flex flex-wrap gap-2">
{% for tag in kit.tags.all %}
<span class="badge bg-secondary">{{ tag.name }}</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Компоненты комплекта -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Состав комплекта ({{ kit_items.count }} компонентов)</h5>
</div>
<div class="card-body">
{% if kit_items %}
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>#</th>
<th>Компонент</th>
<th>Тип</th>
<th>Количество</th>
<th>Примечание</th>
</tr>
</thead>
<tbody>
{% for item in kit_items %}
<tr>
<td>{{ forloop.counter }}</td>
<td>
{% if item.product %}
<a href="{% url 'products:product-detail' item.product.pk %}">
{{ item.product.name }}
</a>
<br>
<small class="text-muted">Артикул: {{ item.product.sku }}</small>
{% else %}
<span class="badge bg-primary">{{ item.variant_group.name }}</span>
<br>
<small class="text-muted">Варианты: {{ item.variant_group.get_products_count }} товаров</small>
{% endif %}
</td>
<td>
{% if item.product %}
<span class="badge bg-success">Товар</span>
{% else %}
<span class="badge bg-primary">Варианты</span>
{% endif %}
</td>
<td>{{ item.quantity }}</td>
<td>
{% if item.notes %}
{{ item.notes }}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Нет компонентов в этом комплекте</p>
{% endif %}
</div>
</div>
</div>
<!-- Сбоку: фотографии и информация -->
<div class="col-md-4">
<!-- Фотографии -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Фотографии ({{ photos_count }})</h5>
</div>
<div class="card-body">
{% if productkit_photos %}
<div class="row g-2">
{% for photo in productkit_photos %}
<div class="col-6">
<div class="card">
<img src="{{ photo.image.url }}" class="card-img-top" alt="{{ kit.name }}"
style="height: 120px; object-fit: cover; cursor: pointer;"
data-bs-toggle="modal"
data-bs-target="#photoModal{{ photo.pk }}">
{% if photo.order == 0 %}
<div class="card-footer bg-success text-white text-center small">⭐ Главное</div>
{% endif %}
</div>
</div>
<!-- Модальное окно для просмотра -->
<div class="modal fade" id="photoModal{{ photo.pk }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Фото комплекта</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<img src="{{ photo.image.url }}" class="img-fluid" style="max-height: 70vh;">
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0">Нет фотографий</p>
{% endif %}
</div>
</div>
<!-- Метаинформация -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Информация</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-8 text-truncate">Создан:</dt>
<dd class="col-sm-4">{{ kit.created_at|date:"d.m.Y H:i" }}</dd>
<dt class="col-sm-8 text-truncate">Обновлен:</dt>
<dd class="col-sm-4">{{ kit.updated_at|date:"d.m.Y H:i" }}</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Кнопка назад -->
<div class="row mt-4">
<div class="col-12">
<a href="{% url 'products:productkit-list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> К списку комплектов
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,664 @@
{% extends 'base.html' %}
{% block title %}{% if object %}Редактировать комплект{% else %}Создать комплект{% endif %}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-10">
<div class="card">
<div class="card-header">
<h3>{% if object %}Редактировать комплект{% else %}Создать комплект{% endif %}</h3>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<!-- Секция управления фотографиями -->
{% if object %}
<div class="mb-4 p-3 bg-light rounded">
<h5 class="mb-3">Управление фотографиями</h5>
<!-- Существующие фотографии (только при редактировании) -->
{% if productkit_photos %}
<div class="mb-3">
<h6 class="mb-3">Текущие фотографии ({{ photos_count }})</h6>
<div class="row g-2 mb-3">
{% for photo in productkit_photos %}
<div class="col-md-3 col-sm-4 col-6">
<div class="card shadow-sm h-100">
<!-- Кликабельное фото для открытия модального окна -->
<div style="width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa; cursor: pointer; overflow: hidden;"
data-bs-toggle="modal"
data-bs-target="#photoModal{{ photo.pk }}"
title="Нажмите для увеличения">
<img src="{{ photo.image.url }}"
alt="Фото комплекта"
style="max-width: 100%; max-height: 100%; object-fit: contain;">
</div>
<div class="card-body p-2">
{% if photo.order == 0 %}
<div class="badge bg-success w-100 mb-1">⭐ Главное</div>
{% else %}
<a href="{% url 'products:productkit-photo-set-main' photo.pk %}"
class="btn btn-outline-primary btn-sm w-100 mb-1 py-0"
title="Сделать главным">
⭐ Главным
</a>
{% endif %}
<div class="btn-group w-100 mb-1" role="group">
<a href="{% url 'products:productkit-photo-move-up' photo.pk %}"
class="btn btn-outline-secondary btn-sm py-0"
title="Переместить вверх">
⬆️
</a>
<a href="{% url 'products:productkit-photo-move-down' photo.pk %}"
class="btn btn-outline-secondary btn-sm py-0"
title="Переместить вниз">
⬇️
</a>
</div>
<a href="{% url 'products:productkit-photo-delete' photo.pk %}"
class="btn btn-danger btn-sm w-100 py-0"
onclick="return confirm('Удалить это фото?');">
🗑️ Удалить
</a>
<small class="text-muted d-block mt-1 text-center">Позиция: {{ photo.order }}</small>
</div>
</div>
</div>
<!-- Модальное окно для просмотра фото -->
<div class="modal fade" id="photoModal{{ photo.pk }}" tabindex="-1" aria-labelledby="photoModalLabel{{ photo.pk }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="photoModalLabel{{ photo.pk }}">Фото комплекта</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body text-center">
<img src="{{ photo.image.url }}" class="img-fluid" alt="Фото комплекта" style="max-height: 70vh;">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
<a href="{% url 'products:productkit-photo-delete' photo.pk %}"
class="btn btn-danger"
onclick="return confirm('Удалить это фото?');">
🗑️ Удалить фото
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Поле для загрузки новых фотографий -->
<div class="mb-0">
<label for="id_photos" class="form-label fw-bold">
{% if object %} Добавить новые фото{% else %}📷 Загрузить фото{% endif %}
</label>
<input type="file" name="photos" accept="image/*" multiple class="form-control" id="id_photos">
<small class="form-text text-muted">
{% if object %}
Выберите фото для добавления к комплекту (можно выбрать несколько, до 10 штук всего)
{% else %}
Выберите фото для комплекта (можно выбрать несколько, до 10 штук)
{% endif %}
</small>
</div>
</div>
<hr class="my-4">
{% endif %}
<!-- Основная информация о комплекте -->
<h5 class="mb-3">Основная информация</h5>
{% for field in form %}
{% if field.name == 'fixed_price' %}
<!-- Alert для неактивного поля fixed_price -->
<div id="fixed-price-alert" class="alert alert-warning d-none mb-3" role="alert">
<i class="bi bi-exclamation-triangle"></i>
<strong>Внимание!</strong> Поле "Фиксированная цена" неактивно при текущем методе ценообразования.
Оно используется только когда выбран метод <strong>"Фиксированная цена"</strong>.
</div>
{% endif %}
<div class="mb-3">
{{ field.label_tag }}
{% if field.name == 'tags' %}
<div class="form-control p-3 bg-light">
{{ field }}
</div>
{% else %}
{{ field }}
{% endif %}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %}
{% if field.errors %}
<div class="text-danger">{{ field.errors }}</div>
{% endif %}
</div>
{% endfor %}
<hr class="my-4">
<!-- Компоненты комплекта (формсет) -->
<h5 class="mb-3">Состав комплекта</h5>
<div class="alert alert-info mb-3">
<i class="bi bi-info-circle"></i>
<strong>Подсказка:</strong> Добавьте компоненты комплекта ниже. Каждый компонент может быть либо конкретным товаром, либо группой вариантов (например, розы разных размеров).
</div>
<!-- Поиск товаров для быстрого добавления (показываем только при редактировании) -->
<div class="card mb-4 bg-light border-info" id="searchCard">
<div class="card-header bg-info text-white">
<h6 class="mb-0">
<i class="bi bi-search"></i> Поиск товара для добавления
</h6>
</div>
<div class="card-body">
<div class="input-group mb-2">
<input type="text"
id="productSearch"
class="form-control"
placeholder="Введите название или артикул товара (минимум 2 символа)..."
autocomplete="off">
<button class="btn btn-info" type="button" id="searchBtn">
<i class="bi bi-search"></i> Поиск
</button>
</div>
<div id="searchResults" class="list-group" style="display: none; max-height: 300px; overflow-y: auto;"></div>
<small class="text-muted">Используйте поиск для быстрого нахождения товара и добавления его в комплект</small>
</div>
</div>
<!-- Management form для формсета -->
{{ kititem_formset.management_form }}
<!-- Ошибки формсета на уровне формсета -->
{% if kititem_formset.non_form_errors %}
<div class="alert alert-danger mb-3">
{% for error in kititem_formset.non_form_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<!-- Формы компонентов -->
<div id="kititem-forms" class="mb-4">
{% for kititem_form in kititem_formset %}
<div class="card mb-3 kititem-form" data-form-index="{{ forloop.counter0 }}">
<!-- Скрытые поля для inline formset -->
{{ kititem_form.id }}
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span><strong>Компонент #{{ forloop.counter }}</strong></span>
{% if kititem_form.DELETE %}
<div class="form-check">
{{ kititem_form.DELETE }}
<label class="form-check-label" for="{{ kititem_form.DELETE.id_for_label }}">
Удалить
</label>
</div>
{% endif %}
</div>
<div class="card-body">
{% if kititem_form.non_field_errors %}
<div class="alert alert-danger">
{% for error in kititem_form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ kititem_form.product.id_for_label }}" class="form-label">
{{ kititem_form.product.label }}
</label>
{{ kititem_form.product }}
{% if kititem_form.product.errors %}
<div class="text-danger">{{ kititem_form.product.errors }}</div>
{% endif %}
<small class="form-text text-muted">Конкретный товар (если выбран, группа вариантов не нужна)</small>
</div>
<div class="col-md-6 mb-3">
<label for="{{ kititem_form.variant_group.id_for_label }}" class="form-label">
{{ kititem_form.variant_group.label }}
</label>
{{ kititem_form.variant_group }}
{% if kititem_form.variant_group.errors %}
<div class="text-danger">{{ kititem_form.variant_group.errors }}</div>
{% endif %}
<small class="form-text text-muted">Группа вариантов (если выбрана, конкретный товар не нужен)</small>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="{{ kititem_form.quantity.id_for_label }}" class="form-label">
{{ kititem_form.quantity.label }}
</label>
{{ kititem_form.quantity }}
{% if kititem_form.quantity.errors %}
<div class="text-danger">{{ kititem_form.quantity.errors }}</div>
{% endif %}
</div>
<div class="col-md-8 mb-3">
<label for="{{ kititem_form.notes.id_for_label }}" class="form-label">
{{ kititem_form.notes.label }}
</label>
{{ kititem_form.notes }}
{% if kititem_form.notes.errors %}
<div class="text-danger">{{ kititem_form.notes.errors }}</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Кнопки действия -->
<div class="d-flex justify-content-between mt-4 gap-2 flex-wrap">
<a href="{% url 'products:productkit-list' %}" class="btn btn-secondary">Отмена</a>
<div class="btn-group">
<button type="submit" name="action" value="exit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Сохранить и выйти
</button>
<button type="submit" name="action" value="continue" class="btn btn-outline-primary">
<i class="bi bi-arrow-repeat"></i> Сохранить и продолжить
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// ========== ОПРЕДЕЛЯЕМ РЕЖИМ: СОЗДАНИЕ ИЛИ РЕДАКТИРОВАНИЕ ==========
const existingForms = document.querySelectorAll('.kititem-form');
const isEditMode = existingForms.length > 0 &&
document.querySelector('[name$="-id"]') !== null &&
document.querySelector('[name$="-id"]').value !== '';
// Если режим редактирования - показываем карточку поиска
// Если создание - скрываем (будет одна пустая форма)
const searchCard = document.getElementById('searchCard');
if (!isEditMode && searchCard) {
searchCard.style.display = 'none';
}
// ========== ФУНКЦИИ ДЛЯ УПРАВЛЕНИЯ ВИДИМОСТЬЮ ПОЛЕЙ ==========
function updateFieldStatus(form) {
const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
if (productSelect && variantGroupSelect) {
const hasProduct = productSelect.value;
const hasVariant = variantGroupSelect.value;
// Если выбран товар - отключаем группу вариантов
if (hasProduct) {
variantGroupSelect.disabled = true;
variantGroupSelect.closest('.col-md-6').style.opacity = '0.6';
} else {
variantGroupSelect.disabled = false;
variantGroupSelect.closest('.col-md-6').style.opacity = '1';
}
// Если выбрана группа - отключаем товар
if (hasVariant) {
productSelect.disabled = true;
productSelect.closest('.col-md-6').style.opacity = '0.6';
} else {
productSelect.disabled = false;
productSelect.closest('.col-md-6').style.opacity = '1';
}
}
}
// Инициализируем все существующие формы
const kititemForms = document.querySelectorAll('.kititem-form');
kititemForms.forEach((form) => {
updateFieldStatus(form);
const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
if (productSelect) productSelect.addEventListener('change', () => updateFieldStatus(form));
if (variantGroupSelect) variantGroupSelect.addEventListener('change', () => updateFieldStatus(form));
});
// ========== ФУНКЦИИ ДЛЯ ПОИСКА И ДОБАВЛЕНИЯ ТОВАРОВ ==========
const searchInput = document.getElementById('productSearch');
const searchBtn = document.getElementById('searchBtn');
const searchResults = document.getElementById('searchResults');
const managementForm = document.querySelector('[name$="TOTAL_FORMS"]');
// Функция для выполнения поиска
async function performSearch(query) {
if (query.length < 2) {
searchResults.style.display = 'none';
return;
}
try {
const response = await fetch(`{% url 'products:api-search-products-variants' %}?q=${encodeURIComponent(query)}`);
const data = await response.json();
searchResults.innerHTML = '';
if (data.results.length === 0) {
searchResults.innerHTML = '<div class="list-group-item text-muted">Товары не найдены</div>';
} else {
data.results.forEach((item) => {
const resultItem = document.createElement('button');
resultItem.type = 'button';
resultItem.className = 'list-group-item list-group-item-action';
resultItem.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">${item.name}</h6>
<small class="text-muted">
${item.type === 'product' ? '📦 Товар • Цена: ' + item.price + ' ₽' : '📋 Варианты • Вариантов: ' + item.count}
</small>
</div>
<span class="badge ${item.type === 'product' ? 'bg-success' : 'bg-primary'}">
${item.type === 'product' ? 'Товар' : 'Варианты'}
</span>
</div>
`;
resultItem.addEventListener('click', (e) => {
e.preventDefault();
addItemToFormset(item);
});
searchResults.appendChild(resultItem);
});
}
searchResults.style.display = 'block';
} catch (error) {
console.error('Ошибка при поиске:', error);
searchResults.innerHTML = '<div class="list-group-item text-danger">Ошибка при поиске</div>';
searchResults.style.display = 'block';
}
}
// Функция для добавления товара в формсет
function addItemToFormset(item) {
const formsContainer = document.getElementById('kititem-forms');
const existingForms = formsContainer.querySelectorAll('.kititem-form');
// Ищем первую пустую форму и используем ее
let emptyForm = null;
existingForms.forEach((form) => {
if (!emptyForm) {
const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
// Если форма пустая и не помечена на удаление
if ((!productSelect || !productSelect.value) &&
(!variantGroupSelect || !variantGroupSelect.value) &&
(!deleteCheckbox || !deleteCheckbox.checked)) {
emptyForm = form;
}
}
});
// Если нашли пустую форму, заполняем ее
if (emptyForm) {
fillFormWithItem(emptyForm, item);
} else {
// Если нет пустой формы, создаем новую
const totalForms = parseInt(document.querySelector('[name*="TOTAL_FORMS"]').value);
const newFormHtml = createNewKitItemForm(totalForms, item);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newFormHtml;
formsContainer.appendChild(tempDiv.firstElementChild);
// Обновляем счетчик форм
document.querySelector('[name*="TOTAL_FORMS"]').value = totalForms + 1;
// Инициализируем новую форму
const newForm = formsContainer.lastElementChild;
initializeForm(newForm);
}
// Очищаем поиск
searchInput.value = '';
searchResults.style.display = 'none';
// Показываем сообщение
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show';
alertDiv.setAttribute('role', 'alert');
alertDiv.innerHTML = `
<strong>✓ Товар добавлен!</strong> "${item.name}" добавлен в комплект.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.querySelector('.card-body').prepend(alertDiv);
setTimeout(() => alertDiv.remove(), 3000);
}
// Функция для заполнения существующей формы
function fillFormWithItem(form, item) {
const itemType = item.type === 'product' ? 'product' : 'variant';
const selectedId = item.id;
if (itemType === 'product') {
const productSelect = form.querySelector('[name$="-product"]');
if (productSelect) {
productSelect.value = selectedId;
productSelect.innerHTML = `<option value="">---------</option><option value="${selectedId}" selected>${item.name}</option>`;
productSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
} else {
const variantSelect = form.querySelector('[name$="-variant_group"]');
if (variantSelect) {
variantSelect.value = selectedId;
variantSelect.innerHTML = `<option value="">---------</option><option value="${selectedId}" selected>${item.name}</option>`;
variantSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
}
updateFieldStatus(form);
}
// Функция для инициализации формы
function initializeForm(form) {
updateFieldStatus(form);
const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
if (productSelect) productSelect.addEventListener('change', () => updateFieldStatus(form));
if (variantGroupSelect) variantGroupSelect.addEventListener('change', () => updateFieldStatus(form));
}
// Функция для создания HTML новой формы
function createNewKitItemForm(formIndex, item) {
const fieldName = `kititem_set-${formIndex}`;
const itemType = item.type === 'product' ? 'product' : 'variant';
const selectedId = item.id;
const selectedField = itemType === 'product' ? 'product' : 'variant_group';
return `
<div class="card mb-3 kititem-form" data-form-index="${formIndex}">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span><strong>Компонент #${formIndex + 1}</strong></span>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="${fieldName}-DELETE" name="${fieldName}-DELETE">
<label class="form-check-label" for="${fieldName}-DELETE">
Удалить
</label>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="${fieldName}-product" class="form-label">
Конкретный товар
</label>
<select class="form-select" id="${fieldName}-product" name="${fieldName}-product"
data-new-record="False" ${itemType === 'variant' ? 'disabled' : ''} style="${itemType === 'variant' ? 'opacity: 0.6' : ''}">
<option value="">---------</option>
${itemType === 'product' ? `<option value="${selectedId}" selected>${item.name}</option>` : ''}
</select>
<small class="form-text text-muted">Конкретный товар (если выбран, группа вариантов не нужна)</small>
</div>
<div class="col-md-6 mb-3">
<label for="${fieldName}-variant_group" class="form-label">
Группа вариантов
</label>
<select class="form-select" id="${fieldName}-variant_group" name="${fieldName}-variant_group"
data-new-record="False" ${itemType === 'product' ? 'disabled' : ''} style="${itemType === 'product' ? 'opacity: 0.6' : ''}">
<option value="">---------</option>
${itemType === 'variant' ? `<option value="${selectedId}" selected>${item.name}</option>` : ''}
</select>
<small class="form-text text-muted">Группа вариантов (если выбрана, конкретный товар не нужен)</small>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="${fieldName}-quantity" class="form-label">
Количество
</label>
<input type="number" class="form-control" id="${fieldName}-quantity" name="${fieldName}-quantity"
value="1" step="0.001" min="0">
</div>
<div class="col-md-8 mb-3">
<label for="${fieldName}-notes" class="form-label">
Примечание
</label>
<input type="text" class="form-control" id="${fieldName}-notes" name="${fieldName}-notes"
placeholder="Опциональное примечание">
</div>
</div>
<input type="hidden" name="${fieldName}-id">
</div>
</div>
`;
}
// Обработчики событий поиска
searchBtn.addEventListener('click', () => {
performSearch(searchInput.value);
});
searchInput.addEventListener('keyup', (e) => {
if (e.key === 'Enter') {
performSearch(searchInput.value);
} else {
// Автопоиск с задержкой
clearTimeout(searchInput.searchTimeout);
searchInput.searchTimeout = setTimeout(() => {
performSearch(searchInput.value);
}, 300);
}
});
// Очистка результатов при клике вне области
document.addEventListener('click', (e) => {
if (!e.target.closest('#productSearch') && !e.target.closest('#searchResults') && !e.target.closest('#searchBtn')) {
// searchResults.style.display = 'none';
}
});
});
// ========== ОЧИСТКА ПУСТЫХ ФОРМ ПЕРЕД СОХРАНЕНИЕМ ==========
document.addEventListener('DOMContentLoaded', function() {
const kitForm = document.querySelector('form[method="post"]');
if (kitForm) {
kitForm.addEventListener('submit', function(e) {
// Отмечаем пустые компоненты для удаления
const formsContainer = document.getElementById('kititem-forms');
if (formsContainer) {
const allForms = formsContainer.querySelectorAll('.kititem-form');
allForms.forEach((form) => {
const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
const hasProduct = productSelect && productSelect.value;
const hasVariant = variantGroupSelect && variantGroupSelect.value;
// Если форма пустая - помечаем на удаление
if (!hasProduct && !hasVariant && deleteCheckbox) {
deleteCheckbox.checked = true;
}
});
}
// Продолжаем нормальную отправку формы
});
}
});
// ========== УПРАВЛЕНИЕ ВИДИМОСТЬЮ ПОЛЯ FIXED_PRICE ==========
document.addEventListener('DOMContentLoaded', function() {
const pricingMethodField = document.querySelector('[name="pricing_method"]');
const fixedPriceField = document.querySelector('[name="fixed_price"]');
const fixedPriceAlert = document.getElementById('fixed-price-alert');
function updatePricingFieldsState() {
if (!pricingMethodField || !fixedPriceField) return;
const method = pricingMethodField.value;
if (method === 'fixed') {
// Поле активно
fixedPriceField.disabled = false;
fixedPriceField.style.opacity = '1';
if (fixedPriceAlert) {
fixedPriceAlert.classList.add('d-none');
}
} else {
// Поле неактивно
fixedPriceField.disabled = true;
fixedPriceField.style.opacity = '0.6';
if (fixedPriceAlert) {
fixedPriceAlert.classList.remove('d-none');
}
}
}
// Инициализируем состояние при загрузке
updatePricingFieldsState();
// Обновляем при изменении метода ценообразования
if (pricingMethodField) {
pricingMethodField.addEventListener('change', updatePricingFieldsState);
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,127 @@
{% extends 'base.html' %}
{% block title %}Список комплектов (букетов){% endblock %}
{% block content %}
<div class="container mt-5">
<h2 class="mb-4">Список комплектов (букетов)</h2>
<!-- Панель фильтрации -->
{% include 'components/filter_panel.html' with title="Комплекты" filters=filters action_buttons=action_buttons %}
{% if kits %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Фото</th>
<th>Название</th>
<th>Артикул</th>
<th>Категория</th>
<th>Цена продажи</th>
<th>Компонентов</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for kit in kits %}
<tr>
<td>
{% if kit.photos.all %}
{% with photo=kit.photos.first %}
<img src="{{ photo.image.url }}" alt="{{ kit.name }}" style="max-width: 50px; max-height: 50px;" class="img-thumbnail">
{% endwith %}
{% else %}
<span class="text-muted">Нет фото</span>
{% endif %}
</td>
<td>
<a href="{% url 'products:productkit-detail' kit.pk %}">{{ kit.name }}</a>
</td>
<td>{{ kit.sku }}</td>
<td>
{% if kit.categories.all %}
{% for category in kit.categories.all %}
{{ category.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
-
{% endif %}
</td>
<td>{{ kit.get_sale_price|floatformat:2 }} руб.</td>
<td>
<span class="badge bg-secondary">{{ kit.get_total_components_count }} шт</span>
{% if kit.get_components_with_variants_count > 0 %}
<span class="badge bg-primary" title="С вариантами">
<i class="bi bi-shuffle"></i> {{ kit.get_components_with_variants_count }}
</span>
{% endif %}
</td>
<td>
{% if kit.is_active %}
<span class="badge bg-success">Активен</span>
{% else %}
<span class="badge bg-secondary">Неактивен</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{% url 'products:productkit-detail' kit.pk %}" class="btn btn-outline-info" title="Просмотреть">
<i class="bi bi-eye"></i>
</a>
<a href="{% url 'products:productkit-update' kit.pk %}" class="btn btn-outline-primary" title="Редактировать">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'products:productkit-delete' kit.pk %}" class="btn btn-outline-danger" title="Удалить">
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if is_paginated %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Предыдущая</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">Страница {{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-info">
<h4><i class="bi bi-info-circle"></i> Комплекты не найдены</h4>
<p>В данный момент нет комплектов, соответствующих выбранным фильтрам.</p>
{% if perms.products.add_productkit %}
<a href="{% url 'products:productkit-create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Создать первый комплект
</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,48 @@
from django.urls import path
from . import views
app_name = 'products'
urlpatterns = [
# CRUD URLs for Product
path('', views.ProductListView.as_view(), name='product-list'),
path('create/', views.ProductCreateView.as_view(), name='product-create'),
path('<int:pk>/', views.ProductDetailView.as_view(), name='product-detail'),
path('<int:pk>/update/', views.ProductUpdateView.as_view(), name='product-update'),
path('<int:pk>/delete/', views.ProductDeleteView.as_view(), name='product-delete'),
# Photo management
path('photo/<int:pk>/delete/', views.product_photo_delete, name='product-photo-delete'),
path('photo/<int:pk>/set-main/', views.product_photo_set_main, name='product-photo-set-main'),
path('photo/<int:pk>/move-up/', views.product_photo_move_up, name='product-photo-move-up'),
path('photo/<int:pk>/move-down/', views.product_photo_move_down, name='product-photo-move-down'),
# CRUD URLs for ProductKit (комплекты/букеты)
path('kits/', views.ProductKitListView.as_view(), name='productkit-list'),
path('kits/create/', views.ProductKitCreateView.as_view(), name='productkit-create'),
path('kits/<int:pk>/', views.ProductKitDetailView.as_view(), name='productkit-detail'),
path('kits/<int:pk>/update/', views.ProductKitUpdateView.as_view(), name='productkit-update'),
path('kits/<int:pk>/delete/', views.ProductKitDeleteView.as_view(), name='productkit-delete'),
# Photo management for ProductKit
path('kits/photo/<int:pk>/delete/', views.productkit_photo_delete, name='productkit-photo-delete'),
path('kits/photo/<int:pk>/set-main/', views.productkit_photo_set_main, name='productkit-photo-set-main'),
path('kits/photo/<int:pk>/move-up/', views.productkit_photo_move_up, name='productkit-photo-move-up'),
path('kits/photo/<int:pk>/move-down/', views.productkit_photo_move_down, name='productkit-photo-move-down'),
# API endpoints
path('api/search-products-variants/', views.search_products_and_variants, name='api-search-products-variants'),
# CRUD URLs for ProductCategory
path('categories/', views.ProductCategoryListView.as_view(), name='category-list'),
path('categories/create/', views.ProductCategoryCreateView.as_view(), name='category-create'),
path('categories/<int:pk>/', views.ProductCategoryDetailView.as_view(), name='category-detail'),
path('categories/<int:pk>/update/', views.ProductCategoryUpdateView.as_view(), name='category-update'),
path('categories/<int:pk>/delete/', views.ProductCategoryDeleteView.as_view(), name='category-delete'),
# Category photo management
path('categories/photo/<int:pk>/delete/', views.category_photo_delete, name='category-photo-delete'),
path('categories/photo/<int:pk>/set-main/', views.category_photo_set_main, name='category-photo-set-main'),
path('categories/photo/<int:pk>/move-up/', views.category_photo_move_up, name='category-photo-move-up'),
path('categories/photo/<int:pk>/move-down/', views.category_photo_move_down, name='category-photo-move-down'),
]

View File

@@ -0,0 +1,4 @@
"""
Utility package for the products app.
Contains various helper functions and utilities.
"""

View File

@@ -0,0 +1,212 @@
"""
Utility functions for generating SKUs for products, kits, and categories.
New SKU format:
- Products: PROD-XXXXXX or PROD-XXXXXX-VARIANT
- Kits: KIT-XXXXXX
- Categories: CAT-XXXX
Examples:
- PROD-000001
- PROD-000002-50
- KIT-000001
- CAT-0001
"""
import re
from string import ascii_uppercase
def parse_variant_suffix(name):
"""
Извлекает суффикс варианта из названия товара.
Поддерживаемые форматы:
- "Роза Freedom 50см" -> "50"
- "Роза Freedom 60 см" -> "60"
- "Лента 2.5м" -> "25" (метры в дециметры)
- "Коробка S" -> "S"
- "Коробка размер M" -> "M"
Args:
name (str): Название товара
Returns:
str or None: Извлеченный суффикс или None
"""
if not name:
return None
# Паттерны для извлечения суффикса
patterns = [
# Размеры в см: "50см", "60 см"
(r'(\d+)\s*см', lambda m: m.group(1)),
# Размеры в метрах: "2.5м" -> "25" (конвертируем в дециметры)
(r'(\d+\.?\d*)\s*м(?:\s|$)', lambda m: str(int(float(m.group(1)) * 10))),
# Буквенные размеры в конце: "S", "M", "L", "XL"
(r'\b([XSML]{1,3})\s*$', lambda m: m.group(1)),
# "размер S", "размер M"
(r'размер\s+([XSML]{1,3})', lambda m: m.group(1)),
# Просто число в конце: "Товар 50"
(r'\s+(\d+)\s*$', lambda m: m.group(1)),
]
for pattern, extractor in patterns:
match = re.search(pattern, name, re.IGNORECASE)
if match:
return extractor(match)
return None
def ensure_sku_unique(base_sku, exclude_id=None, model_type=None):
"""
Проверяет уникальность артикула и добавляет буквенный суффикс при конфликте.
Если артикул уже существует:
PROD-000001 -> PROD-000001A -> PROD-000001B -> ... -> PROD-000001Z
Args:
base_sku (str): Базовый артикул для проверки
exclude_id (int): ID товара/комплекта/категории, который нужно исключить из проверки
model_type (str): Тип модели ('product', 'kit', 'category') для исключения из проверки
Returns:
str: Уникальный артикул
"""
from products.models import Product, ProductKit, ProductCategory
# Проверяем, существует ли базовый артикул
sku = base_sku
# Проверка во всех моделях с артикулами
def sku_exists(sku_to_check):
product_exists = Product.objects.filter(sku=sku_to_check)
if model_type == 'product' and exclude_id:
product_exists = product_exists.exclude(id=exclude_id)
product_exists = product_exists.exists()
kit_exists = ProductKit.objects.filter(sku=sku_to_check)
if model_type == 'kit' and exclude_id:
kit_exists = kit_exists.exclude(id=exclude_id)
kit_exists = kit_exists.exists()
category_exists = ProductCategory.objects.filter(sku=sku_to_check)
if model_type == 'category' and exclude_id:
category_exists = category_exists.exclude(id=exclude_id)
category_exists = category_exists.exists()
return product_exists or kit_exists or category_exists
# Если базовый артикул свободен, возвращаем его
if not sku_exists(sku):
return sku
# Иначе добавляем буквы A-Z
for letter in ascii_uppercase:
sku_with_letter = f"{base_sku}{letter}"
if not sku_exists(sku_with_letter):
return sku_with_letter
# Если все буквы заняты (маловероятно), добавляем AA, AB, и т.д.
for first_letter in ascii_uppercase:
for second_letter in ascii_uppercase:
sku_with_letters = f"{base_sku}{first_letter}{second_letter}"
if not sku_exists(sku_with_letters):
return sku_with_letters
# В крайнем случае возвращаем базовый + timestamp
from django.utils import timezone
return f"{base_sku}-{timezone.now().strftime('%Y%m%d%H%M%S')}"
def generate_product_sku(product):
"""
Генерирует уникальный артикул для товара.
Формат: PROD-XXXXXX или PROD-XXXXXX-VARIANT
Args:
product: Экземпляр модели Product
Returns:
str: Сгенерированный артикул
"""
from products.models import SKUCounter
# Получаем следующий номер из глобального счетчика
next_number = SKUCounter.get_next_value('product')
# Форматируем номер с ведущими нулями (6 цифр)
base_sku = f"PROD-{next_number:06d}"
# Определяем суффикс варианта
variant_suffix = None
# 1. Если суффикс задан вручную в поле variant_suffix
if product.variant_suffix:
variant_suffix = product.variant_suffix.strip()
# 2. Если суффикс не задан, пытаемся извлечь из названия
# (это работает и для товаров в группах вариантов, и без них)
else:
parsed_suffix = parse_variant_suffix(product.name)
if parsed_suffix:
variant_suffix = parsed_suffix
# Добавляем суффикс, если он есть
if variant_suffix:
base_sku = f"{base_sku}-{variant_suffix}"
# Обеспечиваем уникальность
unique_sku = ensure_sku_unique(base_sku, exclude_id=product.id if product.id else None, model_type='product')
return unique_sku
def generate_kit_sku():
"""
Генерирует уникальный артикул для комплекта.
Формат: KIT-XXXXXX
Returns:
str: Сгенерированный артикул
"""
from products.models import SKUCounter
# Получаем следующий номер из глобального счетчика
next_number = SKUCounter.get_next_value('kit')
# Форматируем номер с ведущими нулями (6 цифр)
base_sku = f"KIT-{next_number:06d}"
# Обеспечиваем уникальность
unique_sku = ensure_sku_unique(base_sku, model_type='kit')
return unique_sku
def generate_category_sku():
"""
Генерирует уникальный артикул для категории.
Формат: CAT-XXXX (4 цифры)
Returns:
str: Сгенерированный артикул
"""
from products.models import SKUCounter
# Получаем следующий номер из глобального счетчика
next_number = SKUCounter.get_next_value('category')
# Форматируем номер с ведущими нулями (4 цифры)
base_sku = f"CAT-{next_number:04d}"
# Обеспечиваем уникальность
unique_sku = ensure_sku_unique(base_sku, model_type='category')
return unique_sku

View File

@@ -0,0 +1,83 @@
"""
Менеджер для работы с остатками товаров.
Это заглушка для будущей интеграции с системой складского учёта.
В будущем здесь будет реальная логика проверки остатков на складе.
"""
class StockManager:
"""
Менеджер для работы с остатками товаров (заглушка для будущей реализации).
В будущем этот класс будет интегрирован с реальной системой складского учёта,
чтобы проверять фактические остатки товаров на складе.
"""
def check_stock(self, product, quantity):
"""
Проверяет наличие товара в нужном количестве.
Args:
product: Экземпляр модели Product
quantity: Требуемое количество (Decimal)
Returns:
bool: True если товар доступен в нужном количестве, False иначе
TODO: Интегрировать с реальной системой складского учёта
"""
# Пока всегда возвращаем True (заглушка)
# В будущем здесь будет проверка реальных остатков
return True
def get_available_quantity(self, product):
"""
Возвращает доступное количество товара на складе.
Args:
product: Экземпляр модели Product
Returns:
Decimal: Доступное количество товара
TODO: Интегрировать с реальной системой складского учёта
"""
# Заглушка - возвращаем большое число
# В будущем здесь будет запрос к складской системе
from decimal import Decimal
return Decimal('9999')
def reserve_stock(self, product, quantity, order_id=None):
"""
Резервирует товар под заказ.
Args:
product: Экземпляр модели Product
quantity: Количество для резервирования (Decimal)
order_id: ID заказа (опционально)
Returns:
bool: True если резервирование успешно, False иначе
TODO: Интегрировать с реальной системой складского учёта
"""
# Заглушка
return True
def release_stock(self, product, quantity, order_id=None):
"""
Освобождает зарезервированный товар.
Args:
product: Экземпляр модели Product
quantity: Количество для освобождения (Decimal)
order_id: ID заказа (опционально)
Returns:
bool: True если освобождение успешно, False иначе
TODO: Интегрировать с реальной системой складского учёта
"""
# Заглушка
return True

1201
myproject/products/views.py Normal file

File diff suppressed because it is too large Load Diff