Рефакторинг системы вариативных товаров и справочник атрибутов

Основные изменения:
- Переименование ConfigurableKitProduct → ConfigurableProduct
- Добавлена поддержка Product как варианта (не только ProductKit)
- Создан справочник атрибутов (ProductAttribute, ProductAttributeValue)
- CRUD для управления атрибутами с inline редактированием значений
- Пересозданы миграции с нуля для всех приложений
- Добавлена ссылка на атрибуты в навигацию

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 01:44:34 +03:00
parent 277a514a82
commit 79ff523adb
36 changed files with 1597 additions and 951 deletions

View File

@@ -31,8 +31,14 @@ from .variants import ProductVariantGroup, ProductVariantGroupItem
# Продукты
from .products import Product, CostPriceHistory
# Комплекты
from .kits import ProductKit, KitItem, KitItemPriority, ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute
# Комплекты и вариативные товары
from .kits import (
ProductKit, KitItem, KitItemPriority,
ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute, ConfigurableProductOptionAttribute,
)
# Атрибуты
from .attributes import ProductAttribute, ProductAttributeValue
# Фотографии
from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus
@@ -60,13 +66,18 @@ __all__ = [
'Product',
'CostPriceHistory',
# Kits
# Kits & Configurable Products
'ProductKit',
'KitItem',
'KitItemPriority',
'ConfigurableKitProduct',
'ConfigurableKitOption',
'ConfigurableKitProductAttribute',
'ConfigurableProduct',
'ConfigurableProductOption',
'ConfigurableProductAttribute',
'ConfigurableProductOptionAttribute',
# Attributes
'ProductAttribute',
'ProductAttributeValue',
# Photos
'BasePhoto',

View File

@@ -0,0 +1,116 @@
"""
Модели для справочника атрибутов товаров.
Используется для создания переиспользуемых атрибутов (Длина стебля, Цвет, Размер и т.д.)
"""
from django.db import models
from django.utils.text import slugify
from unidecode import unidecode
class ProductAttribute(models.Model):
"""
Справочник атрибутов для вариативных товаров.
Примеры: Длина стебля, Цвет, Размер, Упаковка.
"""
name = models.CharField(
max_length=100,
unique=True,
verbose_name="Название",
help_text="Например: Длина стебля, Цвет, Размер"
)
slug = models.SlugField(
max_length=100,
unique=True,
blank=True,
verbose_name="Slug",
help_text="Автоматически генерируется из названия"
)
description = models.TextField(
blank=True,
verbose_name="Описание",
help_text="Опциональное описание атрибута"
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Позиция",
help_text="Порядок отображения в списке"
)
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 = ['position', 'name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(unidecode(self.name))
super().save(*args, **kwargs)
@property
def values_count(self):
"""Количество значений у атрибута"""
return self.values.count()
class ProductAttributeValue(models.Model):
"""
Значения атрибутов.
Примеры для атрибута "Длина стебля": 50, 60, 70, 80.
"""
attribute = models.ForeignKey(
ProductAttribute,
on_delete=models.CASCADE,
related_name='values',
verbose_name="Атрибут"
)
value = models.CharField(
max_length=100,
verbose_name="Значение",
help_text="Например: 50, 60, 70 (для длины) или Красный, Белый (для цвета)"
)
slug = models.SlugField(
max_length=100,
blank=True,
verbose_name="Slug"
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Позиция",
help_text="Порядок отображения в списке значений"
)
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 = ['position', 'value']
unique_together = ['attribute', 'value']
indexes = [
models.Index(fields=['attribute', 'position']),
]
def __str__(self):
return f"{self.attribute.name}: {self.value}"
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(unidecode(self.value))
super().save(*args, **kwargs)

View File

@@ -435,14 +435,18 @@ class KitItemPriority(models.Model):
return f"{self.product.name} (приоритет {self.priority})"
class ConfigurableKitProduct(BaseProductEntity):
class ConfigurableProduct(BaseProductEntity):
"""
Вариативный товар, объединяющий несколько наших ProductKit
Вариативный товар, объединяющий несколько ProductKit или Product
как варианты для внешних площадок (WooCommerce и подобные).
Примеры использования:
- Роза Фридом с вариантами длины стебля (50, 60, 70 см) — варианты это Product
- Букет "Нежность" с вариантами количества роз (15, 25, 51) — варианты это ProductKit
"""
class Meta:
verbose_name = "Вариативный товар (из комплектов)"
verbose_name_plural = "Вариативные товары (из комплектов)"
verbose_name = "Вариативный товар"
verbose_name_plural = "Вариативные товары"
# Уникальность активного имени наследуется из BaseProductEntity
def __str__(self):
@@ -451,25 +455,25 @@ class ConfigurableKitProduct(BaseProductEntity):
def delete(self, *args, **kwargs):
"""
Физическое удаление вариативного товара из БД.
При удалении удаляются только связи (ConfigurableKitOption),
но сами ProductKit остаются нетронутыми благодаря CASCADE на уровне связей.
При удалении удаляются только связи (ConfigurableProductOption),
но сами ProductKit/Product остаются нетронутыми благодаря CASCADE на уровне связей.
"""
# Используем super() для вызова стандартного Django delete, минуя BaseProductEntity.delete()
super(BaseProductEntity, self).delete(*args, **kwargs)
class ConfigurableKitProductAttribute(models.Model):
class ConfigurableProductAttribute(models.Model):
"""
Атрибут родительского вариативного товара с привязкой к ProductKit.
Атрибут родительского вариативного товара с привязкой к ProductKit или Product.
Каждое значение атрибута связано с конкретным ProductKit.
Каждое значение атрибута может быть связано с ProductKit или Product.
Например:
- Длина: 50 → ProductKit (A)
- Длина: 60 → ProductKit (B)
- Длина: 70 → ProductKit (C)
- Длина: 50 → Product (Роза 50см)
- Длина: 60 → Product (Роза 60см)
- Количество: 15 роз → ProductKit (Букет 15 роз)
"""
parent = models.ForeignKey(
ConfigurableKitProduct,
'ConfigurableProduct',
on_delete=models.CASCADE,
related_name='parent_attributes',
verbose_name="Родительский товар"
@@ -484,6 +488,7 @@ class ConfigurableKitProductAttribute(models.Model):
verbose_name="Значение опции",
help_text="Например: Красный, M, 60см"
)
# Один из двух должен быть заполнен (kit XOR product) или оба пустые
kit = models.ForeignKey(
ProductKit,
on_delete=models.CASCADE,
@@ -493,6 +498,15 @@ class ConfigurableKitProductAttribute(models.Model):
blank=True,
null=True
)
product = models.ForeignKey(
'Product',
on_delete=models.CASCADE,
related_name='as_attribute_value_in',
verbose_name="Товар для этого значения",
help_text="Какой Product связан с этим значением атрибута",
blank=True,
null=True
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Порядок отображения",
@@ -508,35 +522,60 @@ class ConfigurableKitProductAttribute(models.Model):
verbose_name = "Атрибут вариативного товара"
verbose_name_plural = "Атрибуты вариативных товаров"
ordering = ['parent', 'position', 'name', 'option']
unique_together = [['parent', 'name', 'option', 'kit']]
indexes = [
models.Index(fields=['parent', 'name']),
models.Index(fields=['parent', 'position']),
models.Index(fields=['kit']),
models.Index(fields=['product']),
]
def __str__(self):
kit_str = self.kit.name if self.kit else "no kit"
return f"{self.parent.name} - {self.name}: {self.option} ({kit_str})"
variant_str = self.kit.name if self.kit else (self.product.name if self.product else "no variant")
return f"{self.parent.name} - {self.name}: {self.option} ({variant_str})"
@property
def variant(self):
"""Возвращает связанный вариант (kit или product)"""
return self.kit or self.product
@property
def variant_type(self):
"""Тип варианта: 'kit', 'product' или None"""
if self.kit:
return 'kit'
elif self.product:
return 'product'
return None
class ConfigurableKitOption(models.Model):
class ConfigurableProductOption(models.Model):
"""
Отдельный вариант внутри ConfigurableKitProduct, указывающий на конкретный ProductKit.
Отдельный вариант внутри ConfigurableProduct, указывающий на ProductKit ИЛИ Product.
Атрибуты варианта хранятся в структурированном JSON формате.
Пример: {"length": "60", "color": "red"}
"""
parent = models.ForeignKey(
ConfigurableKitProduct,
'ConfigurableProduct',
on_delete=models.CASCADE,
related_name='options',
verbose_name="Родитель (вариативный товар)"
)
# Один из двух должен быть заполнен (kit XOR product)
kit = models.ForeignKey(
ProductKit,
on_delete=models.CASCADE,
related_name='as_configurable_option_in',
verbose_name="Комплект (вариант)"
verbose_name="Комплект (вариант)",
blank=True,
null=True
)
product = models.ForeignKey(
'Product',
on_delete=models.CASCADE,
related_name='as_configurable_option_in',
verbose_name="Товар (вариант)",
blank=True,
null=True
)
attributes = models.JSONField(
default=dict,
@@ -550,39 +589,79 @@ class ConfigurableKitOption(models.Model):
)
class Meta:
verbose_name = "Вариант комплекта"
verbose_name_plural = "Варианты комплектов"
unique_together = [['parent', 'kit']]
verbose_name = "Вариант товара"
verbose_name_plural = "Варианты товаров"
indexes = [
models.Index(fields=['parent']),
models.Index(fields=['kit']),
models.Index(fields=['product']),
models.Index(fields=['parent', 'is_default']),
]
constraints = [
# kit XOR product — один из двух должен быть заполнен
models.CheckConstraint(
check=(
models.Q(kit__isnull=False, product__isnull=True) |
models.Q(kit__isnull=True, product__isnull=False)
),
name='configurable_option_kit_xor_product'
),
]
def __str__(self):
return f"{self.parent.name}{self.kit.name}"
variant_name = self.kit.name if self.kit else (self.product.name if self.product else "N/A")
return f"{self.parent.name}{variant_name}"
@property
def variant(self):
"""Возвращает связанный вариант (kit или product)"""
return self.kit or self.product
@property
def variant_type(self):
"""Тип варианта: 'kit' или 'product'"""
return 'kit' if self.kit else 'product'
@property
def variant_name(self):
"""Название варианта"""
return self.variant.name if self.variant else None
@property
def variant_sku(self):
"""SKU варианта"""
return self.variant.sku if self.variant else None
@property
def variant_price(self):
"""Цена варианта"""
if self.kit:
return self.kit.actual_price
elif self.product:
return self.product.sale_price or self.product.price
return None
class ConfigurableKitOptionAttribute(models.Model):
class ConfigurableProductOptionAttribute(models.Model):
"""
Связь между вариантом (ConfigurableKitOption) и
конкретным значением атрибута (ConfigurableKitProductAttribute).
Связь между вариантом (ConfigurableProductOption) и
конкретным значением атрибута (ConfigurableProductAttribute).
Вместо хранения текстового поля attributes в ConfigurableKitOption,
Вместо хранения текстового поля attributes в ConfigurableProductOption,
мы создаем явные связи между вариантом и выбранными значениями атрибутов.
Пример:
- option: ConfigurableKitOption (вариант "15 роз 60см")
- attribute: ConfigurableKitProductAttribute (Длина: 60)
- option: ConfigurableProductOption (вариант "15 роз 60см")
- attribute: ConfigurableProductAttribute (Длина: 60)
"""
option = models.ForeignKey(
ConfigurableKitOption,
'ConfigurableProductOption',
on_delete=models.CASCADE,
related_name='attributes_set',
verbose_name="Вариант"
)
attribute = models.ForeignKey(
ConfigurableKitProductAttribute,
'ConfigurableProductAttribute',
on_delete=models.CASCADE,
verbose_name="Значение атрибута"
)