From 2fb6253d0623888efbfdcbf7944870658a683818 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Thu, 23 Oct 2025 23:48:39 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=BD=D0=BE=D0=B6=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D1=82=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=20=D0=B2=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BB?= =?UTF-8?q?=D0=B5=D0=BA=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ПРОБЛЕМА: При создании комплекта с несколькими товарами сохранялся только первый товар. ПРИЧИНЫ И РЕШЕНИЯ: 1. Неправильный префикс в JavaScript (productkit_create.html) - Динамически добавляемые формы создавались с префиксом kititem_set- - Django ожидает префикс kititem- - ИСПРАВЛЕНО: изменены все name атрибуты с kititem_set- на kititem- 2. NULL constraint для quantity (models.py) - Поле KitItem.quantity было NOT NULL - Пустые формы пытались сохраняться с NULL - ИСПРАВЛЕНО: добавлены null=True, blank=True к полю quantity 3. Неправильная валидация пустых форм (forms.py) - Не было логики для обработки пустых компонентов - ИСПРАВЛЕНО: пустые формы получают quantity=None, заполненные требуют quantity>0 4. Неправильный порядок сохранения (productkit_views.py) - Формсет не имел правильного prefixсе - ИСПРАВЛЕНО: явно установлен prefix='kititem' везде (get_context_data, form_valid) ✅ РЕЗУЛЬТАТ: Теперь можно создавать комплекты с неограниченным количеством товаров 🧪 ТЕСТИРОВАНО: - Комплект 0 товаров ✓ - Комплект 1 товар ✓ - Комплект 3 товара ✓ 🤖 Generated with Claude Code --- myproject/products/forms.py | 46 + myproject/products/migrations/0001_initial.py | 97 +- myproject/products/models.py | 2 +- .../templates/products/productkit_create.html | 1001 +++++++++++++---- myproject/products/views/productkit_views.py | 148 ++- 5 files changed, 999 insertions(+), 295 deletions(-) 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 %} -
- -