diff --git a/myproject/inventory/tests/README.md b/myproject/inventory/tests/README.md new file mode 100644 index 0000000..062b2fa --- /dev/null +++ b/myproject/inventory/tests/README.md @@ -0,0 +1,137 @@ +# 🧪 Критические тесты переходов между статусами заказов + +## 📋 Описание + +Набор из **5 критических тестов**, проверяющих корректность работы системы резервирования и списания товаров при переходах между статусами заказов. + +## 🎯 Тестируемые сценарии + +### 1️⃣ **Тест "Танцы туда-сюда"** (`test_01_draft_to_completed_to_cancelled_to_completed`) +Проверяет множественные переходы между статусами: +- `draft` → `completed` → `cancelled` → `completed` (снова) +- **Цель:** Убедиться что Sale не дублируется при повторном переходе в `completed` + +### 2️⃣ **Тест отмены из черновика** (`test_02_draft_to_cancelled_releases_reservations`) +Проверяет освобождение резервов при отмене: +- `draft` → `cancelled` +- **Цель:** Резервы должны корректно освобождаться + +### 3️⃣ **Тест возврата из отмены** (`test_03_cancelled_to_pending_reserves_stock`) +Проверяет резервирование при возврате: +- `draft` → `cancelled` → `pending` +- **Цель:** Резервы должны восстанавливаться при возврате из отмены + +### 4️⃣ **Тест промежуточного статуса** (`test_04_create_order_with_intermediate_status`) +Проверяет создание заказа с новым статусом: +- Создание заказа сразу со статусом "Упаковывается" (не positive, не negative) +- **Цель:** Система должна работать с любыми промежуточными статусами + +### 5️⃣ **Тест отката от выполненного** (`test_05_completed_to_draft_rollback_sale`) +Проверяет откат Sale: +- `draft` → `completed` → `draft` +- **Цель:** Sale должен корректно откатываться, товар возвращаться на склад + +## 🚀 Запуск тестов + +### Запуск всех тестов: +```bash +cd myproject +python manage.py test inventory.tests.test_order_status_transitions +``` + +### Запуск конкретного теста: +```bash +python manage.py test inventory.tests.test_order_status_transitions.OrderStatusTransitionCriticalTest.test_01_draft_to_completed_to_cancelled_to_completed +``` + +### Запуск с подробным выводом: +```bash +python manage.py test inventory.tests.test_order_status_transitions --verbosity=2 +``` + +## ✅ Что проверяет каждый тест + +Все тесты проверяют **3 критических аспекта**: + +### 1. **Корректность Stock** +- `quantity_available` - доступное количество +- `quantity_reserved` - зарезервированное количество +- `quantity_free` - свободное количество (available - reserved) + +### 2. **Корректность Reservation** +- Статус резерва (`reserved`, `converted_to_sale`, `released`) +- Переходы между статусами при изменении статуса заказа + +### 3. **Корректность Sale и StockBatch** +- Sale не дублируется при повторных переходах +- StockBatch корректно уменьшается/восстанавливается +- SaleBatchAllocation создаётся только один раз + +## 🔍 Пример вывода успешного теста + +``` +test_01_draft_to_completed_to_cancelled_to_completed ... ok +test_02_draft_to_cancelled_releases_reservations ... ok +test_03_cancelled_to_pending_reserves_stock ... ok +test_04_create_order_with_intermediate_status ... ok +test_05_completed_to_draft_rollback_sale ... ok + +---------------------------------------------------------------------- +Ran 5 tests in 2.341s + +OK +``` + +## ⚠️ Важные особенности + +1. **Тесты используют TransactionTestCase** - каждый тест выполняется в отдельной транзакции +2. **Создаётся отдельная схема БД** `test_order_status` для изоляции +3. **После каждого теста данные очищаются** - тесты не влияют друг на друга +4. **Тесты работают с тенантами** - используется `schema_context()` + +## 🐛 Если тесты падают + +### Проверьте сигналы: +- `inventory/signals.py` - все сигналы должны быть подключены +- `inventory/apps.py` - сигналы должны импортироваться в `ready()` + +### Проверьте модели: +- `Reservation.status` - должны быть варианты: `reserved`, `converted_to_sale`, `released` +- `OrderStatus` - должны быть флаги `is_positive_end`, `is_negative_end` + +### Проверьте БД: +```bash +python manage.py migrate +``` + +## 📝 Добавление новых тестов + +Чтобы добавить новый тест: + +1. Создайте метод в классе `OrderStatusTransitionCriticalTest` +2. Начните имя с `test_` (обязательно!) +3. Используйте вспомогательные методы: + - `_create_order(status, quantity)` - создать заказ + - `_assert_stock_state(available, reserved, free)` - проверить Stock + - `_assert_reservation_status(order, expected_status)` - проверить резерв + - `_assert_sale_exists(order, should_exist)` - проверить Sale + +Пример: +```python +def test_06_my_new_scenario(self): + """Описание сценария""" + with schema_context('test_order_status'): + order = self._create_order(self.status_draft, quantity=Decimal('10.00')) + + # Ваши проверки + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('10.00'), + free=Decimal('90.00') + ) +``` + +## 🎓 Дополнительная информация + +- Документация Django Testing: https://docs.djangoproject.com/en/stable/topics/testing/ +- Django Tenants Testing: https://django-tenants.readthedocs.io/en/latest/test.html diff --git a/myproject/inventory/tests/__init__.py b/myproject/inventory/tests/__init__.py new file mode 100644 index 0000000..0b4d6c4 --- /dev/null +++ b/myproject/inventory/tests/__init__.py @@ -0,0 +1 @@ +# Inventory app tests diff --git a/myproject/inventory/tests/test_order_status_transitions.py b/myproject/inventory/tests/test_order_status_transitions.py new file mode 100644 index 0000000..bc23148 --- /dev/null +++ b/myproject/inventory/tests/test_order_status_transitions.py @@ -0,0 +1,569 @@ +# -*- 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 TransactionTestCase +from django.db import connection +from django.contrib.auth import get_user_model +from django_tenants.utils import schema_context +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(TransactionTestCase): + """ + Критические тесты переходов между статусами заказов. + + 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): + """Удаляем тестовый тенант после всех тестов""" + # Удаляем схему + with connection.cursor() as cursor: + cursor.execute(f'DROP SCHEMA IF EXISTS {cls.tenant.schema_name} CASCADE') + + # Удаляем тенант + cls.tenant.delete() + + 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): + """Создаёт системные сущности (статусы, способы оплаты, склад)""" + # Создаём статусы заказов + self.status_draft = OrderStatus.objects.create( + code='draft', + name='Черновик', + is_system=True, + is_positive_end=False, + is_negative_end=False, + color='secondary', + order=0 + ) + + self.status_pending = OrderStatus.objects.create( + code='pending', + name='В сборке', + is_system=True, + is_positive_end=False, + is_negative_end=False, + color='warning', + order=1 + ) + + self.status_completed = OrderStatus.objects.create( + code='completed', + name='Выполнен', + is_system=True, + is_positive_end=True, + is_negative_end=False, + color='success', + order=2 + ) + + self.status_cancelled = OrderStatus.objects.create( + code='cancelled', + name='Отменен', + is_system=True, + is_positive_end=False, + is_negative_end=True, + color='danger', + order=3 + ) + + # Промежуточный статус (не positive, не negative) + self.status_packing = OrderStatus.objects.create( + code='packing', + name='Упаковывается', + is_system=False, + is_positive_end=False, + is_negative_end=False, + color='info', + order=4 + ) + + # Создаём способ оплаты + self.payment_method = PaymentMethod.objects.create( + code='cash', + name='Наличными', + is_system=True, + is_active=True + ) + + # Создаём склад + self.warehouse = Warehouse.objects.create( + name='Основной склад', + code='MAIN' + ) + + # Создаём системного клиента + self.customer = Customer.objects.create( + name='Тестовый клиент', + phone='+375291111111', + is_system_customer=False + ) + + def _create_test_data(self): + """Создаёт тестовые товары и партии""" + # Категория + category = ProductCategory.objects.create( + name='Тестовая категория', + is_active=True + ) + + # Товар + self.product = Product.objects.create( + name='Тестовый товар', + sku='TEST-001', + status='active', + category=category, + base_price=Decimal('10.00') + ) + + # Партия товара (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( + product=self.product, + warehouse=self.warehouse + ) + + def _create_order(self, status, quantity=Decimal('10.00')): + """Вспомогательный метод для создания заказа""" + order = Order.objects.create( + customer=self.customer, + status=status, + payment_method=self.payment_method, + total_amount=quantity * self.product.base_price, + amount_paid=Decimal('0.00') + ) + + OrderItem.objects.create( + order=order, + product=self.product, + quantity=quantity, + price=self.product.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: draft → cancelled ==================== + + def test_02_draft_to_cancelled_releases_reservations(self): + """ + КРИТИЧЕСКИЙ ТЕСТ #2: Отмена из черновика освобождает резервы + + Сценарий: + 1. draft (резервы created) + 2. → cancelled (резервы должны освободиться) + + Проверяем: + - Резервы переходят в 'released' + - Stock корректно обновляется + - Товар не списывается + """ + with schema_context('test_order_status'): + # ШАГ 1: Создаём заказ в draft + order = self._create_order(self.status_draft, quantity=Decimal('15.00')) + + # Проверки после draft + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('15.00'), + free=Decimal('85.00'), + msg_prefix="[DRAFT] " + ) + + # ШАГ 2: Переход в cancelled + order.status = self.status_cancelled + order.save() + + # КРИТИЧЕСКИЕ ПРОВЕРКИ + 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 НЕ должен изменяться при отмене из draft" + ) + + # ==================== ТЕСТ 3: cancelled → pending ==================== + + def test_03_cancelled_to_pending_reserves_stock(self): + """ + КРИТИЧЕСКИЙ ТЕСТ #3: Возврат из отмены резервирует товар + + Сценарий: + 1. draft (резервы created) + 2. → cancelled (резервы released) + 3. → pending (резервы должны вернуться в 'reserved') + + Проверяем: + - Резервы переходят обратно в 'reserved' + - Stock корректно резервирует товар + """ + with schema_context('test_order_status'): + # ШАГ 1: Создаём заказ и переводим в cancelled + order = self._create_order(self.status_draft, 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: Переход в pending (В сборке) + order.status = self.status_pending + order.save() + + # КРИТИЧЕСКИЕ ПРОВЕРКИ + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('20.00'), + free=Decimal('80.00'), + msg_prefix="[PENDING] " + ) + self._assert_reservation_status(order, 'reserved', "[PENDING] ") + 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_packing, quantity=Decimal('12.00')) + + # КРИТИЧЕСКИЕ ПРОВЕРКИ + self._assert_stock_state( + available=Decimal('100.00'), + reserved=Decimal('12.00'), + free=Decimal('88.00'), + msg_prefix="[PACKING] " + ) + self._assert_reservation_status(order, 'reserved', "[PACKING] ") + 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="[PACKING→COMPLETED] " + ) + self._assert_reservation_status(order, 'converted_to_sale', "[PACKING→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 должен восстановиться до исходного значения" + ) diff --git a/run_status_tests.bat b/run_status_tests.bat new file mode 100644 index 0000000..f4454f5 --- /dev/null +++ b/run_status_tests.bat @@ -0,0 +1,10 @@ +@echo off +REM Скрипт для запуска тестов переходов между статусами заказов + +cd /d C:\Users\team_\Desktop\test_qwen +call .venv\Scripts\activate.bat + +cd myproject +python manage.py test inventory.tests.test_order_status_transitions --verbosity=2 + +pause