Проблема: - При изменении статуса заказа на 'Выполнен' товар списывался дважды - Заказ на 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 - документация по исправлению
11 KiB
11 KiB
Исправление двойного списания товаров при смене статуса заказа
🐛 Проблема
При смене статуса заказа на "Выполнен" (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...
Сценарий двойного срабатывания:
- Первое сохранение заказа со статусом
completed→ резервы обновляются → Sale создаётся - Повторное сохранение того же заказа (например, через админку) → резервы снова обновляются через
.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...
Ключевые изменения:
- ✅ Проверка на дубликаты перед обновлением резервов
- ✅ Использование
.update()вместо.save()→ не вызывает сигналы - ✅ Ручное обновление 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
Сценарий двойного списания:
Sale.objects.create(processed=False)→ срабатывает сигналprocess_sale_fifo- Сигнал
process_sale_fifo→ списывает товар первый раз (10 шт) StockBatchManager.write_off_by_fifo()в сервисе → списывает товар второй раз (10 шт)- Итого: 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
Ключевые изменения:
- ✅ Sale создаётся сразу с
processed=True→ сигналprocess_sale_fifoне срабатывает - ✅ Списание товара происходит только один раз в сервисе
- ✅ Удалён дублирующий код
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 = количество списанное со склада
🧪 Как проверить исправление
- Создайте заказ с товаром (10 шт)
- Измените статус на "Выполнен"
- Проверьте:
- Sale создалась с quantity=10
- Со склада списалось ровно 10 шт (не 20!)
- Повторно сохраните заказ (через админку или форму)
- Проверьте:
- Sale НЕ создалась повторно
- Количество на складе не изменилось
📝 Lessons Learned
Проблемы с Django Signals:
- Избегайте
.save()в массовых операциях → используйте.update() - Проверяйте дубликаты ДО модификации данных, а не после
- Флаг
processedдолжен устанавливаться при создании, если обработка делается вручную - Signals могут срабатывать многократно при редактировании через разные интерфейсы
Best Practices:
- ✅ Используйте
queryset.update()для массовых обновлений (не вызывает сигналы) - ✅ Вручную обновляйте зависимые данные (Stock) после
.update() - ✅ Устанавливайте флаги обработки (
processed) при создании объекта - ✅ Проверяйте существование записей ДО их создания
- ✅ Используйте транзакции (
@transaction.atomic) для критичных операций
Дата исправления: 2024-12-01