diff --git a/myproject/integrations/recommerce/mappers.py b/myproject/integrations/recommerce/mappers.py index 28a36d8..bcb2368 100644 --- a/myproject/integrations/recommerce/mappers.py +++ b/myproject/integrations/recommerce/mappers.py @@ -48,7 +48,7 @@ def to_api_product( data['name'] = product.name if 'parent_category_sku' in fields: - # Получаем категорию через primary_category или fallback на M2M categories + # Получаем категорию через external_category или fallback на M2M categories _, category_sku = product.get_category_for_integration('recommerce') if category_sku: data['parent_category_sku'] = category_sku diff --git a/myproject/products/admin.py b/myproject/products/admin.py index db1da29..485839c 100644 --- a/myproject/products/admin.py +++ b/myproject/products/admin.py @@ -396,7 +396,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): search_fields = ('name', 'sku', 'description', 'search_keywords') filter_horizontal = ('categories', 'tags', 'variant_groups') readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by', 'cost_price', 'cost_price_details_display') - autocomplete_fields = ['base_unit', 'primary_category'] + autocomplete_fields = ['base_unit', 'external_category'] actions = [ restore_items, delete_selected, @@ -408,7 +408,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): fieldsets = ( ('Основная информация', { - 'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'primary_category', 'base_unit', 'unit', 'price', 'sale_price') + 'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'external_category', 'base_unit', 'unit', 'price', 'sale_price') }), ('Себестоимость', { 'fields': ('cost_price_details_display',), @@ -589,7 +589,7 @@ class ProductKitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): prepopulated_fields = {'slug': ('name',)} filter_horizontal = ('categories', 'tags') readonly_fields = ('photo_preview_large', 'base_price', 'archived_at', 'archived_by', 'order') - autocomplete_fields = ['primary_category'] + autocomplete_fields = ['external_category'] actions = [ show_poor_quality_photos, show_excellent_quality_photos, @@ -598,7 +598,7 @@ class ProductKitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): fieldsets = ( ('Основная информация', { - 'fields': ('name', 'sku', 'slug', 'description', 'short_description', 'categories', 'primary_category') + 'fields': ('name', 'sku', 'slug', 'description', 'short_description', 'categories', 'external_category') }), ('Ценообразование', { 'fields': ('base_price', 'price', 'sale_price', 'price_adjustment_type', 'price_adjustment_value'), @@ -1063,11 +1063,11 @@ class ConfigurableProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): search_fields = ('name', 'sku', 'description') readonly_fields = ('created_at', 'updated_at', 'slug') inlines = [ConfigurableProductOptionInline, ConfigurableProductAttributeInline] - autocomplete_fields = ['primary_category'] + autocomplete_fields = ['external_category'] fieldsets = ( ('Основная информация', { - 'fields': ('name', 'sku', 'slug', 'status', 'primary_category') + 'fields': ('name', 'sku', 'slug', 'status', 'external_category') }), ('Описание', { 'fields': ('short_description', 'description') diff --git a/myproject/products/forms.py b/myproject/products/forms.py index 1113665..2953dc3 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -87,12 +87,12 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm): label="Категории" ) - primary_category = forms.ModelChoiceField( + external_category = forms.ModelChoiceField( queryset=ProductCategory.objects.filter(is_active=True), required=False, empty_label="Не выбрана", - label="Основная категория", - help_text="Используется для интеграций с внешними площадками", + label="Внешняя категория", + help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)", widget=forms.Select(attrs={'class': 'form-select'}) ) @@ -107,7 +107,7 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm): model = Product fields = [ 'name', 'sku', 'description', 'short_description', 'categories', - 'primary_category', 'tags', 'base_unit', 'price', 'sale_price', 'status', + 'external_category', 'tags', 'base_unit', 'price', 'sale_price', 'status', 'is_new', 'is_popular', 'is_special' ] labels = { @@ -202,12 +202,12 @@ class ProductKitForm(SKUUniqueMixin, forms.ModelForm): label="Категории" ) - primary_category = forms.ModelChoiceField( + external_category = forms.ModelChoiceField( queryset=ProductCategory.objects.filter(is_active=True), required=False, empty_label="Не выбрана", - label="Основная категория", - help_text="Используется для интеграций с внешними площадками", + label="Внешняя категория", + help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)", widget=forms.Select(attrs={'class': 'form-select'}) ) @@ -222,7 +222,7 @@ class ProductKitForm(SKUUniqueMixin, forms.ModelForm): model = ProductKit fields = [ 'name', 'sku', 'description', 'short_description', 'categories', - 'primary_category', 'tags', 'sale_price', 'price_adjustment_type', 'price_adjustment_value', 'status' + 'external_category', 'tags', 'sale_price', 'price_adjustment_type', 'price_adjustment_value', 'status' ] labels = { 'name': 'Название', @@ -689,18 +689,18 @@ class ConfigurableProductForm(SKUUniqueMixin, forms.ModelForm): """ Форма для создания и редактирования вариативного товара. """ - primary_category = forms.ModelChoiceField( + external_category = forms.ModelChoiceField( queryset=ProductCategory.objects.filter(is_active=True), required=False, empty_label="Не выбрана", - label="Основная категория", - help_text="Используется для интеграций с внешними площадками", + label="Внешняя категория", + help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)", widget=forms.Select(attrs={'class': 'form-select'}) ) class Meta: model = ConfigurableProduct - fields = ['name', 'sku', 'description', 'short_description', 'primary_category', 'status'] + fields = ['name', 'sku', 'description', 'short_description', 'external_category', 'status'] labels = { 'name': 'Название', 'sku': 'Артикул', diff --git a/myproject/products/migrations/0005_rename_primary_category_to_external_category.py b/myproject/products/migrations/0005_rename_primary_category_to_external_category.py new file mode 100644 index 0000000..ad4a8a2 --- /dev/null +++ b/myproject/products/migrations/0005_rename_primary_category_to_external_category.py @@ -0,0 +1,72 @@ +# Generated migration to rename primary_category to external_category + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('products', '0004_configurableproduct_primary_category_and_more'), + ] + + operations = [ + # Rename primary_category to external_category for Product + migrations.RenameField( + model_name='product', + old_name='primary_category', + new_name='external_category', + ), + # Update related_name for Product + migrations.AlterField( + model_name='product', + name='external_category', + field=models.ForeignKey( + blank=True, + help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)', + null=True, + on_delete=models.SET_NULL, + related_name='external_products', + to='products.productcategory', + verbose_name='Внешняя категория' + ), + ), + # Rename primary_category to external_category for ProductKit + migrations.RenameField( + model_name='productkit', + old_name='primary_category', + new_name='external_category', + ), + # Update related_name for ProductKit + migrations.AlterField( + model_name='productkit', + name='external_category', + field=models.ForeignKey( + blank=True, + help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)', + null=True, + on_delete=models.SET_NULL, + related_name='external_productkits', + to='products.productcategory', + verbose_name='Внешняя категория' + ), + ), + # Rename primary_category to external_category for ConfigurableProduct + migrations.RenameField( + model_name='configurableproduct', + old_name='primary_category', + new_name='external_category', + ), + # Update related_name for ConfigurableProduct + migrations.AlterField( + model_name='configurableproduct', + name='external_category', + field=models.ForeignKey( + blank=True, + help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)', + null=True, + on_delete=models.SET_NULL, + related_name='external_configurableproducts', + to='products.productcategory', + verbose_name='Внешняя категория' + ), + ), + ] diff --git a/myproject/products/models/base.py b/myproject/products/models/base.py index c332cef..32a784d 100644 --- a/myproject/products/models/base.py +++ b/myproject/products/models/base.py @@ -207,15 +207,15 @@ class BaseProductEntity(models.Model): verbose_name="Архивировано пользователем" ) - # Основная категория для интеграций - primary_category = models.ForeignKey( + # Внешняя категория для интеграций + external_category = models.ForeignKey( 'ProductCategory', on_delete=models.SET_NULL, null=True, blank=True, - related_name='primary_%(class)ss', - verbose_name="Основная категория", - help_text="Используется для интеграций с внешними площадками" + related_name='external_%(class)ss', + verbose_name="Внешняя категория", + help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)" ) # Manager @@ -281,7 +281,7 @@ class BaseProductEntity(models.Model): Получить категорию товара для конкретной интеграции. Приоритет: - 1. primary_category (если указана и есть маппинг) + 1. external_category (если указана и есть маппинг) 2. Первая категория из categories с существующим маппингом (если есть M2M) Args: @@ -292,14 +292,14 @@ class BaseProductEntity(models.Model): """ from integrations.models import IntegrationCategoryMapping - # Приоритет 1: primary_category - if self.primary_category: + # Приоритет 1: external_category + if self.external_category: mapping = IntegrationCategoryMapping.objects.filter( - category=self.primary_category, + category=self.external_category, integration_type=integration_type ).first() if mapping: - return self.primary_category, mapping.external_category_sku + return self.external_category, mapping.external_category_sku # Приоритет 2: первая категория из M2M (если есть) if hasattr(self, 'categories'): diff --git a/myproject/products/static/products/js/bulk-category-modal.js b/myproject/products/static/products/js/bulk-category-modal.js index bb2e7e3..e1b0bc6 100644 --- a/myproject/products/static/products/js/bulk-category-modal.js +++ b/myproject/products/static/products/js/bulk-category-modal.js @@ -22,6 +22,8 @@ const selectedItemsBreakdownSpan = document.getElementById('selectedItemsBreakdown'); const modeHint = document.getElementById('bulkCategoryModeHint'); const categoryListSection = document.getElementById('bulkCategoryListSection'); + const externalCategorySelect = document.getElementById('externalCategorySelect'); + const clearExternalCategory = document.getElementById('clearExternalCategory'); /** * Initialize the bulk category modal functionality @@ -58,6 +60,28 @@ }); } + // Listen for external category changes + if (externalCategorySelect) { + externalCategorySelect.addEventListener('change', () => { + hideError(); + if (externalCategorySelect.value) { + clearExternalCategory.checked = false; + } + updateApplyButtonState(); + }); + } + + // Listen for clear external category checkbox + if (clearExternalCategory) { + clearExternalCategory.addEventListener('change', () => { + hideError(); + if (clearExternalCategory.checked) { + externalCategorySelect.value = ''; + } + updateApplyButtonState(); + }); + } + // Listen for modal close to reset state modal.addEventListener('hidden.bs.modal', resetModalState); @@ -90,6 +114,14 @@ // Reset state selectedCategoryIds.clear(); hideError(); + + // Reset external category + if (externalCategorySelect) { + externalCategorySelect.value = ''; + } + if (clearExternalCategory) { + clearExternalCategory.checked = false; + } // Open modal and load categories modalInstance.show(); @@ -330,13 +362,16 @@ } const mode = getCurrentMode(); - + const hasExternalCategory = externalCategorySelect && externalCategorySelect.value; + const shouldClearExternal = clearExternalCategory && clearExternalCategory.checked; + if (mode === 'clear') { - // В режиме очистки категории не требуются + // В режиме очистки M2M категорий не требуются + // Но можно также очистить внешнюю категорию applyBtn.disabled = false; } else { - // Для add/replace нужна хотя бы одна выбранная категория - applyBtn.disabled = selectedCategoryIds.size === 0; + // Для add/replace нужна хотя бы одна выбранная категория ИЛИ внешняя категория ИЛИ очистка + applyBtn.disabled = selectedCategoryIds.size === 0 && !hasExternalCategory && !shouldClearExternal; } } @@ -346,17 +381,27 @@ async function handleApply() { const mode = getCurrentMode(); const selectedItems = getSelectedItems(); + const externalCategoryId = externalCategorySelect ? externalCategorySelect.value : null; + const shouldClearExternal = clearExternalCategory && clearExternalCategory.checked; if (selectedItems.length === 0) { showError('Нет выбранных товаров'); return; } - // Режим очистки категорий + // Проверка: если выбрана внешняя категория и стоит чекбокс очистки + if (externalCategoryId && shouldClearExternal) { + showError('Нельзя одновременно выбрать и очистить внешнюю категорию'); + return; + } + + // Режим очистки M2M категорий if (mode === 'clear') { - const confirmed = confirm( - `Вы уверены, что хотите удалить ВСЕ категории у ${selectedItems.length} выбранных товаров?\n\nЭто действие нельзя отменить!` - ); + let confirmMsg = `Вы уверены, что хотите удалить ВСЕ категории у ${selectedItems.length} выбранных товаров?\n\nЭто действие нельзя отменить!`; + if (shouldClearExternal) { + confirmMsg += '\n\nТакже будет очищена внешняя категория для интеграций.'; + } + const confirmed = confirm(confirmMsg); if (!confirmed) { return; } @@ -374,6 +419,11 @@ action_mode: 'clear' }; + // Добавляем external_category_id только если нужно очистить + if (shouldClearExternal) { + requestData.external_category_id = null; + } + applyBtn.disabled = true; applyBtn.innerHTML = 'Применение...'; @@ -436,18 +486,25 @@ } // Режим add/replace - if (selectedCategoryIds.size === 0) { - showError('Выберите хотя бы одну категорию'); + if (selectedCategoryIds.size === 0 && !externalCategoryId && !shouldClearExternal) { + showError('Выберите хотя бы одну категорию или внешнюю категорию'); return; } const actionMode = mode === 'replace' ? 'replace' : 'add'; - + const requestData = { items: selectedItems, category_ids: Array.from(selectedCategoryIds), action_mode: actionMode }; + + // Добавляем внешнюю категорию если выбрана + if (externalCategoryId) { + requestData.external_category_id = parseInt(externalCategoryId); + } else if (shouldClearExternal) { + requestData.clear_external_category = true; + } const csrfToken = getCsrfToken(); if (!csrfToken) { @@ -610,6 +667,14 @@ addModeRadio.checked = true; } + // Сбрасываем внешнюю категорию + if (externalCategorySelect) { + externalCategorySelect.value = ''; + } + if (clearExternalCategory) { + clearExternalCategory.checked = false; + } + updateModeUI(); updateApplyButtonState(); } diff --git a/myproject/products/templates/products/configurableproduct_form.html b/myproject/products/templates/products/configurableproduct_form.html index 487300a..03f3564 100644 --- a/myproject/products/templates/products/configurableproduct_form.html +++ b/myproject/products/templates/products/configurableproduct_form.html @@ -109,18 +109,18 @@ input[name*="DELETE"] { {% endif %} - +
+ Внешняя категория используется при экспорте на внешние площадки (Recommerce, WooCommerce и др.) +
+ + +