ShowcaseItem: защита от двойной продажи витринных букетов

Новая архитектура:
- ShowcaseItem модель - физический экземпляр букета на витрине
- OneToOneField(sold_order_item) - БД-уровневая защита от двойной продажи
- Поддержка создания нескольких экземпляров одного букета
- Возможность продавать N из M доступных (например 2 из 5)

Изменения:
- inventory/models.py: добавлена модель ShowcaseItem с методами lock/unlock/mark_sold
- inventory/services/showcase_manager.py: переработан для работы с ShowcaseItem
- pos/views.py: API поддерживает quantity и showcase_item_ids
- pos/templates/pos/terminal.html: поле "Сколько букетов создать"
- pos/static/pos/js/terminal.js: выбор количества, передача showcase_item_ids

Миграции:
- 0007: создание модели ShowcaseItem
- 0008: data migration существующих букетов
- 0009: очистка ShowcaseItem для уже проданных букетов

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-09 23:51:37 +03:00
parent 936d2275e4
commit cfc6ce451e
8 changed files with 1076 additions and 318 deletions

View File

@@ -54,107 +54,83 @@ def get_pos_warehouse(request):
def get_showcase_kits_for_pos():
"""
Получает витринные комплекты для отображения в POS.
Возвращает список временных комплектов, которые зарезервированы на витринах.
Оптимизировано: убраны N+1 запросы, используется один проход по данным.
НОВАЯ АРХИТЕКТУРА с ShowcaseItem:
- Группирует экземпляры по (product_kit, showcase)
- Возвращает available_count и showcase_item_ids для каждой группы
- Позволяет продавать несколько экземпляров одного букета
"""
from products.models import ProductKitPhoto
# Получаем все зарезервированные товары на витринах
reserved_products = Reservation.objects.filter(
showcase__isnull=False,
showcase__is_active=True,
status='reserved'
).values_list('product_id', flat=True).distinct()
if not reserved_products:
from inventory.models import ShowcaseItem
from django.db.models import Count, Prefetch as DjangoPrefetch
# Группируем доступные ShowcaseItem по (product_kit, showcase)
available_items = ShowcaseItem.objects.filter(
status='available',
showcase__is_active=True
).select_related(
'product_kit',
'showcase'
).values(
'product_kit_id',
'product_kit__name',
'product_kit__sku',
'product_kit__price',
'product_kit__sale_price',
'showcase_id',
'showcase__name'
).annotate(
available_count=Count('id')
).order_by('showcase__name', 'product_kit__name')
if not available_items:
return []
# Prefetch для первого фото (thumbnail)
first_photo_prefetch = Prefetch(
'photos',
queryset=ProductKitPhoto.objects.order_by('order')[:1],
to_attr='first_photo_list'
)
# Находим комплекты с резервированными компонентами
# ВАЖНО: фильтруем только комплекты, явно привязанные к витрине (showcase__isnull=False)
# Это исключает временные комплекты, созданные для заказов (order__isnull=False, showcase=NULL)
kits_with_showcase_items = ProductKit.objects.filter(
is_temporary=True,
status='active',
showcase__isnull=False, # Только витринные комплекты
kit_items__product_id__in=reserved_products
).prefetch_related(
first_photo_prefetch,
Prefetch('kit_items', queryset=KitItem.objects.select_related('product'))
).distinct()
# Получаем все резервы для компонентов комплектов одним запросом
all_kit_product_ids = set()
kit_to_product_ids = {} # {kit.id: set(product_ids)}
for kit in kits_with_showcase_items:
# Используем prefetch'енные kit_items (без дополнительного запроса)
product_ids = {item.product_id for item in kit.kit_items.all()}
kit_to_product_ids[kit.id] = product_ids
all_kit_product_ids.update(product_ids)
# Один запрос для всех резервов
all_reservations = Reservation.objects.filter(
product_id__in=all_kit_product_ids,
showcase__isnull=False,
showcase__is_active=True,
status='reserved'
).select_related('showcase').values('product_id', 'showcase_id', 'showcase__name')
# Группируем резервы по product_id
product_to_showcases = {} # {product_id: [(showcase_id, showcase_name), ...]}
for res in all_reservations:
product_id = res['product_id']
if product_id not in product_to_showcases:
product_to_showcases[product_id] = []
product_to_showcases[product_id].append((res['showcase_id'], res['showcase__name']))
# Получаем ID всех комплектов для загрузки фото
kit_ids = list(set(item['product_kit_id'] for item in available_items))
# Загружаем первые фото для комплектов
kit_photos = {}
photos = ProductKitPhoto.objects.filter(
kit_id__in=kit_ids
).order_by('kit_id', 'order')
for photo in photos:
if photo.kit_id not in kit_photos:
kit_photos[photo.kit_id] = photo.get_thumbnail_url()
# Формируем результат
showcase_kits = []
for kit in kits_with_showcase_items:
product_ids = kit_to_product_ids[kit.id]
# Находим общую витрину для всех компонентов
showcases_for_kit = None
for product_id in product_ids:
showcases = product_to_showcases.get(product_id, [])
if showcases_for_kit is None:
showcases_for_kit = set(s[0] for s in showcases)
else:
showcases_for_kit &= set(s[0] for s in showcases)
if showcases_for_kit:
# Берём первую витрину
showcase_id = list(showcases_for_kit)[0]
showcase_name = next(
(s[1] for pid in product_ids for s in product_to_showcases.get(pid, []) if s[0] == showcase_id),
'Неизвестно'
)
# Используем prefetch'енное первое фото
image_url = None
if hasattr(kit, 'first_photo_list') and kit.first_photo_list:
image_url = kit.first_photo_list[0].get_thumbnail_url()
showcase_kits.append({
'id': kit.id,
'name': kit.name,
'price': str(kit.actual_price),
'category_ids': [],
'in_stock': True,
'sku': kit.sku or '',
'image': image_url,
'type': 'showcase_kit',
'showcase_name': showcase_name,
'showcase_id': showcase_id
})
for item in available_items:
kit_id = item['product_kit_id']
showcase_id = item['showcase_id']
# Получаем IDs всех доступных экземпляров этой группы
item_ids = list(ShowcaseItem.objects.filter(
product_kit_id=kit_id,
showcase_id=showcase_id,
status='available'
).values_list('id', flat=True))
# Определяем актуальную цену
price = item['product_kit__sale_price'] or item['product_kit__price']
showcase_kits.append({
'id': kit_id,
'name': item['product_kit__name'],
'price': str(price),
'category_ids': [],
'in_stock': True,
'sku': item['product_kit__sku'] or '',
'image': kit_photos.get(kit_id),
'type': 'showcase_kit',
'showcase_name': item['showcase__name'],
'showcase_id': showcase_id,
# НОВЫЕ ПОЛЯ для поддержки количества
'available_count': item['available_count'],
'showcase_item_ids': item_ids
})
return showcase_kits
@@ -477,74 +453,85 @@ def get_showcase_kits_api(request):
@require_http_methods(["POST"])
def add_showcase_kit_to_cart(request, kit_id):
"""
API endpoint для добавления витринного комплекта в корзину с блокировкой.
Создает soft lock на 30 минут, предотвращая добавление другими кассирами.
API endpoint для добавления N экземпляров витринного комплекта в корзину.
НОВАЯ АРХИТЕКТУРА с ShowcaseItem:
- Принимает quantity (количество экземпляров)
- Блокирует конкретные ShowcaseItem
- Возвращает список заблокированных showcase_item_ids
Payload (JSON): { "quantity": 2 }
Returns:
JSON: {
'success': bool,
'message': str,
'lock_expires_at': ISO datetime (если success=True),
'locked_item_ids': list[int],
'lock_expires_at': ISO datetime,
'error': str (если success=False)
}
"""
from datetime import timedelta
from inventory.models import ShowcaseItem
from inventory.services.showcase_manager import ShowcaseManager
try:
# Получаем комплект
kit = ProductKit.objects.select_related('showcase').get(
# Получаем количество из тела запроса
try:
body = json.loads(request.body) if request.body else {}
quantity = int(body.get('quantity', 1))
except (json.JSONDecodeError, ValueError):
quantity = 1
if quantity < 1:
return JsonResponse({
'success': False,
'error': 'Количество должно быть больше 0'
}, status=400)
# Проверяем что комплект существует
kit = ProductKit.objects.get(
id=kit_id,
is_temporary=True,
showcase__isnull=False,
status='active'
)
# Атомарная проверка и создание блокировки (предотвращает race condition)
with transaction.atomic():
# Блокируем строки резервов для этого комплекта на уровне БД
# Примечание: нельзя использовать select_related с nullable FK при select_for_update
reservations = Reservation.objects.select_for_update().filter(
product_kit=kit,
status='reserved'
)
# Находим доступные экземпляры этого комплекта
available_items = ShowcaseItem.objects.select_for_update(
skip_locked=True
).filter(
product_kit_id=kit_id,
status='available',
showcase__is_active=True
)[:quantity]
# Проверяем существующие блокировки другими пользователями
existing_lock = reservations.filter(
cart_lock_expires_at__gt=timezone.now()
).exclude(
locked_by_user=request.user
).first()
available_items = list(available_items)
if existing_lock:
# Получаем username отдельным запросом (избегаем outer join с select_for_update)
locked_by_username = existing_lock.locked_by_user.username if existing_lock.locked_by_user else 'другой кассир'
time_left = (existing_lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
if len(available_items) < quantity:
return JsonResponse({
'success': False,
'error': f'Этот букет уже в корзине кассира "{locked_by_username}". '
f'Блокировка истечет через {int(time_left)} мин.'
}, status=409) # 409 Conflict
'error': f'Доступно только {len(available_items)} из {quantity} запрошенных экземпляров. '
f'Возможно, часть уже в корзине другого кассира.'
}, status=409)
# Создаем или продлеваем блокировку для текущего пользователя
# Блокируем экземпляры
lock_expires_at = timezone.now() + timedelta(minutes=30)
session_id = request.session.session_key or ''
locked_ids = []
# Обновляем все резервы этого комплекта (теперь атомарно!)
updated_count = reservations.update(
cart_lock_expires_at=lock_expires_at,
locked_by_user=request.user,
cart_session_id=session_id
)
if updated_count == 0:
return JsonResponse({
'success': False,
'error': 'У комплекта нет активных резервов. Возможно, он уже продан.'
}, status=400)
for item in available_items:
item.lock_for_cart(
user=request.user,
session_id=session_id,
duration_minutes=30
)
locked_ids.append(item.id)
return JsonResponse({
'success': True,
'message': f'Букет "{kit.name}" добавлен в корзину',
'message': f'Добавлено {len(locked_ids)} экз. "{kit.name}" в корзину',
'locked_item_ids': locked_ids,
'lock_expires_at': lock_expires_at.isoformat(),
'locked_until_minutes': 30
})
@@ -555,6 +542,7 @@ def add_showcase_kit_to_cart(request, kit_id):
'error': 'Витринный комплект не найден'
}, status=404)
except Exception as e:
logger.error(f'Ошибка добавления витринного букета в корзину: {str(e)}', exc_info=True)
return JsonResponse({
'success': False,
'error': f'Ошибка при добавлении в корзину: {str(e)}'
@@ -565,16 +553,24 @@ def add_showcase_kit_to_cart(request, kit_id):
@require_http_methods(["POST"])
def remove_showcase_kit_from_cart(request, kit_id):
"""
API endpoint для снятия блокировки витринного комплекта при удалении из корзины.
Освобождает комплект для добавления другими кассирами.
API endpoint для снятия блокировки витринных экземпляров при удалении из корзины.
НОВАЯ АРХИТЕКТУРА с ShowcaseItem:
- Принимает опционально showcase_item_ids для снятия блокировки с конкретных экземпляров
- Если не указаны - снимает блокировку со всех экземпляров данного комплекта
Payload (JSON): { "showcase_item_ids": [10, 11] } // опционально
Returns:
JSON: {
'success': bool,
'message': str,
'released_count': int,
'error': str (если success=False)
}
"""
from inventory.models import ShowcaseItem
try:
# Получаем комплект
kit = ProductKit.objects.get(
@@ -582,27 +578,42 @@ def remove_showcase_kit_from_cart(request, kit_id):
is_temporary=True
)
# Снимаем блокировку только для текущего пользователя
updated_count = Reservation.objects.filter(
# Получаем список ID экземпляров из тела запроса (опционально)
try:
body = json.loads(request.body) if request.body else {}
showcase_item_ids = body.get('showcase_item_ids', [])
except (json.JSONDecodeError, ValueError):
showcase_item_ids = []
# Базовый фильтр - экземпляры этого комплекта, заблокированные текущим пользователем
qs = ShowcaseItem.objects.filter(
product_kit=kit,
locked_by_user=request.user,
status='reserved'
).update(
cart_lock_expires_at=None,
status='in_cart',
locked_by_user=request.user
)
# Если указаны конкретные ID - фильтруем только их
if showcase_item_ids:
qs = qs.filter(id__in=showcase_item_ids)
# Снимаем блокировку
updated_count = qs.update(
status='available',
locked_by_user=None,
cart_lock_expires_at=None,
cart_session_id=None
)
if updated_count == 0:
# Комплект не был заблокирован этим пользователем
return JsonResponse({
'success': True,
'message': 'Комплект не был заблокирован вами'
'message': 'Экземпляры не были заблокированы вами',
'released_count': 0
})
return JsonResponse({
'success': True,
'message': f'Букет "{kit.name}" удален из корзины. Блокировка снята.',
'message': f'Освобождено {updated_count} экз. "{kit.name}"',
'released_count': updated_count
})
@@ -612,6 +623,7 @@ def remove_showcase_kit_from_cart(request, kit_id):
'error': 'Комплект не найден'
}, status=404)
except Exception as e:
logger.error(f'Ошибка снятия блокировки витринного букета: {str(e)}', exc_info=True)
return JsonResponse({
'success': False,
'error': f'Ошибка при снятии блокировки: {str(e)}'
@@ -865,6 +877,7 @@ def create_temp_kit_to_showcase(request):
price_adjustment_value = Decimal(str(request.POST.get('price_adjustment_value', 0)))
sale_price_str = request.POST.get('sale_price', '')
photo_file = request.FILES.get('photo')
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
# Парсим items из JSON
items = json.loads(items_json)
@@ -962,24 +975,32 @@ def create_temp_kit_to_showcase(request):
order=0
)
# 5. Резервируем комплект на витрину
# 5. Резервируем комплект на витрину (создаём N экземпляров)
result = ShowcaseManager.reserve_kit_to_showcase(
product_kit=kit,
showcase=showcase,
quantity=1
quantity=showcase_kit_quantity
)
if not result['success']:
# Откатываем транзакцию через raise
raise Exception(result['message'])
# Получаем ID созданных ShowcaseItem
showcase_item_ids = [item.id for item in result.get('showcase_items', [])]
created_count = len(showcase_item_ids)
qty_text = f'{created_count} шт.' if created_count > 1 else ''
return JsonResponse({
'success': True,
'message': f'Временный комплект "{kit_name}" создан и зарезервирован на витрине "{showcase.name}"',
'message': f'Букет "{kit_name}" ({qty_text}) создан на витрине "{showcase.name}"' if created_count > 1 else f'Букет "{kit_name}" создан на витрине "{showcase.name}"',
'kit_id': kit.id,
'kit_name': kit.name,
'kit_price': str(kit.actual_price),
'reservations_count': len(result['reservations'])
'reservations_count': len(result['reservations']),
'showcase_item_ids': showcase_item_ids,
'available_count': created_count
})
except json.JSONDecodeError as e:
@@ -1328,6 +1349,9 @@ def pos_checkout(request):
)
# 2. Добавляем товары
from inventory.models import ShowcaseItem
from inventory.services.showcase_manager import ShowcaseManager
for item_data in items_data:
item_type = item_data['type']
item_id = item_data['id']
@@ -1343,7 +1367,8 @@ def pos_checkout(request):
price=price,
is_custom_price=False
)
elif item_type in ['kit', 'showcase_kit']:
elif item_type == 'kit':
# Обычный комплект (не витринный)
kit = ProductKit.objects.get(id=item_id)
OrderItem.objects.create(
order=order,
@@ -1352,6 +1377,40 @@ def pos_checkout(request):
price=price,
is_custom_price=False
)
elif item_type == 'showcase_kit':
# Витринный букет - работаем через ShowcaseItem
kit = ProductKit.objects.get(id=item_id)
showcase_item_ids = item_data.get('showcase_item_ids', [])
if not showcase_item_ids:
# Обратная совместимость: если showcase_item_ids не передан,
# ищем заблокированные экземпляры этого комплекта
showcase_item_ids = list(ShowcaseItem.objects.filter(
product_kit_id=item_id,
status='in_cart',
locked_by_user=request.user
).values_list('id', flat=True)[:int(quantity)])
# Создаём OrderItem
order_item = OrderItem.objects.create(
order=order,
product_kit=kit,
quantity=len(showcase_item_ids) if showcase_item_ids else int(quantity),
price=price,
is_custom_price=False,
is_from_showcase=True
)
# Продаём экземпляры через ShowcaseManager
if showcase_item_ids:
showcase_items = list(ShowcaseItem.objects.filter(
id__in=showcase_item_ids
))
if showcase_items:
result = ShowcaseManager.sell_showcase_items(showcase_items, order_item)
if not result['success']:
raise ValidationError(result['message'])
# 3. Пересчитываем итоговую стоимость
order.calculate_total()
@@ -1374,24 +1433,7 @@ def pos_checkout(request):
# 5. Обновляем статус оплаты
order.update_payment_status()
# 6. Освобождаем блокировки витринных комплектов
showcase_kit_ids = [
item_data['id'] for item_data in items_data
if item_data['type'] == 'showcase_kit'
]
if showcase_kit_ids:
Reservation.objects.filter(
product_kit_id__in=showcase_kit_ids,
locked_by_user=request.user,
status='reserved'
).update(
cart_lock_expires_at=None,
locked_by_user=None,
cart_session_id=None
)
# 7. Очищаем корзину из Redis
# 6. Очищаем корзину из Redis
from django.core.cache import cache
cart_key = f'pos:cart:{request.user.id}:{warehouse_id}'
cache.delete(cart_key)