Files
octopus/DOUBLE_SALE_FIX.md
Andrey Smakotin e0437cdb5a Исправлено двойное списание товаров при смене статуса заказа
Проблема:
- При изменении статуса заказа на 'Выполнен' товар списывался дважды
- Заказ на 10 шт создавал Sale на 10 шт, но со склада уходило 20 шт

Найдено ДВЕ причины:

1. Повторное обновление резервов через .save() (inventory/signals.py)
   - Резервы обновлялись через res.save() каждый раз при сохранении заказа
   - Это вызывало сигнал update_stock_on_reservation_change
   - При повторном сохранении заказа происходило двойное срабатывание

   Решение:
   - Проверка дубликатов ПЕРЕД обновлением резервов
   - Замена .save() на .update() для массового обновления без вызова сигналов
   - Ручное обновление Stock после .update()

2. Двойное FIFO-списание (inventory/services/sale_processor.py)
   - Sale создавалась с processed=False
   - Сигнал process_sale_fifo срабатывал и списывал товар (1-й раз)
   - Затем SaleProcessor.create_sale() тоже списывал товар (2-й раз)

   Решение:
   - Sale создаётся сразу с processed=True
   - Сигнал не срабатывает, списание только в сервисе

Дополнительно:
- Ограничен выбор статусов при создании заказа только промежуточными
- Статус 'Черновик' установлен по умолчанию
- Убран пустой выбор '-------' из поля статуса

Изменённые файлы:
- myproject/orders/forms.py - настройки статусов для формы заказа
- myproject/inventory/signals.py - исправление сигнала create_sale_on_order_completion
- myproject/inventory/services/sale_processor.py - исправление create_sale
- myproject/test_order_status_default.py - обновлён тест
- DOUBLE_SALE_FIX.md - документация по исправлению
2025-12-01 00:56:26 +03:00

11 KiB
Raw Blame History

Исправление двойного списания товаров при смене статуса заказа

🐛 Проблема

При смене статуса заказа на "Выполнен" (completed) происходило двойное списание товара со склада:

  • В заказе было 10 штук товара
  • Sale (продажа) регистрировалась на 10 штук
  • Но со склада списывалось 20 штук

Причины двойного списания

Было обнаружено ДВА независимых источника проблемы:


🔥 Проблема #1: Повторное обновление резервов через .save()

Файл: inventory/signals.py → сигнал create_sale_on_order_completion

Старый код (ОШИБОЧНЫЙ):

@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()

Новый код (ИСПРАВЛЕННЫЙ):

@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

Старый код (ОШИБОЧНЫЙ):

@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

Новый код (ИСПРАВЛЕННЫЙ):

@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