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:
222
myproject/products/models/units.py
Normal file
222
myproject/products/models/units.py
Normal 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}'
|
||||
)
|
||||
Reference in New Issue
Block a user