From 4597ddbd8712b16ec9f119140b911f1703b3e352 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Mon, 1 Dec 2025 10:22:28 +0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20=D0=9A=D0=A0=D0=98=D0=A2=D0=98?= =?UTF-8?q?=D0=A7=D0=95=D0=A1=D0=9A=D0=9E=D0=95=20=D0=98=D0=A1=D0=9F=D0=A0?= =?UTF-8?q?=D0=90=D0=92=D0=9B=D0=95=D0=9D=D0=98=D0=95:=20=D0=94=D1=83?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: - При изменении количества в 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 (полная документация бага и решения) Покрытие всех сценариев: ✅ Создание заказа с товарами ✅ Добавление товара при редактировании ✅ Изменение количества (черновик) ✅ Изменение количества (выполнен) - ИСПРАВЛЕНО ✅ Повторное сохранение заказа КРИТИЧНО: Это исправление влияет на учёт товара и требует тестирования! --- FIX_RESERVATION_DUPLICATE_BUG.md | 228 +++++++++++++++++++++++++++++++ myproject/inventory/signals.py | 38 ++++-- 2 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 FIX_RESERVATION_DUPLICATE_BUG.md diff --git a/FIX_RESERVATION_DUPLICATE_BUG.md b/FIX_RESERVATION_DUPLICATE_BUG.md new file mode 100644 index 0000000..cb38392 --- /dev/null +++ b/FIX_RESERVATION_DUPLICATE_BUG.md @@ -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-списание +- ✅ Продажи + +**Необходимо протестировать** перед продакшеном! diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index e1614d3..5c2c68f 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -434,30 +434,45 @@ def release_stock_on_order_delete(sender, instance, **kwargs): @receiver(post_save, sender=OrderItem) +@transaction.atomic def update_reservation_on_item_change(sender, instance, created, **kwargs): """ Сигнал: При создании или изменении позиции заказа управляем резервами. Процесс: - 1. Ищем существующий резерв для этой позиции - 2. Если резерв ЕСТЬ - обновляем количество + 1. Ищем существующий резерв для этой позиции (в ЛЮБОМ статусе) + 2. Если резерв ЕСТЬ - обновляем ТОЛЬКО количество (статус НЕ меняем!) 3. Если резерва НЕТ - создаем новый Покрывает все сценарии: - Создание заказа с товарами → создаёт резервы - Редактирование + добавление товаров → создаёт резервы для новых - - Изменение количества → обновляет резервы + - Изменение количества → обновляет резервы (даже если уже converted_to_sale) + + КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов! + Резерв ищется по order_item независимо от статуса. + Это предотвращает создание нового резерва для заказа в статусе 'completed'. """ - # Получаем резерв для этой позиции в статусе 'reserved' + import logging + logger = logging.getLogger(__name__) + + # Ищем резерв для этой позиции в ЛЮБОМ статусе (не только 'reserved') + # КРИТИЧНО: Убран фильтр status='reserved' для предотвращения дубликатов reservation = Reservation.objects.filter( - order_item=instance, - status='reserved' + order_item=instance ).first() if reservation: - # Резерв существует - обновляем его количество + # Резерв существует - обновляем ТОЛЬКО количество + # НЕ меняем статус! (может быть 'converted_to_sale', 'reserved', 'released') + old_quantity = reservation.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: # Резерва нет - создаем новый # Это происходит при создании нового OrderItem (через форму или при редактировании) @@ -467,13 +482,18 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs): product = instance.product if instance.product else instance.product_kit if product: - Reservation.objects.create( + reservation = Reservation.objects.create( order_item=instance, product=product, warehouse=warehouse, quantity=Decimal(str(instance.quantity)), 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)