Files
octopus/myproject/inventory/tests/test_order_status_transitions.py
Andrey Smakotin 2aba3d2404 Улучшения в тестах переходов статусов заказов
- Исправлены комментарии и форматирование в signals.py
- Улучшена читаемость кода в models.py
- Обновлены шаблоны форм статусов
- Доработаны тесты переходов статусов
2026-01-05 21:30:25 +03:00

1140 lines
52 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 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_06a_released_reservations_items_available_allow_transition(self):
"""
ТЕСТ #6a: Переход из CANCELLED в COMPLETED разрешён если товары доступны
Сценарий:
1. draft → completed (Sale созданы, резервы converted_to_sale)
2. → cancelled (резервы released, is_returned=True, Sale удалены, товар восстановлен)
3. Товары НЕ использованы в других заказах (quantity_free достаточно)
4. → completed ДОЛЖНО РАБОТАТЬ ✅
Проверяем:
- Заказ с 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()
# Проверяем состояние после первого completed
self._assert_sale_exists(order, should_exist=True)
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()
# Проверяем состояние после 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")
# ВАЖНО: Проверяем что товары ДОСТУПНЫ (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_a.save()
# Проверяем сообщение об ошибке
error_message = str(context.exception)
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 ====================
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()
# Проверяем, что флаг обновился
# Примечание: 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)
# ==================== ТЕСТ 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")
# ==================== ТЕСТ 9: Хаотичная смена статусов (стресс-тест) ====================
def test_09_random_status_changes_stress_test(self):
"""
ТЕСТ #9: Хаотичная смена статусов заказа в разные стороны
Сценарий (симулирует реальное поведение пользователя):
1. draft → completed (продажа)
2. completed → draft (откат)
3. draft → cancelled (отмена)
4. cancelled → in_assembly (возобновление)
5. in_assembly → completed (продажа снова)
6. completed → cancelled (отмена выполненного)
7. cancelled → completed (автоматический промежуточный переход через draft)
8. completed → draft → cancelled (откат и финальная отмена)
Проверяем на каждом шаге:
- Консистентность резервов
- Консистентность Stock (quantity_available, quantity_reserved, quantity_free)
- Отсутствие дублирования Sale
- Корректность восстановления партий
- Корректность флага is_returned
"""
with schema_context('test_order_status'):
# Создаём заказ в draft
order = self._create_order(self.status_draft, quantity=Decimal('10.00'))
# ========== ШАГ 1: draft → completed ==========
order.status = self.status_completed
order.save()
order.refresh_from_db()
# Проверки после первой продажи
self._assert_stock_state(
available=Decimal('90.00'),
reserved=Decimal('0.00'),
free=Decimal('90.00'),
msg_prefix="[STEP 1: draft→completed] "
)
self._assert_reservation_status(order, 'converted_to_sale', "[STEP 1] ")
self._assert_sale_exists(order, should_exist=True)
self.assertFalse(order.is_returned, "[STEP 1] is_returned должен быть False после продажи")
# Проверяем партии
self.stock_batch.refresh_from_db()
self.assertEqual(
self.stock_batch.quantity,
Decimal('90.00'),
"[STEP 1] Партия должна уменьшиться на 10"
)
# ========== ШАГ 2: completed → draft ==========
order.status = self.status_draft
order.save()
order.refresh_from_db()
# Проверки после отката
self._assert_stock_state(
available=Decimal('100.00'),
reserved=Decimal('10.00'),
free=Decimal('90.00'),
msg_prefix="[STEP 2: completed→draft] "
)
self._assert_reservation_status(order, 'reserved', "[STEP 2] ")
self._assert_sale_exists(order, should_exist=False)
self.assertTrue(order.is_returned, "[STEP 2] is_returned должен быть True (был completed, но Sale удалены)")
# Партии должны восстановиться
self.stock_batch.refresh_from_db()
self.assertEqual(
self.stock_batch.quantity,
Decimal('100.00'),
"[STEP 2] Партия должна восстановиться"
)
# ========== ШАГ 3: draft → cancelled ==========
order.status = self.status_cancelled
order.save()
order.refresh_from_db()
# Проверки после отмены
self._assert_stock_state(
available=Decimal('100.00'),
reserved=Decimal('0.00'),
free=Decimal('100.00'),
msg_prefix="[STEP 3: draft→cancelled] "
)
self._assert_reservation_status(order, 'released', "[STEP 3] ")
self._assert_sale_exists(order, should_exist=False)
self.assertTrue(order.is_returned, "[STEP 3] is_returned остается True")
# ========== ШАГ 4: cancelled → in_assembly ==========
order.status = self.status_in_assembly
order.save()
order.refresh_from_db()
# Проверки после возобновления
self._assert_stock_state(
available=Decimal('100.00'),
reserved=Decimal('10.00'),
free=Decimal('90.00'),
msg_prefix="[STEP 4: cancelled→in_assembly] "
)
self._assert_reservation_status(order, 'reserved', "[STEP 4] ")
self._assert_sale_exists(order, should_exist=False)
self.assertTrue(order.is_returned, "[STEP 4] is_returned остается True (Sale еще нет)")
# ========== ШАГ 5: in_assembly → completed ==========
order.status = self.status_completed
order.save()
order.refresh_from_db()
# Проверки после второй продажи
self._assert_stock_state(
available=Decimal('90.00'),
reserved=Decimal('0.00'),
free=Decimal('90.00'),
msg_prefix="[STEP 5: in_assembly→completed] "
)
self._assert_reservation_status(order, 'converted_to_sale', "[STEP 5] ")
self._assert_sale_exists(order, should_exist=True)
self.assertFalse(order.is_returned, "[STEP 5] is_returned должен стать False (Sale созданы)")
# Проверяем, что НЕТ дублей Sale
sales_count = Sale.objects.filter(order=order).count()
self.assertEqual(sales_count, 1, "[STEP 5] Должна быть ровно 1 Sale (нет дублей)")
self.stock_batch.refresh_from_db()
self.assertEqual(
self.stock_batch.quantity,
Decimal('90.00'),
"[STEP 5] Партия должна снова уменьшиться на 10"
)
# ========== ШАГ 6: completed → cancelled ==========
order.status = self.status_cancelled
order.save()
order.refresh_from_db()
# Проверки после отмены выполненного
self._assert_stock_state(
available=Decimal('100.00'),
reserved=Decimal('0.00'),
free=Decimal('100.00'),
msg_prefix="[STEP 6: completed→cancelled] "
)
self._assert_reservation_status(order, 'released', "[STEP 6] ")
self._assert_sale_exists(order, should_exist=False)
self.assertTrue(order.is_returned, "[STEP 6] is_returned должен быть True (был completed, Sale удалены)")
self.stock_batch.refresh_from_db()
self.assertEqual(
self.stock_batch.quantity,
Decimal('100.00'),
"[STEP 6] Партия должна восстановиться после отмены"
)
# ========== ШАГ 7: cancelled → completed (автоматический промежуточный переход) ==========
# Должен произойти: cancelled → draft → completed
order.status = self.status_completed
order.save()
order.refresh_from_db()
# Проверяем, что прошли через draft (автоматический промежуточный переход)
history = order.history.all()
self.assertGreaterEqual(history.count(), 2, "[STEP 7] Должна быть история переходов")
# Проверки после автоматического перехода
self._assert_stock_state(
available=Decimal('90.00'),
reserved=Decimal('0.00'),
free=Decimal('90.00'),
msg_prefix="[STEP 7: cancelled→completed auto] "
)
self._assert_reservation_status(order, 'converted_to_sale', "[STEP 7] ")
self._assert_sale_exists(order, should_exist=True)
self.assertFalse(order.is_returned, "[STEP 7] is_returned должен быть False")
# Проверяем, что НЕТ дублей Sale после автоматического перехода
sales_count = Sale.objects.filter(order=order).count()
self.assertEqual(sales_count, 1, "[STEP 7] Должна быть ровно 1 Sale (нет дублей после auto-transition)")
# ========== ШАГ 8: completed → draft → cancelled ==========
order.status = self.status_draft
order.save()
order.refresh_from_db()
# Промежуточная проверка в draft
self._assert_stock_state(
available=Decimal('100.00'),
reserved=Decimal('10.00'),
free=Decimal('90.00'),
msg_prefix="[STEP 8a: completed→draft] "
)
self._assert_reservation_status(order, 'reserved', "[STEP 8a] ")
self._assert_sale_exists(order, should_exist=False)
# Финальная отмена
order.status = self.status_cancelled
order.save()
order.refresh_from_db()
# ========== ФИНАЛЬНЫЕ ПРОВЕРКИ ==========
self._assert_stock_state(
available=Decimal('100.00'),
reserved=Decimal('0.00'),
free=Decimal('100.00'),
msg_prefix="[FINAL: cancelled] "
)
self._assert_reservation_status(order, 'released', "[FINAL] ")
self._assert_sale_exists(order, should_exist=False)
self.assertTrue(order.is_returned, "[FINAL] is_returned должен быть True в финале")
# Проверяем, что все партии восстановлены до исходного состояния
self.stock_batch.refresh_from_db()
self.assertEqual(
self.stock_batch.quantity,
Decimal('100.00'),
"[FINAL] Партия должна полностью восстановиться до начального значения"
)
# Проверяем, что НЕТ активных Sale
sales = Sale.objects.filter(order=order)
self.assertEqual(sales.count(), 0, "[FINAL] Не должно быть активных Sale после финальной отмены")
# Проверяем общую консистентность данных
# Все резервы должны быть в released
reservations = Reservation.objects.filter(order_item__order=order)
for res in reservations:
self.assertEqual(
res.status,
'released',
f"[FINAL] Все резервы должны быть в 'released', но нашли {res.status}"
)