Files
octopus/myproject/orders/models/order_item.py
Andrey Smakotin 9960590dcc feat(orders): добавлены поля скидок в Order и OrderItem
Интеграция системы скидок с моделями заказов:

Order:
- applied_discount: ForeignKey на Discount
- discount_amount: сумма скидки на заказ
- applied_promo_code: использованный промокод
- calculate_total(): обновлён с учётом скидки

OrderItem:
- applied_discount: ForeignKey на Discount
- discount_amount: сумма скидки на позицию
- get_total_price(): обновлён с учётом скидки

Миграция:
- 0003_order_applied_discount... добавляет новые поля

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

257 lines
9.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.
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 если цена была изменена вручную при создании заказа"
)
# Скидки
applied_discount = models.ForeignKey(
'discounts.Discount',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='order_items',
verbose_name="Скидка на позицию"
)
discount_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Сумма скидки"
)
# Витринные продажи
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