From 503a00de744062fc0ea4f910ea129ab12cfd6772 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Thu, 11 Dec 2025 23:54:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D1=84=D0=BB?= =?UTF-8?q?=D0=B0=D0=B3=D0=B0=20is=5Freturned=20=D0=B8=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B7=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20completed=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: 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 для возвращённого заказа без резервов - Защита от двойного списания при переиспользовании витринных комплектов - Понятные сообщения об ошибках для пользователя - Предсказуемое поведение при любых комбинациях смены статусов --- myproject/inventory/signals.py | 122 +++++++++++++++++++++++---------- 1 file changed, 85 insertions(+), 37 deletions(-) diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index 9efc690..3e18647 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -10,6 +10,8 @@ from django.dispatch import receiver from django.utils import timezone from decimal import Decimal +from django.core.exceptions import ValidationError + from orders.models import Order, OrderItem from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem from inventory.services import SaleProcessor @@ -17,6 +19,39 @@ from inventory.services.batch_manager import StockBatchManager 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) 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! + + ВАЛИДАЦИЯ: + - Запрещаем переход в положительный финальный статус для заказов с is_returned=True, + у которых нет резервов (товар уже продан в другом заказе). Процесс: 1. Проверяем, изменился ли статус на 'completed' - 2. Для каждого товара создаем Sale (автоматический FIFO-список) - 3. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale' + 2. ВАЛИДАЦИЯ: если is_returned=True и резервов нет → запрещаем + 3. Для каждого товара создаем Sale (автоматический FIFO-список) + 4. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale' + 5. Обновляем флаг is_returned """ import logging logger = logging.getLogger(__name__) @@ -89,12 +130,40 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): return # Только для обновлений # Проверяем наличие статуса (может быть None при создании) - if not instance.status or instance.status.code != 'completed': - return # Только для статуса 'completed' + if not instance.status: + 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 для этого заказа if Sale.objects.filter(order=instance).exists(): - return # Продажи уже созданы, выходим БЕЗ обновления резервов + # Продажи уже созданы — просто обновляем флаг is_returned и выходим + update_is_returned_flag(instance) + return # Проверяем наличие резервов для этого заказа # Ищем резервы в статусах 'reserved' (новые) и 'released' (после отката) @@ -105,9 +174,12 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs): if not reservations_to_update.exists(): logger.warning( - f"⚠ Заказ {instance.order_number} переведён в 'completed', " + f"⚠ Заказ {instance.order_number} переведён в '{instance.status.name}', " f"но нет резервов для обновления (все уже converted_to_sale или отсутствуют)" ) + # Обновляем флаг is_returned и выходим + update_is_returned_flag(instance) + return # Определяем склад (используем склад самовывоза из заказа или первый активный) 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"обновлено {reservations_to_update.count() if reservations_to_update.exists() else 0} резервов" ) + + # Обновляем флаг is_returned на основе фактического состояния + update_is_returned_flag(instance) @receiver(post_save, sender=Order) @@ -503,16 +578,8 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs): ) # === Обновляем is_returned === - if is_cancellation: - # Сценарий Б: устанавливаем is_returned = True - 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") + # Используем единую функцию для обновления флага на основе фактического состояния + update_is_returned_flag(instance) logger.info( f"🎉 Откат для заказа {instance.order_number} завершён успешно: " @@ -639,27 +706,8 @@ def release_reservations_on_cancellation(sender, instance, created, **kwargs): ) # === Обновляем is_returned === - # Проверяем: был ли заказ когда-либо в статусе completed (продан)? - # Если да, то это возврат/отмена проданного товара - 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}" - ) + # Используем единую функцию для обновления флага + update_is_returned_flag(instance) @receiver(post_save, sender=Order)