""" Модели для комплектов (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="Заказ, для которого создан временный комплект" ) showcase = models.ForeignKey( 'inventory.Showcase', 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']), models.Index(fields=['showcase']), ] 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() def create_snapshot(self): """ Создает снимок текущего состояния комплекта. Используется при добавлении комплекта в заказ для сохранения истории. Returns: KitSnapshot: Созданный снимок с компонентами """ from orders.models import KitSnapshot, KitItemSnapshot # Создаем снимок комплекта snapshot = KitSnapshot.objects.create( original_kit=self, name=self.name, sku=self.sku or '', description=self.description or '', base_price=self.base_price, price=self.price, sale_price=self.sale_price, price_adjustment_type=self.price_adjustment_type, price_adjustment_value=self.price_adjustment_value, is_temporary=self.is_temporary, ) # Создаем снимки компонентов for item in self.kit_items.select_related('product', 'variant_group'): product_price = Decimal('0') if item.product: product_price = item.product.actual_price or Decimal('0') elif item.variant_group: product_price = item.variant_group.price or Decimal('0') KitItemSnapshot.objects.create( kit_snapshot=snapshot, original_product=item.product, # Сохраняем ссылку для резервирования product_name=item.product.name if item.product else '', product_sku=item.product.sku if item.product else '', product_price=product_price, variant_group_name=item.variant_group.name if item.variant_group else '', quantity=item.quantity or Decimal('1'), ) return snapshot 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(status='active')) 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): """ Атрибут родительского вариативного товара с привязкой к ProductKit. Каждое значение атрибута связано с конкретным ProductKit. Например: - Длина: 50 → ProductKit (A) - Длина: 60 → ProductKit (B) - Длина: 70 → ProductKit (C) """ 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см" ) kit = models.ForeignKey( ProductKit, on_delete=models.CASCADE, related_name='as_attribute_value_in', verbose_name="Комплект для этого значения", help_text="Какой ProductKit связан с этим значением атрибута", blank=True, null=True ) 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', 'kit']] indexes = [ models.Index(fields=['parent', 'name']), models.Index(fields=['parent', 'position']), models.Index(fields=['kit']), ] 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})" 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}"