# Исправление двойного списания товаров при смене статуса заказа ## 🐛 Проблема При смене статуса заказа на "Выполнен" (`completed`) происходило **двойное списание товара со склада**: - В заказе было 10 штук товара - Sale (продажа) регистрировалась на 10 штук - Но со склада списывалось 20 штук ### Причины двойного списания Было обнаружено **ДВА независимых источника** проблемы: --- ## 🔥 Проблема #1: Повторное обновление резервов через `.save()` ### Файл: `inventory/signals.py` → сигнал `create_sale_on_order_completion` **Старый код (ОШИБОЧНЫЙ):** ```python @receiver(post_save, sender=Order) def create_sale_on_order_completion(sender, instance, created, **kwargs): if created: return if not instance.status or instance.status.code != 'completed': return # ❌ ПРОБЛЕМА: Резервы обновлялись ВСЕГДА через .save() # Это вызывало сигнал update_stock_on_reservation_change каждый раз for item in instance.items.all(): reservations = Reservation.objects.filter( order_item=item, status='reserved' ) for res in reservations: res.status = 'converted_to_sale' res.converted_at = timezone.now() res.save() # ← Вызывает сигнал, который пересчитывает Stock # Проверка на дубликаты только ПОСЛЕ обновления резервов if Sale.objects.filter(order=instance).exists(): return # Создание Sale... ``` **Сценарий двойного срабатывания:** 1. Первое сохранение заказа со статусом `completed` → резервы обновляются → Sale создаётся 2. **Повторное сохранение** того же заказа (например, через админку) → резервы **снова** обновляются через `.save()` → вызывается сигнал `update_stock_on_reservation_change` → возможно некорректное двойное списание ### ✅ Решение #1: Использовать `.update()` вместо `.save()` **Новый код (ИСПРАВЛЕННЫЙ):** ```python @receiver(post_save, sender=Order) def create_sale_on_order_completion(sender, instance, created, **kwargs): if created: return if not instance.status or instance.status.code != 'completed': return # ✅ СНАЧАЛА проверяем дубликаты if Sale.objects.filter(order=instance).exists(): return # Продажи уже созданы, выходим БЕЗ обновления резервов # ✅ Обновляем резервы ТОЛЬКО если Sale ещё не созданы # ✅ Используем .update() вместо .save() чтобы избежать вызова сигналов reservations_to_update = Reservation.objects.filter( order_item__order=instance, status='reserved' ) if reservations_to_update.exists(): # Массовое обновление БЕЗ вызова сигналов reservations_to_update.update( status='converted_to_sale', converted_at=timezone.now() ) # Обновляем Stock вручную, т.к. update() не вызывает сигналы reservation_groups = reservations_to_update.values_list('product_id', 'warehouse_id').distinct() for product_id, warehouse_id in reservation_groups: try: stock = Stock.objects.get( product_id=product_id, warehouse_id=warehouse_id ) stock.refresh_from_batches() except Stock.DoesNotExist: pass # Создание Sale... ``` **Ключевые изменения:** 1. ✅ Проверка на дубликаты **перед** обновлением резервов 2. ✅ Использование `.update()` вместо `.save()` → не вызывает сигналы 3. ✅ Ручное обновление Stock после массового обновления резервов --- ## 🔥 Проблема #2: Двойное FIFO-списание в `SaleProcessor.create_sale()` ### Файл: `inventory/services/sale_processor.py` → метод `create_sale` **Старый код (ОШИБОЧНЫЙ):** ```python @transaction.atomic def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None): # ❌ ПРОБЛЕМА: Sale создаётся с processed=False sale = Sale.objects.create( product=product, warehouse=warehouse, quantity=quantity, sale_price=sale_price, order=order, document_number=document_number, processed=False # ← Сигнал process_sale_fifo сработает! ) try: # ❌ Списываем товар первый раз (в сервисе) allocations = StockBatchManager.write_off_by_fifo(product, warehouse, quantity) for batch, qty_allocated in allocations: SaleBatchAllocation.objects.create(...) # Устанавливаем processed=True sale.processed = True sale.save(update_fields=['processed']) return sale except ValueError as e: sale.delete() raise ``` **Сценарий двойного списания:** 1. `Sale.objects.create(processed=False)` → **срабатывает сигнал `process_sale_fifo`** 2. Сигнал `process_sale_fifo` → списывает товар **первый раз** (10 шт) 3. `StockBatchManager.write_off_by_fifo()` в сервисе → списывает товар **второй раз** (10 шт) 4. **Итого: 20 шт списано вместо 10!** ### ✅ Решение #2: Создавать Sale сразу с `processed=True` **Новый код (ИСПРАВЛЕННЫЙ):** ```python @transaction.atomic def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None): # ✅ ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал # (списание делаем вручную ниже, чтобы избежать двойного списания) sale = Sale.objects.create( product=product, warehouse=warehouse, quantity=quantity, sale_price=sale_price, order=order, document_number=document_number, processed=True # ✅ Сразу отмечаем как обработанную ) try: # ✅ Списываем товар ОДИН раз (сигнал НЕ сработает) allocations = StockBatchManager.write_off_by_fifo(product, warehouse, quantity) for batch, qty_allocated in allocations: SaleBatchAllocation.objects.create(...) # processed уже установлен в True при создании Sale return sale except ValueError as e: sale.delete() raise ``` **Ключевые изменения:** 1. ✅ Sale создаётся сразу с `processed=True` → сигнал `process_sale_fifo` не срабатывает 2. ✅ Списание товара происходит **только один раз** в сервисе 3. ✅ Удалён дублирующий код `sale.processed = True; sale.save()` --- ## 📋 Итоговые изменения ### Файл 1: `inventory/signals.py` - Строки 85-103: Переработан сигнал `create_sale_on_order_completion` - Проверка дубликатов перемещена наверх - Замена `.save()` на `.update()` для резервов - Добавлено ручное обновление Stock ### Файл 2: `inventory/services/sale_processor.py` - Строки 87-96: Sale создаётся с `processed=True` - Строки 111-113: Удалён дублирующий код установки `processed=True` --- ## ✅ Результат После исправления: - ✅ Списание товара происходит **строго один раз** - ✅ Нет повторного срабатывания сигналов при редактировании заказа - ✅ Sale не создаются дважды - ✅ Количество в Sale = количество списанное со склада --- ## 🧪 Как проверить исправление 1. Создайте заказ с товаром (10 шт) 2. Измените статус на "Выполнен" 3. Проверьте: - Sale создалась с quantity=10 - Со склада списалось ровно 10 шт (не 20!) 4. Повторно сохраните заказ (через админку или форму) 5. Проверьте: - Sale НЕ создалась повторно - Количество на складе не изменилось --- ## 📝 Lessons Learned ### Проблемы с Django Signals: 1. **Избегайте `.save()` в массовых операциях** → используйте `.update()` 2. **Проверяйте дубликаты ДО модификации данных**, а не после 3. **Флаг `processed` должен устанавливаться при создании**, если обработка делается вручную 4. **Signals могут срабатывать многократно** при редактировании через разные интерфейсы ### Best Practices: - ✅ Используйте `queryset.update()` для массовых обновлений (не вызывает сигналы) - ✅ Вручную обновляйте зависимые данные (Stock) после `.update()` - ✅ Устанавливайте флаги обработки (`processed`) при создании объекта - ✅ Проверяйте существование записей ДО их создания - ✅ Используйте транзакции (`@transaction.atomic`) для критичных операций --- Дата исправления: 2024-12-01