Исправлены 4 проблемы: 1. Расчёт цены первого товара - улучшена валидация в getProductPrice и calculateFinalPrice 2. Отображение actual_price в Select2 вместо обычной цены 3. Количество по умолчанию = 1 для новых форм компонентов 4. Auto-select текста при клике на поле количества для удобства редактирования Изменённые файлы: - products/forms.py: добавлен __init__ в KitItemForm для quantity.initial = 1 - products/templates/includes/select2-product-init.html: обновлена formatSelectResult - products/templates/productkit_create.html: добавлен focus handler для auto-select - products/templates/productkit_edit.html: добавлен focus handler для auto-select 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
353 lines
13 KiB
Python
353 lines
13 KiB
Python
"""
|
||
API представления для приложения products.
|
||
"""
|
||
from django.http import JsonResponse
|
||
from django.db import models
|
||
from django.core.cache import cache
|
||
|
||
from ..models import Product, ProductVariantGroup
|
||
|
||
|
||
def search_products_and_variants(request):
|
||
"""
|
||
API endpoint для поиска товаров и групп вариантов (совместимость с Select2).
|
||
Используется для автокомплита при добавлении компонентов в комплект.
|
||
|
||
Параметры GET:
|
||
- q: строка поиска (term в Select2)
|
||
- id: ID товара для получения его данных
|
||
- type: 'product' или 'variant' (опционально)
|
||
- page: номер страницы для пагинации (по умолчанию 1)
|
||
|
||
Возвращает JSON в формате Select2:
|
||
{
|
||
"results": [
|
||
{
|
||
"id": 1,
|
||
"text": "Роза красная Freedom 50см (PROD-000001)",
|
||
"sku": "PROD-000001",
|
||
"price": "150.00",
|
||
"in_stock": true
|
||
}
|
||
],
|
||
"pagination": {
|
||
"more": true
|
||
}
|
||
}
|
||
"""
|
||
# Если передан ID товара - получаем его данные напрямую
|
||
product_id = request.GET.get('id', '').strip()
|
||
if product_id:
|
||
try:
|
||
product = Product.objects.get(id=int(product_id), is_active=True)
|
||
return JsonResponse({
|
||
'results': [{
|
||
'id': 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'
|
||
}],
|
||
'pagination': {'more': False}
|
||
})
|
||
except (Product.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
|
||
|
||
results = []
|
||
|
||
# Если поиска нет - показываем популярные товары
|
||
if not query or len(query) < 2:
|
||
# Кэшируем популярные товары на 1 час
|
||
cache_key = f'popular_products_{search_type}'
|
||
cached_results = cache.get(cache_key)
|
||
|
||
if cached_results:
|
||
return JsonResponse(cached_results)
|
||
|
||
if search_type in ['all', 'product']:
|
||
# Показываем последние добавленные активные товары
|
||
products = Product.objects.filter(is_active=True)\
|
||
.order_by('-created_at')[:page_size]\
|
||
.values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
|
||
|
||
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']
|
||
|
||
results.append({
|
||
'id': 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'
|
||
})
|
||
|
||
response_data = {
|
||
'results': results,
|
||
'pagination': {'more': False}
|
||
}
|
||
cache.set(cache_key, response_data, 3600)
|
||
return JsonResponse(response_data)
|
||
|
||
# Поиск товаров (регистронезависимый поиск с приоритетом точных совпадений)
|
||
if search_type in ['all', 'product']:
|
||
# Нормализуем запрос - убираем лишние пробелы
|
||
query_normalized = ' '.join(query.split())
|
||
|
||
from django.db.models import Case, When, IntegerField
|
||
from django.conf import settings
|
||
|
||
# ВРЕМЕННЫЙ ФИХ для 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),
|
||
is_active=True
|
||
).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),
|
||
is_active=True
|
||
).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')
|
||
|
||
total_products = products_query.count()
|
||
start = (page - 1) * page_size
|
||
end = start + page_size
|
||
|
||
products = products_query[start:end].values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
|
||
|
||
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']
|
||
|
||
results.append({
|
||
'id': 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'
|
||
})
|
||
|
||
has_more = total_products > end
|
||
else:
|
||
has_more = False
|
||
|
||
# Поиск групп вариантов
|
||
if search_type in ['all', 'variant']:
|
||
variants = ProductVariantGroup.objects.filter(
|
||
models.Q(name__icontains=query) |
|
||
models.Q(description__icontains=query)
|
||
).prefetch_related('products')[:page_size]
|
||
|
||
for variant in variants:
|
||
count = variant.products.filter(is_active=True).count()
|
||
results.append({
|
||
'id': variant.id,
|
||
'text': f"{variant.name} ({count} вариантов)",
|
||
'type': 'variant',
|
||
'count': count
|
||
})
|
||
|
||
return JsonResponse({
|
||
'results': results,
|
||
'pagination': {'more': has_more if search_type == 'product' else False}
|
||
})
|
||
|
||
|
||
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(is_active=True).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)
|