Files
octopus/myproject/products/models/units.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

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