Обновили шапку и вывод всехтоваров. Добавили фильтры

This commit is contained in:
2025-10-24 23:11:29 +03:00
parent 2fb6253d06
commit 9ad9f604e9
35 changed files with 2498 additions and 1270 deletions

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.7 on 2025-10-24 07:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0001_initial'),
]
operations = [
migrations.AddIndex(
model_name='kititem',
index=models.Index(fields=['kit'], name='products_ki_kit_id_d28dc9_idx'),
),
migrations.AddIndex(
model_name='kititem',
index=models.Index(fields=['product'], name='products_ki_product_d2ad00_idx'),
),
migrations.AddIndex(
model_name='kititem',
index=models.Index(fields=['variant_group'], name='products_ki_variant_e42628_idx'),
),
migrations.AddIndex(
model_name='kititem',
index=models.Index(fields=['kit', 'product'], name='products_ki_kit_id_14738f_idx'),
),
migrations.AddIndex(
model_name='kititem',
index=models.Index(fields=['kit', 'variant_group'], name='products_ki_kit_id_8199a8_idx'),
),
]

View File

@@ -1,133 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-23 12:13
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='product',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'),
),
migrations.AddField(
model_name='product',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_products', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'),
),
migrations.AddField(
model_name='product',
name='is_deleted',
field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'),
),
migrations.AddField(
model_name='product',
name='slug',
field=models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор'),
),
migrations.AddField(
model_name='productcategory',
name='created_at',
field=models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания'),
),
migrations.AddField(
model_name='productcategory',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'),
),
migrations.AddField(
model_name='productcategory',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_categories', to=settings.AUTH_USER_MODEL, verbose_name='Удалена пользователем'),
),
migrations.AddField(
model_name='productcategory',
name='is_deleted',
field=models.BooleanField(db_index=True, default=False, verbose_name='Удалена'),
),
migrations.AddField(
model_name='productcategory',
name='updated_at',
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления'),
),
migrations.AddField(
model_name='productkit',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'),
),
migrations.AddField(
model_name='productkit',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_kits', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'),
),
migrations.AddField(
model_name='productkit',
name='is_deleted',
field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'),
),
migrations.AddField(
model_name='producttag',
name='created_at',
field=models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания'),
),
migrations.AddField(
model_name='producttag',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'),
),
migrations.AddField(
model_name='producttag',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_tags', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'),
),
migrations.AddField(
model_name='producttag',
name='is_deleted',
field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'),
),
migrations.AddField(
model_name='producttag',
name='updated_at',
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления'),
),
migrations.AddIndex(
model_name='product',
index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_3bba04_idx'),
),
migrations.AddIndex(
model_name='product',
index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_f30efb_idx'),
),
migrations.AddIndex(
model_name='productcategory',
index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_2a96d1_idx'),
),
migrations.AddIndex(
model_name='productcategory',
index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_b8cdf3_idx'),
),
migrations.AddIndex(
model_name='productkit',
index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_e83a83_idx'),
),
migrations.AddIndex(
model_name='productkit',
index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_1e5bec_idx'),
),
migrations.AddIndex(
model_name='producttag',
index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_ea9be0_idx'),
),
migrations.AddIndex(
model_name='producttag',
index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_bc2d9c_idx'),
),
]

View File

