POS: улучшения работы с витринными букетами

- Упрощено добавление в корзину: 1 клик = 1 шт (без prompt)
- API показывает все букеты (available + in_cart), не только доступные
- Карточка показывает available/total и сколько в корзине
- Корзина показывает реальное количество витринных букетов
- Кнопка "Очистить" сбрасывает блокировки и обновляет отображение
- API release-all-my-locks для сброса зависших блокировок
- Автоочистка истёкших блокировок при загрузке витрины

🤖 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-10 00:23:00 +03:00
parent cfc6ce451e
commit 5c94a5ab95
3 changed files with 113 additions and 55 deletions

View File

@@ -570,10 +570,18 @@ function renderProducts() {
const stock = document.createElement('div'); const stock = document.createElement('div');
stock.className = 'product-stock'; stock.className = 'product-stock';
// Для витринных комплектов показываем название витрины И доступное количество // Для витринных комплектов показываем название витрины И количество (доступно/всего)
if (item.type === 'showcase_kit') { if (item.type === 'showcase_kit') {
const availableCount = item.available_count || 1; const availableCount = item.available_count || 0;
stock.innerHTML = `🌺 ${item.showcase_name} <span class="badge bg-success ms-1">${availableCount} шт</span>`; 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 ? ` <span class="badge bg-warning text-dark">🛒${inCart}</span>` : '';
stock.innerHTML = `🌺 ${item.showcase_name} <span class="badge ${badgeClass} ms-1">${badgeText}</span>${cartInfo}`;
stock.style.color = '#856404'; stock.style.color = '#856404';
stock.style.fontWeight = 'bold'; stock.style.fontWeight = 'bold';
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) { } else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
@@ -736,35 +744,10 @@ function setupInfiniteScroll() {
async function addToCart(item) { async function addToCart(item) {
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1" const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock + количество) // СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock)
if (item.type === 'showcase_kit') { if (item.type === 'showcase_kit') {
// Определяем сколько доступно и сколько добавить // Пытаемся заблокировать 1 экземпляр через API
const availableCount = item.available_count || 1; // API сам проверит доступность и вернёт ошибку если нет свободных
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
try { try {
const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, { const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, {
method: 'POST', method: 'POST',
@@ -772,14 +755,18 @@ async function addToCart(item) {
'X-CSRFToken': getCookie('csrftoken'), 'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ quantity: quantityToAdd }) body: JSON.stringify({ quantity: 1 })
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
// Конфликт - комплект занят другим кассиром // Нет доступных экземпляров или другая ошибка
alert(data.error || 'Не удалось добавить букет в корзину'); alert(data.error || 'Не удалось добавить букет в корзину');
// Обновляем витрину чтобы показать актуальное состояние
if (isShowcaseView) {
await loadShowcaseKits();
}
return; return;
} }
@@ -791,7 +778,6 @@ async function addToCart(item) {
const existing = cart.get(cartKey); const existing = cart.get(cartKey);
existing.qty += lockedItemIds.length; existing.qty += lockedItemIds.length;
existing.showcase_item_ids = [...(existing.showcase_item_ids || []), ...lockedItemIds]; existing.showcase_item_ids = [...(existing.showcase_item_ids || []), ...lockedItemIds];
existing.max_qty = availableCount;
} else { } else {
// Создаём новую запись // Создаём новую запись
cart.set(cartKey, { cart.set(cartKey, {
@@ -800,13 +786,12 @@ async function addToCart(item) {
price: Number(item.price), price: Number(item.price),
qty: lockedItemIds.length, qty: lockedItemIds.length,
type: item.type, type: item.type,
max_qty: availableCount, // Максимум = сколько доступно showcase_item_ids: lockedItemIds,
showcase_item_ids: lockedItemIds, // ID заблокированных экземпляров
lock_expires_at: data.lock_expires_at lock_expires_at: data.lock_expires_at
}); });
} }
// Обновляем список витрины (чтобы показать блокировку) // Обновляем список витрины (чтобы показать актуальные available_count)
if (isShowcaseView) { if (isShowcaseView) {
await loadShowcaseKits(); await loadShowcaseKits();
} }
@@ -919,7 +904,7 @@ function renderCart() {
if (isShowcaseKit) { if (isShowcaseKit) {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'badge bg-warning text-dark'; badge.className = 'badge bg-warning text-dark';
badge.textContent = '1 шт (витрина)'; badge.textContent = `${item.qty} шт (витрина)`;
badge.style.fontSize = '0.85rem'; badge.style.fontSize = '0.85rem';
badge.style.padding = '0.5rem 0.75rem'; badge.style.padding = '0.5rem 0.75rem';
qtyControl.appendChild(badge); 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(); cart.clear();
renderCart(); renderCart();
saveCartToRedis(); // Сохраняем пустую корзину в Redis saveCartToRedis(); // Сохраняем пустую корзину в Redis
// Обновляем отображение товаров/витрины чтобы показать актуальные остатки
if (isShowcaseView) {
await loadShowcaseKits();
} else {
renderProducts(); // Перерисовать карточки товаров с актуальными остатками
}
} }
document.getElementById('clearCart').onclick = clearCart; document.getElementById('clearCart').onclick = clearCart;

View File

@@ -25,6 +25,8 @@ urlpatterns = [
path('api/create-draft/', views.create_order_draft, name='create-order-draft'), path('api/create-draft/', views.create_order_draft, name='create-order-draft'),
# Снять блокировку витринного комплекта при удалении из корзины [POST] # Снять блокировку витринного комплекта при удалении из корзины [POST]
path('api/showcase-kits/<int:kit_id>/remove-from-cart/', views.remove_showcase_kit_from_cart, name='remove-showcase-kit-from-cart'), path('api/showcase-kits/<int:kit_id>/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] # Получить детали комплекта для редактирования [GET]
path('api/product-kits/<int:kit_id>/', views.get_product_kit_details, name='get-product-kit-details'), path('api/product-kits/<int:kit_id>/', views.get_product_kit_details, name='get-product-kit-details'),
# Обновить временный комплект (состав, фото, цены) [POST] # Обновить временный комплект (состав, фото, цены) [POST]

View File

@@ -57,16 +57,17 @@ def get_showcase_kits_for_pos():
НОВАЯ АРХИТЕКТУРА с ShowcaseItem: НОВАЯ АРХИТЕКТУРА с ShowcaseItem:
- Группирует экземпляры по (product_kit, showcase) - Группирует экземпляры по (product_kit, showcase)
- Возвращает available_count и showcase_item_ids для каждой группы - Показывает ВСЕ букеты (available + in_cart), не только доступные
- Позволяет продавать несколько экземпляров одного букета - Возвращает available_count (сколько можно добавить) и total_count (всего)
""" """
from products.models import ProductKitPhoto from products.models import ProductKitPhoto
from inventory.models import ShowcaseItem 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) # Группируем ShowcaseItem по (product_kit, showcase)
available_items = ShowcaseItem.objects.filter( # Включаем и available, и in_cart (чтобы видеть букеты в корзине)
status='available', all_items = ShowcaseItem.objects.filter(
status__in=['available', 'in_cart'],
showcase__is_active=True showcase__is_active=True
).select_related( ).select_related(
'product_kit', 'product_kit',
@@ -80,14 +81,15 @@ def get_showcase_kits_for_pos():
'showcase_id', 'showcase_id',
'showcase__name' 'showcase__name'
).annotate( ).annotate(
available_count=Count('id') total_count=Count('id'),
available_count=Count('id', filter=Q(status='available'))
).order_by('showcase__name', 'product_kit__name') ).order_by('showcase__name', 'product_kit__name')
if not available_items: if not all_items:
return [] return []
# Получаем ID всех комплектов для загрузки фото # Получаем 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 = {} kit_photos = {}
@@ -101,12 +103,12 @@ def get_showcase_kits_for_pos():
# Формируем результат # Формируем результат
showcase_kits = [] showcase_kits = []
for item in available_items: for item in all_items:
kit_id = item['product_kit_id'] kit_id = item['product_kit_id']
showcase_id = item['showcase_id'] showcase_id = item['showcase_id']
# Получаем IDs всех доступных экземпляров этой группы # Получаем IDs только ДОСТУПНЫХ экземпляров этой группы
item_ids = list(ShowcaseItem.objects.filter( available_item_ids = list(ShowcaseItem.objects.filter(
product_kit_id=kit_id, product_kit_id=kit_id,
showcase_id=showcase_id, showcase_id=showcase_id,
status='available' status='available'
@@ -120,15 +122,16 @@ def get_showcase_kits_for_pos():
'name': item['product_kit__name'], 'name': item['product_kit__name'],
'price': str(price), 'price': str(price),
'category_ids': [], 'category_ids': [],
'in_stock': True, 'in_stock': item['available_count'] > 0, # Есть ли доступные
'sku': item['product_kit__sku'] or '', 'sku': item['product_kit__sku'] or '',
'image': kit_photos.get(kit_id), 'image': kit_photos.get(kit_id),
'type': 'showcase_kit', 'type': 'showcase_kit',
'showcase_name': item['showcase__name'], 'showcase_name': item['showcase__name'],
'showcase_id': showcase_id, 'showcase_id': showcase_id,
# НОВЫЕ ПОЛЯ для поддержки количества # Количества
'available_count': item['available_count'], 'available_count': item['available_count'], # Сколько можно добавить
'showcase_item_ids': item_ids 'total_count': item['total_count'], # Всего на витрине (включая в корзине)
'showcase_item_ids': available_item_ids # IDs только доступных
}) })
return showcase_kits return showcase_kits
@@ -404,6 +407,21 @@ def get_showcase_kits_api(request):
Включает информацию о блокировках в корзинах. Включает информацию о блокировках в корзинах.
""" """
from datetime import timedelta 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() showcase_kits_data = get_showcase_kits_for_pos()
@@ -630,6 +648,41 @@ def remove_showcase_kit_from_cart(request, kit_id):
}, status=500) }, 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 @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def get_items_api(request): def get_items_api(request):