- Поле quantity изменено с PositiveIntegerField на DecimalField(max_digits=10, decimal_places=3) - Это необходимо для корректной работы с единицами продажи (например, 2.5 банча) - Создана миграция 0004_change_orderitem_quantity_to_decimal - Теперь POS корректно обрабатывает товары с дробными количествами в единицах продажи
239 lines
8.7 KiB
Python
239 lines
8.7 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.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=3,
|
||
default=1,
|
||
verbose_name="Количество",
|
||
help_text="Количество в единицах продажи (может быть дробным)"
|
||
)
|
||
|
||
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
|