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

243 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Исправление двойного списания товаров при смене статуса заказа
## 🐛 Проблема
При смене статуса заказа на "Выполнен" (`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