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:
@@ -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(); // Отрисовываем восстановленную корзину
|
||||
}
|
||||
});
|
||||
|
||||
// Смена склада
|
||||
|
||||
Reference in New Issue
Block a user