Улучшения в тестах переходов статусов заказов
- Исправлены комментарии и форматирование в signals.py - Улучшена читаемость кода в models.py - Обновлены шаблоны форм статусов - Доработаны тесты переходов статусов
This commit is contained in:
@@ -488,6 +488,17 @@ class Reservation(models.Model):
|
||||
help_text="Для какого физического экземпляра создан резерв"
|
||||
)
|
||||
|
||||
# ЗАЩИТА ОТ КРАЖИ ВИТРИННЫХ РЕЗЕРВОВ: запоминаем исходный order_item при отвязывании
|
||||
# Когда витринный резерв отвязывается от заказа (order_item=None), здесь сохраняется ID
|
||||
# исходного OrderItem. При обратном привязывании резерв вернётся только к своему "хозяину".
|
||||
original_order_item_id = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
verbose_name="ID исходной позиции заказа",
|
||||
help_text="Для витринных резервов: ID OrderItem, которому изначально принадлежал резерв (защита от кражи)"
|
||||
)
|
||||
|
||||
# Связь с позицией документа списания (для резервирования в черновике)
|
||||
writeoff_document_item = models.ForeignKey(
|
||||
'WriteOffDocumentItem',
|
||||
|
||||
@@ -305,13 +305,43 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
|
||||
# Проверяем: это положительный финальный статус?
|
||||
is_positive_end = instance.status.is_positive_end
|
||||
|
||||
|
||||
if not is_positive_end:
|
||||
return # Только для положительных финальных статусов (completed и т.п.)
|
||||
|
||||
|
||||
# === ЗАЩИТА ОТ RACE CONDITION: Проверяем предыдущий статус ===
|
||||
# Если уже были в completed и снова переходим в completed (например completed → draft → completed),
|
||||
# проверяем наличие Sale чтобы избежать дублирования
|
||||
try:
|
||||
history_count = instance.history.count()
|
||||
if history_count >= 2:
|
||||
previous_record = instance.history.all()[1]
|
||||
if previous_record.status_id:
|
||||
from orders.models import OrderStatus
|
||||
previous_status = OrderStatus.objects.get(id=previous_record.status_id)
|
||||
|
||||
# Если предыдущий статус тоже был положительным финальным
|
||||
if previous_status.is_positive_end:
|
||||
logger.info(
|
||||
f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус "
|
||||
f"({previous_status.name} → {instance.status.name}). Проверяем Sale..."
|
||||
)
|
||||
# Проверяем есть ли уже Sale
|
||||
if Sale.objects.filter(order=instance).exists():
|
||||
logger.info(
|
||||
f"✓ Заказ {instance.order_number}: Sale уже существуют, пропускаем создание"
|
||||
)
|
||||
update_is_returned_flag(instance)
|
||||
return
|
||||
except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError):
|
||||
pass
|
||||
|
||||
# Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа
|
||||
if Sale.objects.filter(order=instance).exists():
|
||||
# Продажи уже созданы — просто обновляем флаг is_returned и выходим
|
||||
logger.info(
|
||||
f"✓ Заказ {instance.order_number}: Sale уже существуют (проверка до создания)"
|
||||
)
|
||||
update_is_returned_flag(instance)
|
||||
return
|
||||
|
||||
@@ -365,28 +395,6 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
product_kit=kit
|
||||
).exclude(status='converted_to_sale')
|
||||
|
||||
# Если резервы не найдены по order_item (например, при переходе из cancelled),
|
||||
# ищем витринные резервы через product_kit
|
||||
if not kit_reservations.exists() and kit.is_temporary and kit.showcase:
|
||||
logger.info(
|
||||
f"🔄 Витринный комплект '{kit.name}': резервы не найдены по order_item. "
|
||||
f"Ищем через product_kit..."
|
||||
)
|
||||
|
||||
# Ищем резервы для этого комплекта (могут быть без order_item при переходе из cancelled)
|
||||
kit_reservations = Reservation.objects.filter(
|
||||
product_kit=kit,
|
||||
showcase__isnull=False,
|
||||
status='reserved'
|
||||
)
|
||||
|
||||
# Привязываем резервы к order_item перед созданием Sale
|
||||
if kit_reservations.exists():
|
||||
updated_count = kit_reservations.update(order_item=item)
|
||||
logger.info(
|
||||
f" ✅ Привязано {updated_count} резервов к OrderItem #{item.id}"
|
||||
)
|
||||
|
||||
if not kit_reservations.exists():
|
||||
# Проверяем, может быть витринный комплект уже продан через ShowcaseManager?
|
||||
already_sold = Reservation.objects.filter(
|
||||
@@ -516,13 +524,13 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
f"✓ Обновлено {updated_count} резервов для заказа {instance.order_number}: reserved → converted_to_sale"
|
||||
)
|
||||
|
||||
# === Финализация витринных экземпляров: reserved/available → sold ===
|
||||
# Находим все витринные комплекты в этом заказе:
|
||||
# - в статусе 'reserved' (обычный flow: создание черновика → завершение)
|
||||
# - в статусе 'available' (переход из отмены: cancelled → completed)
|
||||
# === Финализация витринных экземпляров: reserved → sold ===
|
||||
# Находим все витринные комплекты в этом заказе в статусе 'reserved'.
|
||||
# Прямой переход cancelled → completed НЕВОЗМОЖЕН (автоматически разбивается на draft).
|
||||
# Поэтому букеты всегда в 'reserved' при достижении completed.
|
||||
from inventory.models import ShowcaseItem
|
||||
|
||||
# Сначала ищем по sold_order_item (для букетов в reserved)
|
||||
# Ищем по sold_order_item (для букетов в reserved)
|
||||
showcase_items_to_finalize = ShowcaseItem.objects.filter(
|
||||
sold_order_item__order=instance,
|
||||
status='reserved'
|
||||
@@ -542,44 +550,6 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
f"❌ Ошибка финализации ShowcaseItem #{showcase_item.id}: {e}"
|
||||
)
|
||||
|
||||
# Теперь ищем букеты в available (переход из cancelled)
|
||||
# При отмене sold_order_item сбрасывается, поэтому ищем через product_kit
|
||||
showcase_order_items = instance.items.filter(
|
||||
product_kit__is_temporary=True,
|
||||
product_kit__showcase__isnull=False
|
||||
).select_related('product_kit')
|
||||
|
||||
for order_item in showcase_order_items:
|
||||
kit = order_item.product_kit
|
||||
|
||||
# Находим ShowcaseItem этого комплекта в статусе 'available'
|
||||
# Их sold_order_item = None после отмены, поэтому ищем через product_kit
|
||||
available_items = ShowcaseItem.objects.filter(
|
||||
product_kit=kit,
|
||||
status='available',
|
||||
sold_order_item__isnull=True
|
||||
)
|
||||
|
||||
if available_items.exists():
|
||||
logger.info(
|
||||
f" 🔄 Найдено {available_items.count()} ShowcaseItem комплекта '{kit.name}' в статусе 'available'. "
|
||||
f"Финализируем: available → sold (из отмены)..."
|
||||
)
|
||||
|
||||
for item in available_items:
|
||||
try:
|
||||
# Переход из отмены: available → sold (минуя reserved)
|
||||
# Используем mark_sold() который работает с available
|
||||
item.mark_sold(order_item)
|
||||
finalized_count += 1
|
||||
logger.info(
|
||||
f" ✅ ShowcaseItem #{item.id}: available → sold (привязан к OrderItem #{order_item.id})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f" ❌ Ошибка финализации ShowcaseItem #{item.id}: {e}"
|
||||
)
|
||||
|
||||
if finalized_count > 0:
|
||||
logger.info(
|
||||
f"🎉 Финализировано {finalized_count} витринных экземпляров для заказа {instance.order_number}"
|
||||
@@ -835,6 +805,9 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs):
|
||||
for reservation in showcase_kit_reservations:
|
||||
reservation.status = 'reserved'
|
||||
# КРИТИЧНО: Отвязываем резервы от заказа при отмене
|
||||
# Сохраняем ID для защиты от кражи резервов другими заказами
|
||||
if reservation.order_item:
|
||||
reservation.original_order_item_id = reservation.order_item.id
|
||||
reservation.order_item = None
|
||||
# Очищаем блокировки корзины
|
||||
reservation.cart_lock_expires_at = None
|
||||
@@ -842,7 +815,8 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs):
|
||||
reservation.cart_session_id = None
|
||||
# showcase_item и product_kit остаются - букет на витрине
|
||||
# converted_at оставляем (для истории)
|
||||
reservation.save(update_fields=['status', 'order_item', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
||||
reservation.save(update_fields=['status', 'order_item', 'original_order_item_id',
|
||||
'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
||||
|
||||
logger.info(
|
||||
f"✓ Обновлено {showcase_count} резервов витринных комплектов: "
|
||||
@@ -913,13 +887,19 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs):
|
||||
order_item = order_items.first()
|
||||
|
||||
if order_item:
|
||||
# Восстанавливаем связь showcase_item в резервах
|
||||
reservations_updated = Reservation.objects.filter(
|
||||
# Восстанавливаем связь showcase_item в резервах через .save()
|
||||
reservations = Reservation.objects.filter(
|
||||
order_item=order_item,
|
||||
product_kit=item.product_kit,
|
||||
status='reserved'
|
||||
).update(showcase_item=item)
|
||||
|
||||
)
|
||||
|
||||
reservations_updated = 0
|
||||
for reservation in reservations:
|
||||
reservation.showcase_item = item
|
||||
reservation.save(update_fields=['showcase_item'])
|
||||
reservations_updated += 1
|
||||
|
||||
if reservations_updated > 0:
|
||||
logger.debug(
|
||||
f" ✅ Восстановлена связь для {reservations_updated} резервов ShowcaseItem #{item.id}"
|
||||
@@ -1075,11 +1055,15 @@ def release_reservations_on_cancellation(sender, instance, created, **kwargs):
|
||||
# Для витринных комплектов очищаем блокировки корзины
|
||||
# КРИТИЧНО: Отвязываем резервы от заказа при отмене
|
||||
for reservation in showcase_kit_reservations:
|
||||
# Сохраняем ID для защиты от кражи резервов другими заказами
|
||||
if reservation.order_item:
|
||||
reservation.original_order_item_id = reservation.order_item.id
|
||||
reservation.order_item = None
|
||||
reservation.cart_lock_expires_at = None
|
||||
reservation.locked_by_user = None
|
||||
reservation.cart_session_id = None
|
||||
reservation.save(update_fields=['order_item', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
||||
reservation.save(update_fields=['order_item', 'original_order_item_id',
|
||||
'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
||||
|
||||
logger.info(
|
||||
f"ℹ️ Найдено {showcase_count} резервов витринных комплектов - остаются в reserved (на витрине, отвязаны от заказа, блокировки сняты)"
|
||||
@@ -1128,7 +1112,7 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Сигнал: Резервирование товара при переходе ОТ статуса 'cancelled' к другим статусам.
|
||||
|
||||
Триггер: cancelled → любой НЕ отменённый статус (draft, pending, completed и т.д.)
|
||||
Триггер: cancelled → любой НЕ отменённый статус (draft, pending и т.д.)
|
||||
|
||||
Процесс:
|
||||
1. Проверяем что предыдущий статус был 'cancelled' (is_negative_end)
|
||||
@@ -1138,9 +1122,13 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs):
|
||||
5. Stock автоматически обновится через сигнал
|
||||
|
||||
ПРИМЕРЫ сценариев:
|
||||
- cancelled → pending: резервы 'released' → 'reserved' ✅
|
||||
- cancelled → draft: резервы 'released' → 'reserved' ✅
|
||||
- cancelled → completed: резервы 'released' → 'reserved', затем create_sale_on_order_completion обработает ✅
|
||||
- cancelled → pending: резервы 'released' → 'reserved' ✅
|
||||
|
||||
ПРИМЕЧАНИЕ: Прямой переход cancelled → completed НЕВОЗМОЖЕН.
|
||||
Order.save() автоматически разбивает его на два шага:
|
||||
1. cancelled → draft (этот сигнал резервирует)
|
||||
2. draft → completed (create_sale_on_order_completion финализирует)
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1229,26 +1217,33 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs):
|
||||
|
||||
for order_item in showcase_order_items:
|
||||
kit = order_item.product_kit
|
||||
|
||||
# Находим витринные резервы для этого комплекта
|
||||
# (они в статусе 'reserved', но order_item=None)
|
||||
|
||||
# КРИТИЧНО: Находим ТОЛЬКО "родные" витринные резервы этого OrderItem
|
||||
# Используем original_order_item_id для защиты от кражи резервов другими заказами
|
||||
showcase_reservations = Reservation.objects.filter(
|
||||
product_kit=kit,
|
||||
showcase__isnull=False,
|
||||
status='reserved',
|
||||
order_item__isnull=True
|
||||
order_item__isnull=True, # Отвязаны от заказа
|
||||
original_order_item_id=order_item.id # ТОЛЬКО "родные" резервы!
|
||||
)
|
||||
|
||||
|
||||
if showcase_reservations.exists():
|
||||
# Привязываем резервы обратно к OrderItem
|
||||
updated_count = showcase_reservations.update(order_item=order_item)
|
||||
|
||||
# Используем цикл вместо bulk update чтобы очистить original_order_item_id
|
||||
updated_count = 0
|
||||
for reservation in showcase_reservations:
|
||||
reservation.order_item = order_item
|
||||
reservation.original_order_item_id = None # Очищаем после привязывания
|
||||
reservation.save(update_fields=['order_item', 'original_order_item_id'])
|
||||
updated_count += 1
|
||||
|
||||
logger.info(
|
||||
f" ✅ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{order_item.id}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f" ⚠ Витринный комплект '{kit.name}': не найдено витринных резервов без order_item"
|
||||
f" ⚠ Витринный комплект '{kit.name}': не найдено витринных резервов с original_order_item_id={order_item.id}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -1356,25 +1351,32 @@ def release_stock_on_order_delete(sender, instance, **kwargs):
|
||||
@transaction.atomic
|
||||
def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Сигнал: При создании или изменении позиции заказа управляем резервами.
|
||||
Сигнал: При изменении позиции заказа управляем резервами.
|
||||
|
||||
ВАЖНО: При СОЗДАНИИ OrderItem резервы создаются сигналом reserve_stock_on_item_create.
|
||||
Этот сигнал обрабатывает только ИЗМЕНЕНИЯ существующих OrderItem.
|
||||
|
||||
Процесс:
|
||||
1. Ищем существующий резерв для этой позиции (в ЛЮБОМ статусе)
|
||||
2. Если резерв ЕСТЬ - обновляем ТОЛЬКО количество (статус НЕ меняем!)
|
||||
3. Если резерва НЕТ - создаем новый
|
||||
3. Если резерва НЕТ - создаем новый (только для обновлений)
|
||||
|
||||
Покрывает все сценарии:
|
||||
- Создание заказа с товарами → создаёт резервы
|
||||
Покрывает сценарии:
|
||||
- Редактирование + добавление товаров → создаёт резервы для новых
|
||||
- Изменение количества → обновляет резервы (даже если уже converted_to_sale)
|
||||
- Витринные комплекты → привязывает существующие витринные резервы к заказу
|
||||
|
||||
|
||||
КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов!
|
||||
Резерв ищется по order_item независимо от статуса.
|
||||
Это предотвращает создание нового резерва для заказа в статусе 'completed'.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# КРИТИЧНО: Пропускаем создание - обрабатывается reserve_stock_on_item_create
|
||||
# Это предотвращает дублирование резервов при создании OrderItem
|
||||
if created:
|
||||
return
|
||||
|
||||
# Проверяем, это витринный комплект или обычный товар/комплект
|
||||
is_showcase_kit = instance.product_kit and instance.product_kit.is_temporary and instance.product_kit.showcase
|
||||
@@ -1392,11 +1394,13 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
||||
|
||||
if showcase_reservations.exists():
|
||||
# Привязываем витринные резервы к позиции заказа
|
||||
updated_count = showcase_reservations.update(
|
||||
order_item=instance,
|
||||
# Оставляем showcase и product_kit для аудита
|
||||
)
|
||||
|
||||
# Используем цикл вместо bulk update чтобы сработали сигналы обновления Stock
|
||||
updated_count = 0
|
||||
for reservation in showcase_reservations:
|
||||
reservation.order_item = instance
|
||||
reservation.save(update_fields=['order_item'])
|
||||
updated_count += 1
|
||||
|
||||
logger.info(
|
||||
f"✓ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{instance.id}, "
|
||||
f"заказ {instance.order.order_number}"
|
||||
@@ -1906,11 +1910,15 @@ def reserve_on_transformation_input_create(sender, instance, created, **kwargs):
|
||||
@receiver(pre_delete, sender=TransformationInput)
|
||||
def release_reservation_on_input_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
При удалении входного товара - освобождаем резерв.
|
||||
При удалении входного товара - освобождаем резерв через .save() для обновления Stock.
|
||||
"""
|
||||
Reservation.objects.filter(
|
||||
reservations = Reservation.objects.filter(
|
||||
transformation_input=instance
|
||||
).update(status='released', released_at=timezone.now())
|
||||
)
|
||||
for reservation in reservations:
|
||||
reservation.status = 'released'
|
||||
reservation.released_at = timezone.now()
|
||||
reservation.save(update_fields=['status', 'released_at'])
|
||||
|
||||
|
||||
@receiver(post_save, sender=Transformation)
|
||||
@@ -1945,23 +1953,16 @@ def process_transformation_on_complete(sender, instance, created, **kwargs):
|
||||
for batch, qty in allocations:
|
||||
total_input_cost += batch.cost_price * qty
|
||||
|
||||
# Обновляем резерв
|
||||
reservations_updated = Reservation.objects.filter(
|
||||
# Обновляем резерв через .save() чтобы сработали сигналы обновления Stock
|
||||
reservations = Reservation.objects.filter(
|
||||
transformation_input=trans_input,
|
||||
status='reserved'
|
||||
).update(
|
||||
status='converted_to_transformation',
|
||||
converted_at=timezone.now()
|
||||
)
|
||||
|
||||
# ВАЖНО: .update() не вызывает сигналы, поэтому нужно вручную обновить Stock
|
||||
if reservations_updated > 0:
|
||||
stock = Stock.objects.filter(
|
||||
product=trans_input.product,
|
||||
warehouse=instance.warehouse
|
||||
).first()
|
||||
if stock:
|
||||
stock.refresh_from_batches()
|
||||
|
||||
for reservation in reservations:
|
||||
reservation.status = 'converted_to_transformation'
|
||||
reservation.converted_at = timezone.now()
|
||||
reservation.save(update_fields=['status', 'converted_at'])
|
||||
|
||||
# 2. Создаем партии Output
|
||||
for trans_output in instance.outputs.all():
|
||||
@@ -2036,13 +2037,14 @@ def rollback_transformation_on_cancel(sender, instance, **kwargs):
|
||||
cost_price=cost
|
||||
)
|
||||
|
||||
# Возвращаем резерв в reserved
|
||||
Reservation.objects.filter(
|
||||
# Возвращаем резерв в reserved через .save() чтобы обновился Stock
|
||||
reservations = Reservation.objects.filter(
|
||||
transformation_input=trans_input
|
||||
).update(
|
||||
status='reserved',
|
||||
converted_at=None
|
||||
)
|
||||
for reservation in reservations:
|
||||
reservation.status = 'reserved'
|
||||
reservation.converted_at = None
|
||||
reservation.save(update_fields=['status', 'converted_at'])
|
||||
|
||||
|
||||
@receiver(post_save, sender=Transformation)
|
||||
@@ -2065,11 +2067,12 @@ def release_reservations_on_draft_cancel(sender, instance, **kwargs):
|
||||
if has_converted_reservations:
|
||||
return # Это была проведенная трансформация, обрабатывается другим сигналом
|
||||
|
||||
# Освобождаем все резервы
|
||||
Reservation.objects.filter(
|
||||
# Освобождаем все резервы через .save() чтобы обновился Stock
|
||||
reservations = Reservation.objects.filter(
|
||||
transformation_input__transformation=instance,
|
||||
status='reserved'
|
||||
).update(
|
||||
status='released',
|
||||
released_at=timezone.now()
|
||||
)
|
||||
for reservation in reservations:
|
||||
reservation.status = 'released'
|
||||
reservation.released_at = timezone.now()
|
||||
reservation.save(update_fields=['status', 'released_at'])
|
||||
|
||||
@@ -902,3 +902,238 @@ class OrderStatusTransitionCriticalTest(TestCase):
|
||||
# Проверяем, что 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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user