From eac778b06ddd5210b9c4163d2f64620d852a7193 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Thu, 20 Nov 2025 09:55:03 +0300 Subject: [PATCH] Add Redis-based persistence for POS cart and customer selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented Redis caching with 2-hour TTL for POS session data: Backend changes: - Added Redis cache configuration in settings.py - Created save_cart() endpoint to persist cart state - Added cart and customer loading from Redis in pos_terminal() - Validates cart items (products/kits) still exist in DB - Added REDIS_HOST, REDIS_PORT, REDIS_DB to .env Frontend changes: - Added saveCartToRedis() with 500ms debounce - Cart auto-saves on add/remove/quantity change - Added cart initialization from Redis on page load - Enhanced customer button with two-line display and reset button - Red X button appears only for non-system customers Features: - Cart persists across page reloads (2 hour TTL) - Customer selection persists (2 hour TTL) - Independent cart per user+warehouse combination - Automatic cleanup of deleted items - Debounced saves to reduce server load πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- myproject/myproject/settings.py | 14 + myproject/pos/static/pos/js/terminal.js | 358 +++++++++++++++++++++- myproject/pos/templates/pos/terminal.html | 117 ++++++- myproject/pos/urls.py | 4 + myproject/pos/views.py | 140 ++++++++- 5 files changed, 614 insertions(+), 19 deletions(-) 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):