@@ -523,6 +523,43 @@ class ProductKit(models.Model):
def __str__(self):
return self.name
def clean(self):
"""Валидация комплекта перед сохранением"""
# Проверка соответствия метода ценообразования полям
if self.pricing_method == 'fixed' and not self.fixed_price:
raise ValidationError({
'fixed_price': 'Для метода ценообразования "fixed" необходимо указать фиксированную цену.'
})
if self.pricing_method == 'from_cost_plus_percent' and (
self.markup_percent is None or self.markup_percent < 0
):
raise ValidationError({
'markup_percent': 'Для метода ценообразования "from_cost_plus_percent" необходимо указать процент наценки >= 0.'
})
if self.pricing_method == 'from_cost_plus_amount' and (
self.markup_amount is None or self.markup_amount < 0
):
raise ValidationError({
'markup_amount': 'Для метода ценообразования "from_cost_plus_amount" необходимо указать сумму наценки >= 0.'
})
# Проверка уникальности SKU (если задан)
if self.sku:
# Проверяем, что SKU не используется другим комплектом (если объект уже существует)
if self.pk:
if ProductKit.objects.filter(sku=self.sku).exclude(pk=self.pk).exists():
raise ValidationError({
'sku': f'Артикул "{self.sku}" уже используется другим комплектом.'
})
else:
# Для новых объектов просто проверяем, что SKU не используется
if ProductKit.objects.filter(sku=self.sku).exists():
raise ValidationError({
'sku': f'Артикул "{self.sku}" уже используется другим комплектом.'
})
def save(self, *args, **kwargs):
if not self.slug:
from unidecode import unidecode
@@ -541,15 +578,30 @@ class ProductKit(models.Model):
super().save(*args, **kwargs)
def get_total_components_count(self):
"""Возвращает количество позиций в букете"""
"""
Возвращает количество компонентов (строк) в комплекте.
Returns:
int: Количество компонентов в комплекте
"""
return self.kit_items.count()
def get_components_with_variants_count(self):
"""Возвращает количество позиций с группами вариантов"""
"""
Возвращает количество компонентов, которые используют группы вариантов.
Returns:
int: Количество компонентов с группами вариантов
"""
return self.kit_items.filter(variant_group__isnull=False).count()
def get_sale_price(self):
"""Возвращает рассчитанную цену продажи комплекта"""
"""
Возвращает рассчитанную цену продажи комплекта в соответствии с выбранным методом ценообразования.
Returns:
Decimal: Цена продажи комплекта
"""
try:
return self.calculate_price_with_substitutions()
except Exception:
@@ -560,8 +612,16 @@ class ProductKit(models.Model):
def check_availability(self, stock_manager=None):
"""
Проверяет доступность всего букета.
Букет доступен, если для каждой позиции есть хотя бы один доступный вариант.
Проверяет доступность всего комплекта.
Комплект доступен, если для каждой позиции в комплекте
есть хотя бы один доступный вариант товара.
Args:
stock_manager: Объект управления складом (если не указан, используется стандартный)
Returns:
bool: True, если комплект полностью доступен, иначе False
"""
from .utils.stock_manager import StockManager
@@ -577,10 +637,18 @@ class ProductKit(models.Model):
def calculate_price_with_substitutions(self, stock_manager=None):
"""
Расчёт цены букета с учётом доступных замен.
Использует цены фактически доступных товаров.
Расчёт цены комплекта с учётом доступных замен компонентов.
Метод определяет цену комплекта, учитывая доступные товары-заменители
и применяет выбранный метод ценообразования.
Args:
stock_manager: Объект управления складом (если не указан, используется стандартный)
Returns:
Decimal: Расчетная цена комплекта, или 0 в случае ошибки
"""
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from .utils.stock_manager import StockManager
if stock_manager is None:
@@ -594,28 +662,75 @@ class ProductKit(models.Model):
total_sale = Decimal('0.00')
for kit_item in self.kit_items.select_related('product', 'variant_group'):
best_product = kit_item.get_best_available_product(stock_manager)
try:
best_product = kit_item.get_best_available_product(stock_manager)
if not best_product:
# Если товар недоступен, используем цену первого в списке
available_products = kit_item.get_available_products()
best_product = available_products[0] if available_products else None
if not best_product:
# Если товар недоступен, используем цену первого в списке
available_products = kit_item.get_available_products()
best_product = available_products[0] if available_products else None
if best_product:
total_cost += best_product.cost_price * kit_item.quantity
total_sale += best_product.sale_price * kit_item.quantity
if best_product:
item_cost = best_product.cost_price
item_sale = best_product.sale_price
item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано
# Проверяем корректность значений перед умножением
if item_cost and item_quantity:
total_cost += item_cost * item_quantity
if item_sale and item_quantity:
total_sale += item_sale * item_quantity
except (AttributeError, TypeError, InvalidOperation) as e:
# Логируем ошибку, но продолжаем вычисления
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Ошибка при расчёте цены для комплекта {self.name} (item: {kit_item}): {e}")
continue # Пропускаем ошибочный элемент и продолжаем с остальными
# Применяем метод ценообразования
if self.pricing_method == 'from_sale_prices':
return total_sale
elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent:
return total_cost * (Decimal('1') + self.markup_percent / Decimal('100'))
elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount:
return total_cost + self.markup_amount
elif self.pricing_method == 'fixed' and self.fixed_price:
return self.fixed_price
try:
if self.pricing_method == 'from_sale_prices':
return total_sale
elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent is not None:
return total_cost * (Decimal('1') + self.markup_percent / Decimal('100'))
elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount is not None:
return total_cost + self.markup_amount
elif self.pricing_method == 'fixed' and self.fixed_price:
return self.fixed_price
return total_sale
return total_sale
except (TypeError, InvalidOperation) as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Ошибка при применении метода ценообразования для комплекта {self.name}: {e}")
# Возвращаем фиксированную цену если есть, иначе 0
if self.pricing_method == 'fixed' and self.fixed_price:
return self.fixed_price
return Decimal('0.00')
def calculate_cost(self):
"""
Расчёт себестоимости комплекта на основе себестоимости компонентов.
Returns:
Decimal: Себестоимость комплекта
"""
from decimal import Decimal
total_cost = Decimal('0.00')
for kit_item in self.kit_items.select_related('product', 'variant_group'):
# Получаем продукт - либо конкретный, либо первый из группы вариантов
product = kit_item.product
if not product and kit_item.variant_group:
# Берем первый продукт из группы вариантов
product = kit_item.variant_group.products.filter(is_active=True).first()
if product:
item_cost = product.cost_price
item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано
total_cost += item_cost * item_quantity
return total_cost
def delete(self, *args, **kwargs):
"""Soft delete вместо hard delete - марк как удаленный"""
@@ -663,6 +778,13 @@ class KitItem(models.Model):
class Meta:
verbose_name = "Компонент комплекта"
verbose_name_plural = "Компоненты комплектов"
indexes = [
models.Index(fields=['kit']),
models.Index(fields=['product']),
models.Index(fields=['variant_group']),
models.Index(fields=['kit', 'product']),
models.Index(fields=['kit', 'variant_group']),
]
def __str__(self):
return f"{self.kit.name} - {self.get_display_name()}"
@@ -679,17 +801,36 @@ class KitItem(models.Model):
)
def get_display_name(self):
"""Возвращает название для отображения (товар или группа)"""
"""
Возвращает строку для отображения названия компонента.
Returns:
str: Название компонента (либо группа вариантов, либо конкретный товар)
"""
if self.variant_group:
return f"[Варианты] {self.variant_group.name}"
return self.product.name if self.product else "Не указан"
def has_priorities_set(self):
"""Проверяет, настроены ли приоритеты"""
"""
Проверяет, настроены ли приоритеты замены для данного компонента.
Returns:
bool: True, если приоритеты установлены, иначе False
"""
return self.priorities.exists()
def get_available_products(self):
"""Возвращает список доступных товаров с учётом приоритетов"""
"""
Возвращает список доступных товаров для этого компонента.
Если указан конкретный товар - возвращает его.
Если указаны приоритеты - возвращает товары в порядке приоритета.
Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
Returns:
list: Список доступных товаров
"""
if self.product:
# Если указан конкретный товар, возвращаем только его
return [self.product]
@@ -707,7 +848,15 @@ class KitItem(models.Model):
return []
def get_best_available_product(self, stock_manager=None):
"""Возвращает первый доступный товар по приоритету"""
"""
Возвращает первый доступный товар по приоритету, соответствующий требованиям по количеству.
Args:
stock_manager: Объект управления складом (если не указан, используется стандартный)
Returns:
Product or None: Первый доступный товар или None, если ничего не доступно
"""
from .utils.stock_manager import StockManager
if stock_manager is None:

