Добавлена система 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)
|
||||
}
|
||||
Reference in New Issue
Block a user