Исправление ошибок в редактировании комплектов: валидация, верстка, расчет цены

This commit is contained in:
2026-01-21 10:16:37 +03:00
parent 2dc36b3d01
commit e138a28475
12 changed files with 1447 additions and 658 deletions

View File

@@ -313,15 +313,17 @@ class KitItemForm(forms.ModelForm):
""" """
class Meta: class Meta:
model = KitItem model = KitItem
fields = ['product', 'variant_group', 'quantity'] fields = ['product', 'variant_group', 'sales_unit', 'quantity']
labels = { labels = {
'product': 'Конкретный товар', 'product': 'Конкретный товар',
'variant_group': 'Группа вариантов', 'variant_group': 'Группа вариантов',
'sales_unit': 'Единица продажи',
'quantity': 'Количество' 'quantity': 'Количество'
} }
widgets = { widgets = {
'product': forms.Select(attrs={'class': 'form-control'}), 'product': forms.Select(attrs={'class': 'form-control'}),
'variant_group': forms.Select(attrs={'class': 'form-control'}), 'variant_group': forms.Select(attrs={'class': 'form-control'}),
'sales_unit': forms.Select(attrs={'class': 'form-control'}),
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0'}), 'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0'}),
} }
@@ -335,24 +337,35 @@ class KitItemForm(forms.ModelForm):
cleaned_data = super().clean() cleaned_data = super().clean()
product = cleaned_data.get('product') product = cleaned_data.get('product')
variant_group = cleaned_data.get('variant_group') variant_group = cleaned_data.get('variant_group')
sales_unit = cleaned_data.get('sales_unit')
quantity = cleaned_data.get('quantity') quantity = cleaned_data.get('quantity')
# Если оба поля пусты - это пустая форма (не валидируем, она будет удалена) # Подсчитываем, сколько полей заполнено
if not product and not variant_group: filled_fields = sum([bool(product), bool(variant_group), bool(sales_unit)])
# Если все поля пусты - это пустая форма (не валидируем, она будет удалена)
if filled_fields == 0:
# Для пустых форм обнуляем количество # Для пустых форм обнуляем количество
cleaned_data['quantity'] = None cleaned_data['quantity'] = None
return cleaned_data return cleaned_data
# Валидация: должен быть указан либо product, либо variant_group (но не оба) # Валидация несовместимых полей
if product and variant_group: if variant_group and (product or sales_unit):
raise forms.ValidationError( raise forms.ValidationError(
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно." "Нельзя указывать группу вариантов одновременно с товаром или единицей продажи."
) )
# Если выбрана единица продажи, товар обязателен
if sales_unit and not product:
raise forms.ValidationError("Для единицы продажи должен быть выбран товар.")
# Валидация: если выбран товар/группа, количество обязательно и должно быть > 0 # Валидация: если выбран товар/группа/единица продажи, количество обязательно и должно быть > 0
if (product or variant_group): if not quantity or quantity <= 0:
if not quantity or quantity <= 0: raise forms.ValidationError('Необходимо указать количество больше 0')
raise forms.ValidationError('Необходимо указать количество больше 0')
# Валидация: если выбрана единица продажи, проверяем, что она принадлежит выбранному продукту
if sales_unit and product and sales_unit.product != product:
raise forms.ValidationError('Выбранная единица продажи не принадлежит указанному товару.')
return cleaned_data return cleaned_data
@@ -367,6 +380,7 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
products = [] products = []
variant_groups = [] variant_groups = []
sales_units = []
for form in self.forms: for form in self.forms:
if self.can_delete and self._should_delete_form(form): if self.can_delete and self._should_delete_form(form):
@@ -374,6 +388,7 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
product = form.cleaned_data.get('product') product = form.cleaned_data.get('product')
variant_group = form.cleaned_data.get('variant_group') variant_group = form.cleaned_data.get('variant_group')
sales_unit = form.cleaned_data.get('sales_unit')
# Проверка дубликатов товаров # Проверка дубликатов товаров
if product: if product:
@@ -393,13 +408,22 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
) )
variant_groups.append(variant_group) variant_groups.append(variant_group)
# Проверка дубликатов единиц продажи
if sales_unit:
if sales_unit in sales_units:
raise forms.ValidationError(
f'Единица продажи "{sales_unit.name}" добавлена в комплект более одного раза. '
f'Каждая единица продажи может быть добавлена только один раз.'
)
sales_units.append(sales_unit)
# Формсет для создания комплектов (с пустой формой для удобства) # Формсет для создания комплектов (с пустой формой для удобства)
KitItemFormSetCreate = inlineformset_factory( KitItemFormSetCreate = inlineformset_factory(
ProductKit, ProductKit,
KitItem, KitItem,
form=KitItemForm, form=KitItemForm,
formset=BaseKitItemFormSet, formset=BaseKitItemFormSet,
fields=['product', 'variant_group', 'quantity'], fields=['product', 'variant_group', 'sales_unit', 'quantity'],
extra=1, # Показать 1 пустую форму для первого компонента extra=1, # Показать 1 пустую форму для первого компонента
can_delete=True, # Разрешить удаление компонентов can_delete=True, # Разрешить удаление компонентов
min_num=0, # Минимум 0 компонентов (можно создать пустой комплект) min_num=0, # Минимум 0 компонентов (можно создать пустой комплект)
@@ -413,7 +437,7 @@ KitItemFormSetUpdate = inlineformset_factory(
KitItem, KitItem,
form=KitItemForm, form=KitItemForm,
formset=BaseKitItemFormSet, formset=BaseKitItemFormSet,
fields=['product', 'variant_group', 'quantity'], fields=['product', 'variant_group', 'sales_unit', 'quantity'],
extra=0, # НЕ показывать пустые формы при редактировании extra=0, # НЕ показывать пустые формы при редактировании
can_delete=True, # Разрешить удаление компонентов can_delete=True, # Разрешить удаление компонентов
min_num=0, # Минимум 0 компонентов min_num=0, # Минимум 0 компонентов

View File

@@ -0,0 +1,25 @@
# Generated migration file for adding sales_unit field to KitItem model
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('products', '0005_base_unit_nullable'),
]
operations = [
migrations.AddField(
model_name='kititem',
name='sales_unit',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='kit_items',
to='products.productsalesunit',
verbose_name='Единица продажи'
),
),
]

View File

@@ -163,7 +163,11 @@ class ProductKit(BaseProductEntity):
total = Decimal('0') total = Decimal('0')
for item in self.kit_items.all(): for item in self.kit_items.all():
qty = item.quantity or Decimal('1') qty = item.quantity or Decimal('1')
if item.product: if item.sales_unit:
# Для sales_unit используем цену единицы продажи
unit_price = item.sales_unit.actual_price or Decimal('0')
total += unit_price * qty
elif item.product:
# Используем зафиксированную цену если есть, иначе актуальную цену товара # Используем зафиксированную цену если есть, иначе актуальную цену товара
if item.unit_price is not None: if item.unit_price is not None:
unit_price = item.unit_price unit_price = item.unit_price
@@ -213,7 +217,11 @@ class ProductKit(BaseProductEntity):
# Пересчитаем базовую цену из компонентов # Пересчитаем базовую цену из компонентов
total = Decimal('0') total = Decimal('0')
for item in self.kit_items.all(): for item in self.kit_items.all():
if item.product: if item.sales_unit:
actual_price = item.sales_unit.actual_price or Decimal('0')
qty = item.quantity or Decimal('1')
total += actual_price * qty
elif item.product:
actual_price = item.product.actual_price or Decimal('0') actual_price = item.product.actual_price or Decimal('0')
qty = item.quantity or Decimal('1') qty = item.quantity or Decimal('1')
total += actual_price * qty total += actual_price * qty
@@ -382,8 +390,8 @@ class ProductKit(BaseProductEntity):
class KitItem(models.Model): class KitItem(models.Model):
""" """
Состав комплекта: связь между ProductKit и Product или ProductVariantGroup. Состав комплекта: связь между ProductKit и Product, ProductVariantGroup или ProductSalesUnit.
Позиция может быть либо конкретным товаром, либо группой вариантов. Позиция может быть либо конкретным товаром, либо группой вариантов, либо конкретной единицей продажи.
""" """
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items', kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
verbose_name="Комплект") verbose_name="Комплект")
@@ -403,6 +411,14 @@ class KitItem(models.Model):
related_name='kit_items', related_name='kit_items',
verbose_name="Группа вариантов" verbose_name="Группа вариантов"
) )
sales_unit = models.ForeignKey(
'ProductSalesUnit',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='kit_items',
verbose_name="Единица продажи"
)
quantity = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True, verbose_name="Количество") quantity = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True, verbose_name="Количество")
unit_price = models.DecimalField( unit_price = models.DecimalField(
max_digits=10, max_digits=10,
@@ -428,21 +444,46 @@ class KitItem(models.Model):
return f"{self.kit.name} - {self.get_display_name()}" return f"{self.kit.name} - {self.get_display_name()}"
def clean(self): def clean(self):
"""Валидация: должен быть указан либо product, либо variant_group (но не оба)""" """Валидация: должна быть указана группа вариантов ИЛИ (товар [плюс опционально единица продажи])"""
if self.product and self.variant_group:
raise ValidationError( has_variant = bool(self.variant_group)
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно." has_product = bool(self.product)
) has_sales_unit = bool(self.sales_unit)
if not self.product and not self.variant_group:
# 1. Проверка на пустоту
if not (has_variant or has_product or has_sales_unit):
raise ValidationError( raise ValidationError(
"Необходимо указать либо товар, либо группу вариантов." "Необходимо указать либо товар, либо группу вариантов."
) )
# 2. Несовместимость: Группа вариантов VS Товар/Единица
if has_variant and (has_product or has_sales_unit):
raise ValidationError(
"Нельзя указывать группу вариантов одновременно с товаром или единицей продажи."
)
# 3. Зависимость: Если есть sales_unit, должен быть product
if has_sales_unit and not has_product:
raise ValidationError(
"Если указана единица продажи, должен быть выбран соответствующий товар."
)
# 4. Проверка принадлежности
if has_sales_unit and has_product and self.sales_unit.product != self.product:
raise ValidationError(
"Выбранная единица продажи не принадлежит указанному товару."
)
def get_display_name(self): def get_display_name(self):
"""Возвращает строку для отображения названия компонента""" """Возвращает строку для отображения названия компонента"""
if self.variant_group: # Приоритет: сначала единица продажи, затем товар, затем группа вариантов
if self.sales_unit:
return f"[Единица продажи] {self.sales_unit.name}"
elif self.product:
return self.product.name
elif self.variant_group:
return f"[Варианты] {self.variant_group.name}" return f"[Варианты] {self.variant_group.name}"
return self.product.name if self.product else "Не указан" return "Не указан"
def has_priorities_set(self): def has_priorities_set(self):
"""Проверяет, настроены ли приоритеты замены для данного компонента""" """Проверяет, настроены ли приоритеты замены для данного компонента"""
@@ -452,10 +493,16 @@ class KitItem(models.Model):
""" """
Возвращает список доступных товаров для этого компонента. Возвращает список доступных товаров для этого компонента.
Если указана единица продажи - возвращает товар, к которому она относится.
Если указан конкретный товар - возвращает его. Если указан конкретный товар - возвращает его.
Если указаны приоритеты - возвращает товары в порядке приоритета. Если указаны приоритеты - возвращает товары в порядке приоритета.
Если не указаны приоритеты - возвращает все активные товары из группы вариантов. Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
""" """
# Приоритет: сначала единица продажи, затем товар, затем группа вариантов
if self.sales_unit:
# Если указана единица продажи, возвращаем товар, к которому она относится
return [self.sales_unit.product]
if self.product: if self.product:
# Если указан конкретный товар, возвращаем только его # Если указан конкретный товар, возвращаем только его
return [self.product] return [self.product]

View File

@@ -141,6 +141,14 @@ class Product(BaseProductEntity):
from ..services.cost_calculator import ProductCostCalculator from ..services.cost_calculator import ProductCostCalculator
return ProductCostCalculator.get_cost_details(self) return ProductCostCalculator.get_cost_details(self)
@property
def kit_items_using_as_sales_unit(self):
"""
Возвращает QuerySet KitItem, где этот товар используется как единица продажи.
"""
from .kits import KitItem
return KitItem.objects.filter(sales_unit__product=self)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Используем сервис для подготовки к сохранению # Используем сервис для подготовки к сохранению
ProductSaveService.prepare_product_for_save(self) ProductSaveService.prepare_product_for_save(self)

View File

@@ -8,25 +8,33 @@
<div id="kititem-forms"> <div id="kititem-forms">
{% for kititem_form in kititem_formset %} {% for kititem_form in kititem_formset %}
<div class="card mb-2 kititem-form border" <div class="card mb-2 kititem-form border" data-form-index="{{ forloop.counter0 }}"
data-form-index="{{ forloop.counter0 }}" data-product-id="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.id }}{% endif %}"
data-product-id="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.id }}{% endif %}" data-product-price="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.actual_price|default:0 }}{% else %}0{% endif %}">
data-product-price="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.actual_price|default:0 }}{% else %}0{% endif %}">
{{ kititem_form.id }} {{ kititem_form.id }}
<div class="card-body p-2"> <div class="card-body p-2">
{% if kititem_form.non_field_errors %} {% if kititem_form.non_field_errors %}
<div class="alert alert-danger alert-sm mb-2"> <div class="alert alert-danger alert-sm mb-2">
{% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %} {% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %}
</div> </div>
{% endif %} {% endif %}
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
<!-- ТОВАР --> <!-- ТОВАР -->
<div class="col-md-4"> <div class="col-md-3">
<label class="form-label small text-muted mb-1">Товар</label> <label class="form-label small text-muted mb-1">Товар</label>
{{ kititem_form.product }} {{ kititem_form.product }}
{% if kititem_form.product.errors %} {% if kititem_form.product.errors %}
<div class="text-danger small">{{ kititem_form.product.errors }}</div> <div class="text-danger small">{{ kititem_form.product.errors }}</div>
{% endif %}
</div>
<!-- ЕДИНИЦА ПРОДАЖИ -->
<div class="col-md-2">
<label class="form-label small text-muted mb-1">Единица продажи</label>
{{ kititem_form.sales_unit }}
{% if kititem_form.sales_unit.errors %}
<div class="text-danger small">{{ kititem_form.sales_unit.errors }}</div>
{% endif %} {% endif %}
</div> </div>
@@ -34,19 +42,18 @@
<div class="col-md-1 d-flex justify-content-center align-items-center"> <div class="col-md-1 d-flex justify-content-center align-items-center">
<div class="kit-item-separator"> <div class="kit-item-separator">
<span class="separator-text">ИЛИ</span> <span class="separator-text">ИЛИ</span>
<i class="bi bi-info-circle separator-help" <i class="bi bi-info-circle separator-help" data-bs-toggle="tooltip"
data-bs-toggle="tooltip" data-bs-placement="top"
data-bs-placement="top" title="Вы можете выбрать что-то одно: либо товар, либо группу вариантов"></i>
title="Вы можете выбрать что-то одно: либо товар, либо группу вариантов"></i>
</div> </div>
</div> </div>
<!-- ГРУППА ВАРИАНТОВ --> <!-- ГРУППА ВАРИАНТОВ -->
<div class="col-md-4"> <div class="col-md-3">
<label class="form-label small text-muted mb-1">Группа вариантов</label> <label class="form-label small text-muted mb-1">Группа вариантов</label>
{{ kititem_form.variant_group }} {{ kititem_form.variant_group }}
{% if kititem_form.variant_group.errors %} {% if kititem_form.variant_group.errors %}
<div class="text-danger small">{{ kititem_form.variant_group.errors }}</div> <div class="text-danger small">{{ kititem_form.variant_group.errors }}</div>
{% endif %} {% endif %}
</div> </div>
@@ -55,17 +62,19 @@
<label class="form-label small text-muted mb-1">Кол-во</label> <label class="form-label small text-muted mb-1">Кол-во</label>
{{ kititem_form.quantity|smart_quantity }} {{ kititem_form.quantity|smart_quantity }}
{% if kititem_form.quantity.errors %} {% if kititem_form.quantity.errors %}
<div class="text-danger small">{{ kititem_form.quantity.errors }}</div> <div class="text-danger small">{{ kititem_form.quantity.errors }}</div>
{% endif %} {% endif %}
</div> </div>
<!-- УДАЛЕНИЕ --> <!-- УДАЛЕНИЕ -->
<div class="col-md-1 text-end"> <div class="col-md-1 text-end">
{% if kititem_form.DELETE %} {% if kititem_form.DELETE %}
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.nextElementSibling.checked = true; this.closest('.kititem-form').style.display='none'; if(typeof calculateFinalPrice === 'function') calculateFinalPrice();" title="Удалить"> <button type="button" class="btn btn-sm btn-link text-danger p-0"
<i class="bi bi-x-lg"></i> onclick="this.nextElementSibling.checked = true; this.closest('.kititem-form').style.display='none'; if(typeof calculateFinalPrice === 'function') calculateFinalPrice();"
</button> title="Удалить">
{{ kititem_form.DELETE }} <i class="bi bi-x-lg"></i>
</button>
{{ kititem_form.DELETE }}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -81,4 +90,4 @@
</button> </button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -38,7 +38,7 @@
/** /**
* Инициализирует Select2 для элемента с AJAX поиском товаров * Инициализирует Select2 для элемента с AJAX поиском товаров
* @param {Element} element - DOM элемент select * @param {Element} element - DOM элемент select
* @param {string} type - Тип поиска ('product' или 'variant') * @param {string} type - Тип поиска ('product', 'variant' или 'sales_unit')
* @param {string} apiUrl - URL API для поиска * @param {string} apiUrl - URL API для поиска
* @returns {boolean} - true если инициализация прошла успешно, false иначе * @returns {boolean} - true если инициализация прошла успешно, false иначе
*/ */
@@ -70,60 +70,92 @@
var placeholders = { var placeholders = {
'product': 'Начните вводить название товара...', 'product': 'Начните вводить название товара...',
'variant': 'Начните вводить название группы...' 'variant': 'Начните вводить название группы...',
'sales_unit': 'Выберите единицу продажи...'
}; };
try { // Для единиц продажи используем другой подход - не AJAX, а загрузка при выборе товара
$element.select2({ if (type === 'sales_unit') {
theme: 'bootstrap-5', try {
placeholder: placeholders[type] || 'Выберите...', $element.select2({
allowClear: true, theme: 'bootstrap-5',
width: '100%', placeholder: placeholders[type] || 'Выберите...',
language: 'ru', allowClear: true,
minimumInputLength: 0, width: '100%',
dropdownAutoWidth: false, language: 'ru',
ajax: { minimumInputLength: 0,
url: apiUrl, dropdownAutoWidth: false,
dataType: 'json', // Для единиц продажи не используем AJAX, т.к. они загружаются при выборе товара
delay: 250, disabled: true, // Изначально отключен до выбора товара
data: function (params) { templateResult: formatSelectResult,
return { templateSelection: formatSelectSelection
q: params.term || '', });
type: type, console.log('initProductSelect2: successfully initialized sales_unit for', element.name);
page: params.page || 1 return true;
}; } catch (error) {
console.error('initProductSelect2: initialization error for sales_unit', error);
return false;
}
} else {
// Для товаров и вариантов используем AJAX
try {
$element.select2({
theme: 'bootstrap-5',
placeholder: placeholders[type] || 'Выберите...',
allowClear: true,
width: '100%',
language: 'ru',
minimumInputLength: 0,
dropdownAutoWidth: false,
ajax: {
url: apiUrl,
dataType: 'json',
delay: 250,
data: function (params) {
return {
q: params.term || '',
type: type,
page: params.page || 1
};
},
processResults: function (data) {
return {
results: data.results,
pagination: {
more: data.pagination.more
}
};
},
cache: true
}, },
processResults: function (data) { templateResult: formatSelectResult,
return { templateSelection: formatSelectSelection
results: data.results, });
pagination: { console.log('initProductSelect2: successfully initialized for', element.name);
more: data.pagination.more return true;
} } catch (error) {
}; console.error('initProductSelect2: initialization error', error);
}, return false;
cache: true }
},
templateResult: formatSelectResult,
templateSelection: formatSelectSelection
});
console.log('initProductSelect2: successfully initialized for', element.name);
return true;
} catch (error) {
console.error('initProductSelect2: initialization error', error);
return false;
} }
}; };
/** /**
* Инициализирует Select2 для всех селектов, совпадающих с паттерном * Инициализирует Select2 для всех селектов, совпадающих с паттерном
* @param {string} fieldPattern - Паттерн name атрибута (например: 'items-', 'kititem-') * @param {string} fieldPattern - Паттерн name атрибута (например: 'items-', 'kititem-')
* @param {string} type - Тип поиска ('product' или 'variant') * @param {string} type - Тип поиска ('product', 'variant' или 'sales_unit')
* @param {string} apiUrl - URL API для поиска * @param {string} apiUrl - URL API для поиска
*/ */
window.initAllProductSelect2 = function(fieldPattern, type, apiUrl) { window.initAllProductSelect2 = function(fieldPattern, type, apiUrl) {
document.querySelectorAll('[name*="' + fieldPattern + '"][name*="-product"]').forEach(function(element) { if (type === 'sales_unit') {
window.initProductSelect2(element, type, apiUrl); document.querySelectorAll('[name*="' + fieldPattern + '"][name*="-sales_unit"]').forEach(function(element) {
}); window.initProductSelect2(element, type, apiUrl);
});
} else {
document.querySelectorAll('[name*="' + fieldPattern + '"][name*="-product"]').forEach(function(element) {
window.initProductSelect2(element, type, apiUrl);
});
}
}; };
})(); })();
</script> </script>

