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.
This commit is contained in:
2026-01-02 02:09:44 +03:00
parent ca308ae2a2
commit 5b68f14bb4
11 changed files with 764 additions and 15 deletions

View File

@@ -40,6 +40,9 @@ from .kits import (
# Атрибуты
from .attributes import ProductAttribute, ProductAttributeValue
# Единицы измерения
from .units import UnitOfMeasure, ProductSalesUnit
# Фотографии
from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus
@@ -79,6 +82,10 @@ __all__ = [
'ProductAttribute',
'ProductAttributeValue',
# Units
'UnitOfMeasure',
'ProductSalesUnit',
# Photos
'BasePhoto',
'ProductPhoto',

View File

@@ -1,8 +1,9 @@
"""
Модель Product - базовый товар (цветок, упаковка, аксессуар).
"""
from decimal import Decimal
from django.db import models
from django.db.models import Q
from django.db.models import Q, Sum
from .base import BaseProductEntity
from .categories import ProductCategory, ProductTag
@@ -56,7 +57,19 @@ class Product(BaseProductEntity):
max_length=10,
choices=UNIT_CHOICES,
default='шт',
verbose_name="Единица измерения"
verbose_name="Единица измерения (deprecated)"
)
# Новое поле: ссылка на справочник единиц измерения
base_unit = models.ForeignKey(
'UnitOfMeasure',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='products',
verbose_name="Базовая единица",
help_text="Единица хранения и закупки (банч, кг, шт). "
"Если указана, используется вместо поля 'unit'."
)
# ЦЕНООБРАЗОВАНИЕ - переименованные поля
@@ -153,6 +166,99 @@ class Product(BaseProductEntity):
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):
"""

View File

@@ -0,0 +1,222 @@
"""
Модели единиц измерения.
- UnitOfMeasure: справочник единиц измерения
- ProductSalesUnit: единицы продажи товара с коэффициентами конверсии
"""
from decimal import Decimal
from django.db import models
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
class UnitOfMeasure(models.Model):
"""
Справочник единиц измерения.
Расширяемый справочник вместо жестко заданных UNIT_CHOICES.
"""
code = models.CharField(
max_length=20,
unique=True,
verbose_name="Код",
help_text="Короткий код: шт, кг, банч, ветка"
)
name = models.CharField(
max_length=100,
verbose_name="Название",
help_text="Полное название: Штука, Килограмм, Банч"
)
short_name = models.CharField(
max_length=10,
verbose_name="Сокращение",
help_text="Для UI: шт., кг., бч."
)
is_active = models.BooleanField(
default=True,
verbose_name="Активна"
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Порядок сортировки"
)
class Meta:
verbose_name = "Единица измерения"
verbose_name_plural = "Единицы измерения"
ordering = ['position', 'name']
def __str__(self):
return f"{self.name} ({self.code})"
class ProductSalesUnit(models.Model):
"""
Единица продажи товара с коэффициентом конверсии.
Один товар может иметь несколько единиц продажи.
Пример:
Товар: Пихта Нобилис (базовая единица: банч)
Единицы продажи:
- Ветка большая: 1 банч = 15 веток, цена 300₽
- Ветка средняя: 1 банч = 30 веток, цена 150₽
- Ветка маленькая: 1 банч = 50 веток, цена 100₽
"""
product = models.ForeignKey(
'Product',
on_delete=models.CASCADE,
related_name='sales_units',
verbose_name="Товар"
)
unit = models.ForeignKey(
UnitOfMeasure,
on_delete=models.PROTECT,
verbose_name="Единица измерения"
)
name = models.CharField(
max_length=100,
verbose_name="Название",
help_text="Например: 'Ветка большая', 'Ветка средняя'"
)
conversion_factor = models.DecimalField(
max_digits=15,
decimal_places=6,
validators=[MinValueValidator(Decimal('0.000001'))],
verbose_name="Коэффициент конверсии",
help_text="Сколько единиц продажи в 1 базовой единице товара. "
"Например: 15 (из 1 банча получается 15 больших веток)"
)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[MinValueValidator(Decimal('0'))],
verbose_name="Цена продажи"
)
sale_price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0'))],
verbose_name="Цена со скидкой"
)
min_quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
default=Decimal('1'),
validators=[MinValueValidator(Decimal('0.001'))],
verbose_name="Мин. количество",
help_text="Минимальное количество для продажи"
)
quantity_step = models.DecimalField(
max_digits=10,
decimal_places=3,
default=Decimal('1'),
validators=[MinValueValidator(Decimal('0.001'))],
verbose_name="Шаг количества",
help_text="С каким шагом можно заказывать (0.1, 0.5, 1)"
)
is_default = models.BooleanField(
default=False,
verbose_name="Единица по умолчанию",
help_text="Единица, выбираемая по умолчанию при добавлении в заказ"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активна"
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Порядок сортировки"
)
class Meta:
verbose_name = "Единица продажи товара"
verbose_name_plural = "Единицы продажи товаров"
ordering = ['position', 'id']
unique_together = [['product', 'name']]
def __str__(self):
return f"{self.product.name} - {self.name} ({self.unit.code})"
def clean(self):
super().clean()
if self.conversion_factor and self.conversion_factor <= 0:
raise ValidationError({
'conversion_factor': 'Коэффициент конверсии должен быть больше 0'
})
if self.sale_price and self.price and self.sale_price >= self.price:
raise ValidationError({
'sale_price': 'Цена со скидкой должна быть меньше основной цены'
})
def save(self, *args, **kwargs):
# Если это единица по умолчанию, снимаем флаг с других
if self.is_default:
ProductSalesUnit.objects.filter(
product=self.product,
is_default=True
).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
@property
def actual_price(self):
"""Финальная цена (со скидкой или без)"""
return self.sale_price if self.sale_price else self.price
def convert_to_base(self, quantity):
"""
Конвертировать количество в базовые единицы товара.
Args:
quantity: количество в единицах продажи
Returns:
Decimal: количество в базовых единицах
Пример:
10 больших веток → 10 / 15 = 0.667 банча
"""
if not self.conversion_factor or self.conversion_factor == 0:
return Decimal(quantity)
return Decimal(quantity) / self.conversion_factor
def convert_from_base(self, base_quantity):
"""
Конвертировать из базовых единиц в единицы продажи.
Args:
base_quantity: количество в базовых единицах товара
Returns:
Decimal: количество в единицах продажи
Пример:
2.5 банча → 2.5 * 15 = 37.5 больших веток
"""
if not self.conversion_factor:
return Decimal(base_quantity)
return Decimal(base_quantity) * self.conversion_factor
def validate_quantity(self, quantity):
"""
Проверить, что количество соответствует ограничениям.
Args:
quantity: количество для проверки
Raises:
ValidationError: если количество некорректно
"""
quantity = Decimal(quantity)
if quantity < self.min_quantity:
raise ValidationError(
f'Минимальное количество для "{self.name}": {self.min_quantity}'
)
# Проверяем шаг количества (с учётом погрешности float)
if self.quantity_step and self.quantity_step > 0:
remainder = quantity % self.quantity_step
if remainder > Decimal('0.0001') and (self.quantity_step - remainder) > Decimal('0.0001'):
raise ValidationError(
f'Количество должно быть кратно {self.quantity_step}'
)