diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index 4c65af0..386d9a8 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -363,6 +363,20 @@ TENANT_ADMIN_NAME = env('TENANT_ADMIN_NAME') DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# ============================================ +# CACHE CONFIGURATION (Redis) +# ============================================ + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': f'redis://{env("REDIS_HOST", default="localhost")}:{env("REDIS_PORT", default="6379")}/{env("REDIS_DB", default="0")}', + 'KEY_PREFIX': 'myproject', # Префикс для всех ключей + 'TIMEOUT': 300, # Таймаут по умолчанию (5 минут) + } +} + + # ============================================ # CELERY CONFIGURATION # ============================================ diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index 69a1efe..39905de 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -22,10 +22,297 @@ let editingKitId = null; // Временная корзина для модального окна создания/редактирования комплекта const tempCart = new Map(); +// ===== СОХРАНЕНИЕ КОРЗИНЫ В REDIS ===== + +let saveCartTimeout = null; + +/** + * Сохраняет корзину в Redis с debounce 500ms + */ +function saveCartToRedis() { + // Отменяем предыдущий таймер + if (saveCartTimeout) { + clearTimeout(saveCartTimeout); + } + + // Устанавливаем новый таймер + saveCartTimeout = setTimeout(() => { + // Конвертируем Map в обычный объект + const cartObj = {}; + cart.forEach((value, key) => { + cartObj[key] = value; + }); + + // Отправляем на сервер + fetch('/pos/api/save-cart/', { + method: 'POST', + headers: { + 'X-CSRFToken': getCsrfToken(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ cart: cartObj }) + }) + .then(response => response.json()) + .then(data => { + if (!data.success) { + console.error('Ошибка сохранения корзины:', data.error); + } + }) + .catch(error => { + console.error('Ошибка при сохранении корзины в Redis:', error); + }); + }, 500); // Debounce 500ms +} + +// ===== УПРАВЛЕНИЕ КЛИЕНТОМ ===== +// Загружаем данные системного клиента +const SYSTEM_CUSTOMER = JSON.parse(document.getElementById('systemCustomerData').textContent); + +// Текущий выбранный клиент (загружается из Redis или системный) +let selectedCustomer = JSON.parse(document.getElementById('selectedCustomerData').textContent); + function formatMoney(v) { return (Number(v)).toFixed(2); } +// ===== ФУНКЦИИ ДЛЯ РАБОТЫ С КЛИЕНТОМ ===== + +/** + * Обновляет отображение выбранного клиента в UI + * Обновляет: + * - Кнопку "Выбрать клиента" в корзине (показывает имя клиента) + * - Имя клиента в модалке продажи + * - Видимость кнопки сброса (показываем только для не-системного клиента) + */ +function updateCustomerDisplay() { + // Обновляем текст кнопки - всегда показываем имя клиента + const btnText = document.getElementById('customerSelectBtnText'); + btnText.textContent = selectedCustomer.name; + + // Обновляем имя клиента в модалке продажи + const checkoutCustomerName = document.getElementById('checkoutCustomerName'); + if (checkoutCustomerName) { + checkoutCustomerName.textContent = selectedCustomer.name; + } + + // Показываем/скрываем кнопку сброса + const resetBtn = document.getElementById('resetCustomerBtn'); + if (resetBtn) { + // Показываем кнопку сброса только если выбран НЕ системный клиент + if (selectedCustomer.id !== SYSTEM_CUSTOMER.id) { + resetBtn.style.display = 'block'; + } else { + resetBtn.style.display = 'none'; + } + } +} + +/** + * Устанавливает нового клиента и сохраняет в Redis + * @param {number} customerId - ID клиента + * @param {string} customerName - Имя клиента + */ +function selectCustomer(customerId, customerName) { + selectedCustomer = { + id: customerId, + name: customerName + }; + updateCustomerDisplay(); + + // Сохраняем выбор в Redis через AJAX + fetch(`/pos/api/set-customer/${customerId}/`, { + method: 'POST', + headers: { + 'X-CSRFToken': getCsrfToken(), + 'Content-Type': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + if (!data.success) { + console.error('Ошибка сохранения клиента:', data.error); + } + }) + .catch(error => { + console.error('Ошибка при сохранении клиента в Redis:', error); + }); +} + +/** + * Инициализация Select2 для поиска клиента + */ +function initCustomerSelect2() { + const $searchInput = $('#customerSearchInput'); + + $searchInput.select2({ + theme: 'bootstrap-5', + dropdownParent: $('#selectCustomerModal'), + placeholder: 'Начните вводить имя, телефон или email (минимум 3 символа)', + minimumInputLength: 3, + allowClear: true, + ajax: { + url: '/customers/api/search/', + dataType: 'json', + delay: 300, + data: function(params) { + return { + q: params.term + }; + }, + processResults: function(data) { + return { + results: data.results + }; + }, + cache: true + }, + templateResult: formatCustomerOption, // Форматирование результатов в выпадающем списке + templateSelection: formatCustomerSelection // Форматирование выбранного значения + }); + + // Обработка выбора клиента из списка + $searchInput.on('select2:select', function(e) { + const data = e.params.data; + + // Проверяем это не опция "Создать нового клиента" + if (data.id === 'create_new') { + // Открываем модалку создания + const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal')); + modal.hide(); + openCreateCustomerModal(data.text); + return; + } + + // Выбираем клиента + selectCustomer(parseInt(data.id), data.name); + + // Закрываем модалку + const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal')); + modal.hide(); + + // Очищаем Select2 + $searchInput.val(null).trigger('change'); + }); +} + +/** + * Форматирование опции клиента в выпадающем списке Select2 + * Показывает: Имя, телефон, email в одну строку + */ +function formatCustomerOption(customer) { + if (customer.loading) { + return customer.text; + } + + // Если это опция "Создать нового клиента" + if (customer.id === 'create_new') { + return $(' ' + customer.text + ''); + } + + // Формируем текст в одну строку: Имя (жирным) + контакты (мелким) + const parts = []; + + // Имя + const name = customer.name || customer.text; + parts.push('' + $('
').text(name).html() + ''); + + // Телефон и Email + const contactInfo = []; + if (customer.phone) { + contactInfo.push($('
').text(customer.phone).html()); + } + if (customer.email) { + contactInfo.push($('
').text(customer.email).html()); + } + + if (contactInfo.length > 0) { + parts.push(' (' + contactInfo.join(', ') + ')'); + } + + return $('' + parts.join('') + ''); +} + +/** + * Форматирование выбранного клиента в поле Select2 + * Показывает только имя + */ +function formatCustomerSelection(customer) { + return customer.name || customer.text; +} + +/** + * Открывает модальное окно создания нового клиента + * @param {string} prefillName - Предзаполненное имя (из поиска) + */ +function openCreateCustomerModal(prefillName = '') { + const modal = new bootstrap.Modal(document.getElementById('createCustomerModal')); + + // Очищаем форму + document.getElementById('newCustomerName').value = prefillName || ''; + document.getElementById('newCustomerPhone').value = ''; + document.getElementById('newCustomerEmail').value = ''; + document.getElementById('createCustomerError').classList.add('d-none'); + + modal.show(); +} + +/** + * Создаёт нового клиента через API + */ +async function createNewCustomer() { + const name = document.getElementById('newCustomerName').value.trim(); + const phone = document.getElementById('newCustomerPhone').value.trim(); + const email = document.getElementById('newCustomerEmail').value.trim(); + const errorBlock = document.getElementById('createCustomerError'); + + // Валидация + if (!name) { + errorBlock.textContent = 'Укажите имя клиента'; + errorBlock.classList.remove('d-none'); + return; + } + + // Скрываем ошибку + errorBlock.classList.add('d-none'); + + try { + const response = await fetch('/customers/api/create/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ + name: name, + phone: phone || null, + email: email || null + }) + }); + + const data = await response.json(); + + if (data.success) { + // Выбираем созданного клиента + selectCustomer(data.id, data.name); + + // Закрываем модалку + const modal = bootstrap.Modal.getInstance(document.getElementById('createCustomerModal')); + modal.hide(); + + // Показываем уведомление + alert(`Клиент "${data.name}" успешно создан!`); + } else { + // Показываем ошибку + errorBlock.textContent = data.error || 'Ошибка при создании клиента'; + errorBlock.classList.remove('d-none'); + } + } catch (error) { + console.error('Error creating customer:', error); + errorBlock.textContent = 'Ошибка сети при создании клиента'; + errorBlock.classList.remove('d-none'); + } +} + function renderCategories() { const grid = document.getElementById('categoryGrid'); grid.innerHTML = ''; @@ -334,20 +621,21 @@ function setupInfiniteScroll() { 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 }); } 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 (itemIndex !== -1 && qtyInputs[itemIndex]) { qtyInputs[itemIndex].focus(); qtyInputs[itemIndex].select(); // Выделяем весь текст @@ -403,6 +691,7 @@ function renderCart() { } else { cart.get(cartKey).qty = newQty; renderCart(); + saveCartToRedis(); // Сохраняем в Redis при изменении количества } }; @@ -434,11 +723,13 @@ function renderCart() { function removeFromCart(cartKey) { cart.delete(cartKey); renderCart(); + saveCartToRedis(); // Сохраняем в Redis } function clearCart() { cart.clear(); renderCart(); + saveCartToRedis(); // Сохраняем пустую корзину в Redis } document.getElementById('clearCart').onclick = clearCart; @@ -954,7 +1245,7 @@ function renderCheckoutModal() { cart.forEach((item) => { const row = document.createElement('div'); row.className = 'd-flex justify-content-between align-items-center mb-2 pb-2 border-bottom'; - + // Иконка для комплектов let typeIcon = ''; if (item.type === 'kit' || item.type === 'showcase_kit') { @@ -962,7 +1253,7 @@ function renderCheckoutModal() { } else { typeIcon = ''; } - + row.innerHTML = `
${typeIcon}${item.name}
@@ -974,6 +1265,9 @@ function renderCheckoutModal() { total += item.qty * item.price; }); + // Обновляем информацию о клиенте + updateCustomerDisplay(); + // Обновляем базовую цену и пересчитываем updateCheckoutPricing(total); } @@ -1094,9 +1388,57 @@ document.getElementById('scheduleLater').onclick = async () => { alert('Функционал будет подключен позже: создание заказа на доставку/самовывоз.'); }; -// Customer selection +// ===== ОБРАБОТЧИКИ ДЛЯ РАБОТЫ С КЛИЕНТОМ ===== + +// Кнопка "Выбрать клиента" в корзине document.getElementById('customerSelectBtn').addEventListener('click', () => { - alert('Функция выбора клиента будет реализована позже'); + const modal = new bootstrap.Modal(document.getElementById('selectCustomerModal')); + modal.show(); +}); + +// Кнопка сброса клиента на системного +document.getElementById('resetCustomerBtn').addEventListener('click', () => { + selectCustomer(SYSTEM_CUSTOMER.id, SYSTEM_CUSTOMER.name); +}); + +// Кнопка "Создать нового клиента" в модалке выбора +document.getElementById('createNewCustomerBtn').addEventListener('click', () => { + // Закрываем модалку выбора + const selectModal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal')); + selectModal.hide(); + + // Открываем модалку создания + openCreateCustomerModal(); +}); + +// Кнопка "Выбрать системного клиента" +document.getElementById('selectSystemCustomerBtn').addEventListener('click', () => { + selectCustomer(SYSTEM_CUSTOMER.id, SYSTEM_CUSTOMER.name); + + // Закрываем модалку + const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal')); + modal.hide(); +}); + +// Кнопка подтверждения создания клиента +document.getElementById('confirmCreateCustomerBtn').addEventListener('click', () => { + createNewCustomer(); +}); + +// Инициализация Select2 при загрузке страницы +document.addEventListener('DOMContentLoaded', () => { + initCustomerSelect2(); + updateCustomerDisplay(); // Обновляем UI с системным клиентом + + // Восстанавливаем корзину из Redis (если есть сохраненные данные) + const savedCartData = JSON.parse(document.getElementById('cartData').textContent); + if (savedCartData && Object.keys(savedCartData).length > 0) { + // Конвертируем обычный объект обратно в Map + Object.entries(savedCartData).forEach(([key, value]) => { + cart.set(key, value); + }); + renderCart(); // Отрисовываем восстановленную корзину + } }); // Смена склада diff --git a/myproject/pos/templates/pos/terminal.html b/myproject/pos/templates/pos/terminal.html index cebf626..deb234a 100644 --- a/myproject/pos/templates/pos/terminal.html +++ b/myproject/pos/templates/pos/terminal.html @@ -56,13 +56,22 @@
Корзина
- +
+ + +
- +
Итого: @@ -266,13 +275,21 @@
+ +
+ Клиент +
+
+
+
+
Состав заказа -
+
- +
@@ -372,8 +389,8 @@ + + + + + + {% endblock %} {% block extra_js %} @@ -401,6 +489,19 @@ + + + {% endblock %} diff --git a/myproject/pos/urls.py b/myproject/pos/urls.py index 76971b4..491475a 100644 --- a/myproject/pos/urls.py +++ b/myproject/pos/urls.py @@ -9,6 +9,10 @@ urlpatterns = [ path('', views.pos_terminal, name='terminal'), # Установить текущий склад для POS (сохранение в сессии) [POST] path('api/set-warehouse//', views.set_warehouse, name='set-warehouse'), + # Установить текущего клиента для POS (сохранение в Redis с TTL 2 часа) [POST] + path('api/set-customer//', views.set_customer, name='set-customer'), + # Сохранить корзину POS (сохранение в Redis с TTL 2 часа) [POST] + path('api/save-cart/', views.save_cart, name='save-cart'), # Получить товары и комплекты (пагинация, поиск, сортировка) [GET] path('api/items/', views.get_items_api, name='items-api'), # Получить список активных витрин [GET] diff --git a/myproject/pos/views.py b/myproject/pos/views.py index 545294e..04c5b13 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -158,9 +158,12 @@ def pos_terminal(request): Товары загружаются прогрессивно через API при клике на категорию. Работает только с одним выбранным складом. """ + from customers.models import Customer + from django.core.cache import cache + # Получаем текущий склад для POS current_warehouse = get_pos_warehouse(request) - + if not current_warehouse: # Нет активных складов - показываем ошибку from django.contrib import messages @@ -174,11 +177,61 @@ def pos_terminal(request): 'title': 'POS Terminal', } return render(request, 'pos/terminal.html', context) - + + # Получаем или создаём системного клиента + system_customer, _ = Customer.get_or_create_system_customer() + + # Пытаемся получить сохраненного клиента из Redis + selected_customer = None + redis_key = f'pos:customer:{request.user.id}:{current_warehouse.id}' + cached_customer_data = cache.get(redis_key) + + if cached_customer_data: + # Проверяем что клиент еще существует в БД + try: + customer = Customer.objects.get(id=cached_customer_data['customer_id']) + selected_customer = { + 'id': customer.id, + 'name': customer.name + } + except Customer.DoesNotExist: + # Клиент был удален - очищаем кэш + cache.delete(redis_key) + + # Если нет сохраненного клиента - используем системного + if not selected_customer: + selected_customer = { + 'id': system_customer.id, + 'name': system_customer.name + } + + # Пытаемся получить сохраненную корзину из Redis + cart_redis_key = f'pos:cart:{request.user.id}:{current_warehouse.id}' + cached_cart_data = cache.get(cart_redis_key) + cart_data = {} + + if cached_cart_data: + # Валидируем товары и комплекты в корзине + from products.models import Product, ProductKit + + for cart_key, item in cached_cart_data.items(): + try: + if item['type'] == 'product': + # Проверяем что товар существует + Product.objects.get(id=item['id']) + cart_data[cart_key] = item + elif item['type'] in ('kit', 'showcase_kit'): + # Проверяем что комплект существует + ProductKit.objects.get(id=item['id']) + cart_data[cart_key] = item + except (Product.DoesNotExist, ProductKit.DoesNotExist): + # Товар или комплект удален - пропускаем + continue + # Загружаем только категории categories_qs = ProductCategory.objects.filter(is_active=True) categories = [{'id': c.id, 'name': c.name} for c in categories_qs] - + # Список всех активных складов для модалки выбора warehouses = Warehouse.objects.filter(is_active=True).order_by('-is_default', 'name') warehouses_list = [{ @@ -196,11 +249,92 @@ def pos_terminal(request): 'name': current_warehouse.name }, 'warehouses': warehouses_list, + 'system_customer': { + 'id': system_customer.id, + 'name': system_customer.name + }, + 'selected_customer': selected_customer, # Текущий выбранный клиент (из Redis или системный) + 'cart_data': json.dumps(cart_data), # Сохраненная корзина из Redis 'title': 'POS Terminal', } return render(request, 'pos/terminal.html', context) +@login_required +@require_http_methods(["POST"]) +def save_cart(request): + """ + Сохранить корзину POS в Redis для текущего пользователя и склада. + TTL: 2 часа (7200 секунд) + """ + from django.core.cache import cache + import json + + # Получаем текущий склад + current_warehouse = get_pos_warehouse(request) + if not current_warehouse: + return JsonResponse({'success': False, 'error': 'Не выбран активный склад'}, status=400) + + try: + # Получаем данные корзины из тела запроса + body = json.loads(request.body) + cart_data = body.get('cart', {}) + + # Валидация структуры данных корзины + if not isinstance(cart_data, dict): + return JsonResponse({'success': False, 'error': 'Неверный формат данных корзины'}, status=400) + + # Сохраняем в Redis + redis_key = f'pos:cart:{request.user.id}:{current_warehouse.id}' + cache.set(redis_key, cart_data, timeout=7200) # 2 часа + + return JsonResponse({ + 'success': True, + 'items_count': len(cart_data) + }) + + except json.JSONDecodeError: + return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}, status=500) + + +@login_required +@require_http_methods(["POST"]) +def set_customer(request, customer_id): + """ + Сохранить выбранного клиента в Redis для текущего пользователя и склада. + TTL: 2 часа (7200 секунд) + """ + from customers.models import Customer + from django.core.cache import cache + + # Получаем текущий склад + current_warehouse = get_pos_warehouse(request) + if not current_warehouse: + return JsonResponse({'success': False, 'error': 'Не выбран активный склад'}, status=400) + + # Проверяем, что клиент существует + try: + customer = Customer.objects.get(id=customer_id) + except Customer.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Клиент не найден'}, status=404) + + # Сохраняем в Redis + redis_key = f'pos:customer:{request.user.id}:{current_warehouse.id}' + customer_data = { + 'customer_id': customer.id, + 'customer_name': customer.name + } + cache.set(redis_key, customer_data, timeout=7200) # 2 часа + + return JsonResponse({ + 'success': True, + 'customer_id': customer.id, + 'customer_name': customer.name + }) + + @login_required @require_http_methods(["POST"]) def set_warehouse(request, warehouse_id):