1798 lines
72 KiB
Python
1798 lines
72 KiB
Python
"""
|
||
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__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()
|
||
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__unit')
|
||
# Применяем фильтры
|
||
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,
|
||
'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, stock_status, 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)
|
||
|
||
|
||
@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)
|