# -*- coding: utf-8 -*- """ КРИТИЧЕСКИЕ ТЕСТЫ: Переходы между статусами заказов и управление резервами Тестируем 5 самых критичных сценариев: 1. draft → completed → cancelled → completed (танцы туда-сюда) 2. draft → cancelled (освобождение резервов при отмене из черновика) 3. cancelled → pending (резервирование при возврате из отмены) 4. Создание заказа с промежуточным статусом (не positive, не negative) 5. completed → draft (откат Sale и возврат в резервы) Каждый тест проверяет: - Корректность статусов резервов - Корректность Stock (доступно, зарезервировано, свободно) - Корректность StockBatch (количество не дублируется) - Отсутствие дублирования Sale - Корректность SaleBatchAllocation """ from django.test import TestCase from django.db import connection from django.contrib.auth import get_user_model from django_tenants.utils import schema_context from django.core.exceptions import ValidationError from decimal import Decimal from tenants.models import Client, Domain from products.models import Product, ProductCategory from inventory.models import ( Warehouse, StockBatch, Stock, Reservation, Sale, SaleBatchAllocation ) from orders.models import Order, OrderItem, OrderStatus, PaymentMethod from customers.models import Customer User = get_user_model() class OrderStatusTransitionCriticalTest(TestCase): """ Критические тесты переходов между статусами заказов. TransactionTestCase используется для работы с тенантами и реальными транзакциями. """ @classmethod def setUpClass(cls): """Создаём тестовый тенант один раз для всех тестов""" super().setUpClass() # Создаём тестовый тенант cls.tenant = Client.objects.create( schema_name='test_order_status', name='Test Order Status Tenant', is_active=True ) # Создаём домен Domain.objects.create( domain='test_order_status.localhost', tenant=cls.tenant, is_primary=True ) @classmethod def tearDownClass(cls): """Удаляем тестовый тенант после всех тестов""" # Удаляем схему и тенант (игнорируем все ошибки) try: with connection.cursor() as cursor: cursor.execute(f'DROP SCHEMA IF EXISTS {cls.tenant.schema_name} CASCADE') except Exception: pass try: cls.tenant.delete() except Exception: pass super().tearDownClass() def setUp(self): """Подготовка перед каждым тестом""" with schema_context('test_order_status'): # Создаём системные сущности self._create_system_entities() # Создаём тестовые данные self._create_test_data() def tearDown(self): """Очистка после каждого теста""" with schema_context('test_order_status'): # Удаляем все тестовые данные Sale.objects.all().delete() SaleBatchAllocation.objects.all().delete() Reservation.objects.all().delete() OrderItem.objects.all().delete() Order.objects.all().delete() Stock.objects.all().delete() StockBatch.objects.all().delete() Product.objects.all().delete() def _create_system_entities(self): """Создаёт системные сущности (статусы, способы оплаты, склад)""" # Очищаем старые статусы (если остались от предыдущего теста) OrderStatus.objects.all().delete() # Создаём статусы заказов (из реальной системы) self.status_draft = OrderStatus.objects.create( code='draft', name='Черновик', is_system=True, is_positive_end=False, is_negative_end=False, color='#9E9E9E', order=0 ) self.status_new = OrderStatus.objects.create( code='new', name='Новый', is_system=True, is_positive_end=False, is_negative_end=False, color='#2196F3', order=10 ) self.status_confirmed = OrderStatus.objects.create( code='confirmed', name='Подтвержден', is_system=True, is_positive_end=False, is_negative_end=False, color='#FF9800', order=20 ) self.status_in_assembly = OrderStatus.objects.create( code='in_assembly', name='В сборке', is_system=True, is_positive_end=False, is_negative_end=False, color='#FF9800', order=30 ) self.status_in_delivery = OrderStatus.objects.create( code='in_delivery', name='В доставке', is_system=True, is_positive_end=False, is_negative_end=False, color='#9C27B0', order=40 ) self.status_completed = OrderStatus.objects.create( code='completed', name='Выполнен', is_system=True, is_positive_end=True, is_negative_end=False, color='#4CAF50', order=50 ) self.status_return = OrderStatus.objects.create( code='return', name='Возврат', is_system=True, is_positive_end=False, is_negative_end=False, color='#FF5722', order=60 ) self.status_cancelled = OrderStatus.objects.create( code='cancelled', name='Отменен', is_system=True, is_positive_end=False, is_negative_end=True, color='#F44336', order=70 ) # Создаём склад (БЕЗ поля code - его нет в модели!) self.warehouse = Warehouse.objects.create( name='Основной склад' ) # Создаём системного клиента (используем get_or_create чтобы избежать дублирования) self.customer, _ = Customer.objects.get_or_create( phone='+375291111111', defaults={ 'name': 'Тестовый клиент', 'is_system_customer': False } ) def _create_test_data(self): """Создаёт тестовые товары и партии""" # Категория category = ProductCategory.objects.create( name='Тестовая категория', is_active=True ) # Товар (используем правильные поля: price вместо base_price, без category) self.product = Product.objects.create( name='Тестовый товар', sku='TEST-001', status='active', price=Decimal('10.00') # Основная цена ) # Добавляем категорию через M2M связь self.product.categories.add(category) # Партия товара (100 шт по 5.00 за шт) self.stock_batch = StockBatch.objects.create( product=self.product, warehouse=self.warehouse, quantity=Decimal('100.00'), cost_price=Decimal('5.00') ) # Создаём Stock вручную (т.к. сигнал не сработает в тестах) self.stock, _ = Stock.objects.get_or_create( product=self.product, warehouse=self.warehouse ) # Пересчитываем остатки из партий self.stock.refresh_from_batches() def _create_order(self, status, quantity=Decimal('10.00')): """Вспомогательный метод для создания заказа""" order = Order.objects.create( customer=self.customer, status=status, total_amount=quantity * self.product.price, amount_paid=Decimal('0.00') ) OrderItem.objects.create( order=order, product=self.product, quantity=quantity, price=self.product.price # Используем price вместо base_price ) return order def _assert_stock_state(self, available, reserved, free, msg_prefix=""): """Проверяет состояние Stock""" self.stock.refresh_from_db() self.assertEqual( self.stock.quantity_available, available, f"{msg_prefix}Stock.quantity_available: ожидалось {available}, получено {self.stock.quantity_available}" ) self.assertEqual( self.stock.quantity_reserved, reserved, f"{msg_prefix}Stock.quantity_reserved: ожидалось {reserved}, получено {self.stock.quantity_reserved}" ) self.assertEqual( self.stock.quantity_free, free, f"{msg_prefix}Stock.quantity_free: ожидалось {free}, получено {self.stock.quantity_free}" ) def _assert_reservation_status(self, order, expected_status, msg_prefix=""): """Проверяет статус резерва для заказа""" reservation = Reservation.objects.filter( order_item__order=order ).first() self.assertIsNotNone(reservation, f"{msg_prefix}Резерв не найден") self.assertEqual( reservation.status, expected_status, f"{msg_prefix}Reservation.status: ожидался '{expected_status}', получен '{reservation.status}'" ) return reservation def _assert_sale_exists(self, order, should_exist=True): """Проверяет наличие/отсутствие Sale для заказа""" sale = Sale.objects.filter(order=order).first() if should_exist: self.assertIsNotNone(sale, "Sale должен существовать") return sale else: self.assertIsNone(sale, "Sale НЕ должен существовать") return None # ==================== ТЕСТ 1: Танцы туда-сюда ==================== def test_01_draft_to_completed_to_cancelled_to_completed(self): """ КРИТИЧЕСКИЙ ТЕСТ #1: Множественные переходы между статусами Сценарий: 1. draft (резервы created) 2. → completed (Sale created, товар списан) 3. → cancelled (Sale откатывается, резервы released) 4. → completed СНОВА (Sale создаётся заново, товар списан снова) Проверяем: - Нет дублирования Sale - Корректность Stock на каждом шаге - Корректность StockBatch (количество не дублируется) """ with schema_context('test_order_status'): # ШАГ 1: Создаём заказ в draft order = self._create_order(self.status_draft, quantity=Decimal('10.00')) # Проверки после draft self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('10.00'), free=Decimal('90.00'), msg_prefix="[DRAFT] " ) self._assert_reservation_status(order, 'reserved', "[DRAFT] ") self._assert_sale_exists(order, should_exist=False) # ШАГ 2: Переход в completed order.status = self.status_completed order.save() # Проверки после completed self._assert_stock_state( available=Decimal('90.00'), reserved=Decimal('0.00'), free=Decimal('90.00'), msg_prefix="[COMPLETED] " ) self._assert_reservation_status(order, 'converted_to_sale', "[COMPLETED] ") sale1 = self._assert_sale_exists(order, should_exist=True) self.assertEqual(sale1.quantity, Decimal('10.00')) # Проверяем StockBatch self.stock_batch.refresh_from_db() self.assertEqual( self.stock_batch.quantity, Decimal('90.00'), "[COMPLETED] StockBatch должен уменьшиться на 10" ) # ШАГ 3: Переход в cancelled order.status = self.status_cancelled order.save() # Проверки после cancelled self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('0.00'), free=Decimal('100.00'), msg_prefix="[CANCELLED] " ) self._assert_reservation_status(order, 'released', "[CANCELLED] ") self._assert_sale_exists(order, should_exist=False) # Проверяем StockBatch восстановился self.stock_batch.refresh_from_db() self.assertEqual( self.stock_batch.quantity, Decimal('100.00'), "[CANCELLED] StockBatch должен восстановиться" ) # ШАГ 4: Переход обратно в completed order.status = self.status_completed order.save() # Проверки после повторного completed self._assert_stock_state( available=Decimal('90.00'), reserved=Decimal('0.00'), free=Decimal('90.00'), msg_prefix="[COMPLETED AGAIN] " ) self._assert_reservation_status(order, 'converted_to_sale', "[COMPLETED AGAIN] ") # КРИТИЧЕСКАЯ ПРОВЕРКА: Sale должен быть ОДИН (не дублируется) sales_count = Sale.objects.filter(order=order).count() self.assertEqual( sales_count, 1, f"[COMPLETED AGAIN] Должен быть ОДИН Sale, найдено {sales_count}" ) sale2 = Sale.objects.get(order=order) self.assertEqual(sale2.quantity, Decimal('10.00')) # Проверяем StockBatch снова уменьшился self.stock_batch.refresh_from_db() self.assertEqual( self.stock_batch.quantity, Decimal('90.00'), "[COMPLETED AGAIN] StockBatch должен снова уменьшиться" ) # ==================== ТЕСТ 2: Отмена из любого статуса (кроме completed) ==================== def test_02_cancel_from_any_status_releases_reservations(self): """ КРИТИЧЕСКИЙ ТЕСТ #2: Отмена из любого статуса (кроме Выполнен) освобождает резервы Сценарии: 1. new → cancelled (Новый → Отменен) 2. confirmed → cancelled (Подтвержден → Отменен) 3. in_assembly → cancelled (В сборке → Отменен) 4. in_delivery → cancelled (В доставке → Отменен) Проверяем: - Резервы переходят в 'released' из ЛЮБОГО промежуточного статуса - Stock корректно обновляется - Товар НЕ списывается (Sale не создаётся) - StockBatch остаётся неизменным """ with schema_context('test_order_status'): # СЦЕНАРИЙ 1: new → cancelled order1 = self._create_order(self.status_new, quantity=Decimal('10.00')) self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('10.00'), free=Decimal('90.00'), msg_prefix="[NEW] " ) order1.status = self.status_cancelled order1.save() self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('0.00'), free=Decimal('100.00'), msg_prefix="[NEW→CANCELLED] " ) self._assert_reservation_status(order1, 'released', "[NEW→CANCELLED] ") self._assert_sale_exists(order1, should_exist=False) # СЦЕНАРИЙ 2: confirmed → cancelled order2 = self._create_order(self.status_confirmed, quantity=Decimal('15.00')) self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('15.00'), free=Decimal('85.00'), msg_prefix="[CONFIRMED] " ) order2.status = self.status_cancelled order2.save() self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('0.00'), free=Decimal('100.00'), msg_prefix="[CONFIRMED→CANCELLED] " ) self._assert_reservation_status(order2, 'released', "[CONFIRMED→CANCELLED] ") self._assert_sale_exists(order2, should_exist=False) # СЦЕНАРИЙ 3: in_assembly → cancelled order3 = self._create_order(self.status_in_assembly, quantity=Decimal('20.00')) self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('20.00'), free=Decimal('80.00'), msg_prefix="[IN_ASSEMBLY] " ) order3.status = self.status_cancelled order3.save() self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('0.00'), free=Decimal('100.00'), msg_prefix="[IN_ASSEMBLY→CANCELLED] " ) self._assert_reservation_status(order3, 'released', "[IN_ASSEMBLY→CANCELLED] ") self._assert_sale_exists(order3, should_exist=False) # СЦЕНАРИЙ 4: in_delivery → cancelled order4 = self._create_order(self.status_in_delivery, quantity=Decimal('12.00')) self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('12.00'), free=Decimal('88.00'), msg_prefix="[IN_DELIVERY] " ) order4.status = self.status_cancelled order4.save() self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('0.00'), free=Decimal('100.00'), msg_prefix="[IN_DELIVERY→CANCELLED] " ) self._assert_reservation_status(order4, 'released', "[IN_DELIVERY→CANCELLED] ") self._assert_sale_exists(order4, should_exist=False) # ФИНАЛЬНАЯ ПРОВЕРКА: StockBatch не должен изменяться ни в одном случае self.stock_batch.refresh_from_db() self.assertEqual( self.stock_batch.quantity, Decimal('100.00'), "[FINAL] StockBatch НЕ должен изменяться при отмене из любого промежуточного статуса" ) # ==================== ТЕСТ 3: cancelled → in_assembly ==================== def test_03_cancelled_to_in_assembly_reserves_stock(self): """ КРИТИЧЕСКИЙ ТЕСТ #3: Возврат из отмены резервирует товар Сценарий: 1. new (резервы created) 2. → cancelled (резервы released) 3. → in_assembly (резервы должны вернуться в 'reserved') Проверяем: - Резервы переходят обратно в 'reserved' - Stock корректно резервирует товар """ with schema_context('test_order_status'): # ШАГ 1: Создаём заказ и переводим в cancelled order = self._create_order(self.status_new, quantity=Decimal('20.00')) order.status = self.status_cancelled order.save() # Проверяем cancelled self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('0.00'), free=Decimal('100.00'), msg_prefix="[CANCELLED] " ) self._assert_reservation_status(order, 'released', "[CANCELLED] ") # ШАГ 2: Переход в in_assembly (В сборке) order.status = self.status_in_assembly order.save() # КРИТИЧЕСКИЕ ПРОВЕРКИ self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('20.00'), free=Decimal('80.00'), msg_prefix="[IN_ASSEMBLY] " ) self._assert_reservation_status(order, 'reserved', "[IN_ASSEMBLY] ") self._assert_sale_exists(order, should_exist=False) # ==================== ТЕСТ 4: Промежуточный статус ==================== def test_04_create_order_with_intermediate_status(self): """ КРИТИЧЕСКИЙ ТЕСТ #4: Создание заказа с промежуточным статусом Сценарий: 1. Создаём заказ СРАЗУ со статусом "В доставке" (is_positive_end=False, is_negative_end=False) Проверяем: - Резервы создаются корректно - Stock резервируется - Sale НЕ создаётся """ with schema_context('test_order_status'): # Создаём заказ сразу со статусом "В доставке" order = self._create_order(self.status_in_delivery, quantity=Decimal('12.00')) # КРИТИЧЕСКИЕ ПРОВЕРКИ self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('12.00'), free=Decimal('88.00'), msg_prefix="[IN_DELIVERY] " ) self._assert_reservation_status(order, 'reserved', "[IN_DELIVERY] ") self._assert_sale_exists(order, should_exist=False) # Проверяем что можем перейти в completed order.status = self.status_completed order.save() self._assert_stock_state( available=Decimal('88.00'), reserved=Decimal('0.00'), free=Decimal('88.00'), msg_prefix="[IN_DELIVERY→COMPLETED] " ) self._assert_reservation_status(order, 'converted_to_sale', "[IN_DELIVERY→COMPLETED] ") self._assert_sale_exists(order, should_exist=True) # ==================== ТЕСТ 5: completed → draft ==================== def test_05_completed_to_draft_rollback_sale(self): """ КРИТИЧЕСКИЙ ТЕСТ #5: Откат от completed к draft Сценарий: 1. draft (резервы created) 2. → completed (Sale created) 3. → draft (Sale откатывается, резервы восстанавливаются) Проверяем: - Sale удаляется - Резервы возвращаются в 'reserved' - Stock корректно восстанавливается - StockBatch восстанавливается """ with schema_context('test_order_status'): # ШАГ 1: Создаём заказ и переводим в completed order = self._create_order(self.status_draft, quantity=Decimal('25.00')) order.status = self.status_completed order.save() # Проверяем completed self._assert_stock_state( available=Decimal('75.00'), reserved=Decimal('0.00'), free=Decimal('75.00'), msg_prefix="[COMPLETED] " ) self._assert_reservation_status(order, 'converted_to_sale', "[COMPLETED] ") self._assert_sale_exists(order, should_exist=True) self.stock_batch.refresh_from_db() batch_qty_after_sale = self.stock_batch.quantity self.assertEqual(batch_qty_after_sale, Decimal('75.00')) # ШАГ 2: Откат в draft order.status = self.status_draft order.save() # КРИТИЧЕСКИЕ ПРОВЕРКИ self._assert_stock_state( available=Decimal('100.00'), reserved=Decimal('25.00'), free=Decimal('75.00'), msg_prefix="[DRAFT ROLLBACK] " ) self._assert_reservation_status(order, 'reserved', "[DRAFT ROLLBACK] ") self._assert_sale_exists(order, should_exist=False) # Проверяем восстановление StockBatch self.stock_batch.refresh_from_db() self.assertEqual( self.stock_batch.quantity, Decimal('100.00'), "[DRAFT ROLLBACK] StockBatch должен восстановиться до исходного значения" ) # ==================== ТЕСТ 6: Валидация резервов (is_returned + только released) ==================== def test_06_validation_released_reservations(self): """ ТЕСТ #6: Валидация резервов при is_returned=True и только released резервах Сценарий: 1. completed (Sale созданы) 2. → cancelled (резервы released, is_returned=True) 3. Попытка перейти в положительный статус должна быть запрещена Проверяем: - Заказ с is_returned=True и только released резервами не может перейти в положительный статус - Валидация корректно исключает released резервы """ 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 созданы self._assert_sale_exists(order, should_exist=True) self.assertFalse(order.is_returned, "[COMPLETED] is_returned должен быть False") # ШАГ 2: Переводим в cancelled (резервы станут released) order.status = self.status_cancelled order.save() # Проверяем, что Sale удалены и резервы released 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: Попытка перейти в положительный статус должна быть запрещена order.status = self.status_completed with self.assertRaises(ValidationError) as context: order.save() error_message = str(context.exception) self.assertIn('был отменён', error_message) self.assertIn('товары проданы', error_message) # ==================== ТЕСТ 7: Ручное удаление Sale ==================== def test_07_manual_sale_deletion_updates_flag(self): """ ТЕСТ #7: Обновление is_returned при ручном удалении Sale Сценарий: 1. completed (Sale созданы, is_returned=False) 2. Ручное удаление Sale 3. is_returned должен стать True Проверяем: - При удалении Sale через delete() флаг is_returned обновляется - Сигнал pre_delete корректно обрабатывает удаление """ 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 = self._assert_sale_exists(order, should_exist=True) order.refresh_from_db() self.assertFalse(order.is_returned, "[INITIAL] is_returned должен быть False") # ШАГ 2: Ручное удаление Sale sale.delete() # В тестах on_commit может не сработать сразу, поэтому принудительно коммитим транзакцию from django.db import transaction transaction.get_connection().commit() # Проверяем, что флаг обновился order.refresh_from_db() self.assertTrue(order.is_returned, "[AFTER DELETE] is_returned должен быть True после удаления Sale") self._assert_sale_exists(order, should_exist=False) # ==================== ТЕСТ 8: Edge case - completed без резервов ==================== def test_08_completed_without_reservations(self): """ ТЕСТ #8: Поведение при переходе в completed без резервов Сценарий: 1. Создаём заказ без резервов (или удаляем резервы) 2. Переход в completed 3. Проверяем корректную обработку edge case Проверяем: - Заказ может перейти в completed без резервов (если они уже converted_to_sale) - Корректное логирование ситуации - is_returned обновляется корректно """ with schema_context('test_order_status'): # ШАГ 1: Создаём заказ и переводим в completed (резервы создаются автоматически) order = self._create_order(self.status_draft, quantity=Decimal('10.00')) # Проверяем, что резервы созданы reservation = Reservation.objects.filter(order_item__order=order).first() self.assertIsNotNone(reservation, "[DRAFT] Резерв должен быть создан") self.assertEqual(reservation.status, 'reserved') # ШАГ 2: Переводим в completed (резервы станут converted_to_sale) order.status = self.status_completed order.save() # Проверяем, что резервы converted_to_sale reservation.refresh_from_db() self.assertEqual(reservation.status, 'converted_to_sale') self._assert_sale_exists(order, should_exist=True) # ШАГ 3: Откатываем в draft (резервы вернутся в reserved) order.status = self.status_draft order.save() reservation.refresh_from_db() self.assertEqual(reservation.status, 'reserved') self._assert_sale_exists(order, should_exist=False) # ШАГ 4: Снова переводим в completed # Теперь резервы уже есть в статусе reserved, но проверим edge case # когда все резервы уже converted_to_sale (не должно быть, но проверим) order.status = self.status_completed order.save() # Проверяем, что Sale созданы снова self._assert_sale_exists(order, should_exist=True) reservation.refresh_from_db() self.assertEqual(reservation.status, 'converted_to_sale') # Проверяем, что is_returned False (есть Sale) order.refresh_from_db() self.assertFalse(order.is_returned, "[COMPLETED AGAIN] is_returned должен быть False при наличии Sale")