Files
octopus/myproject/products/models/products.py

338 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Модель 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()})"