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.
223 lines
8.3 KiB
Python
223 lines
8.3 KiB
Python
"""
|
||
Модели единиц измерения.
|
||
- 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}'
|
||
)
|