""" Модель Product - базовый товар (цветок, упаковка, аксессуар). """ from decimal import Decimal from django.db import models from django.db.models import Q, Sum from .base import BaseProductEntity from .categories import ProductCategory, ProductTag from .variants import ProductVariantGroup from ..services.product_service import ProductSaveService class Product(BaseProductEntity): """ Базовый товар (цветок, упаковка, аксессуар). Наследует общие поля из BaseProductEntity. """ # Специфичные поля 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="Группы вариантов" ) # Базовая единица измерения (единица закупки/хранения) base_unit = models.ForeignKey( 'UnitOfMeasure', on_delete=models.PROTECT, related_name='products', verbose_name="Базовая единица", help_text="Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах." ) # ЦЕНООБРАЗОВАНИЕ - переименованные поля 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 main_photo(self): """ Главное фото товара (is_main=True). Используется в карточках, каталоге, превью. Returns: ProductPhoto | None: Главное фото или None если фото нет """ return self.photos.filter(is_main=True).first() @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() # === МЕТОДЫ ДЛЯ РАБОТЫ С ЕДИНИЦАМИ ПРОДАЖИ === @property def unit_display(self): """ Отображаемое название единицы измерения. Возвращает код базовой единицы. """ return self.base_unit.code if self.base_unit else 'шт' @property def has_sales_units(self): """Есть ли у товара настроенные единицы продажи""" return self.sales_units.filter(is_active=True).exists() def get_default_sales_unit(self): """ Получить единицу продажи по умолчанию. Возвращает первую единицу с is_default=True или первую активную. """ default = self.sales_units.filter(is_active=True, is_default=True).first() if default: return default return self.sales_units.filter(is_active=True).first() def get_total_available(self, warehouse=None): """ Получить общий доступный остаток в базовых единицах. Args: warehouse: (опционально) конкретный склад Returns: Decimal: количество в базовых единицах """ from inventory.models import Stock qs = Stock.objects.filter(product=self) if warehouse: qs = qs.filter(warehouse=warehouse) result = qs.aggregate( total=Sum('quantity_available') )['total'] return result or Decimal('0') def get_available_in_unit(self, sales_unit, warehouse=None): """ Получить остаток в указанной единице продажи. Args: sales_unit: объект ProductSalesUnit warehouse: (опционально) конкретный склад Returns: Decimal: количество в единицах продажи """ base_qty = self.get_total_available(warehouse) return sales_unit.convert_from_base(base_qty) def get_all_units_availability(self, warehouse=None): """ Получить остатки во всех единицах продажи. Args: warehouse: (опционально) конкретный склад Returns: list[dict]: список с информацией по каждой единице продажи [ { 'sales_unit': ProductSalesUnit, 'available': Decimal, 'price': Decimal }, ... ] """ base_qty = self.get_total_available(warehouse) result = [] for su in self.sales_units.filter(is_active=True).select_related('unit'): result.append({ 'sales_unit': su, 'available': su.convert_from_base(base_qty), 'price': su.actual_price }) return result 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()})"