# -*- coding: utf-8 -*- from django.shortcuts import render from django.contrib.auth.decorators import login_required from django.http import JsonResponse from django.views.decorators.http import require_http_methods from django.db import transaction from django.db.models import Prefetch, OuterRef, Subquery from django.utils import timezone from decimal import Decimal, InvalidOperation import json from products.models import Product, ProductCategory, ProductKit, KitItem from inventory.models import Showcase, Reservation, Warehouse from inventory.services import ShowcaseManager def get_pos_warehouse(request): """ Получить текущий склад для POS из сессии или выбрать дефолтный. Логика выбора: 1. Если в сессии есть pos_warehouse_id - используем его 2. Иначе берем склад с is_default=True 3. Если нет is_default - берем первый активный 4. Если нет активных складов - None """ warehouse_id = request.session.get('pos_warehouse_id') if warehouse_id: try: return Warehouse.objects.get(id=warehouse_id, is_active=True) except Warehouse.DoesNotExist: # Склад был удален или деактивирован - сбрасываем сессию request.session.pop('pos_warehouse_id', None) # Ищем склад по умолчанию warehouse = Warehouse.objects.filter(is_active=True, is_default=True).first() if not warehouse: # Берем любой первый активный warehouse = Warehouse.objects.filter(is_active=True).first() # Сохраняем в сессию для следующих запросов if warehouse: request.session['pos_warehouse_id'] = warehouse.id return warehouse def get_showcase_kits_for_pos(): """ Получает витринные комплекты для отображения в POS. Возвращает список временных комплектов, которые зарезервированы на витринах. Оптимизировано: убраны N+1 запросы, используется один проход по данным. """ from products.models import ProductKitPhoto # Получаем все зарезервированные товары на витринах reserved_products = Reservation.objects.filter( showcase__isnull=False, showcase__is_active=True, status='reserved' ).values_list('product_id', flat=True).distinct() if not reserved_products: return [] # Prefetch для первого фото (thumbnail) first_photo_prefetch = Prefetch( 'photos', queryset=ProductKitPhoto.objects.order_by('order')[:1], to_attr='first_photo_list' ) # Находим комплекты с резервированными компонентами kits_with_showcase_items = ProductKit.objects.filter( is_temporary=True, status='active', kit_items__product_id__in=reserved_products ).prefetch_related( first_photo_prefetch, Prefetch('kit_items', queryset=KitItem.objects.select_related('product')) ).distinct() # Получаем все резервы для компонентов комплектов одним запросом all_kit_product_ids = set() kit_to_product_ids = {} # {kit.id: set(product_ids)} for kit in kits_with_showcase_items: # Используем prefetch'енные kit_items (без дополнительного запроса) product_ids = {item.product_id for item in kit.kit_items.all()} kit_to_product_ids[kit.id] = product_ids all_kit_product_ids.update(product_ids) # Один запрос для всех резервов all_reservations = Reservation.objects.filter( product_id__in=all_kit_product_ids, showcase__isnull=False, showcase__is_active=True, status='reserved' ).select_related('showcase').values('product_id', 'showcase_id', 'showcase__name') # Группируем резервы по product_id product_to_showcases = {} # {product_id: [(showcase_id, showcase_name), ...]} for res in all_reservations: product_id = res['product_id'] if product_id not in product_to_showcases: product_to_showcases[product_id] = [] product_to_showcases[product_id].append((res['showcase_id'], res['showcase__name'])) # Формируем результат showcase_kits = [] for kit in kits_with_showcase_items: product_ids = kit_to_product_ids[kit.id] # Находим общую витрину для всех компонентов showcases_for_kit = None for product_id in product_ids: showcases = product_to_showcases.get(product_id, []) if showcases_for_kit is None: showcases_for_kit = set(s[0] for s in showcases) else: showcases_for_kit &= set(s[0] for s in showcases) if showcases_for_kit: # Берём первую витрину showcase_id = list(showcases_for_kit)[0] showcase_name = next( (s[1] for pid in product_ids for s in product_to_showcases.get(pid, []) if s[0] == showcase_id), 'Неизвестно' ) # Используем prefetch'енное первое фото image_url = None if hasattr(kit, 'first_photo_list') and kit.first_photo_list: image_url = kit.first_photo_list[0].get_thumbnail_url() showcase_kits.append({ 'id': kit.id, 'name': kit.name, 'price': str(kit.actual_price), 'category_ids': [], 'in_stock': True, 'sku': kit.sku or '', 'image': image_url, 'type': 'showcase_kit', 'showcase_name': showcase_name, 'showcase_id': showcase_id }) return showcase_kits @login_required def pos_terminal(request): """ Tablet-friendly POS screen prototype. Shows categories and all items (products + kits) for quick tap-to-add. Оптимизировано: убрана стартовая загрузка витрин, только thumbnail фото. Работает только с одним выбранным складом. """ from products.models import ProductPhoto, ProductKitPhoto # Получаем текущий склад для POS current_warehouse = get_pos_warehouse(request) if not current_warehouse: # Нет активных складов - показываем ошибку from django.contrib import messages messages.error(request, 'Нет активных складов. Обратитесь к администратору.') context = { 'categories_json': json.dumps([]), 'items_json': json.dumps([]), 'showcase_kits_json': json.dumps([]), 'current_warehouse': None, 'warehouses': [], 'title': 'POS Terminal', } return render(request, 'pos/terminal.html', context) categories_qs = ProductCategory.objects.filter(is_active=True) # Prefetch для первого фото товаров first_product_photo = Prefetch( 'photos', queryset=ProductPhoto.objects.order_by('order')[:1], to_attr='first_photo_list' ) # Показываем все товары, не только in_stock products_qs = Product.objects.all().prefetch_related( 'categories', first_product_photo ) # Prefetch для первого фото комплектов first_kit_photo = Prefetch( 'photos', queryset=ProductKitPhoto.objects.order_by('order')[:1], to_attr='first_photo_list' ) # Показываем все комплекты (кроме временных) kits_qs = ProductKit.objects.filter(is_temporary=False).prefetch_related( 'categories', first_kit_photo ) categories = [{'id': c.id, 'name': c.name} for c in categories_qs] # Сериализация товаров с оптимизацией фото products = [] for p in products_qs: image_url = None if hasattr(p, 'first_photo_list') and p.first_photo_list: image_url = p.first_photo_list[0].get_thumbnail_url() products.append({ 'id': p.id, 'name': p.name, 'price': str(p.actual_price), 'category_ids': [c.id for c in p.categories.all()], 'in_stock': p.in_stock, 'sku': p.sku or '', 'image': image_url, 'type': 'product' }) # Сериализация комплектов с оптимизацией фото kits = [] for k in kits_qs: image_url = None if hasattr(k, 'first_photo_list') and k.first_photo_list: image_url = k.first_photo_list[0].get_thumbnail_url() kits.append({ 'id': k.id, 'name': k.name, 'price': str(k.actual_price), 'category_ids': [c.id for c in k.categories.all()], 'in_stock': False, # Комплекты всегда "Под заказ" 'sku': k.sku or '', 'image': image_url, 'type': 'kit' }) # Объединяем все позиции all_items = products + kits # Список всех активных складов для модалки выбора warehouses = Warehouse.objects.filter(is_active=True).order_by('-is_default', 'name') warehouses_list = [{ 'id': w.id, 'name': w.name, 'is_default': w.is_default } for w in warehouses] context = { 'categories_json': json.dumps(categories), 'items_json': json.dumps(all_items), 'showcase_kits_json': json.dumps([]), # Пустой массив - загрузка по API 'current_warehouse': { 'id': current_warehouse.id, 'name': current_warehouse.name }, 'warehouses': warehouses_list, 'title': 'POS Terminal', } return render(request, 'pos/terminal.html', context) @login_required @require_http_methods(["POST"]) def set_warehouse(request, warehouse_id): """ Установить текущий склад для POS. Сохраняет выбор в сессию. """ try: warehouse = Warehouse.objects.get(id=warehouse_id, is_active=True) request.session['pos_warehouse_id'] = warehouse.id return JsonResponse({ 'success': True, 'warehouse_id': warehouse.id, 'warehouse_name': warehouse.name }) except Warehouse.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Склад не найден или неактивен' }, status=404) @login_required @require_http_methods(["GET"]) def showcase_items_api(request): """ API endpoint для получения витринных букетов. Возвращает комплекты, зарезервированные на активных витринах. """ # Получаем все активные резервы на витринах showcase_reservations = Reservation.objects.filter( showcase__isnull=False, showcase__is_active=True, status='reserved' ).select_related('showcase', 'product').prefetch_related('product__photos') # Группируем по витринам showcases_dict = {} for res in showcase_reservations: showcase_id = res.showcase.id if showcase_id not in showcases_dict: showcases_dict[showcase_id] = { 'id': showcase_id, 'name': res.showcase.name, 'warehouse': res.showcase.warehouse.name, 'items': [] } # Добавляем товар в список showcases_dict[showcase_id]['items'].append({ 'product_id': res.product.id, 'product_name': res.product.name, 'quantity': str(res.quantity), 'image': res.product.photos.first().get_thumbnail_url() if res.product.photos.exists() else None, }) showcases_list = list(showcases_dict.values()) return JsonResponse({ 'success': True, 'showcases': showcases_list }) @login_required @require_http_methods(["GET"]) def get_showcases_api(request): """ API endpoint для получения списка активных витрин. Используется для выбора витрины при создании временного комплекта. """ showcases = Showcase.objects.filter(is_active=True).select_related('warehouse') showcases_data = [{ 'id': s.id, 'name': s.name, 'warehouse_name': s.warehouse.name, 'warehouse_id': s.warehouse.id, 'is_default_warehouse': s.warehouse.is_default # Для автовыбора } for s in showcases] return JsonResponse({ 'success': True, 'showcases': showcases_data }) @login_required @require_http_methods(["GET"]) def get_showcase_kits_api(request): """ API endpoint для получения актуального списка витринных комплектов. Используется для динамического обновления после создания нового букета. """ showcase_kits_data = get_showcase_kits_for_pos() return JsonResponse({ 'success': True, 'items': showcase_kits_data }) @login_required @require_http_methods(["GET"]) def get_product_kit_details(request, kit_id): """ API endpoint для получения полных данных комплекта для редактирования. """ try: kit = ProductKit.objects.prefetch_related('kit_items__product', 'photos').get(id=kit_id) # Получаем витрину, на которой размещен комплект showcase_reservation = Reservation.objects.filter( product__in=kit.kit_items.values_list('product_id', flat=True), showcase__isnull=False, showcase__is_active=True, status='reserved' ).select_related('showcase').first() showcase_id = showcase_reservation.showcase.id if showcase_reservation else None # Собираем данные о составе items = [{ 'product_id': ki.product.id, 'name': ki.product.name, 'qty': str(ki.quantity), 'price': str(ki.product.actual_price) } for ki in kit.kit_items.all()] # Фото photo_url = kit.photos.first().image.url if kit.photos.exists() else None return JsonResponse({ 'success': True, 'kit': { 'id': kit.id, 'name': kit.name, 'description': kit.description or '', 'price_adjustment_type': kit.price_adjustment_type, 'price_adjustment_value': str(kit.price_adjustment_value), 'sale_price': str(kit.sale_price) if kit.sale_price else '', 'base_price': str(kit.base_price), 'final_price': str(kit.actual_price), 'showcase_id': showcase_id, 'items': items, 'photo_url': photo_url } }) except ProductKit.DoesNotExist: return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404) @login_required @require_http_methods(["POST"]) def create_temp_kit_to_showcase(request): """ API endpoint для создания временного комплекта из корзины POS и резервирования его на витрину. Ожидаемый payload (multipart/form-data): - kit_name: Название комплекта - showcase_id: ID витрины - items: JSON список [{product_id, quantity}, ...] - description: Описание (опционально) - price_adjustment_type: Тип корректировки (опционально) - price_adjustment_value: Значение корректировки (опционально) - sale_price: Ручная финальная цена (опционально) - photo: Файл изображения (опционально) """ try: # Получаем данные из FormData kit_name = request.POST.get('kit_name', '').strip() showcase_id = request.POST.get('showcase_id') description = request.POST.get('description', '').strip() items_json = request.POST.get('items', '[]') price_adjustment_type = request.POST.get('price_adjustment_type', 'none') price_adjustment_value = Decimal(str(request.POST.get('price_adjustment_value', 0))) sale_price_str = request.POST.get('sale_price', '') photo_file = request.FILES.get('photo') # Парсим items из JSON items = json.loads(items_json) # Sale price (опционально) sale_price = None if sale_price_str: try: sale_price = Decimal(str(sale_price_str)) if sale_price <= 0: sale_price = None except (ValueError, InvalidOperation): sale_price = None # Валидация if not kit_name: return JsonResponse({ 'success': False, 'error': 'Необходимо указать название комплекта' }, status=400) if not showcase_id: return JsonResponse({ 'success': False, 'error': 'Необходимо выбрать витрину' }, status=400) if not items or len(items) == 0: return JsonResponse({ 'success': False, 'error': 'Корзина пуста. Добавьте товары для создания комплекта' }, status=400) # Проверяем что витрина существует и активна try: showcase = Showcase.objects.select_related('warehouse').get(id=showcase_id, is_active=True) except Showcase.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Витрина не найдена или неактивна' }, status=404) # Проверяем что все товары из корзины - это Product (не Kit) product_ids = [item['product_id'] for item in items] products = Product.objects.in_bulk(product_ids) if len(products) != len(product_ids): return JsonResponse({ 'success': False, 'error': 'Некоторые товары не найдены' }, status=400) # Агрегируем дубликаты (если один товар добавлен несколько раз) aggregated_items = {} for item in items: product_id = item['product_id'] quantity = Decimal(str(item['quantity'])) if product_id in aggregated_items: aggregated_items[product_id] += quantity else: aggregated_items[product_id] = quantity # Создаём временный комплект и резервируем на витрину with transaction.atomic(): # 1. Создаём ProductKit (is_temporary=True) kit = ProductKit.objects.create( name=kit_name, description=description, is_temporary=True, status='active', price_adjustment_type=price_adjustment_type, price_adjustment_value=price_adjustment_value, sale_price=sale_price ) # 2. Создаём KitItem для каждого товара из корзины for product_id, quantity in aggregated_items.items(): KitItem.objects.create( kit=kit, product=products[product_id], quantity=quantity ) # 3. Пересчитываем цену комплекта kit.recalculate_base_price() # 4. Загружаем фото, если есть if photo_file: from products.models import ProductKitPhoto ProductKitPhoto.objects.create( kit=kit, image=photo_file, order=0 ) # 5. Резервируем комплект на витрину result = ShowcaseManager.reserve_kit_to_showcase( product_kit=kit, showcase=showcase, quantity=1 ) if not result['success']: # Откатываем транзакцию через raise raise Exception(result['message']) return JsonResponse({ 'success': True, 'message': f'Временный комплект "{kit_name}" создан и зарезервирован на витрине "{showcase.name}"', 'kit_id': kit.id, 'kit_name': kit.name, 'kit_price': str(kit.actual_price), 'reservations_count': len(result['reservations']) }) except json.JSONDecodeError: return JsonResponse({ 'success': False, 'error': 'Неверный формат данных' }, status=400) except Exception as e: return JsonResponse({ 'success': False, 'error': f'Ошибка при создании комплекта: {str(e)}' }, status=500) @login_required @require_http_methods(["POST"]) def update_product_kit(request, kit_id): """ API endpoint для обновления временного комплекта. Payload (multipart/form-data): - kit_name: Новое название - description: Описание - items: JSON список [{product_id, quantity}, ...] - price_adjustment_type, price_adjustment_value, sale_price - photo: Новое фото (опционально) - remove_photo: '1' для удаления фото """ try: kit = ProductKit.objects.prefetch_related('kit_items__product', 'photos').get(id=kit_id, is_temporary=True) # Получаем данные kit_name = request.POST.get('kit_name', '').strip() description = request.POST.get('description', '').strip() items_json = request.POST.get('items', '[]') price_adjustment_type = request.POST.get('price_adjustment_type', 'none') price_adjustment_value = Decimal(str(request.POST.get('price_adjustment_value', 0))) sale_price_str = request.POST.get('sale_price', '') photo_file = request.FILES.get('photo') remove_photo = request.POST.get('remove_photo', '') == '1' items = json.loads(items_json) sale_price = None if sale_price_str: try: sale_price = Decimal(str(sale_price_str)) if sale_price <= 0: sale_price = None except (ValueError, InvalidOperation): sale_price = None # Валидация if not kit_name: return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400) if not items: return JsonResponse({'success': False, 'error': 'Состав не может быть пустым'}, status=400) # Проверяем товары product_ids = [item['product_id'] for item in items] products = Product.objects.in_bulk(product_ids) if len(products) != len(product_ids): return JsonResponse({'success': False, 'error': 'Некоторые товары не найдены'}, status=400) # Агрегируем количества aggregated_items = {} for item in items: product_id = item['product_id'] quantity = Decimal(str(item['quantity'])) aggregated_items[product_id] = aggregated_items.get(product_id, Decimal('0')) + quantity with transaction.atomic(): # Получаем старый состав для сравнения old_items = {ki.product_id: ki.quantity for ki in kit.kit_items.all()} # Получаем витрину для резервов showcase_reservation = Reservation.objects.filter( product__in=old_items.keys(), showcase__isnull=False, status='reserved' ).select_related('showcase').first() showcase = showcase_reservation.showcase if showcase_reservation else None # Вычисляем разницу в составе all_product_ids = set(old_items.keys()) | set(aggregated_items.keys()) for product_id in all_product_ids: old_qty = old_items.get(product_id, Decimal('0')) new_qty = aggregated_items.get(product_id, Decimal('0')) diff = new_qty - old_qty if diff > 0 and showcase: # Нужно дозарезервировать result = ShowcaseManager.reserve_product_to_showcase( product=products[product_id], showcase=showcase, quantity=diff ) if not result['success']: raise Exception(f"Недостаточно запасов: {result['message']}") elif diff < 0 and showcase: # Нужно освободить резерв ShowcaseManager.release_showcase_reservation( product=products[product_id], showcase=showcase, quantity=abs(diff) ) # Обновляем комплект kit.name = kit_name kit.description = description kit.price_adjustment_type = price_adjustment_type kit.price_adjustment_value = price_adjustment_value kit.sale_price = sale_price kit.save() # Обновляем состав kit.kit_items.all().delete() for product_id, quantity in aggregated_items.items(): KitItem.objects.create( kit=kit, product=products[product_id], quantity=quantity ) kit.recalculate_base_price() # Обновляем фото if remove_photo: kit.photos.all().delete() if photo_file: from products.models import ProductKitPhoto kit.photos.all().delete() # Удаляем старое ProductKitPhoto.objects.create(kit=kit, image=photo_file, order=0) return JsonResponse({ 'success': True, 'message': f'Комплект "{kit.name}" обновлён', 'kit_id': kit.id, 'kit_name': kit.name, 'kit_price': str(kit.actual_price) }) except ProductKit.DoesNotExist: return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Неверный формат данных'}, status=400) except Exception as e: return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)