Добавлена система 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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user