Улучшения в тестах переходов статусов заказов
- Исправлены комментарии и форматирование в signals.py - Улучшена читаемость кода в models.py - Обновлены шаблоны форм статусов - Доработаны тесты переходов статусов
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -305,13 +305,43 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
|
|
||||||
# Проверяем: это положительный финальный статус?
|
# Проверяем: это положительный финальный статус?
|
||||||
is_positive_end = instance.status.is_positive_end
|
is_positive_end = instance.status.is_positive_end
|
||||||
|
|
||||||
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,13 +887,19 @@ 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(
|
||||||
f" ✅ Восстановлена связь для {reservations_updated} резервов ShowcaseItem #{item.id}"
|
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:
|
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__)
|
||||||
@@ -1229,26 +1217,33 @@ 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,25 +1351,32 @@ 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)
|
||||||
- Витринные комплекты → привязывает существующие витринные резервы к заказу
|
- Витринные комплекты → привязывает существующие витринные резервы к заказу
|
||||||
|
|
||||||
КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов!
|
КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов!
|
||||||
Резерв ищется по order_item независимо от статуса.
|
Резерв ищется по order_item независимо от статуса.
|
||||||
Это предотвращает создание нового резерва для заказа в статусе 'completed'.
|
Это предотвращает создание нового резерва для заказа в статусе 'completed'.
|
||||||
"""
|
"""
|
||||||
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,11 +1394,13 @@ 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}, "
|
||||||
f"заказ {instance.order.order_number}"
|
f"заказ {instance.order.order_number}"
|
||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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):
|
||||||
"""Количество заказов в этом статусе"""
|
"""Количество заказов в этом статусе"""
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user