Улучшена логика флага is_returned и добавлен запрет повторного completed для возвращённых заказов

Проблема:
1. Флаг is_returned управлялся в разных местах непоследовательно
2. При цепочке completed → cancelled → completed флаг оставался True
3. Можно было установить положительный статус для заказа с is_returned=True
   без резервов (товар уже продан в другом заказе)

Решение:

1. ЕДИНАЯ ФУНКЦИЯ update_is_returned_flag():
   - Флаг основан на РЕАЛЬНОМ состоянии заказа (наличие Sale)
   - Логика: есть Sale → is_returned=False
   - Нет Sale + был когда-то в положительном финальном статусе → is_returned=True
   - Нет Sale + никогда не был в положительном статусе → is_returned=False

2. ВЫЗОВ update_is_returned_flag() в ключевых точках:
   - После создания Sale (create_sale_on_order_completion)
   - После отката Sale (rollback_sale_on_status_change)
   - После освобождения резервов (release_reservations_on_cancellation)

3. ВАЛИДАЦИЯ в create_sale_on_order_completion:
   - Запрещаем переход в положительный финальный статус (is_positive_end=True)
     для заказов с is_returned=True, у которых нет резервов
   - Даём понятное сообщение: резервы отсутствуют, товары могли быть проданы
     в другом заказе, оставьте статус отрицательного исхода или создайте новый заказ

4. АВТОМАТИЧЕСКИЙ СБРОС is_returned:
   - При законном переходе в положительный статус с резервами флаг сбрасывается
   - Это позволяет исправить ошибочную отмену: cancelled → completed работает,
     если резервы на месте (товар не ушёл в другой заказ)

5. УДАЛЕНА ДУБЛИРУЮЩАЯ ЛОГИКА:
   - Убрали ручное управление is_returned в rollback_sale_on_status_change
   - Убрали ручное управление is_returned в release_reservations_on_cancellation
   - Теперь один источник истины через update_is_returned_flag()

Результат:
- Флаг is_returned всегда соответствует реальности (наличию Sale)
- Невозможно установить completed для возвращённого заказа без резервов
- Защита от двойного списания при переиспользовании витринных комплектов
- Понятные сообщения об ошибках для пользователя
- Предсказуемое поведение при любых комбинациях смены статусов
This commit is contained in:
2025-12-11 23:54:48 +03:00
parent 2a3898fb44
commit 503a00de74

View File

