Add new models UnitOfMeasure and ProductSalesUnit to enable selling products in different units (e.g., bunches, kg). Update Product model with base_unit field and methods for unit conversions and availability. Extend Sale, Reservation, and OrderItem models with sales_unit fields and snapshots. Modify SaleProcessor to handle quantity conversions. Include admin interfaces for managing units. Add corresponding database migrations.
337 lines
12 KiB
Python
337 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.
|
||
"""
|
||
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="Единица измерения (deprecated)"
|
||
)
|
||
|
||
# Новое поле: ссылка на справочник единиц измерения
|
||
base_unit = models.ForeignKey(
|
||
'UnitOfMeasure',
|
||
on_delete=models.PROTECT,
|
||
null=True,
|
||
blank=True,
|
||
related_name='products',
|
||
verbose_name="Базовая единица",
|
||
help_text="Единица хранения и закупки (банч, кг, шт). "
|
||
"Если указана, используется вместо поля 'unit'."
|
||
)
|
||
|
||
# ЦЕНООБРАЗОВАНИЕ - переименованные поля
|
||
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()
|
||
|
||
# === МЕТОДЫ ДЛЯ РАБОТЫ С ЕДИНИЦАМИ ПРОДАЖИ ===
|
||
|
||
@property
|
||
def unit_display(self):
|
||
"""
|
||
Отображаемое название единицы измерения.
|
||
Приоритет: base_unit.code > unit
|
||
"""
|
||
if self.base_unit:
|
||
return self.base_unit.code
|
||
return self.unit
|
||
|
||
@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()})"
|