Files
octopus/myproject/inventory/tests/test_order_status_transitions.py
Andrey Smakotin 812ecb53e6 Fix media file storage path and permissions
- Fix MEDIA_ROOT path to match Docker volume mount (/app/myproject/media)
- Update docker-compose.yml volume mounts to match MEDIA_ROOT
- Add setup_directories() function in entrypoint.sh to create media directories with proper permissions
- Add logging to TenantAwareFileSystemStorage for debugging
- Fix is_returned flag logic improvements (from previous work)
2025-12-21 16:54:44 +03:00

804 lines
35 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_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")