Исправлены баги с дублированием резервов и Sale для витринных комплектов

Проблемы:
1. При продаже витринного комплекта через POS создавались дубликаты резервов
   - reserve_stock_on_item_create создавал новые резервы для витринного комплекта
   - Хотя резервы уже существовали от ShowcaseManager.reserve_kit_to_showcase

2. При переходе заказа в Completed создавались дубликаты Sale
   - ShowcaseManager.sell_showcase_items создавал Sale
   - Затем сигнал create_sale_on_order_completion создавал Sale повторно

3. При отмене заказа (Completed → Cancelled) терялась связь ShowcaseItem с резервами
   - ShowcaseItem возвращался на витрину, но резервы теряли поле showcase_item
   - При повторном переходе в Completed резервы дублировались

Исправления:

1. inventory/signals.py - reserve_stock_on_item_create (строки 165-180):
   - Добавлена проверка витринного комплекта (is_temporary && showcase)
   - Для витринных комплектов сигнал пропускает создание новых резервов
   - Привязка существующих резервов происходит в update_reservation_on_item_change

2. inventory/signals.py - create_sale_on_order_completion (строки 346-365):
   - Добавлена проверка уже обработанных резервов (status='converted_to_sale')
   - Сигнал пропускает витринные резервы, уже обработанные ShowcaseManager
   - Логирует информацию о пропущенных резервах

3. inventory/signals.py - rollback_sale_on_status_change (строки 746-774):
   - При возврате ShowcaseItem на витрину восстанавливается связь с резервами
   - Обновляется поле showcase_item в резервах через Reservation.objects.update()
   - Логируется количество восстановленных связей

4. inventory/services/showcase_manager.py - sell_showcase_items (строки 201-206):
   - Добавлена проверка статуса резерва перед созданием Sale
   - Если резерв уже в 'converted_to_sale', он пропускается
   - Защита от двойного списания одного резерва

Результат:
 Резервы создаются только один раз при размещении на витрине
 Sale создаются только один раз при продаже
 ShowcaseItem корректно возвращается на витрину со связью с резервами
 Остатки на складе корректные (60 → 55 после продажи, 60 после отмены)
 Нет дублирования при многократных переходах Completed ↔ Cancelled
This commit is contained in:
2026-01-04 22:04:51 +03:00
parent 666e007931
commit 595cf6a018
2 changed files with 68 additions and 13 deletions

View File

@@ -132,8 +132,9 @@ def reserve_stock_on_item_create(sender, instance, created, **kwargs):
1. Проверяем, новая ли позиция (создана только что)
2. Для обычных товаров - создаём резерв с учетом единиц продажи
3. Для комплектов - резервируем компоненты (группируя одинаковые товары)
4. Статус резерва = 'reserved'
5. Проверяем на существующие резервы (защита от дубликатов)
4. Для ВИТРИННЫХ комплектов - НЕ создаём резервы (они уже есть от ShowcaseItem)
5. Статус резерва = 'reserved'
6. Проверяем на существующие резервы (защита от дубликатов)
"""
from collections import defaultdict
@@ -163,7 +164,22 @@ def reserve_stock_on_item_create(sender, instance, created, **kwargs):
)
elif instance.product_kit and instance.kit_snapshot:
# Комплект - резервируем КОМПОНЕНТЫ из снимка
# КРИТИЧНО: Проверяем витринный ли это комплект
is_showcase_kit = instance.product_kit.is_temporary and instance.product_kit.showcase
if is_showcase_kit:
# Витринный комплект - резервы УЖЕ созданы через ShowcaseManager.reserve_kit_to_showcase
# Привязка резервов к OrderItem происходит в update_reservation_on_item_change
# НЕ создаём новые резервы!
import logging
logger = logging.getLogger(__name__)
logger.info(
f" Витринный комплект '{instance.product_kit.name}': пропускаем создание резервов "
f"(уже созданы ShowcaseManager), OrderItem #{instance.id}"
)
return
# Обычный (постоянный) комплект - резервируем КОМПОНЕНТЫ из снимка
# Группируем одинаковые товары для создания одного резерва
product_quantities = defaultdict(Decimal)
@@ -350,9 +366,21 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
).exclude(status='converted_to_sale')
if not kit_reservations.exists():
logger.warning(
f"⚠ Комплект '{kit.name}': не найдено резервов компонентов"
)
# Проверяем, может быть витринный комплект уже продан через ShowcaseManager?
already_sold = Reservation.objects.filter(
order_item=item,
product_kit=kit,
status='converted_to_sale'
).exists()
if already_sold:
logger.info(
f" Витринный комплект '{kit.name}': резервы уже обработаны через ShowcaseManager.sell_showcase_items"
)
else:
logger.warning(
f"⚠ Комплект '{kit.name}': не найдено резервов компонентов"
)
continue
# Создаем Sale для каждого компонента комплекта
@@ -728,20 +756,20 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs):
# === Возвращаем витринные экземпляры обратно на витрину ===
from inventory.models import ShowcaseItem
# Находим все ShowcaseItem, проданные в рамках этого заказа
showcase_items = ShowcaseItem.objects.filter(
sold_order_item__order=instance,
status='sold'
)
showcase_items_count = showcase_items.count()
if showcase_items_count > 0:
logger.info(
f"🔄 Возвращаем {showcase_items_count} витринных экземпляров обратно на витрину..."
)
# Возвращаем каждый экземпляр на витрину
for item in showcase_items:
item.status = 'available'
@@ -749,9 +777,31 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs):
item.sold_at = None
# showcase и product_kit не трогаем - букет остаётся на той же витрине
item.save(update_fields=['status', 'sold_order_item', 'sold_at', 'updated_at'])
# КРИТИЧНО: Восстанавливаем связь между ShowcaseItem и Reservation
# Находим все резервы этого комплекта для данного OrderItem
order_item = item.sold_order_item if hasattr(item, '_original_sold_order_item') else None
if not order_item:
# Пытаемся найти через заказ и product_kit
order_items = instance.items.filter(product_kit=item.product_kit)
if order_items.exists():
order_item = order_items.first()
if order_item:
# Восстанавливаем связь showcase_item в резервах
reservations_updated = Reservation.objects.filter(
order_item=order_item,
product_kit=item.product_kit,
status='reserved'
).update(showcase_item=item)
if reservations_updated > 0:
logger.debug(
f" ✅ Восстановлена связь для {reservations_updated} резервов ShowcaseItem #{item.id}"
)
logger.info(
f" {showcase_items_count} витринных экземпляров вернулись на витрину: sold → available"
f" {showcase_items_count} витринных экземпляров вернулись на витрину: sold → available со связью с резервами"
)
# === Обновляем is_returned ===