""" Сервис управления витринами - резервирование, продажа и разбор витринных букетов. Новая архитектура с 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: 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 - кастомная цена за единицу (опционально) 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, is_delivery=False, pickup_warehouse=warehouse, status=completed_status, payment_method=payment_method, is_paid=True, modified_by=user ) # Определяем цену 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 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)}' }