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

Проблема:
- При изменении статуса заказа на 'Выполнен' товар списывался дважды
- Заказ на 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:
2025-12-01 00:56:26 +03:00
parent 4e66f03957
commit e0437cdb5a
8 changed files with 1284 additions and 70 deletions

242
DOUBLE_SALE_FIX.md Normal file
View 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