Исправлены баги с дублированием резервов и 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

@@ -180,7 +180,7 @@ class ShowcaseManager:
) )
for showcase_item in showcase_items_locked: for showcase_item in showcase_items_locked:
# Проверка статуса перед продажей # Проверяем статус перед продажей
if showcase_item.status == 'sold': if showcase_item.status == 'sold':
raise ValidationError( raise ValidationError(
f'Экземпляр "{showcase_item}" уже продан' f'Экземпляр "{showcase_item}" уже продан'
@@ -200,6 +200,11 @@ class ShowcaseManager:
) )
for reservation in reservations: for reservation in reservations:
# Проверяем что резерв ещё НЕ обработан
if reservation.status == 'converted_to_sale':
# Этот резерв уже преобразован в продажу, пропускаем
continue
# Сначала устанавливаем order_item для правильного определения цены # Сначала устанавливаем order_item для правильного определения цены
reservation.order_item = order_item reservation.order_item = order_item
reservation.save() reservation.save()

View File

@@ -132,8 +132,9 @@ def reserve_stock_on_item_create(sender, instance, created, **kwargs):
1. Проверяем, новая ли позиция (создана только что) 1. Проверяем, новая ли позиция (создана только что)
2. Для обычных товаров - создаём резерв с учетом единиц продажи 2. Для обычных товаров - создаём резерв с учетом единиц продажи
3. Для комплектов - резервируем компоненты (группируя одинаковые товары) 3. Для комплектов - резервируем компоненты (группируя одинаковые товары)
4. Статус резерва = 'reserved' 4. Для ВИТРИННЫХ комплектов - НЕ создаём резервы (они уже есть от ShowcaseItem)
5. Проверяем на существующие резервы (защита от дубликатов) 5. Статус резерва = 'reserved'
6. Проверяем на существующие резервы (защита от дубликатов)
""" """
from collections import defaultdict 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: 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) product_quantities = defaultdict(Decimal)
@@ -350,9 +366,21 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
).exclude(status='converted_to_sale') ).exclude(status='converted_to_sale')
if not kit_reservations.exists(): if not kit_reservations.exists():
logger.warning( # Проверяем, может быть витринный комплект уже продан через ShowcaseManager?
f"⚠ Комплект '{kit.name}': не найдено резервов компонентов" 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 continue
# Создаем Sale для каждого компонента комплекта # Создаем Sale для каждого компонента комплекта
@@ -750,8 +778,30 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs):
# showcase и product_kit не трогаем - букет остаётся на той же витрине # showcase и product_kit не трогаем - букет остаётся на той же витрине
item.save(update_fields=['status', 'sold_order_item', 'sold_at', 'updated_at']) 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( logger.info(
f" {showcase_items_count} витринных экземпляров вернулись на витрину: sold → available" f" {showcase_items_count} витринных экземпляров вернулись на витрину: sold → available со связью с резервами"
) )
# === Обновляем is_returned === # === Обновляем is_returned ===