Files
octopus/myproject/products/models/kits.py
Andrey Smakotin 48938db04f Implement M2M architecture for ConfigurableKitProduct variants with dynamic attribute selection
This commit introduces a complete refactoring of the variable product system:

1. **New Model**: ConfigurableKitOptionAttribute - M2M relationship between variants and attribute values
   - Replaces TextField-based attribute storage with proper database relationships
   - Ensures one value per attribute per variant through unique_together constraint
   - Includes indexes on both option and attribute fields for query performance

2. **Form Refactoring**:
   - Removed static 'attributes' field from ConfigurableKitOptionForm
   - Added dynamic field generation in __init__ based on parent attributes
   - Creates ModelChoiceField for each attribute (e.g., attribute_Длина, attribute_Упаковка)
   - Enhanced BaseConfigurableKitOptionFormSet validation to check all attributes are filled

3. **View Updates**:
   - Modified ConfigurableKitProductCreateView.form_valid() to save M2M relationships
   - Modified ConfigurableKitProductUpdateView.form_valid() with same logic
   - Uses transaction.atomic() for data consistency

4. **Template & JS Enhancements**:
   - Reordered form so attributes section appears before variants
   - Fixed template syntax: changed from field.name.startswith to "attribute_" in field.name
   - Updated JavaScript to dynamically generate attribute select fields when adding variants
   - Properly handles formset naming convention (options-{idx}-attribute_{name})

5. **Database Migrations**:
   - Created migration 0005 to alter ConfigurableKitOption.attributes to JSONField (for future use)
   - Created migration 0006 to add ConfigurableKitOptionAttribute model

6. **Tests**:
   - Added test_configurable_simple.py for model/form verification
   - Added test_workflow.py for complete end-to-end testing
   - All tests passing successfully

Features:
✓ All attributes required for each variant if defined on parent
✓ One value per attribute per variant (unique_together constraint)
✓ One default variant per product (formset validation)
✓ Dynamic form field generation based on parent attributes
✓ Atomic transactions for multi-part operations
✓ Proper error messages per variant number

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 20:04:22 +03:00