View File

@@ -0,0 +1,71 @@
<!-- КОМПОНЕНТЫ КОМПЛЕКТА - Shared include для создания и редактирования -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body p-3">
<h6 class="mb-3 text-muted"><i class="bi bi-boxes me-1"></i>Состав комплекта</h6>
{{ kititem_formset.management_form }}
<div id="kititem-forms">
{% for kititem_form in kititem_formset %}
<div class="card mb-2 kititem-form border" data-form-index="{{ forloop.counter0 }}">
{{ kititem_form.id }}
<div class="card-body p-2">
{% if kititem_form.non_field_errors %}
<div class="alert alert-danger alert-sm mb-2">
{% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %}
</div>
{% endif %}
<div class="row g-2 align-items-end">
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Товар</label>
{{ kititem_form.product }}
{% if kititem_form.product.errors %}
<div class="text-danger small">{{ kititem_form.product.errors }}</div>
{% endif %}
</div>
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Группа вариантов</label>
{{ kititem_form.variant_group }}
{% if kititem_form.variant_group.errors %}
<div class="text-danger small">{{ kititem_form.variant_group.errors }}</div>
{% endif %}
</div>
<div class="col-md-2">
<label class="form-label small text-muted mb-1">Кол-во</label>
{{ kititem_form.quantity }}
{% if kititem_form.quantity.errors %}
<div class="text-danger small">{{ kititem_form.quantity.errors }}</div>
{% endif %}
</div>
<div class="col-md-1 text-end">
{% if kititem_form.DELETE %}
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.previousElementSibling.checked = true; this.closest('.kititem-form').style.display='none';" title="Удалить">
<i class="bi bi-x-lg"></i>
</button>
{{ kititem_form.DELETE }}
{% endif %}
</div>
</div>
{% if kititem_form.notes %}
<div class="row g-2 mt-1">
<div class="col-12">
<label class="form-label small text-muted mb-1">Примечание</label>
{{ kititem_form.notes }}
</div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Кнопка добавления внизу списка -->
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-success w-100" id="addKitItemBtn">
<i class="bi bi-plus-circle"></i> Добавить товар
</button>
</div>
</div>
</div>

