diff --git a/myproject/products/forms.py b/myproject/products/forms.py index 652ee77..6506ea4 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -146,9 +146,12 @@ class KitItemForm(forms.ModelForm): cleaned_data = super().clean() product = cleaned_data.get('product') variant_group = cleaned_data.get('variant_group') + quantity = cleaned_data.get('quantity') # Если оба поля пусты - это пустая форма (не валидируем, она будет удалена) if not product and not variant_group: + # Для пустых форм обнуляем количество + cleaned_data['quantity'] = None return cleaned_data # Валидация: должен быть указан либо product, либо variant_group (но не оба) @@ -157,14 +160,56 @@ class KitItemForm(forms.ModelForm): "Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно." ) + # Валидация: если выбран товар/группа, количество обязательно и должно быть > 0 + if (product or variant_group): + if not quantity or quantity <= 0: + raise forms.ValidationError('Необходимо указать количество больше 0') + return cleaned_data +# Кастомный базовый формсет с валидацией на дубликаты +class BaseKitItemFormSet(forms.BaseInlineFormSet): + def clean(self): + """Проверка на дубликаты товаров в комплекте""" + if any(self.errors): + # Не проверяем дубликаты если есть другие ошибки + return + + products = [] + variant_groups = [] + + for form in self.forms: + if self.can_delete and self._should_delete_form(form): + continue + + product = form.cleaned_data.get('product') + variant_group = form.cleaned_data.get('variant_group') + + # Проверка дубликатов товаров + if product: + if product in products: + raise forms.ValidationError( + f'Товар "{product.name}" добавлен в комплект более одного раза. ' + f'Каждый товар может быть добавлен только один раз.' + ) + products.append(product) + + # Проверка дубликатов групп вариантов + if variant_group: + if variant_group in variant_groups: + raise forms.ValidationError( + f'Группа вариантов "{variant_group.name}" добавлена более одного раза. ' + f'Каждая группа может быть добавлена только один раз.' + ) + variant_groups.append(variant_group) + # Формсет для создания комплектов (с пустой формой для удобства) KitItemFormSetCreate = inlineformset_factory( ProductKit, KitItem, form=KitItemForm, + formset=BaseKitItemFormSet, fields=['id', 'product', 'variant_group', 'quantity', 'notes'], extra=1, # Показать 1 пустую форму для первого компонента can_delete=True, # Разрешить удаление компонентов @@ -178,6 +223,7 @@ KitItemFormSetUpdate = inlineformset_factory( ProductKit, KitItem, form=KitItemForm, + formset=BaseKitItemFormSet, fields=['id', 'product', 'variant_group', 'quantity', 'notes'], extra=0, # НЕ показывать пустые формы при редактировании can_delete=True, # Разрешить удаление компонентов diff --git a/myproject/products/migrations/0001_initial.py b/myproject/products/migrations/0001_initial.py index db0366d..559b274 100644 --- a/myproject/products/migrations/0001_initial.py +++ b/myproject/products/migrations/0001_initial.py @@ -1,6 +1,7 @@ -# Generated by Django 5.2.7 on 2025-10-22 13:03 +# Generated by Django 5.2.7 on 2025-10-23 20:27 import django.db.models.deletion +from django.conf import settings from django.db import migrations, models @@ -9,21 +10,10 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] 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=[ @@ -59,6 +49,11 @@ class Migration(migrations.Migration): ('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, unique=True, verbose_name='Артикул')), ('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')), ('is_active', models.BooleanField(default=True, verbose_name='Активна')), + ('created_at', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления')), + ('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удалена')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Время удаления')), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_categories', to=settings.AUTH_USER_MODEL, 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={ @@ -72,6 +67,7 @@ class Migration(migrations.Migration): ('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='Артикул')), + ('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')), ('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='Единица измерения')), @@ -81,9 +77,10 @@ class Migration(migrations.Migration): ('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='Ключевые слова для поиска')), + ('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удален')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Время удаления')), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_products', to=settings.AUTH_USER_MODEL, 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': 'Товар', @@ -120,8 +117,10 @@ class Migration(migrations.Migration): ('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='Дата обновления')), + ('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удален')), + ('deleted_at', models.DateTimeField(blank=True, null=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='Теги')), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_kits', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')), ], options={ 'verbose_name': 'Комплект', @@ -158,11 +157,43 @@ class Migration(migrations.Migration): 'ordering': ['order', '-created_at'], }, ), + 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-идентификатор')), + ('created_at', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления')), + ('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удален')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Время удаления')), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_tags', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')), + ], + options={ + 'verbose_name': 'Тег товара', + 'verbose_name_plural': 'Теги товаров', + }, + ), + migrations.AddField( + model_name='productkit', + name='tags', + field=models.ManyToManyField(blank=True, related_name='kits', to='products.producttag', verbose_name='Теги'), + ), + migrations.AddField( + model_name='product', + name='tags', + field=models.ManyToManyField(blank=True, related_name='products', to='products.producttag', verbose_name='Теги'), + ), + migrations.AddField( + model_name='product', + name='variant_groups', + field=models.ManyToManyField(blank=True, related_name='products', to='products.productvariantgroup', verbose_name='Группы вариантов'), + ), 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='Количество')), + ('quantity', models.DecimalField(blank=True, decimal_places=3, max_digits=10, null=True, 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='Комплект')), @@ -192,6 +223,22 @@ class Migration(migrations.Migration): model_name='productcategory', index=models.Index(fields=['is_active'], name='products_pr_is_acti_226e74_idx'), ), + migrations.AddIndex( + model_name='productcategory', + index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_2a96d1_idx'), + ), + migrations.AddIndex( + model_name='productcategory', + index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_b8cdf3_idx'), + ), + migrations.AddIndex( + model_name='producttag', + index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_ea9be0_idx'), + ), + migrations.AddIndex( + model_name='producttag', + index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_bc2d9c_idx'), + ), migrations.AddIndex( model_name='productkit', index=models.Index(fields=['is_active'], name='products_pr_is_acti_214d4f_idx'), @@ -200,8 +247,24 @@ class Migration(migrations.Migration): model_name='productkit', index=models.Index(fields=['slug'], name='products_pr_slug_b5e185_idx'), ), + migrations.AddIndex( + model_name='productkit', + index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_e83a83_idx'), + ), + migrations.AddIndex( + model_name='productkit', + index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_1e5bec_idx'), + ), migrations.AddIndex( model_name='product', index=models.Index(fields=['is_active'], name='products_pr_is_acti_ca4d9a_idx'), ), + migrations.AddIndex( + model_name='product', + index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_3bba04_idx'), + ), + migrations.AddIndex( + model_name='product', + index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_f30efb_idx'), + ), ] diff --git a/myproject/products/models.py b/myproject/products/models.py index 0ac036e..9d3c01b 100644 --- a/myproject/products/models.py +++ b/myproject/products/models.py @@ -653,7 +653,7 @@ class KitItem(models.Model): related_name='kit_items', verbose_name="Группа вариантов" ) - quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество") + quantity = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True, verbose_name="Количество") notes = models.CharField( max_length=200, blank=True, diff --git a/myproject/products/templates/products/productkit_create.html b/myproject/products/templates/products/productkit_create.html index 86ce25e..e5d7b24 100644 --- a/myproject/products/templates/products/productkit_create.html +++ b/myproject/products/templates/products/productkit_create.html @@ -3,259 +3,486 @@ {% block title %}Создать комплект{% endblock %} {% block content %} -
- -