Обновили шапку и вывод всехтоваров. Добавили фильтры
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user