View File

@@ -46,76 +46,7 @@
</div>
<!-- КОМПОНЕНТЫ КОМПЛЕКТА -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body p-3">
<h6 class="mb-3 text-muted"><i class="bi bi-boxes me-1"></i>Состав комплекта</h6>
{{ kititem_formset.management_form }}
<div id="kititem-forms">
{% for kititem_form in kititem_formset %}
<div class="card mb-2 kititem-form border" data-form-index="{{ forloop.counter0 }}">
{{ kititem_form.id }}
<div class="card-body p-2">
{% if kititem_form.non_field_errors %}
<div class="alert alert-danger alert-sm mb-2">
{% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %}
</div>
{% endif %}
<div class="row g-2 align-items-end">
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Товар</label>
{{ kititem_form.product }}
{% if kititem_form.product.errors %}
<div class="text-danger small">{{ kititem_form.product.errors }}</div>
{% endif %}
</div>
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Группа вариантов</label>
{{ kititem_form.variant_group }}
{% if kititem_form.variant_group.errors %}
<div class="text-danger small">{{ kititem_form.variant_group.errors }}</div>
{% endif %}
</div>
<div class="col-md-2">
<label class="form-label small text-muted mb-1">Кол-во</label>
{{ kititem_form.quantity }}
{% if kititem_form.quantity.errors %}
<div class="text-danger small">{{ kititem_form.quantity.errors }}</div>
{% endif %}
</div>
<div class="col-md-1 text-end">
{% if kititem_form.DELETE %}
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.previousElementSibling.checked = true; this.closest('.kititem-form').style.display='none';" title="Удалить">
<i class="bi bi-x-lg"></i>
</button>
{{ kititem_form.DELETE }}
{% endif %}
</div>
</div>
{% if kititem_form.notes %}
<div class="row g-2 mt-1">
<div class="col-12">
<label class="form-label small text-muted mb-1">Примечание</label>
{{ kititem_form.notes }}
</div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Кнопка добавления внизу списка -->
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-success w-100" id="addKitItemBtn">
<i class="bi bi-plus-circle"></i> Добавить товар
</button>
</div>
</div>
</div>
{% include 'products/includes/kititem_formset.html' %}
<!-- ФОТОГРАФИИ -->
<div class="card border-0 shadow-sm mb-3">

