Add Redis-based persistence for POS cart and customer selection

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 <noreply@anthropic.com>
This commit is contained in:
2025-11-20 09:55:03 +03:00
parent 685c06d94d
commit eac778b06d
5 changed files with 614 additions and 19 deletions

View File

@@ -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 $('<span><i class="bi bi-person-plus"></i> ' + customer.text + '</span>');
}
// Формируем текст в одну строку: Имя (жирным) + контакты (мелким)
const parts = [];
// Имя
const name = customer.name || customer.text;
parts.push('<span class="fw-bold">' + $('<div>').text(name).html() + '</span>');
// Телефон и Email
const contactInfo = [];
if (customer.phone) {
contactInfo.push($('<div>').text(customer.phone).html());
}
if (customer.email) {
contactInfo.push($('<div>').text(customer.email).html());
}
if (contactInfo.length > 0) {
parts.push('<span class="text-muted small"> (' + contactInfo.join(', ') + ')</span>');
}
return $('<span>' + parts.join('') + '</span>');
}
/**
* Форматирование выбранного клиента в поле 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 = '<i class="bi bi-box text-success me-1" title="Товар"></i>';
}
row.innerHTML = `
<div>
<div class="fw-semibold">${typeIcon}${item.name}</div>
@@ -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(); // Отрисовываем восстановленную корзину
}
});
// Смена склада