1136 lines
51 KiB
Python
1136 lines
51 KiB
Python
# -*- 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()
|
||
|
||
# Проверки после автоматического перехода
|
||
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}"
|
||
)
|