View File

@@ -52,6 +52,11 @@
<strong class="text-success fs-5">{{ kit.get_sale_price|floatformat:2 }} ₽</strong>
</dd>
<dt class="col-sm-4">Себестоимость:</dt>
<dd class="col-sm-8">
<strong class="text-danger fs-5">{{ kit.calculate_cost|floatformat:2 }} ₽</strong>
</dd>
<dt class="col-sm-4">Ценообразование:</dt>
<dd class="col-sm-8">
<span class="badge bg-info text-dark">{{ kit.get_pricing_method_display }}</span>

File diff suppressed because it is too large Load Diff

View File

@@ -3,79 +3,164 @@ 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 для поиска товаров и групп вариантов.
API endpoint для поиска товаров и групп вариантов (совместимость с Select2).
Используется для автокомплита при добавлении компонентов в комплект.
Параметры GET:
- q: строка поиска
- type: 'product' или 'variant' (опционально, если не указано - поиск по обоим)
- q: строка поиска (term в Select2)
- type: 'product' или 'variant' (опционально)
- page: номер страницы для пагинации (по умолчанию 1)
Возвращает JSON список:
[
{
"id": 1,
"name": "Роза красная Freedom 50см",
"sku": "PROD-000001",
"type": "product",
"price": "150.00"
},
{
"id": 1,
"name": "Роза красная Freedom",
"type": "variant",
"count": 3
Возвращает JSON в формате Select2:
{
"results": [
{
"id": 1,
"text": "Роза красная Freedom 50см (PROD-000001)",
"sku": "PROD-000001",
"price": "150.00"
}
],
"pagination": {
"more": true
}
]
}
"""
query = request.GET.get('q', '').strip()
search_type = request.GET.get('type', 'all')
if not query or len(query) < 2:
return JsonResponse({'results': []})
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', 'sale_price')
for product in products:
text = product['name']
if product['sku']:
text += f" ({product['sku']})"
results.append({
'id': product['id'],
'text': text,
'sku': product['sku'],
'price': str(product['sale_price']) if product['sale_price'] else None
})
response_data = {
'results': results,
'pagination': {'more': False}
}
cache.set(cache_key, response_data, 3600)
return JsonResponse(response_data)
# Поиск товаров (регистронезависимый поиск с приоритетом точных совпадений)
if search_type in ['all', 'product']:
products = Product.objects.filter(
models.Q(name__icontains=query) |
models.Q(sku__icontains=query) |
models.Q(description__icontains=query) |
models.Q(search_keywords__icontains=query),
is_active=True
).values('id', 'name', 'sku', 'sale_price')[:10]
# Нормализуем запрос - убираем лишние пробелы
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', 'sale_price')
for product in products:
text = product['name']
if product['sku']:
text += f" ({product['sku']})"
results.append({
'id': product['id'],
'name': f"{product['name']} ({product['sku']})",
'text': text,
'sku': product['sku'],
'type': 'product',
'price': str(product['sale_price']),
'display_name': product['name'],
'display_price': f"{product['sale_price']:.2f}"
'price': str(product['sale_price']) if product['sale_price'] else None,
'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')[:10]
).prefetch_related('products')[:page_size]
for variant in variants:
count = variant.products.filter(is_active=True).count()
results.append({
'id': variant.id,
'name': f"{variant.name} ({count} вариантов)",
'text': f"{variant.name} ({count} вариантов)",
'type': 'variant',
'count': count
})
return JsonResponse({'results': results})
return JsonResponse({
'results': results,
'pagination': {'more': has_more if search_type == 'product' else False}
})