from django.db import models from django.urls import reverse from django.utils.text import slugify from django.core.exceptions import ValidationError from django.db import transaction from django.utils import timezone from django.contrib.auth import get_user_model from .utils.sku_generator import generate_product_sku, generate_kit_sku, generate_category_sku # Получаем User модель один раз для использования в ForeignKey User = get_user_model() class SKUCounter(models.Model): """ Глобальные счетчики для генерации уникальных номеров артикулов. Используется для товаров (product), комплектов (kit) и категорий (category). """ COUNTER_TYPE_CHOICES = [ ('product', 'Product Counter'), ('kit', 'Kit Counter'), ('category', 'Category Counter'), ] counter_type = models.CharField( max_length=20, unique=True, choices=COUNTER_TYPE_CHOICES, verbose_name="Тип счетчика" ) current_value = models.IntegerField( default=0, verbose_name="Текущее значение" ) class Meta: verbose_name = "Счетчик артикулов" verbose_name_plural = "Счетчики артикулов" def __str__(self): return f"{self.get_counter_type_display()}: {self.current_value}" @classmethod def get_next_value(cls, counter_type): """ Получить следующее значение счетчика (thread-safe). Использует select_for_update для предотвращения race conditions. """ with transaction.atomic(): counter, created = cls.objects.select_for_update().get_or_create( counter_type=counter_type, defaults={'current_value': 0} ) counter.current_value += 1 counter.save() return counter.current_value class ActiveManager(models.Manager): def get_queryset(self): return super().get_queryset().filter(is_active=True) class SoftDeleteQuerySet(models.QuerySet): """ QuerySet для мягкого удаления (soft delete). Позволяет фильтровать удаленные элементы и восстанавливать их. """ def delete(self): """Soft delete вместо hard delete""" return self.update( is_deleted=True, deleted_at=timezone.now() ) def hard_delete(self): """Явный hard delete - удаляет из БД окончательно""" return super().delete() def restore(self): """Восстановление из удаленного состояния""" return self.update( is_deleted=False, deleted_at=None, deleted_by=None ) def deleted_only(self): """Получить только удаленные элементы""" return self.filter(is_deleted=True) def not_deleted(self): """Получить только не удаленные элементы""" return self.filter(is_deleted=False) def with_deleted(self): """Получить все элементы включая удаленные""" return self.all() class SoftDeleteManager(models.Manager): """ Manager для работы с мягким удалением. По умолчанию исключает удаленные элементы из запросов. """ def get_queryset(self): return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False) def deleted_only(self): """Получить только удаленные элементы""" return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=True) def all_with_deleted(self): """Получить все элементы включая удаленные""" return SoftDeleteQuerySet(self.model, using=self._db).all() class ProductCategory(models.Model): """ Категории товаров и комплектов (поддержка нескольких уровней не обязательна, но возможна позже). """ name = models.CharField(max_length=200, verbose_name="Название") sku = models.CharField(max_length=100, blank=True, null=True, unique=True, verbose_name="Артикул", db_index=True) slug = models.SlugField(max_length=200, unique=True, blank=True, verbose_name="URL-идентификатор") parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="Родительская категория") is_active = models.BooleanField(default=True, verbose_name="Активна") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True) updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True) # Поля для мягкого удаления is_deleted = models.BooleanField(default=False, verbose_name="Удалена", db_index=True) deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления") deleted_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='deleted_categories', verbose_name="Удалена пользователем" ) objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные) all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные) active = ActiveManager() # Кастомный менеджер для активных категорий class Meta: verbose_name = "Категория товара" verbose_name_plural = "Категории товаров" indexes = [ models.Index(fields=['is_active']), models.Index(fields=['is_deleted']), models.Index(fields=['is_deleted', 'created_at']), ] def __str__(self): return self.name def clean(self): """Валидация категории перед сохранением""" from django.core.exceptions import ValidationError # 1. Защита от самоссылки if self.parent and self.parent.pk == self.pk: raise ValidationError({ 'parent': 'Категория не может быть родителем самой себя.' }) # 2. Защита от циклических ссылок (только для существующих категорий) if self.parent and self.pk: self._check_parent_chain() # 3. Проверка активности родителя if self.parent and not self.parent.is_active: raise ValidationError({ 'parent': 'Нельзя выбрать неактивную категорию в качестве родителя.' }) def _check_parent_chain(self): """Проверяет цепочку родителей на циклы и глубину вложенности""" from django.core.exceptions import ValidationError from django.conf import settings current = self.parent depth = 0 max_depth = getattr(settings, 'MAX_CATEGORY_DEPTH', 10) while current: if current.pk == self.pk: raise ValidationError({ 'parent': f'Обнаружена циклическая ссылка. ' f'Категория "{self.name}" не может быть потомком самой себя.' }) depth += 1 if depth > max_depth: raise ValidationError({ 'parent': f'Слишком глубокая вложенность категорий ' f'(максимум {max_depth} уровней).' }) current = current.parent def save(self, *args, **kwargs): # Вызываем валидацию перед сохранением self.full_clean() # Автоматическая генерация slug из названия с транслитерацией if not self.slug or self.slug.strip() == '': from unidecode import unidecode # Транслитерируем кириллицу в латиницу, затем применяем slugify transliterated_name = unidecode(self.name) self.slug = slugify(transliterated_name) # Автоматическая генерация артикула при создании новой категории if not self.sku and not self.pk: from .utils.sku_generator import generate_category_sku self.sku = generate_category_sku() super().save(*args, **kwargs) 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']) # Возвращаем результат в формате Django return 1, {self.__class__._meta.label: 1} def hard_delete(self): """Полное удаление из БД (необратимо!)""" super().delete() class ProductTag(models.Model): """ Свободные теги для фильтрации и поиска. """ name = models.CharField(max_length=100, unique=True, verbose_name="Название") slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True) updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True) # Поля для мягкого удаления is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True) deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления") deleted_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='deleted_tags', verbose_name="Удален пользователем" ) objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные) all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные) class Meta: verbose_name = "Тег товара" verbose_name_plural = "Теги товаров" indexes = [ models.Index(fields=['is_deleted']), models.Index(fields=['is_deleted', 'created_at']), ] def __str__(self): return self.name def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) 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 ProductVariantGroup(models.Model): """ Группа вариантов товара (взаимозаменяемые товары). Например: "Роза красная Freedom" включает розы 50см, 60см, 70см. """ name = models.CharField(max_length=200, verbose_name="Название") description = models.TextField(blank=True, verbose_name="Описание") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") class Meta: verbose_name = "Группа вариантов" verbose_name_plural = "Группы вариантов" ordering = ['name'] def __str__(self): return self.name def get_products_count(self): """Возвращает количество товаров в группе""" return self.items.count() @property def in_stock(self): """ Вариант в наличии, если хотя бы один из его товаров в наличии. Товар в наличии, если Product.in_stock = True. """ return self.items.filter(product__in_stock=True).exists() @property def price(self): """ Цена варианта определяется по приоритету товаров: 1. Берётся цена товара с приоритетом 1, если он в наличии 2. Если нет - цена товара с приоритетом 2 3. И так далее по приоритетам 4. Если ни один товар не в наличии - берётся самый дорогой товар из группы Возвращает Decimal (цену) или None если группа пуста. """ items = self.items.all().order_by('priority', 'id') if not items.exists(): return None # Ищем первый товар в наличии for item in items: if item.product.in_stock: return item.product.sale_price # Если ни один товар не в наличии - берем самый дорогой max_price = None for item in items: if max_price is None or item.product.sale_price > max_price: max_price = item.product.sale_price return max_price class Product(models.Model): """ Базовый товар (цветок, упаковка, аксессуар). """ UNIT_CHOICES = [ ('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм'), ] name = models.CharField(max_length=200, verbose_name="Название") sku = models.CharField(max_length=100, blank=True, null=True, verbose_name="Артикул", db_index=True) slug = models.SlugField(max_length=200, unique=True, blank=True, verbose_name="URL-идентификатор") variant_suffix = models.CharField( max_length=20, blank=True, null=True, verbose_name="Суффикс варианта", help_text="Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия." ) description = models.TextField(blank=True, null=True, verbose_name="Описание") categories = models.ManyToManyField( ProductCategory, blank=True, related_name='products', verbose_name="Категории" ) tags = models.ManyToManyField(ProductTag, blank=True, related_name='products', verbose_name="Теги") variant_groups = models.ManyToManyField( ProductVariantGroup, blank=True, related_name='products', verbose_name="Группы вариантов" ) unit = models.CharField(max_length=10, choices=UNIT_CHOICES, default='шт', verbose_name="Единица измерения") cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Себестоимость") sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Розничная цена") is_active = models.BooleanField(default=True, verbose_name="Активен") in_stock = models.BooleanField(default=False, verbose_name="В наличии", db_index=True, help_text="Автоматически обновляется при изменении остатков на складе") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") # Поле для улучшенного поиска (задел на будущее) search_keywords = models.TextField( blank=True, verbose_name="Ключевые слова для поиска", help_text="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную." ) # Поля для мягкого удаления is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True) deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления") deleted_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='deleted_products', verbose_name="Удален пользователем" ) objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные) all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные) active = ActiveManager() # Кастомный менеджер для активных товаров class Meta: verbose_name = "Товар" verbose_name_plural = "Товары" indexes = [ models.Index(fields=['is_active']), models.Index(fields=['is_deleted']), models.Index(fields=['is_deleted', 'created_at']), models.Index(fields=['in_stock']), ] def __str__(self): return self.name def save(self, *args, **kwargs): # Автоматическое извлечение variant_suffix из названия # (только если не задан вручную и товар еще не сохранен с суффиксом) if not self.variant_suffix and self.name: from .utils.sku_generator import parse_variant_suffix parsed_suffix = parse_variant_suffix(self.name) if parsed_suffix: self.variant_suffix = parsed_suffix # Генерация артикула для новых товаров if not self.sku: self.sku = generate_product_sku(self) # Автоматическая генерация slug из названия с транслитерацией if not self.slug or self.slug.strip() == '': from unidecode import unidecode # Транслитерируем кириллицу в латиницу, затем применяем slugify transliterated_name = unidecode(self.name) self.slug = slugify(transliterated_name) # Убеждаемся, что slug уникален original_slug = self.slug counter = 1 while Product.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): self.slug = f"{original_slug}-{counter}" counter += 1 # Автоматическая генерация ключевых слов для поиска # Собираем все релевантные данные в одну строку keywords_parts = [ self.name or '', self.sku or '', self.description or '', ] # Генерируем строку для поиска (только если поле пустое) # Это позволит администратору добавлять кастомные ключевые слова вручную if not self.search_keywords: self.search_keywords = ' '.join(filter(None, keywords_parts)) super().save(*args, **kwargs) # Добавляем названия категорий в search_keywords после сохранения # (ManyToMany требует, чтобы объект уже существовал в БД) if self.pk and self.categories.exists(): category_names = ' '.join([cat.name for cat in self.categories.all()]) if category_names and category_names not in self.search_keywords: self.search_keywords = f"{self.search_keywords} {category_names}".strip() # Используем update чтобы избежать рекурсии Product.objects.filter(pk=self.pk).update(search_keywords=self.search_keywords) 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']) # Возвращаем результат в формате Django return 1, {self.__class__._meta.label: 1} def hard_delete(self): """Полное удаление из БД (необратимо!)""" super().delete() def get_variant_groups(self): """Возвращает все группы вариантов товара""" return self.variant_groups.all() def get_similar_products(self): """Возвращает все товары из тех же групп вариантов (исключая себя)""" return Product.objects.filter( variant_groups__in=self.variant_groups.all() ).exclude(id=self.id).distinct() class ProductKit(models.Model): """ Шаблон комплекта / букета (рецепт). """ PRICING_METHOD_CHOICES = [ ('fixed', 'Фиксированная цена'), ('from_sale_prices', 'По ценам продажи компонентов'), ('from_cost_plus_percent', 'Себестоимость + процент наценки'), ('from_cost_plus_amount', 'Себестоимость + фикс. наценка'), ] name = models.CharField(max_length=200, verbose_name="Название") sku = models.CharField(max_length=100, blank=True, null=True, verbose_name="Артикул") slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-идентификатор") description = models.TextField(blank=True, null=True, verbose_name="Описание") categories = models.ManyToManyField( ProductCategory, blank=True, related_name='kits', verbose_name="Категории" ) tags = models.ManyToManyField(ProductTag, blank=True, related_name='kits', verbose_name="Теги") is_active = models.BooleanField(default=True, verbose_name="Активен") pricing_method = models.CharField(max_length=30, choices=PRICING_METHOD_CHOICES, default='from_sale_prices', verbose_name="Метод ценообразования") fixed_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Фиксированная цена") markup_percent = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True, verbose_name="Процент наценки") markup_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Фиксированная наценка") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") # Поля для мягкого удаления is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True) deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления") deleted_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='deleted_kits', verbose_name="Удален пользователем" ) objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные) all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные) active = ActiveManager() # Кастомный менеджер для активных комплектов class Meta: verbose_name = "Комплект" verbose_name_plural = "Комплекты" indexes = [ models.Index(fields=['is_active']), models.Index(fields=['slug']), models.Index(fields=['is_deleted']), models.Index(fields=['is_deleted', 'created_at']), ] def __str__(self): return self.name def clean(self): """Валидация комплекта перед сохранением""" # Проверка соответствия метода ценообразования полям if self.pricing_method == 'fixed' and not self.fixed_price: raise ValidationError({ 'fixed_price': 'Для метода ценообразования "fixed" необходимо указать фиксированную цену.' }) if self.pricing_method == 'from_cost_plus_percent' and ( self.markup_percent is None or self.markup_percent < 0 ): raise ValidationError({ 'markup_percent': 'Для метода ценообразования "from_cost_plus_percent" необходимо указать процент наценки >= 0.' }) if self.pricing_method == 'from_cost_plus_amount' and ( self.markup_amount is None or self.markup_amount < 0 ): raise ValidationError({ 'markup_amount': 'Для метода ценообразования "from_cost_plus_amount" необходимо указать сумму наценки >= 0.' }) # Проверка уникальности SKU (если задан) if self.sku: # Проверяем, что SKU не используется другим комплектом (если объект уже существует) if self.pk: if ProductKit.objects.filter(sku=self.sku).exclude(pk=self.pk).exists(): raise ValidationError({ 'sku': f'Артикул "{self.sku}" уже используется другим комплектом.' }) else: # Для новых объектов просто проверяем, что SKU не используется if ProductKit.objects.filter(sku=self.sku).exists(): raise ValidationError({ 'sku': f'Артикул "{self.sku}" уже используется другим комплектом.' }) def save(self, *args, **kwargs): if not self.slug: from unidecode import unidecode # Транслитерируем кириллицу в латиницу, затем применяем slugify transliterated_name = unidecode(self.name) self.slug = slugify(transliterated_name) # Убеждаемся, что slug уникален original_slug = self.slug counter = 1 while ProductKit.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): self.slug = f"{original_slug}-{counter}" counter += 1 if not self.sku: self.sku = generate_kit_sku() super().save(*args, **kwargs) def get_total_components_count(self): """ Возвращает количество компонентов (строк) в комплекте. Returns: int: Количество компонентов в комплекте """ return self.kit_items.count() def get_components_with_variants_count(self): """ Возвращает количество компонентов, которые используют группы вариантов. Returns: int: Количество компонентов с группами вариантов """ return self.kit_items.filter(variant_group__isnull=False).count() def get_sale_price(self): """ Возвращает рассчитанную цену продажи комплекта в соответствии с выбранным методом ценообразования. Returns: Decimal: Цена продажи комплекта """ try: return self.calculate_price_with_substitutions() except Exception: # Если что-то пошло не так, возвращаем фиксированную цену если есть if self.pricing_method == 'fixed' and self.fixed_price: return self.fixed_price return 0 def check_availability(self, stock_manager=None): """ Проверяет доступность всего комплекта. Комплект доступен, если для каждой позиции в комплекте есть хотя бы один доступный вариант товара. Args: stock_manager: Объект управления складом (если не указан, используется стандартный) Returns: bool: True, если комплект полностью доступен, иначе False """ from .utils.stock_manager import StockManager if stock_manager is None: stock_manager = StockManager() for kit_item in self.kit_items.all(): best_product = kit_item.get_best_available_product(stock_manager) if not best_product: return False return True def calculate_price_with_substitutions(self, stock_manager=None): """ Расчёт цены комплекта с учётом доступных замен компонентов. Метод определяет цену комплекта, учитывая доступные товары-заменители и применяет выбранный метод ценообразования. Args: stock_manager: Объект управления складом (если не указан, используется стандартный) Returns: Decimal: Расчетная цена комплекта, или 0 в случае ошибки """ from decimal import Decimal, InvalidOperation from .utils.stock_manager import StockManager if stock_manager is None: stock_manager = StockManager() # Если указана фиксированная цена, используем её if self.pricing_method == 'fixed' and self.fixed_price: return self.fixed_price total_cost = Decimal('0.00') total_sale = Decimal('0.00') for kit_item in self.kit_items.select_related('product', 'variant_group'): try: best_product = kit_item.get_best_available_product(stock_manager) if not best_product: # Если товар недоступен, используем цену первого в списке available_products = kit_item.get_available_products() best_product = available_products[0] if available_products else None if best_product: item_cost = best_product.cost_price item_sale = best_product.sale_price item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано # Проверяем корректность значений перед умножением if item_cost and item_quantity: total_cost += item_cost * item_quantity if item_sale and item_quantity: total_sale += item_sale * item_quantity except (AttributeError, TypeError, InvalidOperation) as e: # Логируем ошибку, но продолжаем вычисления import logging logger = logging.getLogger(__name__) logger.warning(f"Ошибка при расчёте цены для комплекта {self.name} (item: {kit_item}): {e}") continue # Пропускаем ошибочный элемент и продолжаем с остальными # Применяем метод ценообразования try: if self.pricing_method == 'from_sale_prices': return total_sale elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent is not None: return total_cost * (Decimal('1') + self.markup_percent / Decimal('100')) elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount is not None: return total_cost + self.markup_amount elif self.pricing_method == 'fixed' and self.fixed_price: return self.fixed_price return total_sale except (TypeError, InvalidOperation) as e: import logging logger = logging.getLogger(__name__) logger.error(f"Ошибка при применении метода ценообразования для комплекта {self.name}: {e}") # Возвращаем фиксированную цену если есть, иначе 0 if self.pricing_method == 'fixed' and self.fixed_price: return self.fixed_price return Decimal('0.00') def calculate_cost(self): """ Расчёт себестоимости комплекта на основе себестоимости компонентов. Returns: Decimal: Себестоимость комплекта """ from decimal import Decimal total_cost = Decimal('0.00') for kit_item in self.kit_items.select_related('product', 'variant_group'): # Получаем продукт - либо конкретный, либо первый из группы вариантов product = kit_item.product if not product and kit_item.variant_group: # Берем первый продукт из группы вариантов product = kit_item.variant_group.products.filter(is_active=True).first() if product: item_cost = product.cost_price item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано total_cost += item_cost * item_quantity return total_cost 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']) # Возвращаем результат в формате Django 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="Количество") notes = models.CharField( max_length=200, 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): """ Возвращает строку для отображения названия компонента. Returns: str: Название компонента (либо группа вариантов, либо конкретный товар) """ if self.variant_group: return f"[Варианты] {self.variant_group.name}" return self.product.name if self.product else "Не указан" def has_priorities_set(self): """ Проверяет, настроены ли приоритеты замены для данного компонента. Returns: bool: True, если приоритеты установлены, иначе False """ return self.priorities.exists() def get_available_products(self): """ Возвращает список доступных товаров для этого компонента. Если указан конкретный товар - возвращает его. Если указаны приоритеты - возвращает товары в порядке приоритета. Если не указаны приоритеты - возвращает все активные товары из группы вариантов. Returns: list: Список доступных товаров """ 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): """ Возвращает первый доступный товар по приоритету, соответствующий требованиям по количеству. Args: stock_manager: Объект управления складом (если не указан, используется стандартный) Returns: Product or None: Первый доступный товар или 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 ProductPhoto(models.Model): """ Модель для хранения фото товара (один товар может иметь несколько фото). Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large. """ product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='photos', verbose_name="Товар") image = models.ImageField(upload_to='products/temp/', verbose_name="Оригинальное фото") order = models.PositiveIntegerField(default=0, verbose_name="Порядок") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") class Meta: verbose_name = "Фото товара" verbose_name_plural = "Фото товаров" ordering = ['order', '-created_at'] def __str__(self): return f"Фото для {self.product.name}" def save(self, *args, **kwargs): """ При загрузке нового изображения обрабатывает его и создает все необходимые размеры. """ from .utils.image_processor import ImageProcessor is_new = not self.pk # Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID if is_new and self.image: # Сохраняем объект без изображения, чтобы получить ID temp_image = self.image self.image = None super().save(*args, **kwargs) # Теперь обрабатываем изображение с известными ID processed_paths = ImageProcessor.process_image(temp_image, 'products', entity_id=self.product.id, photo_id=self.id) self.image = processed_paths['original'] # Обновляем только поле image, чтобы избежать рекурсии и дублирования ID super().save(update_fields=['image']) else: # Проверяем старый путь для удаления, если это обновление old_image_path = None if self.pk: try: old_obj = ProductPhoto.objects.get(pk=self.pk) if old_obj.image and old_obj.image != self.image: old_image_path = old_obj.image.name except ProductPhoto.DoesNotExist: pass # Проверяем, нужно ли обрабатывать изображение if self.image and old_image_path: # Обновление существующего изображения processed_paths = ImageProcessor.process_image(self.image, 'products', entity_id=self.product.id, photo_id=self.id) self.image = processed_paths['original'] # Удаляем старые версии ImageProcessor.delete_all_versions('products', old_image_path, entity_id=self.product.id, photo_id=self.id) # Обновляем только поле image, чтобы избежать рекурсии super().save(update_fields=['image']) else: # Просто сохраняем без обработки изображения super().save(*args, **kwargs) def delete(self, *args, **kwargs): """Удаляет все версии изображения при удалении фото""" import logging from .utils.image_processor import ImageProcessor logger = logging.getLogger(__name__) if self.image: try: logger.info(f"[ProductPhoto.delete] Удаляем изображение: {self.image.name}") ImageProcessor.delete_all_versions('products', self.image.name, entity_id=self.product.id, photo_id=self.id) logger.info(f"[ProductPhoto.delete] ✓ Все версии изображения удалены") except Exception as e: logger.error(f"[ProductPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True) super().delete(*args, **kwargs) def get_thumbnail_url(self): """Получить URL миниатюры (150x150)""" from .utils.image_service import ImageService return ImageService.get_thumbnail_url(self.image.name) def get_medium_url(self): """Получить URL среднего размера (400x400)""" from .utils.image_service import ImageService return ImageService.get_medium_url(self.image.name) def get_large_url(self): """Получить URL большого размера (800x800)""" from .utils.image_service import ImageService return ImageService.get_large_url(self.image.name) def get_original_url(self): """Получить URL оригинального изображения""" from .utils.image_service import ImageService return ImageService.get_original_url(self.image.name) class ProductKitPhoto(models.Model): """ Модель для хранения фото комплекта (один комплект может иметь несколько фото). Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large. """ kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='photos', verbose_name="Комплект") image = models.ImageField(upload_to='kits/temp/', verbose_name="Оригинальное фото") order = models.PositiveIntegerField(default=0, verbose_name="Порядок") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") class Meta: verbose_name = "Фото комплекта" verbose_name_plural = "Фото комплектов" ordering = ['order', '-created_at'] def __str__(self): return f"Фото для {self.kit.name}" def save(self, *args, **kwargs): """ При загрузке нового изображения обрабатывает его и создает все необходимые размеры. """ from .utils.image_processor import ImageProcessor is_new = not self.pk # Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID if is_new and self.image: # Сохраняем объект без изображения, чтобы получить ID temp_image = self.image self.image = None super().save(*args, **kwargs) # Теперь обрабатываем изображение с известными ID processed_paths = ImageProcessor.process_image(temp_image, 'kits', entity_id=self.kit.id, photo_id=self.id) self.image = processed_paths['original'] # Обновляем только поле image, чтобы избежать рекурсии и дублирования ID super().save(update_fields=['image']) else: # Проверяем старый путь для удаления, если это обновление old_image_path = None if self.pk: try: old_obj = ProductKitPhoto.objects.get(pk=self.pk) if old_obj.image and old_obj.image != self.image: old_image_path = old_obj.image.name except ProductKitPhoto.DoesNotExist: pass # Проверяем, нужно ли обрабатывать изображение if self.image and old_image_path: # Обновление существующего изображения processed_paths = ImageProcessor.process_image(self.image, 'kits', entity_id=self.kit.id, photo_id=self.id) self.image = processed_paths['original'] # Удаляем старые версии ImageProcessor.delete_all_versions('kits', old_image_path, entity_id=self.kit.id, photo_id=self.id) # Обновляем только поле image, чтобы избежать рекурсии super().save(update_fields=['image']) else: # Просто сохраняем без обработки изображения super().save(*args, **kwargs) def delete(self, *args, **kwargs): """Удаляет все версии изображения при удалении фото""" import logging from .utils.image_processor import ImageProcessor logger = logging.getLogger(__name__) if self.image: try: logger.info(f"[ProductKitPhoto.delete] Удаляем изображение: {self.image.name}") ImageProcessor.delete_all_versions('kits', self.image.name, entity_id=self.kit.id, photo_id=self.id) logger.info(f"[ProductKitPhoto.delete] ✓ Все версии изображения удалены") except Exception as e: logger.error(f"[ProductKitPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True) super().delete(*args, **kwargs) def get_thumbnail_url(self): """Получить URL миниатюры (150x150)""" from .utils.image_service import ImageService return ImageService.get_thumbnail_url(self.image.name) def get_medium_url(self): """Получить URL среднего размера (400x400)""" from .utils.image_service import ImageService return ImageService.get_medium_url(self.image.name) def get_large_url(self): """Получить URL большого размера (800x800)""" from .utils.image_service import ImageService return ImageService.get_large_url(self.image.name) def get_original_url(self): """Получить URL оригинального изображения""" from .utils.image_service import ImageService return ImageService.get_original_url(self.image.name) class ProductCategoryPhoto(models.Model): """ Модель для хранения фото категории (одна категория может иметь несколько фото). Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large. """ category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, related_name='photos', verbose_name="Категория") image = models.ImageField(upload_to='categories/temp/', verbose_name="Оригинальное фото") order = models.PositiveIntegerField(default=0, verbose_name="Порядок") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") class Meta: verbose_name = "Фото категории" verbose_name_plural = "Фото категорий" ordering = ['order', '-created_at'] def __str__(self): return f"Фото для {self.category.name}" def save(self, *args, **kwargs): """ При загрузке нового изображения обрабатывает его и создает все необходимые размеры. """ from .utils.image_processor import ImageProcessor is_new = not self.pk # Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID if is_new and self.image: # Сохраняем объект без изображения, чтобы получить ID temp_image = self.image self.image = None super().save(*args, **kwargs) # Теперь обрабатываем изображение с известными ID processed_paths = ImageProcessor.process_image(temp_image, 'categories', entity_id=self.category.id, photo_id=self.id) self.image = processed_paths['original'] # Обновляем только поле image, чтобы избежать рекурсии и дублирования ID super().save(update_fields=['image']) else: # Проверяем старый путь для удаления, если это обновление old_image_path = None if self.pk: try: old_obj = ProductCategoryPhoto.objects.get(pk=self.pk) if old_obj.image and old_obj.image != self.image: old_image_path = old_obj.image.name except ProductCategoryPhoto.DoesNotExist: pass # Проверяем, нужно ли обрабатывать изображение if self.image and old_image_path: # Обновление существующего изображения processed_paths = ImageProcessor.process_image(self.image, 'categories', entity_id=self.category.id, photo_id=self.id) self.image = processed_paths['original'] # Удаляем старые версии ImageProcessor.delete_all_versions('categories', old_image_path, entity_id=self.category.id, photo_id=self.id) # Обновляем только поле image, чтобы избежать рекурсии super().save(update_fields=['image']) else: # Просто сохраняем без обработки изображения super().save(*args, **kwargs) def delete(self, *args, **kwargs): """Удаляет все версии изображения при удалении фото""" import logging from .utils.image_processor import ImageProcessor logger = logging.getLogger(__name__) if self.image: try: logger.info(f"[ProductCategoryPhoto.delete] Удаляем изображение: {self.image.name}") ImageProcessor.delete_all_versions('categories', self.image.name, entity_id=self.category.id, photo_id=self.id) logger.info(f"[ProductCategoryPhoto.delete] ✓ Все версии изображения удалены") except Exception as e: logger.error(f"[ProductCategoryPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True) super().delete(*args, **kwargs) def get_thumbnail_url(self): """Получить URL миниатюры (150x150)""" from .utils.image_service import ImageService return ImageService.get_thumbnail_url(self.image.name) def get_medium_url(self): """Получить URL среднего размера (400x400)""" from .utils.image_service import ImageService return ImageService.get_medium_url(self.image.name) def get_large_url(self): """Получить URL большого размера (800x800)""" from .utils.image_service import ImageService return ImageService.get_large_url(self.image.name) def get_original_url(self): """Получить URL оригинального изображения""" from .utils.image_service import ImageService return ImageService.get_original_url(self.image.name) class ProductVariantGroupItem(models.Model): """ Товар в группе вариантов с приоритетом для этой конкретной группы. Приоритет определяет порядок выбора товара при использовании группы в комплектах. Например: в группе "Роза красная Freedom" - роза 50см имеет приоритет 1, 60см = 2, 70см = 3. """ variant_group = models.ForeignKey( ProductVariantGroup, on_delete=models.CASCADE, related_name='items', verbose_name="Группа вариантов" ) product = models.ForeignKey( Product, on_delete=models.CASCADE, related_name='variant_group_items', verbose_name="Товар" ) priority = models.PositiveIntegerField( default=0, help_text="Меньше = выше приоритет (1 - наивысший приоритет в этой группе)" ) class Meta: verbose_name = "Товар в группе вариантов" verbose_name_plural = "Товары в группах вариантов" ordering = ['priority', 'id'] unique_together = [['variant_group', 'product']] indexes = [ models.Index(fields=['variant_group', 'priority']), models.Index(fields=['product']), ] def __str__(self): return f"{self.variant_group.name} - {self.product.name} (приоритет {self.priority})"