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

Основные изменения:
- Переименование 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

@@ -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="Значение атрибута"
)