View File

@@ -362,8 +362,115 @@
</table> </table>
</div> </div>
</div> </div>
<!-- Комплекты, содержащие этот товар как единицу продажи -->
{% if kit_items_using_sales_units %}
<div class="card mt-4">
<div class="card-header">
<h5>Комплекты, содержащие этот товар как единицу продажи</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Комплект</th>
<th>Количество в комплекте</th>
<th>Цена за единицу</th>
</tr>
</thead>
<tbody>
{% for kit_item in kit_items_using_sales_units %}
<tr>
<td>
<a href="{% url 'products:productkit-detail' kit_item.kit.pk %}">
{{ kit_item.kit.name }}
</a>
</td>
<td>{{ kit_item.quantity|default:"1" }}</td>
<td>{{ kit_item.sales_unit.actual_price|default:"0.00" }} руб.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Комплекты, содержащие этот товар напрямую -->
{% if kit_items_using_products %}
<div class="card mt-4">
<div class="card-header">
<h5>Комплекты, содержащие этот товар напрямую</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Комплект</th>
<th>Количество в комплекте</th>
</tr>
</thead>
<tbody>
{% for kit_item in kit_items_using_products %}
<tr>
<td>
<a href="{% url 'products:productkit-detail' kit_item.kit.pk %}">
{{ kit_item.kit.name }}
</a>
</td>
<td>{{ kit_item.quantity|default:"1" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Комплекты, содержащие этот товар как часть группы вариантов -->
{% if variant_group_kit_items %}
<div class="card mt-4">
<div class="card-header">
<h5>Комплекты, содержащие этот товар как часть группы вариантов</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Комплект</th>
<th>Группа вариантов</th>
<th>Количество в комплекте</th>
</tr>
</thead>
<tbody>
{% for variant_group_item in variant_group_kit_items %}
{% for kit_item in variant_group_item.variant_group.kit_items.all %}
{% if kit_item.product == product %}
<tr>
<td>
<a href="{% url 'products:productkit-detail' kit_item.kit.pk %}">
{{ kit_item.kit.name }}
</a>
</td>
<td>{{ variant_group_item.variant_group.name }}</td>
<td>{{ kit_item.quantity|default:"1" }}</td>
</tr>
{% endif %}
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">

View File

@@ -539,6 +539,135 @@ document.addEventListener('DOMContentLoaded', function() {
return 0; return 0;
} }
// Функция для получения цены единицы продажи
async function getSalesUnitPrice(selectElement) {
if (!selectElement) {
console.warn('getSalesUnitPrice: selectElement is null or undefined');
return 0;
}
const rawValue = selectElement.value;
if (!rawValue) {
console.warn('getSalesUnitPrice: no value');
return 0;
}
// Извлекаем числовой ID из значения (может быть "sales_unit_123" или "123")
let salesUnitId;
if (rawValue.includes('_')) {
const parts = rawValue.split('_');
salesUnitId = parseInt(parts[1]);
} else {
salesUnitId = parseInt(rawValue);
}
if (isNaN(salesUnitId) || salesUnitId <= 0) {
console.warn('getSalesUnitPrice: invalid sales unit id', rawValue);
return 0;
}
// Если уже загружена в кэш - возвращаем
const cacheKey = `sales_unit_${salesUnitId}`;
if (priceCache[cacheKey] !== undefined) {
const cachedPrice = parseFloat(priceCache[cacheKey]) || 0;
console.log('getSalesUnitPrice: from cache', salesUnitId, cachedPrice);
return cachedPrice;
}
// Пытаемся получить из Select2 data (приоритет: actual_price > price)
const $select = $(selectElement);
const selectedData = $select.select2('data');
if (selectedData && selectedData.length > 0) {
const itemData = selectedData[0];
const priceData = itemData.actual_price || itemData.price;
if (priceData) {
const price = parseFloat(priceData) || 0;
if (price > 0) {
priceCache[cacheKey] = price;
console.log('getSalesUnitPrice: from select2 data', salesUnitId, price);
return price;
}
}
}
// Загружаем информацию о единице продажи через API
try {
console.log('getSalesUnitPrice: fetching from API', salesUnitId);
const response = await fetch(
`{% url "products:api-product-sales-units" product_id=0 %}`.replace('/0/', `/${salesUnitId}/`),
{ method: 'GET', headers: { 'Accept': 'application/json' } }
);
if (response.ok) {
const data = await response.json();
if (data.sales_units && data.sales_units.length > 0) {
const salesUnitData = data.sales_units.find(su => su.id == salesUnitId);
if (salesUnitData) {
const price = parseFloat(salesUnitData.actual_price || salesUnitData.price || 0);
if (price > 0) {
priceCache[cacheKey] = price;
console.log('getSalesUnitPrice: from API', salesUnitId, price);
}
return price;
}
}
}
} catch (error) {
console.error('Error fetching sales unit price:', error);
}
console.warn('getSalesUnitPrice: returning 0 for sales unit', salesUnitId);
return 0;
}
// Функция для обновления списка единиц продажи при выборе товара
async function updateSalesUnitsOptions(salesUnitSelect, productValue) {
// Очищаем текущие опции
salesUnitSelect.innerHTML = '<option value="">---------</option>';
salesUnitSelect.disabled = true;
if (!productValue) {
return;
}
// Извлекаем ID товара
let productId;
if (productValue.includes('_')) {
const parts = productValue.split('_');
productId = parseInt(parts[1]);
} else {
productId = parseInt(productValue);
}
if (isNaN(productId) || productId <= 0) {
console.warn('updateSalesUnitsOptions: invalid product id', productValue);
return;
}
try {
// Загружаем единицы продажи для выбранного товара
const response = await fetch(
`{% url "products:api-product-sales-units" product_id=0 %}`.replace('/0/', `/${productId}/`),
{ method: 'GET', headers: { 'Accept': 'application/json' } }
);
if (response.ok) {
const data = await response.json();
if (data.sales_units && data.sales_units.length > 0) {
data.sales_units.forEach(su => {
const option = document.createElement('option');
option.value = su.id;
option.textContent = `${su.name} (${su.actual_price || su.price} руб.)`;
option.dataset.price = su.actual_price || su.price;
option.dataset.actual_price = su.actual_price;
salesUnitSelect.appendChild(option);
});
salesUnitSelect.disabled = false;
}
}
} catch (error) {
console.error('Error fetching sales units:', error);
}
}
// Обновляем data-product-id и загружаем цену при выборе товара // Обновляем data-product-id и загружаем цену при выборе товара
$('[name$="-product"]').on('select2:select', async function() { $('[name$="-product"]').on('select2:select', async function() {
const form = $(this).closest('.kititem-form'); const form = $(this).closest('.kititem-form');
@@ -637,14 +766,21 @@ document.addEventListener('DOMContentLoaded', function() {
// Пропускаем если количество не валидно // Пропускаем если количество не валидно
const validQuantity = quantity > 0 ? quantity : 1; const validQuantity = quantity > 0 ? quantity : 1;
// Проверяем товар // Проверяем единицу продажи (имеет наивысший приоритет)
if (productSelect && productSelect.value) { if (salesUnitSelect && salesUnitSelect.value) {
const salesUnitPrice = await getSalesUnitPrice(salesUnitSelect);
if (salesUnitPrice > 0) {
newBasePrice += (salesUnitPrice * validQuantity);
}
}
// Проверяем товар (если нет единицы продажи)
else if (productSelect && productSelect.value) {
const productPrice = await getProductPrice(productSelect); const productPrice = await getProductPrice(productSelect);
if (productPrice > 0) { if (productPrice > 0) {
newBasePrice += (productPrice * validQuantity); newBasePrice += (productPrice * validQuantity);
} }
} }
// Проверяем группу вариантов // Проверяем группу вариантов (если нет ни единицы продажи, ни товара)
else if (variantGroupSelect && variantGroupSelect.value) { else if (variantGroupSelect && variantGroupSelect.value) {
const variantPrice = await getVariantGroupPrice(variantGroupSelect); const variantPrice = await getVariantGroupPrice(variantGroupSelect);
if (variantPrice > 0) { if (variantPrice > 0) {
@@ -801,6 +937,7 @@ document.addEventListener('DOMContentLoaded', function() {
const selectedProducts = {{ selected_products|default:"{}"|safe }}; const selectedProducts = {{ selected_products|default:"{}"|safe }};
const selectedVariants = {{ selected_variants|default:"{}"|safe }}; const selectedVariants = {{ selected_variants|default:"{}"|safe }};
const selectedSalesUnits = {{ selected_sales_units|default:"{}"|safe }};
$('[name$="-product"]').each(function() { $('[name$="-product"]').each(function() {
const fieldName = $(this).attr('name'); const fieldName = $(this).attr('name');
@@ -817,34 +954,72 @@ document.addEventListener('DOMContentLoaded', function() {
$(this).on('select2:select select2:unselect', calculateFinalPrice); $(this).on('select2:select select2:unselect', calculateFinalPrice);
}); });
$('[name$="-sales_unit"]').each(function() {
const fieldName = $(this).attr('name');
const preloadedData = selectedSalesUnits[fieldName] || null;
initSelect2(this, 'sales_unit', preloadedData);
$(this).on('select2:select select2:unselect', calculateFinalPrice);
});
// ========== УПРАВЛЕНИЕ КОМПОНЕНТАМИ ========== // ========== УПРАВЛЕНИЕ КОМПОНЕНТАМИ ==========
function updateFieldStatus(form) { function updateFieldStatus(form) {
const productSelect = form.querySelector('[name$="-product"]'); const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]'); const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
const salesUnitSelect = form.querySelector('[name$="-sales_unit"]');
if (!productSelect || !variantGroupSelect) return; if (!productSelect || !variantGroupSelect || !salesUnitSelect) return;
const hasProduct = productSelect.value; const hasProduct = productSelect.value;
const hasVariant = variantGroupSelect.value; const hasVariant = variantGroupSelect.value;
const hasSalesUnit = salesUnitSelect.value;
variantGroupSelect.disabled = !!hasProduct; // Если выбрана группа вариантов, блокируем товар и единицу продажи
productSelect.disabled = !!hasVariant; if (hasVariant) {
productSelect.disabled = true;
salesUnitSelect.disabled = true;
}
// Если выбран товар, разблокируем единицу продажи и блокируем группу вариантов
else if (hasProduct) {
salesUnitSelect.disabled = false;
variantGroupSelect.disabled = true;
}
// Если выбрана только единица продажи, но не товар - блокируем все остальные
else if (hasSalesUnit) {
productSelect.disabled = true;
variantGroupSelect.disabled = true;
}
// Если ничего не выбрано - разблокируем товар и группу вариантов, блокируем единицу продажи
else {
productSelect.disabled = false;
variantGroupSelect.disabled = false;
salesUnitSelect.disabled = true;
}
} }
function initializeForm(form) { function initializeForm(form) {
updateFieldStatus(form); updateFieldStatus(form);
const productSelect = form.querySelector('[name$="-product"]'); const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]'); const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
const salesUnitSelect = form.querySelector('[name$="-sales_unit"]');
[productSelect, variantGroupSelect].forEach(field => { [productSelect, variantGroupSelect, salesUnitSelect].forEach(field => {
if (field) { if (field) {
field.addEventListener('change', () => { field.addEventListener('change', () => {
updateFieldStatus(form); updateFieldStatus(form);
// Обновляем список единиц продажи при изменении товара
if (field === productSelect) {
updateSalesUnitsOptions(salesUnitSelect, productSelect.value);
}
calculateFinalPrice(); calculateFinalPrice();
}); });
} }
}); });
// Инициализируем список единиц продажи, если товар уже выбран
if (productSelect && productSelect.value) {
updateSalesUnitsOptions(salesUnitSelect, productSelect.value);
}
const quantityInput = form.querySelector('[name$="-quantity"]'); const quantityInput = form.querySelector('[name$="-quantity"]');
if (quantityInput) { if (quantityInput) {
quantityInput.addEventListener('change', calculateFinalPrice); quantityInput.addEventListener('change', calculateFinalPrice);
@@ -874,12 +1049,13 @@ document.addEventListener('DOMContentLoaded', function() {
<option value="">---------</option> <option value="">---------</option>
</select> </select>
</div> </div>
<div class="col-md-1 d-flex justify-content-center"> <div class="col-md-3">
<div class="kit-item-separator"> <label class="form-label small text-muted mb-1">Единица продажи</label>
<span class="separator-text">ИЛИ</span> <select class="form-control form-control-sm" name="kititem-${newFormId}-sales_unit">
</div> <option value="">---------</option>
</select>
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<label class="form-label small text-muted mb-1">Группа вариантов</label> <label class="form-label small text-muted mb-1">Группа вариантов</label>
<select class="form-control form-control-sm" name="kititem-${newFormId}-variant_group"> <select class="form-control form-control-sm" name="kititem-${newFormId}-variant_group">
<option value="">---------</option> <option value="">---------</option>
@@ -911,12 +1087,16 @@ document.addEventListener('DOMContentLoaded', function() {
const productSelect = newForm.querySelector('[name$="-product"]'); const productSelect = newForm.querySelector('[name$="-product"]');
const variantSelect = newForm.querySelector('[name$="-variant_group"]'); const variantSelect = newForm.querySelector('[name$="-variant_group"]');
const salesUnitSelect = newForm.querySelector('[name$="-sales_unit"]');
initSelect2(productSelect, 'product'); initSelect2(productSelect, 'product');
initSelect2(variantSelect, 'variant'); initSelect2(variantSelect, 'variant');
initSelect2(salesUnitSelect, 'sales_unit');
// Добавляем обработчики для новой формы // Добавляем обработчики для новой формы
$(productSelect).on('select2:select select2:unselect', calculateFinalPrice); $(productSelect).on('select2:select select2:unselect', calculateFinalPrice);
$(variantSelect).on('select2:select select2:unselect', calculateFinalPrice); $(variantSelect).on('select2:select select2:unselect', calculateFinalPrice);
$(salesUnitSelect).on('select2:select select2:unselect', calculateFinalPrice);
initializeForm(newForm); initializeForm(newForm);
@@ -987,9 +1167,20 @@ document.addEventListener('DOMContentLoaded', function() {
allForms.forEach(form => { allForms.forEach(form => {
const productSelect = form.querySelector('[name$="-product"]'); const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]'); const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
const salesUnitSelect = form.querySelector('[name$="-sales_unit"]');
const deleteCheckbox = form.querySelector('[name$="-DELETE"]'); const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
if (!productSelect.value && !variantGroupSelect.value && deleteCheckbox) { // Проверяем, что выбран хотя бы один компонент (товар, группа вариантов или единица продажи)
// Если выбрана единица продажи, товар должен быть выбран
if (salesUnitSelect && salesUnitSelect.value && (!productSelect || !productSelect.value)) {
// Если выбрана единица продажи, но не выбран товар - это ошибка
alert('Если выбрана единица продажи, должен быть выбран соответствующий товар.');
e.preventDefault();
return;
}
// Если ничего не выбрано, отмечаем для удаления
if (!productSelect.value && !variantGroupSelect.value && !salesUnitSelect.value && deleteCheckbox) {
deleteCheckbox.checked = true; deleteCheckbox.checked = true;
} }
}); });

View File

@@ -136,7 +136,13 @@
<tr> <tr>
<td>{{ forloop.counter }}</td> <td>{{ forloop.counter }}</td>
<td> <td>
{% if item.product %} {% if item.sales_unit %}
<a href="{% url 'products:product-detail' item.sales_unit.product.pk %}">
{{ item.sales_unit.name }}
</a>
<br>
<small class="text-muted">Единица продажи: {{ item.sales_unit.product.name }}</small>
{% elif item.product %}
<a href="{% url 'products:product-detail' item.product.pk %}"> <a href="{% url 'products:product-detail' item.product.pk %}">
{{ item.product.name }} {{ item.product.name }}
</a> </a>
@@ -149,7 +155,9 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if item.product %} {% if item.sales_unit %}
<span class="badge bg-info">Единица продажи</span>
{% elif item.product %}
<span class="badge bg-success">Товар</span> <span class="badge bg-success">Товар</span>
{% else %} {% else %}
<span class="badge bg-primary">Варианты</span> <span class="badge bg-primary">Варианты</span>

File diff suppressed because it is too large Load Diff

View File

@@ -208,6 +208,15 @@ class ProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailVie
# Единицы продажи (активные, отсортированные) # Единицы продажи (активные, отсортированные)
context['sales_units'] = self.object.sales_units.filter(is_active=True).order_by('position', 'name') context['sales_units'] = self.object.sales_units.filter(is_active=True).order_by('position', 'name')
# Комплекты, в которых этот товар используется как единица продажи
context['kit_items_using_sales_units'] = self.object.kit_items_using_as_sales_unit.select_related('kit', 'sales_unit').prefetch_related('kit__photos')
# Комплекты, в которых этот товар используется напрямую
context['kit_items_using_products'] = self.object.kit_items_direct.select_related('kit').prefetch_related('kit__photos')
# Комплекты, в которых этот товар используется как часть группы вариантов
context['variant_group_kit_items'] = self.object.variant_group_items.select_related('variant_group').prefetch_related('variant_group__kit_items__kit__photos')
return context return context

View File

@@ -113,6 +113,12 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
# Извлекаем числовой ID из "product_123" # Извлекаем числовой ID из "product_123"
numeric_id = value.split('_')[1] numeric_id = value.split('_')[1]
post_data[key] = numeric_id post_data[key] = numeric_id
elif key.endswith('-sales_unit') and post_data[key]:
value = post_data[key]
if '_' in value:
# Извлекаем числовой ID из "sales_unit_123"
numeric_id = value.split('_')[1]
post_data[key] = numeric_id
# Заменяем request.POST на очищенные данные # Заменяем request.POST на очищенные данные
request.POST = post_data request.POST = post_data
@@ -126,9 +132,10 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem') context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2 # При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
from ..models import Product, ProductVariantGroup from ..models import Product, ProductVariantGroup, ProductSalesUnit
selected_products = {} selected_products = {}
selected_variants = {} selected_variants = {}
selected_sales_units = {}
for key, value in self.request.POST.items(): for key, value in self.request.POST.items():
if '-product' in key and value: if '-product' in key and value:
@@ -168,8 +175,25 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
except ProductVariantGroup.DoesNotExist: except ProductVariantGroup.DoesNotExist:
pass pass
if '-sales_unit' in key and value:
try:
sales_unit = ProductSalesUnit.objects.select_related('product').get(id=value)
text = f"{sales_unit.name} ({sales_unit.product.name})"
# Получаем actual_price: приоритет sale_price > price
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
selected_sales_units[key] = {
'id': sales_unit.id,
'text': text,
'price': str(sales_unit.price) if sales_unit.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
except ProductSalesUnit.DoesNotExist:
pass
context['selected_products'] = selected_products context['selected_products'] = selected_products
context['selected_variants'] = selected_variants context['selected_variants'] = selected_variants
context['selected_sales_units'] = selected_sales_units
else: else:
context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem') context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem')
@@ -271,6 +295,12 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
# Извлекаем числовой ID из "product_123" # Извлекаем числовой ID из "product_123"
numeric_id = value.split('_')[1] numeric_id = value.split('_')[1]
post_data[key] = numeric_id post_data[key] = numeric_id
elif key.endswith('-sales_unit') and post_data[key]:
value = post_data[key]
if '_' in value:
# Извлекаем числовой ID из "sales_unit_123"
numeric_id = value.split('_')[1]
post_data[key] = numeric_id
# Заменяем request.POST на очищенные данные # Заменяем request.POST на очищенные данные
request.POST = post_data request.POST = post_data
@@ -284,8 +314,10 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object, prefix='kititem') context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object, prefix='kititem')
# При ошибке валидации - подготавливаем данные для Select2 # При ошибке валидации - подготавливаем данные для Select2
from ..models import Product, ProductVariantGroup, ProductSalesUnit
selected_products = {} selected_products = {}
selected_variants = {} selected_variants = {}
selected_sales_units = {}
for key, value in self.request.POST.items(): for key, value in self.request.POST.items():
if '-product' in key and value: if '-product' in key and value:
@@ -328,14 +360,35 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
except ProductVariantGroup.DoesNotExist: except ProductVariantGroup.DoesNotExist:
pass pass
if '-sales_unit' in key and value:
try:
# Очищаем ID от префикса если есть
numeric_value = value.split('_')[1] if '_' in value else value
sales_unit = ProductSalesUnit.objects.select_related('product').get(id=numeric_value)
text = f"{sales_unit.name} ({sales_unit.product.name})"
# Получаем actual_price: приоритет sale_price > price
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
selected_sales_units[key] = {
'id': sales_unit.id,
'text': text,
'price': str(sales_unit.price) if sales_unit.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
except ProductSalesUnit.DoesNotExist:
pass
context['selected_products'] = selected_products context['selected_products'] = selected_products
context['selected_variants'] = selected_variants context['selected_variants'] = selected_variants
context['selected_sales_units'] = selected_sales_units
else: else:
context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem') context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem')
# Подготавливаем данные для предзагрузки в Select2 # Подготавливаем данные для предзагрузки в Select2
from ..models import Product, ProductVariantGroup, ProductSalesUnit
selected_products = {} selected_products = {}
selected_variants = {} selected_variants = {}
selected_sales_units = {}
for item in self.object.kit_items.all(): for item in self.object.kit_items.all():
form_prefix = f"kititem-{item.id}" form_prefix = f"kititem-{item.id}"
@@ -354,6 +407,17 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
'actual_price': str(actual_price) if actual_price else '0' 'actual_price': str(actual_price) if actual_price else '0'
} }
if item.sales_unit:
sales_unit = item.sales_unit
text = f"{sales_unit.name} ({sales_unit.product.name})"
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
selected_sales_units[f"{form_prefix}-sales_unit"] = {
'id': sales_unit.id,
'text': text,
'price': str(sales_unit.price) if sales_unit.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
if item.variant_group: if item.variant_group:
variant_group = ProductVariantGroup.objects.prefetch_related( variant_group = ProductVariantGroup.objects.prefetch_related(
'items__product' 'items__product'
@@ -373,6 +437,7 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
context['selected_products'] = selected_products context['selected_products'] = selected_products
context['selected_variants'] = selected_variants context['selected_variants'] = selected_variants
context['selected_sales_units'] = selected_sales_units
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at') context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count() context['photos_count'] = self.object.photos.count()