- Добавить inline-редактирование цен в списке товаров - Оптимизировать карточки товаров в POS-терминале - Рефакторинг моделей единиц измерения - Миграция unit -> base_unit в SalesUnit - Улучшить UI форм создания/редактирования товаров Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
218 lines
8.2 KiB
Python
218 lines
8.2 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="Товар"
|
||
)
|
||
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}"
|
||
|
||
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}'
|
||
)
|