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'
|
||||
|
||||
|
||||
# ============================================
|
||||
# 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
|
||||
# ============================================
|
||||
|
||||
@@ -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(); // Отрисовываем восстановленную корзину
|
||||
}
|
||||
});
|
||||
|
||||
// Смена склада
|
||||
|
||||
@@ -56,13 +56,22 @@
|
||||
<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">
|
||||
<h6 class="mb-0">Корзина</h6>
|
||||
<button class="btn btn-outline-primary btn-sm" id="customerSelectBtn">
|
||||
<i class="bi bi-person"></i> Выбрать клиента
|
||||
</button>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button class="btn btn-outline-primary btn-sm d-flex align-items-center" id="customerSelectBtn">
|
||||
<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 class="card-body d-flex flex-column" style="min-height: 0;">
|
||||
<div id="cartList" class="flex-grow-1" style="overflow-y: auto;"></div>
|
||||
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="d-flex justify-content-between align-items-center py-1 border-top">
|
||||
<strong class="mb-0">Итого:</strong>
|
||||
@@ -266,13 +275,21 @@
|
||||
<div class="row">
|
||||
<!-- Левая колонка: состав заказа -->
|
||||
<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">
|
||||
<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 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Опции оплаты и комментарий -->
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
@@ -372,8 +389,8 @@
|
||||
<div class="modal-body">
|
||||
<div class="list-group" id="warehouseList">
|
||||
{% for warehouse in warehouses %}
|
||||
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center warehouse-item"
|
||||
data-warehouse-id="{{ warehouse.id }}"
|
||||
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center warehouse-item"
|
||||
data-warehouse-id="{{ warehouse.id }}"
|
||||
data-warehouse-name="{{ warehouse.name }}">
|
||||
<div>
|
||||
<strong>{{ warehouse.name }}</strong>
|
||||
@@ -394,6 +411,77 @@
|
||||
</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 %}
|
||||
|
||||
{% block extra_js %}
|
||||
@@ -401,6 +489,19 @@
|
||||
<script id="categoriesData" type="application/json">{{ categories_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="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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,6 +9,10 @@ urlpatterns = [
|
||||
path('', views.pos_terminal, name='terminal'),
|
||||
# Установить текущий склад для POS (сохранение в сессии) [POST]
|
||||
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]
|
||||
path('api/items/', views.get_items_api, name='items-api'),
|
||||
# Получить список активных витрин [GET]
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user