Проблема: - При изменении количества в 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 (полная документация бага и решения) Покрытие всех сценариев: ✅ Создание заказа с товарами ✅ Добавление товара при редактировании ✅ Изменение количества (черновик) ✅ Изменение количества (выполнен) - ИСПРАВЛЕНО ✅ Повторное сохранение заказа КРИТИЧНО: Это исправление влияет на учёт товара и требует тестирования!
10 KiB
10 KiB
Исправление критического бага дублирования резервов
🔥 СЕРЬЁЗНЕЙШИЙ БАГ
Описание проблемы
При изменении количества товара в заказе со статусом "Выполнен" (completed) создавался ДУБЛИКАТ резерва, что приводило к двойному списанию товара со склада.
Сценарий воспроизведения
-
Начальное состояние:
- Заказ в статусе "Выполнен" (completed)
- OrderItem: quantity = 20 шт
- Reservation: quantity = 20 шт, status =
converted_to_sale - Sale: 20 шт создан
- Товар списан со склада
-
Пользователь увеличивает количество на 10 единиц (до 30 шт):
- OrderItem.quantity = 30 шт (было 20)
- Срабатывает сигнал
update_reservation_on_item_change
-
ЧТО ПРОИСХОДИЛО (БАГ):
# Старый код (ОШИБОЧНЫЙ): reservation = Reservation.objects.filter( order_item=instance, status='reserved' # ← ПРОБЛЕМА! ).first()- Сигнал искал резерв в статусе
'reserved' - НО резерв был в статусе
'converted_to_sale'(уже продан!) - Резерв НЕ находился →
reservation = None - Создавался НОВЫЙ резерв на 30 шт!
- Сигнал искал резерв в статусе
-
Результат (ДУБЛИКАТ):
- Старый резерв: 20 шт, status =
converted_to_sale(остался!) - Новый резерв: 30 шт, status =
reserved(создан заново!) - ИТОГО: 50 шт зарезервировано вместо 30!
- Старый резерв: 20 шт, status =
-
При переводе обратно в "Выполнен":
- Создаётся Sale на 30 шт (дополнительно к существующим 20 шт)
- Оба резерва переводятся в
converted_to_sale - На складе списывается 30 + 20 = 50 шт, хотя должно быть 30!
- Товара становится на 10 шт меньше, чем должно быть!
✅ РЕШЕНИЕ
Изменения в коде
Файл: inventory/signals.py
Функция: update_reservation_on_item_change (строка 436)
Было (ОШИБОЧНО):
@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:
# Создаем новый резерв (даже если старый уже существует!)
# ...
Стало (ИСПРАВЛЕНО):
@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:
# Создаём новый резерв ТОЛЬКО если НЕТ вообще
# ...
Ключевые изменения
- ✅ Убран фильтр
status='reserved'- теперь ищем резерв в ЛЮБОМ статусе - ✅ Добавлен
@transaction.atomic- обеспечивает атомарность операции - ✅ Используем
save(update_fields=['quantity'])- обновляем только количество, статус НЕ меняется - ✅ Добавлено логирование - видим что происходит с резервами
- ✅ Обновлена документация - объяснена критичность изменения
🎯 БЕЗОПАСНОСТЬ РЕШЕНИЯ
Вопрос: "А если товар есть в разных заказах?"
Ответ: НЕТ, проблемы не будет!
Почему безопасно:
Один товар в разных заказах = разные OrderItem:
-
Заказ #1:
- OrderItem #100: товар "Роза", количество 20 шт
- Reservation #1:
order_item=100, product="Роза", quantity=20
-
Заказ #2:
- OrderItem #101: товар "Роза", количество 30 шт
- Reservation #2:
order_item=101, product="Роза", quantity=30
Ключ поиска:
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 ✅ |
🔧 ТЕСТИРОВАНИЕ
Как проверить исправление:
-
Создать заказ:
- Добавить товар (20 шт)
- Перевести в статус "Выполнен"
- Проверить: создался Sale на 20 шт, резерв в статусе
converted_to_sale
-
Изменить количество:
- Увеличить до 30 шт
- Проверить резервы в debug странице
/inventory/debug/ - Должен быть ОДИН резерв на 30 шт, НЕ два!
-
Проверить склад:
- Товара должно остаться на складе правильное количество
- НЕ должно быть двойного списания
📝 КОММИТ
Исправлен критический баг дублирования резервов при изменении количества
Проблема:
- При изменении количества в OrderItem для заказа в статусе 'completed'
- Создавался ДУБЛИКАТ резерва (старый + новый)
- Это приводило к двойному списанию товара со склада
- Фильтр status='reserved' пропускал резервы в статусе 'converted_to_sale'
Решение:
- Убран фильтр status='reserved' из поиска резерва
- Теперь резерв ищется по order_item независимо от статуса
- Обновляется только quantity, статус НЕ меняется
- Добавлен @transaction.atomic для атомарности
- Добавлено логирование операций с резервами
- Используется save(update_fields=['quantity']) для оптимизации
Безопасность:
- Резервы разных заказов НЕ конфликтуют (разные order_item)
- Один товар в разных заказах = разные OrderItem = разные Reservation
- Дубликаты больше НЕ создаются
Покрытие:
- Создание заказа ✅
- Добавление товара ✅
- Изменение количества (черновик) ✅
- Изменение количества (выполнен) ✅ [ИСПРАВЛЕНО]
- Повторное сохранение ✅
⚠️ ВАЖНО
Это критическое исправление, которое влияет на:
- ✅ Учёт товара на складе
- ✅ Резервирование товаров
- ✅ FIFO-списание
- ✅ Продажи
Необходимо протестировать перед продакшеном!