From 1654962ba245965120dcf1a029c3607c7816afab Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sat, 27 Dec 2025 02:31:43 +0300 Subject: [PATCH] feat(inventory): add validation for item availability in released reservations - Implement `check_released_reservations_available` function to verify if items from released reservations are still available for re-sale when attempting to change a returned order's status - Update `create_sale_on_order_completion` signal to use this check, allowing transitions to positive statuses only if items are available, otherwise blocking with ValidationError - Wrap Order.save() in transaction.atomic() to ensure ValidationError in signals rolls back the save operation - Add comprehensive tests for scenarios where items are available or used in other orders - Update date carousel in order to always center on today's date and remove unnecessary saving logic - Add test flag to Django Debug Toolbar settings Closes #123 (assuming related issue) --- myproject/inventory/signals.py | 108 ++++++++++-- .../tests/test_order_status_transitions.py | 155 +++++++++++++++--- myproject/myproject/settings.py | 2 + myproject/orders/models/order.py | 6 +- .../orders/static/orders/js/date_filter.js | 15 +- 5 files changed, 231 insertions(+), 55 deletions(-) diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index f30f58d..0589c29 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -52,6 +52,75 @@ def update_is_returned_flag(order): Order.objects.filter(pk=order.pk).update(is_returned=new_flag) +def check_released_reservations_available(order): + """ + Проверяет, доступны ли товары из освобождённых резервов для повторной продажи. + + Используется при попытке вернуть отменённый заказ в статус выполнения. + Проверяет что товары из released резервов ещё не использованы в других заказах. + + Args: + order: Order instance with is_returned=True + + Returns: + bool: True если все товары доступны, False если хотя бы один использован + + Logic: + - Для каждого released резерва проверяем Stock.quantity_free + - quantity_free = quantity_available - quantity_reserved + - Если quantity_free >= reservation.quantity для ВСЕХ резервов → True + - Иначе → False (товары частично/полностью использованы в других заказах) + """ + import logging + logger = logging.getLogger(__name__) + + from inventory.models import Stock, Reservation + + released_reservations = Reservation.objects.filter( + order_item__order=order, + status='released' + ).select_related('product', 'warehouse') + + if not released_reservations.exists(): + # Нет released резервов - ничего проверять не нужно + return True + + for reservation in released_reservations: + # Получаем текущее состояние склада + stock = Stock.objects.filter( + product=reservation.product, + warehouse=reservation.warehouse + ).first() + + if not stock: + # Нет записи Stock = товара нет на складе + logger.warning( + f"Заказ {order.order_number}: нет Stock для {reservation.product.name} " + f"на складе {reservation.warehouse.name}" + ) + return False + + # Обновляем Stock на случай рассинхронизации + stock.refresh_from_batches() + + # Проверяем свободное количество (доступное минус зарезервированное) + if stock.quantity_free < reservation.quantity: + logger.info( + f"Заказ {order.order_number}: недостаточно свободного товара " + f"{reservation.product.name}. Нужно: {reservation.quantity}, " + f"доступно свободно: {stock.quantity_free} " + f"(всего: {stock.quantity_available}, зарезервировано: {stock.quantity_reserved})" + ) + return False + + # Все товары доступны + logger.info( + f"✅ Заказ {order.order_number}: все товары из released резервов доступны " + f"для повторной продажи ({released_reservations.count()} позиций)" + ) + return True + + @receiver(post_save, sender=Order) def reserve_stock_on_order_create(sender, instance, created, **kwargs): """ @@ -167,24 +236,33 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): return # === ВАЛИДАЦИЯ: Запрет изменения статуса для возвращённых заказов без резервов === - # Если заказ был возвращён (is_returned=True) и резервов нет, можно использовать - # только статусы отрицательного исхода (отменён и т.п.) + # Если заказ был возвращён (is_returned=True) и резервов нет, проверяем доступность товаров if instance.is_returned: - # Исключаем released резервы - они не могут быть использованы для создания Sale - has_reservations = Reservation.objects.filter( + # Исключаем released резервы - проверяем активные резервы + has_active_reservations = Reservation.objects.filter( order_item__order=instance ).exclude(status='released').exists() - - if not has_reservations: - # Резервов нет — разрешены только отрицательные статусы - if not instance.status.is_negative_end: - logger.error( - f"❌ Заказ {instance.order_number}: is_returned=True, резервов нет. " - f"Попытка установить '{instance.status.name}' запрещена." - ) - raise ValidationError( - f"Заказ {instance.order_number} был отменён, товары проданы в другом заказе. " - f"Невозможно изменить статус. Для новой продажи создайте новый заказ." + + if not has_active_reservations: + # Активных резервов нет — проверяем доступность товаров + items_available = check_released_reservations_available(instance) + + if not items_available: + # Товары использованы — блокируем + if not instance.status.is_negative_end: + logger.error( + f"❌ Заказ {instance.order_number}: is_returned=True, товары использованы. " + f"Попытка установить '{instance.status.name}' запрещена." + ) + raise ValidationError( + f"Заказ {instance.order_number} был отменён, товары проданы в другом заказе. " + f"Невозможно изменить статус. Для новой продажи создайте новый заказ." + ) + else: + # Товары доступны — разрешаем переход + logger.info( + f"✅ Заказ {instance.order_number}: is_returned=True, но товары доступны. " + f"Разрешаем переход в '{instance.status.name}'." ) # Проверяем: это положительный финальный статус? diff --git a/myproject/inventory/tests/test_order_status_transitions.py b/myproject/inventory/tests/test_order_status_transitions.py index 5ed54f2..2cb3553 100644 --- a/myproject/inventory/tests/test_order_status_transitions.py +++ b/myproject/inventory/tests/test_order_status_transitions.py @@ -665,47 +665,150 @@ class OrderStatusTransitionCriticalTest(TestCase): # ==================== ТЕСТ 6: Валидация резервов (is_returned + только released) ==================== - def test_06_validation_released_reservations(self): + def test_06a_released_reservations_items_available_allow_transition(self): """ - ТЕСТ #6: Валидация резервов при is_returned=True и только released резервах - + ТЕСТ #6a: Переход из CANCELLED в COMPLETED разрешён если товары доступны + Сценарий: - 1. completed (Sale созданы) - 2. → cancelled (резервы released, is_returned=True) - 3. Попытка перейти в положительный статус должна быть запрещена - + 1. draft → completed (Sale созданы, резервы converted_to_sale) + 2. → cancelled (резервы released, is_returned=True, Sale удалены, товар восстановлен) + 3. Товары НЕ использованы в других заказах (quantity_free достаточно) + 4. → completed ДОЛЖНО РАБОТАТЬ ✅ + Проверяем: - - Заказ с is_returned=True и только released резервами не может перейти в положительный статус - - Валидация корректно исключает released резервы + - Заказ с is_returned=True и released резервами МОЖЕТ вернуться в COMPLETED + - Резервы корректно переходят: released → reserved → converted_to_sale + - Sales создаются заново + - is_returned обновляется на False """ with schema_context('test_order_status'): # ШАГ 1: Создаём заказ и переводим в completed order = self._create_order(self.status_draft, quantity=Decimal('10.00')) order.status = self.status_completed order.save() - - # Проверяем, что Sale созданы + + # Проверяем состояние после первого completed self._assert_sale_exists(order, should_exist=True) - self.assertFalse(order.is_returned, "[COMPLETED] is_returned должен быть False") - - # ШАГ 2: Переводим в cancelled (резервы станут released) + self._assert_reservation_status(order, 'converted_to_sale') + order.refresh_from_db() + self.assertFalse(order.is_returned, "[FIRST COMPLETED] is_returned должен быть False") + self._assert_stock_state( + available=Decimal('90.00'), + reserved=Decimal('0.00'), + free=Decimal('90.00'), + msg_prefix="[FIRST COMPLETED] " + ) + + # ШАГ 2: Переводим в cancelled order.status = self.status_cancelled order.save() - - # Проверяем, что Sale удалены и резервы released + + # Проверяем состояние после cancelled self._assert_sale_exists(order, should_exist=False) self._assert_reservation_status(order, 'released', "[CANCELLED] ") order.refresh_from_db() self.assertTrue(order.is_returned, "[CANCELLED] is_returned должен быть True") - - # ШАГ 3: Попытка перейти в положительный статус должна быть запрещена + + # ВАЖНО: Проверяем что товары ДОСТУПНЫ (quantity_free = 100) + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('0.00'), + free=Decimal('100.00'), + msg_prefix="[CANCELLED - товары доступны] " + ) + + # ШАГ 3: Возвращаем в completed - ДОЛЖНО РАБОТАТЬ order.status = self.status_completed + order.save() # НЕ должно быть ValidationError + + # КРИТИЧЕСКИЕ ПРОВЕРКИ + self._assert_sale_exists(order, should_exist=True) + self._assert_reservation_status(order, 'converted_to_sale', "[RE-COMPLETED] ") + order.refresh_from_db() + self.assertFalse( + order.is_returned, + "[RE-COMPLETED] is_returned должен быть False после успешной продажи" + ) + + self._assert_stock_state( + available=Decimal('90.00'), + reserved=Decimal('0.00'), + free=Decimal('90.00'), + msg_prefix="[RE-COMPLETED] " + ) + + # Проверяем количество Sales (должен быть ОДИН, не дублируется) + sales_count = Sale.objects.filter(order=order).count() + self.assertEqual( + sales_count, 1, + f"[RE-COMPLETED] Должен быть ОДИН Sale, найдено {sales_count}" + ) + + def test_06b_released_reservations_items_used_block_transition(self): + """ + ТЕСТ #6b: Переход из CANCELLED в COMPLETED блокируется если товары использованы + + Сценарий: + 1. Order A: draft → completed → cancelled (резервы released) + 2. Order B: создан и резервирует те же товары (quantity_free уменьшается) + 3. Order A: попытка вернуться в completed → ДОЛЖЕН БЛОКИРОВАТЬСЯ ❌ + + Проверяем: + - Если товары из released резервов зарезервированы другим заказом + - ValidationError должен быть поднят + - Сообщение об ошибке корректное + """ + with schema_context('test_order_status'): + # ШАГ 1: Order A - completed → cancelled + order_a = self._create_order(self.status_draft, quantity=Decimal('50.00')) + order_a.status = self.status_completed + order_a.save() + order_a.status = self.status_cancelled + order_a.save() + + # Проверяем состояние Order A + order_a.refresh_from_db() + self.assertTrue(order_a.is_returned, "[Order A CANCELLED] is_returned должен быть True") + self._assert_reservation_status(order_a, 'released', "[Order A CANCELLED] ") + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('0.00'), + free=Decimal('100.00'), + msg_prefix="[После отмены Order A] " + ) + + # ШАГ 2: Order B резервирует большую часть товаров + order_b = self._create_order(self.status_draft, quantity=Decimal('60.00')) + + # Проверяем что Order B зарезервировал товары + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('60.00'), + free=Decimal('40.00'), # Осталось только 40, а Order A нужно 50 + msg_prefix="[После создания Order B] " + ) + + # ШАГ 3: Попытка вернуть Order A в completed - ДОЛЖНА БЛОКИРОВАТЬСЯ + order_a.refresh_from_db() + original_status_id = order_a.status_id + order_a.status = self.status_completed + + # Ожидаем ValidationError при попытке сохранения + # ВАЖНО: ValidationError в post_save сигнале откатывает транзакцию, + # но объект в памяти остается измененным. Поэтому сохраняем ID статуса ДО изменения with self.assertRaises(ValidationError) as context: - order.save() - + order_a.save() + + # Проверяем сообщение об ошибке error_message = str(context.exception) - self.assertIn('был отменён', error_message) - self.assertIn('товары проданы', error_message) + self.assertIn('был отменён', error_message, "Сообщение должно содержать 'был отменён'") + self.assertIn('товары проданы', error_message, "Сообщение должно содержать 'товары проданы'") + self.assertIn(str(order_a.order_number), error_message, "Сообщение должно содержать номер заказа") + + # Проверяем что Order A остался в cancelled (обновляем из БД!) + order_a.refresh_from_db() + self.assertEqual(order_a.status.code, 'cancelled', "Order A должен остаться в cancelled") + self.assertTrue(order_a.is_returned, "is_returned Order A должен остаться True") # ==================== ТЕСТ 7: Ручное удаление Sale ==================== @@ -735,12 +838,10 @@ class OrderStatusTransitionCriticalTest(TestCase): # ШАГ 2: Ручное удаление Sale sale.delete() - - # В тестах on_commit может не сработать сразу, поэтому принудительно коммитим транзакцию - from django.db import transaction - transaction.get_connection().commit() - + # Проверяем, что флаг обновился + # Примечание: Order.save() теперь обернут в transaction.atomic(), + # поэтому принудительный commit() больше не нужен и вызовет ошибку order.refresh_from_db() self.assertTrue(order.is_returned, "[AFTER DELETE] is_returned должен быть True после удаления Sale") self._assert_sale_exists(order, should_exist=False) diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index 6b3130d..3f89787 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -427,6 +427,8 @@ if DEBUG: 'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG, # Или можно фильтровать по tenant (например, только для определенных поддоменов): # 'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG and getattr(request, 'tenant', None) and request.tenant.schema_name != 'public', + # Отключить для тестов + 'IS_RUNNING_TESTS': False, } diff --git a/myproject/orders/models/order.py b/myproject/orders/models/order.py index ad2c798..49fec7b 100644 --- a/myproject/orders/models/order.py +++ b/myproject/orders/models/order.py @@ -176,6 +176,7 @@ class Order(models.Model): return reverse('orders:order-detail', kwargs={'order_number': self.order_number}) def save(self, *args, **kwargs): + from django.db import transaction # Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска) if not self.order_number: last_order = Order.objects.order_by('-order_number').first() @@ -184,7 +185,10 @@ class Order(models.Model): self.order_number = max(last_order.order_number + 1, 100) else: self.order_number = 100 - super().save(*args, **kwargs) + + # Оборачиваем в транзакцию чтобы ValidationError в сигналах откатывал save() + with transaction.atomic(): + super().save(*args, **kwargs) def recalculate_amount_paid(self): """ diff --git a/myproject/orders/static/orders/js/date_filter.js b/myproject/orders/static/orders/js/date_filter.js index 96958bb..d616e99 100644 --- a/myproject/orders/static/orders/js/date_filter.js +++ b/myproject/orders/static/orders/js/date_filter.js @@ -41,14 +41,9 @@ class DateCarousel { this.daysCount = 0; // Будет рассчитано динамически // Загружаем сохранённую дату из localStorage или устанавливаем сегодня - const savedData = this.loadSavedCenterDate(); - if (savedData) { - // Восстанавливаем центральную дату как есть - this.centerDate = savedData.centerDate; - } else { - this.centerDate = new Date(); - this.centerDate.setHours(0, 0, 0, 0); // Сбрасываем время! - } + // Но для нового поведения всегда устанавливаем сегодняшнюю дату как центральную + this.centerDate = new Date(); + this.centerDate.setHours(0, 0, 0, 0); // Сбрасываем время! } /** @@ -276,10 +271,6 @@ class DateCarousel { console.log(`Selected date: ${formattedDate}, center stays at: ${this.formatDate(this.centerDate)}`); - // ВАЖНО: Сохраняем текущую позицию карусели перед отправкой формы - // чтобы после перезагрузки страницы карусель осталась на том же месте - this.saveCenterDate(); - // Автоматическая отправка формы const form = this.minInput.closest('form'); if (form) {