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:
2025-12-09 23:51:37 +03:00
parent 936d2275e4
commit cfc6ce451e
8 changed files with 1076 additions and 318 deletions

View File

@@ -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):
"""
Агрегированные остатки по товарам и складам.