🔥 КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Дублирование резервов при изменении количества
Проблема: - При изменении количества в OrderItem для заказа в статусе 'completed' - Создавался ДУБЛИКАТ резерва (старый converted_to_sale + новый reserved) - Это приводило к двойному списанию товара со склада (-10 лишних единиц) - Фильтр status='reserved' пропускал существующие резервы в других статусах Сценарий бага: 1. Заказ выполнен: 20 шт → резерв 20 шт (converted_to_sale) 2. Увеличить на 10 шт (до 30) → создаётся НОВЫЙ резерв 30 шт (reserved) 3. Итого: 20 + 30 = 50 шт зарезервировано вместо 30! 4. При переводе обратно в 'completed' → двойное списание (50 вместо 30) Решение: - Убран фильтр status='reserved' из update_reservation_on_item_change - Теперь резерв ищется по order_item независимо от статуса - Обновляется ТОЛЬКО quantity, статус НЕ меняется - Добавлен @transaction.atomic для атомарности операции - Добавлено логирование всех операций с резервами - Используется save(update_fields=['quantity']) для оптимизации Безопасность решения: - Резервы разных заказов НЕ конфликтуют (разные order_item) - Один товар в разных заказах = разные OrderItem = разные Reservation - Каждый OrderItem имеет уникальный резерв - Дубликаты больше НЕ создаются Изменённые файлы: - inventory/signals.py (функция update_reservation_on_item_change) - FIX_RESERVATION_DUPLICATE_BUG.md (полная документация бага и решения) Покрытие всех сценариев: ✅ Создание заказа с товарами ✅ Добавление товара при редактировании ✅ Изменение количества (черновик) ✅ Изменение количества (выполнен) - ИСПРАВЛЕНО ✅ Повторное сохранение заказа КРИТИЧНО: Это исправление влияет на учёт товара и требует тестирования!
This commit is contained in:
228
FIX_RESERVATION_DUPLICATE_BUG.md
Normal file
228
FIX_RESERVATION_DUPLICATE_BUG.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# Исправление критического бага дублирования резервов
|
||||||
|
|
||||||
|
## 🔥 СЕРЬЁЗНЕЙШИЙ БАГ
|
||||||
|
|
||||||
|
### Описание проблемы
|
||||||
|
|
||||||
|
При изменении количества товара в заказе со статусом "Выполнен" (completed) создавался ДУБЛИКАТ резерва, что приводило к двойному списанию товара со склада.
|
||||||
|
|
||||||
|
### Сценарий воспроизведения
|
||||||
|
|
||||||
|
1. **Начальное состояние:**
|
||||||
|
- Заказ в статусе "Выполнен" (completed)
|
||||||
|
- OrderItem: quantity = 20 шт
|
||||||
|
- Reservation: quantity = 20 шт, status = `converted_to_sale`
|
||||||
|
- Sale: 20 шт создан
|
||||||
|
- Товар списан со склада
|
||||||
|
|
||||||
|
2. **Пользователь увеличивает количество на 10 единиц (до 30 шт):**
|
||||||
|
- OrderItem.quantity = 30 шт (было 20)
|
||||||
|
- Срабатывает сигнал `update_reservation_on_item_change`
|
||||||
|
|
||||||
|
3. **ЧТО ПРОИСХОДИЛО (БАГ):**
|
||||||
|
```python
|
||||||
|
# Старый код (ОШИБОЧНЫЙ):
|
||||||
|
reservation = Reservation.objects.filter(
|
||||||
|
order_item=instance,
|
||||||
|
status='reserved' # ← ПРОБЛЕМА!
|
||||||
|
).first()
|
||||||
|
```
|
||||||
|
- Сигнал искал резерв в статусе `'reserved'`
|
||||||
|
- НО резерв был в статусе `'converted_to_sale'` (уже продан!)
|
||||||
|
- Резерв НЕ находился → `reservation = None`
|
||||||
|
- **Создавался НОВЫЙ резерв на 30 шт!**
|
||||||
|
|
||||||
|
4. **Результат (ДУБЛИКАТ):**
|
||||||
|
- **Старый резерв:** 20 шт, status = `converted_to_sale` (остался!)
|
||||||
|
- **Новый резерв:** 30 шт, status = `reserved` (создан заново!)
|
||||||
|
- **ИТОГО: 50 шт зарезервировано вместо 30!**
|
||||||
|
|
||||||
|
5. **При переводе обратно в "Выполнен":**
|
||||||
|
- Создаётся Sale на 30 шт (дополнительно к существующим 20 шт)
|
||||||
|
- Оба резерва переводятся в `converted_to_sale`
|
||||||
|
- **На складе списывается 30 + 20 = 50 шт, хотя должно быть 30!**
|
||||||
|
- **Товара становится на 10 шт меньше, чем должно быть!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ РЕШЕНИЕ
|
||||||
|
|
||||||
|
### Изменения в коде
|
||||||
|
|
||||||
|
**Файл:** `inventory/signals.py`
|
||||||
|
**Функция:** `update_reservation_on_item_change` (строка 436)
|
||||||
|
|
||||||
|
#### Было (ОШИБОЧНО):
|
||||||
|
```python
|
||||||
|
@receiver(post_save, sender=OrderItem)
|
||||||
|
def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
||||||
|
# Получаем резерв для этой позиции в статусе 'reserved'
|
||||||
|
reservation = Reservation.objects.filter(
|
||||||
|
order_item=instance,
|
||||||
|
status='reserved' # ← ПРОБЛЕМА: пропускает резервы в других статусах!
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if reservation:
|
||||||
|
# Обновляем количество
|
||||||
|
reservation.quantity = Decimal(str(instance.quantity))
|
||||||
|
reservation.save()
|
||||||
|
else:
|
||||||
|
# Создаем новый резерв (даже если старый уже существует!)
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Стало (ИСПРАВЛЕНО):
|
||||||
|
```python
|
||||||
|
@receiver(post_save, sender=OrderItem)
|
||||||
|
@transaction.atomic # ← Добавлена транзакция для безопасности
|
||||||
|
def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
||||||
|
"""
|
||||||
|
КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов!
|
||||||
|
Резерв ищется по order_item независимо от статуса.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Ищем резерв для этой позиции в ЛЮБОМ статусе
|
||||||
|
reservation = Reservation.objects.filter(
|
||||||
|
order_item=instance # ← Без фильтра по статусу!
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if reservation:
|
||||||
|
# Резерв существует - обновляем ТОЛЬКО количество
|
||||||
|
# НЕ меняем статус! (может быть 'converted_to_sale', 'reserved', 'released')
|
||||||
|
old_quantity = reservation.quantity
|
||||||
|
reservation.quantity = Decimal(str(instance.quantity))
|
||||||
|
reservation.save(update_fields=['quantity']) # ← Обновляем только quantity
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✓ Резерв #{reservation.id} обновлён: quantity {old_quantity} → {reservation.quantity} "
|
||||||
|
f"(статус: {reservation.status}, OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Создаём новый резерв ТОЛЬКО если НЕТ вообще
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые изменения
|
||||||
|
|
||||||
|
1. ✅ **Убран фильтр `status='reserved'`** - теперь ищем резерв в ЛЮБОМ статусе
|
||||||
|
2. ✅ **Добавлен `@transaction.atomic`** - обеспечивает атомарность операции
|
||||||
|
3. ✅ **Используем `save(update_fields=['quantity'])`** - обновляем только количество, статус НЕ меняется
|
||||||
|
4. ✅ **Добавлено логирование** - видим что происходит с резервами
|
||||||
|
5. ✅ **Обновлена документация** - объяснена критичность изменения
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 БЕЗОПАСНОСТЬ РЕШЕНИЯ
|
||||||
|
|
||||||
|
### Вопрос: "А если товар есть в разных заказах?"
|
||||||
|
|
||||||
|
**Ответ: НЕТ, проблемы не будет!**
|
||||||
|
|
||||||
|
### Почему безопасно:
|
||||||
|
|
||||||
|
**Один товар в разных заказах = разные OrderItem:**
|
||||||
|
|
||||||
|
1. **Заказ #1:**
|
||||||
|
- OrderItem #100: товар "Роза", количество 20 шт
|
||||||
|
- Reservation #1: `order_item=100`, product="Роза", quantity=20
|
||||||
|
|
||||||
|
2. **Заказ #2:**
|
||||||
|
- OrderItem #101: товар "Роза", количество 30 шт
|
||||||
|
- Reservation #2: `order_item=101`, product="Роза", quantity=30
|
||||||
|
|
||||||
|
### Ключ поиска:
|
||||||
|
|
||||||
|
```python
|
||||||
|
reservation = Reservation.objects.filter(
|
||||||
|
order_item=instance # ← Фильтруем по КОНКРЕТНОМУ OrderItem!
|
||||||
|
).first()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Фильтруем по `order_item=instance`, а НЕ по `product`!**
|
||||||
|
|
||||||
|
Это гарантирует:
|
||||||
|
- ✅ Для OrderItem #100 найдётся только Reservation #1
|
||||||
|
- ✅ Для OrderItem #101 найдётся только Reservation #2
|
||||||
|
- ✅ **Резервы НИКОГДА не пересекутся**, даже если товар одинаковый!
|
||||||
|
- ✅ Каждый OrderItem имеет СВОЙ уникальный резерв
|
||||||
|
- ✅ Резервы разных заказов НЕ конфликтуют (разные order_item)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 ПОКРЫТИЕ ВСЕХ СЦЕНАРИЕВ
|
||||||
|
|
||||||
|
| Сценарий | OrderItem | Резерв существует? | Статус резерва | Действие |
|
||||||
|
|----------|-----------|-------------------|----------------|----------|
|
||||||
|
| **Создание заказа** | Новый | Нет | - | Создать резерв (status='reserved') ✅ |
|
||||||
|
| **Добавление товара** | Новый | Нет | - | Создать резерв (status='reserved') ✅ |
|
||||||
|
| **Изменение qty (черновик)** | Старый | Да | reserved | Обновить quantity ✅ |
|
||||||
|
| **Изменение qty (выполнен)** | Старый | Да | converted_to_sale | Обновить quantity (БЕЗ смены статуса!) ✅ |
|
||||||
|
| **Повторное сохранение** | Старый | Да | Любой | Обновить quantity ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 ТЕСТИРОВАНИЕ
|
||||||
|
|
||||||
|
### Как проверить исправление:
|
||||||
|
|
||||||
|
1. **Создать заказ:**
|
||||||
|
- Добавить товар (20 шт)
|
||||||
|
- Перевести в статус "Выполнен"
|
||||||
|
- Проверить: создался Sale на 20 шт, резерв в статусе `converted_to_sale`
|
||||||
|
|
||||||
|
2. **Изменить количество:**
|
||||||
|
- Увеличить до 30 шт
|
||||||
|
- Проверить резервы в debug странице `/inventory/debug/`
|
||||||
|
- **Должен быть ОДИН резерв на 30 шт**, НЕ два!
|
||||||
|
|
||||||
|
3. **Проверить склад:**
|
||||||
|
- Товара должно остаться на складе правильное количество
|
||||||
|
- НЕ должно быть двойного списания
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 КОММИТ
|
||||||
|
|
||||||
|
```
|
||||||
|
Исправлен критический баг дублирования резервов при изменении количества
|
||||||
|
|
||||||
|
Проблема:
|
||||||
|
- При изменении количества в OrderItem для заказа в статусе 'completed'
|
||||||
|
- Создавался ДУБЛИКАТ резерва (старый + новый)
|
||||||
|
- Это приводило к двойному списанию товара со склада
|
||||||
|
- Фильтр status='reserved' пропускал резервы в статусе 'converted_to_sale'
|
||||||
|
|
||||||
|
Решение:
|
||||||
|
- Убран фильтр status='reserved' из поиска резерва
|
||||||
|
- Теперь резерв ищется по order_item независимо от статуса
|
||||||
|
- Обновляется только quantity, статус НЕ меняется
|
||||||
|
- Добавлен @transaction.atomic для атомарности
|
||||||
|
- Добавлено логирование операций с резервами
|
||||||
|
- Используется save(update_fields=['quantity']) для оптимизации
|
||||||
|
|
||||||
|
Безопасность:
|
||||||
|
- Резервы разных заказов НЕ конфликтуют (разные order_item)
|
||||||
|
- Один товар в разных заказах = разные OrderItem = разные Reservation
|
||||||
|
- Дубликаты больше НЕ создаются
|
||||||
|
|
||||||
|
Покрытие:
|
||||||
|
- Создание заказа ✅
|
||||||
|
- Добавление товара ✅
|
||||||
|
- Изменение количества (черновик) ✅
|
||||||
|
- Изменение количества (выполнен) ✅ [ИСПРАВЛЕНО]
|
||||||
|
- Повторное сохранение ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ ВАЖНО
|
||||||
|
|
||||||
|
Это **критическое исправление**, которое влияет на:
|
||||||
|
- ✅ Учёт товара на складе
|
||||||
|
- ✅ Резервирование товаров
|
||||||
|
- ✅ FIFO-списание
|
||||||
|
- ✅ Продажи
|
||||||
|
|
||||||
|
**Необходимо протестировать** перед продакшеном!
|
||||||
@@ -434,30 +434,45 @@ def release_stock_on_order_delete(sender, instance, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=OrderItem)
|
@receiver(post_save, sender=OrderItem)
|
||||||
|
@transaction.atomic
|
||||||
def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
||||||
"""
|
"""
|
||||||
Сигнал: При создании или изменении позиции заказа управляем резервами.
|
Сигнал: При создании или изменении позиции заказа управляем резервами.
|
||||||
|
|
||||||
Процесс:
|
Процесс:
|
||||||
1. Ищем существующий резерв для этой позиции
|
1. Ищем существующий резерв для этой позиции (в ЛЮБОМ статусе)
|
||||||
2. Если резерв ЕСТЬ - обновляем количество
|
2. Если резерв ЕСТЬ - обновляем ТОЛЬКО количество (статус НЕ меняем!)
|
||||||
3. Если резерва НЕТ - создаем новый
|
3. Если резерва НЕТ - создаем новый
|
||||||
|
|
||||||
Покрывает все сценарии:
|
Покрывает все сценарии:
|
||||||
- Создание заказа с товарами → создаёт резервы
|
- Создание заказа с товарами → создаёт резервы
|
||||||
- Редактирование + добавление товаров → создаёт резервы для новых
|
- Редактирование + добавление товаров → создаёт резервы для новых
|
||||||
- Изменение количества → обновляет резервы
|
- Изменение количества → обновляет резервы (даже если уже converted_to_sale)
|
||||||
|
|
||||||
|
КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов!
|
||||||
|
Резерв ищется по order_item независимо от статуса.
|
||||||
|
Это предотвращает создание нового резерва для заказа в статусе 'completed'.
|
||||||
"""
|
"""
|
||||||
# Получаем резерв для этой позиции в статусе 'reserved'
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Ищем резерв для этой позиции в ЛЮБОМ статусе (не только 'reserved')
|
||||||
|
# КРИТИЧНО: Убран фильтр status='reserved' для предотвращения дубликатов
|
||||||
reservation = Reservation.objects.filter(
|
reservation = Reservation.objects.filter(
|
||||||
order_item=instance,
|
order_item=instance
|
||||||
status='reserved'
|
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if reservation:
|
if reservation:
|
||||||
# Резерв существует - обновляем его количество
|
# Резерв существует - обновляем ТОЛЬКО количество
|
||||||
|
# НЕ меняем статус! (может быть 'converted_to_sale', 'reserved', 'released')
|
||||||
|
old_quantity = reservation.quantity
|
||||||
reservation.quantity = Decimal(str(instance.quantity))
|
reservation.quantity = Decimal(str(instance.quantity))
|
||||||
reservation.save()
|
reservation.save(update_fields=['quantity'])
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✓ Резерв #{reservation.id} обновлён: quantity {old_quantity} → {reservation.quantity} "
|
||||||
|
f"(статус: {reservation.status}, OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Резерва нет - создаем новый
|
# Резерва нет - создаем новый
|
||||||
# Это происходит при создании нового OrderItem (через форму или при редактировании)
|
# Это происходит при создании нового OrderItem (через форму или при редактировании)
|
||||||
@@ -467,7 +482,7 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
|||||||
product = instance.product if instance.product else instance.product_kit
|
product = instance.product if instance.product else instance.product_kit
|
||||||
|
|
||||||
if product:
|
if product:
|
||||||
Reservation.objects.create(
|
reservation = Reservation.objects.create(
|
||||||
order_item=instance,
|
order_item=instance,
|
||||||
product=product,
|
product=product,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
@@ -475,6 +490,11 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
|||||||
status='reserved'
|
status='reserved'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✓ Создан новый резерв #{reservation.id}: {product.name}, quantity={reservation.quantity} "
|
||||||
|
f"(OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Incoming)
|
@receiver(post_save, sender=Incoming)
|
||||||
def create_stock_batch_on_incoming(sender, instance, created, **kwargs):
|
def create_stock_batch_on_incoming(sender, instance, created, **kwargs):
|
||||||
|
|||||||
Reference in New Issue
Block a user