- Добавлен combine_mode в форму создания/редактирования скидок - Добавлена колонка "Объединение" в список скидок с иконками - Добавлен фильтр по режиму объединения скидок - Добавлена валидация: только одна exclusive скидка на заказ - Удалены дублирующие поля из Order и OrderItem: - applied_discount, applied_promo_code, discount_amount - Скидки теперь хранятся только в DiscountApplication - Добавлены свойства для обратной совместимости Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
524 lines
24 KiB
Python
524 lines
24 KiB
Python
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
|