from django.db import models from accounts.models import CustomUser from customers.models import Customer from simple_history.models import HistoricalRecords 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="Общая сумма заказа" ) # Частичная оплата 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="Последний пользователь, изменивший заказ" ) # История изменений 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 # Генерируем уникальный номер заказа при создании (начиная с 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 к любому не-отменённому статусу 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 """ from decimal import Decimal subtotal = self.subtotal delivery_cost = Decimal('0') # Получаем стоимость доставки из связанной модели Delivery if hasattr(self, 'delivery'): delivery_cost = self.delivery.cost self.total_amount = subtotal + delivery_cost 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']) # === Свойства обратной совместимости для доступа к полям доставки === # Эти свойства обеспечивают доступ к полям 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