Добавлена автогенерация и валидация уникальности артикулов для всех типов товаров

- Добавлен миксин 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:
2025-12-30 10:47:03 +03:00
parent a95bd56b2b
commit 577401447b
6 changed files with 135 additions and 30 deletions

View File

@@ -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

View File

@@ -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='Тип счетчика'),
),
]

View File

@@ -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(

View File

@@ -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):
""" """
Физическое удаление вариативного товара из БД. Физическое удаление вариативного товара из БД.

View File

@@ -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 %}

View File

@@ -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