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:
@@ -363,6 +363,20 @@ TENANT_ADMIN_NAME = env('TENANT_ADMIN_NAME')
|
|||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
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
|
# CELERY CONFIGURATION
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -22,10 +22,297 @@ let editingKitId = null;
|
|||||||
// Временная корзина для модального окна создания/редактирования комплекта
|
// Временная корзина для модального окна создания/редактирования комплекта
|
||||||
const tempCart = new Map();
|
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) {
|
function formatMoney(v) {
|
||||||
return (Number(v)).toFixed(2);
|
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() {
|
function renderCategories() {
|
||||||
const grid = document.getElementById('categoryGrid');
|
const grid = document.getElementById('categoryGrid');
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
@@ -342,6 +629,7 @@ function addToCart(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderCart();
|
renderCart();
|
||||||
|
saveCartToRedis(); // Сохраняем в Redis
|
||||||
|
|
||||||
// Автоматический фокус на поле количества
|
// Автоматический фокус на поле количества
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -403,6 +691,7 @@ function renderCart() {
|
|||||||
} else {
|
} else {
|
||||||
cart.get(cartKey).qty = newQty;
|
cart.get(cartKey).qty = newQty;
|
||||||
renderCart();
|
renderCart();
|
||||||
|
saveCartToRedis(); // Сохраняем в Redis при изменении количества
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -434,11 +723,13 @@ function renderCart() {
|
|||||||
function removeFromCart(cartKey) {
|
function removeFromCart(cartKey) {
|
||||||
cart.delete(cartKey);
|
cart.delete(cartKey);
|
||||||
renderCart();
|
renderCart();
|
||||||
|
saveCartToRedis(); // Сохраняем в Redis
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCart() {
|
function clearCart() {
|
||||||
cart.clear();
|
cart.clear();
|
||||||
renderCart();
|
renderCart();
|
||||||
|
saveCartToRedis(); // Сохраняем пустую корзину в Redis
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('clearCart').onclick = clearCart;
|
document.getElementById('clearCart').onclick = clearCart;
|
||||||
@@ -974,6 +1265,9 @@ function renderCheckoutModal() {
|
|||||||
total += item.qty * item.price;
|
total += item.qty * item.price;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Обновляем информацию о клиенте
|
||||||
|
updateCustomerDisplay();
|
||||||
|
|
||||||
// Обновляем базовую цену и пересчитываем
|
// Обновляем базовую цену и пересчитываем
|
||||||
updateCheckoutPricing(total);
|
updateCheckoutPricing(total);
|
||||||
}
|
}
|
||||||
@@ -1094,9 +1388,57 @@ document.getElementById('scheduleLater').onclick = async () => {
|
|||||||
alert('Функционал будет подключен позже: создание заказа на доставку/самовывоз.');
|
alert('Функционал будет подключен позже: создание заказа на доставку/самовывоз.');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Customer selection
|
// ===== ОБРАБОТЧИКИ ДЛЯ РАБОТЫ С КЛИЕНТОМ =====
|
||||||
|
|
||||||
|
// Кнопка "Выбрать клиента" в корзине
|
||||||
document.getElementById('customerSelectBtn').addEventListener('click', () => {
|
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(); // Отрисовываем восстановленную корзину
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Смена склада
|
// Смена склада
|
||||||
|
|||||||
@@ -56,9 +56,18 @@
|
|||||||
<div class="card mb-2 flex-grow-1" style="min-height: 0;">
|
<div class="card mb-2 flex-grow-1" style="min-height: 0;">
|
||||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||||
<h6 class="mb-0">Корзина</h6>
|
<h6 class="mb-0">Корзина</h6>
|
||||||
<button class="btn btn-outline-primary btn-sm" id="customerSelectBtn">
|
<div class="d-flex gap-1 align-items-center">
|
||||||
<i class="bi bi-person"></i> Выбрать клиента
|
<button class="btn btn-outline-primary btn-sm d-flex align-items-center" id="customerSelectBtn">
|
||||||
</button>
|
<i class="bi bi-person me-1"></i>
|
||||||
|
<div class="d-flex flex-column align-items-start lh-1">
|
||||||
|
<small class="text-muted" style="font-size: 0.65rem;">Клиент</small>
|
||||||
|
<span id="customerSelectBtnText" class="fw-semibold">Выбрать</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" id="resetCustomerBtn" title="Сбросить на системного клиента" style="display: none;">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body d-flex flex-column" style="min-height: 0;">
|
<div class="card-body d-flex flex-column" style="min-height: 0;">
|
||||||
<div id="cartList" class="flex-grow-1" style="overflow-y: auto;"></div>
|
<div id="cartList" class="flex-grow-1" style="overflow-y: auto;"></div>
|
||||||
@@ -266,9 +275,17 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<!-- Левая колонка: состав заказа -->
|
<!-- Левая колонка: состав заказа -->
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
|
<!-- Информация о клиенте -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Клиент</strong>
|
||||||
|
<div class="border rounded p-2 mt-2 bg-light">
|
||||||
|
<div class="fw-bold" id="checkoutCustomerName">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<strong>Состав заказа</strong>
|
<strong>Состав заказа</strong>
|
||||||
<div class="border rounded p-3 mt-2" id="checkoutItems" style="max-height: 280px; overflow-y: auto; background: #f8f9fa;">
|
<div class="border rounded p-3 mt-2" id="checkoutItems" style="max-height: 240px; overflow-y: auto; background: #f8f9fa;">
|
||||||
<!-- Заполняется из JS -->
|
<!-- Заполняется из JS -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,6 +411,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Модалка: Выбор клиента -->
|
||||||
|
<div class="modal fade" id="selectCustomerModal" tabindex="-1" aria-labelledby="selectCustomerModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="selectCustomerModalLabel">
|
||||||
|
<i class="bi bi-person-search"></i> Выбор клиента
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="customerSearchInput" class="form-label">Поиск клиента</label>
|
||||||
|
<input type="text" class="form-select" id="customerSearchInput"
|
||||||
|
placeholder="Начните вводить имя, телефон или email (минимум 3 символа)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-success" id="createNewCustomerBtn">
|
||||||
|
<i class="bi bi-person-plus"></i> Создать нового клиента
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="selectSystemCustomerBtn">
|
||||||
|
<i class="bi bi-person"></i> Выбрать системного клиента (анонимный)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модалка: Создание нового клиента -->
|
||||||
|
<div class="modal fade" id="createCustomerModal" tabindex="-1" aria-labelledby="createCustomerModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="createCustomerModalLabel">
|
||||||
|
<i class="bi bi-person-plus"></i> Создать клиента
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="newCustomerName" class="form-label">Имя *</label>
|
||||||
|
<input type="text" class="form-control" id="newCustomerName" placeholder="Введите имя клиента" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="newCustomerPhone" class="form-label">Телефон</label>
|
||||||
|
<input type="text" class="form-control" id="newCustomerPhone" placeholder="+375XXXXXXXXX">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="newCustomerEmail" class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" id="newCustomerEmail" placeholder="email@example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="createCustomerError" class="alert alert-danger d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="confirmCreateCustomerBtn">
|
||||||
|
<i class="bi bi-check-circle"></i> Создать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@@ -401,6 +489,19 @@
|
|||||||
<script id="categoriesData" type="application/json">{{ categories_json|safe }}</script>
|
<script id="categoriesData" type="application/json">{{ categories_json|safe }}</script>
|
||||||
<script id="itemsData" type="application/json">{{ items_json|safe }}</script>
|
<script id="itemsData" type="application/json">{{ items_json|safe }}</script>
|
||||||
<script id="showcaseKitsData" type="application/json">{{ showcase_kits_json|safe }}</script>
|
<script id="showcaseKitsData" type="application/json">{{ showcase_kits_json|safe }}</script>
|
||||||
|
<script id="systemCustomerData" type="application/json">
|
||||||
|
{
|
||||||
|
"id": {{ system_customer.id }},
|
||||||
|
"name": "{{ system_customer.name|escapejs }}"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script id="selectedCustomerData" type="application/json">
|
||||||
|
{
|
||||||
|
"id": {{ selected_customer.id }},
|
||||||
|
"name": "{{ selected_customer.name|escapejs }}"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script id="cartData" type="application/json">{{ cart_data|safe }}</script>
|
||||||
|
|
||||||
<script src="{% static 'pos/js/terminal.js' %}"></script>
|
<script src="{% static 'pos/js/terminal.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ urlpatterns = [
|
|||||||
path('', views.pos_terminal, name='terminal'),
|
path('', views.pos_terminal, name='terminal'),
|
||||||
# Установить текущий склад для POS (сохранение в сессии) [POST]
|
# Установить текущий склад для POS (сохранение в сессии) [POST]
|
||||||
path('api/set-warehouse/<int:warehouse_id>/', views.set_warehouse, name='set-warehouse'),
|
path('api/set-warehouse/<int:warehouse_id>/', views.set_warehouse, name='set-warehouse'),
|
||||||
|
# Установить текущего клиента для POS (сохранение в Redis с TTL 2 часа) [POST]
|
||||||
|
path('api/set-customer/<int:customer_id>/', views.set_customer, name='set-customer'),
|
||||||
|
# Сохранить корзину POS (сохранение в Redis с TTL 2 часа) [POST]
|
||||||
|
path('api/save-cart/', views.save_cart, name='save-cart'),
|
||||||
# Получить товары и комплекты (пагинация, поиск, сортировка) [GET]
|
# Получить товары и комплекты (пагинация, поиск, сортировка) [GET]
|
||||||
path('api/items/', views.get_items_api, name='items-api'),
|
path('api/items/', views.get_items_api, name='items-api'),
|
||||||
# Получить список активных витрин [GET]
|
# Получить список активных витрин [GET]
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ def pos_terminal(request):
|
|||||||
Товары загружаются прогрессивно через API при клике на категорию.
|
Товары загружаются прогрессивно через API при клике на категорию.
|
||||||
Работает только с одним выбранным складом.
|
Работает только с одним выбранным складом.
|
||||||
"""
|
"""
|
||||||
|
from customers.models import Customer
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
# Получаем текущий склад для POS
|
# Получаем текущий склад для POS
|
||||||
current_warehouse = get_pos_warehouse(request)
|
current_warehouse = get_pos_warehouse(request)
|
||||||
|
|
||||||
@@ -175,6 +178,56 @@ def pos_terminal(request):
|
|||||||
}
|
}
|
||||||
return render(request, 'pos/terminal.html', context)
|
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_qs = ProductCategory.objects.filter(is_active=True)
|
||||||
categories = [{'id': c.id, 'name': c.name} for c in categories_qs]
|
categories = [{'id': c.id, 'name': c.name} for c in categories_qs]
|
||||||
@@ -196,11 +249,92 @@ def pos_terminal(request):
|
|||||||
'name': current_warehouse.name
|
'name': current_warehouse.name
|
||||||
},
|
},
|
||||||
'warehouses': warehouses_list,
|
'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',
|
'title': 'POS Terminal',
|
||||||
}
|
}
|
||||||
return render(request, 'pos/terminal.html', context)
|
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
|
@login_required
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def set_warehouse(request, warehouse_id):
|
def set_warehouse(request, warehouse_id):
|
||||||
|
|||||||
Reference in New Issue
Block a user