Добавлена система 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:
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2025-11-20 20:20
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('inventory', '0005_reservation_product_kit_and_more'),
|
||||||
|
('orders', '0003_historicalorderitem_is_from_showcase_and_more'),
|
||||||
|
('products', '0008_productkit_showcase_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='reservation',
|
||||||
|
name='cart_lock_expires_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Время истечения блокировки в корзине (для витринных комплектов)', null=True, verbose_name='Блокировка корзины истекает'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='reservation',
|
||||||
|
name='cart_session_id',
|
||||||
|
field=models.CharField(blank=True, help_text='Дополнительная идентификация сессии для надежности', max_length=100, null=True, verbose_name='ID сессии корзины'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='reservation',
|
||||||
|
name='locked_by_user',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Кассир, который добавил комплект в корзину', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cart_locks', to=settings.AUTH_USER_MODEL, verbose_name='Заблокировано пользователем'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='reservation',
|
||||||
|
index=models.Index(fields=['cart_lock_expires_at'], name='inventory_r_cart_lo_e9b52a_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='reservation',
|
||||||
|
index=models.Index(fields=['locked_by_user'], name='inventory_r_locked__706cbf_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='reservation',
|
||||||
|
index=models.Index(fields=['product_kit', 'cart_lock_expires_at'], name='inventory_r_product_5dacdf_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.conf import settings
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from products.models import Product
|
from products.models import Product
|
||||||
from phonenumber_field.modelfields import PhoneNumberField
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
@@ -428,6 +429,27 @@ class Reservation(models.Model):
|
|||||||
converted_at = models.DateTimeField(null=True, blank=True,
|
converted_at = models.DateTimeField(null=True, blank=True,
|
||||||
verbose_name="Дата преобразования в продажу")
|
verbose_name="Дата преобразования в продажу")
|
||||||
|
|
||||||
|
# Soft Lock для корзины POS (витринные комплекты)
|
||||||
|
cart_lock_expires_at = models.DateTimeField(
|
||||||
|
null=True, blank=True,
|
||||||
|
verbose_name="Блокировка корзины истекает",
|
||||||
|
help_text="Время истечения блокировки в корзине (для витринных комплектов)"
|
||||||
|
)
|
||||||
|
locked_by_user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True, blank=True,
|
||||||
|
related_name='cart_locks',
|
||||||
|
verbose_name="Заблокировано пользователем",
|
||||||
|
help_text="Кассир, который добавил комплект в корзину"
|
||||||
|
)
|
||||||
|
cart_session_id = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
null=True, blank=True,
|
||||||
|
verbose_name="ID сессии корзины",
|
||||||
|
help_text="Дополнительная идентификация сессии для надежности"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Резервирование"
|
verbose_name = "Резервирование"
|
||||||
verbose_name_plural = "Резервирования"
|
verbose_name_plural = "Резервирования"
|
||||||
@@ -438,6 +460,9 @@ class Reservation(models.Model):
|
|||||||
models.Index(fields=['order_item']),
|
models.Index(fields=['order_item']),
|
||||||
models.Index(fields=['showcase']),
|
models.Index(fields=['showcase']),
|
||||||
models.Index(fields=['product_kit']),
|
models.Index(fields=['product_kit']),
|
||||||
|
models.Index(fields=['cart_lock_expires_at']),
|
||||||
|
models.Index(fields=['locked_by_user']),
|
||||||
|
models.Index(fields=['product_kit', 'cart_lock_expires_at']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
81
myproject/inventory/tasks.py
Normal file
81
myproject/inventory/tasks.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from celery import shared_task
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db.models import Q
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def cleanup_expired_cart_locks():
|
||||||
|
"""
|
||||||
|
Периодическая задача для очистки истекших блокировок корзины.
|
||||||
|
Освобождает витринные комплекты, которые были добавлены в корзину,
|
||||||
|
но блокировка истекла (timeout 30 минут).
|
||||||
|
|
||||||
|
Запускается каждые 5 минут (настроить в celery beat schedule).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Статистика очистки {
|
||||||
|
'released_count': int, # Количество освобожденных блокировок
|
||||||
|
'affected_kits': list # ID освобожденных комплектов
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from inventory.models import Reservation
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Находим все резервы с истекшей блокировкой
|
||||||
|
expired_locks = Reservation.objects.filter(
|
||||||
|
Q(cart_lock_expires_at__lte=timezone.now()) &
|
||||||
|
Q(cart_lock_expires_at__isnull=False) &
|
||||||
|
Q(status='reserved')
|
||||||
|
).select_related('product_kit', 'locked_by_user')
|
||||||
|
|
||||||
|
# Собираем статистику перед очисткой
|
||||||
|
affected_kits = list(
|
||||||
|
expired_locks.values_list('product_kit_id', flat=True).distinct()
|
||||||
|
)
|
||||||
|
released_count = expired_locks.count()
|
||||||
|
|
||||||
|
# Логируем информацию о блокировках
|
||||||
|
if released_count > 0:
|
||||||
|
logger.info(
|
||||||
|
f"Очистка истекших блокировок: {released_count} резервов, "
|
||||||
|
f"{len(affected_kits)} комплектов"
|
||||||
|
)
|
||||||
|
|
||||||
|
for lock in expired_locks[:10]: # Логируем первые 10 для отладки
|
||||||
|
kit_name = lock.product_kit.name if lock.product_kit else 'N/A'
|
||||||
|
user_name = lock.locked_by_user.username if lock.locked_by_user else 'N/A'
|
||||||
|
logger.debug(
|
||||||
|
f"Освобождение блокировки: комплект='{kit_name}', "
|
||||||
|
f"пользователь='{user_name}', "
|
||||||
|
f"истекла={lock.cart_lock_expires_at}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Очищаем блокировки
|
||||||
|
expired_locks.update(
|
||||||
|
cart_lock_expires_at=None,
|
||||||
|
locked_by_user=None,
|
||||||
|
cart_session_id=None
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'released_count': released_count,
|
||||||
|
'affected_kits': affected_kits,
|
||||||
|
'timestamp': timezone.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
if released_count > 0:
|
||||||
|
logger.info(f"Очистка завершена успешно: {result}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при очистке истекших блокировок: {str(e)}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'released_count': 0,
|
||||||
|
'affected_kits': [],
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
@@ -414,3 +414,17 @@ CELERY_TASK_SEND_SENT_EVENT = True
|
|||||||
# Retry настройки
|
# Retry настройки
|
||||||
CELERY_TASK_DEFAULT_MAX_RETRIES = 3
|
CELERY_TASK_DEFAULT_MAX_RETRIES = 3
|
||||||
CELERY_TASK_DEFAULT_RETRY_DELAY = 60 # Повторить через 60 секунд при ошибке
|
CELERY_TASK_DEFAULT_RETRY_DELAY = 60 # Повторить через 60 секунд при ошибке
|
||||||
|
|
||||||
|
# Celery Beat Schedule (периодические задачи)
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
# Очистка истекших блокировок корзины каждые 5 минут
|
||||||
|
'cleanup-expired-cart-locks': {
|
||||||
|
'task': 'inventory.tasks.cleanup_expired_cart_locks',
|
||||||
|
'schedule': crontab(minute='*/5'), # Каждые 5 минут
|
||||||
|
'options': {
|
||||||
|
'expires': 240, # Задача устаревает через 4 минуты (меньше интервала)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -430,18 +430,53 @@ function renderProducts() {
|
|||||||
|
|
||||||
// Если это витринный комплект - добавляем кнопку редактирования
|
// Если это витринный комплект - добавляем кнопку редактирования
|
||||||
if (item.type === 'showcase_kit') {
|
if (item.type === 'showcase_kit') {
|
||||||
const editBtn = document.createElement('button');
|
// ИНДИКАЦИЯ БЛОКИРОВКИ
|
||||||
editBtn.className = 'btn btn-sm btn-outline-primary';
|
if (item.is_locked) {
|
||||||
editBtn.style.position = 'absolute';
|
// Создаем бейдж блокировки
|
||||||
editBtn.style.top = '5px';
|
const lockBadge = document.createElement('div');
|
||||||
editBtn.style.right = '5px';
|
lockBadge.style.position = 'absolute';
|
||||||
editBtn.style.zIndex = '10';
|
lockBadge.style.top = '5px';
|
||||||
editBtn.innerHTML = '<i class="bi bi-pencil"></i>';
|
lockBadge.style.left = '5px';
|
||||||
editBtn.onclick = (e) => {
|
lockBadge.style.zIndex = '10';
|
||||||
e.stopPropagation();
|
|
||||||
openEditKitModal(item.id);
|
if (item.locked_by_me) {
|
||||||
};
|
// Заблокирован мной - зеленый бейдж
|
||||||
card.appendChild(editBtn);
|
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');
|
const body = document.createElement('div');
|
||||||
@@ -620,28 +655,80 @@ function setupInfiniteScroll() {
|
|||||||
observer.observe(sentinel);
|
observer.observe(sentinel);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addToCart(item) {
|
async function addToCart(item) {
|
||||||
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
|
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
|
||||||
|
|
||||||
if (!cart.has(cartKey)) {
|
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock)
|
||||||
cart.set(cartKey, { id: item.id, name: item.name, price: Number(item.price), qty: 1, type: item.type });
|
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 {
|
} 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();
|
renderCart();
|
||||||
saveCartToRedis(); // Сохраняем в Redis
|
saveCartToRedis(); // Сохраняем в Redis
|
||||||
|
|
||||||
// Автоматический фокус на поле количества
|
// Автоматический фокус на поле количества (только для обычных товаров)
|
||||||
setTimeout(() => {
|
if (item.type !== 'showcase_kit') {
|
||||||
const qtyInputs = document.querySelectorAll('.qty-input');
|
setTimeout(() => {
|
||||||
const itemIndex = Array.from(cart.keys()).indexOf(cartKey);
|
const qtyInputs = document.querySelectorAll('.qty-input');
|
||||||
|
const itemIndex = Array.from(cart.keys()).indexOf(cartKey);
|
||||||
|
|
||||||
if (itemIndex !== -1 && qtyInputs[itemIndex]) {
|
if (itemIndex !== -1 && qtyInputs[itemIndex]) {
|
||||||
qtyInputs[itemIndex].focus();
|
qtyInputs[itemIndex].focus();
|
||||||
qtyInputs[itemIndex].select(); // Выделяем весь текст
|
qtyInputs[itemIndex].select(); // Выделяем весь текст
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCart() {
|
function renderCart() {
|
||||||
@@ -659,6 +746,15 @@ function renderCart() {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'cart-item mb-2';
|
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');
|
const namePrice = document.createElement('div');
|
||||||
namePrice.className = 'item-name-price';
|
namePrice.className = 'item-name-price';
|
||||||
@@ -684,57 +780,69 @@ function renderCart() {
|
|||||||
qtyControl.className = 'd-flex align-items-center';
|
qtyControl.className = 'd-flex align-items-center';
|
||||||
qtyControl.style.gap = '2px';
|
qtyControl.style.gap = '2px';
|
||||||
|
|
||||||
// Кнопка минус
|
// СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов (только badge, без кнопок)
|
||||||
const minusBtn = document.createElement('button');
|
if (isShowcaseKit) {
|
||||||
minusBtn.className = 'btn btn-outline-secondary btn-sm';
|
const badge = document.createElement('span');
|
||||||
minusBtn.innerHTML = '<i class="bi bi-dash-circle" style="font-size: 1.2em;"></i>';
|
badge.className = 'badge bg-warning text-dark';
|
||||||
minusBtn.onclick = (e) => {
|
badge.textContent = '1 шт (витрина)';
|
||||||
e.preventDefault();
|
badge.style.fontSize = '0.85rem';
|
||||||
const currentQty = cart.get(cartKey).qty;
|
badge.style.padding = '0.5rem 0.75rem';
|
||||||
if (currentQty <= 1) {
|
qtyControl.appendChild(badge);
|
||||||
removeFromCart(cartKey);
|
} else {
|
||||||
} else {
|
// ОБЫЧНАЯ ЛОГИКА для товаров и комплектов
|
||||||
cart.get(cartKey).qty = currentQty - 1;
|
|
||||||
|
// Кнопка минус
|
||||||
|
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();
|
renderCart();
|
||||||
saveCartToRedis();
|
saveCartToRedis();
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// Поле ввода количества
|
// Собираем контейнер
|
||||||
const qtyInput = document.createElement('input');
|
qtyControl.appendChild(minusBtn);
|
||||||
qtyInput.type = 'number';
|
qtyControl.appendChild(qtyInput);
|
||||||
qtyInput.className = 'qty-input form-control form-control-sm';
|
qtyControl.appendChild(plusBtn);
|
||||||
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);
|
|
||||||
|
|
||||||
// Сумма за позицию
|
// Сумма за позицию
|
||||||
const itemTotal = document.createElement('div');
|
const itemTotal = document.createElement('div');
|
||||||
@@ -761,7 +869,38 @@ function renderCart() {
|
|||||||
document.getElementById('cartTotal').textContent = formatMoney(total);
|
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);
|
cart.delete(cartKey);
|
||||||
renderCart();
|
renderCart();
|
||||||
saveCartToRedis(); // Сохраняем в Redis
|
saveCartToRedis(); // Сохраняем в Redis
|
||||||
@@ -909,13 +1048,17 @@ async function openEditKitModal(kitId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обновление списка витринных комплектов
|
// Обновление списка витринных комплектов
|
||||||
async function refreshShowcaseKits() {
|
async function loadShowcaseKits() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/pos/api/showcase-kits/');
|
const response = await fetch('/pos/api/showcase-kits/');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showcaseKits = data.items;
|
showcaseKits = data.items;
|
||||||
|
// Перерисовываем грид если мы в режиме витрины
|
||||||
|
if (isShowcaseView) {
|
||||||
|
renderProducts();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to refresh showcase kits:', data);
|
console.error('Failed to refresh showcase kits:', data);
|
||||||
}
|
}
|
||||||
@@ -924,6 +1067,9 @@ async function refreshShowcaseKits() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Алиас для совместимости
|
||||||
|
const refreshShowcaseKits = loadShowcaseKits;
|
||||||
|
|
||||||
// Загрузка списка витрин
|
// Загрузка списка витрин
|
||||||
async function loadShowcases() {
|
async function loadShowcases() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ urlpatterns = [
|
|||||||
path('api/get-showcases/', views.get_showcases_api, name='get-showcases-api'),
|
path('api/get-showcases/', views.get_showcases_api, name='get-showcases-api'),
|
||||||
# Получить актуальные витринные временные комплекты [GET]
|
# Получить актуальные витринные временные комплекты [GET]
|
||||||
path('api/showcase-kits/', views.get_showcase_kits_api, name='showcase-kits-api'),
|
path('api/showcase-kits/', views.get_showcase_kits_api, name='showcase-kits-api'),
|
||||||
|
# Добавить витринный комплект в корзину с блокировкой [POST]
|
||||||
|
path('api/showcase-kits/<int:kit_id>/add-to-cart/', views.add_showcase_kit_to_cart, name='add-showcase-kit-to-cart'),
|
||||||
|
# Снять блокировку витринного комплекта при удалении из корзины [POST]
|
||||||
|
path('api/showcase-kits/<int:kit_id>/remove-from-cart/', views.remove_showcase_kit_from_cart, name='remove-showcase-kit-from-cart'),
|
||||||
# Получить детали комплекта для редактирования [GET]
|
# Получить детали комплекта для редактирования [GET]
|
||||||
path('api/product-kits/<int:kit_id>/', views.get_product_kit_details, name='get-product-kit-details'),
|
path('api/product-kits/<int:kit_id>/', views.get_product_kit_details, name='get-product-kit-details'),
|
||||||
# Обновить временный комплект (состав, фото, цены) [POST]
|
# Обновить временный комплект (состав, фото, цены) [POST]
|
||||||
|
|||||||
@@ -387,15 +387,194 @@ def get_showcase_kits_api(request):
|
|||||||
"""
|
"""
|
||||||
API endpoint для получения актуального списка витринных комплектов.
|
API endpoint для получения актуального списка витринных комплектов.
|
||||||
Используется для динамического обновления после создания нового букета.
|
Используется для динамического обновления после создания нового букета.
|
||||||
|
Включает информацию о блокировках в корзинах.
|
||||||
"""
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
showcase_kits_data = get_showcase_kits_for_pos()
|
showcase_kits_data = get_showcase_kits_for_pos()
|
||||||
|
|
||||||
|
# Добавляем информацию о блокировках для каждого комплекта
|
||||||
|
kit_ids = [kit['id'] for kit in showcase_kits_data]
|
||||||
|
|
||||||
|
# Получаем активные блокировки (не истекшие)
|
||||||
|
active_locks = Reservation.objects.filter(
|
||||||
|
product_kit_id__in=kit_ids,
|
||||||
|
cart_lock_expires_at__gt=timezone.now(),
|
||||||
|
status='reserved'
|
||||||
|
).select_related('locked_by_user').values(
|
||||||
|
'product_kit_id',
|
||||||
|
'locked_by_user_id',
|
||||||
|
'locked_by_user__username',
|
||||||
|
'cart_lock_expires_at'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Индексируем блокировки по kit_id
|
||||||
|
locks_by_kit = {}
|
||||||
|
for lock in active_locks:
|
||||||
|
kit_id = lock['product_kit_id']
|
||||||
|
if kit_id not in locks_by_kit:
|
||||||
|
locks_by_kit[kit_id] = lock
|
||||||
|
|
||||||
|
# Дополняем данные о комплектах информацией о блокировках
|
||||||
|
for kit in showcase_kits_data:
|
||||||
|
lock_info = locks_by_kit.get(kit['id'])
|
||||||
|
|
||||||
|
if lock_info:
|
||||||
|
is_locked_by_me = lock_info['locked_by_user_id'] == request.user.id
|
||||||
|
kit['is_locked'] = True
|
||||||
|
kit['locked_by_me'] = is_locked_by_me
|
||||||
|
kit['locked_by_user'] = lock_info['locked_by_user__username']
|
||||||
|
kit['lock_expires_at'] = lock_info['cart_lock_expires_at'].isoformat()
|
||||||
|
else:
|
||||||
|
kit['is_locked'] = False
|
||||||
|
kit['locked_by_me'] = False
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'success': True,
|
'success': True,
|
||||||
'items': showcase_kits_data
|
'items': showcase_kits_data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def add_showcase_kit_to_cart(request, kit_id):
|
||||||
|
"""
|
||||||
|
API endpoint для добавления витринного комплекта в корзину с блокировкой.
|
||||||
|
Создает soft lock на 30 минут, предотвращая добавление другими кассирами.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON: {
|
||||||
|
'success': bool,
|
||||||
|
'message': str,
|
||||||
|
'lock_expires_at': ISO datetime (если success=True),
|
||||||
|
'error': str (если success=False)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Получаем комплект
|
||||||
|
kit = ProductKit.objects.select_related('showcase').get(
|
||||||
|
id=kit_id,
|
||||||
|
is_temporary=True,
|
||||||
|
showcase__isnull=False,
|
||||||
|
status='active'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем существующие блокировки
|
||||||
|
existing_locks = Reservation.objects.filter(
|
||||||
|
product_kit=kit,
|
||||||
|
cart_lock_expires_at__gt=timezone.now(),
|
||||||
|
status='reserved'
|
||||||
|
).exclude(
|
||||||
|
locked_by_user=request.user
|
||||||
|
).select_related('locked_by_user')
|
||||||
|
|
||||||
|
if existing_locks.exists():
|
||||||
|
lock = existing_locks.first()
|
||||||
|
time_left = (lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Этот букет уже в корзине кассира "{lock.locked_by_user.username}". '
|
||||||
|
f'Блокировка истечет через {int(time_left)} мин.'
|
||||||
|
}, status=409) # 409 Conflict
|
||||||
|
|
||||||
|
# Создаем или продлеваем блокировку для текущего пользователя
|
||||||
|
lock_expires_at = timezone.now() + timedelta(minutes=30)
|
||||||
|
session_id = request.session.session_key or ''
|
||||||
|
|
||||||
|
# Обновляем все резервы этого комплекта
|
||||||
|
updated_count = Reservation.objects.filter(
|
||||||
|
product_kit=kit,
|
||||||
|
status='reserved'
|
||||||
|
).update(
|
||||||
|
cart_lock_expires_at=lock_expires_at,
|
||||||
|
locked_by_user=request.user,
|
||||||
|
cart_session_id=session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated_count == 0:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'У комплекта нет активных резервов. Возможно, он уже продан.'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Букет "{kit.name}" добавлен в корзину',
|
||||||
|
'lock_expires_at': lock_expires_at.isoformat(),
|
||||||
|
'locked_until_minutes': 30
|
||||||
|
})
|
||||||
|
|
||||||
|
except ProductKit.DoesNotExist:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Витринный комплект не найден'
|
||||||
|
}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Ошибка при добавлении в корзину: {str(e)}'
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def remove_showcase_kit_from_cart(request, kit_id):
|
||||||
|
"""
|
||||||
|
API endpoint для снятия блокировки витринного комплекта при удалении из корзины.
|
||||||
|
Освобождает комплект для добавления другими кассирами.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON: {
|
||||||
|
'success': bool,
|
||||||
|
'message': str,
|
||||||
|
'error': str (если success=False)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Получаем комплект
|
||||||
|
kit = ProductKit.objects.get(
|
||||||
|
id=kit_id,
|
||||||
|
is_temporary=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Снимаем блокировку только для текущего пользователя
|
||||||
|
updated_count = Reservation.objects.filter(
|
||||||
|
product_kit=kit,
|
||||||
|
locked_by_user=request.user,
|
||||||
|
status='reserved'
|
||||||
|
).update(
|
||||||
|
cart_lock_expires_at=None,
|
||||||
|
locked_by_user=None,
|
||||||
|
cart_session_id=None
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated_count == 0:
|
||||||
|
# Комплект не был заблокирован этим пользователем
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Комплект не был заблокирован вами'
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Букет "{kit.name}" удален из корзины. Блокировка снята.',
|
||||||
|
'released_count': updated_count
|
||||||
|
})
|
||||||
|
|
||||||
|
except ProductKit.DoesNotExist:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Комплект не найден'
|
||||||
|
}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Ошибка при снятии блокировки: {str(e)}'
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def get_items_api(request):
|
def get_items_api(request):
|
||||||
|
|||||||
25
ЗАПУСК.md
Normal file
25
ЗАПУСК.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Запуск системы
|
||||||
|
|
||||||
|
## Из каталога `(venv) PS C:\Users\team_\Desktop\test_qwen\`
|
||||||
|
|
||||||
|
### 1. Запуск Celery Worker (для фото и прочих задач)
|
||||||
|
```powershell
|
||||||
|
start_celery.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Запуск Celery Beat (для периодических задач)
|
||||||
|
**Новая команда - для автоматической очистки блокировок корзины каждые 5 минут:**
|
||||||
|
```powershell из myproject
|
||||||
|
celery -A myproject beat -l info
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что делает Celery Beat?
|
||||||
|
- Автоматически освобождает витринные комплекты, если блокировка в корзине истекла (30 минут)
|
||||||
|
- Запускает задачу `cleanup_expired_cart_locks` каждые 5 минут
|
||||||
|
- Логи пишет в консоль (уровень INFO)
|
||||||
|
|
||||||
|
## Можно ли не запускать Beat?
|
||||||
|
Да, но тогда блокировки не будут автоматически сниматься при истечении таймаута.
|
||||||
|
Кассиры все равно смогут работать, просто букет останется "занят" до ручного удаления из корзины.
|
||||||
Reference in New Issue
Block a user