From 293e8640ef6c65fc15254f60427e1498dcbd9e5a Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Mon, 1 Dec 2025 01:10:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема #1: Резервы не создавались при создании заказа - Order сохранялся БЕЗ items → сигнал reserve_stock_on_order_create не мог создать резервы (items.all() был пустой) - OrderItem создавались ПОСЛЕ, но сигнал уже отработал Решение #1: Создание резервов через сигнал OrderItem - Доработан сигнал update_reservation_on_item_change - Убрано раннее возвращение для created=True - Теперь резервы создаются при добавлении OrderItem (любым способом) - Работает для всех сценариев: * Создание заказа с товарами * Добавление товаров при редактировании * Изменение количества Проблема #2: Риск расхождений при удалении заказа - Сигнал pre_delete освобождал резервы ДО удаления - Если удаление падало с ошибкой → резервы освобождены, Order не удалён - Возникало расхождение данных Решение #2: transaction.on_commit для освобождения резервов - Добавлен @transaction.atomic к сигналу release_stock_on_order_delete - Резервы получаются ДО удаления через list() - Освобождение происходит через transaction.on_commit() - Резервы освобождаются ТОЛЬКО если удаление успешно - Гарантия целостности данных Изменённые файлы: - myproject/inventory/signals.py - оба сигнала исправлены - RESERVATION_FIX.md - полная документация --- RESERVATION_FIX.md | 229 +++++++++++++++++++++++++++++++++ myproject/inventory/signals.py | 51 +++++--- 2 files changed, 260 insertions(+), 20 deletions(-) create mode 100644 RESERVATION_FIX.md diff --git a/RESERVATION_FIX.md b/RESERVATION_FIX.md new file mode 100644 index 0000000..2197549 --- /dev/null +++ b/RESERVATION_FIX.md @@ -0,0 +1,229 @@ +# Исправление создания резервов для заказов + +## 🐛 Проблема + +При создании нового заказа со статусом "Черновик" (или любым другим) резервы товаров **не создавались**. + +### Причина + +**Неправильный порядок сохранения в `orders/views.py`:** + +```python +def order_create(request): + # ... + order.save() # ← 1. Сигнал reserve_stock_on_order_create срабатывает + # НО instance.items.all() пустой! + + formset.save() # ← 2. OrderItem создаются ПОСЛЕ + # Сигнал больше не сработает +``` + +**Что происходило:** +1. `order.save()` → сигнал `reserve_stock_on_order_create` срабатывал +2. Сигнал проверял `instance.items.all()` → **ПУСТО** (OrderItem ещё не созданы) +3. Цикл `for item in instance.items.all()` не выполнялся +4. Резервы НЕ создавались +5. `formset.save()` → OrderItem создавались, но сигнал уже отработал + +--- + +## ✅ Решение #1: Создание резервов через сигнал OrderItem + +### Изменённый файл: `inventory/signals.py` + +**Сигнал:** `update_reservation_on_item_change` (строки 415-452) + +### Было (ОШИБОЧНО): +```python +@receiver(post_save, sender=OrderItem) +def update_reservation_on_item_change(sender, instance, created, **kwargs): + if created: + return # ❌ Новые позиции игнорировались! + + # Код обновления резервов... +``` + +### Стало (ИСПРАВЛЕНО): +```python +@receiver(post_save, sender=OrderItem) +def update_reservation_on_item_change(sender, instance, created, **kwargs): + """ + При создании или изменении позиции заказа управляем резервами. + + Покрывает все сценарии: + - Создание заказа с товарами → создаёт резервы + - Редактирование + добавление товаров → создаёт резервы для новых + - Изменение количества → обновляет резервы + """ + # ✅ Убрали раннее возвращение для created=True + + reservation = Reservation.objects.filter( + order_item=instance, + status='reserved' + ).first() + + if reservation: + # Резерв существует - обновляем количество + reservation.quantity = Decimal(str(instance.quantity)) + reservation.save() + else: + # Резерва нет - создаём новый + warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first() + + if warehouse: + product = instance.product if instance.product else instance.product_kit + + if product: + Reservation.objects.create( + order_item=instance, + product=product, + warehouse=warehouse, + quantity=Decimal(str(instance.quantity)), + status='reserved' + ) +``` + +### Ключевые изменения: +1. ✅ Убрали `if created: return` - сигнал работает для ВСЕХ сохранений OrderItem +2. ✅ Резервы создаются при добавлении OrderItem (независимо от способа создания) +3. ✅ При редактировании только обновляются (если резерв уже есть) +4. ✅ Работает для любого статуса заказа (Черновик, Новый, и т.д.) + +--- + +## ✅ Решение #2: Безопасное освобождение резервов при удалении + +### Изменённый файл: `inventory/signals.py` + +**Сигнал:** `release_stock_on_order_delete` (строки 392-413) + +### Проблема с `pre_delete`: + +**Сценарий риска:** +1. Сигнал `pre_delete` срабатывает → резервы освобождаются +2. Удаление Order **ПАДАЕТ** с ошибкой (например, из-за ограничений БД) +3. **Результат:** Order не удалён, но резервы освобождены! ❌ Расхождение! + +### Было (РИСКОВАННО): +```python +@receiver(pre_delete, sender=Order) +def release_stock_on_order_delete(sender, instance, **kwargs): + reservations = Reservation.objects.filter( + order_item__order=instance, + status='reserved' + ) + + # ❌ Освобождаем резервы ДО удаления + # Если удаление упадёт - резервы уже изменены! + for res in reservations: + res.status = 'released' + res.released_at = timezone.now() + res.save() +``` + +### Стало (БЕЗОПАСНО): +```python +@receiver(pre_delete, sender=Order) +@transaction.atomic +def release_stock_on_order_delete(sender, instance, **kwargs): + """ + При удалении заказа освобождаем резервы ТОЛЬКО после успешного коммита. + """ + # ✅ Получаем резервы ДО удаления (пока Order существует) + # Используем list() чтобы выполнить запрос сейчас + reservations_to_release = list( + Reservation.objects.filter( + order_item__order=instance, + status='reserved' + ) + ) + + # ✅ Освобождаем резервы ПОСЛЕ успешного коммита + def release_reservations(): + for res in reservations_to_release: + res.status = 'released' + res.released_at = timezone.now() + res.save() + + transaction.on_commit(release_reservations) +``` + +### Ключевые изменения: +1. ✅ Добавлен `@transaction.atomic` - гарантирует транзакцию +2. ✅ Используем `list()` для получения резервов ДО удаления +3. ✅ Используем `transaction.on_commit()` - освобождаем резервы ТОЛЬКО если удаление успешно +4. ✅ Гарантия целостности данных - нет риска расхождений + +--- + +## 📊 Покрытие всех сценариев + +| Сценарий | OrderItem | created | Резерв существует? | Действие | +|----------|-----------|---------|-------------------|----------| +| **Создание заказа с товарами** | Новый | True | Нет | Создать резерв ✅ | +| **Создание заказа без товаров** | - | - | - | Ничего ✅ | +| **Редактирование: добавить товар** | Новый | True | Нет | Создать резерв ✅ | +| **Редактирование: изменить количество** | Старый | False | Да | Обновить резерв ✅ | +| **Повторное сохранение заказа** | Старый | False | Да | Обновить резерв ✅ | +| **Удаление заказа (успешно)** | - | - | - | Освободить резервы ✅ | +| **Удаление заказа (с ошибкой)** | - | - | - | Резервы НЕ освобождаются ✅ | + +--- + +## 🎯 Преимущества решения + +### Решение #1 (OrderItem сигнал): +- ✅ **Универсальность** - работает для любого способа создания OrderItem +- ✅ **Простота** - минимальное изменение существующего сигнала +- ✅ **Независимость от статуса** - резервы создаются для любого статуса заказа +- ✅ **Гибкость** - можно добавлять товары при редактировании + +### Решение #2 (transaction.on_commit): +- ✅ **Целостность данных** - резервы освобождаются только при успешном удалении +- ✅ **Безопасность** - нет риска расхождений при ошибках +- ✅ **Прозрачность** - очевидная логика работы +- ✅ **Надёжность** - гарантии через транзакции + +--- + +## 🧪 Как проверить + +### Тест 1: Создание заказа с товарами +1. Создайте новый заказ со статусом "Черновик" +2. Добавьте 2 товара по 10 штук каждый +3. Сохраните заказ +4. **Проверка:** В таблице `Reservation` должно быть 2 записи со `status='reserved'` + +### Тест 2: Создание заказа без товаров +1. Создайте новый заказ без товаров +2. Сохраните заказ +3. **Проверка:** Резервы не создались (это нормально) + +### Тест 3: Добавление товара при редактировании +1. Откройте существующий заказ +2. Добавьте новый товар (5 штук) +3. Сохраните заказ +4. **Проверка:** Создался новый резерв для добавленного товара + +### Тест 4: Изменение количества +1. Откройте существующий заказ с товарами +2. Измените количество товара с 10 на 15 +3. Сохраните заказ +4. **Проверка:** Резерв обновился (quantity=15) + +### Тест 5: Удаление заказа +1. Удалите заказ с товарами +2. **Проверка:** Резервы переведены в `status='released'` +3. Попробуйте удалить заказ с нарушением ограничений БД +4. **Проверка:** Если удаление упало - резервы НЕ изменились + +--- + +## 📝 Связанные файлы + +- `myproject/inventory/signals.py` - оба исправления +- `myproject/orders/views.py` - порядок сохранения (не изменялся) + +--- + +Дата исправления: 2024-12-01 diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index 1faf4d0..674170c 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -390,41 +390,51 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs): @receiver(pre_delete, sender=Order) +@transaction.atomic def release_stock_on_order_delete(sender, instance, **kwargs): """ Сигнал: При удалении/отмене заказа освободить резервы. Процесс: - 1. Ищем все резервы для этого заказа - 2. Меняем статус резерва на 'released' - 3. Фиксируем время освобождения + 1. Ищем все резервы для этого заказа ДО удаления + 2. Освобождаем резервы ПОСЛЕ успешного коммита транзакции + 3. Это гарантирует, что резервы освободятся только если удаление успешно """ - # Находим все резервы для этого заказа - reservations = Reservation.objects.filter( - order_item__order=instance, - status='reserved' + # Находим все резервы для этого заказа ДО удаления + # Используем list() чтобы выполнить запрос сейчас, пока Order ещё существует + reservations_to_release = list( + Reservation.objects.filter( + order_item__order=instance, + status='reserved' + ) ) - # Освобождаем каждый резерв - for res in reservations: - res.status = 'released' - res.released_at = timezone.now() - res.save() + # Освобождаем резервы ПОСЛЕ успешного коммита транзакции + # Это гарантирует целостность: резервы освободятся только если удаление прошло успешно + def release_reservations(): + for res in reservations_to_release: + res.status = 'released' + res.released_at = timezone.now() + res.save() + + transaction.on_commit(release_reservations) @receiver(post_save, sender=OrderItem) def update_reservation_on_item_change(sender, instance, created, **kwargs): """ - Сигнал: Если изменилось количество товара в позиции заказа, - обновить резерв. + Сигнал: При создании или изменении позиции заказа управляем резервами. Процесс: - 1. Если это новая позиция - игнорируем (резерв уже создан через Order) - 2. Если изменилось количество - обновляем резерв или создаем новый - """ - if created: - return # Новые позиции обрабатываются через Order signal + 1. Ищем существующий резерв для этой позиции + 2. Если резерв ЕСТЬ - обновляем количество + 3. Если резерва НЕТ - создаем новый + Покрывает все сценарии: + - Создание заказа с товарами → создаёт резервы + - Редактирование + добавление товаров → создаёт резервы для новых + - Изменение количества → обновляет резервы + """ # Получаем резерв для этой позиции в статусе 'reserved' reservation = Reservation.objects.filter( order_item=instance, @@ -432,11 +442,12 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs): ).first() if reservation: - # Резерв существует - обновляем его + # Резерв существует - обновляем его количество reservation.quantity = Decimal(str(instance.quantity)) reservation.save() else: # Резерва нет - создаем новый + # Это происходит при создании нового OrderItem (через форму или при редактировании) warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first() if warehouse: