Files
octopus/myproject/orders/models/order.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

524 lines
24 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 django.db import models
from accounts.models import CustomUser
from customers.models import Customer
from .status import OrderStatus
from .recipient import Recipient
class Order(models.Model):
"""
Заказ клиента для доставки цветов.
ВАЖНО: Поле payment_method УДАЛЕНО для поддержки смешанной оплаты.
Используйте модель Payment (один Order → много Payment) для платежей.
"""
# Основная информация
customer = models.ForeignKey(
Customer,
on_delete=models.PROTECT,
related_name='orders',
verbose_name="Клиент"
)
order_number = models.PositiveIntegerField(
unique=True,
editable=False,
verbose_name="Номер заказа",
help_text="Уникальный номер заказа"
)
# Статус заказа
status = models.ForeignKey(
'OrderStatus',
on_delete=models.PROTECT,
related_name='orders',
null=True,
blank=True,
verbose_name="Статус заказа"
)
# Флаг для отслеживания возвратов
is_returned = models.BooleanField(
default=False,
verbose_name="Возвращен",
help_text="True если заказ был выполнен, но потом отменен или возвращен клиентом"
)
# Автосохранение (для черновиков)
last_autosave_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Последнее автосохранение",
help_text="Время последнего автоматического сохранения черновика"
)
# Оплата
# УДАЛЕНО: PAYMENT_METHOD_CHOICES и payment_method поле
# Вместо этого используйте модель Payment для смешанной оплаты
is_paid = models.BooleanField(
default=False,
verbose_name="Оплачен"
)
total_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Итоговая сумма заказа",
help_text="Общая сумма заказа"
)
# === СКИДКИ ===
# Скидки хранятся в модели DiscountApplication (через related_name='discount_applications')
# Старые поля applied_discount, discount_amount, applied_promo_code УДАЛЕНЫ
# Используйте свойства ниже для доступа к скидкам
# Частичная оплата
amount_paid = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Оплачено",
help_text="Сумма, внесенная клиентом"
)
PAYMENT_STATUS_CHOICES = [
('unpaid', 'Не оплачен'),
('partial', 'Частично оплачен'),
('paid', 'Оплачен полностью'),
]
payment_status = models.CharField(
max_length=20,
choices=PAYMENT_STATUS_CHOICES,
default='unpaid',
verbose_name="Статус оплаты",
help_text="Обновляется автоматически при добавлении платежей"
)
# Информация о получателе
# Получатель (если None - получатель = покупатель, иначе - отдельный получатель)
recipient = models.ForeignKey(
Recipient,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='orders',
verbose_name="Получатель",
help_text="Заполняется, если покупатель не является получателем"
)
is_anonymous = models.BooleanField(
default=False,
verbose_name="Анонимная доставка",
help_text="Не сообщать получателю имя отправителя"
)
special_instructions = models.TextField(
blank=True,
null=True,
verbose_name="Особые пожелания",
help_text="Комментарии и пожелания к заказу"
)
# Фото
needs_product_photo = models.BooleanField(
default=False,
verbose_name="Необходимо фото товара",
help_text="Требуется фотография товара перед отправкой"
)
needs_delivery_photo = models.BooleanField(
default=False,
verbose_name="Необходимо фото вручения",
help_text="Требуется фотография процесса вручения заказа"
)
# Временные метки
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления"
)
modified_by = models.ForeignKey(
CustomUser,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='modified_orders',
verbose_name="Изменен пользователем",
help_text="Последний пользователь, изменивший заказ"
)
# TODO: История изменений (simple_history) - вернуть позже
# history = HistoricalRecords()
class Meta:
verbose_name = "Заказ"
verbose_name_plural = "Заказы"
indexes = [
models.Index(fields=['customer']),
models.Index(fields=['status']),
models.Index(fields=['payment_status']),
models.Index(fields=['created_at']),
models.Index(fields=['order_number']),
]
ordering = ['-created_at']
def __str__(self):
return f"Заказ #{self.order_number} - {self.customer}"
def get_absolute_url(self):
"""Возвращает канонический URL для заказа"""
from django.urls import reverse
return reverse('orders:order-detail', kwargs={'order_number': self.order_number})
def save(self, *args, **kwargs):
from django.db import transaction
from django.core.exceptions import ValidationError
import logging
logger = logging.getLogger(__name__)
# Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска)
if not self.order_number:
last_order = Order.objects.order_by('-order_number').first()
if last_order:
# Если ранее нумерация была ниже 100, начинаем с 100; иначе инкремент
self.order_number = max(last_order.order_number + 1, 100)
else:
self.order_number = 100
# === АВТОМАТИЧЕСКИЙ ПРОМЕЖУТОЧНЫЙ ПЕРЕХОД: cancelled → draft → completed ===
# При прямом переходе из отрицательного (cancelled) в положительный (completed) статус,
# делаем промежуточный переход через нейтральный статус 'draft'.
# Это гарантирует корректную работу всех сигналов:
# 1. cancelled → draft: витринные букеты available → reserved
# 2. draft → completed: резервы и букеты корректно финализируются в sold
if self.pk: # Только при редактировании
try:
old_instance = Order.objects.get(pk=self.pk)
old_status = old_instance.status
new_status = self.status
# Проверяем: переход от отрицательного к положительному?
if (old_status and old_status.is_negative_end and
new_status and new_status.is_positive_end):
logger.info(
f"🔄 Заказ #{self.order_number}: Обнаружен прямой переход "
f"{old_status.name} (отрицательный) → {new_status.name} (положительный). "
f"Выполняем автоматический промежуточный переход через 'draft'..."
)
# Получаем статус 'draft'
try:
draft_status = OrderStatus.objects.get(code='draft', is_system=True)
except OrderStatus.DoesNotExist:
raise ValidationError(
f"Невозможно выполнить переход из '{old_status.name}' в '{new_status.name}': "
f"системный статус 'draft' не найден. Обратитесь к администратору."
)
# Сохраняем целевой статус для второго шага
target_status = new_status
# ШАГ 1: cancelled → draft
logger.info(f" 📍 Шаг 1/2: {old_status.name} → draft")
self.status = draft_status
with transaction.atomic():
super().save(*args, **kwargs)
# Обновляем old_instance для следующего шага
old_instance.refresh_from_db()
# ШАГ 2: draft → completed
logger.info(f" 📍 Шаг 2/2: draft → {target_status.name}")
self.status = target_status
with transaction.atomic():
super().save(*args, **kwargs)
logger.info(
f"✅ Заказ #{self.order_number}: Промежуточный переход завершён успешно. "
f"Итоговый статус: {target_status.name}"
)
return # Выходим, т.к. save() уже вызван дважды
except Order.DoesNotExist:
# Заказ ещё не создан в БД (не должно произойти, но на всякий случай)
pass
# === ВАЛИДАЦИЯ: Проверяем доступность витринных комплектов ===
# При переходе ИЗ cancelled к любому не-отменённому статусу
if self.pk: # Только при редактировании
try:
# Получаем старый статус из БД
old_instance = Order.objects.get(pk=self.pk)
old_status = old_instance.status
new_status = self.status
# Проверяем: переход от cancelled к не-cancelled?
if (old_status and old_status.is_negative_end and
new_status and not new_status.is_negative_end):
# Находим все витринные комплекты в этом заказе
from orders.models import OrderItem
showcase_items = OrderItem.objects.filter(
order=self,
product_kit__is_temporary=True,
product_kit__showcase__isnull=False
).select_related('product_kit')
if showcase_items.exists():
# Проверяем доступность резервов для каждого комплекта
from inventory.models import Reservation
unavailable_kits = []
for item in showcase_items:
kit = item.product_kit
# КРИТИЧНО: Ищем ВСЕ витринные резервы этого комплекта
# Проверяем не привязаны ли они к ДРУГОМУ заказу (в ЛЮБОМ статусе)
occupied_reservations = Reservation.objects.filter(
product_kit=kit,
showcase__isnull=False,
order_item__isnull=False # Привязаны к какому-то заказу
).exclude(
order_item__order=self # Исключаем текущий заказ
).select_related('order_item__order')
if occupied_reservations.exists():
# Резервы заняты другим заказом - блокируем переход
occupied_res = occupied_reservations.first()
other_order_number = occupied_res.order_item.order.order_number
other_order_status = occupied_res.order_item.order.status.name if occupied_res.order_item.order.status else 'неизвестен'
unavailable_kits.append(
f"Витринный комплект '{kit.name}' занят заказом #{other_order_number} (статус: {other_order_status})"
)
else:
# Проверяем что вообще есть резервы для этого комплекта
any_reservations = Reservation.objects.filter(
product_kit=kit,
showcase__isnull=False
).exists()
if not any_reservations:
# Комплект демонтирован или удалён
unavailable_kits.append(
f"Витринный комплект '{kit.name}' больше не существует на витрине"
)
# Если есть недоступные комплекты - блокируем переход
if unavailable_kits:
error_message = (
f"Невозможно восстановить заказ #{self.order_number}. "
f"Витринные комплекты уже проданы:\n\n" +
"\n".join(f"\u2022 {msg}" for msg in unavailable_kits) +
f"\n\nОтмените сначала соответствующие заказы или удалите эти позиции из заказа."
)
raise ValidationError(error_message)
except Order.DoesNotExist:
# Заказ ещё не создан в БД (не должно произойти, но на всякий случай)
pass
# Оборачиваем в транзакцию чтобы ValidationError в сигналах откатывал save()
with transaction.atomic():
super().save(*args, **kwargs)
def recalculate_amount_paid(self):
"""
Пересчитывает оплаченную сумму на основе транзакций.
amount_paid = сумма_платежей - суммаозвратов
"""
from django.db.models import Sum, Q
from decimal import Decimal
payments_sum = self.transactions.filter(
transaction_type='payment'
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
refunds_sum = self.transactions.filter(
transaction_type='refund'
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
self.amount_paid = payments_sum - refunds_sum
self.update_payment_status()
def update_payment_status(self):
"""Автоматически обновляет статус оплаты на основе amount_paid"""
if self.amount_paid >= self.total_amount:
self.payment_status = 'paid'
self.is_paid = True
elif self.amount_paid > 0:
self.payment_status = 'partial'
self.is_paid = False
else:
self.payment_status = 'unpaid'
self.is_paid = False
# Сохраняем только изменённые поля для оптимизации
self.save(update_fields=['payment_status', 'is_paid', 'amount_paid'])
def is_draft(self):
"""Проверяет, является ли заказ черновиком"""
return self.status and self.status.code == 'draft'
@property
def amount_due(self):
"""Остаток к оплате"""
return max(self.total_amount - self.amount_paid, 0)
@property
def overpayment(self):
"""Переплата (если amount_paid > total_amount)"""
return max(self.amount_paid - self.total_amount, 0)
@property
def subtotal(self):
"""Сумма только товаров (без доставки)"""
return sum(item.get_total_price() for item in self.items.all())
def calculate_total(self):
"""
Пересчитывает итоговую сумму заказа.
total_amount = subtotal + delivery_cost - discount_amount
Скидка берётся из DiscountApplication
"""
from decimal import Decimal
subtotal = self.subtotal
delivery_cost = Decimal('0')
# Получаем стоимость доставки из связанной модели Delivery
if hasattr(self, 'delivery'):
delivery_cost = self.delivery.cost
# Вычитаем скидку на весь заказ (из DiscountApplication через свойство)
order_discount = Decimal(str(self.discount_amount)) if self.discount_amount else Decimal('0')
self.total_amount = subtotal + delivery_cost - order_discount
self.save(update_fields=['total_amount'])
def reset_delivery_cost(self):
"""
Сбрасывает стоимость доставки.
Если есть Delivery, устанавливает cost = 0.
"""
if hasattr(self, 'delivery'):
self.delivery.cost = 0
self.delivery.save(update_fields=['cost'])
# === Свойства для доступа к скидкам (через DiscountApplication) ===
@property
def order_discounts(self):
"""Скидки на заказ (QuerySet DiscountApplication с target='order')"""
from discounts.models import DiscountApplication
return self.discount_applications.filter(target='order').select_related('discount', 'promo_code')
@property
def item_discounts(self):
"""Скидки на позиции (QuerySet DiscountApplication с target='order_item')"""
from discounts.models import DiscountApplication
return self.discount_applications.filter(target='order_item').select_related('discount', 'order_item')
@property
def discount_amount(self):
"""Общая сумма скидки на заказ"""
from decimal import Decimal
return self.order_discounts.aggregate(
total=models.Sum('discount_amount')
)['total'] or Decimal('0')
@property
def applied_discount(self):
"""Первая применённая скидка (для обратной совместимости)"""
first = self.order_discounts.first()
return first.discount if first else None
@property
def applied_promo_code(self):
"""Промокод из первой записи (для обратной совместимости)"""
first = self.order_discounts.first()
if first and first.promo_code:
return first.promo_code.code
return None
# === Свойства обратной совместимости для доступа к полям доставки ===
# Эти свойства обеспечивают доступ к полям Delivery через Order для обратной совместимости
# после рефакторинга, когда поля доставки были перенесены в отдельную модель Delivery
@property
def delivery_date(self):
"""Дата доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.delivery_date
return None
@property
def delivery_time_start(self):
"""Время начала доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.time_from
return None
@property
def delivery_time_end(self):
"""Время окончания доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.time_to
return None
@property
def delivery_time_window(self):
"""Форматированное окно времени доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
if self.delivery.time_from and self.delivery.time_to:
return f"{self.delivery.time_from.strftime('%H:%M')} - {self.delivery.time_to.strftime('%H:%M')}"
return None
@property
def delivery_time(self):
"""Время доставки (обратная совместимость, использует delivery_time_window)"""
return self.delivery_time_window
@property
def is_delivery(self):
"""Является ли заказ доставкой (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
from .delivery import Delivery
return self.delivery.delivery_type == Delivery.DELIVERY_TYPE_COURIER
return False
@property
def delivery_address(self):
"""Адрес доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.address
return None
@property
def delivery_cost(self):
"""Стоимость доставки (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.cost
return 0
@property
def pickup_warehouse(self):
"""Склад самовывоза (обратная совместимость)"""
if hasattr(self, 'delivery') and self.delivery:
return self.delivery.pickup_warehouse
return None
@property
def is_customer_recipient(self):
"""Является ли покупатель получателем (обратная совместимость)"""
return self.recipient is None