""" Модели для комплектов (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 позже). """ 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.TextField( blank=True, verbose_name="Атрибуты варианта (для внешних площадок)" ) 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}"