ShowcaseItem: защита от двойной продажи витринных букетов
Новая архитектура: - ShowcaseItem модель - физический экземпляр букета на витрине - OneToOneField(sold_order_item) - БД-уровневая защита от двойной продажи - Поддержка создания нескольких экземпляров одного букета - Возможность продавать N из M доступных (например 2 из 5) Изменения: - inventory/models.py: добавлена модель ShowcaseItem с методами lock/unlock/mark_sold - inventory/services/showcase_manager.py: переработан для работы с ShowcaseItem - pos/views.py: API поддерживает quantity и showcase_item_ids - pos/templates/pos/terminal.html: поле "Сколько букетов создать" - pos/static/pos/js/terminal.js: выбор количества, передача showcase_item_ids Миграции: - 0007: создание модели ShowcaseItem - 0008: data migration существующих букетов - 0009: очистка ShowcaseItem для уже проданных букетов 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -450,6 +450,17 @@ class Reservation(models.Model):
|
||||
help_text="Дополнительная идентификация сессии для надежности"
|
||||
)
|
||||
|
||||
# Связь с конкретным экземпляром витринного букета
|
||||
showcase_item = models.ForeignKey(
|
||||
'ShowcaseItem',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='reservations',
|
||||
verbose_name="Экземпляр на витрине",
|
||||
help_text="Для какого физического экземпляра создан резерв"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Резервирование"
|
||||
verbose_name_plural = "Резервирования"
|
||||
@@ -463,6 +474,7 @@ class Reservation(models.Model):
|
||||
models.Index(fields=['cart_lock_expires_at']),
|
||||
models.Index(fields=['locked_by_user']),
|
||||
models.Index(fields=['product_kit', 'cart_lock_expires_at']),
|
||||
models.Index(fields=['showcase_item']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
@@ -477,6 +489,152 @@ class Reservation(models.Model):
|
||||
return f"Резерв {self.product.name}: {self.quantity} шт{context} [{self.get_status_display()}]"
|
||||
|
||||
|
||||
class ShowcaseItem(models.Model):
|
||||
"""
|
||||
Физический экземпляр комплекта на витрине.
|
||||
|
||||
Один ProductKit (шаблон) -> N ShowcaseItem (экземпляры).
|
||||
Каждый экземпляр имеет свой набор резервов и может быть продан независимо.
|
||||
|
||||
Защита от двойной продажи:
|
||||
- sold_order_item = OneToOneField гарантирует что один экземпляр
|
||||
может быть продан только в один OrderItem (на уровне БД).
|
||||
"""
|
||||
showcase = models.ForeignKey(
|
||||
'Showcase',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='showcase_items',
|
||||
verbose_name="Витрина"
|
||||
)
|
||||
product_kit = models.ForeignKey(
|
||||
'products.ProductKit',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='showcase_items',
|
||||
verbose_name="Шаблон комплекта"
|
||||
)
|
||||
|
||||
# Статусы жизненного цикла
|
||||
STATUS_CHOICES = [
|
||||
('available', 'Доступен'),
|
||||
('in_cart', 'В корзине'),
|
||||
('sold', 'Продан'),
|
||||
('dismantled', 'Разобран'),
|
||||
]
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='available',
|
||||
db_index=True,
|
||||
verbose_name="Статус"
|
||||
)
|
||||
|
||||
# === ЗАЩИТА ОТ ДВОЙНОЙ ПРОДАЖИ ===
|
||||
# OneToOneField гарантирует на уровне БД: 1 ShowcaseItem = max 1 OrderItem
|
||||
sold_order_item = models.OneToOneField(
|
||||
'orders.OrderItem',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='sold_showcase_item',
|
||||
verbose_name="Позиция заказа (продажа)"
|
||||
)
|
||||
sold_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Дата продажи"
|
||||
)
|
||||
|
||||
# === SOFT LOCK для корзины ===
|
||||
locked_by_user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='locked_showcase_items',
|
||||
verbose_name="Заблокировано пользователем"
|
||||
)
|
||||
cart_lock_expires_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Блокировка истекает"
|
||||
)
|
||||
cart_session_id = models.CharField(
|
||||
max_length=100,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="ID сессии корзины"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлен")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Экземпляр на витрине"
|
||||
verbose_name_plural = "Экземпляры на витрине"
|
||||
indexes = [
|
||||
models.Index(fields=['showcase', 'status']),
|
||||
models.Index(fields=['product_kit', 'status']),
|
||||
models.Index(fields=['status', 'cart_lock_expires_at']),
|
||||
models.Index(fields=['locked_by_user', 'status']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product_kit.name} #{self.id} ({self.get_status_display()})"
|
||||
|
||||
def lock_for_cart(self, user, session_id=None, duration_minutes=30):
|
||||
"""Заблокировать экземпляр для корзины"""
|
||||
from datetime import timedelta
|
||||
|
||||
self.status = 'in_cart'
|
||||
self.locked_by_user = user
|
||||
self.cart_lock_expires_at = timezone.now() + timedelta(minutes=duration_minutes)
|
||||
self.cart_session_id = session_id
|
||||
self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at'])
|
||||
|
||||
def release_lock(self):
|
||||
"""Снять блокировку корзины"""
|
||||
self.status = 'available'
|
||||
self.locked_by_user = None
|
||||
self.cart_lock_expires_at = None
|
||||
self.cart_session_id = None
|
||||
self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at'])
|
||||
|
||||
def mark_sold(self, order_item):
|
||||
"""
|
||||
Пометить как проданный.
|
||||
OneToOneField автоматически выбросит IntegrityError при повторной продаже.
|
||||
"""
|
||||
self.status = 'sold'
|
||||
self.sold_order_item = order_item # БД защита от дублей!
|
||||
self.sold_at = timezone.now()
|
||||
self.locked_by_user = None
|
||||
self.cart_lock_expires_at = None
|
||||
self.cart_session_id = None
|
||||
self.save()
|
||||
|
||||
def is_lock_expired(self):
|
||||
"""Проверить истекла ли блокировка"""
|
||||
if self.cart_lock_expires_at is None:
|
||||
return True
|
||||
return timezone.now() > self.cart_lock_expires_at
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired_locks(cls):
|
||||
"""Снять все просроченные блокировки (для Celery задачи)"""
|
||||
expired = cls.objects.filter(
|
||||
status='in_cart',
|
||||
cart_lock_expires_at__lt=timezone.now()
|
||||
)
|
||||
count = expired.update(
|
||||
status='available',
|
||||
locked_by_user=None,
|
||||
cart_lock_expires_at=None,
|
||||
cart_session_id=None
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
class Stock(models.Model):
|
||||
"""
|
||||
Агрегированные остатки по товарам и складам.
|
||||
|
||||
Reference in New Issue
Block a user