Files
octopus/myproject/products/views/api_views.py
Andrey Smakotin 2f1f0621e6 feat: упростить создание заказов и рефакторинг единиц измерения
- Добавить inline-редактирование цен в списке товаров
- Оптимизировать карточки товаров в POS-терминале
- Рефакторинг моделей единиц измерения
- Миграция unit -> base_unit в SalesUnit
- Улучшить UI форм создания/редактирования товаров

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 03:34:43 +03:00

1803 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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, stock_status='all', warehouse_id=None, skip_stock_filter=False):
"""Применяет фильтры к queryset товаров.
Args:
stock_status: 'all' - все товары, 'in_stock' - только в наличии, 'out_of_stock' - только не в наличии
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 stock_status == 'in_stock':
queryset = queryset.filter(in_stock=True)
elif stock_status == 'out_of_stock':
queryset = queryset.filter(in_stock=False)
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 тега для фильтрации (опционально)
- stock_status: 'all' (все), 'in_stock' (в наличии), 'out_of_stock' (не в наличии) (опционально, по умолчанию 'all')
- 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').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,
'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()
stock_status = request.GET.get('stock_status', 'all').strip()
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 stock_status != 'all' 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')
# Применяем фильтры
products_qs = _apply_product_filters(products_qs, category_id, tag_id, stock_status, 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,
'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, stock_status, warehouse_id, skip_stock_filter)
# Добавляем prefetch для единиц продажи
products_query = products_query.prefetch_related('sales_units')
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,
'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)
@login_required
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)
# Проверка прав доступа через кастомную систему ролей
from user_roles.services import RoleService
# Добавляем отладочное логирование
try:
logger.info(f"Update price API - User: {request.user.email}, is_superuser: {request.user.is_superuser}")
user_role = RoleService.get_user_role(request.user)
logger.info(f"Update price API - User role: {user_role.code if user_role else 'None'}")
except Exception as e:
logger.error(f"Update price API - Error getting user role: {str(e)}")
# Owner и Manager имеют право изменять цены
if not request.user.is_superuser and not RoleService.user_has_role(request.user, 'owner', 'manager'):
logger.warning(f"Update price API - Access denied for user {request.user.email}")
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').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,
'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)
@login_required
def get_filtered_items_ids(request):
"""
API endpoint для получения ID всех отфильтрованных товаров и комплектов.
Используется для функции "Выбрать все отфильтрованные".
Параметры GET (те же, что и в CombinedProductListView):
- search: строка поиска
- type: 'all', 'products', 'kits'
- category: ID категории
- status: статус товара/комплекта
- in_stock: '1' или '0'
- tags: список ID тегов
Возвращает JSON:
{
"success": true,
"items": [
{"type": "product", "id": 1},
{"type": "kit", "id": 5},
...
],
"count": 150
}
"""
from django.db.models import Q
from ..models import Product, ProductKit
try:
# Получаем фильтры из GET-параметров
type_filter = request.GET.get('type', 'all')
search_query = request.GET.get('search')
category_id = request.GET.get('category')
status_filter = request.GET.get('status')
in_stock_filter = request.GET.get('in_stock')
tags = request.GET.getlist('tags')
# Начинаем с базовых queryset
products = Product.objects.all()
kits = ProductKit.objects.filter(is_temporary=False)
# Применяем фильтры (та же логика, что в CombinedProductListView)
if search_query:
products = products.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(description__icontains=search_query) |
Q(categories__name__icontains=search_query) |
Q(search_keywords__icontains=search_query)
).distinct()
kits = kits.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(description__icontains=search_query) |
Q(categories__name__icontains=search_query)
).distinct()
if category_id:
products = products.filter(categories__id=category_id)
kits = kits.filter(categories__id=category_id)
if status_filter:
products = products.filter(status=status_filter)
kits = kits.filter(status=status_filter)
if in_stock_filter == '1':
products = products.filter(in_stock=True)
elif in_stock_filter == '0':
products = products.filter(in_stock=False)
if tags:
products = products.filter(tags__id__in=tags).distinct()
kits = kits.filter(tags__id__in=tags).distinct()
# Собираем ID в зависимости от фильтра типа
items = []
if type_filter in ['all', 'products']:
product_ids = products.values_list('id', flat=True)
items.extend([{'type': 'product', 'id': pid} for pid in product_ids])
if type_filter in ['all', 'kits']:
kit_ids = kits.values_list('id', flat=True)
items.extend([{'type': 'kit', 'id': kid} for kid in kit_ids])
return JsonResponse({
'success': True,
'items': items,
'count': len(items)
})
except Exception as e:
logger.error(f'Ошибка получения отфильтрованных ID: {str(e)}')
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
@login_required
def bulk_update_categories(request):
"""
API endpoint для массового обновления категорий у выбранных товаров и комплектов.
POST /products/api/bulk-update-categories/
Request JSON:
{
"items": [
{"type": "product", "id": 123},
{"type": "kit", "id": 456}
],
"category_ids": [5, 12, 18],
"action_mode": "add", // "add", "replace" или "clear"
"external_category_id": 7 // опционально, для установки внешней категории
}
Response JSON:
{
"success": true,
"updated_count": 15,
"skipped_count": 0,
"message": "Категории успешно обновлены для 15 товаров"
}
"""
if request.method != 'POST':
return JsonResponse({
'success': False,
'message': 'Метод не поддерживается'
}, status=405)
try:
import json
from django.db import transaction
# Парсинг JSON запроса
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'message': 'Неверный формат JSON'
}, status=400)
# Валидация обязательных полей
items = data.get('items', [])
category_ids = data.get('category_ids', [])
action_mode = data.get('action_mode', 'add')
external_category_id = data.get('external_category_id') # Может быть null для очистки
should_clear_external = data.get('clear_external_category', False)
if not items:
return JsonResponse({
'success': False,
'message': 'Не выбраны товары для обновления'
}, status=400)
# Для режима 'clear' категории не обязательны, но нужна либо M2M категория, либо внешняя
if action_mode != 'clear' and not category_ids and external_category_id is None and not should_clear_external:
return JsonResponse({
'success': False,
'message': 'Не выбраны категории'
}, status=400)
if action_mode not in ['add', 'replace', 'clear']:
return JsonResponse({
'success': False,
'message': 'Неверный режим действия (должен быть add, replace или clear)'
}, status=400)
# Проверка существования M2M категорий (только если они указаны)
categories = []
if category_ids:
categories = ProductCategory.objects.filter(id__in=category_ids, is_active=True)
if categories.count() != len(category_ids):
return JsonResponse({
'success': False,
'message': 'Некоторые категории не найдены или неактивны'
}, status=400)
# Проверка существования внешней категории (если указана)
external_category = None
if external_category_id is not None:
try:
external_category = ProductCategory.objects.get(id=external_category_id, is_active=True)
except ProductCategory.DoesNotExist:
return JsonResponse({
'success': False,
'message': 'Главная категория не найдена или неактивна'
}, status=400)
# Проверка: нельзя одновременно устанавливать и очищать внешнюю категорию
if external_category and should_clear_external:
return JsonResponse({
'success': False,
'message': 'Нельзя одновременно установить и очистить главную категорию'
}, status=400)
# Обработка товаров в транзакции
updated_count = 0
skipped_count = 0
skipped_reasons = []
with transaction.atomic():
for item in items:
item_type = item.get('type')
item_id = item.get('id')
if not item_type or not item_id:
skipped_count += 1
continue
try:
# Получаем объект товара или комплекта
if item_type == 'product':
# Проверка прав
if not request.user.has_perm('products.change_product'):
skipped_count += 1
skipped_reasons.append(f'Товар ID {item_id}: нет прав')
continue
obj = Product.objects.get(id=item_id)
elif item_type == 'kit':
# Проверка прав
if not request.user.has_perm('products.change_productkit'):
skipped_count += 1
skipped_reasons.append(f'Комплект ID {item_id}: нет прав')
continue
obj = ProductKit.objects.get(id=item_id)
else:
skipped_count += 1
continue
# Применяем изменения M2M категорий
if action_mode == 'add':
# Добавляем категории к существующим
obj.categories.add(*categories)
elif action_mode == 'replace':
# Заменяем все категории
obj.categories.set(categories)
elif action_mode == 'clear':
# Очищаем все категории
obj.categories.clear()
# Применяем изменения внешней категории
if external_category:
obj.external_category = external_category
obj.save(update_fields=['external_category'])
elif should_clear_external:
obj.external_category = None
obj.save(update_fields=['external_category'])
updated_count += 1
except (Product.DoesNotExist, ProductKit.DoesNotExist):
skipped_count += 1
skipped_reasons.append(f'{item_type.capitalize()} ID {item_id} не найден')
continue
except Exception as e:
logger.error(f'Ошибка обновления {item_type} ID {item_id}: {str(e)}')
skipped_count += 1
skipped_reasons.append(f'{item_type.capitalize()} ID {item_id}: {str(e)}')
continue
# Формируем сообщение результата
if updated_count > 0:
message_parts = []
# Сообщение про M2M категории
if action_mode == 'clear':
if categories or action_mode == 'clear':
message_parts.append(f'Категории удалены')
else:
if categories:
category_names = ', '.join([c.name for c in categories[:3]])
if len(categories) > 3:
category_names += f' и ещё {len(categories) - 3}'
if action_mode == 'add':
action_text = 'добавлены'
else:
action_text = 'установлены'
message_parts.append(f'Категории "{category_names}" {action_text}')
# Сообщение про внешнюю категорию
if external_category:
message_parts.append(f'Внешняя категория "{external_category.name}" установлена')
elif should_clear_external:
message_parts.append('Внешняя категория очищена')
# Собираем финальное сообщение
if message_parts:
message = f'{", ".join(message_parts)} для {updated_count} товаров'
else:
message = f'Обновлено {updated_count} товаров'
if skipped_count > 0:
message += f'. Пропущено: {skipped_count}'
response_data = {
'success': True,
'updated_count': updated_count,
'skipped_count': skipped_count,
'message': message
}
if skipped_reasons:
response_data['skipped_reasons'] = skipped_reasons[:10] # Ограничение первыми 10
return JsonResponse(response_data)
else:
return JsonResponse({
'success': False,
'updated_count': 0,
'skipped_count': skipped_count,
'message': 'Не удалось обновить ни один товар',
'skipped_reasons': skipped_reasons[:10]
}, status=400)
except Exception as e:
logger.error(f'Ошибка массового обновления категорий: {str(e)}')
return JsonResponse({
'success': False,
'message': f'Произошла ошибка: {str(e)}'
}, status=500)