From e138a28475b91d8f59def77178ef8f48abd0ecb2 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Wed, 21 Jan 2026 10:16:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= =?UTF-8?q?=20=D0=B2=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=BB=D0=B5=D0=BA=D1=82=D0=BE=D0=B2:=20=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B0,=20=D1=80=D0=B0=D1=81=D1=87=D0=B5?= =?UTF-8?q?=D1=82=20=D1=86=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- myproject/products/forms.py | 48 +- .../0001_add_sales_unit_to_kititem.py | 25 + myproject/products/models/kits.py | 71 +- myproject/products/models/products.py | 8 + .../products/includes/kititem_formset.html | 51 +- .../includes/select2-product-init.html | 118 +- .../templates/products/product_detail.html | 109 +- .../templates/products/productkit_create.html | 217 ++- .../templates/products/productkit_detail.html | 12 +- .../templates/products/productkit_edit.html | 1370 ++++++++++------- myproject/products/views/product_views.py | 9 + myproject/products/views/productkit_views.py | 67 +- 12 files changed, 1447 insertions(+), 658 deletions(-) create mode 100644 myproject/products/migrations/0001_add_sales_unit_to_kititem.py diff --git a/myproject/products/forms.py b/myproject/products/forms.py index 6b6a335..4a5276a 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -313,15 +313,17 @@ class KitItemForm(forms.ModelForm): """ class Meta: model = KitItem - fields = ['product', 'variant_group', 'quantity'] + fields = ['product', 'variant_group', 'sales_unit', 'quantity'] labels = { 'product': 'Конкретный товар', 'variant_group': 'Группа вариантов', + 'sales_unit': 'Единица продажи', 'quantity': 'Количество' } widgets = { 'product': 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'}), } @@ -335,24 +337,35 @@ class KitItemForm(forms.ModelForm): cleaned_data = super().clean() product = cleaned_data.get('product') variant_group = cleaned_data.get('variant_group') + sales_unit = cleaned_data.get('sales_unit') 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 return cleaned_data - # Валидация: должен быть указан либо product, либо variant_group (но не оба) - if product and variant_group: + # Валидация несовместимых полей + if variant_group and (product or sales_unit): raise forms.ValidationError( - "Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно." + "Нельзя указывать группу вариантов одновременно с товаром или единицей продажи." ) + + # Если выбрана единица продажи, товар обязателен + if sales_unit and not product: + raise forms.ValidationError("Для единицы продажи должен быть выбран товар.") - # Валидация: если выбран товар/группа, количество обязательно и должно быть > 0 - if (product or variant_group): - if not quantity or quantity <= 0: - raise forms.ValidationError('Необходимо указать количество больше 0') + # Валидация: если выбран товар/группа/единица продажи, количество обязательно и должно быть > 0 + if not quantity or quantity <= 0: + raise forms.ValidationError('Необходимо указать количество больше 0') + + # Валидация: если выбрана единица продажи, проверяем, что она принадлежит выбранному продукту + if sales_unit and product and sales_unit.product != product: + raise forms.ValidationError('Выбранная единица продажи не принадлежит указанному товару.') return cleaned_data @@ -367,6 +380,7 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet): products = [] variant_groups = [] + sales_units = [] for form in self.forms: if self.can_delete and self._should_delete_form(form): @@ -374,6 +388,7 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet): product = form.cleaned_data.get('product') variant_group = form.cleaned_data.get('variant_group') + sales_unit = form.cleaned_data.get('sales_unit') # Проверка дубликатов товаров if product: @@ -393,13 +408,22 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet): ) 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( ProductKit, KitItem, form=KitItemForm, formset=BaseKitItemFormSet, - fields=['product', 'variant_group', 'quantity'], + fields=['product', 'variant_group', 'sales_unit', 'quantity'], extra=1, # Показать 1 пустую форму для первого компонента can_delete=True, # Разрешить удаление компонентов min_num=0, # Минимум 0 компонентов (можно создать пустой комплект) @@ -413,7 +437,7 @@ KitItemFormSetUpdate = inlineformset_factory( KitItem, form=KitItemForm, formset=BaseKitItemFormSet, - fields=['product', 'variant_group', 'quantity'], + fields=['product', 'variant_group', 'sales_unit', 'quantity'], extra=0, # НЕ показывать пустые формы при редактировании can_delete=True, # Разрешить удаление компонентов min_num=0, # Минимум 0 компонентов diff --git a/myproject/products/migrations/0001_add_sales_unit_to_kititem.py b/myproject/products/migrations/0001_add_sales_unit_to_kititem.py new file mode 100644 index 0000000..12664b8 --- /dev/null +++ b/myproject/products/migrations/0001_add_sales_unit_to_kititem.py @@ -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='Единица продажи' + ), + ), + ] \ No newline at end of file diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py index 76faf4f..76eb1d4 100644 --- a/myproject/products/models/kits.py +++ b/myproject/products/models/kits.py @@ -163,7 +163,11 @@ class ProductKit(BaseProductEntity): total = Decimal('0') for item in self.kit_items.all(): 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: unit_price = item.unit_price @@ -213,7 +217,11 @@ class ProductKit(BaseProductEntity): # Пересчитаем базовую цену из компонентов total = Decimal('0') 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') qty = item.quantity or Decimal('1') total += actual_price * qty @@ -382,8 +390,8 @@ class ProductKit(BaseProductEntity): class KitItem(models.Model): """ - Состав комплекта: связь между ProductKit и Product или ProductVariantGroup. - Позиция может быть либо конкретным товаром, либо группой вариантов. + Состав комплекта: связь между ProductKit и Product, ProductVariantGroup или ProductSalesUnit. + Позиция может быть либо конкретным товаром, либо группой вариантов, либо конкретной единицей продажи. """ kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items', verbose_name="Комплект") @@ -403,6 +411,14 @@ class KitItem(models.Model): related_name='kit_items', 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="Количество") unit_price = models.DecimalField( max_digits=10, @@ -428,21 +444,46 @@ class KitItem(models.Model): return f"{self.kit.name} - {self.get_display_name()}" def clean(self): - """Валидация: должен быть указан либо product, либо variant_group (но не оба)""" - if self.product and self.variant_group: - raise ValidationError( - "Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно." - ) - if not self.product and not self.variant_group: + """Валидация: должна быть указана группа вариантов ИЛИ (товар [плюс опционально единица продажи])""" + + has_variant = bool(self.variant_group) + has_product = bool(self.product) + has_sales_unit = bool(self.sales_unit) + + # 1. Проверка на пустоту + if not (has_variant or has_product or has_sales_unit): 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): """Возвращает строку для отображения названия компонента""" - 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 self.product.name if self.product else "Не указан" + return "Не указан" 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: # Если указан конкретный товар, возвращаем только его return [self.product] diff --git a/myproject/products/models/products.py b/myproject/products/models/products.py index 1d9c585..c6b8bae 100644 --- a/myproject/products/models/products.py +++ b/myproject/products/models/products.py @@ -141,6 +141,14 @@ class Product(BaseProductEntity): from ..services.cost_calculator import ProductCostCalculator 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): # Используем сервис для подготовки к сохранению ProductSaveService.prepare_product_for_save(self) diff --git a/myproject/products/templates/products/includes/kititem_formset.html b/myproject/products/templates/products/includes/kititem_formset.html index b053974..dfcb453 100644 --- a/myproject/products/templates/products/includes/kititem_formset.html +++ b/myproject/products/templates/products/includes/kititem_formset.html @@ -8,25 +8,33 @@
{% for kititem_form in kititem_formset %} -
+
{{ kititem_form.id }}
{% if kititem_form.non_field_errors %} -
- {% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %} -
+
+ {% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %} +
{% endif %}
-
+
{{ kititem_form.product }} {% if kititem_form.product.errors %} -
{{ kititem_form.product.errors }}
+
{{ kititem_form.product.errors }}
+ {% endif %} +
+ + +
+ + {{ kititem_form.sales_unit }} + {% if kititem_form.sales_unit.errors %} +
{{ kititem_form.sales_unit.errors }}
{% endif %}
@@ -34,19 +42,18 @@
ИЛИ - +
-
+
{{ kititem_form.variant_group }} {% if kititem_form.variant_group.errors %} -
{{ kititem_form.variant_group.errors }}
+
{{ kititem_form.variant_group.errors }}
{% endif %}
@@ -55,17 +62,19 @@ {{ kititem_form.quantity|smart_quantity }} {% if kititem_form.quantity.errors %} -
{{ kititem_form.quantity.errors }}
+
{{ kititem_form.quantity.errors }}
{% endif %}
{% if kititem_form.DELETE %} - - {{ kititem_form.DELETE }} + + {{ kititem_form.DELETE }} {% endif %}
@@ -81,4 +90,4 @@
-
+
\ No newline at end of file diff --git a/myproject/products/templates/products/includes/select2-product-init.html b/myproject/products/templates/products/includes/select2-product-init.html index 5d0ebd2..8710544 100644 --- a/myproject/products/templates/products/includes/select2-product-init.html +++ b/myproject/products/templates/products/includes/select2-product-init.html @@ -38,7 +38,7 @@ /** * Инициализирует Select2 для элемента с AJAX поиском товаров * @param {Element} element - DOM элемент select - * @param {string} type - Тип поиска ('product' или 'variant') + * @param {string} type - Тип поиска ('product', 'variant' или 'sales_unit') * @param {string} apiUrl - URL API для поиска * @returns {boolean} - true если инициализация прошла успешно, false иначе */ @@ -70,60 +70,92 @@ var placeholders = { 'product': 'Начните вводить название товара...', - 'variant': 'Начните вводить название группы...' + 'variant': 'Начните вводить название группы...', + 'sales_unit': 'Выберите единицу продажи...' }; - 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 - }; + // Для единиц продажи используем другой подход - не AJAX, а загрузка при выборе товара + if (type === 'sales_unit') { + try { + $element.select2({ + theme: 'bootstrap-5', + placeholder: placeholders[type] || 'Выберите...', + allowClear: true, + width: '100%', + language: 'ru', + minimumInputLength: 0, + dropdownAutoWidth: false, + // Для единиц продажи не используем AJAX, т.к. они загружаются при выборе товара + disabled: true, // Изначально отключен до выбора товара + templateResult: formatSelectResult, + templateSelection: formatSelectSelection + }); + console.log('initProductSelect2: successfully initialized sales_unit for', element.name); + 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) { - return { - results: data.results, - pagination: { - more: data.pagination.more - } - }; - }, - 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; + 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 для всех селектов, совпадающих с паттерном * @param {string} fieldPattern - Паттерн name атрибута (например: 'items-', 'kititem-') - * @param {string} type - Тип поиска ('product' или 'variant') + * @param {string} type - Тип поиска ('product', 'variant' или 'sales_unit') * @param {string} apiUrl - URL API для поиска */ window.initAllProductSelect2 = function(fieldPattern, type, apiUrl) { - document.querySelectorAll('[name*="' + fieldPattern + '"][name*="-product"]').forEach(function(element) { - window.initProductSelect2(element, type, apiUrl); - }); + if (type === 'sales_unit') { + 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); + }); + } }; })(); diff --git a/myproject/products/templates/products/product_detail.html b/myproject/products/templates/products/product_detail.html index 8bd9796..3512a12 100644 --- a/myproject/products/templates/products/product_detail.html +++ b/myproject/products/templates/products/product_detail.html @@ -362,8 +362,115 @@
+ + + {% if kit_items_using_sales_units %} +
+
+
Комплекты, содержащие этот товар как единицу продажи
+
+
+
+ + + + + + + + + + {% for kit_item in kit_items_using_sales_units %} + + + + + + {% endfor %} + +
КомплектКоличество в комплектеЦена за единицу
+ + {{ kit_item.kit.name }} + + {{ kit_item.quantity|default:"1" }}{{ kit_item.sales_unit.actual_price|default:"0.00" }} руб.
+
+
+
+ {% endif %} + + + {% if kit_items_using_products %} +
+
+
Комплекты, содержащие этот товар напрямую
+
+
+
+ + + + + + + + + {% for kit_item in kit_items_using_products %} + + + + + {% endfor %} + +
КомплектКоличество в комплекте
+ + {{ kit_item.kit.name }} + + {{ kit_item.quantity|default:"1" }}
+
+
+
+ {% endif %} + + + {% if variant_group_kit_items %} +
+
+
Комплекты, содержащие этот товар как часть группы вариантов
+
+
+
+ + + + + + + + + + {% 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 %} + + + + + + {% endif %} + {% endfor %} + {% endfor %} + +
КомплектГруппа вариантовКоличество в комплекте
+ + {{ kit_item.kit.name }} + + {{ variant_group_item.variant_group.name }}{{ kit_item.quantity|default:"1" }}
+
+
+
+ {% endif %} - +
diff --git a/myproject/products/templates/products/productkit_create.html b/myproject/products/templates/products/productkit_create.html index 96445b3..720069e 100644 --- a/myproject/products/templates/products/productkit_create.html +++ b/myproject/products/templates/products/productkit_create.html @@ -539,6 +539,135 @@ document.addEventListener('DOMContentLoaded', function() { 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 = ''; + 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 и загружаем цену при выборе товара $('[name$="-product"]').on('select2:select', async function() { const form = $(this).closest('.kititem-form'); @@ -637,14 +766,21 @@ document.addEventListener('DOMContentLoaded', function() { // Пропускаем если количество не валидно 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); if (productPrice > 0) { newBasePrice += (productPrice * validQuantity); } } - // Проверяем группу вариантов + // Проверяем группу вариантов (если нет ни единицы продажи, ни товара) else if (variantGroupSelect && variantGroupSelect.value) { const variantPrice = await getVariantGroupPrice(variantGroupSelect); if (variantPrice > 0) { @@ -801,6 +937,7 @@ document.addEventListener('DOMContentLoaded', function() { const selectedProducts = {{ selected_products|default:"{}"|safe }}; const selectedVariants = {{ selected_variants|default:"{}"|safe }}; + const selectedSalesUnits = {{ selected_sales_units|default:"{}"|safe }}; $('[name$="-product"]').each(function() { const fieldName = $(this).attr('name'); @@ -817,34 +954,72 @@ document.addEventListener('DOMContentLoaded', function() { $(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) { const productSelect = form.querySelector('[name$="-product"]'); 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 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) { updateFieldStatus(form); const productSelect = form.querySelector('[name$="-product"]'); 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) { field.addEventListener('change', () => { updateFieldStatus(form); + // Обновляем список единиц продажи при изменении товара + if (field === productSelect) { + updateSalesUnitsOptions(salesUnitSelect, productSelect.value); + } calculateFinalPrice(); }); } }); + // Инициализируем список единиц продажи, если товар уже выбран + if (productSelect && productSelect.value) { + updateSalesUnitsOptions(salesUnitSelect, productSelect.value); + } + const quantityInput = form.querySelector('[name$="-quantity"]'); if (quantityInput) { quantityInput.addEventListener('change', calculateFinalPrice); @@ -874,12 +1049,13 @@ document.addEventListener('DOMContentLoaded', function() {
-
-
- ИЛИ -
+
+ +
-
+
+ @@ -75,7 +77,7 @@
-
+
@@ -91,8 +93,10 @@
- Сумма цен компонентов: - 0.00 руб. + Сумма цен + компонентов: + 0.00 + руб.
@@ -106,13 +110,15 @@
- + %
- + руб
@@ -128,13 +134,15 @@
- + %
- + руб
@@ -147,14 +155,18 @@
- Итоговая цена: - 0.00 руб. + Итоговая + цена: + 0.00 руб.
- - + +
@@ -164,9 +176,10 @@
Цена со скидкой
{{ form.sale_price }} - Если указана, будет использоваться вместо расчетной цены + Если указана, будет использоваться вместо расчетной + цены {% if form.sale_price.errors %} -
{{ form.sale_price.errors }}
+
{{ form.sale_price.errors }}
{% endif %}
@@ -180,7 +193,7 @@ {{ form.categories }}
{% if form.categories.errors %} -
{{ form.categories.errors }}
+
{{ form.categories.errors }}
{% endif %}
@@ -191,7 +204,7 @@ {{ form.external_category }} {% if form.external_category.errors %} -
{{ form.external_category.errors }}
+
{{ form.external_category.errors }}
{% endif %}
@@ -201,7 +214,7 @@ {{ form.tags }}
{% if form.tags.errors %} -
{{ form.tags.errors }}
+
{{ form.tags.errors }}
{% endif %}
@@ -216,7 +229,7 @@ {{ form.sku }} {% if form.sku.errors %} -
{{ form.sku.errors }}
+
{{ form.sku.errors }}
{% endif %} @@ -224,7 +237,7 @@ {{ form.status.label_tag }} {{ form.status }} {% if form.status.errors %} -
{{ form.status.errors }}
+
{{ form.status.errors }}
{% endif %} @@ -233,7 +246,8 @@ -
+
Отмена @@ -245,558 +259,726 @@
{% include 'products/includes/select2-product-init.html' %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/myproject/products/views/product_views.py b/myproject/products/views/product_views.py index c0914a2..08f2405 100644 --- a/myproject/products/views/product_views.py +++ b/myproject/products/views/product_views.py @@ -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['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 diff --git a/myproject/products/views/productkit_views.py b/myproject/products/views/productkit_views.py index 2dff15c..e26be53 100644 --- a/myproject/products/views/productkit_views.py +++ b/myproject/products/views/productkit_views.py @@ -113,6 +113,12 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create # Извлекаем числовой ID из "product_123" numeric_id = value.split('_')[1] 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 = post_data @@ -126,9 +132,10 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem') # При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2 - from ..models import Product, ProductVariantGroup + from ..models import Product, ProductVariantGroup, ProductSalesUnit selected_products = {} selected_variants = {} + selected_sales_units = {} for key, value in self.request.POST.items(): if '-product' in key and value: @@ -168,8 +175,25 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create except ProductVariantGroup.DoesNotExist: 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_variants'] = selected_variants + context['selected_sales_units'] = selected_sales_units else: context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem') @@ -271,6 +295,12 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update # Извлекаем числовой ID из "product_123" numeric_id = value.split('_')[1] 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 = post_data @@ -284,8 +314,10 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object, prefix='kititem') # При ошибке валидации - подготавливаем данные для Select2 + from ..models import Product, ProductVariantGroup, ProductSalesUnit selected_products = {} selected_variants = {} + selected_sales_units = {} for key, value in self.request.POST.items(): if '-product' in key and value: @@ -328,14 +360,35 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update except ProductVariantGroup.DoesNotExist: 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_variants'] = selected_variants + context['selected_sales_units'] = selected_sales_units else: context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem') # Подготавливаем данные для предзагрузки в Select2 + from ..models import Product, ProductVariantGroup, ProductSalesUnit selected_products = {} selected_variants = {} + selected_sales_units = {} for item in self.object.kit_items.all(): 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' } + 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: variant_group = ProductVariantGroup.objects.prefetch_related( 'items__product' @@ -373,6 +437,7 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update context['selected_products'] = selected_products 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['photos_count'] = self.object.photos.count()