Добавлена автогенерация и валидация уникальности артикулов для всех типов товаров
- Добавлен миксин SKUUniqueMixin для единообразной валидации артикулов - Валидация проверяет уникальность SKU среди Product, ProductKit, ProductCategory, ConfigurableProduct - Реализована автогенерация артикулов для ConfigurableProduct (формат VAR-XXXXXX) - Добавлен новый тип счетчика 'configurable' в SKUCounter - Обновлены формы Product, ProductKit, ProductCategory, ConfigurableProduct - Рефакторинг методов clean() в формах: валидация имени вынесена в clean_name() - Добавлена функция generate_configurable_sku() в sku_generator.py - Обновлена функция ensure_sku_unique() для проверки ConfigurableProduct - Добавлен метод save() в модель ConfigurableProduct для автогенерации SKU - Обновлен шаблон configurableproduct_form.html с отображением help_text для SKU Код стал чистым, без дублирования логики валидации.
This commit is contained in:
@@ -8,7 +8,51 @@ from .models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProductForm(forms.ModelForm):
|
class SKUUniqueMixin:
|
||||||
|
"""
|
||||||
|
Миксин для валидации уникальности артикула среди всех моделей.
|
||||||
|
Проверяет уникальность SKU среди: Product, ProductKit, ProductCategory, ConfigurableProduct.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def clean_sku(self):
|
||||||
|
"""
|
||||||
|
Проверяет уникальность артикула среди всех моделей с артикулами.
|
||||||
|
Если SKU пустой - пропускаем (будет сгенерирован автоматически).
|
||||||
|
"""
|
||||||
|
sku = self.cleaned_data.get('sku')
|
||||||
|
|
||||||
|
# Пустое значение - ок, будет автогенерация
|
||||||
|
if not sku or sku.strip() == '':
|
||||||
|
return None
|
||||||
|
|
||||||
|
sku = sku.strip()
|
||||||
|
|
||||||
|
# Проверяем во всех моделях
|
||||||
|
models_to_check = [
|
||||||
|
(Product, 'товар'),
|
||||||
|
(ProductKit, 'комплект'),
|
||||||
|
(ProductCategory, 'категория'),
|
||||||
|
(ConfigurableProduct, 'вариативный товар'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for model, model_name in models_to_check:
|
||||||
|
queryset = model.objects.filter(sku=sku)
|
||||||
|
|
||||||
|
# Исключаем текущий объект при редактировании
|
||||||
|
if self.instance.pk and isinstance(self.instance, model):
|
||||||
|
queryset = queryset.exclude(pk=self.instance.pk)
|
||||||
|
|
||||||
|
if queryset.exists():
|
||||||
|
existing = queryset.first()
|
||||||
|
raise forms.ValidationError(
|
||||||
|
f'Артикул "{sku}" уже используется в {model_name} "{existing.name}". '
|
||||||
|
f'Пожалуйста, выберите другой артикул.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return sku
|
||||||
|
|
||||||
|
|
||||||
|
class ProductForm(SKUUniqueMixin, forms.ModelForm):
|
||||||
"""
|
"""
|
||||||
Форма для создания и редактирования товара.
|
Форма для создания и редактирования товара.
|
||||||
Поле photos НЕ включено в форму - файлы обрабатываются отдельно в view.
|
Поле photos НЕ включено в форму - файлы обрабатываются отдельно в view.
|
||||||
@@ -71,10 +115,9 @@ class ProductForm(forms.ModelForm):
|
|||||||
self.fields['unit'].widget.attrs.update({'class': 'form-control'})
|
self.fields['unit'].widget.attrs.update({'class': 'form-control'})
|
||||||
self.fields['status'].widget.attrs.update({'class': 'form-control'})
|
self.fields['status'].widget.attrs.update({'class': 'form-control'})
|
||||||
|
|
||||||
def clean(self):
|
def clean_name(self):
|
||||||
"""Валидация уникальности имени для активных товаров"""
|
"""Валидация уникальности имени для активных товаров"""
|
||||||
cleaned_data = super().clean()
|
name = self.cleaned_data.get('name')
|
||||||
name = cleaned_data.get('name')
|
|
||||||
|
|
||||||
if name:
|
if name:
|
||||||
# Проверяем уникальность имени среди активных товаров
|
# Проверяем уникальность имени среди активных товаров
|
||||||
@@ -88,15 +131,15 @@ class ProductForm(forms.ModelForm):
|
|||||||
existing = existing.exclude(pk=self.instance.pk)
|
existing = existing.exclude(pk=self.instance.pk)
|
||||||
|
|
||||||
if existing.exists():
|
if existing.exists():
|
||||||
self.add_error('name',
|
raise forms.ValidationError(
|
||||||
f'Товар с названием "{name}" уже существует. '
|
f'Товар с названием "{name}" уже существует. '
|
||||||
f'Пожалуйста, используйте другое название.'
|
f'Пожалуйста, используйте другое название.'
|
||||||
)
|
)
|
||||||
|
|
||||||
return cleaned_data
|
return name
|
||||||
|
|
||||||
|
|
||||||
class ProductKitForm(forms.ModelForm):
|
class ProductKitForm(SKUUniqueMixin, forms.ModelForm):
|
||||||
"""
|
"""
|
||||||
Форма для создания и редактирования комплекта.
|
Форма для создания и редактирования комплекта.
|
||||||
Цена комплекта вычисляется автоматически из цен компонентов.
|
Цена комплекта вычисляется автоматически из цен компонентов.
|
||||||
@@ -163,17 +206,10 @@ class ProductKitForm(forms.ModelForm):
|
|||||||
})
|
})
|
||||||
self.fields['status'].widget.attrs.update({'class': 'form-control'})
|
self.fields['status'].widget.attrs.update({'class': 'form-control'})
|
||||||
|
|
||||||
def clean(self):
|
def clean_name(self):
|
||||||
"""
|
"""Валидация уникальности имени для активных комплектов"""
|
||||||
Валидация формы комплекта.
|
name = self.cleaned_data.get('name')
|
||||||
Проверяет:
|
|
||||||
1. Уникальность имени для активных комплектов
|
|
||||||
2. Что если выбран тип корректировки, указано значение
|
|
||||||
"""
|
|
||||||
cleaned_data = super().clean()
|
|
||||||
|
|
||||||
# Проверяем уникальность имени среди активных комплектов
|
|
||||||
name = cleaned_data.get('name')
|
|
||||||
if name:
|
if name:
|
||||||
existing = ProductKit.objects.filter(
|
existing = ProductKit.objects.filter(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -185,11 +221,17 @@ class ProductKitForm(forms.ModelForm):
|
|||||||
existing = existing.exclude(pk=self.instance.pk)
|
existing = existing.exclude(pk=self.instance.pk)
|
||||||
|
|
||||||
if existing.exists():
|
if existing.exists():
|
||||||
self.add_error('name',
|
raise forms.ValidationError(
|
||||||
f'Комплект с названием "{name}" уже существует. '
|
f'Комплект с названием "{name}" уже существует. '
|
||||||
f'Пожалуйста, используйте другое название.'
|
f'Пожалуйста, используйте другое название.'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Дополнительная валидация: если выбран тип корректировки, указано значение"""
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
adjustment_type = cleaned_data.get('price_adjustment_type')
|
adjustment_type = cleaned_data.get('price_adjustment_type')
|
||||||
adjustment_value = cleaned_data.get('price_adjustment_value')
|
adjustment_value = cleaned_data.get('price_adjustment_value')
|
||||||
|
|
||||||
@@ -323,7 +365,7 @@ KitItemFormSetUpdate = inlineformset_factory(
|
|||||||
KitItemFormSet = KitItemFormSetCreate
|
KitItemFormSet = KitItemFormSetCreate
|
||||||
|
|
||||||
|
|
||||||
class ProductCategoryForm(forms.ModelForm):
|
class ProductCategoryForm(SKUUniqueMixin, forms.ModelForm):
|
||||||
"""
|
"""
|
||||||
Форма для создания и редактирования категории товаров.
|
Форма для создания и редактирования категории товаров.
|
||||||
Поле photos НЕ включено в форму - файлы обрабатываются отдельно в view.
|
Поле photos НЕ включено в форму - файлы обрабатываются отдельно в view.
|
||||||
@@ -380,10 +422,9 @@ class ProductCategoryForm(forms.ModelForm):
|
|||||||
is_active=True
|
is_active=True
|
||||||
).exclude(pk__in=exclude_ids)
|
).exclude(pk__in=exclude_ids)
|
||||||
|
|
||||||
def clean(self):
|
def clean_name(self):
|
||||||
"""Валидация уникальности имени для активных категорий"""
|
"""Валидация уникальности имени для активных категорий"""
|
||||||
cleaned_data = super().clean()
|
name = self.cleaned_data.get('name')
|
||||||
name = cleaned_data.get('name')
|
|
||||||
|
|
||||||
if name:
|
if name:
|
||||||
# Проверяем уникальность имени среди активных категорий
|
# Проверяем уникальность имени среди активных категорий
|
||||||
@@ -396,12 +437,12 @@ class ProductCategoryForm(forms.ModelForm):
|
|||||||
existing = existing.exclude(pk=self.instance.pk)
|
existing = existing.exclude(pk=self.instance.pk)
|
||||||
|
|
||||||
if existing.exists():
|
if existing.exists():
|
||||||
self.add_error('name',
|
raise forms.ValidationError(
|
||||||
f'Категория с названием "{name}" уже существует. '
|
f'Категория с названием "{name}" уже существует. '
|
||||||
f'Пожалуйста, используйте другое название.'
|
f'Пожалуйста, используйте другое название.'
|
||||||
)
|
)
|
||||||
|
|
||||||
return cleaned_data
|
return name
|
||||||
|
|
||||||
def clean_slug(self):
|
def clean_slug(self):
|
||||||
"""Преобразуем пустую строку в None для автогенерации slug"""
|
"""Преобразуем пустую строку в None для автогенерации slug"""
|
||||||
@@ -584,7 +625,7 @@ class ProductTagForm(forms.ModelForm):
|
|||||||
|
|
||||||
# ==================== CONFIGURABLE KIT FORMS ====================
|
# ==================== CONFIGURABLE KIT FORMS ====================
|
||||||
|
|
||||||
class ConfigurableProductForm(forms.ModelForm):
|
class ConfigurableProductForm(SKUUniqueMixin, forms.ModelForm):
|
||||||
"""
|
"""
|
||||||
Форма для создания и редактирования вариативного товара.
|
Форма для создания и редактирования вариативного товара.
|
||||||
"""
|
"""
|
||||||
@@ -598,6 +639,9 @@ class ConfigurableProductForm(forms.ModelForm):
|
|||||||
'short_description': 'Краткое описание',
|
'short_description': 'Краткое описание',
|
||||||
'status': 'Статус'
|
'status': 'Статус'
|
||||||
}
|
}
|
||||||
|
help_texts = {
|
||||||
|
'sku': 'Оставьте пустым для автогенерации в формате VAR-XXXXXX',
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -607,8 +651,9 @@ class ConfigurableProductForm(forms.ModelForm):
|
|||||||
})
|
})
|
||||||
self.fields['sku'].widget.attrs.update({
|
self.fields['sku'].widget.attrs.update({
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'placeholder': 'Артикул (необязательно)'
|
'placeholder': 'VAR-XXXXXX (автогенерация)'
|
||||||
})
|
})
|
||||||
|
self.fields['sku'].required = False
|
||||||
self.fields['description'].widget.attrs.update({
|
self.fields['description'].widget.attrs.update({
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'rows': 5
|
'rows': 5
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2025-12-30 07:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='skucounter',
|
||||||
|
name='counter_type',
|
||||||
|
field=models.CharField(choices=[('product', 'Product Counter'), ('kit', 'Kit Counter'), ('category', 'Category Counter'), ('configurable', 'Configurable Product Counter')], max_length=20, unique=True, verbose_name='Тип счетчика'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -22,6 +22,7 @@ class SKUCounter(models.Model):
|
|||||||
('product', 'Product Counter'),
|
('product', 'Product Counter'),
|
||||||
('kit', 'Kit Counter'),
|
('kit', 'Kit Counter'),
|
||||||
('category', 'Category Counter'),
|
('category', 'Category Counter'),
|
||||||
|
('configurable', 'Configurable Product Counter'),
|
||||||
]
|
]
|
||||||
|
|
||||||
counter_type = models.CharField(
|
counter_type = models.CharField(
|
||||||
|
|||||||
@@ -452,6 +452,16 @@ class ConfigurableProduct(BaseProductEntity):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""При сохранении - генерируем артикул если не задан"""
|
||||||
|
# Генерация артикула для новых вариативных товаров
|
||||||
|
if not self.sku:
|
||||||
|
from ..utils.sku_generator import generate_configurable_sku
|
||||||
|
self.sku = generate_configurable_sku()
|
||||||
|
|
||||||
|
# Вызов родительского save (генерация slug и т.д.)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Физическое удаление вариативного товара из БД.
|
Физическое удаление вариативного товара из БД.
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ input[name*="DELETE"] {
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.sku.id_for_label }}" class="form-label">Артикул</label>
|
<label for="{{ form.sku.id_for_label }}" class="form-label">Артикул</label>
|
||||||
{{ form.sku }}
|
{{ form.sku }}
|
||||||
|
<div class="form-text">{{ form.fields.sku.help_text }}</div>
|
||||||
{% if form.sku.errors %}
|
{% if form.sku.errors %}
|
||||||
<div class="text-danger small">{{ form.sku.errors.0 }}</div>
|
<div class="text-danger small">{{ form.sku.errors.0 }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ New SKU format:
|
|||||||
- Products: PROD-XXXXXX or PROD-XXXXXX-VARIANT
|
- Products: PROD-XXXXXX or PROD-XXXXXX-VARIANT
|
||||||
- Kits: KIT-XXXXXX
|
- Kits: KIT-XXXXXX
|
||||||
- Categories: CAT-XXXX
|
- Categories: CAT-XXXX
|
||||||
|
- Configurable Products: VAR-XXXXXX
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- PROD-000001
|
- PROD-000001
|
||||||
- PROD-000002-50
|
- PROD-000002-50
|
||||||
- KIT-000001
|
- KIT-000001
|
||||||
- CAT-0001
|
- CAT-0001
|
||||||
|
- VAR-000001
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
from string import ascii_uppercase
|
from string import ascii_uppercase
|
||||||
@@ -71,13 +73,13 @@ def ensure_sku_unique(base_sku, exclude_id=None, model_type=None):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_sku (str): Базовый артикул для проверки
|
base_sku (str): Базовый артикул для проверки
|
||||||
exclude_id (int): ID товара/комплекта/категории, который нужно исключить из проверки
|
exclude_id (int): ID товара/комплекта/категории/вариативного товара, который нужно исключить из проверки
|
||||||
model_type (str): Тип модели ('product', 'kit', 'category') для исключения из проверки
|
model_type (str): Тип модели ('product', 'kit', 'category', 'configurable') для исключения из проверки
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Уникальный артикул
|
str: Уникальный артикул
|
||||||
"""
|
"""
|
||||||
from products.models import Product, ProductKit, ProductCategory
|
from products.models import Product, ProductKit, ProductCategory, ConfigurableProduct
|
||||||
|
|
||||||
# Проверяем, существует ли базовый артикул
|
# Проверяем, существует ли базовый артикул
|
||||||
sku = base_sku
|
sku = base_sku
|
||||||
@@ -99,7 +101,12 @@ def ensure_sku_unique(base_sku, exclude_id=None, model_type=None):
|
|||||||
category_exists = category_exists.exclude(id=exclude_id)
|
category_exists = category_exists.exclude(id=exclude_id)
|
||||||
category_exists = category_exists.exists()
|
category_exists = category_exists.exists()
|
||||||
|
|
||||||
return product_exists or kit_exists or category_exists
|
configurable_exists = ConfigurableProduct.objects.filter(sku=sku_to_check)
|
||||||
|
if model_type == 'configurable' and exclude_id:
|
||||||
|
configurable_exists = configurable_exists.exclude(id=exclude_id)
|
||||||
|
configurable_exists = configurable_exists.exists()
|
||||||
|
|
||||||
|
return product_exists or kit_exists or category_exists or configurable_exists
|
||||||
|
|
||||||
# Если базовый артикул свободен, возвращаем его
|
# Если базовый артикул свободен, возвращаем его
|
||||||
if not sku_exists(sku):
|
if not sku_exists(sku):
|
||||||
@@ -210,3 +217,26 @@ def generate_category_sku():
|
|||||||
unique_sku = ensure_sku_unique(base_sku, model_type='category')
|
unique_sku = ensure_sku_unique(base_sku, model_type='category')
|
||||||
|
|
||||||
return unique_sku
|
return unique_sku
|
||||||
|
|
||||||
|
|
||||||
|
def generate_configurable_sku():
|
||||||
|
"""
|
||||||
|
Генерирует уникальный артикул для вариативного товара.
|
||||||
|
|
||||||
|
Формат: VAR-XXXXXX
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Сгенерированный артикул
|
||||||
|
"""
|
||||||
|
from products.models import SKUCounter
|
||||||
|
|
||||||
|
# Получаем следующий номер из глобального счетчика
|
||||||
|
next_number = SKUCounter.get_next_value('configurable')
|
||||||
|
|
||||||
|
# Форматируем номер с ведущими нулями (6 цифр)
|
||||||
|
base_sku = f"VAR-{next_number:06d}"
|
||||||
|
|
||||||
|
# Обеспечиваем уникальность
|
||||||
|
unique_sku = ensure_sku_unique(base_sku, model_type='configurable')
|
||||||
|
|
||||||
|
return unique_sku
|
||||||
|
|||||||
Reference in New Issue
Block a user