Исправлено двойное списание товаров при смене статуса заказа
Проблема: - При изменении статуса заказа на 'Выполнен' товар списывался дважды - Заказ на 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 - документация по исправлению
This commit is contained in:
242
DOUBLE_SALE_FIX.md
Normal file
242
DOUBLE_SALE_FIX.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Исправление двойного списания товаров при смене статуса заказа
|
||||
|
||||
## 🐛 Проблема
|
||||
|
||||
При смене статуса заказа на "Выполнен" (`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
|
||||
Reference in New Issue
Block a user