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:
@@ -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;
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user