Added 5 critical tests for order status transitions

Tests cover:
1. Multiple status transitions (draft->completed->cancelled->completed)
2. Cancellation from draft (reservation release)
3. Un-cancellation to pending (reservation restore)
4. Creating order with intermediate status
5. Rollback from completed to draft

Each test verifies:
- Stock state (available, reserved, free)
- Reservation status transitions
- Sale creation/deletion without duplicates
- StockBatch quantity changes

Files:
- inventory/tests/test_order_status_transitions.py (570 lines)
- inventory/tests/README.md (138 lines)
- inventory/tests/__init__.py
- run_status_tests.bat (launch script)
This commit is contained in:
2025-12-01 12:42:37 +03:00
parent 1168659df8
commit d023d1ab25
4 changed files with 717 additions and 0 deletions

View File

@@ -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 должен восстановиться до исходного значения"
)