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.
236 lines
8.5 KiB
Python
236 lines
8.5 KiB
Python
from decimal import Decimal
|
||
from django.db import models
|
||
from django.core.exceptions import ValidationError
|
||
from products.models import Product, ProductKit
|
||
from simple_history.models import HistoricalRecords
|
||
from .order import Order
|
||
from .kit_snapshot import KitSnapshot
|
||
|
||
|
||
class OrderItem(models.Model):
|
||
"""
|
||
Позиция (товар) в заказе.
|
||
Хранит информацию о товаре или комплекте, количестве и цене на момент заказа.
|
||
"""
|
||
order = models.ForeignKey(
|
||
Order,
|
||
on_delete=models.CASCADE,
|
||
related_name='items',
|
||
verbose_name="Заказ"
|
||
)
|
||
|
||
# Товар или комплект (один из двух должен быть заполнен)
|
||
product = models.ForeignKey(
|
||
Product,
|
||
on_delete=models.PROTECT,
|
||
null=True,
|
||
blank=True,
|
||
related_name='order_items',
|
||
verbose_name="Товар"
|
||
)
|
||
|
||
product_kit = models.ForeignKey(
|
||
ProductKit,
|
||
on_delete=models.PROTECT,
|
||
null=True,
|
||
blank=True,
|
||
related_name='order_items',
|
||
verbose_name="Комплект товаров"
|
||
)
|
||
|
||
# Снимок комплекта (для сохранения истории состава комплекта на момент заказа)
|
||
kit_snapshot = models.ForeignKey(
|
||
KitSnapshot,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='order_items',
|
||
verbose_name="Снимок комплекта",
|
||
help_text="Хранит состав комплекта на момент оформления заказа"
|
||
)
|
||
|
||
# Снапшот названия и артикула (для Product и Kit)
|
||
item_name_snapshot = models.CharField(
|
||
max_length=200,
|
||
default='',
|
||
verbose_name="Название на момент заказа"
|
||
)
|
||
|
||
item_sku_snapshot = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
verbose_name="Артикул на момент заказа"
|
||
)
|
||
|
||
quantity = models.PositiveIntegerField(
|
||
default=1,
|
||
verbose_name="Количество"
|
||
)
|
||
|
||
price = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
verbose_name="Цена за единицу",
|
||
help_text="Цена на момент создания заказа (фиксируется)"
|
||
)
|
||
|
||
is_custom_price = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Цена изменена вручную",
|
||
help_text="True если цена была изменена вручную при создании заказа"
|
||
)
|
||
|
||
# Витринные продажи
|
||
is_from_showcase = models.BooleanField(
|
||
default=False,
|
||
verbose_name="С витрины",
|
||
help_text="True если товар продан с витрины"
|
||
)
|
||
|
||
showcase = models.ForeignKey(
|
||
'inventory.Showcase',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='order_items',
|
||
verbose_name="Витрина",
|
||
help_text="Витрина, с которой был продан товар"
|
||
)
|
||
|
||
# === ПОЛЯ ДЛЯ ЕДИНИЦ ПРОДАЖИ ===
|
||
sales_unit = models.ForeignKey(
|
||
'products.ProductSalesUnit',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='order_items',
|
||
verbose_name="Единица продажи"
|
||
)
|
||
unit_name_snapshot = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
default='',
|
||
verbose_name="Название единицы (snapshot)",
|
||
help_text="Название единицы продажи на момент заказа"
|
||
)
|
||
conversion_factor_snapshot = models.DecimalField(
|
||
max_digits=15,
|
||
decimal_places=6,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Коэффициент конверсии (snapshot)",
|
||
help_text="Коэффициент конверсии на момент заказа"
|
||
)
|
||
quantity_in_base_units = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=6,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Количество в базовых единицах",
|
||
help_text="Количество в единицах хранения товара (для списания со склада)"
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата добавления"
|
||
)
|
||
|
||
# История изменений
|
||
history = HistoricalRecords()
|
||
|
||
class Meta:
|
||
verbose_name = "Позиция заказа"
|
||
verbose_name_plural = "Позиции заказа"
|
||
indexes = [
|
||
models.Index(fields=['order']),
|
||
models.Index(fields=['product']),
|
||
models.Index(fields=['product_kit']),
|
||
models.Index(fields=['is_from_showcase']),
|
||
models.Index(fields=['showcase']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.item_name_snapshot} x{self.quantity} в заказе #{self.order.order_number}"
|
||
|
||
def clean(self):
|
||
"""Валидация модели"""
|
||
super().clean()
|
||
|
||
# Проверка: должен быть заполнен либо product, либо product_kit
|
||
if not self.product and not self.product_kit:
|
||
raise ValidationError(
|
||
'Необходимо указать либо товар, либо комплект товаров'
|
||
)
|
||
|
||
# Проверка: не должны быть заполнены оба поля одновременно
|
||
if self.product and self.product_kit:
|
||
raise ValidationError(
|
||
'Нельзя указать одновременно и товар, и комплект'
|
||
)
|
||
|
||
def save(self, *args, **kwargs):
|
||
is_new = not self.pk
|
||
|
||
# Определяем, изменился ли комплект (для существующих записей)
|
||
kit_changed = False
|
||
if self.pk and self.product_kit:
|
||
old = OrderItem.objects.filter(pk=self.pk).values('product_kit_id').first()
|
||
if old and old['product_kit_id'] != self.product_kit_id:
|
||
kit_changed = True
|
||
|
||
# Создаем snapshot для комплекта (новый или при замене)
|
||
if self.product_kit and (is_new or kit_changed):
|
||
self.kit_snapshot = self.product_kit.create_snapshot()
|
||
|
||
# Сохраняем название и артикул
|
||
if self.product:
|
||
self.item_name_snapshot = self.product.name
|
||
self.item_sku_snapshot = self.product.sku or ''
|
||
elif self.kit_snapshot:
|
||
self.item_name_snapshot = self.kit_snapshot.name
|
||
self.item_sku_snapshot = self.kit_snapshot.sku or ''
|
||
|
||
# Автоматически фиксируем цену при создании, если она не указана
|
||
if not self.price:
|
||
# Сначала проверяем единицу продажи
|
||
if self.sales_unit:
|
||
self.price = self.sales_unit.actual_price
|
||
elif self.product:
|
||
self.price = self.product.actual_price
|
||
elif self.kit_snapshot:
|
||
self.price = self.kit_snapshot.actual_price
|
||
|
||
# Сохраняем snapshot единицы продажи
|
||
if self.sales_unit:
|
||
self.unit_name_snapshot = self.sales_unit.name
|
||
self.conversion_factor_snapshot = self.sales_unit.conversion_factor
|
||
self.quantity_in_base_units = self.sales_unit.convert_to_base(
|
||
Decimal(self.quantity)
|
||
)
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
def get_total_price(self):
|
||
"""Возвращает общую стоимость позиции"""
|
||
return self.price * self.quantity
|
||
|
||
@property
|
||
def item_name(self):
|
||
"""Название из snapshot"""
|
||
return self.item_name_snapshot
|
||
|
||
@property
|
||
def original_price(self):
|
||
"""Оригинальная цена из snapshot"""
|
||
if self.kit_snapshot:
|
||
return self.kit_snapshot.actual_price
|
||
return self.price
|
||
|
||
@property
|
||
def price_difference(self):
|
||
"""Разница между установленной ценой и оригинальной"""
|
||
if self.is_custom_price and self.original_price:
|
||
return self.price - self.original_price
|
||
return None
|