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)
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user