338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""
|
||
Модель 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="Базовая единица",
|
||
null=True,
|
||
blank=True,
|
||
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)
|
||
|
||
@property
|
||
def kit_items_using_as_sales_unit(self):
|
||
"""
|
||
Возвращает QuerySet KitItem, где этот товар используется как единица продажи.
|
||
"""
|
||
from .kits import KitItem
|
||
return KitItem.objects.filter(sales_unit__product=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()})"
|