Files
octopus/myproject/products/models/products.py
Andrey Smakotin 5b68f14bb4 feat(products): add support for product sales units
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.
2026-01-02 02:09:44 +03:00

337 lines
12 KiB
Python
Raw 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.
"""
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()})"