534 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Модели для комплектов (ProductKit) и их компонентов.
Цена комплекта динамически вычисляется из actual_price компонентов.
"""
from decimal import Decimal
from django.db import models
from django.db.models import Q
from django.utils import timezone
from django.core.exceptions import ValidationError
from .base import BaseProductEntity
from .categories import ProductCategory, ProductTag
from .variants import ProductVariantGroup
from .products import Product
from ..utils.sku_generator import generate_kit_sku
from ..services.kit_availability import KitAvailabilityChecker
class ProductKit(BaseProductEntity):
"""
Шаблон комплекта / букета (рецепт).
Наследует общие поля из BaseProductEntity.
Цена комплекта = сумма actual_price всех компонентов + корректировка.
Корректировка может быть увеличением или уменьшением на % или фиксированную сумму.
"""
ADJUSTMENT_TYPE_CHOICES = [
('none', 'Без изменения'),
('increase_percent', 'Увеличить на %'),
('increase_amount', 'Увеличить на сумму'),
('decrease_percent', 'Уменьшить на %'),
('decrease_amount', 'Уменьшить на сумму'),
]
# Categories and Tags
categories = models.ManyToManyField(
ProductCategory,
blank=True,
related_name='kits',
verbose_name="Категории"
)
tags = models.ManyToManyField(
ProductTag,
blank=True,
related_name='kits',
verbose_name="Теги"
)
# ЦЕНООБРАЗОВАНИЕ - новый подход
base_price = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Базовая цена",
help_text="Сумма actual_price всех компонентов. Пересчитывается автоматически."
)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Итоговая цена",
help_text="Базовая цена с учетом корректировок. Вычисляется автоматически."
)
sale_price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name="Цена со скидкой",
help_text="Если задана, комплект продается по этой цене"
)
price_adjustment_type = models.CharField(
max_length=20,
choices=ADJUSTMENT_TYPE_CHOICES,
default='none',
verbose_name="Тип корректировки цены"
)
price_adjustment_value = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Значение корректировки",
help_text="Процент (%) или сумма (руб) в зависимости от типа корректировки"
)
# Временные комплекты
is_temporary = models.BooleanField(
default=False,
verbose_name="Временный комплект",
help_text="Временные комплекты не показываются в каталоге и создаются для конкретного заказа"
)
order = models.ForeignKey(
'orders.Order',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='temporary_kits',
verbose_name="Заказ",
help_text="Заказ, для которого создан временный комплект"
)
class Meta:
verbose_name = "Комплект"
verbose_name_plural = "Комплекты"
indexes = [
models.Index(fields=['is_temporary']),
models.Index(fields=['order']),
]
constraints = [
# Уникальное имя для активных комплектов (исключаем архивированные и снятые)
# Примечание: временные комплекты могут иметь дубли имён (создаются для заказов)
models.UniqueConstraint(
fields=['name'],
condition=Q(status='active', is_temporary=False),
name='unique_active_kit_name'
),
]
@property
def actual_price(self):
"""
Финальная цена для продажи.
Приоритет: sale_price > price (рассчитанная)
"""
if self.sale_price:
return self.sale_price
return self.price
def recalculate_base_price(self):
"""
Пересчитать сумму actual_price всех компонентов.
Вызывается автоматически при изменении цены товара (через signal).
"""
if not self.pk:
return # Новый объект еще не сохранен
total = Decimal('0')
for item in self.kit_items.all():
if item.product:
actual_price = item.product.actual_price or Decimal('0')
qty = item.quantity or Decimal('1')
total += actual_price * qty
elif item.variant_group:
actual_price = item.variant_group.price or Decimal('0')
qty = item.quantity or Decimal('1')
total += actual_price * qty
self.base_price = total
# Обновляем финальную цену
self.price = self.calculate_final_price()
self.save(update_fields=['base_price', 'price'])
def calculate_final_price(self):
"""
Вычислить финальную цену с учетом корректировок.
Returns:
Decimal: Итоговая цена комплекта
"""
if self.price_adjustment_type == 'none':
return self.base_price
adjustment_value = self.price_adjustment_value or Decimal('0')
if 'percent' in self.price_adjustment_type:
adjustment = self.base_price * adjustment_value / Decimal('100')
else: # 'amount'
adjustment = adjustment_value
if 'increase' in self.price_adjustment_type:
return self.base_price + adjustment
else: # 'decrease'
return max(Decimal('0'), self.base_price - adjustment)
def save(self, *args, **kwargs):
"""При сохранении - пересчитываем финальную цену"""
# Генерация артикула для новых комплектов
if not self.sku:
self.sku = generate_kit_sku()
# Если объект уже существует и имеет компоненты, пересчитываем base_price
if self.pk and self.kit_items.exists():
# Пересчитаем базовую цену из компонентов
total = Decimal('0')
for item in self.kit_items.all():
if item.product:
actual_price = item.product.actual_price or Decimal('0')
qty = item.quantity or Decimal('1')
total += actual_price * qty
elif item.variant_group:
actual_price = item.variant_group.price or Decimal('0')
qty = item.quantity or Decimal('1')
total += actual_price * qty
self.base_price = total
# Устанавливаем финальную цену в поле price
self.price = self.calculate_final_price()
# Вызов родительского save (генерация slug и т.д.)
super().save(*args, **kwargs)
def get_total_components_count(self):
"""Возвращает количество компонентов (строк) в комплекте"""
return self.kit_items.count()
def get_components_with_variants_count(self):
"""Возвращает количество компонентов, которые используют группы вариантов"""
return self.kit_items.filter(variant_group__isnull=False).count()
def check_availability(self, stock_manager=None):
"""
Проверяет доступность всего комплекта.
Делегирует проверку в сервис.
"""
return KitAvailabilityChecker.check_availability(self, stock_manager)
def make_permanent(self):
"""
Преобразует временный комплект в постоянный.
Отвязывает от заказа и делает видимым в каталоге.
Returns:
bool: True если преобразование успешно, False если комплект уже постоянный
"""
if not self.is_temporary:
return False
self.is_temporary = False
self.order = None
self.save(update_fields=['is_temporary', 'order'])
return True
def delete(self, *args, **kwargs):
"""Soft delete вместо hard delete - марк как удаленный"""
self.is_deleted = True
self.deleted_at = timezone.now()
self.save(update_fields=['is_deleted', 'deleted_at'])
return 1, {self.__class__._meta.label: 1}
def hard_delete(self):
"""Полное удаление из БД (необратимо!)"""
super().delete()
class KitItem(models.Model):
"""
Состав комплекта: связь между ProductKit и Product или ProductVariantGroup.
Позиция может быть либо конкретным товаром, либо группой вариантов.
"""
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
verbose_name="Комплект")
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='kit_items_direct',
verbose_name="Конкретный товар"
)
variant_group = models.ForeignKey(
ProductVariantGroup,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='kit_items',
verbose_name="Группа вариантов"
)
quantity = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True, verbose_name="Количество")
class Meta:
verbose_name = "Компонент комплекта"
verbose_name_plural = "Компоненты комплектов"
indexes = [
models.Index(fields=['kit']),
models.Index(fields=['product']),
models.Index(fields=['variant_group']),
models.Index(fields=['kit', 'product']),
models.Index(fields=['kit', 'variant_group']),
]
def __str__(self):
return f"{self.kit.name} - {self.get_display_name()}"
def clean(self):
"""Валидация: должен быть указан либо product, либо variant_group (но не оба)"""
if self.product and self.variant_group:
raise ValidationError(
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
)
if not self.product and not self.variant_group:
raise ValidationError(
"Необходимо указать либо товар, либо группу вариантов."
)
def get_display_name(self):
"""Возвращает строку для отображения названия компонента"""
if self.variant_group:
return f"[Варианты] {self.variant_group.name}"
return self.product.name if self.product else "Не указан"
def has_priorities_set(self):
"""Проверяет, настроены ли приоритеты замены для данного компонента"""
return self.priorities.exists()
def get_available_products(self):
"""
Возвращает список доступных товаров для этого компонента.
Если указан конкретный товар - возвращает его.
Если указаны приоритеты - возвращает товары в порядке приоритета.
Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
"""
if self.product:
# Если указан конкретный товар, возвращаем только его
return [self.product]
if self.variant_group:
# Если есть настроенные приоритеты, используем их
if self.has_priorities_set():
return [
priority.product
for priority in self.priorities.select_related('product').order_by('priority', 'id')
]
# Иначе возвращаем все товары из группы
return list(self.variant_group.products.filter(is_active=True))
return []
def get_best_available_product(self, stock_manager=None):
"""
Возвращает первый доступный товар по приоритету, соответствующий требованиям по количеству.
"""
from ..utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
available_products = self.get_available_products()
for product in available_products:
if stock_manager.check_stock(product, self.quantity):
return product
return None
class KitItemPriority(models.Model):
"""
Приоритеты товаров для конкретной позиции букета.
Позволяет настроить индивидуальные приоритеты замен для каждого букета.
"""
kit_item = models.ForeignKey(
KitItem,
on_delete=models.CASCADE,
related_name='priorities',
verbose_name="Позиция в букете"
)
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
verbose_name="Товар"
)
priority = models.PositiveIntegerField(
default=0,
help_text="Меньше = выше приоритет (0 - наивысший)"
)
class Meta:
verbose_name = "Приоритет варианта"
verbose_name_plural = "Приоритеты вариантов"
ordering = ['priority', 'id']
unique_together = ['kit_item', 'product']
def __str__(self):
return f"{self.product.name} (приоритет {self.priority})"
class ConfigurableKitProduct(BaseProductEntity):
"""
Вариативный товар, объединяющий несколько наших ProductKit
как варианты для внешних площадок (WooCommerce и подобные).
"""
class Meta:
verbose_name = "Вариативный товар (из комплектов)"
verbose_name_plural = "Вариативные товары (из комплектов)"
# Уникальность активного имени наследуется из BaseProductEntity
def __str__(self):
return self.name
def delete(self, *args, **kwargs):
"""
Физическое удаление вариативного товара из БД.
При удалении удаляются только связи (ConfigurableKitOption),
но сами ProductKit остаются нетронутыми благодаря CASCADE на уровне связей.
"""
# Используем super() для вызова стандартного Django delete, минуя BaseProductEntity.delete()
super(BaseProductEntity, self).delete(*args, **kwargs)
class ConfigurableKitProductAttribute(models.Model):
"""
Атрибут родительского вариативного товара.
Определяет схему атрибутов для экспорта на WooCommerce и подобные площадки.
Например: name="Цвет", option="Красный" или name="Размер", option="M".
"""
parent = models.ForeignKey(
ConfigurableKitProduct,
on_delete=models.CASCADE,
related_name='parent_attributes',
verbose_name="Родительский товар"
)
name = models.CharField(
max_length=150,
verbose_name="Название атрибута",
help_text="Например: Цвет, Размер, Длина"
)
option = models.CharField(
max_length=150,
verbose_name="Значение опции",
help_text="Например: Красный, M, 60см"
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Порядок отображения",
help_text="Меньше = выше в списке"
)
visible = models.BooleanField(
default=True,
verbose_name="Видимый на витрине",
help_text="Показывать ли атрибут на странице товара"
)
class Meta:
verbose_name = "Атрибут вариативного товара"
verbose_name_plural = "Атрибуты вариативных товаров"
ordering = ['parent', 'position', 'name', 'option']
unique_together = [['parent', 'name', 'option']]
indexes = [
models.Index(fields=['parent', 'name']),
models.Index(fields=['parent', 'position']),
]
def __str__(self):
return f"{self.parent.name} - {self.name}: {self.option}"
class ConfigurableKitOption(models.Model):
"""
Отдельный вариант внутри ConfigurableKitProduct, указывающий на конкретный ProductKit.
Атрибуты варианта хранятся в структурированном JSON формате.
Пример: {"length": "60", "color": "red"}
"""
parent = models.ForeignKey(
ConfigurableKitProduct,
on_delete=models.CASCADE,
related_name='options',
verbose_name="Родитель (вариативный товар)"
)
kit = models.ForeignKey(
ProductKit,
on_delete=models.CASCADE,
related_name='as_configurable_option_in',
verbose_name="Комплект (вариант)"
)
attributes = models.JSONField(
default=dict,
blank=True,
verbose_name="Атрибуты варианта",
help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}'
)
is_default = models.BooleanField(
default=False,
verbose_name="Вариант по умолчанию"
)
class Meta:
verbose_name = "Вариант комплекта"
verbose_name_plural = "Варианты комплектов"
unique_together = [['parent', 'kit']]
indexes = [
models.Index(fields=['parent']),
models.Index(fields=['kit']),
models.Index(fields=['parent', 'is_default']),
]
def __str__(self):
return f"{self.parent.name}{self.kit.name}"
class ConfigurableKitOptionAttribute(models.Model):
"""
Связь между вариантом (ConfigurableKitOption) и
конкретным значением атрибута (ConfigurableKitProductAttribute).
Вместо хранения текстового поля attributes в ConfigurableKitOption,
мы создаем явные связи между вариантом и выбранными значениями атрибутов.
Пример:
- option: ConfigurableKitOption (вариант "15 роз 60см")
- attribute: ConfigurableKitProductAttribute (Длина: 60)
"""
option = models.ForeignKey(
ConfigurableKitOption,
on_delete=models.CASCADE,
related_name='attributes_set',
verbose_name="Вариант"
)
attribute = models.ForeignKey(
ConfigurableKitProductAttribute,
on_delete=models.CASCADE,
verbose_name="Значение атрибута"
)
class Meta:
verbose_name = "Атрибут варианта"
verbose_name_plural = "Атрибуты варианта"
# Одна опция не может использовать два разных значения одного атрибута
# Например: нельзя иметь Длина=60 и Длина=70 одновременно
# Уникальность будет проверяться на уровне формы
unique_together = [['option', 'attribute']]
indexes = [
models.Index(fields=['option']),
models.Index(fields=['attribute']),
]
def __str__(self):
return f"{self.option.parent.name}{self.attribute.name}: {self.attribute.option}"