diff --git a/myproject/inventory/migrations/0006_reservation_cart_lock_expires_at_and_more.py b/myproject/inventory/migrations/0006_reservation_cart_lock_expires_at_and_more.py new file mode 100644 index 0000000..674d76c --- /dev/null +++ b/myproject/inventory/migrations/0006_reservation_cart_lock_expires_at_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 5.0.10 on 2025-11-20 20:20 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0005_reservation_product_kit_and_more'), + ('orders', '0003_historicalorderitem_is_from_showcase_and_more'), + ('products', '0008_productkit_showcase_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='reservation', + name='cart_lock_expires_at', + field=models.DateTimeField(blank=True, help_text='Время истечения блокировки в корзине (для витринных комплектов)', null=True, verbose_name='Блокировка корзины истекает'), + ), + migrations.AddField( + model_name='reservation', + name='cart_session_id', + field=models.CharField(blank=True, help_text='Дополнительная идентификация сессии для надежности', max_length=100, null=True, verbose_name='ID сессии корзины'), + ), + migrations.AddField( + model_name='reservation', + name='locked_by_user', + field=models.ForeignKey(blank=True, help_text='Кассир, который добавил комплект в корзину', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cart_locks', to=settings.AUTH_USER_MODEL, verbose_name='Заблокировано пользователем'), + ), + migrations.AddIndex( + model_name='reservation', + index=models.Index(fields=['cart_lock_expires_at'], name='inventory_r_cart_lo_e9b52a_idx'), + ), + migrations.AddIndex( + model_name='reservation', + index=models.Index(fields=['locked_by_user'], name='inventory_r_locked__706cbf_idx'), + ), + migrations.AddIndex( + model_name='reservation', + index=models.Index(fields=['product_kit', 'cart_lock_expires_at'], name='inventory_r_product_5dacdf_idx'), + ), + ] diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py index 119b35d..e5e818a 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -1,6 +1,7 @@ from django.db import models from django.utils import timezone from django.core.exceptions import ValidationError +from django.conf import settings from decimal import Decimal from products.models import Product from phonenumber_field.modelfields import PhoneNumberField @@ -428,6 +429,27 @@ class Reservation(models.Model): converted_at = models.DateTimeField(null=True, blank=True, verbose_name="Дата преобразования в продажу") + # Soft Lock для корзины POS (витринные комплекты) + cart_lock_expires_at = models.DateTimeField( + null=True, blank=True, + verbose_name="Блокировка корзины истекает", + help_text="Время истечения блокировки в корзине (для витринных комплектов)" + ) + locked_by_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, blank=True, + related_name='cart_locks', + verbose_name="Заблокировано пользователем", + help_text="Кассир, который добавил комплект в корзину" + ) + cart_session_id = models.CharField( + max_length=100, + null=True, blank=True, + verbose_name="ID сессии корзины", + help_text="Дополнительная идентификация сессии для надежности" + ) + class Meta: verbose_name = "Резервирование" verbose_name_plural = "Резервирования" @@ -438,6 +460,9 @@ class Reservation(models.Model): models.Index(fields=['order_item']), models.Index(fields=['showcase']), models.Index(fields=['product_kit']), + models.Index(fields=['cart_lock_expires_at']), + models.Index(fields=['locked_by_user']), + models.Index(fields=['product_kit', 'cart_lock_expires_at']), ] def __str__(self): diff --git a/myproject/inventory/tasks.py b/myproject/inventory/tasks.py new file mode 100644 index 0000000..d41d578 --- /dev/null +++ b/myproject/inventory/tasks.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +from celery import shared_task +from django.utils import timezone +from django.db.models import Q +import logging + +logger = logging.getLogger(__name__) + + +@shared_task +def cleanup_expired_cart_locks(): + """ + Периодическая задача для очистки истекших блокировок корзины. + Освобождает витринные комплекты, которые были добавлены в корзину, + но блокировка истекла (timeout 30 минут). + + Запускается каждые 5 минут (настроить в celery beat schedule). + + Returns: + dict: Статистика очистки { + 'released_count': int, # Количество освобожденных блокировок + 'affected_kits': list # ID освобожденных комплектов + } + """ + from inventory.models import Reservation + + try: + # Находим все резервы с истекшей блокировкой + expired_locks = Reservation.objects.filter( + Q(cart_lock_expires_at__lte=timezone.now()) & + Q(cart_lock_expires_at__isnull=False) & + Q(status='reserved') + ).select_related('product_kit', 'locked_by_user') + + # Собираем статистику перед очисткой + affected_kits = list( + expired_locks.values_list('product_kit_id', flat=True).distinct() + ) + released_count = expired_locks.count() + + # Логируем информацию о блокировках + if released_count > 0: + logger.info( + f"Очистка истекших блокировок: {released_count} резервов, " + f"{len(affected_kits)} комплектов" + ) + + for lock in expired_locks[:10]: # Логируем первые 10 для отладки + kit_name = lock.product_kit.name if lock.product_kit else 'N/A' + user_name = lock.locked_by_user.username if lock.locked_by_user else 'N/A' + logger.debug( + f"Освобождение блокировки: комплект='{kit_name}', " + f"пользователь='{user_name}', " + f"истекла={lock.cart_lock_expires_at}" + ) + + # Очищаем блокировки + expired_locks.update( + cart_lock_expires_at=None, + locked_by_user=None, + cart_session_id=None + ) + + result = { + 'released_count': released_count, + 'affected_kits': affected_kits, + 'timestamp': timezone.now().isoformat() + } + + if released_count > 0: + logger.info(f"Очистка завершена успешно: {result}") + + return result + + except Exception as e: + logger.error(f"Ошибка при очистке истекших блокировок: {str(e)}", exc_info=True) + return { + 'released_count': 0, + 'affected_kits': [], + 'error': str(e) + } diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index 386d9a8..e368028 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -414,3 +414,17 @@ CELERY_TASK_SEND_SENT_EVENT = True # Retry настройки CELERY_TASK_DEFAULT_MAX_RETRIES = 3 CELERY_TASK_DEFAULT_RETRY_DELAY = 60 # Повторить через 60 секунд при ошибке + +# Celery Beat Schedule (периодические задачи) +from celery.schedules import crontab + +CELERY_BEAT_SCHEDULE = { + # Очистка истекших блокировок корзины каждые 5 минут + 'cleanup-expired-cart-locks': { + 'task': 'inventory.tasks.cleanup_expired_cart_locks', + 'schedule': crontab(minute='*/5'), # Каждые 5 минут + 'options': { + 'expires': 240, # Задача устаревает через 4 минуты (меньше интервала) + }, + }, +} diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index 84dba53..bda4b10 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -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 = ''; - 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 = ' В корзине'; + lockBadge.title = 'Добавлен в вашу корзину'; + } else { + // Заблокирован другим кассиром - красный бейдж + блокируем карточку + lockBadge.className = 'badge bg-danger'; + lockBadge.innerHTML = ' Занят'; + 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 = ''; + 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 = ' '; } - + namePrice.innerHTML = `
${typeIcon}${item.name}
${formatMoney(item.price)} / шт
`; - + // Знак умножения 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 = ''; - 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 = ''; + 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 = ''; + 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 = ''; - 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 { diff --git a/myproject/pos/urls.py b/myproject/pos/urls.py index 079761e..f8e06eb 100644 --- a/myproject/pos/urls.py +++ b/myproject/pos/urls.py @@ -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//add-to-cart/', views.add_showcase_kit_to_cart, name='add-showcase-kit-to-cart'), + # Снять блокировку витринного комплекта при удалении из корзины [POST] + path('api/showcase-kits//remove-from-cart/', views.remove_showcase_kit_from_cart, name='remove-showcase-kit-from-cart'), # Получить детали комплекта для редактирования [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 8133f21..cdc24eb 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -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): diff --git a/ЗАПУСК.md b/ЗАПУСК.md new file mode 100644 index 0000000..425acdb --- /dev/null +++ b/ЗАПУСК.md @@ -0,0 +1,25 @@ +# Запуск системы + +## Из каталога `(venv) PS C:\Users\team_\Desktop\test_qwen\` + +### 1. Запуск Celery Worker (для фото и прочих задач) +```powershell +start_celery.bat +``` + +### 2. Запуск Celery Beat (для периодических задач) +**Новая команда - для автоматической очистки блокировок корзины каждые 5 минут:** +```powershell из myproject +celery -A myproject beat -l info +``` + +--- + +## Что делает Celery Beat? +- Автоматически освобождает витринные комплекты, если блокировка в корзине истекла (30 минут) +- Запускает задачу `cleanup_expired_cart_locks` каждые 5 минут +- Логи пишет в консоль (уровень INFO) + +## Можно ли не запускать Beat? +Да, но тогда блокировки не будут автоматически сниматься при истечении таймаута. +Кассиры все равно смогут работать, просто букет останется "занят" до ручного удаления из корзины.