""" Модели для комплектов (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 ConfigurableProduct(BaseProductEntity): """ Вариативный товар, объединяющий несколько ProductKit или Product как варианты для внешних площадок (WooCommerce и подобные). Примеры использования: - Роза Фридом с вариантами длины стебля (50, 60, 70 см) — варианты это Product - Букет "Нежность" с вариантами количества роз (15, 25, 51) — варианты это ProductKit """ class Meta: verbose_name = "Вариативный товар" verbose_name_plural = "Вариативные товары" # Уникальность активного имени наследуется из BaseProductEntity def __str__(self): return self.name def save(self, *args, **kwargs): """При сохранении - генерируем артикул если не задан""" # Генерация артикула для новых вариативных товаров if not self.sku: from ..utils.sku_generator import generate_configurable_sku self.sku = generate_configurable_sku() # Вызов родительского save (генерация slug и т.д.) super().save(*args, **kwargs) def delete(self, *args, **kwargs): """ Физическое удаление вариативного товара из БД. При удалении удаляются только связи (ConfigurableProductOption), но сами ProductKit/Product остаются нетронутыми благодаря CASCADE на уровне связей. """ # Используем super() для вызова стандартного Django delete, минуя BaseProductEntity.delete() super(BaseProductEntity, self).delete(*args, **kwargs) class ConfigurableProductAttribute(models.Model): """ Атрибут родительского вариативного товара с привязкой к ProductKit или Product. Каждое значение атрибута может быть связано с ProductKit или Product. Например: - Длина: 50 → Product (Роза 50см) - Длина: 60 → Product (Роза 60см) - Количество: 15 роз → ProductKit (Букет 15 роз) """ parent = models.ForeignKey( 'ConfigurableProduct', 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 XOR product) или оба пустые kit = models.ForeignKey( ProductKit, on_delete=models.CASCADE, related_name='as_attribute_value_in', verbose_name="Комплект для этого значения", help_text="Какой ProductKit связан с этим значением атрибута", 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="Порядок отображения", help_text="Меньше = выше в списке" ) visible = models.BooleanField( default=True, verbose_name="Видимый на витрине", help_text="Показывать ли атрибут на странице товара" ) class Meta: verbose_name = "Атрибут вариативного товара" verbose_name_plural = "Атрибуты вариативных товаров" ordering = ['parent', 'position', 'name', 'option'] indexes = [ models.Index(fields=['parent', 'name']), models.Index(fields=['parent', 'position']), models.Index(fields=['kit']), models.Index(fields=['product']), ] def __str__(self): 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 ConfigurableProductOption(models.Model): """ Отдельный вариант внутри ConfigurableProduct, указывающий на ProductKit ИЛИ Product. Атрибуты варианта хранятся в структурированном JSON формате. Пример: {"length": "60", "color": "red"} """ parent = models.ForeignKey( '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="Комплект (вариант)", 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, blank=True, verbose_name="Атрибуты варианта", help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}' ) is_default = models.BooleanField( default=False, verbose_name="Вариант по умолчанию" ) variant_sku = models.CharField( max_length=50, blank=True, verbose_name="Артикул варианта", help_text="Дополнительный артикул для внешних площадок. Генерируется автоматически." ) class Meta: verbose_name = "Вариант товара" verbose_name_plural = "Варианты товаров" indexes = [ models.Index(fields=['parent']), models.Index(fields=['kit']), models.Index(fields=['product']), models.Index(fields=['parent', 'is_default']), models.Index(fields=['variant_sku']), ] 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): 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}" def save(self, *args, **kwargs): """При создании - генерируем variant_sku если не задан""" if not self.variant_sku and self.parent_id: # Генерируем артикул варианта self.variant_sku = self._generate_variant_sku() super().save(*args, **kwargs) def _generate_variant_sku(self): """ Генерирует артикул варианта в формате {parent.sku}-V{counter}. Счетчик не переиспользуется при удалении вариантов (защита интеграций). """ import re # Получаем все варианты родителя с заполненным variant_sku existing_variants = ConfigurableProductOption.objects.filter( parent=self.parent, variant_sku__isnull=False ).exclude(pk=self.pk).values_list('variant_sku', flat=True) # Извлекаем номера из существующих variant_sku max_number = 0 for sku in existing_variants: # Ищем паттерн -V\d+ в конце строки match = re.search(r'-V(\d+)$', sku) if match: number = int(match.group(1)) max_number = max(max_number, number) # Следующий номер next_number = max_number + 1 # Формируем артикул return f"{self.parent.sku}-V{next_number}" @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_base_sku(self): """Основной SKU варианта (Product/ProductKit)""" 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 ConfigurableProductOptionAttribute(models.Model): """ Связь между вариантом (ConfigurableProductOption) и конкретным значением атрибута (ConfigurableProductAttribute). Вместо хранения текстового поля attributes в ConfigurableProductOption, мы создаем явные связи между вариантом и выбранными значениями атрибутов. Пример: - option: ConfigurableProductOption (вариант "15 роз 60см") - attribute: ConfigurableProductAttribute (Длина: 60) """ option = models.ForeignKey( 'ConfigurableProductOption', on_delete=models.CASCADE, related_name='attributes_set', verbose_name="Вариант" ) attribute = models.ForeignKey( 'ConfigurableProductAttribute', 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}"