Добавлена система 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.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
from decimal import Decimal
|
||||
from products.models import Product
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
@@ -428,6 +429,27 @@ class Reservation(models.Model):
|
||||
converted_at = models.DateTimeField(null=True, blank=True,
|
||||
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:
|
||||
verbose_name = "Резервирование"
|
||||
verbose_name_plural = "Резервирования"
|
||||
@@ -438,6 +460,9 @@ class Reservation(models.Model):
|
||||
models.Index(fields=['order_item']),
|
||||
models.Index(fields=['showcase']),
|
||||
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):
|
||||
|
||||
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 настройки
|
||||
CELERY_TASK_DEFAULT_MAX_RETRIES = 3
|
||||
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,6 +430,40 @@ function renderProducts() {
|
||||
|
||||
// Если это витринный комплект - добавляем кнопку редактирования
|
||||
if (item.type === 'showcase_kit') {
|
||||
// ИНДИКАЦИЯ БЛОКИРОВКИ
|
||||
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';
|
||||
@@ -443,6 +477,7 @@ function renderProducts() {
|
||||
};
|
||||
card.appendChild(editBtn);
|
||||
}
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card-body';
|
||||
@@ -620,19 +655,70 @@ function setupInfiniteScroll() {
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
|
||||
function addToCart(item) {
|
||||
async function addToCart(item) {
|
||||
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
|
||||
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (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 {
|
||||
// ОБЫЧНАЯ ЛОГИКА для товаров и комплектов
|
||||
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
|
||||
|
||||
// Автоматический фокус на поле количества
|
||||
// Автоматический фокус на поле количества (только для обычных товаров)
|
||||
if (item.type !== 'showcase_kit') {
|
||||
setTimeout(() => {
|
||||
const qtyInputs = document.querySelectorAll('.qty-input');
|
||||
const itemIndex = Array.from(cart.keys()).indexOf(cartKey);
|
||||
@@ -642,6 +728,7 @@ function addToCart(item) {
|
||||
qtyInputs[itemIndex].select(); // Выделяем весь текст
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCart() {
|
||||
@@ -659,6 +746,15 @@ function renderCart() {
|
||||
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';
|
||||
@@ -684,6 +780,17 @@ function renderCart() {
|
||||
qtyControl.className = 'd-flex align-items-center';
|
||||
qtyControl.style.gap = '2px';
|
||||
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов (только 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';
|
||||
@@ -735,6 +842,7 @@ function renderCart() {
|
||||
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 {
|
||||
|
||||
@@ -19,6 +19,10 @@ urlpatterns = [
|
||||
path('api/get-showcases/', views.get_showcases_api, name='get-showcases-api'),
|
||||
# Получить актуальные витринные временные комплекты [GET]
|
||||
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]
|
||||
path('api/product-kits/<int:kit_id>/', views.get_product_kit_details, name='get-product-kit-details'),
|
||||
# Обновить временный комплект (состав, фото, цены) [POST]
|
||||
|
||||
@@ -387,15 +387,194 @@ def get_showcase_kits_api(request):
|
||||
"""
|
||||
API endpoint для получения актуального списка витринных комплектов.
|
||||
Используется для динамического обновления после создания нового букета.
|
||||
Включает информацию о блокировках в корзинах.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
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({
|
||||
'success': True,
|
||||
'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
|
||||
@require_http_methods(["GET"])
|
||||
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