@@ -10,6 +10,8 @@ from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ValidationError
from orders.models import Order, OrderItem from orders.models import Order, OrderItem
from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem
from inventory.services import SaleProcessor from inventory.services import SaleProcessor
@@ -17,6 +19,39 @@ from inventory.services.batch_manager import StockBatchManager
from inventory.services.inventory_processor import InventoryProcessor from inventory.services.inventory_processor import InventoryProcessor
def update_is_returned_flag(order):
"""
Обновляет флаг is_returned на основе фактического состояния заказа.
Логика:
- Если есть хотя бы одна Sale по этому заказу → is_returned = False
- Если Sale нет, но заказ когда-либо был в статусе completed → is_returned = True
- Если заказ ни разу не был completed → is_returned = False
Это гарантирует что флаг отражает реальность:
- Заказ продан и не возвращён → False
- Заказ был продан, но продажи откачены (возврат) → True
- Новый заказ без продаж → False
"""
has_sale_now = Sale.objects.filter(order=order).exists()
# Проверяем историю: был ли когда-либо в положительном финальном статусе
was_completed_ever = order.history.filter(
status__is_positive_end=True
).exists()
if has_sale_now:
# Есть актуальные продажи → заказ не возвращён
new_flag = False
else:
# Продаж нет → возвращён только если был когда-то completed
new_flag = was_completed_ever
# Обновляем только если значение изменилось
if order.is_returned != new_flag:
Order.objects.filter(pk=order.pk).update(is_returned=new_flag)
@receiver(post_save, sender=Order) @receiver(post_save, sender=Order)
def reserve_stock_on_order_create(sender, instance, created, **kwargs): def reserve_stock_on_order_create(sender, instance, created, **kwargs):
""" """
@@ -76,11 +111,17 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
создается операция Sale и резервы преобразуются в продажу. создается операция Sale и резервы преобразуются в продажу.
КРИТИЧНО: Резервы обновляются ТОЛЬКО ПОСЛЕ успешного создания Sale! КРИТИЧНО: Резервы обновляются ТОЛЬКО ПОСЛЕ успешного создания Sale!
ВАЛИДАЦИЯ:
- Запрещаем переход в положительный финальный статус для заказов с is_returned=True,
у которых нет резервов (товар уже продан в другом заказе).
Процесс: Процесс:
1. Проверяем, изменился ли статус на 'completed' 1. Проверяем, изменился ли статус на 'completed'
2. Для каждого товара создаем Sale (автоматический FIFO-список) 2. ВАЛИДАЦИЯ: если is_returned=True и резервов нет → запрещаем
3. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale' 3. Для каждого товара создаем Sale (автоматический FIFO-список)
4. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale'
5. Обновляем флаг is_returned
""" """
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -89,12 +130,40 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
return # Только для обновлений return # Только для обновлений
# Проверяем наличие статуса (может быть None при создании) # Проверяем наличие статуса (может быть None при создании)
if not instance.status or instance.status.code != 'completed': if not instance.status:
return # Только для статуса 'completed' return
# Проверяем: это положительный финальный статус?
is_positive_end = instance.status.is_positive_end
if not is_positive_end:
return # Только для положительных финальных статусов (completed и т.п.)
# === ВАЛИДАЦИЯ: Запрет повторного completed для возвращённых заказов без резервов ===
if instance.is_returned:
# Заказ уже был продан и возвращён — проверяем наличие резервов
has_reservations = Reservation.objects.filter(
order_item__order=instance
).exists()
if not has_reservations:
# Резервов нет — товар уже ушёл в другой заказ или был освобождён
logger.error(
f"❌ Заказ {instance.order_number} имеет флаг is_returned=True и не имеет резервов. "
f"Невозможно перевести в статус '{instance.status.name}'."
)
raise ValidationError(
f"Невозможно установить статус '{instance.status.name}' для заказа {instance.order_number}. "
f"Этот заказ уже был отменён после продажи, резервы отсутствуют. "
f"Товары могли быть проданы в другом заказе. "
f"Пожалуйста, оставьте статус отрицательного исхода (отменён) или создайте новый заказ."
)
# Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа # Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа
if Sale.objects.filter(order=instance).exists(): if Sale.objects.filter(order=instance).exists():
return # Продажи уже созданы, выходим БЕЗ обновления резервов # Продажи уже созданы — просто обновляем флаг is_returned и выходим
update_is_returned_flag(instance)
return
# Проверяем наличие резервов для этого заказа # Проверяем наличие резервов для этого заказа
# Ищем резервы в статусах 'reserved' (новые) и 'released' (после отката) # Ищем резервы в статусах 'reserved' (новые) и 'released' (после отката)
@@ -105,9 +174,12 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
if not reservations_to_update.exists(): if not reservations_to_update.exists():
logger.warning( logger.warning(
f"⚠ Заказ {instance.order_number} переведён в 'completed', " f"⚠ Заказ {instance.order_number} переведён в '{instance.status.name}', "
f"но нет резервов для обновления (все уже converted_to_sale или отсутствуют)" f"но нет резервов для обновления (все уже converted_to_sale или отсутствуют)"
) )
# Обновляем флаг is_returned и выходим
update_is_returned_flag(instance)
return
# Определяем склад (используем склад самовывоза из заказа или первый активный) # Определяем склад (используем склад самовывоза из заказа или первый активный)
warehouse = instance.pickup_warehouse or Warehouse.objects.filter(is_active=True).first() warehouse = instance.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
@@ -234,6 +306,9 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
f"🎉 Заказ {instance.order_number} успешно обработан: создано {len(sales_created)} Sale, " f"🎉 Заказ {instance.order_number} успешно обработан: создано {len(sales_created)} Sale, "
f"обновлено {reservations_to_update.count() if reservations_to_update.exists() else 0} резервов" f"обновлено {reservations_to_update.count() if reservations_to_update.exists() else 0} резервов"
) )
# Обновляем флаг is_returned на основе фактического состояния
update_is_returned_flag(instance)
@receiver(post_save, sender=Order) @receiver(post_save, sender=Order)
@@ -503,16 +578,8 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs):
) )
# === Обновляем is_returned === # === Обновляем is_returned ===
if is_cancellation: # Используем единую функцию для обновления флага на основе фактического состояния
# Сценарий Б: устанавливаем is_returned = True update_is_returned_flag(instance)
Order.objects.filter(pk=instance.pk).update(is_returned=True)
logger.info(f"✓ Установлен флаг is_returned = True")
else:
# Сценарий А: сбрасываем is_returned = False
# (на случай если ранее был cancelled, а теперь вернули в промежуточный)
if instance.is_returned:
Order.objects.filter(pk=instance.pk).update(is_returned=False)
logger.info(f"✓ Сброшен флаг is_returned = False")
logger.info( logger.info(
f"🎉 Откат для заказа {instance.order_number} завершён успешно: " f"🎉 Откат для заказа {instance.order_number} завершён успешно: "
@@ -639,27 +706,8 @@ def release_reservations_on_cancellation(sender, instance, created, **kwargs):
) )
# === Обновляем is_returned === # === Обновляем is_returned ===
# Проверяем: был ли заказ когда-либо в статусе completed (продан)? # Используем единую функцию для обновления флага
# Если да, то это возврат/отмена проданного товара update_is_returned_flag(instance)
try:
from orders.models import OrderStatus
# Проверяем всю историю заказа
was_completed = instance.history.filter(
status__is_positive_end=True
).exists()
if was_completed:
# Заказ был продан → это возврат
Order.objects.filter(pk=instance.pk).update(is_returned=True)
logger.info(
f"✓ Заказ {instance.order_number} был продан ранее. "
f"Установлен флаг is_returned = True"
)
except Exception as e:
logger.warning(
f"Не удалось проверить историю заказа {instance.order_number}: {e}"
)
@receiver(post_save, sender=Order) @receiver(post_save, sender=Order)