# -*- coding: utf-8 -*- from django.shortcuts import render, get_object_or_404 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, DecimalField from django.db.models.functions import Coalesce from django.utils import timezone from django.core.exceptions import ValidationError from decimal import Decimal, InvalidOperation import json import logging from products.models import Product, ProductCategory, ProductKit, KitItem from inventory.models import Showcase, Reservation, Warehouse, Stock from inventory.services import ShowcaseManager logger = logging.getLogger(__name__) 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. НОВАЯ АРХИТЕКТУРА с ShowcaseItem: - Группирует экземпляры по (product_kit, showcase) - Показывает только ДОСТУПНЫЕ букеты (available) - В корзине (in_cart) тоже показываем для информации о текущих блокировках - Зарезервированные (reserved) НЕ показываем — они заняты под другие заказы - Возвращает available_count (сколько можно добавить) и total_count (всего видимых) """ from products.models import ProductKitPhoto from inventory.models import ShowcaseItem from django.db.models import Count, Q # Группируем ShowcaseItem по (product_kit, showcase) # Включаем только available и in_cart (в корзине текущего пользователя) # reserved НЕ показываем - они заняты под отложенные заказы all_items = ShowcaseItem.objects.filter( status__in=['available', 'in_cart'], showcase__is_active=True ).select_related( 'product_kit', 'showcase' ).values( 'product_kit_id', 'product_kit__name', 'product_kit__sku', 'product_kit__price', 'product_kit__sale_price', 'showcase_id', 'showcase__name' ).annotate( total_count=Count('id'), available_count=Count('id', filter=Q(status='available')) ).order_by('showcase__name', 'product_kit__name') if not all_items: return [] # Получаем ID всех комплектов для загрузки фото kit_ids = list(set(item['product_kit_id'] for item in all_items)) # Загружаем первые фото для комплектов kit_photos = {} photos = ProductKitPhoto.objects.filter( kit_id__in=kit_ids ).order_by('kit_id', 'order') for photo in photos: if photo.kit_id not in kit_photos: if photo and photo.image: thumbnail_url = photo.get_thumbnail_url() # Если миниатюра не найдена, возвращаем None (без фото) if not thumbnail_url: thumbnail_url = None kit_photos[photo.kit_id] = thumbnail_url # Формируем результат showcase_kits = [] for item in all_items: kit_id = item['product_kit_id'] showcase_id = item['showcase_id'] # Получаем IDs только ДОСТУПНЫХ экземпляров этой группы available_item_ids = list(ShowcaseItem.objects.filter( product_kit_id=kit_id, showcase_id=showcase_id, status='available' ).values_list('id', flat=True)) # Определяем актуальную цену price = item['product_kit__sale_price'] or item['product_kit__price'] showcase_kits.append({ 'id': kit_id, 'name': item['product_kit__name'], 'price': str(price), 'category_ids': [], 'in_stock': item['available_count'] > 0, # Есть ли доступные 'sku': item['product_kit__sku'] or '', 'image': kit_photos.get(kit_id), 'type': 'showcase_kit', 'showcase_name': item['showcase__name'], 'showcase_id': showcase_id, # Количества 'available_count': item['available_count'], # Сколько можно добавить 'total_count': item['total_count'], # Всего на витрине (включая в корзине) 'showcase_item_ids': available_item_ids # IDs только доступных }) return showcase_kits @login_required def pos_terminal(request): """ Tablet-friendly POS screen prototype. Товары загружаются прогрессивно через API при клике на категорию. Работает только с одним выбранным складом. """ from customers.models import Customer from django.core.cache import cache # Получаем текущий склад для POS current_warehouse = get_pos_warehouse(request) if not current_warehouse: # Нет активных складов - информация отображается в блоке склада в шаблоне # Получаем системного клиента для корректного рендеринга JSON в шаблоне system_customer, _ = Customer.get_or_create_system_customer() context = { 'categories_json': json.dumps([]), 'items_json': json.dumps([]), 'showcase_kits_json': json.dumps([]), 'current_warehouse': None, 'warehouses': [], 'system_customer_json': json.dumps({ 'id': system_customer.id, 'name': system_customer.name, 'wallet_balance': float(system_customer.wallet_balance) }), 'selected_customer_json': json.dumps({ 'id': system_customer.id, 'name': system_customer.name, 'wallet_balance': float(system_customer.wallet_balance) }), 'cart_data': json.dumps({}), 'title': 'POS Terminal', } return render(request, 'pos/terminal.html', context) # Получаем или создаём системного клиента system_customer, _ = Customer.get_or_create_system_customer() # Пытаемся получить сохраненного клиента из Redis selected_customer = None redis_key = f'pos:customer:{request.user.id}:{current_warehouse.id}' cached_customer_data = cache.get(redis_key) if cached_customer_data: # Проверяем что клиент еще существует в БД try: customer = Customer.objects.get(id=cached_customer_data['customer_id']) selected_customer = { 'id': customer.id, 'name': customer.name, 'wallet_balance': float(customer.wallet_balance) } except Customer.DoesNotExist: # Клиент был удален - очищаем кэш cache.delete(redis_key) # Если нет сохраненного клиента - используем системного if not selected_customer: selected_customer = { 'id': system_customer.id, 'name': system_customer.name, 'wallet_balance': float(system_customer.wallet_balance) } # Пытаемся получить сохраненную корзину из Redis cart_redis_key = f'pos:cart:{request.user.id}:{current_warehouse.id}' cached_cart_data = cache.get(cart_redis_key) cart_data = {} if cached_cart_data: # Валидируем товары и комплекты в корзине from products.models import Product, ProductKit from inventory.models import ShowcaseItem for cart_key, item in cached_cart_data.items(): try: if item['type'] == 'product': # Проверяем что товар существует Product.objects.get(id=item['id']) cart_data[cart_key] = item elif item['type'] == 'kit': # Обычный комплект - только проверяем существование ProductKit.objects.get(id=item['id']) cart_data[cart_key] = item elif item['type'] == 'showcase_kit': # Витринный комплект - проверяем существование И актуальность блокировки kit = ProductKit.objects.get(id=item['id']) # Получаем showcase_item_ids из корзины (если есть) showcase_item_ids = item.get('showcase_item_ids', []) if showcase_item_ids: # Проверяем, что все указанные ShowcaseItem заблокированы на текущего пользователя locked_items = ShowcaseItem.objects.filter( id__in=showcase_item_ids, product_kit=kit, status='in_cart', locked_by_user=request.user, cart_lock_expires_at__gt=timezone.now() ) locked_count = locked_items.count() if locked_count > 0: # Обновляем количество на актуальное (на случай если часть блокировок истекла) item['qty'] = locked_count item['showcase_item_ids'] = list(locked_items.values_list('id', flat=True)) cart_data[cart_key] = item # Если ни один не заблокирован - не добавляем в корзину # Если showcase_item_ids отсутствует - не добавляем (устаревший формат) except (Product.DoesNotExist, ProductKit.DoesNotExist): # Товар или комплект удален - пропускаем continue # Загружаем только категории categories_qs = ProductCategory.objects.filter(is_active=True) categories = [{'id': c.id, 'name': c.name} for c in categories_qs] # Список всех активных складов для модалки выбора 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([]), # Пустой массив - загрузка по API при клике на категорию 'showcase_kits_json': json.dumps([]), # Пустой массив - загрузка по API 'categories': categories_qs, # QuerySet для компонента product_search_picker 'current_warehouse': { 'id': current_warehouse.id, 'name': current_warehouse.name }, 'warehouses': warehouses_list, 'system_customer_json': json.dumps({ 'id': system_customer.id, 'name': system_customer.name, 'wallet_balance': float(system_customer.wallet_balance) }), 'selected_customer_json': json.dumps(selected_customer), # Текущий выбранный клиент (из Redis или системный) 'cart_data': json.dumps(cart_data), # Сохраненная корзина из Redis 'title': 'POS Terminal', } return render(request, 'pos/terminal.html', context) @login_required @require_http_methods(["POST"]) def save_cart(request): """ Сохранить корзину POS в Redis для текущего пользователя и склада. TTL: 2 часа (7200 секунд) """ from django.core.cache import cache import json # Получаем текущий склад current_warehouse = get_pos_warehouse(request) if not current_warehouse: return JsonResponse({'success': False, 'error': 'Не выбран активный склад'}, status=400) try: # Получаем данные корзины из тела запроса body = json.loads(request.body) cart_data = body.get('cart', {}) # Валидация структуры данных корзины if not isinstance(cart_data, dict): return JsonResponse({'success': False, 'error': 'Неверный формат данных корзины'}, status=400) # Сохраняем в Redis redis_key = f'pos:cart:{request.user.id}:{current_warehouse.id}' cache.set(redis_key, cart_data, timeout=7200) # 2 часа return JsonResponse({ 'success': True, 'items_count': len(cart_data) }) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400) except Exception as e: return JsonResponse({'success': False, 'error': str(e)}, status=500) @login_required @require_http_methods(["POST"]) def set_customer(request, customer_id): """ Сохранить выбранного клиента в Redis для текущего пользователя и склада. TTL: 2 часа (7200 секунд) """ from customers.models import Customer from django.core.cache import cache # Получаем текущий склад current_warehouse = get_pos_warehouse(request) if not current_warehouse: return JsonResponse({'success': False, 'error': 'Не выбран активный склад'}, status=400) # Проверяем, что клиент существует try: customer = Customer.objects.get(id=customer_id) except Customer.DoesNotExist: return JsonResponse({'success': False, 'error': 'Клиент не найден'}, status=404) # Сохраняем в Redis redis_key = f'pos:customer:{request.user.id}:{current_warehouse.id}' customer_data = { 'customer_id': customer.id, 'customer_name': customer.name, 'wallet_balance': float(customer.wallet_balance) } cache.set(redis_key, customer_data, timeout=7200) # 2 часа return JsonResponse({ 'success': True, 'customer_id': customer.id, 'customer_name': customer.name, 'wallet_balance': float(customer.wallet_balance) }) @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 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': s.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 для получения актуального списка витринных комплектов. Используется для динамического обновления после создания нового букета. Включает информацию о блокировках в корзинах. """ from datetime import timedelta from inventory.models import ShowcaseItem # Очищаем только ИСТЁКШИЕ блокировки (cart_lock_expires_at < now) expired_locks = ShowcaseItem.objects.filter( status='in_cart', cart_lock_expires_at__lt=timezone.now() ) expired_count = expired_locks.update( status='available', locked_by_user=None, cart_lock_expires_at=None, cart_session_id=None ) if expired_count > 0: logger.info(f'Очищено {expired_count} истёкших блокировок ShowcaseItem') showcase_kits_data = get_showcase_kits_for_pos() # Добавляем информацию о блокировках для каждого комплекта kit_ids = [kit['id'] for kit in showcase_kits_data] # Получаем активные блокировки (не истекшие) active_locks = Reservation.objects.filter( product_kit_id__in=kit_ids, cart_lock_expires_at__gt=timezone.now(), status='reserved' ).select_related('locked_by_user').values( 'product_kit_id', 'locked_by_user_id', 'locked_by_user__username', 'cart_lock_expires_at' ) # Индексируем блокировки по kit_id locks_by_kit = {} for lock in active_locks: kit_id = lock['product_kit_id'] if kit_id not in locks_by_kit: locks_by_kit[kit_id] = lock # Дополняем данные о комплектах информацией о блокировках for kit in showcase_kits_data: lock_info = locks_by_kit.get(kit['id']) if lock_info: is_locked_by_me = lock_info['locked_by_user_id'] == request.user.id kit['is_locked'] = True kit['locked_by_me'] = is_locked_by_me kit['locked_by_user'] = lock_info['locked_by_user__username'] kit['lock_expires_at'] = lock_info['cart_lock_expires_at'].isoformat() else: kit['is_locked'] = False kit['locked_by_me'] = False return JsonResponse({ 'success': True, 'items': showcase_kits_data }) @login_required @require_http_methods(["POST"]) def add_showcase_kit_to_cart(request, kit_id): """ API endpoint для добавления N экземпляров витринного комплекта в корзину. НОВАЯ АРХИТЕКТУРА с ShowcaseItem: - Принимает quantity (количество экземпляров) - Блокирует конкретные ShowcaseItem - Возвращает список заблокированных showcase_item_ids Payload (JSON): { "quantity": 2 } Returns: JSON: { 'success': bool, 'message': str, 'locked_item_ids': list[int], 'lock_expires_at': ISO datetime, 'error': str (если success=False) } """ from datetime import timedelta from inventory.models import ShowcaseItem from inventory.services.showcase_manager import ShowcaseManager try: # Получаем количество из тела запроса try: body = json.loads(request.body) if request.body else {} quantity = int(body.get('quantity', 1)) except (json.JSONDecodeError, ValueError): quantity = 1 if quantity < 1: return JsonResponse({ 'success': False, 'error': 'Количество должно быть больше 0' }, status=400) # Проверяем что комплект существует kit = ProductKit.objects.get( id=kit_id, is_temporary=True, status='active' ) with transaction.atomic(): # Находим доступные экземпляры этого комплекта available_items = ShowcaseItem.objects.select_for_update( skip_locked=True ).filter( product_kit_id=kit_id, status='available', showcase__is_active=True )[:quantity] available_items = list(available_items) if len(available_items) < quantity: return JsonResponse({ 'success': False, 'error': f'Доступно только {len(available_items)} из {quantity} запрошенных экземпляров. ' f'Возможно, часть уже в корзине другого кассира.' }, status=409) # Блокируем экземпляры lock_expires_at = timezone.now() + timedelta(minutes=30) session_id = request.session.session_key or '' locked_ids = [] for item in available_items: item.lock_for_cart( user=request.user, session_id=session_id, duration_minutes=30 ) locked_ids.append(item.id) return JsonResponse({ 'success': True, 'message': f'Добавлено {len(locked_ids)} экз. "{kit.name}" в корзину', 'locked_item_ids': locked_ids, 'lock_expires_at': lock_expires_at.isoformat(), 'locked_until_minutes': 30 }) except ProductKit.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Витринный комплект не найден' }, status=404) except Exception as e: logger.error(f'Ошибка добавления витринного букета в корзину: {str(e)}', exc_info=True) return JsonResponse({ 'success': False, 'error': f'Ошибка при добавлении в корзину: {str(e)}' }, status=500) @login_required @require_http_methods(["POST"]) def remove_showcase_kit_from_cart(request, kit_id): """ API endpoint для снятия блокировки витринных экземпляров при удалении из корзины. НОВАЯ АРХИТЕКТУРА с ShowcaseItem: - Принимает опционально showcase_item_ids для снятия блокировки с конкретных экземпляров - Если не указаны - снимает блокировку со всех экземпляров данного комплекта Payload (JSON): { "showcase_item_ids": [10, 11] } // опционально Returns: JSON: { 'success': bool, 'message': str, 'released_count': int, 'error': str (если success=False) } """ from inventory.models import ShowcaseItem try: # Получаем комплект kit = ProductKit.objects.get( id=kit_id, is_temporary=True ) # Получаем список ID экземпляров из тела запроса (опционально) try: body = json.loads(request.body) if request.body else {} showcase_item_ids = body.get('showcase_item_ids', []) except (json.JSONDecodeError, ValueError): showcase_item_ids = [] # Базовый фильтр - экземпляры этого комплекта, заблокированные текущим пользователем qs = ShowcaseItem.objects.filter( product_kit=kit, status='in_cart', locked_by_user=request.user ) # Если указаны конкретные ID - фильтруем только их if showcase_item_ids: qs = qs.filter(id__in=showcase_item_ids) # Снимаем блокировку updated_count = qs.update( status='available', locked_by_user=None, cart_lock_expires_at=None, cart_session_id=None ) if updated_count == 0: return JsonResponse({ 'success': True, 'message': 'Экземпляры не были заблокированы вами', 'released_count': 0 }) return JsonResponse({ 'success': True, 'message': f'Освобождено {updated_count} экз. "{kit.name}"', 'released_count': updated_count }) except ProductKit.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Комплект не найден' }, status=404) except Exception as e: logger.error(f'Ошибка снятия блокировки витринного букета: {str(e)}', exc_info=True) return JsonResponse({ 'success': False, 'error': f'Ошибка при снятии блокировки: {str(e)}' }, status=500) @login_required @require_http_methods(["POST"]) def release_all_my_showcase_locks(request): """ API endpoint для сброса ВСЕХ блокировок витринных букетов текущего пользователя. Используется при загрузке POS если корзина пустая, чтобы освободить зависшие блокировки. """ from inventory.models import ShowcaseItem try: # Снимаем ВСЕ блокировки текущего пользователя updated_count = ShowcaseItem.objects.filter( status='in_cart', locked_by_user=request.user ).update( status='available', locked_by_user=None, cart_lock_expires_at=None, cart_session_id=None ) return JsonResponse({ 'success': True, 'message': f'Освобождено {updated_count} блокировок', 'released_count': updated_count }) except Exception as e: logger.error(f'Ошибка сброса блокировок: {str(e)}', exc_info=True) return JsonResponse({ 'success': False, 'error': str(e) }, status=500) @login_required @require_http_methods(["GET"]) def get_items_api(request): """ API endpoint для получения товаров и комплектов с пагинацией и поиском. Параметры: - category_id: ID категории (опционально, для фильтрации) - query: поисковый запрос по name или sku (опционально) - page: номер страницы (по умолчанию 1) - page_size: размер страницы (по умолчанию 60) Сортировка по умолчанию: по свободному остатку (available - reserved) DESC """ from products.models import ProductPhoto, ProductKitPhoto from django.core.paginator import Paginator from django.db.models import Q # Получаем текущий склад current_warehouse = get_pos_warehouse(request) if not current_warehouse: return JsonResponse({ 'success': False, 'error': 'Нет активного склада' }, status=400) # Параметры пагинации и поиска category_id = request.GET.get('category_id') search_query = request.GET.get('query', '').strip() page = int(request.GET.get('page', 1)) page_size = int(request.GET.get('page_size', 60)) # Prefetch для первого фото товаров first_product_photo = Prefetch( 'photos', queryset=ProductPhoto.objects.order_by('order')[:1], to_attr='first_photo_list' ) # Подзапросы для остатков по текущему складу stock_available_subquery = Stock.objects.filter( product=OuterRef('pk'), warehouse=current_warehouse ).values('quantity_available')[:1] stock_reserved_subquery = Stock.objects.filter( product=OuterRef('pk'), warehouse=current_warehouse ).values('quantity_reserved')[:1] # Фильтруем только активные товары products_qs = Product.objects.filter(status='active').annotate( available_qty=Coalesce( Subquery(stock_available_subquery, output_field=DecimalField()), Decimal('0'), output_field=DecimalField() ), reserved_qty=Coalesce( Subquery(stock_reserved_subquery, output_field=DecimalField()), Decimal('0'), output_field=DecimalField() ) ).prefetch_related( 'categories', first_product_photo ) # Фильтруем по категории, если указана if category_id: products_qs = products_qs.filter(categories__id=category_id) # Фильтруем по поисковому запросу (name или sku) if search_query: products_qs = products_qs.filter( Q(name__icontains=search_query) | Q(sku__icontains=search_query) ) # Сериализуем товары products = [] for p in products_qs: image_url = None if hasattr(p, 'first_photo_list') and p.first_photo_list: photo = p.first_photo_list[0] if photo and photo.image: image_url = photo.get_thumbnail_url() # Если миниатюра не найдена, возвращаем None (без фото) if not image_url: image_url = None available = p.available_qty reserved = p.reserved_qty free_qty = available - reserved # Подсчитываем активные единицы продажи sales_units_count = p.sales_units.filter(is_active=True).count() has_sales_units = sales_units_count > 0 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', 'available_qty': str(available), 'reserved_qty': str(reserved), 'free_qty': str(free_qty), # Передаём как строку для сохранения точности 'free_qty_sort': float(free_qty), # Для сортировки отдельное поле 'sales_units_count': sales_units_count, 'has_sales_units': has_sales_units }) # 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, status='active' ).prefetch_related( 'categories', first_kit_photo ) # Фильтруем комплекты по категории, если указана if category_id: kits_qs = kits_qs.filter(categories__id=category_id) # Фильтруем комплекты по поисковому запросу (name или sku) if search_query: kits_qs = kits_qs.filter( Q(name__icontains=search_query) | Q(sku__icontains=search_query) ) # Сериализуем комплекты kits = [] for k in kits_qs: image_url = None if hasattr(k, 'first_photo_list') and k.first_photo_list: photo = k.first_photo_list[0] if photo and photo.image: image_url = photo.get_thumbnail_url() # Если миниатюра не найдена, возвращаем None (без фото) if not image_url: image_url = None # Рассчитываем доступное количество комплектов на текущем складе available_kits = k.calculate_available_quantity(warehouse=current_warehouse) 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': available_kits > 0, # Доступен если можно собрать хоть один комплект 'sku': k.sku or '', 'image': image_url, 'type': 'kit', 'free_qty': str(available_kits), # Количество комплектов которые можно собрать 'free_qty_sort': float(available_kits) # Для сортировки }) # Объединяем и сортируем по free_qty_sort DESC all_items = products + kits all_items.sort(key=lambda x: x['free_qty_sort'], reverse=True) # Пагинация paginator = Paginator(all_items, page_size) page_obj = paginator.get_page(page) # Удаляем временное поле free_qty_sort из результата (оставляем free_qty как строку) items_to_return = [] for item in page_obj.object_list: item_copy = item.copy() item_copy.pop('free_qty_sort', None) # Удаляем только free_qty_sort items_to_return.append(item_copy) return JsonResponse({ 'success': True, 'items': items_to_return, 'has_more': page_obj.has_next(), 'next_page': page_obj.next_page_number() if page_obj.has_next() else None, 'total_pages': paginator.num_pages, 'total_items': paginator.count }) @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 = None if kit.photos.exists(): first_photo = kit.photos.first() if first_photo and first_photo.image: photo_url = first_photo.get_thumbnail_url() # Если миниатюра не найдена, возвращаем None (без фото) if not photo_url: photo_url = 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') showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину # Парсим 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, showcase=showcase ) # 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. Резервируем комплект на витрину (создаём N экземпляров) result = ShowcaseManager.reserve_kit_to_showcase( product_kit=kit, showcase=showcase, quantity=showcase_kit_quantity ) if not result['success']: # Откатываем транзакцию через raise raise Exception(result['message']) # Получаем ID созданных ShowcaseItem showcase_item_ids = [item.id for item in result.get('showcase_items', [])] created_count = len(showcase_item_ids) qty_text = f'{created_count} шт.' if created_count > 1 else '' return JsonResponse({ 'success': True, 'message': f'Букет "{kit_name}" ({qty_text}) создан на витрине "{showcase.name}"' if created_count > 1 else f'Букет "{kit_name}" создан на витрине "{showcase.name}"', 'kit_id': kit.id, 'kit_name': kit.name, 'kit_price': str(kit.actual_price), 'reservations_count': len(result['reservations']), 'showcase_item_ids': showcase_item_ids, 'available_count': created_count, 'warnings': result.get('warnings') }) except json.JSONDecodeError as e: logger.error(f'JSON decode error при создании временного комплекта: {str(e)}') return JsonResponse({ 'success': False, 'error': 'Неверный формат данных корзины' }, status=400) except Showcase.DoesNotExist: logger.warning(f'Попытка создать комплект на несуществующей витрине (ID: {request.POST.get("showcase_id")})') return JsonResponse({ 'success': False, 'error': 'Выбранная витрина не найдена' }, status=404) except ValidationError as e: logger.error(f'Validation error при создании временного комплекта: {str(e)}', exc_info=True) return JsonResponse({ 'success': False, 'error': f'Ошибка валидации: {str(e)}' }, status=400) except Exception as e: # Детальное логирование для диагностики 500 ошибок logger.error( f'Непредвиденная ошибка при создании временного комплекта:\n' f' Название: {request.POST.get("kit_name")}\n' f' Витрина ID: {request.POST.get("showcase_id")}\n' f' Товары: {request.POST.get("items")}\n' f' Пользователь: {request.user.username}\n' f' Ошибка: {str(e)}', exc_info=True ) # Проверяем на типичные ошибки и даём понятные сообщения error_msg = str(e).lower() if 'недостаточно' in error_msg or 'insufficient' in error_msg or 'stock' in error_msg: return JsonResponse({ 'success': False, 'error': f'Недостаточно товара на складе. {str(e)}' }, status=400) elif 'integrity' in error_msg or 'constraint' in error_msg: return JsonResponse({ 'success': False, 'error': 'Ошибка целостности данных. Проверьте, что все товары существуют и витрина активна.' }, status=400) elif 'lock' in error_msg or 'blocked' in error_msg or 'заблокирован' in error_msg: return JsonResponse({ 'success': False, 'error': f'Конфликт блокировки: {str(e)}' }, status=409) else: 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_kit=kit, showcase__isnull=False, status='reserved' ).select_related('showcase').first() showcase = showcase_reservation.showcase if showcase_reservation else kit.showcase # Вычисляем разницу в составе и собираем информацию о дефиците all_product_ids = set(old_items.keys()) | set(aggregated_items.keys()) stock_warnings = [] # Список товаров с нехваткой остатков 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, product_kit=kit, quantity_per_item=diff, ) # Собираем информацию о дефиците if result.get('overdraft', Decimal('0')) > 0: stock_warnings.append({ 'product_name': products[product_id].name, 'overdraft': str(result['overdraft']) }) elif diff < 0 and showcase: # Нужно освободить резерв (на каждый экземпляр) ShowcaseManager.release_showcase_reservation( product=products[product_id], showcase=showcase, product_kit=kit, quantity_per_item=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), 'stock_warning': len(stock_warnings) > 0, 'stock_warnings': stock_warnings }) 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) @login_required @require_http_methods(["POST"]) def disassemble_product_kit(request, kit_id): """ Разбирает витринный комплект: освобождает резервы и устанавливает статус 'discontinued'. Args: request: HTTP запрос kit_id: ID комплекта для разбора Returns: JSON: { 'success': bool, 'released_count': int, 'message': str, 'error': str (если failed) } """ try: # Получаем комплект с витриной (только временные комплекты) kit = ProductKit.objects.select_related('showcase').get(id=kit_id, is_temporary=True) # Проверяем, что комплект ещё не разобран if kit.status == 'discontinued': return JsonResponse({ 'success': False, 'error': 'Комплект уже разобран (статус: Снят)' }, status=400) # Проверяем, что у комплекта есть привязанная витрина if not kit.showcase: return JsonResponse({ 'success': False, 'error': 'Комплект не привязан к витрине' }, status=400) # Освобождаем резервы и устанавливаем статус # ShowcaseManager.dismantle_from_showcase уже использует transaction.atomic() result = ShowcaseManager.dismantle_from_showcase( showcase=kit.showcase, product_kit=kit ) if not result['success']: return JsonResponse({ 'success': False, 'error': result['message'] }, status=400) # Устанавливаем статус комплекта 'discontinued' kit.discontinue(user=request.user) return JsonResponse({ 'success': True, 'released_count': result['released_count'], 'message': f'Комплект "{kit.name}" разобран. Статус изменён на "Снят".' }) except ProductKit.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Комплект не найден' }, status=404) except Exception as e: return JsonResponse({ 'success': False, 'error': f'Ошибка при разборе: {str(e)}' }, status=500) @login_required @require_http_methods(["POST"]) def pos_checkout(request): """ Создать заказ и провести оплату в POS-терминале. Payload (JSON): { "customer_id": int, "warehouse_id": int, "items": [ {"type": "product"|"kit"|"showcase_kit", "id": int, "quantity": float, "price": float}, ... ], "payments": [ {"payment_method": "cash"|"card"|"online"|"account_balance", "amount": float, "notes": str}, ... ], "notes": str (optional), "promo_code": str (optional) - Промокод для скидки } """ from orders.models import Order, OrderItem, OrderStatus from orders.services.transaction_service import TransactionService from customers.models import Customer from products.models import Product, ProductKit from inventory.models import Warehouse, Reservation from django.db import transaction as db_transaction from decimal import Decimal import json try: body = json.loads(request.body) # Валидация customer_id = body.get('customer_id') warehouse_id = body.get('warehouse_id') items_data = body.get('items', []) payments_data = body.get('payments', []) order_notes = body.get('notes', '') promo_code = body.get('promo_code') # Промокод для скидки if not customer_id: return JsonResponse({'success': False, 'error': 'Не указан клиент'}, status=400) if not warehouse_id: return JsonResponse({'success': False, 'error': 'Не указан склад'}, status=400) if not items_data: return JsonResponse({'success': False, 'error': 'Корзина пуста'}, status=400) if not payments_data: return JsonResponse({'success': False, 'error': 'Не указаны способы оплаты'}, status=400) # Получаем объекты customer = get_object_or_404(Customer, id=customer_id) warehouse = get_object_or_404(Warehouse, id=warehouse_id, is_active=True) try: completed_status = OrderStatus.objects.get(code='completed', is_system=True) except OrderStatus.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Статус "Выполнен" не найден в системе' }, status=500) # Атомарная операция with db_transaction.atomic(): # 1. Создаём заказ с текущей датой и временем в локальном часовом поясе (Europe/Minsk) from django.utils import timezone as tz from orders.models import Delivery now_utc = tz.now() # Текущее время в UTC now_local = tz.localtime(now_utc) # Конвертируем в локальный часовой пояс (Europe/Minsk) current_time = now_local.time() # Извлекаем время в минском часовом поясе order = Order.objects.create( customer=customer, status=completed_status, # Сразу "Выполнен" special_instructions=order_notes, modified_by=request.user ) # Создаём связанную доставку (самовывоз для POS) Delivery.objects.create( order=order, delivery_type=Delivery.DELIVERY_TYPE_PICKUP, # POS - всегда самовывоз pickup_warehouse=warehouse, delivery_date=now_local.date(), # Текущая дата в минском часовом поясе time_from=current_time, # Текущее время (Минск) time_to=current_time, # То же время (точное время) cost=0 # Самовывоз бесплатный ) # 2. Добавляем товары from inventory.models import ShowcaseItem from inventory.services.showcase_manager import ShowcaseManager for item_data in items_data: item_type = item_data['type'] item_id = item_data['id'] quantity = Decimal(str(item_data['quantity'])) price = Decimal(str(item_data['price'])) if item_type == 'product': product = Product.objects.get(id=item_id) # Получаем sales_unit_id если передан sales_unit_id = item_data.get('sales_unit_id') sales_unit = None if sales_unit_id: from products.models import ProductSalesUnit try: sales_unit = ProductSalesUnit.objects.get(id=sales_unit_id, product=product) except ProductSalesUnit.DoesNotExist: pass OrderItem.objects.create( order=order, product=product, quantity=quantity, price=price, is_custom_price=False, sales_unit=sales_unit ) elif item_type == 'kit': # Обычный комплект (не витринный) kit = ProductKit.objects.get(id=item_id) OrderItem.objects.create( order=order, product_kit=kit, quantity=quantity, price=price, is_custom_price=False ) elif item_type == 'showcase_kit': # Витринный букет - работаем через ShowcaseItem kit = ProductKit.objects.get(id=item_id) showcase_item_ids = item_data.get('showcase_item_ids', []) if not showcase_item_ids: # Обратная совместимость: если showcase_item_ids не передан, # ищем заблокированные экземпляры этого комплекта showcase_item_ids = list(ShowcaseItem.objects.filter( product_kit_id=item_id, status='in_cart', locked_by_user=request.user ).values_list('id', flat=True)[:int(quantity)]) # Создаём OrderItem order_item = OrderItem.objects.create( order=order, product_kit=kit, quantity=len(showcase_item_ids) if showcase_item_ids else int(quantity), price=price, is_custom_price=False, is_from_showcase=True ) # Продаём экземпляры через ShowcaseManager if showcase_item_ids: # ShowcaseManager.sell_showcase_items выполнит всю валидацию с блокировкой БД showcase_items = list(ShowcaseItem.objects.filter( id__in=showcase_item_ids )) if showcase_items: result = ShowcaseManager.sell_showcase_items(showcase_items, order_item) if not result['success']: raise ValidationError(result['message']) # 3. Пересчитываем итоговую стоимость # Обновляем объект заказа из БД, чтобы получить все связанные товары order.refresh_from_db() order.calculate_total() # 4. Применяем скидки if promo_code: from discounts.services.applier import DiscountApplier apply_result = DiscountApplier.apply_promo_code( order=order, promo_code=promo_code, user=request.user ) if not apply_result['success']: raise ValidationError(apply_result['error']) else: # Применяем автоматические скидки from discounts.services.applier import DiscountApplier DiscountApplier.apply_auto_discounts(order, user=request.user) # 5. Проводим платежи payments_list = [] for payment_data in payments_data: payments_list.append({ 'payment_method': payment_data['payment_method'], 'amount': Decimal(str(payment_data['amount'])), 'notes': payment_data.get('notes', f"Оплата POS: {payment_data['payment_method']}") }) transactions = TransactionService.create_multiple_payments( order=order, payments_list=payments_list, user=request.user ) # 5. Обновляем статус оплаты order.update_payment_status() # 6. Очищаем корзину из Redis from django.core.cache import cache cart_key = f'pos:cart:{request.user.id}:{warehouse_id}' cache.delete(cart_key) return JsonResponse({ 'success': True, 'order_number': order.order_number, 'order_id': order.id, 'total_amount': float(order.total_amount), 'amount_paid': float(order.amount_paid), 'amount_due': float(order.amount_due), 'payments_count': len(transactions), 'message': f'Заказ #{order.order_number} успешно создан и оплачен' }) except (Customer.DoesNotExist, Warehouse.DoesNotExist, Product.DoesNotExist, ProductKit.DoesNotExist) as e: return JsonResponse({'success': False, 'error': 'Объект не найден'}, status=404) except ValidationError as e: return JsonResponse({'success': False, 'error': str(e)}, status=400) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400) except Exception as e: logger.error(f'Ошибка при проведении продажи POS: {str(e)}', exc_info=True) return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500) @login_required @require_http_methods(["POST"]) def create_order_draft(request): """ Создает черновик заказа из корзины POS и сохраняет в Redis. Возвращает токен для передачи в orders/create/. Payload (JSON): { "customer_id": int, "items": [ {"type": "product"|"kit"|"showcase_kit", "id": int, "quantity": float, "price": float, "showcase_item_ids": [int, ...] // для showcase_kit }, ... ] } Response: { "success": true, "token": "abc123..." } """ from django.core.cache import cache import secrets try: data = json.loads(request.body) customer_id = data.get('customer_id') items = data.get('items', []) if not items: return JsonResponse({'success': False, 'error': 'Корзина пуста'}, status=400) # Генерируем уникальный токен token = secrets.token_urlsafe(16) # Сохраняем в Redis с TTL 1 час cache_key = f'pos_draft:{token}' draft_data = { 'customer_id': customer_id, 'items': items, # items уже содержат showcase_item_ids для showcase_kit } cache.set(cache_key, draft_data, timeout=3600) # 1 час return JsonResponse({ 'success': True, 'token': token }) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400) except Exception as e: logger.error(f'Ошибка при создании черновика заказа: {str(e)}', exc_info=True) return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500) # ============================================ # DISCOUNT API # ============================================ @login_required @require_http_methods(["POST"]) def validate_promo_code(request): """ Валидировать промокод. Payload JSON: { 'promo_code': str, 'cart_total': decimal (optional) } Returns JSON: { 'success': true/false, 'promo_code': {...}, 'error': str } """ from discounts.services.validator import DiscountValidator from customers.models import Customer try: data = json.loads(request.body) promo_code = data.get('promo_code', '') cart_total = data.get('cart_total', '0') # Получаем текущего клиента из Redis from django.core.cache import cache cart_key = f'pos:customer:{request.user.id}' customer_id = cache.get(cart_key) customer = None if customer_id: try: customer = Customer.objects.get(id=customer_id) except Customer.DoesNotExist: pass is_valid, promo, error = DiscountValidator.validate_promo_code( promo_code, customer, Decimal(str(cart_total)) if cart_total else None ) if is_valid: return JsonResponse({ 'success': True, 'promo_code': { 'code': promo.code, 'discount_id': promo.discount.id, 'discount_name': promo.discount.name, 'discount_type': promo.discount.discount_type, 'discount_value': float(promo.discount.value), 'scope': promo.discount.scope, } }) else: return JsonResponse({'success': False, 'error': error}) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400) except Exception as e: logger.error(f'Ошибка при валидации промокода: {str(e)}', exc_info=True) return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500) @login_required @require_http_methods(["POST"]) def calculate_cart_discounts(request): """ Рассчитать скидки для корзины POS. Payload JSON: { 'items': [...], 'promo_code': str (optional), 'customer_id': int (optional) } Returns JSON: { 'success': true, 'cart_subtotal': float, 'order_discount': {...}, 'item_discounts': [...], 'total_discount': float, 'final_total': float } """ from discounts.services.calculator import DiscountCalculator from customers.models import Customer try: data = json.loads(request.body) items_data = data.get('items', []) promo_code = data.get('promo_code') customer_id = data.get('customer_id') customer = None if customer_id: try: customer = Customer.objects.get(id=customer_id) except Customer.DoesNotExist: pass result = DiscountCalculator.calculate_cart_discounts( items_data, promo_code, customer ) cart_subtotal = Decimal('0') for item in items_data: cart_subtotal += Decimal(str(item['price'])) * Decimal(str(item['quantity'])) response_data = { 'success': True, 'cart_subtotal': float(cart_subtotal), 'order_discount': { 'discount_id': result['order_discount']['discount'].id if result['order_discount'].get('discount') else None, 'discount_name': result['order_discount']['discount'].name if result['order_discount'].get('discount') else None, 'discount_amount': float(result['order_discount']['discount_amount']), 'error': result['order_discount'].get('error'), } if result['order_discount'] else None, 'item_discounts': [ { 'cart_index': i['cart_index'], 'discount_id': i['discount'].id, 'discount_name': i['discount'].name, 'discount_amount': float(i['discount_amount']), } for i in result['item_discounts'] ], 'total_discount': float(result['total_discount']), 'final_total': float(result['final_total']), } return JsonResponse(response_data) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400) except Exception as e: logger.error(f'Ошибка при расчете скидок: {str(e)}', exc_info=True) return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)