diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index 0ea5409..0b1f92b 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -570,10 +570,18 @@ function renderProducts() { const stock = document.createElement('div'); stock.className = 'product-stock'; - // Для витринных комплектов показываем название витрины И доступное количество + // Для витринных комплектов показываем название витрины И количество (доступно/всего) if (item.type === 'showcase_kit') { - const availableCount = item.available_count || 1; - stock.innerHTML = `🌺 ${item.showcase_name} ${availableCount} шт`; + const availableCount = item.available_count || 0; + const totalCount = item.total_count || availableCount; + const inCart = totalCount - availableCount; + + // Показываем: доступно / всего (и сколько в корзине) + let badgeClass = availableCount > 0 ? 'bg-success' : 'bg-secondary'; + let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`; + let cartInfo = inCart > 0 ? ` 🛒${inCart}` : ''; + + stock.innerHTML = `🌺 ${item.showcase_name} ${badgeText}${cartInfo}`; stock.style.color = '#856404'; stock.style.fontWeight = 'bold'; } else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) { @@ -736,35 +744,10 @@ function setupInfiniteScroll() { async function addToCart(item) { const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1" - // СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock + количество) + // СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock) if (item.type === 'showcase_kit') { - // Определяем сколько доступно и сколько добавить - const availableCount = item.available_count || 1; - const currentInCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0; - const remainingAvailable = availableCount - currentInCart; - - if (remainingAvailable <= 0) { - alert(`Все ${availableCount} экз. этого букета уже в корзине.`); - return; - } - - // Если доступно > 1, спрашиваем количество - let quantityToAdd = 1; - if (remainingAvailable > 1 && !cart.has(cartKey)) { - const input = prompt( - `Доступно ${availableCount} экз. букета "${item.name}".\n` + - `Сколько добавить в корзину? (1-${remainingAvailable})`, - '1' - ); - if (input === null) return; // Отмена - quantityToAdd = parseInt(input, 10); - if (isNaN(quantityToAdd) || quantityToAdd < 1 || quantityToAdd > remainingAvailable) { - alert(`Введите число от 1 до ${remainingAvailable}`); - return; - } - } - - // Пытаемся создать блокировку через API + // Пытаемся заблокировать 1 экземпляр через API + // API сам проверит доступность и вернёт ошибку если нет свободных try { const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, { method: 'POST', @@ -772,14 +755,18 @@ async function addToCart(item) { 'X-CSRFToken': getCookie('csrftoken'), 'Content-Type': 'application/json' }, - body: JSON.stringify({ quantity: quantityToAdd }) + body: JSON.stringify({ quantity: 1 }) }); const data = await response.json(); if (!response.ok || !data.success) { - // Конфликт - комплект занят другим кассиром + // Нет доступных экземпляров или другая ошибка alert(data.error || 'Не удалось добавить букет в корзину'); + // Обновляем витрину чтобы показать актуальное состояние + if (isShowcaseView) { + await loadShowcaseKits(); + } return; } @@ -791,7 +778,6 @@ async function addToCart(item) { const existing = cart.get(cartKey); existing.qty += lockedItemIds.length; existing.showcase_item_ids = [...(existing.showcase_item_ids || []), ...lockedItemIds]; - existing.max_qty = availableCount; } else { // Создаём новую запись cart.set(cartKey, { @@ -800,13 +786,12 @@ async function addToCart(item) { price: Number(item.price), qty: lockedItemIds.length, type: item.type, - max_qty: availableCount, // Максимум = сколько доступно - showcase_item_ids: lockedItemIds, // ID заблокированных экземпляров + showcase_item_ids: lockedItemIds, lock_expires_at: data.lock_expires_at }); } - // Обновляем список витрины (чтобы показать блокировку) + // Обновляем список витрины (чтобы показать актуальные available_count) if (isShowcaseView) { await loadShowcaseKits(); } @@ -919,7 +904,7 @@ function renderCart() { if (isShowcaseKit) { const badge = document.createElement('span'); badge.className = 'badge bg-warning text-dark'; - badge.textContent = '1 шт (витрина)'; + badge.textContent = `${item.qty} шт (витрина)`; badge.style.fontSize = '0.85rem'; badge.style.padding = '0.5rem 0.75rem'; qtyControl.appendChild(badge); @@ -1040,10 +1025,28 @@ async function removeFromCart(cartKey) { } } -function clearCart() { +async function clearCart() { + // Сбрасываем все свои блокировки витринных букетов + try { + await fetch('/pos/api/showcase-kits/release-all-my-locks/', { + method: 'POST', + headers: { 'X-CSRFToken': getCookie('csrftoken') } + }); + } catch (e) { + console.error('Ошибка сброса блокировок:', e); + } + + // Очищаем корзину cart.clear(); renderCart(); saveCartToRedis(); // Сохраняем пустую корзину в Redis + + // Обновляем отображение товаров/витрины чтобы показать актуальные остатки + if (isShowcaseView) { + await loadShowcaseKits(); + } else { + renderProducts(); // Перерисовать карточки товаров с актуальными остатками + } } document.getElementById('clearCart').onclick = clearCart; diff --git a/myproject/pos/urls.py b/myproject/pos/urls.py index 638adf7..4056778 100644 --- a/myproject/pos/urls.py +++ b/myproject/pos/urls.py @@ -25,6 +25,8 @@ urlpatterns = [ path('api/create-draft/', views.create_order_draft, name='create-order-draft'), # Снять блокировку витринного комплекта при удалении из корзины [POST] path('api/showcase-kits//remove-from-cart/', views.remove_showcase_kit_from_cart, name='remove-showcase-kit-from-cart'), + # Сбросить ВСЕ свои блокировки витринных букетов [POST] + path('api/showcase-kits/release-all-my-locks/', views.release_all_my_showcase_locks, name='release-all-my-showcase-locks'), # Получить детали комплекта для редактирования [GET] path('api/product-kits//', views.get_product_kit_details, name='get-product-kit-details'), # Обновить временный комплект (состав, фото, цены) [POST] diff --git a/myproject/pos/views.py b/myproject/pos/views.py index 0e8b18f..0c5ec48 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -57,16 +57,17 @@ def get_showcase_kits_for_pos(): НОВАЯ АРХИТЕКТУРА с ShowcaseItem: - Группирует экземпляры по (product_kit, showcase) - - Возвращает available_count и showcase_item_ids для каждой группы - - Позволяет продавать несколько экземпляров одного букета + - Показывает ВСЕ букеты (available + in_cart), не только доступные + - Возвращает available_count (сколько можно добавить) и total_count (всего) """ from products.models import ProductKitPhoto from inventory.models import ShowcaseItem - from django.db.models import Count, Prefetch as DjangoPrefetch + from django.db.models import Count, Q - # Группируем доступные ShowcaseItem по (product_kit, showcase) - available_items = ShowcaseItem.objects.filter( - status='available', + # Группируем ShowcaseItem по (product_kit, showcase) + # Включаем и available, и in_cart (чтобы видеть букеты в корзине) + all_items = ShowcaseItem.objects.filter( + status__in=['available', 'in_cart'], showcase__is_active=True ).select_related( 'product_kit', @@ -80,14 +81,15 @@ def get_showcase_kits_for_pos(): 'showcase_id', 'showcase__name' ).annotate( - available_count=Count('id') + total_count=Count('id'), + available_count=Count('id', filter=Q(status='available')) ).order_by('showcase__name', 'product_kit__name') - if not available_items: + if not all_items: return [] # Получаем ID всех комплектов для загрузки фото - kit_ids = list(set(item['product_kit_id'] for item in available_items)) + kit_ids = list(set(item['product_kit_id'] for item in all_items)) # Загружаем первые фото для комплектов kit_photos = {} @@ -101,12 +103,12 @@ def get_showcase_kits_for_pos(): # Формируем результат showcase_kits = [] - for item in available_items: + for item in all_items: kit_id = item['product_kit_id'] showcase_id = item['showcase_id'] - # Получаем IDs всех доступных экземпляров этой группы - item_ids = list(ShowcaseItem.objects.filter( + # Получаем IDs только ДОСТУПНЫХ экземпляров этой группы + available_item_ids = list(ShowcaseItem.objects.filter( product_kit_id=kit_id, showcase_id=showcase_id, status='available' @@ -120,15 +122,16 @@ def get_showcase_kits_for_pos(): 'name': item['product_kit__name'], 'price': str(price), 'category_ids': [], - 'in_stock': True, + 'in_stock': item['available_count'] > 0, # Есть ли доступные '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 + # Количества + 'available_count': item['available_count'], # Сколько можно добавить + 'total_count': item['total_count'], # Всего на витрине (включая в корзине) + 'showcase_item_ids': available_item_ids # IDs только доступных }) return showcase_kits @@ -404,6 +407,21 @@ def get_showcase_kits_api(request): Включает информацию о блокировках в корзинах. """ from datetime import timedelta + from inventory.models import ShowcaseItem + + # Очищаем только ИСТЁКШИЕ блокировки (cart_lock_expires_at < now) + expired_locks = ShowcaseItem.objects.filter( + status='in_cart', + cart_lock_expires_at__lt=timezone.now() + ) + expired_count = expired_locks.update( + status='available', + locked_by_user=None, + cart_lock_expires_at=None, + cart_session_id=None + ) + if expired_count > 0: + logger.info(f'Очищено {expired_count} истёкших блокировок ShowcaseItem') showcase_kits_data = get_showcase_kits_for_pos() @@ -630,6 +648,41 @@ def remove_showcase_kit_from_cart(request, kit_id): }, status=500) +@login_required +@require_http_methods(["POST"]) +def release_all_my_showcase_locks(request): + """ + API endpoint для сброса ВСЕХ блокировок витринных букетов текущего пользователя. + Используется при загрузке POS если корзина пустая, чтобы освободить зависшие блокировки. + """ + from inventory.models import ShowcaseItem + + try: + # Снимаем ВСЕ блокировки текущего пользователя + updated_count = ShowcaseItem.objects.filter( + status='in_cart', + locked_by_user=request.user + ).update( + status='available', + locked_by_user=None, + cart_lock_expires_at=None, + cart_session_id=None + ) + + return JsonResponse({ + 'success': True, + 'message': f'Освобождено {updated_count} блокировок', + 'released_count': updated_count + }) + + except Exception as e: + logger.error(f'Ошибка сброса блокировок: {str(e)}', exc_info=True) + return JsonResponse({ + 'success': False, + 'error': str(e) + }, status=500) + + @login_required @require_http_methods(["GET"]) def get_items_api(request):