Добавлена система 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:
2025-11-20 23:45:34 +03:00
parent ff0756498c
commit 33e33ecbac
8 changed files with 600 additions and 81 deletions

View File

@@ -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'),
),
]

View File

@@ -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):

View 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)
}

View File

@@ -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 минуты (меньше интервала)
},
},
}

View File

@@ -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() {
@@ -658,22 +745,31 @@ function renderCart() {
cart.forEach((item, cartKey) => { cart.forEach((item, cartKey) => {
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';
// Иконка только для комплектов // Иконка только для комплектов
let typeIcon = ''; let typeIcon = '';
if (item.type === 'kit' || item.type === 'showcase_kit') { if (item.type === 'kit' || item.type === 'showcase_kit') {
typeIcon = '<i class="bi bi-box-seam text-info" title="Комплект"></i> '; typeIcon = '<i class="bi bi-box-seam text-info" title="Комплект"></i> ';
} }
namePrice.innerHTML = ` namePrice.innerHTML = `
<div class="fw-semibold small">${typeIcon}${item.name}</div> <div class="fw-semibold small">${typeIcon}${item.name}</div>
<div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / шт</div> <div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / шт</div>
`; `;
// Знак умножения // Знак умножения
const multiplySign = document.createElement('span'); const multiplySign = document.createElement('span');
multiplySign.className = 'multiply-sign'; multiplySign.className = 'multiply-sign';
@@ -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 {

View File

@@ -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]

View File

@@ -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
View 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?
Да, но тогда блокировки не будут автоматически сниматься при истечении таймаута.
Кассиры все равно смогут работать, просто букет останется "занят" до ручного удаления из корзины.