Files
octopus/myproject/inventory/tests/test_order_status_transitions.py
Andrey Smakotin d023d1ab25 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)
2025-12-01 12:42:37 +03:00

570 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 должен восстановиться до исходного значения"
)