diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index 49bf505..4e84a02 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -732,76 +732,102 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs): f"converted_to_sale → {reservation_target_status}" ) - # Витринные временные комплекты ВСЕГДА возвращаются в reserved (остаются на витрине) + # Витринные временные комплекты: логика зависит от сценария if showcase_count > 0: - for reservation in showcase_kit_reservations: - reservation.status = 'reserved' - # Очищаем ТОЛЬКО блокировки корзины при отмене заказа - # НЕ трогаем order_item - он нужен для повторной продажи при возврате в completed - reservation.cart_lock_expires_at = None - reservation.locked_by_user = None - reservation.cart_session_id = None - # Не трогаем showcase и product_kit - они остаются привязанными - # converted_at оставляем (для истории) - reservation.save(update_fields=['status', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id']) - - logger.info( - f"✓ Обновлено {showcase_count} резервов витринных комплектов: " - f"converted_to_sale → reserved (возвращены на витрину, блокировки сняты, order_item сохранён)" - ) + if is_cancellation: + # Сценарий Б: Отмена - возвращаем на витрину + for reservation in showcase_kit_reservations: + reservation.status = 'reserved' + # КРИТИЧНО: Отвязываем резервы от заказа при отмене + reservation.order_item = None + # Очищаем блокировки корзины + reservation.cart_lock_expires_at = None + reservation.locked_by_user = None + reservation.cart_session_id = None + # showcase_item и product_kit остаются - букет на витрине + # converted_at оставляем (для истории) + reservation.save(update_fields=['status', 'order_item', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id']) + + logger.info( + f"✓ Обновлено {showcase_count} резервов витринных комплектов: " + f"converted_to_sale → reserved (возвращены на витрину, отвязаны от заказа, блокировки сняты)" + ) + else: + # Сценарий А: Возврат к нейтральному - резервы ОСТАЮТСЯ в заказе + for reservation in showcase_kit_reservations: + reservation.status = 'reserved' + # Очищаем ТОЛЬКО блокировки корзины + # order_item НЕ ТРОГАЕМ - резерв остаётся за заказом! + reservation.cart_lock_expires_at = None + reservation.locked_by_user = None + reservation.cart_session_id = None + # converted_at оставляем (для истории) + reservation.save(update_fields=['status', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id']) + + logger.info( + f"✓ Обновлено {showcase_count} резервов витринных комплектов: " + f"converted_to_sale → reserved (остаются в заказе, блокировки сняты)" + ) else: logger.warning( f"⚠ Для заказа {instance.order_number} нет резервов в статусе 'converted_to_sale'" ) # === Возвращаем витринные экземпляры обратно на витрину === - from inventory.models import ShowcaseItem - - # Находим все ShowcaseItem, проданные в рамках этого заказа - showcase_items = ShowcaseItem.objects.filter( - sold_order_item__order=instance, - status='sold' - ) - - showcase_items_count = showcase_items.count() - - if showcase_items_count > 0: - logger.info( - f"🔄 Возвращаем {showcase_items_count} витринных экземпляров обратно на витрину..." + # ТОЛЬКО при отмене (отрицательный статус)! + if is_cancellation: + from inventory.models import ShowcaseItem + + # Находим все ShowcaseItem, проданные в рамках этого заказа + showcase_items = ShowcaseItem.objects.filter( + sold_order_item__order=instance, + status='sold' ) - # Возвращаем каждый экземпляр на витрину - for item in showcase_items: - item.status = 'available' - item.sold_order_item = None - item.sold_at = None - # showcase и product_kit не трогаем - букет остаётся на той же витрине - item.save(update_fields=['status', 'sold_order_item', 'sold_at', 'updated_at']) - - # КРИТИЧНО: Восстанавливаем связь между ShowcaseItem и Reservation - # Находим все резервы этого комплекта для данного OrderItem - order_item = item.sold_order_item if hasattr(item, '_original_sold_order_item') else None - if not order_item: - # Пытаемся найти через заказ и product_kit - order_items = instance.items.filter(product_kit=item.product_kit) - if order_items.exists(): - order_item = order_items.first() - - if order_item: - # Восстанавливаем связь showcase_item в резервах - reservations_updated = Reservation.objects.filter( - order_item=order_item, - product_kit=item.product_kit, - status='reserved' - ).update(showcase_item=item) - - if reservations_updated > 0: - logger.debug( - f" ✅ Восстановлена связь для {reservations_updated} резервов ShowcaseItem #{item.id}" - ) + showcase_items_count = showcase_items.count() + if showcase_items_count > 0: + logger.info( + f"🔄 Возвращаем {showcase_items_count} витринных экземпляров обратно на витрину..." + ) + + # Возвращаем каждый экземпляр на витрину + for item in showcase_items: + item.status = 'available' + item.sold_order_item = None + item.sold_at = None + # showcase и product_kit не трогаем - букет остаётся на той же витрине + item.save(update_fields=['status', 'sold_order_item', 'sold_at', 'updated_at']) + + # КРИТИЧНО: Восстанавливаем связь между ShowcaseItem и Reservation + # Находим все резервы этого комплекта для данного OrderItem + order_item = item.sold_order_item if hasattr(item, '_original_sold_order_item') else None + if not order_item: + # Пытаемся найти через заказ и product_kit + order_items = instance.items.filter(product_kit=item.product_kit) + if order_items.exists(): + order_item = order_items.first() + + if order_item: + # Восстанавливаем связь showcase_item в резервах + reservations_updated = Reservation.objects.filter( + order_item=order_item, + product_kit=item.product_kit, + status='reserved' + ).update(showcase_item=item) + + if reservations_updated > 0: + logger.debug( + f" ✅ Восстановлена связь для {reservations_updated} резервов ShowcaseItem #{item.id}" + ) + + logger.info( + f"✅ {showcase_items_count} витринных экземпляров вернулись на витрину: sold → available со связью с резервами" + ) + else: + # Сценарий А: Возврат к нейтральному - ShowcaseItem ОСТАЁТСЯ sold logger.info( - f"✅ {showcase_items_count} витринных экземпляров вернулись на витрину: sold → available со связью с резервами" + f"ℹ️ Сценарий А: Витринные экземпляры остаются в статусе 'sold' (заказ в нейтральном статусе)" ) # === Обновляем is_returned === @@ -916,15 +942,16 @@ def release_reservations_on_cancellation(sender, instance, created, **kwargs): if showcase_count > 0: # Для витринных комплектов очищаем блокировки корзины - # НЕ трогаем order_item - он нужен для повторной продажи если статус вернётся в completed + # КРИТИЧНО: Отвязываем резервы от заказа при отмене for reservation in showcase_kit_reservations: + reservation.order_item = None reservation.cart_lock_expires_at = None reservation.locked_by_user = None reservation.cart_session_id = None - reservation.save(update_fields=['cart_lock_expires_at', 'locked_by_user', 'cart_session_id']) + reservation.save(update_fields=['order_item', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id']) logger.info( - f"ℹ️ Найдено {showcase_count} резервов витринных комплектов - остаются в reserved (на витрине, блокировки сняты, order_item сохранён)" + f"ℹ️ Найдено {showcase_count} резервов витринных комплектов - остаются в reserved (на витрине, отвязаны от заказа, блокировки сняты)" ) if normal_count == 0 and showcase_count == 0: @@ -996,7 +1023,7 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs): return # Не было перехода от cancelled, выходим # === Резервируем товар заново === - # Ищем резервы в статусе 'released' + # Ищем резервы в статусе 'released' (обычные резервы) reservations = Reservation.objects.filter( order_item__order=instance, status='released' @@ -1023,6 +1050,52 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs): logger.debug( f"ℹ️ Для заказа {instance.order_number} нет резервов в статусе 'released'" ) + + # === Привязываем витринные резервы обратно к заказу === + # Витринные резервы остаются в статусе 'reserved' при отмене, + # но отвязываются от order_item. При возврате нужно привязать их обратно. + + # Находим все OrderItem витринных комплектов в этом заказе + showcase_order_items = instance.items.filter( + product_kit__is_temporary=True, + product_kit__showcase__isnull=False + ).select_related('product_kit') + + showcase_items_count = showcase_order_items.count() + + if showcase_items_count > 0: + logger.info( + f"🔄 Найдено {showcase_items_count} витринных комплектов в заказе. " + f"Привязываем резервы обратно к заказу..." + ) + + for order_item in showcase_order_items: + kit = order_item.product_kit + + # Находим витринные резервы для этого комплекта + # (они в статусе 'reserved', но order_item=None) + showcase_reservations = Reservation.objects.filter( + product_kit=kit, + showcase__isnull=False, + status='reserved', + order_item__isnull=True + ) + + if showcase_reservations.exists(): + # Привязываем резервы обратно к OrderItem + updated_count = showcase_reservations.update(order_item=order_item) + + logger.info( + f" ✅ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{order_item.id}" + ) + else: + logger.warning( + f" ⚠ Витринный комплект '{kit.name}': не найдено витринных резервов без order_item" + ) + + logger.info( + f"✅ Обработано {showcase_items_count} витринных комплектов" + ) @receiver(pre_delete, sender=Order) diff --git a/myproject/inventory/templates/inventory/debug_page.html b/myproject/inventory/templates/inventory/debug_page.html index fbcded1..19944dd 100644 --- a/myproject/inventory/templates/inventory/debug_page.html +++ b/myproject/inventory/templates/inventory/debug_page.html @@ -292,6 +292,7 @@ Единица продажи Статус Заказ + Витрина Создан Преобразован Освобожден @@ -341,6 +342,15 @@ - {% endif %} + + {% if res.showcase %} + 🌺 {{ res.showcase.name }} + {% elif res.showcase_item %} + 🌺 {{ res.showcase_item.showcase.name }} + {% else %} + - + {% endif %} + {{ res.reserved_at|date:"d.m.Y H:i:s" }} {% if res.converted_at %}{{ res.converted_at|date:"d.m.Y H:i:s" }}{% else %}-{% endif %} @@ -350,7 +360,7 @@ {% empty %} - Нет резервов + Нет резервов {% endfor %} diff --git a/myproject/orders/models/order.py b/myproject/orders/models/order.py index 49fec7b..9e32dee 100644 --- a/myproject/orders/models/order.py +++ b/myproject/orders/models/order.py @@ -177,6 +177,8 @@ class Order(models.Model): 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() @@ -185,6 +187,81 @@ class Order(models.Model): 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():