""" Модели для комплектов (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 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 @property def main_photo(self): """ Главное фото комплекта (is_main=True). Используется в карточках, каталоге, превью. Returns: ProductKitPhoto | None: Главное фото или None если фото нет """ return self.photos.filter(is_main=True).first() def recalculate_base_price(self): """ Пересчитать сумму actual_price всех компонентов. Вызывается автоматически при изменении цены товара (через signal). """ if not self.pk: return # Новый объект еще не сохранен total = Decimal('0') for item in self.kit_items.all(): qty = item.quantity or Decimal('1') if item.sales_unit: # Для sales_unit используем цену единицы продажи unit_price = item.sales_unit.actual_price or Decimal('0') total += unit_price * qty elif item.product: # Используем зафиксированную цену если есть, иначе актуальную цену товара if item.unit_price is not None: unit_price = item.unit_price else: unit_price = item.product.actual_price or Decimal('0') total += unit_price * qty elif item.variant_group: # Для variant_group unit_price не используется (только для продуктов) actual_price = item.variant_group.price or Decimal('0') 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.sales_unit: actual_price = item.sales_unit.actual_price or Decimal('0') qty = item.quantity or Decimal('1') total += actual_price * qty elif 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): """ Проверяет доступность всего комплекта (возвращает True/False). Для обратной совместимости. Использует calculate_available_quantity(). """ return self.calculate_available_quantity() > 0 def calculate_available_quantity(self, warehouse=None): """ Рассчитывает максимальное количество комплектов, которое можно собрать на основе свободных остатков компонентов на складе. Args: warehouse: Склад для проверки остатков. Если None, суммируются остатки по всем складам. Returns: Decimal: Максимальное количество комплектов (0 если хоть один компонент недоступен) """ from inventory.models import Stock if not self.kit_items.exists(): return Decimal('0') min_available = None for kit_item in self.kit_items.select_related('product', 'variant_group'): # Определяем товар для проверки product = None if kit_item.product: product = kit_item.product elif kit_item.variant_group: # Берём первый активный товар из группы вариантов available_products = kit_item.get_available_products() product = available_products[0] if available_products else None if not product: # Если товар не найден - комплект недоступен return Decimal('0') # Получаем остатки на складе stock_filter = {'product': product} if warehouse: stock_filter['warehouse'] = warehouse stocks = Stock.objects.filter(**stock_filter) # Суммируем свободное количество (available - reserved) total_free = Decimal('0') for stock in stocks: free_qty = stock.quantity_available - stock.quantity_reserved total_free += free_qty # Вычисляем сколько комплектов можно собрать из этого компонента component_quantity = kit_item.quantity or Decimal('1') if component_quantity <= 0: return Decimal('0') kits_from_this_component = total_free / component_quantity # Ищем минимум (узкое место) if min_available is None or kits_from_this_component < min_available: min_available = kits_from_this_component # Возвращаем целую часть (нельзя собрать половину комплекта) # Нельзя собрать отрицательное количество комплектов if min_available is not None: if min_available <= 0: return Decimal('0') return Decimal(int(min_available)) return Decimal('0') 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 или ProductSalesUnit. Позиция может быть либо конкретным товаром, либо группой вариантов, либо конкретной единицей продажи. """ 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="Группа вариантов" ) sales_unit = models.ForeignKey( 'ProductSalesUnit', 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="Количество") unit_price = models.DecimalField( max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Цена за единицу (зафиксированная)", help_text="Если задана, используется эта цена вместо актуальной цены товара. Применяется для временных витринных комплектов." ) 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): """Валидация: должна быть указана группа вариантов ИЛИ (товар [плюс опционально единица продажи])""" has_variant = bool(self.variant_group) has_product = bool(self.product) has_sales_unit = bool(self.sales_unit) # 1. Проверка на пустоту if not (has_variant or has_product or has_sales_unit): raise ValidationError( "Необходимо указать либо товар, либо группу вариантов." ) # 2. Несовместимость: Группа вариантов VS Товар/Единица if has_variant and (has_product or has_sales_unit): raise ValidationError( "Нельзя указывать группу вариантов одновременно с товаром или единицей продажи." ) # 3. Зависимость: Если есть sales_unit, должен быть product if has_sales_unit and not has_product: raise ValidationError( "Если указана единица продажи, должен быть выбран соответствующий товар." ) # 4. Проверка принадлежности if has_sales_unit and has_product and self.sales_unit.product != self.product: raise ValidationError( "Выбранная единица продажи не принадлежит указанному товару." ) def get_display_name(self): """Возвращает строку для отображения названия компонента""" # Приоритет: сначала единица продажи, затем товар, затем группа вариантов if self.sales_unit: return f"[Единица продажи] {self.sales_unit.name}" elif self.product: return self.product.name elif self.variant_group: return f"[Варианты] {self.variant_group.name}" return "Не указан" def has_priorities_set(self): """Проверяет, настроены ли приоритеты замены для данного компонента""" return self.priorities.exists() def get_available_products(self): """ Возвращает список доступных товаров для этого компонента. Если указана единица продажи - возвращает товар, к которому она относится. Если указан конкретный товар - возвращает его. Если указаны приоритеты - возвращает товары в порядке приоритета. Если не указаны приоритеты - возвращает все активные товары из группы вариантов. """ # Приоритет: сначала единица продажи, затем товар, затем группа вариантов if self.sales_unit: # Если указана единица продажи, возвращаем товар, к которому она относится return [self.sales_unit.product] 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}"