From 2aba3d240491ea5f63fe11702be5cd1240f5bd22 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Mon, 5 Jan 2026 21:30:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D1=85=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=BE=D0=B2=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=BE=D0=B2=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Исправлены комментарии и форматирование в signals.py - Улучшена читаемость кода в models.py - Обновлены шаблоны форм статусов - Доработаны тесты переходов статусов --- myproject/inventory/models.py | 11 + myproject/inventory/signals.py | 245 +++++++++--------- .../tests/test_order_status_transitions.py | 235 +++++++++++++++++ myproject/orders/models/status.py | 15 ++ .../orders/templates/orders/status_form.html | 15 ++ 5 files changed, 400 insertions(+), 121 deletions(-) diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py index 0cfcc51..4d713f6 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -488,6 +488,17 @@ class Reservation(models.Model): help_text="Для какого физического экземпляра создан резерв" ) + # ЗАЩИТА ОТ КРАЖИ ВИТРИННЫХ РЕЗЕРВОВ: запоминаем исходный order_item при отвязывании + # Когда витринный резерв отвязывается от заказа (order_item=None), здесь сохраняется ID + # исходного OrderItem. При обратном привязывании резерв вернётся только к своему "хозяину". + original_order_item_id = models.IntegerField( + null=True, + blank=True, + db_index=True, + verbose_name="ID исходной позиции заказа", + help_text="Для витринных резервов: ID OrderItem, которому изначально принадлежал резерв (защита от кражи)" + ) + # Связь с позицией документа списания (для резервирования в черновике) writeoff_document_item = models.ForeignKey( 'WriteOffDocumentItem', diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index b729bb9..890e406 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -305,13 +305,43 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): # Проверяем: это положительный финальный статус? is_positive_end = instance.status.is_positive_end - + if not is_positive_end: return # Только для положительных финальных статусов (completed и т.п.) - + + # === ЗАЩИТА ОТ RACE CONDITION: Проверяем предыдущий статус === + # Если уже были в completed и снова переходим в completed (например completed → draft → completed), + # проверяем наличие Sale чтобы избежать дублирования + try: + history_count = instance.history.count() + if history_count >= 2: + previous_record = instance.history.all()[1] + if previous_record.status_id: + from orders.models import OrderStatus + previous_status = OrderStatus.objects.get(id=previous_record.status_id) + + # Если предыдущий статус тоже был положительным финальным + if previous_status.is_positive_end: + logger.info( + f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус " + f"({previous_status.name} → {instance.status.name}). Проверяем Sale..." + ) + # Проверяем есть ли уже Sale + if Sale.objects.filter(order=instance).exists(): + logger.info( + f"✓ Заказ {instance.order_number}: Sale уже существуют, пропускаем создание" + ) + update_is_returned_flag(instance) + return + except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError): + pass + # Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа if Sale.objects.filter(order=instance).exists(): # Продажи уже созданы — просто обновляем флаг is_returned и выходим + logger.info( + f"✓ Заказ {instance.order_number}: Sale уже существуют (проверка до создания)" + ) update_is_returned_flag(instance) return @@ -365,28 +395,6 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): product_kit=kit ).exclude(status='converted_to_sale') - # Если резервы не найдены по order_item (например, при переходе из cancelled), - # ищем витринные резервы через product_kit - if not kit_reservations.exists() and kit.is_temporary and kit.showcase: - logger.info( - f"🔄 Витринный комплект '{kit.name}': резервы не найдены по order_item. " - f"Ищем через product_kit..." - ) - - # Ищем резервы для этого комплекта (могут быть без order_item при переходе из cancelled) - kit_reservations = Reservation.objects.filter( - product_kit=kit, - showcase__isnull=False, - status='reserved' - ) - - # Привязываем резервы к order_item перед созданием Sale - if kit_reservations.exists(): - updated_count = kit_reservations.update(order_item=item) - logger.info( - f" ✅ Привязано {updated_count} резервов к OrderItem #{item.id}" - ) - if not kit_reservations.exists(): # Проверяем, может быть витринный комплект уже продан через ShowcaseManager? already_sold = Reservation.objects.filter( @@ -516,13 +524,13 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): f"✓ Обновлено {updated_count} резервов для заказа {instance.order_number}: reserved → converted_to_sale" ) - # === Финализация витринных экземпляров: reserved/available → sold === - # Находим все витринные комплекты в этом заказе: - # - в статусе 'reserved' (обычный flow: создание черновика → завершение) - # - в статусе 'available' (переход из отмены: cancelled → completed) + # === Финализация витринных экземпляров: reserved → sold === + # Находим все витринные комплекты в этом заказе в статусе 'reserved'. + # Прямой переход cancelled → completed НЕВОЗМОЖЕН (автоматически разбивается на draft). + # Поэтому букеты всегда в 'reserved' при достижении completed. from inventory.models import ShowcaseItem - # Сначала ищем по sold_order_item (для букетов в reserved) + # Ищем по sold_order_item (для букетов в reserved) showcase_items_to_finalize = ShowcaseItem.objects.filter( sold_order_item__order=instance, status='reserved' @@ -542,44 +550,6 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): f"❌ Ошибка финализации ShowcaseItem #{showcase_item.id}: {e}" ) - # Теперь ищем букеты в available (переход из cancelled) - # При отмене sold_order_item сбрасывается, поэтому ищем через product_kit - showcase_order_items = instance.items.filter( - product_kit__is_temporary=True, - product_kit__showcase__isnull=False - ).select_related('product_kit') - - for order_item in showcase_order_items: - kit = order_item.product_kit - - # Находим ShowcaseItem этого комплекта в статусе 'available' - # Их sold_order_item = None после отмены, поэтому ищем через product_kit - available_items = ShowcaseItem.objects.filter( - product_kit=kit, - status='available', - sold_order_item__isnull=True - ) - - if available_items.exists(): - logger.info( - f" 🔄 Найдено {available_items.count()} ShowcaseItem комплекта '{kit.name}' в статусе 'available'. " - f"Финализируем: available → sold (из отмены)..." - ) - - for item in available_items: - try: - # Переход из отмены: available → sold (минуя reserved) - # Используем mark_sold() который работает с available - item.mark_sold(order_item) - finalized_count += 1 - logger.info( - f" ✅ ShowcaseItem #{item.id}: available → sold (привязан к OrderItem #{order_item.id})" - ) - except Exception as e: - logger.error( - f" ❌ Ошибка финализации ShowcaseItem #{item.id}: {e}" - ) - if finalized_count > 0: logger.info( f"🎉 Финализировано {finalized_count} витринных экземпляров для заказа {instance.order_number}" @@ -835,6 +805,9 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs): for reservation in showcase_kit_reservations: reservation.status = 'reserved' # КРИТИЧНО: Отвязываем резервы от заказа при отмене + # Сохраняем ID для защиты от кражи резервов другими заказами + if reservation.order_item: + reservation.original_order_item_id = reservation.order_item.id reservation.order_item = None # Очищаем блокировки корзины reservation.cart_lock_expires_at = None @@ -842,7 +815,8 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs): 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']) + reservation.save(update_fields=['status', 'order_item', 'original_order_item_id', + 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id']) logger.info( f"✓ Обновлено {showcase_count} резервов витринных комплектов: " @@ -913,13 +887,19 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs): order_item = order_items.first() if order_item: - # Восстанавливаем связь showcase_item в резервах - reservations_updated = Reservation.objects.filter( + # Восстанавливаем связь showcase_item в резервах через .save() + reservations = Reservation.objects.filter( order_item=order_item, product_kit=item.product_kit, status='reserved' - ).update(showcase_item=item) - + ) + + reservations_updated = 0 + for reservation in reservations: + reservation.showcase_item = item + reservation.save(update_fields=['showcase_item']) + reservations_updated += 1 + if reservations_updated > 0: logger.debug( f" ✅ Восстановлена связь для {reservations_updated} резервов ShowcaseItem #{item.id}" @@ -1075,11 +1055,15 @@ def release_reservations_on_cancellation(sender, instance, created, **kwargs): # Для витринных комплектов очищаем блокировки корзины # КРИТИЧНО: Отвязываем резервы от заказа при отмене for reservation in showcase_kit_reservations: + # Сохраняем ID для защиты от кражи резервов другими заказами + if reservation.order_item: + reservation.original_order_item_id = reservation.order_item.id reservation.order_item = None reservation.cart_lock_expires_at = None reservation.locked_by_user = None reservation.cart_session_id = None - reservation.save(update_fields=['order_item', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id']) + reservation.save(update_fields=['order_item', 'original_order_item_id', + 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id']) logger.info( f"ℹ️ Найдено {showcase_count} резервов витринных комплектов - остаются в reserved (на витрине, отвязаны от заказа, блокировки сняты)" @@ -1128,7 +1112,7 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs): """ Сигнал: Резервирование товара при переходе ОТ статуса 'cancelled' к другим статусам. - Триггер: cancelled → любой НЕ отменённый статус (draft, pending, completed и т.д.) + Триггер: cancelled → любой НЕ отменённый статус (draft, pending и т.д.) Процесс: 1. Проверяем что предыдущий статус был 'cancelled' (is_negative_end) @@ -1138,9 +1122,13 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs): 5. Stock автоматически обновится через сигнал ПРИМЕРЫ сценариев: - - cancelled → pending: резервы 'released' → 'reserved' ✅ - cancelled → draft: резервы 'released' → 'reserved' ✅ - - cancelled → completed: резервы 'released' → 'reserved', затем create_sale_on_order_completion обработает ✅ + - cancelled → pending: резервы 'released' → 'reserved' ✅ + + ПРИМЕЧАНИЕ: Прямой переход cancelled → completed НЕВОЗМОЖЕН. + Order.save() автоматически разбивает его на два шага: + 1. cancelled → draft (этот сигнал резервирует) + 2. draft → completed (create_sale_on_order_completion финализирует) """ import logging logger = logging.getLogger(__name__) @@ -1229,26 +1217,33 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs): for order_item in showcase_order_items: kit = order_item.product_kit - - # Находим витринные резервы для этого комплекта - # (они в статусе 'reserved', но order_item=None) + + # КРИТИЧНО: Находим ТОЛЬКО "родные" витринные резервы этого OrderItem + # Используем original_order_item_id для защиты от кражи резервов другими заказами showcase_reservations = Reservation.objects.filter( product_kit=kit, showcase__isnull=False, status='reserved', - order_item__isnull=True + order_item__isnull=True, # Отвязаны от заказа + original_order_item_id=order_item.id # ТОЛЬКО "родные" резервы! ) - + if showcase_reservations.exists(): # Привязываем резервы обратно к OrderItem - updated_count = showcase_reservations.update(order_item=order_item) - + # Используем цикл вместо bulk update чтобы очистить original_order_item_id + updated_count = 0 + for reservation in showcase_reservations: + reservation.order_item = order_item + reservation.original_order_item_id = None # Очищаем после привязывания + reservation.save(update_fields=['order_item', 'original_order_item_id']) + updated_count += 1 + logger.info( f" ✅ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{order_item.id}" ) else: logger.warning( - f" ⚠ Витринный комплект '{kit.name}': не найдено витринных резервов без order_item" + f" ⚠ Витринный комплект '{kit.name}': не найдено витринных резервов с original_order_item_id={order_item.id}" ) logger.info( @@ -1356,25 +1351,32 @@ def release_stock_on_order_delete(sender, instance, **kwargs): @transaction.atomic def update_reservation_on_item_change(sender, instance, created, **kwargs): """ - Сигнал: При создании или изменении позиции заказа управляем резервами. + Сигнал: При изменении позиции заказа управляем резервами. + + ВАЖНО: При СОЗДАНИИ OrderItem резервы создаются сигналом reserve_stock_on_item_create. + Этот сигнал обрабатывает только ИЗМЕНЕНИЯ существующих OrderItem. Процесс: 1. Ищем существующий резерв для этой позиции (в ЛЮБОМ статусе) 2. Если резерв ЕСТЬ - обновляем ТОЛЬКО количество (статус НЕ меняем!) - 3. Если резерва НЕТ - создаем новый + 3. Если резерва НЕТ - создаем новый (только для обновлений) - Покрывает все сценарии: - - Создание заказа с товарами → создаёт резервы + Покрывает сценарии: - Редактирование + добавление товаров → создаёт резервы для новых - Изменение количества → обновляет резервы (даже если уже converted_to_sale) - Витринные комплекты → привязывает существующие витринные резервы к заказу - + КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов! Резерв ищется по order_item независимо от статуса. Это предотвращает создание нового резерва для заказа в статусе 'completed'. """ import logging logger = logging.getLogger(__name__) + + # КРИТИЧНО: Пропускаем создание - обрабатывается reserve_stock_on_item_create + # Это предотвращает дублирование резервов при создании OrderItem + if created: + return # Проверяем, это витринный комплект или обычный товар/комплект is_showcase_kit = instance.product_kit and instance.product_kit.is_temporary and instance.product_kit.showcase @@ -1392,11 +1394,13 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs): if showcase_reservations.exists(): # Привязываем витринные резервы к позиции заказа - updated_count = showcase_reservations.update( - order_item=instance, - # Оставляем showcase и product_kit для аудита - ) - + # Используем цикл вместо bulk update чтобы сработали сигналы обновления Stock + updated_count = 0 + for reservation in showcase_reservations: + reservation.order_item = instance + reservation.save(update_fields=['order_item']) + updated_count += 1 + logger.info( f"✓ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{instance.id}, " f"заказ {instance.order.order_number}" @@ -1906,11 +1910,15 @@ def reserve_on_transformation_input_create(sender, instance, created, **kwargs): @receiver(pre_delete, sender=TransformationInput) def release_reservation_on_input_delete(sender, instance, **kwargs): """ - При удалении входного товара - освобождаем резерв. + При удалении входного товара - освобождаем резерв через .save() для обновления Stock. """ - Reservation.objects.filter( + reservations = Reservation.objects.filter( transformation_input=instance - ).update(status='released', released_at=timezone.now()) + ) + for reservation in reservations: + reservation.status = 'released' + reservation.released_at = timezone.now() + reservation.save(update_fields=['status', 'released_at']) @receiver(post_save, sender=Transformation) @@ -1945,23 +1953,16 @@ def process_transformation_on_complete(sender, instance, created, **kwargs): for batch, qty in allocations: total_input_cost += batch.cost_price * qty - # Обновляем резерв - reservations_updated = Reservation.objects.filter( + # Обновляем резерв через .save() чтобы сработали сигналы обновления Stock + reservations = Reservation.objects.filter( transformation_input=trans_input, status='reserved' - ).update( - status='converted_to_transformation', - converted_at=timezone.now() ) - - # ВАЖНО: .update() не вызывает сигналы, поэтому нужно вручную обновить Stock - if reservations_updated > 0: - stock = Stock.objects.filter( - product=trans_input.product, - warehouse=instance.warehouse - ).first() - if stock: - stock.refresh_from_batches() + + for reservation in reservations: + reservation.status = 'converted_to_transformation' + reservation.converted_at = timezone.now() + reservation.save(update_fields=['status', 'converted_at']) # 2. Создаем партии Output for trans_output in instance.outputs.all(): @@ -2036,13 +2037,14 @@ def rollback_transformation_on_cancel(sender, instance, **kwargs): cost_price=cost ) - # Возвращаем резерв в reserved - Reservation.objects.filter( + # Возвращаем резерв в reserved через .save() чтобы обновился Stock + reservations = Reservation.objects.filter( transformation_input=trans_input - ).update( - status='reserved', - converted_at=None ) + for reservation in reservations: + reservation.status = 'reserved' + reservation.converted_at = None + reservation.save(update_fields=['status', 'converted_at']) @receiver(post_save, sender=Transformation) @@ -2065,11 +2067,12 @@ def release_reservations_on_draft_cancel(sender, instance, **kwargs): if has_converted_reservations: return # Это была проведенная трансформация, обрабатывается другим сигналом - # Освобождаем все резервы - Reservation.objects.filter( + # Освобождаем все резервы через .save() чтобы обновился Stock + reservations = Reservation.objects.filter( transformation_input__transformation=instance, status='reserved' - ).update( - status='released', - released_at=timezone.now() ) + for reservation in reservations: + reservation.status = 'released' + reservation.released_at = timezone.now() + reservation.save(update_fields=['status', 'released_at']) diff --git a/myproject/inventory/tests/test_order_status_transitions.py b/myproject/inventory/tests/test_order_status_transitions.py index 2cb3553..08c351e 100644 --- a/myproject/inventory/tests/test_order_status_transitions.py +++ b/myproject/inventory/tests/test_order_status_transitions.py @@ -902,3 +902,238 @@ class OrderStatusTransitionCriticalTest(TestCase): # Проверяем, что is_returned False (есть Sale) order.refresh_from_db() self.assertFalse(order.is_returned, "[COMPLETED AGAIN] is_returned должен быть False при наличии Sale") + + # ==================== ТЕСТ 9: Хаотичная смена статусов (стресс-тест) ==================== + + def test_09_random_status_changes_stress_test(self): + """ + ТЕСТ #9: Хаотичная смена статусов заказа в разные стороны + + Сценарий (симулирует реальное поведение пользователя): + 1. draft → completed (продажа) + 2. completed → draft (откат) + 3. draft → cancelled (отмена) + 4. cancelled → in_assembly (возобновление) + 5. in_assembly → completed (продажа снова) + 6. completed → cancelled (отмена выполненного) + 7. cancelled → completed (автоматический промежуточный переход через draft) + 8. completed → draft → cancelled (откат и финальная отмена) + + Проверяем на каждом шаге: + - Консистентность резервов + - Консистентность Stock (quantity_available, quantity_reserved, quantity_free) + - Отсутствие дублирования Sale + - Корректность восстановления партий + - Корректность флага is_returned + """ + with schema_context('test_order_status'): + # Создаём заказ в draft + order = self._create_order(self.status_draft, quantity=Decimal('10.00')) + + # ========== ШАГ 1: draft → completed ========== + order.status = self.status_completed + order.save() + order.refresh_from_db() + + # Проверки после первой продажи + self._assert_stock_state( + available=Decimal('90.00'), + reserved=Decimal('0.00'), + free=Decimal('90.00'), + msg_prefix="[STEP 1: draft→completed] " + ) + self._assert_reservation_status(order, 'converted_to_sale', "[STEP 1] ") + self._assert_sale_exists(order, should_exist=True) + self.assertFalse(order.is_returned, "[STEP 1] is_returned должен быть False после продажи") + + # Проверяем партии + self.stock_batch.refresh_from_db() + self.assertEqual( + self.stock_batch.quantity, + Decimal('90.00'), + "[STEP 1] Партия должна уменьшиться на 10" + ) + + # ========== ШАГ 2: completed → draft ========== + order.status = self.status_draft + order.save() + order.refresh_from_db() + + # Проверки после отката + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('10.00'), + free=Decimal('90.00'), + msg_prefix="[STEP 2: completed→draft] " + ) + self._assert_reservation_status(order, 'reserved', "[STEP 2] ") + self._assert_sale_exists(order, should_exist=False) + self.assertTrue(order.is_returned, "[STEP 2] is_returned должен быть True (был completed, но Sale удалены)") + + # Партии должны восстановиться + self.stock_batch.refresh_from_db() + self.assertEqual( + self.stock_batch.quantity, + Decimal('100.00'), + "[STEP 2] Партия должна восстановиться" + ) + + # ========== ШАГ 3: draft → cancelled ========== + order.status = self.status_cancelled + order.save() + order.refresh_from_db() + + # Проверки после отмены + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('0.00'), + free=Decimal('100.00'), + msg_prefix="[STEP 3: draft→cancelled] " + ) + self._assert_reservation_status(order, 'released', "[STEP 3] ") + self._assert_sale_exists(order, should_exist=False) + self.assertTrue(order.is_returned, "[STEP 3] is_returned остается True") + + # ========== ШАГ 4: cancelled → in_assembly ========== + order.status = self.status_in_assembly + order.save() + order.refresh_from_db() + + # Проверки после возобновления + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('10.00'), + free=Decimal('90.00'), + msg_prefix="[STEP 4: cancelled→in_assembly] " + ) + self._assert_reservation_status(order, 'reserved', "[STEP 4] ") + self._assert_sale_exists(order, should_exist=False) + self.assertTrue(order.is_returned, "[STEP 4] is_returned остается True (Sale еще нет)") + + # ========== ШАГ 5: in_assembly → completed ========== + order.status = self.status_completed + order.save() + order.refresh_from_db() + + # Проверки после второй продажи + self._assert_stock_state( + available=Decimal('90.00'), + reserved=Decimal('0.00'), + free=Decimal('90.00'), + msg_prefix="[STEP 5: in_assembly→completed] " + ) + self._assert_reservation_status(order, 'converted_to_sale', "[STEP 5] ") + self._assert_sale_exists(order, should_exist=True) + self.assertFalse(order.is_returned, "[STEP 5] is_returned должен стать False (Sale созданы)") + + # Проверяем, что НЕТ дублей Sale + sales_count = Sale.objects.filter(order=order).count() + self.assertEqual(sales_count, 1, "[STEP 5] Должна быть ровно 1 Sale (нет дублей)") + + self.stock_batch.refresh_from_db() + self.assertEqual( + self.stock_batch.quantity, + Decimal('90.00'), + "[STEP 5] Партия должна снова уменьшиться на 10" + ) + + # ========== ШАГ 6: completed → cancelled ========== + order.status = self.status_cancelled + order.save() + order.refresh_from_db() + + # Проверки после отмены выполненного + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('0.00'), + free=Decimal('100.00'), + msg_prefix="[STEP 6: completed→cancelled] " + ) + self._assert_reservation_status(order, 'released', "[STEP 6] ") + self._assert_sale_exists(order, should_exist=False) + self.assertTrue(order.is_returned, "[STEP 6] is_returned должен быть True (был completed, Sale удалены)") + + self.stock_batch.refresh_from_db() + self.assertEqual( + self.stock_batch.quantity, + Decimal('100.00'), + "[STEP 6] Партия должна восстановиться после отмены" + ) + + # ========== ШАГ 7: cancelled → completed (автоматический промежуточный переход) ========== + # Должен произойти: cancelled → draft → completed + order.status = self.status_completed + order.save() + order.refresh_from_db() + + # Проверяем, что прошли через draft (автоматический промежуточный переход) + history = order.history.all() + self.assertGreaterEqual(history.count(), 2, "[STEP 7] Должна быть история переходов") + + # Проверки после автоматического перехода + self._assert_stock_state( + available=Decimal('90.00'), + reserved=Decimal('0.00'), + free=Decimal('90.00'), + msg_prefix="[STEP 7: cancelled→completed auto] " + ) + self._assert_reservation_status(order, 'converted_to_sale', "[STEP 7] ") + self._assert_sale_exists(order, should_exist=True) + self.assertFalse(order.is_returned, "[STEP 7] is_returned должен быть False") + + # Проверяем, что НЕТ дублей Sale после автоматического перехода + sales_count = Sale.objects.filter(order=order).count() + self.assertEqual(sales_count, 1, "[STEP 7] Должна быть ровно 1 Sale (нет дублей после auto-transition)") + + # ========== ШАГ 8: completed → draft → cancelled ========== + order.status = self.status_draft + order.save() + order.refresh_from_db() + + # Промежуточная проверка в draft + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('10.00'), + free=Decimal('90.00'), + msg_prefix="[STEP 8a: completed→draft] " + ) + self._assert_reservation_status(order, 'reserved', "[STEP 8a] ") + self._assert_sale_exists(order, should_exist=False) + + # Финальная отмена + order.status = self.status_cancelled + order.save() + order.refresh_from_db() + + # ========== ФИНАЛЬНЫЕ ПРОВЕРКИ ========== + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('0.00'), + free=Decimal('100.00'), + msg_prefix="[FINAL: cancelled] " + ) + self._assert_reservation_status(order, 'released', "[FINAL] ") + self._assert_sale_exists(order, should_exist=False) + self.assertTrue(order.is_returned, "[FINAL] is_returned должен быть True в финале") + + # Проверяем, что все партии восстановлены до исходного состояния + self.stock_batch.refresh_from_db() + self.assertEqual( + self.stock_batch.quantity, + Decimal('100.00'), + "[FINAL] Партия должна полностью восстановиться до начального значения" + ) + + # Проверяем, что НЕТ активных Sale + sales = Sale.objects.filter(order=order) + self.assertEqual(sales.count(), 0, "[FINAL] Не должно быть активных Sale после финальной отмены") + + # Проверяем общую консистентность данных + # Все резервы должны быть в released + reservations = Reservation.objects.filter(order_item__order=order) + for res in reservations: + self.assertEqual( + res.status, + 'released', + f"[FINAL] Все резервы должны быть в 'released', но нашли {res.status}" + ) diff --git a/myproject/orders/models/status.py b/myproject/orders/models/status.py index 318ccd0..8a4cb13 100644 --- a/myproject/orders/models/status.py +++ b/myproject/orders/models/status.py @@ -1,4 +1,5 @@ from django.db import models +from django.core.exceptions import ValidationError from accounts.models import CustomUser @@ -90,10 +91,24 @@ class OrderStatus(models.Model): models.Index(fields=['is_system']), models.Index(fields=['order']), ] + constraints = [ + models.CheckConstraint( + check=~(models.Q(is_positive_end=True) & models.Q(is_negative_end=True)), + name='not_both_positive_and_negative_end', + violation_error_message="Статус не может быть одновременно положительным и отрицательным концом" + ) + ] def __str__(self): return self.name + def clean(self): + """Валидация модели перед сохранением""" + if self.is_positive_end and self.is_negative_end: + raise ValidationError( + "Статус не может быть одновременно положительным и отрицательным концом" + ) + @property def orders_count(self): """Количество заказов в этом статусе""" diff --git a/myproject/orders/templates/orders/status_form.html b/myproject/orders/templates/orders/status_form.html index ed0c905..8bef6f8 100644 --- a/myproject/orders/templates/orders/status_form.html +++ b/myproject/orders/templates/orders/status_form.html @@ -294,6 +294,21 @@ document.addEventListener('DOMContentLoaded', function() { positiveEndCheckbox.addEventListener('change', updatePreview); negativeEndCheckbox.addEventListener('change', updatePreview); + // Взаимное отключение чекбоксов: нельзя быть одновременно положительным и отрицательным + positiveEndCheckbox.addEventListener('change', function() { + if (this.checked) { + negativeEndCheckbox.checked = false; + } + updatePreview(); + }); + + negativeEndCheckbox.addEventListener('change', function() { + if (this.checked) { + positiveEndCheckbox.checked = false; + } + updatePreview(); + }); + // Инициальное обновление updatePreview(); });