Добавлена система Soft Lock для витринных комплектов в POS-терминале

Реализована элегантная блокировка витринных букетов при добавлении в корзину,
предотвращающая многократную продажу одного физического комплекта.

## Изменения в БД:
- Добавлены поля в Reservation: cart_lock_expires_at, locked_by_user, cart_session_id
- Созданы индексы для оптимизации запросов блокировок
- Миграция 0006: добавление полей Soft Lock

## Backend (pos/views.py):
- add_showcase_kit_to_cart: создание блокировки на 30 минут с проверкой конфликтов
- remove_showcase_kit_from_cart: снятие блокировки при удалении из корзины
- get_showcase_kits_api: возврат статусов блокировок (is_locked, locked_by_me)

## Frontend (terminal.js):
- addToCart: AJAX запрос для создания блокировки, запрет qty > 1
- removeFromCart: автоматическое снятие блокировки
- renderCart: желтый фон, badge "1 шт (витрина)", скрыты кнопки +/−
- UI индикация: зеленый badge "В корзине" (свой), красный "Занят" (чужой)

## Автоматизация (inventory/tasks.py):
- cleanup_expired_cart_locks: Celery periodic task (каждые 5 минут)
- Автоматическое освобождение истекших блокировок (30 минут timeout)
- Логирование очистки для мониторинга

## Маршруты (pos/urls.py):
- POST /api/showcase-kits/<id>/add-to-cart/ - создание блокировки
- POST /api/showcase-kits/<id>/remove-from-cart/ - снятие блокировки

## Документация:
- ЗАПУСК.md: инструкция по запуску Celery Beat

Преимущества:
✓ Предотвращает конфликты между кассирами
✓ Автоматическое освобождение при таймауте
✓ Понятный UX с визуальной индикацией
✓ Совместимость с существующей логикой резервирования

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 23:45:34 +03:00
parent ff0756498c
commit 33e33ecbac
8 changed files with 600 additions and 81 deletions

View File

