Добавлена система Soft Lock для витринных комплектов в POS-терминале
Реализована элегантная блокировка витринных букетов при добавлении в корзину, предотвращающая многократную продажу одного физического комплекта. ## Изменения в БД: - Добавлены поля в Reservation: cart_lock_expires_at, locked_by_user, cart_session_id - Созданы индексы для оптимизации запросов блокировок - Миграция 0006: добавление полей Soft Lock ## Backend (pos/views.py): - add_showcase_kit_to_cart: создание блокировки на 30 минут с проверкой конфликтов - remove_showcase_kit_from_cart: снятие блокировки при удалении из корзины - get_showcase_kits_api: возврат статусов блокировок (is_locked, locked_by_me) ## Frontend (terminal.js): - addToCart: AJAX запрос для создания блокировки, запрет qty > 1 - removeFromCart: автоматическое снятие блокировки - renderCart: желтый фон, badge "1 шт (витрина)", скрыты кнопки +/− - UI индикация: зеленый badge "В корзине" (свой), красный "Занят" (чужой) ## Автоматизация (inventory/tasks.py): - cleanup_expired_cart_locks: Celery periodic task (каждые 5 минут) - Автоматическое освобождение истекших блокировок (30 минут timeout) - Логирование очистки для мониторинга ## Маршруты (pos/urls.py): - POST /api/showcase-kits/<id>/add-to-cart/ - создание блокировки - POST /api/showcase-kits/<id>/remove-from-cart/ - снятие блокировки ## Документация: - ЗАПУСК.md: инструкция по запуску Celery Beat Преимущества: ✓ Предотвращает конфликты между кассирами ✓ Автоматическое освобождение при таймауте ✓ Понятный UX с визуальной индикацией ✓ Совместимость с существующей логикой резервирования 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -430,18 +430,53 @@ function renderProducts() {
|
||||
|
||||
// Если это витринный комплект - добавляем кнопку редактирования
|
||||
if (item.type === 'showcase_kit') {
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-sm btn-outline-primary';
|
||||
editBtn.style.position = 'absolute';
|
||||
editBtn.style.top = '5px';
|
||||
editBtn.style.right = '5px';
|
||||
editBtn.style.zIndex = '10';
|
||||
editBtn.innerHTML = '<i class="bi bi-pencil"></i>';
|
||||
editBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
openEditKitModal(item.id);
|
||||
};
|
||||
card.appendChild(editBtn);
|
||||
// ИНДИКАЦИЯ БЛОКИРОВКИ
|
||||
if (item.is_locked) {
|
||||
// Создаем бейдж блокировки
|
||||
const lockBadge = document.createElement('div');
|
||||
lockBadge.style.position = 'absolute';
|
||||
lockBadge.style.top = '5px';
|
||||
lockBadge.style.left = '5px';
|
||||
lockBadge.style.zIndex = '10';
|
||||
|
||||
if (item.locked_by_me) {
|
||||
// Заблокирован мной - зеленый бейдж
|
||||
lockBadge.className = 'badge bg-success';
|
||||
lockBadge.innerHTML = '<i class="bi bi-cart-check"></i> В корзине';
|
||||
lockBadge.title = 'Добавлен в вашу корзину';
|
||||
} else {
|
||||
// Заблокирован другим кассиром - красный бейдж + блокируем карточку
|
||||
lockBadge.className = 'badge bg-danger';
|
||||
lockBadge.innerHTML = '<i class="bi bi-lock-fill"></i> Занят';
|
||||
lockBadge.title = `В корзине ${item.locked_by_user}`;
|
||||
|
||||
// Затемняем карточку и блокируем клики
|
||||
card.style.opacity = '0.5';
|
||||
card.style.cursor = 'not-allowed';
|
||||
card.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
alert(`Этот букет уже в корзине кассира "${item.locked_by_user}".\nДождитесь освобождения блокировки.`);
|
||||
};
|
||||
}
|
||||
|
||||
card.appendChild(lockBadge);
|
||||
}
|
||||
|
||||
// Кнопка редактирования (только если НЕ заблокирован другим)
|
||||
if (!item.is_locked || item.locked_by_me) {
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-sm btn-outline-primary';
|
||||
editBtn.style.position = 'absolute';
|
||||
editBtn.style.top = '5px';
|
||||
editBtn.style.right = '5px';
|
||||
editBtn.style.zIndex = '10';
|
||||
editBtn.innerHTML = '<i class="bi bi-pencil"></i>';
|
||||
editBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
openEditKitModal(item.id);
|
||||
};
|
||||
card.appendChild(editBtn);
|
||||
}
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
@@ -620,28 +655,80 @@ function setupInfiniteScroll() {
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
|
||||
function addToCart(item) {
|
||||
async 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 });
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock)
|
||||
if (item.type === 'showcase_kit') {
|
||||
// Проверяем: не заблокирован ли уже этим пользователем
|
||||
if (cart.has(cartKey)) {
|
||||
alert('Этот букет уже в вашей корзине.\nВитринные комплекты доступны только в количестве 1 шт.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Пытаемся создать блокировку через API
|
||||
try {
|
||||
const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
// Конфликт - комплект занят другим кассиром
|
||||
alert(data.error || 'Не удалось добавить букет в корзину');
|
||||
return;
|
||||
}
|
||||
|
||||
// Успешно заблокировали - добавляем в корзину с qty=1 и флагом max_qty
|
||||
cart.set(cartKey, {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
price: Number(item.price),
|
||||
qty: 1,
|
||||
type: item.type,
|
||||
max_qty: 1, // Флаг: нельзя увеличить количество
|
||||
lock_expires_at: data.lock_expires_at // Время истечения блокировки
|
||||
});
|
||||
|
||||
// Обновляем список витрины (чтобы показать блокировку)
|
||||
if (isShowcaseView) {
|
||||
await loadShowcaseKits();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка при добавлении витринного комплекта:', error);
|
||||
alert('Ошибка сервера. Попробуйте еще раз.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
cart.get(cartKey).qty += 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 (item.type !== 'showcase_kit') {
|
||||
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(); // Выделяем весь текст
|
||||
}
|
||||
}, 50);
|
||||
if (itemIndex !== -1 && qtyInputs[itemIndex]) {
|
||||
qtyInputs[itemIndex].focus();
|
||||
qtyInputs[itemIndex].select(); // Выделяем весь текст
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCart() {
|
||||
@@ -658,22 +745,31 @@ function renderCart() {
|
||||
cart.forEach((item, cartKey) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cart-item mb-2';
|
||||
|
||||
|
||||
// СПЕЦИАЛЬНАЯ СТИЛИЗАЦИЯ для витринных комплектов
|
||||
const isShowcaseKit = item.type === 'showcase_kit';
|
||||
if (isShowcaseKit) {
|
||||
row.style.backgroundColor = '#fff3cd'; // Желтый фон
|
||||
row.style.border = '1px solid #ffc107';
|
||||
row.style.borderRadius = '4px';
|
||||
row.style.padding = '8px';
|
||||
}
|
||||
|
||||
// Левая часть: Название и цена единицы
|
||||
const namePrice = document.createElement('div');
|
||||
namePrice.className = 'item-name-price';
|
||||
|
||||
|
||||
// Иконка только для комплектов
|
||||
let typeIcon = '';
|
||||
if (item.type === 'kit' || item.type === 'showcase_kit') {
|
||||
typeIcon = '<i class="bi bi-box-seam text-info" title="Комплект"></i> ';
|
||||
}
|
||||
|
||||
|
||||
namePrice.innerHTML = `
|
||||
<div class="fw-semibold small">${typeIcon}${item.name}</div>
|
||||
<div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / шт</div>
|
||||
`;
|
||||
|
||||
|
||||
// Знак умножения
|
||||
const multiplySign = document.createElement('span');
|
||||
multiplySign.className = 'multiply-sign';
|
||||
@@ -684,57 +780,69 @@ function renderCart() {
|
||||
qtyControl.className = 'd-flex align-items-center';
|
||||
qtyControl.style.gap = '2px';
|
||||
|
||||
// Кнопка минус
|
||||
const minusBtn = document.createElement('button');
|
||||
minusBtn.className = 'btn btn-outline-secondary btn-sm';
|
||||
minusBtn.innerHTML = '<i class="bi bi-dash-circle" style="font-size: 1.2em;"></i>';
|
||||
minusBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
const currentQty = cart.get(cartKey).qty;
|
||||
if (currentQty <= 1) {
|
||||
removeFromCart(cartKey);
|
||||
} else {
|
||||
cart.get(cartKey).qty = currentQty - 1;
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов (только badge, без кнопок)
|
||||
if (isShowcaseKit) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge bg-warning text-dark';
|
||||
badge.textContent = '1 шт (витрина)';
|
||||
badge.style.fontSize = '0.85rem';
|
||||
badge.style.padding = '0.5rem 0.75rem';
|
||||
qtyControl.appendChild(badge);
|
||||
} else {
|
||||
// ОБЫЧНАЯ ЛОГИКА для товаров и комплектов
|
||||
|
||||
// Кнопка минус
|
||||
const minusBtn = document.createElement('button');
|
||||
minusBtn.className = 'btn btn-outline-secondary btn-sm';
|
||||
minusBtn.innerHTML = '<i class="bi bi-dash-circle" style="font-size: 1.2em;"></i>';
|
||||
minusBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
const currentQty = cart.get(cartKey).qty;
|
||||
if (currentQty <= 1) {
|
||||
removeFromCart(cartKey);
|
||||
} else {
|
||||
cart.get(cartKey).qty = currentQty - 1;
|
||||
renderCart();
|
||||
saveCartToRedis();
|
||||
}
|
||||
};
|
||||
|
||||
// Поле ввода количества
|
||||
const qtyInput = document.createElement('input');
|
||||
qtyInput.type = 'number';
|
||||
qtyInput.className = 'qty-input form-control form-control-sm';
|
||||
qtyInput.style.width = '60px';
|
||||
qtyInput.style.textAlign = 'center';
|
||||
qtyInput.style.padding = '0.375rem 0.25rem';
|
||||
qtyInput.value = item.qty;
|
||||
qtyInput.min = 1;
|
||||
qtyInput.onchange = (e) => {
|
||||
const newQty = parseInt(e.target.value) || 1;
|
||||
if (newQty <= 0) {
|
||||
removeFromCart(cartKey);
|
||||
} else {
|
||||
cart.get(cartKey).qty = newQty;
|
||||
renderCart();
|
||||
saveCartToRedis(); // Сохраняем в Redis при изменении количества
|
||||
}
|
||||
};
|
||||
|
||||
// Кнопка плюс
|
||||
const plusBtn = document.createElement('button');
|
||||
plusBtn.className = 'btn btn-outline-secondary btn-sm';
|
||||
plusBtn.innerHTML = '<i class="bi bi-plus-circle" style="font-size: 1.2em;"></i>';
|
||||
plusBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
cart.get(cartKey).qty += 1;
|
||||
renderCart();
|
||||
saveCartToRedis();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Поле ввода количества
|
||||
const qtyInput = document.createElement('input');
|
||||
qtyInput.type = 'number';
|
||||
qtyInput.className = 'qty-input form-control form-control-sm';
|
||||
qtyInput.style.width = '60px';
|
||||
qtyInput.style.textAlign = 'center';
|
||||
qtyInput.style.padding = '0.375rem 0.25rem';
|
||||
qtyInput.value = item.qty;
|
||||
qtyInput.min = 1;
|
||||
qtyInput.onchange = (e) => {
|
||||
const newQty = parseInt(e.target.value) || 1;
|
||||
if (newQty <= 0) {
|
||||
removeFromCart(cartKey);
|
||||
} else {
|
||||
cart.get(cartKey).qty = newQty;
|
||||
renderCart();
|
||||
saveCartToRedis(); // Сохраняем в Redis при изменении количества
|
||||
}
|
||||
};
|
||||
|
||||
// Кнопка плюс
|
||||
const plusBtn = document.createElement('button');
|
||||
plusBtn.className = 'btn btn-outline-secondary btn-sm';
|
||||
plusBtn.innerHTML = '<i class="bi bi-plus-circle" style="font-size: 1.2em;"></i>';
|
||||
plusBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
cart.get(cartKey).qty += 1;
|
||||
renderCart();
|
||||
saveCartToRedis();
|
||||
};
|
||||
|
||||
// Собираем контейнер
|
||||
qtyControl.appendChild(minusBtn);
|
||||
qtyControl.appendChild(qtyInput);
|
||||
qtyControl.appendChild(plusBtn);
|
||||
// Собираем контейнер
|
||||
qtyControl.appendChild(minusBtn);
|
||||
qtyControl.appendChild(qtyInput);
|
||||
qtyControl.appendChild(plusBtn);
|
||||
}
|
||||
|
||||
// Сумма за позицию
|
||||
const itemTotal = document.createElement('div');
|
||||
@@ -761,7 +869,38 @@ function renderCart() {
|
||||
document.getElementById('cartTotal').textContent = formatMoney(total);
|
||||
}
|
||||
|
||||
function removeFromCart(cartKey) {
|
||||
async function removeFromCart(cartKey) {
|
||||
const item = cart.get(cartKey);
|
||||
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов - снимаем блокировку
|
||||
if (item && item.type === 'showcase_kit') {
|
||||
try {
|
||||
const response = await fetch(`/pos/api/showcase-kits/${item.id}/remove-from-cart/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Ошибка при снятии блокировки:', data.error);
|
||||
// Продолжаем удаление из корзины даже при ошибке
|
||||
}
|
||||
|
||||
// Обновляем список витрины (чтобы убрать индикацию блокировки)
|
||||
if (isShowcaseView) {
|
||||
await loadShowcaseKits();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка при снятии блокировки витринного комплекта:', error);
|
||||
// Продолжаем удаление из корзины
|
||||
}
|
||||
}
|
||||
|
||||
cart.delete(cartKey);
|
||||
renderCart();
|
||||
saveCartToRedis(); // Сохраняем в Redis
|
||||
@@ -909,13 +1048,17 @@ async function openEditKitModal(kitId) {
|
||||
}
|
||||
|
||||
// Обновление списка витринных комплектов
|
||||
async function refreshShowcaseKits() {
|
||||
async function loadShowcaseKits() {
|
||||
try {
|
||||
const response = await fetch('/pos/api/showcase-kits/');
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
showcaseKits = data.items;
|
||||
// Перерисовываем грид если мы в режиме витрины
|
||||
if (isShowcaseView) {
|
||||
renderProducts();
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to refresh showcase kits:', data);
|
||||
}
|
||||
@@ -924,6 +1067,9 @@ async function refreshShowcaseKits() {
|
||||
}
|
||||
}
|
||||
|
||||
// Алиас для совместимости
|
||||
const refreshShowcaseKits = loadShowcaseKits;
|
||||
|
||||
// Загрузка списка витрин
|
||||
async function loadShowcases() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user