Files
octopus/myproject/orders/models/order_item.py
Andrey Smakotin c070e42cab feat(discounts, orders): рефакторинг системы скидок - единый источник правды
- Добавлен combine_mode в форму создания/редактирования скидок
- Добавлена колонка "Объединение" в список скидок с иконками
- Добавлен фильтр по режиму объединения скидок
- Добавлена валидация: только одна exclusive скидка на заказ
- Удалены дублирующие поля из Order и OrderItem:
  - applied_discount, applied_promo_code, discount_amount
- Скидки теперь хранятся только в DiscountApplication
- Добавлены свойства для обратной совместимости

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:46:02 +03:00

265 lines
10 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.
from decimal import Decimal
from django.db import models
from django.core.exceptions import ValidationError
from products.models import Product, ProductKit
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 если цена была изменена вручную при создании заказа"
)
# === СКИДКИ ===
# Скидки хранятся в модели DiscountApplication (через related_name='discount_applications')
# Старые поля applied_discount, discount_amount УДАЛЕНЫ
# Используйте свойства ниже для доступа к скидкам
# Витринные продажи
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="Дата добавления"
)
# TODO: История изменений (simple_history) - вернуть позже
# 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):
"""Возвращает общую стоимость позиции с учетом скидки"""
subtotal = self.price * self.quantity
discount = Decimal(str(self.discount_amount)) if self.discount_amount else Decimal('0')
return subtotal - discount
@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
# === Свойства для доступа к скидкам (через DiscountApplication) ===
@property
def item_discounts(self):
"""Скидки на эту позицию (QuerySet DiscountApplication)"""
return self.discount_applications.filter(target='order_item').select_related('discount')
@property
def discount_amount(self):
"""Общая сумма скидки на позицию"""
from django.db.models import Sum
total = self.item_discounts.aggregate(total=Sum('discount_amount'))['total']
return total if total else Decimal('0')
@property
def applied_discount(self):
"""Первая применённая скидка (для обратной совместимости)"""
first = self.item_discounts.first()
return first.discount if first else None