- Обновлён pos/views.py: метод pos_checkout теперь создаёт Order и связанную модель Delivery - Обновлён showcase_manager.py: метод sell_showcase_item_to_customer использует новую архитектуру - Удалён устаревший скрипт create_demo_orders.py - Исправлена ошибка 'property is_delivery of Order object has no setter'
863 lines
34 KiB
Python
863 lines
34 KiB
Python
"""
|
||
Сервис управления витринами - резервирование, продажа и разбор витринных букетов.
|
||
|
||
Новая архитектура с ShowcaseItem:
|
||
- ProductKit = шаблон (рецепт букета)
|
||
- ShowcaseItem = физический экземпляр на витрине
|
||
- Каждый ShowcaseItem имеет свой набор Reservation
|
||
- Защита от двойной продажи через OneToOneField на sold_order_item
|
||
"""
|
||
from decimal import Decimal
|
||
from django.db import transaction, IntegrityError
|
||
from django.utils import timezone
|
||
from django.core.exceptions import ValidationError
|
||
|
||
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
|
||
|
||
|
||
class ShowcaseManager:
|
||
"""
|
||
Менеджер для работы с витринами и витринными букетами.
|
||
"""
|
||
|
||
@staticmethod
|
||
def reserve_kit_to_showcase(product_kit, showcase, quantity=1):
|
||
"""
|
||
Резервирует N экземпляров комплекта на витрину.
|
||
Создаёт ShowcaseItem для каждого экземпляра и резервы компонентов.
|
||
|
||
Args:
|
||
product_kit: ProductKit - шаблон комплекта
|
||
showcase: Showcase - витрина
|
||
quantity: int - количество экземпляров (например, 5 одинаковых букетов)
|
||
|
||
Returns:
|
||
dict: {
|
||
'success': bool,
|
||
'showcase_items': list[ShowcaseItem],
|
||
'reservations': list[Reservation],
|
||
'message': str
|
||
}
|
||
"""
|
||
if not showcase.is_active:
|
||
return {
|
||
'success': False,
|
||
'showcase_items': [],
|
||
'reservations': [],
|
||
'message': f'Витрина "{showcase.name}" не активна'
|
||
}
|
||
|
||
warehouse = showcase.warehouse
|
||
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}" не содержит компонентов'
|
||
}
|
||
|
||
# Создаём 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=product_to_reserve,
|
||
warehouse=warehouse,
|
||
showcase=showcase,
|
||
product_kit=product_kit,
|
||
showcase_item=showcase_item, # Связь с экземпляром!
|
||
quantity=kit_item.quantity,
|
||
status='reserved'
|
||
)
|
||
all_reservations.append(reservation)
|
||
|
||
# Обновляем агрегаты 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_id=product_id,
|
||
warehouse=warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
|
||
return {
|
||
'success': True,
|
||
'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_showcase_items(showcase_items, order_item):
|
||
"""
|
||
Продаёт указанные экземпляры с витрины.
|
||
Привязывает каждый ShowcaseItem к OrderItem и конвертирует резервы в продажи.
|
||
|
||
Args:
|
||
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():
|
||
# Собираем ID для перезагрузки с блокировкой
|
||
showcase_item_ids = [item.id for item in showcase_items]
|
||
|
||
# Перезагружаем объекты из БД с блокировкой для актуального статуса
|
||
showcase_items_locked = list(
|
||
ShowcaseItem.objects.select_for_update().filter(
|
||
id__in=showcase_item_ids
|
||
)
|
||
)
|
||
|
||
for showcase_item in showcase_items_locked:
|
||
# Проверка статуса перед продажей
|
||
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:
|
||
# Сначала устанавливаем order_item для правильного определения цены
|
||
reservation.order_item = order_item
|
||
reservation.save()
|
||
|
||
# Теперь создаём продажу с правильной ценой из OrderItem
|
||
SaleProcessor.create_sale_from_reservation(
|
||
reservation=reservation,
|
||
order=order
|
||
)
|
||
|
||
# Обновляем статус резерва
|
||
reservation.status = 'converted_to_sale'
|
||
reservation.converted_at = timezone.now()
|
||
reservation.save()
|
||
|
||
sold_count += 1
|
||
|
||
return {
|
||
'success': True,
|
||
'sold_count': sold_count,
|
||
'message': f'Продано {sold_count} экз.'
|
||
}
|
||
|
||
except ValidationError as e:
|
||
# Ошибка валидации (например, экземпляр уже продан)
|
||
return {
|
||
'success': False,
|
||
'sold_count': 0,
|
||
'message': str(e)
|
||
}
|
||
|
||
@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 - кастомная цена за единицу (опционально)
|
||
user: CustomUser - пользователь, выполняющий операцию
|
||
quantity: int - количество экземпляров для продажи
|
||
|
||
Returns:
|
||
dict: {
|
||
'success': bool,
|
||
'order': Order or None,
|
||
'sold_count': int,
|
||
'message': str
|
||
}
|
||
"""
|
||
warehouse = showcase.warehouse
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
# Находим доступные экземпляры этого комплекта на витрине
|
||
available_items = ShowcaseItem.objects.select_for_update(
|
||
skip_locked=True
|
||
).filter(
|
||
showcase=showcase,
|
||
product_kit=product_kit,
|
||
status='available'
|
||
)[:quantity]
|
||
|
||
available_items = list(available_items)
|
||
|
||
if len(available_items) < quantity:
|
||
return {
|
||
'success': False,
|
||
'order': None,
|
||
'sold_count': 0,
|
||
'message': f'Доступно только {len(available_items)} из {quantity} запрошенных экземпляров'
|
||
}
|
||
|
||
# Получаем статус "Завершён" для POS-продаж
|
||
completed_status = OrderStatus.objects.filter(
|
||
code='completed',
|
||
is_positive_end=True
|
||
).first()
|
||
|
||
if not completed_status:
|
||
completed_status = OrderStatus.objects.filter(
|
||
is_positive_end=True
|
||
).first()
|
||
|
||
# Создаём заказ
|
||
order = Order.objects.create(
|
||
customer=customer,
|
||
status=completed_status,
|
||
is_paid=True,
|
||
modified_by=user
|
||
)
|
||
|
||
# Создаём доставку (самовывоз)
|
||
from orders.models import Delivery
|
||
from django.utils import timezone as tz
|
||
now_local = tz.localtime(tz.now())
|
||
|
||
Delivery.objects.create(
|
||
order=order,
|
||
delivery_type=Delivery.DELIVERY_TYPE_PICKUP,
|
||
pickup_warehouse=warehouse,
|
||
delivery_date=now_local.date(),
|
||
time_from=now_local.time(),
|
||
time_to=now_local.time(),
|
||
cost=0
|
||
)
|
||
|
||
# Определяем цену
|
||
price = custom_price if custom_price else product_kit.actual_price
|
||
is_custom = custom_price is not None
|
||
|
||
# Создаём позицию заказа
|
||
order_item = OrderItem.objects.create(
|
||
order=order,
|
||
product_kit=product_kit,
|
||
quantity=len(available_items),
|
||
price=price,
|
||
is_custom_price=is_custom,
|
||
is_from_showcase=True,
|
||
showcase=showcase
|
||
)
|
||
|
||
# Продаём экземпляры
|
||
result = ShowcaseManager.sell_showcase_items(available_items, order_item)
|
||
|
||
if not result['success']:
|
||
raise ValidationError(result['message'])
|
||
|
||
# Пересчитываем итоговую сумму заказа
|
||
order.calculate_total()
|
||
order.amount_paid = order.total_amount
|
||
order.update_payment_status()
|
||
order.save()
|
||
|
||
return {
|
||
'success': True,
|
||
'order': order,
|
||
'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 reserve_product_to_showcase(product, showcase, product_kit, quantity_per_item):
|
||
"""
|
||
Дозарезервировать товар для всех АКТИВНЫХ экземпляров витринного комплекта.
|
||
НЕ блокирует при нехватке товара - допускает резерв "в минус".
|
||
|
||
Args:
|
||
product: Product – товар-компонент
|
||
showcase: Showcase – витрина
|
||
product_kit: ProductKit – шаблон витринного комплекта
|
||
quantity_per_item: Decimal или int – насколько увеличить количество
|
||
этого товара в ОДНОМ экземпляре букета
|
||
|
||
Returns:
|
||
dict: {
|
||
'success': bool,
|
||
'overdraft': Decimal, # сколько не хватает (может быть 0)
|
||
'message': str
|
||
}
|
||
"""
|
||
from decimal import Decimal
|
||
|
||
if not showcase or not product_kit:
|
||
return {
|
||
'success': True,
|
||
'overdraft': Decimal('0'),
|
||
'message': 'Нет витрины или комплекта – резервы не изменены'
|
||
}
|
||
|
||
quantity_per_item = Decimal(str(quantity_per_item))
|
||
if quantity_per_item <= 0:
|
||
return {
|
||
'success': True,
|
||
'overdraft': Decimal('0'),
|
||
'message': 'Изменение количества не требует дополнительного резерва'
|
||
}
|
||
|
||
# Берём только актуальные экземпляры на витрине
|
||
active_items = ShowcaseItem.objects.filter(
|
||
showcase=showcase,
|
||
product_kit=product_kit,
|
||
status__in=['available', 'in_cart'],
|
||
)
|
||
|
||
item_count = active_items.count()
|
||
if item_count == 0:
|
||
return {
|
||
'success': True,
|
||
'overdraft': Decimal('0'),
|
||
'message': 'Нет активных экземпляров на витрине – резервы не изменены'
|
||
}
|
||
|
||
warehouse = showcase.warehouse
|
||
total_needed = quantity_per_item * item_count
|
||
|
||
# Проверяем, хватает ли свободного остатка (НЕ блокируем, только считаем дефицит)
|
||
try:
|
||
stock = Stock.objects.get(product=product, warehouse=warehouse)
|
||
free_qty = stock.quantity_free
|
||
except Stock.DoesNotExist:
|
||
free_qty = Decimal('0')
|
||
|
||
overdraft = max(Decimal('0'), total_needed - free_qty)
|
||
|
||
# Создаём/увеличиваем резервы по каждому экземпляру (даже если не хватает!)
|
||
with transaction.atomic():
|
||
for showcase_item in active_items:
|
||
reservation, created = Reservation.objects.get_or_create(
|
||
product=product,
|
||
warehouse=warehouse,
|
||
showcase=showcase,
|
||
product_kit=product_kit,
|
||
showcase_item=showcase_item,
|
||
status='reserved',
|
||
defaults={'quantity': quantity_per_item},
|
||
)
|
||
if not created:
|
||
reservation.quantity = (reservation.quantity or Decimal('0')) + quantity_per_item
|
||
reservation.quantity_base = (reservation.quantity_base or Decimal('0')) + quantity_per_item
|
||
reservation.save(update_fields=['quantity', 'quantity_base'])
|
||
|
||
return {
|
||
'success': True,
|
||
'overdraft': overdraft,
|
||
'message': (
|
||
f'Зарезервировано {total_needed} ед. товара "{product.name}"'
|
||
+ (f' (не хватает {overdraft})' if overdraft > 0 else '')
|
||
),
|
||
}
|
||
|
||
@staticmethod
|
||
def release_showcase_reservation(product, showcase, product_kit, quantity_per_item):
|
||
"""
|
||
Освободить часть резерва товара для всех АКТИВНЫХ экземпляров витринного комплекта.
|
||
|
||
Args:
|
||
product: Product – товар-компонент
|
||
showcase: Showcase – витрина
|
||
product_kit: ProductKit – шаблон витринного комплекта
|
||
quantity_per_item: Decimal или int – насколько уменьшить количество
|
||
этого товара в ОДНОМ экземпляре букета
|
||
|
||
Returns:
|
||
dict: {
|
||
'success': bool,
|
||
'released': Decimal,
|
||
'message': str
|
||
}
|
||
"""
|
||
from decimal import Decimal
|
||
|
||
if not showcase or not product_kit:
|
||
return {
|
||
'success': True,
|
||
'released': Decimal('0'),
|
||
'message': 'Нет витрины или комплекта – резервы не изменены',
|
||
}
|
||
|
||
quantity_per_item = Decimal(str(quantity_per_item))
|
||
if quantity_per_item <= 0:
|
||
return {
|
||
'success': True,
|
||
'released': Decimal('0'),
|
||
'message': 'Изменение количества не требует освобождения резерва',
|
||
}
|
||
|
||
active_items = ShowcaseItem.objects.filter(
|
||
showcase=showcase,
|
||
product_kit=product_kit,
|
||
status__in=['available', 'in_cart'],
|
||
)
|
||
|
||
if not active_items.exists():
|
||
return {
|
||
'success': True,
|
||
'released': Decimal('0'),
|
||
'message': 'Нет активных экземпляров на витрине – резервы не изменены',
|
||
}
|
||
|
||
released_total = Decimal('0')
|
||
|
||
with transaction.atomic():
|
||
reservations = Reservation.objects.filter(
|
||
showcase_item__in=active_items,
|
||
product=product,
|
||
product_kit=product_kit,
|
||
showcase=showcase,
|
||
warehouse=showcase.warehouse,
|
||
status='reserved',
|
||
)
|
||
|
||
for res in reservations:
|
||
old_qty = res.quantity or Decimal('0')
|
||
new_qty = old_qty - quantity_per_item
|
||
|
||
if new_qty > 0:
|
||
res.quantity = new_qty
|
||
res.quantity_base = new_qty
|
||
res.save(update_fields=['quantity', 'quantity_base'])
|
||
released_amount = quantity_per_item
|
||
else:
|
||
# Полностью освобождаем резерв
|
||
released_amount = old_qty
|
||
res.status = 'released'
|
||
res.released_at = timezone.now()
|
||
res.save(update_fields=['status', 'released_at'])
|
||
|
||
released_total += released_amount
|
||
|
||
return {
|
||
'success': True,
|
||
'released': released_total,
|
||
'message': f'Освобождено {released_total} ед. товара "{product.name}"',
|
||
}
|
||
|
||
@staticmethod
|
||
def dismantle_showcase_item(showcase_item):
|
||
"""
|
||
Разбирает один экземпляр на витрине - освобождает его резервы.
|
||
|
||
Args:
|
||
showcase_item: ShowcaseItem - экземпляр для разбора
|
||
|
||
Returns:
|
||
dict: {
|
||
'success': bool,
|
||
'released_count': int,
|
||
'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_item=showcase_item,
|
||
status='reserved'
|
||
)
|
||
|
||
released_count = reservations.count()
|
||
affected_products = list(reservations.values_list('product_id', flat=True).distinct())
|
||
|
||
# Освобождаем резервы
|
||
reservations.update(
|
||
status='released',
|
||
released_at=timezone.now(),
|
||
showcase=None,
|
||
showcase_item=None
|
||
)
|
||
|
||
# Обновляем статус экземпляра
|
||
showcase_item.status = 'dismantled'
|
||
showcase_item.save()
|
||
|
||
# Обновляем агрегаты Stock
|
||
for product_id in affected_products:
|
||
try:
|
||
stock = Stock.objects.get(
|
||
product_id=product_id,
|
||
warehouse=warehouse
|
||
)
|
||
stock.refresh_from_batches()
|
||
except Stock.DoesNotExist:
|
||
pass
|
||
|
||
return {
|
||
'success': True,
|
||
'released_count': released_count,
|
||
'message': f'Экземпляр разобран, освобождено {released_count} резервов'
|
||
}
|
||
|
||
except Exception as e:
|
||
return {
|
||
'success': False,
|
||
'released_count': 0,
|
||
'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: список словарей с информацией о компонентах
|
||
"""
|
||
reservations = Reservation.objects.filter(
|
||
showcase=showcase,
|
||
status='reserved'
|
||
).select_related('product').values('product__id', 'product__name', 'quantity')
|
||
|
||
# Группируем по товарам
|
||
products_dict = {}
|
||
for res in reservations:
|
||
product_id = res['product__id']
|
||
if product_id not in products_dict:
|
||
products_dict[product_id] = {
|
||
'product_name': res['product__name'],
|
||
'quantity': Decimal('0')
|
||
}
|
||
products_dict[product_id]['quantity'] += res['quantity']
|
||
|
||
return [
|
||
{
|
||
'product_id': pid,
|
||
'product_name': data['product_name'],
|
||
'quantity': data['quantity']
|
||
}
|
||
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)}'
|
||
}
|