""" API представления для приложения products. """ from django.http import JsonResponse from django.db import models from django.core.cache import cache from django.core.exceptions import ValidationError from django.contrib.auth.decorators import login_required import logging from ..models import Product, ProductVariantGroup, ProductKit, ProductCategory, ProductPhoto, ProductSalesUnit logger = logging.getLogger(__name__) def _get_product_photo_url(product_id): """Получает URL главного фото товара (первого по порядку).""" photo = ProductPhoto.objects.filter(product_id=product_id).order_by('order').first() if photo and photo.image: return photo.image.url return None def _apply_product_filters(queryset, category_id=None, tag_id=None, in_stock_only=False, warehouse_id=None, skip_stock_filter=False): """Применяет фильтры к queryset товаров. Args: skip_stock_filter: Если True, warehouse_id не фильтрует по остаткам. Используется для приёмки товаров. """ if category_id: queryset = queryset.filter(categories__id=category_id) if tag_id: queryset = queryset.filter(tags__id=tag_id) if in_stock_only: queryset = queryset.filter(in_stock=True) if warehouse_id and not skip_stock_filter: # Фильтруем только товары, которые есть на указанном складе с доступным количеством # НЕ применяется при skip_stock_filter=True (приёмка товаров) from inventory.models import Stock products_with_stock = Stock.objects.filter( warehouse_id=warehouse_id, quantity_available__gt=0 ).values_list('product_id', flat=True) queryset = queryset.filter(id__in=products_with_stock) return queryset.distinct() def search_products_and_variants(request): """ API endpoint для поиска товаров, групп вариантов и комплектов (совместимость с Select2). Используется для автокомплита при добавлении компонентов в комплект и товаров в заказ. Параметры GET: - q: строка поиска (term в Select2) - id: ID товара/комплекта для получения его данных (формат: "product_123" или "kit_456") - type: 'product', 'variant', 'kit' или 'all' (опционально, по умолчанию 'all') - page: номер страницы для пагинации (по умолчанию 1) - category: ID категории для фильтрации (опционально) - tag: ID тега для фильтрации (опционально) - in_stock: 'true' для фильтрации только товаров в наличии (опционально) - warehouse: ID склада для фильтрации только товаров с доступным остатком (опционально) Возвращает JSON в формате Select2 с группировкой: { "results": [ { "text": "Товары", "children": [ { "id": "product_1", "text": "Роза красная Freedom 50см (PROD-000001)", "sku": "PROD-000001", "price": "150.00", "actual_price": "135.00", "in_stock": true, "type": "product" } ] }, { "text": "Комплекты", "children": [ { "id": "kit_1", "text": "Букет 'Нежность' (KIT-000001)", "sku": "KIT-000001", "price": "2500.00", "actual_price": "2500.00", "type": "kit" } ] } ], "pagination": { "more": false } } """ # Если передан ID товара/комплекта - получаем его данные напрямую item_id = request.GET.get('id', '').strip() if item_id: try: # Проверяем формат ID: "product_123" или "kit_456" или просто "123" if '_' in item_id: item_type, numeric_id = item_id.split('_', 1) numeric_id = int(numeric_id) else: # Для обратной совместимости: если нет префикса, считаем что это product item_type = 'product' numeric_id = int(item_id) if item_type == 'product': product = Product.objects.prefetch_related('sales_units__unit').get(id=numeric_id, status='active') # Сериализация единиц продажи sales_units_data = [] for su in product.sales_units.filter(is_active=True).order_by('position', 'id'): sales_units_data.append({ 'id': su.id, 'name': su.name, 'unit_code': su.unit.code, 'unit_short_name': su.unit.short_name, 'price': str(su.price), 'sale_price': str(su.sale_price) if su.sale_price else None, 'actual_price': str(su.actual_price), 'conversion_factor': str(su.conversion_factor), 'min_quantity': str(su.min_quantity), 'quantity_step': str(su.quantity_step), 'is_default': su.is_default }) return JsonResponse({ 'results': [{ 'id': f'product_{product.id}', 'text': f"{product.name} ({product.sku})" if product.sku else product.name, 'sku': product.sku, 'price': str(product.price) if product.price else None, 'actual_price': str(product.actual_price) if product.actual_price else '0', 'in_stock': product.in_stock, 'type': 'product', 'sales_units': sales_units_data }], 'pagination': {'more': False} }) elif item_type == 'kit': # Для комплектов: временные комплекты можно получать по ID (для заказов) # но не показываем их в общем поиске kit = ProductKit.objects.get(id=numeric_id, status='active') return JsonResponse({ 'results': [{ 'id': f'kit_{kit.id}', 'text': f"{kit.name} ({kit.sku})" if kit.sku else kit.name, 'sku': kit.sku, 'price': str(kit.price) if kit.price else None, 'actual_price': str(kit.actual_price) if kit.actual_price else '0', 'type': 'kit', 'is_temporary': kit.is_temporary }], 'pagination': {'more': False} }) elif item_type == 'variant': # Для групп вариантов: получаем по ID с prefetch приоритетов variant = ProductVariantGroup.objects.prefetch_related( 'items__product' ).get(id=numeric_id) variant_price = variant.price or 0 count = variant.items.count() return JsonResponse({ 'results': [{ 'id': variant.id, 'text': f"{variant.name} ({count} вариантов)", 'price': str(variant_price), 'actual_price': str(variant_price), 'type': 'variant', 'count': count }], 'pagination': {'more': False} }) except (Product.DoesNotExist, ProductKit.DoesNotExist, ProductVariantGroup.DoesNotExist, ValueError): return JsonResponse({'results': [], 'pagination': {'more': False}}) query = request.GET.get('q', '').strip() search_type = request.GET.get('type', 'all') page = int(request.GET.get('page', 1)) page_size = 30 # Дополнительные фильтры category_id = request.GET.get('category', '').strip() tag_id = request.GET.get('tag', '').strip() in_stock_only = request.GET.get('in_stock', '').lower() == 'true' warehouse_id = request.GET.get('warehouse', '').strip() skip_stock_filter = request.GET.get('skip_stock_filter', '').lower() == 'true' results = [] # Проверяем, есть ли дополнительные фильтры has_filters = category_id or tag_id or in_stock_only or warehouse_id # Если поиска нет - показываем популярные товары и комплекты if not query or len(query) < 2: # Кэшируем только если нет фильтров if not has_filters: cache_key = f'popular_items_{search_type}' cached_results = cache.get(cache_key) if cached_results: return JsonResponse(cached_results) product_results = [] kit_results = [] if search_type in ['all', 'product']: # Показываем последние добавленные активные товары products_qs = Product.objects.filter(status='active').prefetch_related('sales_units__unit') # Применяем фильтры products_qs = _apply_product_filters(products_qs, category_id, tag_id, in_stock_only, warehouse_id, skip_stock_filter) products = products_qs.order_by('-created_at')[:page_size] for product in products: text = product.name if product.sku: text += f" ({product.sku})" # Получаем actual_price: приоритет sale_price > price actual_price = product.sale_price if product.sale_price else product.price # Сериализация единиц продажи sales_units_data = [] for su in product.sales_units.filter(is_active=True).order_by('position', 'id'): sales_units_data.append({ 'id': su.id, 'name': su.name, 'unit_code': su.unit.code, 'unit_short_name': su.unit.short_name, 'price': str(su.price), 'sale_price': str(su.sale_price) if su.sale_price else None, 'actual_price': str(su.actual_price), 'conversion_factor': str(su.conversion_factor), 'min_quantity': str(su.min_quantity), 'quantity_step': str(su.quantity_step), 'is_default': su.is_default }) product_results.append({ 'id': f"product_{product.id}", 'text': text, 'sku': product.sku, 'price': str(product.price) if product.price else None, 'actual_price': str(actual_price) if actual_price else '0', 'in_stock': product.in_stock, 'type': 'product', 'photo_url': _get_product_photo_url(product.id), 'sales_units': sales_units_data }) if search_type in ['all', 'kit']: # Показываем последние добавленные активные комплекты (только постоянные) kits = ProductKit.objects.filter(status='active', is_temporary=False)\ .order_by('-created_at')[:page_size]\ .values('id', 'name', 'sku', 'price', 'sale_price') for kit in kits: text = kit['name'] if kit['sku']: text += f" ({kit['sku']})" # Получаем actual_price: приоритет sale_price > price actual_price = kit['sale_price'] if kit['sale_price'] else kit['price'] kit_results.append({ 'id': f"kit_{kit['id']}", 'text': text, 'sku': kit['sku'], 'price': str(kit['price']) if kit['price'] else None, 'actual_price': str(actual_price) if actual_price else '0', 'type': 'kit' }) # Формируем результат с группировкой или без if search_type == 'all' and (product_results or kit_results): # С группировкой grouped_results = [] if product_results: grouped_results.append({ 'text': 'Товары', 'children': product_results }) if kit_results: grouped_results.append({ 'text': 'Комплекты', 'children': kit_results }) results = grouped_results else: # Без группировки (когда ищем только product или только kit) results = product_results + kit_results response_data = { 'results': results, 'pagination': {'more': False} } # Кэшируем только если нет фильтров if not has_filters: cache.set(cache_key, response_data, 3600) return JsonResponse(response_data) # Поиск товаров и комплектов (регистронезависимый поиск с приоритетом точных совпадений) product_results = [] kit_results = [] has_more = False # Нормализуем запрос - убираем лишние пробелы query_normalized = ' '.join(query.split()) from django.db.models import Case, When, IntegerField from django.conf import settings # Поиск товаров if search_type in ['all', 'product']: # ВРЕМЕННЫЙ ФИХ для SQLite: удалить когда база данных будет PostgreSQL # SQLite не поддерживает регистронезависимый поиск для кириллицы в LIKE if 'sqlite' in settings.DATABASES['default']['ENGINE']: from django.db.models.functions import Lower query_lower = query_normalized.lower() products_query = Product.objects.annotate( name_lower=Lower('name'), sku_lower=Lower('sku'), description_lower=Lower('description') ).filter( models.Q(name_lower__contains=query_lower) | models.Q(sku_lower__contains=query_lower) | models.Q(description_lower__contains=query_lower), status='active' ).annotate( relevance=Case( When(name_lower=query_lower, then=3), When(name_lower__startswith=query_lower, then=2), default=1, output_field=IntegerField() ) ).order_by('-relevance', 'name') else: # Основное решение для PostgreSQL (работает корректно с кириллицей) products_query = Product.objects.filter( models.Q(name__icontains=query_normalized) | models.Q(sku__icontains=query_normalized) | models.Q(description__icontains=query_normalized), status='active' ).annotate( relevance=Case( When(name__iexact=query_normalized, then=3), When(name__istartswith=query_normalized, then=2), default=1, output_field=IntegerField() ) ).order_by('-relevance', 'name') # Применяем дополнительные фильтры products_query = _apply_product_filters(products_query, category_id, tag_id, in_stock_only, warehouse_id, skip_stock_filter) # Добавляем prefetch для единиц продажи products_query = products_query.prefetch_related('sales_units__unit') total_products = products_query.count() start = (page - 1) * page_size end = start + page_size products = products_query[start:end] for product in products: text = product.name if product.sku: text += f" ({product.sku})" # Получаем actual_price: приоритет sale_price > price actual_price = product.sale_price if product.sale_price else product.price # Сериализация единиц продажи sales_units_data = [] for su in product.sales_units.filter(is_active=True).order_by('position', 'id'): sales_units_data.append({ 'id': su.id, 'name': su.name, 'unit_code': su.unit.code, 'unit_short_name': su.unit.short_name, 'price': str(su.price), 'sale_price': str(su.sale_price) if su.sale_price else None, 'actual_price': str(su.actual_price), 'conversion_factor': str(su.conversion_factor), 'min_quantity': str(su.min_quantity), 'quantity_step': str(su.quantity_step), 'is_default': su.is_default }) product_results.append({ 'id': f"product_{product.id}", 'text': text, 'sku': product.sku, 'price': str(product.price) if product.price else None, 'actual_price': str(actual_price) if actual_price else '0', 'in_stock': product.in_stock, 'type': 'product', 'photo_url': _get_product_photo_url(product.id), 'sales_units': sales_units_data }) has_more = total_products > end # Поиск комплектов if search_type in ['all', 'kit']: # Используем аналогичную логику для комплектов (только постоянные) if 'sqlite' in settings.DATABASES['default']['ENGINE']: from django.db.models.functions import Lower query_lower = query_normalized.lower() kits_query = ProductKit.objects.annotate( name_lower=Lower('name'), sku_lower=Lower('sku'), description_lower=Lower('description') ).filter( models.Q(name_lower__contains=query_lower) | models.Q(sku_lower__contains=query_lower) | models.Q(description_lower__contains=query_lower), status='active', is_temporary=False ).annotate( relevance=Case( When(name_lower=query_lower, then=3), When(name_lower__startswith=query_lower, then=2), default=1, output_field=IntegerField() ) ).order_by('-relevance', 'name') else: kits_query = ProductKit.objects.filter( models.Q(name__icontains=query_normalized) | models.Q(sku__icontains=query_normalized) | models.Q(description__icontains=query_normalized), status='active', is_temporary=False ).annotate( relevance=Case( When(name__iexact=query_normalized, then=3), When(name__istartswith=query_normalized, then=2), default=1, output_field=IntegerField() ) ).order_by('-relevance', 'name') kits = kits_query[:page_size].values('id', 'name', 'sku', 'price', 'sale_price') for kit in kits: text = kit['name'] if kit['sku']: text += f" ({kit['sku']})" # Получаем actual_price: приоритет sale_price > price actual_price = kit['sale_price'] if kit['sale_price'] else kit['price'] kit_results.append({ 'id': f"kit_{kit['id']}", 'text': text, 'sku': kit['sku'], 'price': str(kit['price']) if kit['price'] else None, 'actual_price': str(actual_price) if actual_price else '0', 'type': 'kit' }) # Поиск групп вариантов variant_results = [] if search_type in ['all', 'variant']: variants = ProductVariantGroup.objects.filter( models.Q(name__icontains=query) | models.Q(description__icontains=query) ).prefetch_related('items__product')[:page_size] for variant in variants: count = variant.items.count() variant_price = variant.price or 0 variant_results.append({ 'id': variant.id, 'text': f"{variant.name} ({count} вариантов)", 'type': 'variant', 'count': count, 'price': str(variant_price), 'actual_price': str(variant_price) }) # Формируем финальный результат с группировкой или без # Для 'all' показываем только товары и комплекты (без вариантов) if search_type == 'all': if product_results or kit_results: # С группировкой для заказов (товары + комплекты) grouped_results = [] if product_results: grouped_results.append({ 'text': 'Товары', 'children': product_results }) if kit_results: grouped_results.append({ 'text': 'Комплекты', 'children': kit_results }) final_results = grouped_results else: final_results = [] elif search_type == 'variant': # Только варианты final_results = variant_results else: # Без группировки для специфичного поиска (product или kit) final_results = product_results + kit_results + variant_results return JsonResponse({ 'results': final_results, 'pagination': {'more': has_more} }) def validate_kit_cost(request): """ AJAX endpoint для валидации себестоимости комплекта в реальном времени. Принимает список компонентов и возвращает информацию о валидности себестоимости, доступных методах ценообразования и проблемах. Request (JSON POST): { 'components': [ { 'product_id': int or null, 'variant_group_id': int or null, 'quantity': float }, ... ] } Response (JSON): { 'is_valid': bool, 'total_cost': float or null, 'problems': [ { 'component_name': str, 'reason': str }, ... ], 'available_methods': { 'manual': bool, 'from_sale_prices': bool, 'from_cost_plus_percent': bool, 'from_cost_plus_amount': bool } } """ if request.method != 'POST': return JsonResponse({'error': 'Method not allowed'}, status=405) try: import json from decimal import Decimal data = json.loads(request.body) components = data.get('components', []) if not components: return JsonResponse({ 'is_valid': False, 'total_cost': None, 'problems': [{ 'component_name': 'Комплект', 'reason': 'Комплект не содержит компонентов' }], 'available_methods': { 'manual': True, 'from_sale_prices': True, 'from_cost_plus_percent': False, 'from_cost_plus_amount': False } }) # Валидируем каждый компонент total_cost = Decimal('0.00') problems = [] for idx, component in enumerate(components): product_id = component.get('product_id') variant_group_id = component.get('variant_group_id') quantity = Decimal(str(component.get('quantity', 1))) product = None product_name = '' # Получаем товар if product_id: try: product = Product.objects.get(id=product_id) product_name = product.name except Product.DoesNotExist: problems.append({ 'component_name': f'Товар #{product_id}', 'reason': 'Товар не найден' }) continue elif variant_group_id: try: variant_group = ProductVariantGroup.objects.get(id=variant_group_id) product = variant_group.products.filter(status='active').first() if variant_group: product_name = f"[Варианты] {variant_group.name}" except ProductVariantGroup.DoesNotExist: problems.append({ 'component_name': f'Группа вариантов #{variant_group_id}', 'reason': 'Группа не найдена' }) continue if not product: problems.append({ 'component_name': product_name or f'Компонент {idx + 1}', 'reason': 'Товар не выбран или группа пуста' }) continue # Проверяем себестоимость if product.cost_price is None: problems.append({ 'component_name': product_name, 'reason': 'Себестоимость не определена' }) continue if product.cost_price <= 0: problems.append({ 'component_name': product_name, 'reason': 'Себестоимость равна 0' }) continue # Добавляем в сумму if quantity > 0: total_cost += product.cost_price * quantity # Определяем, какие методы доступны is_cost_valid = len(problems) == 0 available_methods = { 'manual': True, 'from_sale_prices': True, 'from_cost_plus_percent': is_cost_valid, 'from_cost_plus_amount': is_cost_valid } return JsonResponse({ 'is_valid': is_cost_valid, 'total_cost': float(total_cost) if is_cost_valid else None, 'problems': problems, 'available_methods': available_methods }) except json.JSONDecodeError: return JsonResponse({ 'error': 'Invalid JSON' }, status=400) except Exception as e: return JsonResponse({ 'error': str(e) }, status=500) def create_temporary_kit_api(request): """ AJAX endpoint для создания временного комплекта. Используется при оформлении заказа для создания букета "на лету". Принимает JSON: { "name": "Букет для Анны", "description": "Красные розы и белые лилии", "order_id": 123, // опционально, если заказ уже создан "components": [ {"product_id": 1, "quantity": "5"}, {"product_id": 2, "quantity": "3"} ] } Возвращает JSON: { "success": true, "kit_id": 456, "kit_name": "Букет для Анны", "kit_sku": "KIT-000456", "kit_price": "1500.00", "message": "Временный комплект создан успешно" } """ if request.method != 'POST': return JsonResponse({ 'success': False, 'error': 'Метод не поддерживается' }, status=405) import json from ..services.kit_service import create_temporary_kit from orders.models import Order try: data = json.loads(request.body) name = data.get('name', '').strip() description = data.get('description', '').strip() order_id = data.get('order_id') components = data.get('components', []) # Получаем заказ если указан order = None if order_id: try: order = Order.objects.get(pk=order_id) except Order.DoesNotExist: return JsonResponse({ 'success': False, 'error': f'Заказ #{order_id} не найден' }, status=404) # Создаем временный комплект через сервис kit = create_temporary_kit( name=name, description=description, components=components, order=order ) return JsonResponse({ 'success': True, 'kit_id': kit.id, 'kit_name': kit.name, 'kit_sku': kit.sku, 'kit_price': str(kit.actual_price), 'message': f'Временный комплект "{kit.name}" создан успешно' }) except ValueError as e: logger.warning(f'Validation error при создании временного комплекта: {str(e)}') return JsonResponse({ 'success': False, 'error': str(e) }, status=400) except json.JSONDecodeError as e: logger.error(f'JSON decode error при создании временного комплекта: {str(e)}') return JsonResponse({ 'success': False, 'error': 'Некорректный JSON в запросе' }, status=400) except ValidationError as e: logger.error(f'Django ValidationError при создании временного комплекта: {str(e)}', exc_info=True) return JsonResponse({ 'success': False, 'error': f'Ошибка валидации: {str(e)}' }, status=400) except Exception as e: # Детальное логирование для диагностики 500 ошибок try: data = json.loads(request.body) name = data.get('name', 'N/A') order_id = data.get('order_id', 'N/A') components_count = len(data.get('components', [])) except: name = 'N/A' order_id = 'N/A' components_count = 'N/A' logger.error( f'Непредвиденная ошибка при создании временного комплекта:\n' f' Название: {name}\n' f' Заказ ID: {order_id}\n' f' Количество компонентов: {components_count}\n' f' Пользователь: {request.user.username if request.user.is_authenticated else "Anonymous"}\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 'not found' in error_msg or 'does not exist' in error_msg or 'не найден' in error_msg: return JsonResponse({ 'success': False, 'error': f'Объект не найден: {str(e)}' }, status=404) else: return JsonResponse({ 'success': False, 'error': f'Не удалось создать комплект: {str(e)}. Проверьте консоль сервера для деталей.' }, status=500) def create_tag_api(request): """ AJAX endpoint для быстрого создания тега из списка тегов. Принимает JSON: { "name": "Новый тег" } Возвращает JSON: { "success": true, "tag": { "id": 1, "name": "Новый тег", "slug": "novyj-teg", "is_active": true, "products_count": 0, "kits_count": 0 } } Или при ошибке: { "success": false, "error": "Описание ошибки" } """ if request.method != 'POST': return JsonResponse({ 'success': False, 'error': 'Метод не поддерживается' }, status=405) try: import json from django.db import IntegrityError from ..models import ProductTag data = json.loads(request.body) name = data.get('name', '').strip() # Валидация if not name: return JsonResponse({ 'success': False, 'error': 'Название тега не может быть пустым' }, status=400) if len(name) > 100: return JsonResponse({ 'success': False, 'error': 'Название тега слишком длинное (максимум 100 символов)' }, status=400) # Проверка уникальности (регистронезависимо) # Примечание: это проверка перед созданием, но race condition все еще возможна if ProductTag.objects.filter(name__iexact=name, is_active=True).exists(): return JsonResponse({ 'success': False, 'error': f'Тег "{name}" уже существует' }, status=400) try: # Создание тега (slug будет сгенерирован автоматически в модели) tag = ProductTag.objects.create( name=name, is_active=True ) return JsonResponse({ 'success': True, 'tag': { 'id': tag.id, 'name': tag.name, 'slug': tag.slug, 'is_active': tag.is_active, 'products_count': 0, 'kits_count': 0 } }) except IntegrityError as e: # Защита от race condition: если 2 запроса одновременно попытались создать тег error_msg = str(e).lower() if 'unique_active_tag_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg): # Тег был создан параллельным запросом, получаем его tag = ProductTag.objects.get(name__iexact=name, is_active=True) return JsonResponse({ 'success': True, 'tag': { 'id': tag.id, 'name': tag.name, 'slug': tag.slug, 'is_active': tag.is_active, 'products_count': tag.products.count(), 'kits_count': tag.kits.count() } }) elif 'slug' in error_msg: # Конфликт slug, это редко должно происходить но обработаем return JsonResponse({ 'success': False, 'error': f'Тег с названием "{name}" не может быть создан (конфликт идентификатора). Пожалуйста, попробуйте другое название.' }, status=400) else: return JsonResponse({ 'success': False, 'error': 'Ошибка при создании тега: нарушение уникальности' }, status=500) except json.JSONDecodeError: return JsonResponse({ 'success': False, 'error': 'Некорректный JSON' }, status=400) except Exception as e: return JsonResponse({ 'success': False, 'error': f'Ошибка при создании тега: {str(e)}' }, status=500) def toggle_tag_status_api(request, pk): """ AJAX endpoint для переключения статуса активности тега. Принимает POST запрос и переключает is_active на противоположное значение. Возвращает JSON: { "success": true, "is_active": true/false, "message": "Тег активирован" / "Тег деактивирован" } Или при ошибке: { "success": false, "error": "Описание ошибки" } """ if request.method != 'POST': return JsonResponse({ 'success': False, 'error': 'Метод не поддерживается' }, status=405) try: from ..models import ProductTag # Получаем тег tag = ProductTag.objects.get(pk=pk) # Переключаем статус tag.is_active = not tag.is_active tag.save() # Определяем сообщение message = "Тег активирован" if tag.is_active else "Тег деактивирован" return JsonResponse({ 'success': True, 'is_active': tag.is_active, 'message': message }) except ProductTag.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Тег не найден' }, status=404) except Exception as e: return JsonResponse({ 'success': False, 'error': f'Ошибка при обновлении тега: {str(e)}' }, status=500) def rename_category_api(request, pk): """ AJAX endpoint для переименования категории (inline editing). Принимает JSON: { "name": "Новое название" } Возвращает JSON: { "success": true, "name": "Новое название" } """ if request.method != 'POST': return JsonResponse({ 'success': False, 'error': 'Метод не поддерживается' }, status=405) try: import json data = json.loads(request.body) name = data.get('name', '').strip() # Валидация if not name: return JsonResponse({ 'success': False, 'error': 'Название категории не может быть пустым' }, status=400) if len(name) > 255: return JsonResponse({ 'success': False, 'error': 'Название слишком длинное (максимум 255 символов)' }, status=400) # Получаем категорию category = ProductCategory.objects.get(pk=pk) # Обновляем название category.name = name category.save(update_fields=['name']) return JsonResponse({ 'success': True, 'name': category.name }) except ProductCategory.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Категория не найдена' }, status=404) except json.JSONDecodeError: return JsonResponse({ 'success': False, 'error': 'Некорректный JSON' }, status=400) except Exception as e: return JsonResponse({ 'success': False, 'error': f'Ошибка при переименовании: {str(e)}' }, status=500) def create_category_api(request): """ AJAX endpoint для создания новой категории (inline creation). Принимает JSON: { "name": "Название категории", "parent_id": 123 # опционально, null для корневой категории } Возвращает JSON: { "success": true, "category": { "id": 456, "name": "Название категории", "slug": "nazvanie-kategorii", "parent_id": 123 или null } } """ if request.method != 'POST': return JsonResponse({ 'success': False, 'error': 'Метод не поддерживается' }, status=405) try: import json from django.db import IntegrityError data = json.loads(request.body) name = data.get('name', '').strip() parent_id = data.get('parent_id') # Валидация названия if not name: return JsonResponse({ 'success': False, 'error': 'Название категории не может быть пустым' }, status=400) if len(name) > 200: return JsonResponse({ 'success': False, 'error': 'Название слишком длинное (максимум 200 символов)' }, status=400) # Получаем родительскую категорию, если указана parent = None if parent_id: try: parent = ProductCategory.objects.get(pk=parent_id) if not parent.is_active: return JsonResponse({ 'success': False, 'error': 'Родительская категория неактивна' }, status=400) except ProductCategory.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Родительская категория не найдена' }, status=404) # Создаем новую категорию category = ProductCategory( name=name, parent=parent, is_active=True ) # save() автоматически сгенерирует slug и sku category.save() return JsonResponse({ 'success': True, 'category': { 'id': category.pk, 'name': category.name, 'slug': category.slug, 'parent_id': category.parent_id } }) except IntegrityError as e: # Определяем тип ошибки уникальности error_str = str(e).lower() if 'unique_active_category_name' in error_str or 'unique constraint' in error_str: error_message = 'Категория с таким названием уже существует' elif 'sku' in error_str: error_message = 'Ошибка при генерации артикула. Попробуйте ещё раз' else: error_message = 'Ошибка: категория с такими данными уже существует' return JsonResponse({ 'success': False, 'error': error_message }, status=400) except json.JSONDecodeError: return JsonResponse({ 'success': False, 'error': 'Некорректный JSON' }, status=400) except ValidationError as e: # ValidationError может содержать словарь с ошибками if hasattr(e, 'message_dict'): # Извлекаем первое сообщение из словаря error_messages = [] for field, messages in e.message_dict.items(): if isinstance(messages, list): error_messages.extend(messages) else: error_messages.append(str(messages)) error_text = ' '.join(error_messages) elif hasattr(e, 'messages'): error_text = ' '.join(e.messages) else: error_text = str(e) # Заменяем технические сообщения на понятные if 'unique_active_category_name' in error_text.lower(): error_text = 'Категория с таким названием уже существует' elif 'циклическая ссылка' in error_text.lower(): error_text = 'Невозможно создать категорию: обнаружена циклическая ссылка' elif 'слишком глубокая вложенность' in error_text.lower(): error_text = 'Превышена максимальная глубина вложенности категорий' return JsonResponse({ 'success': False, 'error': error_text }, status=400) except Exception as e: logger.error(f'Ошибка при создании категории: {str(e)}') return JsonResponse({ 'success': False, 'error': f'Ошибка при создании категории: {str(e)}' }, status=500) def update_product_price_api(request, pk): """ AJAX endpoint для изменения цены товара (inline editing в каталоге). Принимает JSON: { "field": "price" | "sale_price", "value": "150.50" | null } Возвращает JSON: { "success": true, "price": "199.00", "sale_price": "150.00" | null, "actual_price": "150.00" } """ if request.method != 'POST': return JsonResponse({ 'success': False, 'error': 'Метод не поддерживается' }, status=405) # Проверка прав доступа if not request.user.has_perm('products.change_product'): return JsonResponse({ 'success': False, 'error': 'У вас нет прав для изменения цен товаров' }, status=403) try: import json from decimal import Decimal, InvalidOperation data = json.loads(request.body) field = data.get('field') value = data.get('value') # Валидация поля if field not in ['price', 'sale_price']: return JsonResponse({ 'success': False, 'error': 'Недопустимое поле. Разрешены: price, sale_price' }, status=400) # Получаем товар product = Product.objects.get(pk=pk) # Обработка значения if value is None: # Очистка sale_price if field == 'sale_price': product.sale_price = None else: return JsonResponse({ 'success': False, 'error': 'Основная цена не может быть пустой' }, status=400) else: # Валидация значения try: decimal_value = Decimal(str(value)) except (InvalidOperation, ValueError): return JsonResponse({ 'success': False, 'error': 'Некорректное числовое значение' }, status=400) # Проверка диапазона if decimal_value <= 0: return JsonResponse({ 'success': False, 'error': 'Цена должна быть положительной' }, status=400) if decimal_value > Decimal('999999.99'): return JsonResponse({ 'success': False, 'error': 'Цена слишком большая (максимум 999999.99)' }, status=400) # Проверка десятичных знаков if decimal_value.as_tuple().exponent < -2: return JsonResponse({ 'success': False, 'error': 'Максимум 2 знака после запятой' }, status=400) # Устанавливаем значение if field == 'price': product.price = decimal_value # Проверка: sale_price должна быть меньше price if product.sale_price and product.sale_price >= decimal_value: return JsonResponse({ 'success': False, 'error': 'Скидочная цена должна быть меньше обычной цены' }, status=400) else: # sale_price # Проверка: sale_price должна быть меньше price if decimal_value >= product.price: return JsonResponse({ 'success': False, 'error': 'Скидочная цена должна быть меньше обычной цены' }, status=400) product.sale_price = decimal_value # Сохраняем product.save(update_fields=[field]) # Возвращаем обновлённые данные return JsonResponse({ 'success': True, 'price': str(product.price), 'sale_price': str(product.sale_price) if product.sale_price else None, 'actual_price': str(product.actual_price) }) except Product.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Товар не найден' }, status=404) except json.JSONDecodeError: return JsonResponse({ 'success': False, 'error': 'Некорректный JSON' }, status=400) except Exception as e: logger.error(f'Ошибка при обновлении цены товара: {str(e)}') return JsonResponse({ 'success': False, 'error': f'Ошибка при обновлении цены: {str(e)}' }, status=500) def get_payment_methods(request): """ API endpoint для получения списка активных способов оплаты. Используется для динамической загрузки payment methods в JavaScript. Возвращает JSON: [ { "id": 1, "name": "Наличные курьеру", "code": "cash_to_courier", "description": "Оплата наличными при получении заказа" }, ... ] """ try: from orders.models import PaymentMethod # Получаем все активные способы оплаты, упорядоченные по полю order и названию payment_methods = PaymentMethod.objects.filter( is_active=True ).order_by('order', 'name').values('id', 'name', 'code', 'description') # Преобразуем QuerySet в список methods_list = list(payment_methods) return JsonResponse(methods_list, safe=False) except Exception as e: logger.error(f'Ошибка при загрузке способов оплаты: {str(e)}') return JsonResponse({ 'error': f'Ошибка при загрузке способов оплаты: {str(e)}' }, status=500) @login_required def get_product_sales_units_api(request, product_id): """ API для получения единиц продажи товара с остатками. GET /api/products/{product_id}/sales-units/?warehouse={id} Параметры GET: - warehouse: ID склада для получения остатков (опционально) Возвращает JSON: { "success": true, "product_id": 123, "product_name": "Роза красная Freedom 50см", "sales_units": [ { "id": 1, "name": "Ветка большая", "unit_code": "шт", "unit_short_name": "шт", "price": "150.00", "sale_price": "135.00", "actual_price": "135.00", "conversion_factor": "15.000000", "min_quantity": "1.000", "quantity_step": "1.000", "is_default": true, "available_quantity": "45.000000" } ] } """ try: from decimal import Decimal warehouse_id = request.GET.get('warehouse', '').strip() # Получаем товар с prefetch единиц продажи product = Product.objects.prefetch_related('sales_units__unit').get( id=product_id, status='active' ) # Получаем остатки со склада from inventory.models import Stock available_qty = Decimal('0') if warehouse_id: try: stock = Stock.objects.filter( product_id=product_id, warehouse_id=int(warehouse_id) ).first() if stock: available_qty = stock.quantity_available - stock.quantity_reserved except (ValueError, Stock.DoesNotExist): pass # Формируем список единиц с пересчётом остатков sales_units_data = [] for su in product.sales_units.filter(is_active=True).order_by('position', 'id'): qty_in_unit = su.convert_from_base(available_qty) if available_qty > 0 else Decimal('0') sales_units_data.append({ 'id': su.id, 'name': su.name, 'unit_code': su.unit.code, 'unit_short_name': su.unit.short_name, 'price': str(su.price), 'sale_price': str(su.sale_price) if su.sale_price else None, 'actual_price': str(su.actual_price), 'conversion_factor': str(su.conversion_factor), 'min_quantity': str(su.min_quantity), 'quantity_step': str(su.quantity_step), 'is_default': su.is_default, 'available_quantity': str(qty_in_unit) }) return JsonResponse({ 'success': True, 'product_id': product.id, 'product_name': product.name, 'sales_units': sales_units_data }) except Product.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Товар не найден' }, status=404) except Exception as e: logger.error(f'Ошибка загрузки единиц продажи: {str(e)}') return JsonResponse({ 'success': False, 'error': str(e) }, status=500)