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

@@ -1,12 +1,18 @@
"""
Сервис управления витринами - резервирование, продажа и разбор витринных букетов.
Новая архитектура с ShowcaseItem:
- ProductKit = шаблон (рецепт букета)
- ShowcaseItem = физический экземпляр на витрине
- Каждый ShowcaseItem имеет свой набор Reservation
- Защита от двойной продажи через OneToOneField на sold_order_item
"""
from decimal import Decimal
from django.db import transaction
from django.db import transaction, IntegrityError
from django.utils import timezone
from django.core.exceptions import ValidationError
from inventory.models import Showcase, Reservation, Warehouse
from inventory.models import Showcase, Reservation, Warehouse, ShowcaseItem, Stock
from products.models import ProductKit
from orders.models import Order, OrderItem, OrderStatus
from customers.models import Customer
@@ -20,17 +26,18 @@ class ShowcaseManager:
@staticmethod
def reserve_kit_to_showcase(product_kit, showcase, quantity=1):
"""
Резервирует комплект на витрину.
Раскладывает комплект на компоненты и создаёт резервы по каждому товару.
Резервирует N экземпляров комплекта на витрину.
Создаёт ShowcaseItem для каждого экземпляра и резервы компонентов.
Args:
product_kit: ProductKit - комплект для резервирования
product_kit: ProductKit - шаблон комплекта
showcase: Showcase - витрина
quantity: int - количество комплектов (по умолчанию 1)
quantity: int - количество экземпляров (например, 5 одинаковых букетов)
Returns:
dict: {
'success': bool,
'showcase_items': list[ShowcaseItem],
'reservations': list[Reservation],
'message': str
}
@@ -38,100 +45,181 @@ class ShowcaseManager:
if not showcase.is_active:
return {
'success': False,
'showcase_items': [],
'reservations': [],
'message': f'Витрина "{showcase.name}" не активна'
}
warehouse = showcase.warehouse
reservations = []
showcase_items = []
all_reservations = []
try:
with transaction.atomic():
# Раскладываем комплект на компоненты
kit_items = product_kit.kit_items.all()
if not kit_items.exists():
return {
'success': False,
'showcase_items': [],
'reservations': [],
'message': f'Комплект "{product_kit.name}" не содержит компонентов'
}
# Создаём резервы по каждому компоненту
for kit_item in kit_items:
if kit_item.product:
# Обычный товар
component_quantity = kit_item.quantity * quantity
reservation = Reservation.objects.create(
product=kit_item.product,
warehouse=warehouse,
showcase=showcase,
product_kit=product_kit,
quantity=component_quantity,
status='reserved'
)
reservations.append(reservation)
elif kit_item.variant_group:
# Группа вариантов - резервируем первый доступный вариант
# В будущем можно добавить выбор конкретного варианта
variant_items = kit_item.variant_group.items.all()
if variant_items.exists():
first_variant = variant_items.first()
component_quantity = kit_item.quantity * quantity
# Создаём N экземпляров
for _ in range(quantity):
# 1. Создаём ShowcaseItem
showcase_item = ShowcaseItem.objects.create(
showcase=showcase,
product_kit=product_kit,
status='available'
)
showcase_items.append(showcase_item)
# 2. Создаём резервы ДЛЯ ЭТОГО ЭКЗЕМПЛЯРА
for kit_item in kit_items:
product_to_reserve = None
if kit_item.product:
product_to_reserve = kit_item.product
elif kit_item.variant_group:
# Группа вариантов - резервируем первый доступный вариант
variant_items = kit_item.variant_group.items.all()
if variant_items.exists():
product_to_reserve = variant_items.first().product
if product_to_reserve:
reservation = Reservation.objects.create(
product=first_variant.product,
product=product_to_reserve,
warehouse=warehouse,
showcase=showcase,
product_kit=product_kit,
quantity=component_quantity,
showcase_item=showcase_item, # Связь с экземпляром!
quantity=kit_item.quantity,
status='reserved'
)
reservations.append(reservation)
all_reservations.append(reservation)
# Обновляем агрегаты Stock для всех затронутых товаров
from inventory.models import Stock
for reservation in reservations:
# Обновляем агрегаты Stock
affected_products = set(r.product_id for r in all_reservations)
for product_id in affected_products:
stock, _ = Stock.objects.get_or_create(
product=reservation.product,
product_id=product_id,
warehouse=warehouse
)
stock.refresh_from_batches()
return {
'success': True,
'reservations': reservations,
'message': f'Комплект "{product_kit.name}" зарезервирован на витрине "{showcase.name}"'
'showcase_items': showcase_items,
'reservations': all_reservations,
'message': f'Создано {quantity} экз. комплекта "{product_kit.name}" на витрине "{showcase.name}"'
}
except Exception as e:
return {
'success': False,
'showcase_items': [],
'reservations': [],
'message': f'Ошибка резервирования: {str(e)}'
}
@staticmethod
def sell_from_showcase(product_kit, showcase, customer, payment_method='cash_to_courier',
custom_price=None, user=None):
def sell_showcase_items(showcase_items, order_item):
"""
Продаёт комплект с витрины.
Создаёт Order, OrderItem, конвертирует резервы в Sale.
Продаёт указанные экземпляры с витрины.
Привязывает каждый ShowcaseItem к OrderItem и конвертирует резервы в продажи.
Args:
product_kit: ProductKit - комплект для продажи
showcase_items: list[ShowcaseItem] - экземпляры для продажи
order_item: OrderItem - позиция заказа
Returns:
dict: {
'success': bool,
'sold_count': int,
'message': str
}
Raises:
IntegrityError: если экземпляр уже был продан (защита на уровне БД)
"""
from inventory.services.sale_processor import SaleProcessor
sold_count = 0
order = order_item.order
try:
with transaction.atomic():
for showcase_item in showcase_items:
# Проверка статуса перед продажей
if showcase_item.status == 'sold':
raise ValidationError(
f'Экземпляр "{showcase_item}" уже продан'
)
if showcase_item.status == 'dismantled':
raise ValidationError(
f'Экземпляр "{showcase_item}" был разобран'
)
# Помечаем как проданный (OneToOneField защитит от дублей!)
showcase_item.mark_sold(order_item)
# Конвертируем резервы этого экземпляра в продажи
reservations = Reservation.objects.filter(
showcase_item=showcase_item,
status='reserved'
)
for reservation in reservations:
SaleProcessor.create_sale_from_reservation(
reservation=reservation,
order=order
)
reservation.status = 'converted_to_sale'
reservation.converted_at = timezone.now()
reservation.order_item = order_item
reservation.save()
sold_count += 1
return {
'success': True,
'sold_count': sold_count,
'message': f'Продано {sold_count} экз.'
}
except IntegrityError as e:
# Защита от двойной продажи сработала на уровне БД
if 'sold_order_item' in str(e) or 'UNIQUE' in str(e):
return {
'success': False,
'sold_count': 0,
'message': 'Один из экземпляров уже был продан. Обновите список витринных букетов.'
}
raise
@staticmethod
def sell_from_showcase(product_kit, showcase, customer, payment_method='cash_to_courier',
custom_price=None, user=None, quantity=1):
"""
Продаёт N экземпляров комплекта с витрины.
Создаёт Order, OrderItem, выбирает доступные ShowcaseItem и продаёт их.
Args:
product_kit: ProductKit - шаблон комплекта
showcase: Showcase - витрина
customer: Customer - покупатель
payment_method: str - способ оплаты
custom_price: Decimal - кастомная цена (опционально)
custom_price: Decimal - кастомная цена за единицу (опционально)
user: CustomUser - пользователь, выполняющий операцию
quantity: int - количество экземпляров для продажи
Returns:
dict: {
'success': bool,
'order': Order or None,
'sold_count': int,
'message': str
}
"""
@@ -139,38 +227,23 @@ class ShowcaseManager:
try:
with transaction.atomic():
# Находим резервы для этого комплекта на витрине
# Группируем по product для подсчёта
reservations = Reservation.objects.filter(
# Находим доступные экземпляры этого комплекта на витрине
available_items = ShowcaseItem.objects.select_for_update(
skip_locked=True
).filter(
showcase=showcase,
status='reserved'
).select_related('product', 'locked_by_user')
product_kit=product_kit,
status='available'
)[:quantity]
if not reservations.exists():
available_items = list(available_items)
if len(available_items) < quantity:
return {
'success': False,
'order': None,
'message': f'На витрине "{showcase.name}" нет зарезервированных товаров'
}
# Проверяем блокировки корзины (Soft Lock)
# Если комплект заблокирован в корзине другого кассира, запрещаем продажу
active_locks = reservations.filter(
cart_lock_expires_at__gt=timezone.now(),
cart_lock_expires_at__isnull=False
)
if active_locks.exists():
lock = active_locks.first()
time_left = (lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
locker_name = lock.locked_by_user.username if lock.locked_by_user else 'неизвестный кассир'
return {
'success': False,
'order': None,
'message': f'Комплект заблокирован в корзине кассира "{locker_name}". '
f'Блокировка истечёт через {int(time_left)} мин. '
f'Дождитесь освобождения или попросите кассира удалить букет из корзины.'
'sold_count': 0,
'message': f'Доступно только {len(available_items)} из {quantity} запрошенных экземпляров'
}
# Получаем статус "Завершён" для POS-продаж
@@ -180,12 +253,11 @@ class ShowcaseManager:
).first()
if not completed_status:
# Если нет статуса completed, берём любой положительный
completed_status = OrderStatus.objects.filter(
is_positive_end=True
).first()
# Создаём заказ (самовывоз с витринного склада)
# Создаём заказ
order = Order.objects.create(
customer=customer,
is_delivery=False,
@@ -204,29 +276,18 @@ class ShowcaseManager:
order_item = OrderItem.objects.create(
order=order,
product_kit=product_kit,
quantity=1,
quantity=len(available_items),
price=price,
is_custom_price=is_custom,
is_from_showcase=True,
showcase=showcase
)
# Привязываем резервы к OrderItem
reservations.update(order_item=order_item)
# Продаём экземпляры
result = ShowcaseManager.sell_showcase_items(available_items, order_item)
# Конвертируем резервы в продажи
from inventory.services.sale_processor import SaleProcessor
for reservation in reservations:
# Создаём Sale
sale = SaleProcessor.create_sale_from_reservation(
reservation=reservation,
order=order
)
# Обновляем статус резерва
reservation.status = 'converted_to_sale'
reservation.converted_at = timezone.now()
reservation.save()
if not result['success']:
raise ValidationError(result['message'])
# Пересчитываем итоговую сумму заказа
order.calculate_total()
@@ -237,24 +298,25 @@ class ShowcaseManager:
return {
'success': True,
'order': order,
'message': f'Заказ #{order.order_number} создан. Продан комплект с витрины "{showcase.name}"'
'sold_count': result['sold_count'],
'message': f'Заказ #{order.order_number} создан. Продано {result["sold_count"]} экз. с витрины "{showcase.name}"'
}
except Exception as e:
return {
'success': False,
'order': None,
'sold_count': 0,
'message': f'Ошибка продажи: {str(e)}'
}
@staticmethod
def dismantle_from_showcase(showcase, product_kit=None):
def dismantle_showcase_item(showcase_item):
"""
Разбирает букет на витрине - освобождает резервы.
Разбирает один экземпляр на витрине - освобождает его резервы.
Args:
showcase: Showcase - витрина
product_kit: ProductKit - конкретный комплект (опционально)
showcase_item: ShowcaseItem - экземпляр для разбора
Returns:
dict: {
@@ -263,39 +325,38 @@ class ShowcaseManager:
'message': str
}
"""
if showcase_item.status == 'sold':
return {
'success': False,
'released_count': 0,
'message': 'Нельзя разобрать проданный экземпляр'
}
try:
with transaction.atomic():
# Находим активные резервы
warehouse = showcase_item.showcase.warehouse
# Находим резервы этого экземпляра
reservations = Reservation.objects.filter(
showcase=showcase,
showcase_item=showcase_item,
status='reserved'
)
if product_kit:
# Если указан конкретный комплект, фильтруем только его резервы
reservations = reservations.filter(product_kit=product_kit)
released_count = reservations.count()
if released_count == 0:
return {
'success': False,
'released_count': 0,
'message': f'На витрине "{showcase.name}" нет активных резервов'
}
# Сохраняем список затронутых товаров и склад ДО обновления резервов
from inventory.models import Stock
affected_products = list(reservations.values_list('product_id', flat=True).distinct())
warehouse = showcase.warehouse
# Освобождаем резервы
reservations.update(
status='released',
released_at=timezone.now(),
showcase=None
showcase=None,
showcase_item=None
)
# Обновляем статус экземпляра
showcase_item.status = 'dismantled'
showcase_item.save()
# Обновляем агрегаты Stock
for product_id in affected_products:
try:
@@ -310,7 +371,7 @@ class ShowcaseManager:
return {
'success': True,
'released_count': released_count,
'message': f'Разобрано {released_count} резервов с витрины "{showcase.name}"'
'message': f'Экземпляр разобран, освобождено {released_count} резервов'
}
except Exception as e:
@@ -320,16 +381,152 @@ class ShowcaseManager:
'message': f'Ошибка разбора: {str(e)}'
}
@staticmethod
def dismantle_from_showcase(showcase, product_kit=None):
"""
Разбирает все экземпляры на витрине (или конкретного комплекта).
Освобождает резервы.
Args:
showcase: Showcase - витрина
product_kit: ProductKit - конкретный комплект (опционально)
Returns:
dict: {
'success': bool,
'released_count': int,
'dismantled_items_count': int,
'message': str
}
"""
try:
with transaction.atomic():
# Находим экземпляры для разбора
items_qs = ShowcaseItem.objects.filter(
showcase=showcase,
status='available'
)
if product_kit:
items_qs = items_qs.filter(product_kit=product_kit)
items_to_dismantle = list(items_qs)
if not items_to_dismantle:
return {
'success': False,
'released_count': 0,
'dismantled_items_count': 0,
'message': f'На витрине "{showcase.name}" нет доступных экземпляров для разбора'
}
total_released = 0
for item in items_to_dismantle:
result = ShowcaseManager.dismantle_showcase_item(item)
if result['success']:
total_released += result['released_count']
return {
'success': True,
'released_count': total_released,
'dismantled_items_count': len(items_to_dismantle),
'message': f'Разобрано {len(items_to_dismantle)} экз., освобождено {total_released} резервов'
}
except Exception as e:
return {
'success': False,
'released_count': 0,
'dismantled_items_count': 0,
'message': f'Ошибка разбора: {str(e)}'
}
@staticmethod
def get_showcase_items_for_pos(showcase=None):
"""
Возвращает витринные экземпляры, сгруппированные по шаблону комплекта.
Для использования в POS интерфейсе.
Args:
showcase: Showcase - конкретная витрина (опционально, если None - все активные)
Returns:
list: [
{
'kit_id': int,
'kit_name': str,
'kit_sku': str,
'price': Decimal,
'available_count': int,
'showcase_item_ids': list[int],
'showcase_id': int,
'showcase_name': str,
'type': 'showcase_kit'
},
...
]
"""
from django.db.models import Count
# Базовый queryset
qs = ShowcaseItem.objects.filter(
status='available',
showcase__is_active=True
)
if showcase:
qs = qs.filter(showcase=showcase)
# Группируем по (product_kit, showcase)
grouped = qs.values(
'product_kit_id',
'product_kit__name',
'product_kit__sku',
'product_kit__price',
'product_kit__sale_price',
'showcase_id',
'showcase__name'
).annotate(
available_count=Count('id')
).order_by('showcase__name', 'product_kit__name')
result = []
for item in grouped:
# Получаем IDs всех доступных экземпляров этой группы
item_ids = list(ShowcaseItem.objects.filter(
product_kit_id=item['product_kit_id'],
showcase_id=item['showcase_id'],
status='available'
).values_list('id', flat=True))
# Определяем актуальную цену
price = item['product_kit__sale_price'] or item['product_kit__price']
result.append({
'kit_id': item['product_kit_id'],
'kit_name': item['product_kit__name'],
'kit_sku': item['product_kit__sku'] or '',
'price': str(price),
'available_count': item['available_count'],
'showcase_item_ids': item_ids,
'showcase_id': item['showcase_id'],
'showcase_name': item['showcase__name'],
'type': 'showcase_kit'
})
return result
@staticmethod
def get_showcase_kits(showcase):
"""
Возвращает список комплектов, зарезервированных на витрине.
Возвращает список компонентов, зарезервированных на витрине.
(Для совместимости со старым API)
Args:
showcase: Showcase
Returns:
list: список словарей с информацией о комплектах
list: список словарей с информацией о компонентах
"""
reservations = Reservation.objects.filter(
showcase=showcase,
@@ -355,3 +552,110 @@ class ShowcaseManager:
}
for pid, data in products_dict.items()
]
@staticmethod
def lock_showcase_items_for_cart(showcase_item_ids, user, session_id=None, duration_minutes=30):
"""
Блокирует указанные экземпляры для корзины.
Args:
showcase_item_ids: list[int] - ID экземпляров для блокировки
user: User - пользователь (кассир)
session_id: str - ID сессии корзины
duration_minutes: int - длительность блокировки в минутах
Returns:
dict: {
'success': bool,
'locked_item_ids': list[int],
'lock_expires_at': datetime,
'message': str
}
"""
from datetime import timedelta
try:
with transaction.atomic():
# Выбираем и блокируем экземпляры
items = ShowcaseItem.objects.select_for_update(
skip_locked=True
).filter(
id__in=showcase_item_ids,
status='available'
)
items = list(items)
if len(items) < len(showcase_item_ids):
# Некоторые экземпляры недоступны
unavailable_count = len(showcase_item_ids) - len(items)
return {
'success': False,
'locked_item_ids': [],
'lock_expires_at': None,
'message': f'{unavailable_count} экземпляр(ов) недоступны. Возможно, они уже в корзине или проданы.'
}
lock_expires_at = timezone.now() + timedelta(minutes=duration_minutes)
locked_ids = []
for item in items:
item.lock_for_cart(user, session_id, duration_minutes)
locked_ids.append(item.id)
return {
'success': True,
'locked_item_ids': locked_ids,
'lock_expires_at': lock_expires_at,
'message': f'Заблокировано {len(locked_ids)} экз.'
}
except Exception as e:
return {
'success': False,
'locked_item_ids': [],
'lock_expires_at': None,
'message': f'Ошибка блокировки: {str(e)}'
}
@staticmethod
def release_showcase_items_from_cart(showcase_item_ids, user):
"""
Снимает блокировку с указанных экземпляров.
Args:
showcase_item_ids: list[int] - ID экземпляров
user: User - пользователь (только владелец блокировки может снять)
Returns:
dict: {
'success': bool,
'released_count': int,
'message': str
}
"""
try:
# Снимаем блокировку только для экземпляров, заблокированных этим пользователем
updated = ShowcaseItem.objects.filter(
id__in=showcase_item_ids,
status='in_cart',
locked_by_user=user
).update(
status='available',
locked_by_user=None,
cart_lock_expires_at=None,
cart_session_id=None
)
return {
'success': True,
'released_count': updated,
'message': f'Освобождено {updated} экз.'
}
except Exception as e:
return {
'success': False,
'released_count': 0,
'message': f'Ошибка освобождения: {str(e)}'
}