Улучшения в тестах переходов статусов заказов

- Исправлены комментарии и форматирование в signals.py
- Улучшена читаемость кода в models.py
- Обновлены шаблоны форм статусов
- Доработаны тесты переходов статусов
This commit is contained in:
2026-01-05 21:30:25 +03:00
parent 70f0e4fb4c
commit 2aba3d2404
5 changed files with 400 additions and 121 deletions

View File

@@ -488,6 +488,17 @@ class Reservation(models.Model):
help_text="Для какого физического экземпляра создан резерв" 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( writeoff_document_item = models.ForeignKey(
'WriteOffDocumentItem', 'WriteOffDocumentItem',

View File

@@ -309,9 +309,39 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
if not is_positive_end: if not is_positive_end:
return # Только для положительных финальных статусов (completed и т.п.) 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 для этого заказа # Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа
if Sale.objects.filter(order=instance).exists(): if Sale.objects.filter(order=instance).exists():
# Продажи уже созданы — просто обновляем флаг is_returned и выходим # Продажи уже созданы — просто обновляем флаг is_returned и выходим
logger.info(
f"✓ Заказ {instance.order_number}: Sale уже существуют (проверка до создания)"
)
update_is_returned_flag(instance) update_is_returned_flag(instance)
return return
@@ -365,28 +395,6 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
product_kit=kit product_kit=kit
).exclude(status='converted_to_sale') ).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(): if not kit_reservations.exists():
# Проверяем, может быть витринный комплект уже продан через ShowcaseManager? # Проверяем, может быть витринный комплект уже продан через ShowcaseManager?
already_sold = Reservation.objects.filter( 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" f"✓ Обновлено {updated_count} резервов для заказа {instance.order_number}: reserved → converted_to_sale"
) )
# === Финализация витринных экземпляров: reserved/available → sold === # === Финализация витринных экземпляров: reserved → sold ===
# Находим все витринные комплекты в этом заказе: # Находим все витринные комплекты в этом заказе в статусе 'reserved'.
# - в статусе 'reserved' (обычный flow: создание черновика → завершение) # Прямой переход cancelled → completed НЕВОЗМОЖЕН (автоматически разбивается на draft).
# - в статусе 'available' (переход из отмены: cancelled → completed) # Поэтому букеты всегда в 'reserved' при достижении completed.
from inventory.models import ShowcaseItem from inventory.models import ShowcaseItem
# Сначала ищем по sold_order_item (для букетов в reserved) # Ищем по sold_order_item (для букетов в reserved)
showcase_items_to_finalize = ShowcaseItem.objects.filter( showcase_items_to_finalize = ShowcaseItem.objects.filter(
sold_order_item__order=instance, sold_order_item__order=instance,
status='reserved' status='reserved'
@@ -542,44 +550,6 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
f"❌ Ошибка финализации ShowcaseItem #{showcase_item.id}: {e}" 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: if finalized_count > 0:
logger.info( logger.info(
f"🎉 Финализировано {finalized_count} витринных экземпляров для заказа {instance.order_number}" 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: for reservation in showcase_kit_reservations:
reservation.status = 'reserved' reservation.status = 'reserved'
# КРИТИЧНО: Отвязываем резервы от заказа при отмене # КРИТИЧНО: Отвязываем резервы от заказа при отмене
# Сохраняем ID для защиты от кражи резервов другими заказами
if reservation.order_item:
reservation.original_order_item_id = reservation.order_item.id
reservation.order_item = None reservation.order_item = None
# Очищаем блокировки корзины # Очищаем блокировки корзины
reservation.cart_lock_expires_at = 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 reservation.cart_session_id = None
# showcase_item и product_kit остаются - букет на витрине # showcase_item и product_kit остаются - букет на витрине
# converted_at оставляем (для истории) # 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( logger.info(
f"✓ Обновлено {showcase_count} резервов витринных комплектов: " f"✓ Обновлено {showcase_count} резервов витринных комплектов: "
@@ -913,12 +887,18 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs):
order_item = order_items.first() order_item = order_items.first()
if order_item: if order_item:
# Восстанавливаем связь showcase_item в резервах # Восстанавливаем связь showcase_item в резервах через .save()
reservations_updated = Reservation.objects.filter( reservations = Reservation.objects.filter(
order_item=order_item, order_item=order_item,
product_kit=item.product_kit, product_kit=item.product_kit,
status='reserved' 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: if reservations_updated > 0:
logger.debug( logger.debug(
@@ -1075,11 +1055,15 @@ def release_reservations_on_cancellation(sender, instance, created, **kwargs):
# Для витринных комплектов очищаем блокировки корзины # Для витринных комплектов очищаем блокировки корзины
# КРИТИЧНО: Отвязываем резервы от заказа при отмене # КРИТИЧНО: Отвязываем резервы от заказа при отмене
for reservation in showcase_kit_reservations: 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.order_item = None
reservation.cart_lock_expires_at = None reservation.cart_lock_expires_at = None
reservation.locked_by_user = None reservation.locked_by_user = None
reservation.cart_session_id = 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( logger.info(
f" Найдено {showcase_count} резервов витринных комплектов - остаются в reserved (на витрине, отвязаны от заказа, блокировки сняты)" f" Найдено {showcase_count} резервов витринных комплектов - остаются в reserved (на витрине, отвязаны от заказа, блокировки сняты)"
@@ -1128,7 +1112,7 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs):
""" """
Сигнал: Резервирование товара при переходе ОТ статуса 'cancelled' к другим статусам. Сигнал: Резервирование товара при переходе ОТ статуса 'cancelled' к другим статусам.
Триггер: cancelled → любой НЕ отменённый статус (draft, pending, completed и т.д.) Триггер: cancelled → любой НЕ отменённый статус (draft, pending и т.д.)
Процесс: Процесс:
1. Проверяем что предыдущий статус был 'cancelled' (is_negative_end) 1. Проверяем что предыдущий статус был 'cancelled' (is_negative_end)
@@ -1138,9 +1122,13 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs):
5. Stock автоматически обновится через сигнал 5. Stock автоматически обновится через сигнал
ПРИМЕРЫ сценариев: ПРИМЕРЫ сценариев:
- cancelled → pending: резервы 'released''reserved'
- cancelled → draft: резервы '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 import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1230,25 +1218,32 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs):
for order_item in showcase_order_items: for order_item in showcase_order_items:
kit = order_item.product_kit kit = order_item.product_kit
# Находим витринные резервы для этого комплекта # КРИТИЧНО: Находим ТОЛЬКО "родные" витринные резервы этого OrderItem
# (они в статусе 'reserved', но order_item=None) # Используем original_order_item_id для защиты от кражи резервов другими заказами
showcase_reservations = Reservation.objects.filter( showcase_reservations = Reservation.objects.filter(
product_kit=kit, product_kit=kit,
showcase__isnull=False, showcase__isnull=False,
status='reserved', status='reserved',
order_item__isnull=True order_item__isnull=True, # Отвязаны от заказа
original_order_item_id=order_item.id # ТОЛЬКО "родные" резервы!
) )
if showcase_reservations.exists(): if showcase_reservations.exists():
# Привязываем резервы обратно к OrderItem # Привязываем резервы обратно к 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( logger.info(
f" ✅ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{order_item.id}" f" ✅ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{order_item.id}"
) )
else: else:
logger.warning( logger.warning(
f" ⚠ Витринный комплект '{kit.name}': не найдено витринных резервов без order_item" f" ⚠ Витринный комплект '{kit.name}': не найдено витринных резервов с original_order_item_id={order_item.id}"
) )
logger.info( logger.info(
@@ -1356,15 +1351,17 @@ def release_stock_on_order_delete(sender, instance, **kwargs):
@transaction.atomic @transaction.atomic
def update_reservation_on_item_change(sender, instance, created, **kwargs): def update_reservation_on_item_change(sender, instance, created, **kwargs):
""" """
Сигнал: При создании или изменении позиции заказа управляем резервами. Сигнал: При изменении позиции заказа управляем резервами.
ВАЖНО: При СОЗДАНИИ OrderItem резервы создаются сигналом reserve_stock_on_item_create.
Этот сигнал обрабатывает только ИЗМЕНЕНИЯ существующих OrderItem.
Процесс: Процесс:
1. Ищем существующий резерв для этой позиции (в ЛЮБОМ статусе) 1. Ищем существующий резерв для этой позиции (в ЛЮБОМ статусе)
2. Если резерв ЕСТЬ - обновляем ТОЛЬКО количество (статус НЕ меняем!) 2. Если резерв ЕСТЬ - обновляем ТОЛЬКО количество (статус НЕ меняем!)
3. Если резерва НЕТ - создаем новый 3. Если резерва НЕТ - создаем новый (только для обновлений)
Покрывает все сценарии: Покрывает сценарии:
- Создание заказа с товарами → создаёт резервы
- Редактирование + добавление товаров → создаёт резервы для новых - Редактирование + добавление товаров → создаёт резервы для новых
- Изменение количества → обновляет резервы (даже если уже converted_to_sale) - Изменение количества → обновляет резервы (даже если уже converted_to_sale)
- Витринные комплекты → привязывает существующие витринные резервы к заказу - Витринные комплекты → привязывает существующие витринные резервы к заказу
@@ -1376,6 +1373,11 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
import logging import logging
logger = logging.getLogger(__name__) 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 is_showcase_kit = instance.product_kit and instance.product_kit.is_temporary and instance.product_kit.showcase
@@ -1392,10 +1394,12 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
if showcase_reservations.exists(): if showcase_reservations.exists():
# Привязываем витринные резервы к позиции заказа # Привязываем витринные резервы к позиции заказа
updated_count = showcase_reservations.update( # Используем цикл вместо bulk update чтобы сработали сигналы обновления Stock
order_item=instance, updated_count = 0
# Оставляем showcase и product_kit для аудита for reservation in showcase_reservations:
) reservation.order_item = instance
reservation.save(update_fields=['order_item'])
updated_count += 1
logger.info( logger.info(
f"✓ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{instance.id}, " f"✓ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{instance.id}, "
@@ -1906,11 +1910,15 @@ def reserve_on_transformation_input_create(sender, instance, created, **kwargs):
@receiver(pre_delete, sender=TransformationInput) @receiver(pre_delete, sender=TransformationInput)
def release_reservation_on_input_delete(sender, instance, **kwargs): def release_reservation_on_input_delete(sender, instance, **kwargs):
""" """
При удалении входного товара - освобождаем резерв. При удалении входного товара - освобождаем резерв через .save() для обновления Stock.
""" """
Reservation.objects.filter( reservations = Reservation.objects.filter(
transformation_input=instance 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) @receiver(post_save, sender=Transformation)
@@ -1945,23 +1953,16 @@ def process_transformation_on_complete(sender, instance, created, **kwargs):
for batch, qty in allocations: for batch, qty in allocations:
total_input_cost += batch.cost_price * qty total_input_cost += batch.cost_price * qty
# Обновляем резерв # Обновляем резерв через .save() чтобы сработали сигналы обновления Stock
reservations_updated = Reservation.objects.filter( reservations = Reservation.objects.filter(
transformation_input=trans_input, transformation_input=trans_input,
status='reserved' status='reserved'
).update(
status='converted_to_transformation',
converted_at=timezone.now()
) )
# ВАЖНО: .update() не вызывает сигналы, поэтому нужно вручную обновить Stock for reservation in reservations:
if reservations_updated > 0: reservation.status = 'converted_to_transformation'
stock = Stock.objects.filter( reservation.converted_at = timezone.now()
product=trans_input.product, reservation.save(update_fields=['status', 'converted_at'])
warehouse=instance.warehouse
).first()
if stock:
stock.refresh_from_batches()
# 2. Создаем партии Output # 2. Создаем партии Output
for trans_output in instance.outputs.all(): for trans_output in instance.outputs.all():
@@ -2036,13 +2037,14 @@ def rollback_transformation_on_cancel(sender, instance, **kwargs):
cost_price=cost cost_price=cost
) )
# Возвращаем резерв в reserved # Возвращаем резерв в reserved через .save() чтобы обновился Stock
Reservation.objects.filter( reservations = Reservation.objects.filter(
transformation_input=trans_input 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) @receiver(post_save, sender=Transformation)
@@ -2065,11 +2067,12 @@ def release_reservations_on_draft_cancel(sender, instance, **kwargs):
if has_converted_reservations: if has_converted_reservations:
return # Это была проведенная трансформация, обрабатывается другим сигналом return # Это была проведенная трансформация, обрабатывается другим сигналом
# Освобождаем все резервы # Освобождаем все резервы через .save() чтобы обновился Stock
Reservation.objects.filter( reservations = Reservation.objects.filter(
transformation_input__transformation=instance, transformation_input__transformation=instance,
status='reserved' 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'])

View File

@@ -902,3 +902,238 @@ class OrderStatusTransitionCriticalTest(TestCase):
# Проверяем, что is_returned False (есть Sale) # Проверяем, что is_returned False (есть Sale)
order.refresh_from_db() order.refresh_from_db()
self.assertFalse(order.is_returned, "[COMPLETED AGAIN] is_returned должен быть False при наличии Sale") 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}"
)

View File

@@ -1,4 +1,5 @@
from django.db import models from django.db import models
from django.core.exceptions import ValidationError
from accounts.models import CustomUser from accounts.models import CustomUser
@@ -90,10 +91,24 @@ class OrderStatus(models.Model):
models.Index(fields=['is_system']), models.Index(fields=['is_system']),
models.Index(fields=['order']), models.Index(fields=['order']),
] ]
constraints = [
models.CheckConstraint(
check=~(models.Q(is_positive_end=True) & models.Q(is_negative_end=True)),
name='not_both_positive_and_negative_end',
violation_error_message="Статус не может быть одновременно положительным и отрицательным концом"
)
]
def __str__(self): def __str__(self):
return self.name return self.name
def clean(self):
"""Валидация модели перед сохранением"""
if self.is_positive_end and self.is_negative_end:
raise ValidationError(
"Статус не может быть одновременно положительным и отрицательным концом"
)
@property @property
def orders_count(self): def orders_count(self):
"""Количество заказов в этом статусе""" """Количество заказов в этом статусе"""

View File

@@ -294,6 +294,21 @@ document.addEventListener('DOMContentLoaded', function() {
positiveEndCheckbox.addEventListener('change', updatePreview); positiveEndCheckbox.addEventListener('change', updatePreview);
negativeEndCheckbox.addEventListener('change', updatePreview); negativeEndCheckbox.addEventListener('change', updatePreview);
// Взаимное отключение чекбоксов: нельзя быть одновременно положительным и отрицательным
positiveEndCheckbox.addEventListener('change', function() {
if (this.checked) {
negativeEndCheckbox.checked = false;
}
updatePreview();
});
negativeEndCheckbox.addEventListener('change', function() {
if (this.checked) {
positiveEndCheckbox.checked = false;
}
updatePreview();
});
// Инициальное обновление // Инициальное обновление
updatePreview(); updatePreview();
}); });