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

@@ -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}'
)