""" Модель Product - базовый товар (цветок, упаковка, аксессуар). """ from django.db import models from django.db.models import Q from .base import BaseProductEntity from .categories import ProductCategory, ProductTag from .variants import ProductVariantGroup from ..services.product_service import ProductSaveService class Product(BaseProductEntity): """ Базовый товар (цветок, упаковка, аксессуар). Наследует общие поля из BaseProductEntity. """ UNIT_CHOICES = [ ('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм'), ] # Специфичные поля Product variant_suffix = models.CharField( max_length=20, blank=True, null=True, verbose_name="Суффикс варианта", help_text="Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия." ) # Categories and Tags - остаются в Product с related_name='products' 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, default=0, null=True, blank=True, verbose_name="Себестоимость", help_text="Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)" ) price = models.DecimalField( max_digits=10, decimal_places=2, verbose_name="Основная цена", help_text="Цена продажи товара (бывшее поле sale_price)" ) sale_price = models.DecimalField( max_digits=10, decimal_places=2, blank=True, null=True, verbose_name="Цена со скидкой", help_text="Если задана, товар продается по этой цене (дешевле основной)" ) in_stock = models.BooleanField( default=False, verbose_name="В наличии", db_index=True, help_text="Автоматически обновляется при изменении остатков на складе" ) # Поле для улучшенного поиска search_keywords = models.TextField( blank=True, verbose_name="Ключевые слова для поиска", help_text="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную." ) class Meta: verbose_name = "Товар" verbose_name_plural = "Товары" indexes = [ models.Index(fields=['in_stock']), models.Index(fields=['sku']), ] # constraints наследуются из BaseProductEntity (unique_active_product_name) @property def actual_price(self): """ Финальная цена для продажи. Если есть sale_price (скидка) - возвращает его, иначе - основную цену. """ return self.sale_price if self.sale_price else self.price @property def cost_price_details(self): """ Детали расчета себестоимости для отображения в UI. Показывает разбивку по партиям и сравнение кешированной/рассчитанной стоимости. Returns: dict: { 'cached_cost': Decimal, # Кешированная себестоимость (из БД) 'calculated_cost': Decimal, # Рассчитанная себестоимость (из партий) 'is_synced': bool, # Совпадают ли значения 'total_quantity': Decimal, # Общее количество в партиях 'batches': [...] # Список партий с деталями } """ from ..services.cost_calculator import ProductCostCalculator return ProductCostCalculator.get_cost_details(self) def save(self, *args, **kwargs): # Используем сервис для подготовки к сохранению ProductSaveService.prepare_product_for_save(self) # Вызов родительского save (генерация slug и т.д.) super().save(*args, **kwargs) # Обновление поисковых слов с категориями (после сохранения) ProductSaveService.update_search_keywords_with_categories(self) 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 CostPriceHistory(models.Model): """ История изменений себестоимости товара. Логирует все изменения себестоимости, их причины и дата/время. """ REASON_CHOICES = [ ('incoming', 'Поступление товара'), ('batch_edit', 'Редактирование партии'), ('batch_delete', 'Удаление партии'), ('recalculation', 'Пересчет себестоимости'), ('system', 'Системная корректировка'), ] product = models.ForeignKey( Product, on_delete=models.CASCADE, related_name='cost_price_history', verbose_name="Товар" ) old_cost_price = models.DecimalField( max_digits=10, decimal_places=2, verbose_name="Старая себестоимость" ) new_cost_price = models.DecimalField( max_digits=10, decimal_places=2, verbose_name="Новая себестоимость" ) reason = models.CharField( max_length=20, choices=REASON_CHOICES, verbose_name="Причина изменения" ) related_object_id = models.IntegerField( null=True, blank=True, verbose_name="ID связанного объекта", help_text="Например, ID партии (StockBatch) для поступлений" ) related_object_type = models.CharField( max_length=50, blank=True, verbose_name="Тип связанного объекта", help_text="Например, 'StockBatch' для партий" ) notes = models.TextField( blank=True, verbose_name="Примечания", help_text="Дополнительная информация об изменении" ) created_at = models.DateTimeField( auto_now_add=True, verbose_name="Дата и время изменения" ) class Meta: verbose_name = "История себестоимости" verbose_name_plural = "Истории себестоимости" ordering = ['-created_at'] indexes = [ models.Index(fields=['product', '-created_at']), models.Index(fields=['reason']), ] def __str__(self): return f"{self.product.name}: {self.old_cost_price} → {self.new_cost_price} ({self.get_reason_display()})"