Рефакторинг системы вариативных товаров и справочник атрибутов
Основные изменения: - Переименование 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:
@@ -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="Значение атрибута"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user