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 = `