@@ -430,18 +430,53 @@ function renderProducts() {
// Если это витринный комплект - добавляем кнопку редактирования
if (item.type === 'showcase_kit') {
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-sm btn-outline-primary';
editBtn.style.position = 'absolute';
editBtn.style.top = '5px';
editBtn.style.right = '5px';
editBtn.style.zIndex = '10';
editBtn.innerHTML = '<i class="bi bi-pencil"></i>';
editBtn.onclick = (e) => {
e.stopPropagation();
openEditKitModal(item.id);
};
card.appendChild(editBtn);
// ИНДИКАЦИЯ БЛОКИРОВКИ
if (item.is_locked) {
// Создаем бейдж блокировки
const lockBadge = document.createElement('div');
lockBadge.style.position = 'absolute';
lockBadge.style.top = '5px';
lockBadge.style.left = '5px';
lockBadge.style.zIndex = '10';
if (item.locked_by_me) {
// Заблокирован мной - зеленый бейдж
lockBadge.className = 'badge bg-success';
lockBadge.innerHTML = '<i class="bi bi-cart-check"></i> В корзине';
lockBadge.title = 'Добавлен в вашу корзину';
} else {
// Заблокирован другим кассиром - красный бейдж + блокируем карточку
lockBadge.className = 'badge bg-danger';
lockBadge.innerHTML = '<i class="bi bi-lock-fill"></i> Занят';
lockBadge.title = `В корзине ${item.locked_by_user}`;
// Затемняем карточку и блокируем клики
card.style.opacity = '0.5';
card.style.cursor = 'not-allowed';
card.onclick = (e) => {
e.stopPropagation();
alert(`Этот букет уже в корзине кассира "${item.locked_by_user}".\nДождитесь освобождения блокировки.`);
};
}
card.appendChild(lockBadge);
}
// Кнопка редактирования (только если НЕ заблокирован другим)
if (!item.is_locked || item.locked_by_me) {
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-sm btn-outline-primary';
editBtn.style.position = 'absolute';
editBtn.style.top = '5px';
editBtn.style.right = '5px';
editBtn.style.zIndex = '10';
editBtn.innerHTML = '<i class="bi bi-pencil"></i>';
editBtn.onclick = (e) => {
e.stopPropagation();
openEditKitModal(item.id);
};
card.appendChild(editBtn);
}
}
const body = document.createElement('div');
@@ -620,28 +655,80 @@ function setupInfiniteScroll() {
observer.observe(sentinel);
}
function addToCart(item) {
async function addToCart(item) {
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
if (!cart.has(cartKey)) {
cart.set(cartKey, { id: item.id, name: item.name, price: Number(item.price), qty: 1, type: item.type });
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock)
if (item.type === 'showcase_kit') {
// Проверяем: не заблокирован ли уже этим пользователем
if (cart.has(cartKey)) {
alert('Этот букет уже в вашей корзине.\nВитринные комплекты доступны только в количестве 1 шт.');
return;
}
// Пытаемся создать блокировку через API
try {
const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (!response.ok || !data.success) {
// Конфликт - комплект занят другим кассиром
alert(data.error || 'Не удалось добавить букет в корзину');
return;
}
// Успешно заблокировали - добавляем в корзину с qty=1 и флагом max_qty
cart.set(cartKey, {
id: item.id,
name: item.name,
price: Number(item.price),
qty: 1,
type: item.type,
max_qty: 1, // Флаг: нельзя увеличить количество
lock_expires_at: data.lock_expires_at // Время истечения блокировки
});
// Обновляем список витрины (чтобы показать блокировку)
if (isShowcaseView) {
await loadShowcaseKits();
}
} catch (error) {
console.error('Ошибка при добавлении витринного комплекта:', error);
alert('Ошибка сервера. Попробуйте еще раз.');
return;
}
} else {
cart.get(cartKey).qty += 1;
// ОБЫЧНАЯ ЛОГИКА для товаров и комплектов
if (!cart.has(cartKey)) {
cart.set(cartKey, { id: item.id, name: item.name, price: Number(item.price), qty: 1, type: item.type });
} else {
cart.get(cartKey).qty += 1;
}
}
renderCart();
saveCartToRedis(); // Сохраняем в Redis
// Автоматический фокус на поле количества
setTimeout(() => {
const qtyInputs = document.querySelectorAll('.qty-input');
const itemIndex = Array.from(cart.keys()).indexOf(cartKey);
// Автоматический фокус на поле количества (только для обычных товаров)
if (item.type !== 'showcase_kit') {
setTimeout(() => {
const qtyInputs = document.querySelectorAll('.qty-input');
const itemIndex = Array.from(cart.keys()).indexOf(cartKey);
if (itemIndex !== -1 && qtyInputs[itemIndex]) {
qtyInputs[itemIndex].focus();
qtyInputs[itemIndex].select(); // Выделяем весь текст
}
}, 50);
if (itemIndex !== -1 && qtyInputs[itemIndex]) {
qtyInputs[itemIndex].focus();
qtyInputs[itemIndex].select(); // Выделяем весь текст
}
}, 50);
}
}
function renderCart() {
@@ -658,22 +745,31 @@ function renderCart() {
cart.forEach((item, cartKey) => {
const row = document.createElement('div');
row.className = 'cart-item mb-2';
// СПЕЦИАЛЬНАЯ СТИЛИЗАЦИЯ для витринных комплектов
const isShowcaseKit = item.type === 'showcase_kit';
if (isShowcaseKit) {
row.style.backgroundColor = '#fff3cd'; // Желтый фон
row.style.border = '1px solid #ffc107';
row.style.borderRadius = '4px';
row.style.padding = '8px';
}
// Левая часть: Название и цена единицы
const namePrice = document.createElement('div');
namePrice.className = 'item-name-price';
// Иконка только для комплектов
let typeIcon = '';
if (item.type === 'kit' || item.type === 'showcase_kit') {
typeIcon = '<i class="bi bi-box-seam text-info" title="Комплект"></i> ';
}
namePrice.innerHTML = `
<div class="fw-semibold small">${typeIcon}${item.name}</div>
<div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / шт</div>
`;
// Знак умножения
const multiplySign = document.createElement('span');
multiplySign.className = 'multiply-sign';
@@ -684,57 +780,69 @@ function renderCart() {
qtyControl.className = 'd-flex align-items-center';
qtyControl.style.gap = '2px';
// Кнопка минус
const minusBtn = document.createElement('button');
minusBtn.className = 'btn btn-outline-secondary btn-sm';
minusBtn.innerHTML = '<i class="bi bi-dash-circle" style="font-size: 1.2em;"></i>';
minusBtn.onclick = (e) => {
e.preventDefault();
const currentQty = cart.get(cartKey).qty;
if (currentQty <= 1) {
removeFromCart(cartKey);
} else {
cart.get(cartKey).qty = currentQty - 1;
// СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов (только badge, без кнопок)
if (isShowcaseKit) {
const badge = document.createElement('span');
badge.className = 'badge bg-warning text-dark';
badge.textContent = '1 шт (витрина)';
badge.style.fontSize = '0.85rem';
badge.style.padding = '0.5rem 0.75rem';
qtyControl.appendChild(badge);
} else {
// ОБЫЧНАЯ ЛОГИКА для товаров и комплектов
// Кнопка минус
const minusBtn = document.createElement('button');
minusBtn.className = 'btn btn-outline-secondary btn-sm';
minusBtn.innerHTML = '<i class="bi bi-dash-circle" style="font-size: 1.2em;"></i>';
minusBtn.onclick = (e) => {
e.preventDefault();
const currentQty = cart.get(cartKey).qty;
if (currentQty <= 1) {
removeFromCart(cartKey);
} else {
cart.get(cartKey).qty = currentQty - 1;
renderCart();
saveCartToRedis();
}
};
// Поле ввода количества
const qtyInput = document.createElement('input');
qtyInput.type = 'number';
qtyInput.className = 'qty-input form-control form-control-sm';
qtyInput.style.width = '60px';
qtyInput.style.textAlign = 'center';
qtyInput.style.padding = '0.375rem 0.25rem';
qtyInput.value = item.qty;
qtyInput.min = 1;
qtyInput.onchange = (e) => {
const newQty = parseInt(e.target.value) || 1;
if (newQty <= 0) {
removeFromCart(cartKey);
} else {
cart.get(cartKey).qty = newQty;
renderCart();
saveCartToRedis(); // Сохраняем в Redis при изменении количества
}
};
// Кнопка плюс
const plusBtn = document.createElement('button');
plusBtn.className = 'btn btn-outline-secondary btn-sm';
plusBtn.innerHTML = '<i class="bi bi-plus-circle" style="font-size: 1.2em;"></i>';
plusBtn.onclick = (e) => {
e.preventDefault();
cart.get(cartKey).qty += 1;
renderCart();
saveCartToRedis();
}
};
};
// Поле ввода количества
const qtyInput = document.createElement('input');
qtyInput.type = 'number';
qtyInput.className = 'qty-input form-control form-control-sm';
qtyInput.style.width = '60px';
qtyInput.style.textAlign = 'center';
qtyInput.style.padding = '0.375rem 0.25rem';
qtyInput.value = item.qty;
qtyInput.min = 1;
qtyInput.onchange = (e) => {
const newQty = parseInt(e.target.value) || 1;
if (newQty <= 0) {
removeFromCart(cartKey);
} else {
cart.get(cartKey).qty = newQty;
renderCart();
saveCartToRedis(); // Сохраняем в Redis при изменении количества
}
};
// Кнопка плюс
const plusBtn = document.createElement('button');
plusBtn.className = 'btn btn-outline-secondary btn-sm';
plusBtn.innerHTML = '<i class="bi bi-plus-circle" style="font-size: 1.2em;"></i>';
plusBtn.onclick = (e) => {
e.preventDefault();
cart.get(cartKey).qty += 1;
renderCart();
saveCartToRedis();
};
// Собираем контейнер
qtyControl.appendChild(minusBtn);
qtyControl.appendChild(qtyInput);
qtyControl.appendChild(plusBtn);
// Собираем контейнер
qtyControl.appendChild(minusBtn);
qtyControl.appendChild(qtyInput);
qtyControl.appendChild(plusBtn);
}
// Сумма за позицию
const itemTotal = document.createElement('div');
@@ -761,7 +869,38 @@ function renderCart() {
document.getElementById('cartTotal').textContent = formatMoney(total);
}
function removeFromCart(cartKey) {
async function removeFromCart(cartKey) {
const item = cart.get(cartKey);
// СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов - снимаем блокировку
if (item && item.type === 'showcase_kit') {
try {
const response = await fetch(`/pos/api/showcase-kits/${item.id}/remove-from-cart/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (!response.ok) {
console.error('Ошибка при снятии блокировки:', data.error);
// Продолжаем удаление из корзины даже при ошибке
}
// Обновляем список витрины (чтобы убрать индикацию блокировки)
if (isShowcaseView) {
await loadShowcaseKits();
}
} catch (error) {
console.error('Ошибка при снятии блокировки витринного комплекта:', error);
// Продолжаем удаление из корзины
}
}
cart.delete(cartKey);
renderCart();
saveCartToRedis(); // Сохраняем в Redis
@@ -909,13 +1048,17 @@ async function openEditKitModal(kitId) {
}
// Обновление списка витринных комплектов
async function refreshShowcaseKits() {
async function loadShowcaseKits() {
try {
const response = await fetch('/pos/api/showcase-kits/');
const data = await response.json();
if (data.success) {
showcaseKits = data.items;
// Перерисовываем грид если мы в режиме витрины
if (isShowcaseView) {
renderProducts();
}
} else {
console.error('Failed to refresh showcase kits:', data);
}
@@ -924,6 +1067,9 @@ async function refreshShowcaseKits() {
}
}
// Алиас для совместимости
const refreshShowcaseKits = loadShowcaseKits;
// Загрузка списка витрин
async function loadShowcases() {
try {

View File

@@ -19,6 +19,10 @@ urlpatterns = [
path('api/get-showcases/', views.get_showcases_api, name='get-showcases-api'),
# Получить актуальные витринные временные комплекты [GET]
path('api/showcase-kits/', views.get_showcase_kits_api, name='showcase-kits-api'),
# Добавить витринный комплект в корзину с блокировкой [POST]
path('api/showcase-kits/<int:kit_id>/add-to-cart/', views.add_showcase_kit_to_cart, name='add-showcase-kit-to-cart'),
# Снять блокировку витринного комплекта при удалении из корзины [POST]
path('api/showcase-kits/<int:kit_id>/remove-from-cart/', views.remove_showcase_kit_from_cart, name='remove-showcase-kit-from-cart'),
# Получить детали комплекта для редактирования [GET]
path('api/product-kits/<int:kit_id>/', views.get_product_kit_details, name='get-product-kit-details'),
# Обновить временный комплект (состав, фото, цены) [POST]

View File

@@ -387,15 +387,194 @@ def get_showcase_kits_api(request):
"""
API endpoint для получения актуального списка витринных комплектов.
Используется для динамического обновления после создания нового букета.
Включает информацию о блокировках в корзинах.
"""
from datetime import timedelta
showcase_kits_data = get_showcase_kits_for_pos()
# Добавляем информацию о блокировках для каждого комплекта
kit_ids = [kit['id'] for kit in showcase_kits_data]
# Получаем активные блокировки (не истекшие)
active_locks = Reservation.objects.filter(
product_kit_id__in=kit_ids,
cart_lock_expires_at__gt=timezone.now(),
status='reserved'
).select_related('locked_by_user').values(
'product_kit_id',
'locked_by_user_id',
'locked_by_user__username',
'cart_lock_expires_at'
)
# Индексируем блокировки по kit_id
locks_by_kit = {}
for lock in active_locks:
kit_id = lock['product_kit_id']
if kit_id not in locks_by_kit:
locks_by_kit[kit_id] = lock
# Дополняем данные о комплектах информацией о блокировках
for kit in showcase_kits_data:
lock_info = locks_by_kit.get(kit['id'])
if lock_info:
is_locked_by_me = lock_info['locked_by_user_id'] == request.user.id
kit['is_locked'] = True
kit['locked_by_me'] = is_locked_by_me
kit['locked_by_user'] = lock_info['locked_by_user__username']
kit['lock_expires_at'] = lock_info['cart_lock_expires_at'].isoformat()
else:
kit['is_locked'] = False
kit['locked_by_me'] = False
return JsonResponse({
'success': True,
'items': showcase_kits_data
})
@login_required
@require_http_methods(["POST"])
def add_showcase_kit_to_cart(request, kit_id):
"""
API endpoint для добавления витринного комплекта в корзину с блокировкой.
Создает soft lock на 30 минут, предотвращая добавление другими кассирами.
Returns:
JSON: {
'success': bool,
'message': str,
'lock_expires_at': ISO datetime (если success=True),
'error': str (если success=False)
}
"""
from datetime import timedelta
try:
# Получаем комплект
kit = ProductKit.objects.select_related('showcase').get(
id=kit_id,
is_temporary=True,
showcase__isnull=False,
status='active'
)
# Проверяем существующие блокировки
existing_locks = Reservation.objects.filter(
product_kit=kit,
cart_lock_expires_at__gt=timezone.now(),
status='reserved'
).exclude(
locked_by_user=request.user
).select_related('locked_by_user')
if existing_locks.exists():
lock = existing_locks.first()
time_left = (lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
return JsonResponse({
'success': False,
'error': f'Этот букет уже в корзине кассира "{lock.locked_by_user.username}". '
f'Блокировка истечет через {int(time_left)} мин.'
}, status=409) # 409 Conflict
# Создаем или продлеваем блокировку для текущего пользователя
lock_expires_at = timezone.now() + timedelta(minutes=30)
session_id = request.session.session_key or ''
# Обновляем все резервы этого комплекта
updated_count = Reservation.objects.filter(
product_kit=kit,
status='reserved'
).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)
return JsonResponse({
'success': True,
'message': f'Букет "{kit.name}" добавлен в корзину',
'lock_expires_at': lock_expires_at.isoformat(),
'locked_until_minutes': 30
})
except ProductKit.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Витринный комплект не найден'
}, status=404)
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Ошибка при добавлении в корзину: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["POST"])
def remove_showcase_kit_from_cart(request, kit_id):
"""
API endpoint для снятия блокировки витринного комплекта при удалении из корзины.
Освобождает комплект для добавления другими кассирами.
Returns:
JSON: {
'success': bool,
'message': str,
'error': str (если success=False)
}
"""
try:
# Получаем комплект
kit = ProductKit.objects.get(
id=kit_id,
is_temporary=True
)
# Снимаем блокировку только для текущего пользователя
updated_count = Reservation.objects.filter(
product_kit=kit,
locked_by_user=request.user,
status='reserved'
).update(
cart_lock_expires_at=None,
locked_by_user=None,
cart_session_id=None
)
if updated_count == 0:
# Комплект не был заблокирован этим пользователем
return JsonResponse({
'success': True,
'message': 'Комплект не был заблокирован вами'
})
return JsonResponse({
'success': True,
'message': f'Букет "{kit.name}" удален из корзины. Блокировка снята.',
'released_count': updated_count
})
except ProductKit.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Комплект не найден'
}, status=404)
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Ошибка при снятии блокировки: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["GET"])
def get_items_api(request):