From 1fb280607af705ca5be65c6fc1209cc89f41c134 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Wed, 14 Jan 2026 01:53:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(integrations):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=BB=D0=B5=20primary?= =?UTF-8?q?=5Fcategory=20=D0=B8=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена поддержка выбора основной категории (primary_category) для товаров и наборов, а также новая модель IntegrationCategoryMapping для связи категорий с внешними площадками. Теперь можно указать категорию товара, которая будет использоваться при экспорте на внешние площадки (Recommerce, WooCommerce и др.), с возможностью настройки маппинга категорий для каждого типа интеграции. --- myproject/integrations/admin.py | 18 ++++- .../0002_integrationcategorymapping.py | 33 ++++++++++ myproject/integrations/models/__init__.py | 2 + .../integrations/models/category_mappings.py | 65 +++++++++++++++++++ myproject/integrations/recommerce/mappers.py | 5 +- myproject/products/admin.py | 12 ++-- myproject/products/forms.py | 33 +++++++++- ...urableproduct_primary_category_and_more.py | 29 +++++++++ myproject/products/models/base.py | 48 ++++++++++++++ .../products/configurableproduct_form.html | 15 +++++ .../templates/products/product_form.html | 15 +++++ .../templates/products/productkit_create.html | 11 ++++ .../templates/products/productkit_edit.html | 11 ++++ .../templates/products/products_list.html | 6 +- 14 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 myproject/integrations/migrations/0002_integrationcategorymapping.py create mode 100644 myproject/integrations/models/category_mappings.py create mode 100644 myproject/products/migrations/0004_configurableproduct_primary_category_and_more.py diff --git a/myproject/integrations/admin.py b/myproject/integrations/admin.py index be93628..2c3445f 100644 --- a/myproject/integrations/admin.py +++ b/myproject/integrations/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import RecommerceIntegration, WooCommerceIntegration +from .models import RecommerceIntegration, WooCommerceIntegration, IntegrationCategoryMapping @admin.register(RecommerceIntegration) @@ -30,3 +30,19 @@ class WooCommerceIntegrationAdmin(admin.ModelAdmin): ('Синхронизация', {'fields': ('auto_sync_products', 'import_orders')}), ('Служебное', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}), ) + + +@admin.register(IntegrationCategoryMapping) +class IntegrationCategoryMappingAdmin(admin.ModelAdmin): + """Админка для маппинга категорий на внешние площадки""" + list_display = ['category', 'integration_type', 'external_category_sku', 'external_category_name', 'updated_at'] + list_filter = ['integration_type'] + search_fields = ['category__name', 'category__sku', 'external_category_sku', 'external_category_name'] + autocomplete_fields = ['category'] + readonly_fields = ['created_at', 'updated_at'] + + fieldsets = ( + ('Связь', {'fields': ('category', 'integration_type')}), + ('Внешняя категория', {'fields': ('external_category_sku', 'external_category_name')}), + ('Служебное', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}), + ) diff --git a/myproject/integrations/migrations/0002_integrationcategorymapping.py b/myproject/integrations/migrations/0002_integrationcategorymapping.py new file mode 100644 index 0000000..5893598 --- /dev/null +++ b/myproject/integrations/migrations/0002_integrationcategorymapping.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.10 on 2026-01-13 21:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrations', '0001_add_integration_models'), + ('products', '0004_configurableproduct_primary_category_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='IntegrationCategoryMapping', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('integration_type', models.CharField(choices=[('recommerce', 'Recommerce'), ('woocommerce', 'WooCommerce')], db_index=True, max_length=20, verbose_name='Интеграция')), + ('external_category_sku', models.CharField(help_text='SKU или ID категории на внешней площадке', max_length=100, verbose_name='Артикул категории во внешней системе')), + ('external_category_name', models.CharField(blank=True, help_text='Для справки, не обязательно', max_length=200, verbose_name='Название категории во внешней системе')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integration_mappings', to='products.productcategory', verbose_name='Категория')), + ], + options={ + 'verbose_name': 'Маппинг категории', + 'verbose_name_plural': 'Маппинги категорий', + 'indexes': [models.Index(fields=['integration_type', 'external_category_sku'], name='integration_integra_450473_idx')], + 'unique_together': {('category', 'integration_type')}, + }, + ), + ] diff --git a/myproject/integrations/models/__init__.py b/myproject/integrations/models/__init__.py index 163e3cf..a0a9689 100644 --- a/myproject/integrations/models/__init__.py +++ b/myproject/integrations/models/__init__.py @@ -4,6 +4,7 @@ from .marketplaces import ( WooCommerceIntegration, RecommerceIntegration, ) +from .category_mappings import IntegrationCategoryMapping __all__ = [ 'BaseIntegration', @@ -11,4 +12,5 @@ __all__ = [ 'MarketplaceIntegration', 'WooCommerceIntegration', 'RecommerceIntegration', + 'IntegrationCategoryMapping', ] diff --git a/myproject/integrations/models/category_mappings.py b/myproject/integrations/models/category_mappings.py new file mode 100644 index 0000000..f04f536 --- /dev/null +++ b/myproject/integrations/models/category_mappings.py @@ -0,0 +1,65 @@ +""" +Модели для маппинга категорий на внешние площадки. +""" +from django.db import models + + +class IntegrationCategoryMapping(models.Model): + """ + Маппинг внутренней категории на внешнюю категорию маркетплейса. + + Позволяет связать категории товаров с их аналогами на внешних площадках + (Recommerce, WooCommerce и др.). + """ + + INTEGRATION_CHOICES = [ + ('recommerce', 'Recommerce'), + ('woocommerce', 'WooCommerce'), + ] + + category = models.ForeignKey( + 'products.ProductCategory', + on_delete=models.CASCADE, + related_name='integration_mappings', + verbose_name="Категория" + ) + + integration_type = models.CharField( + max_length=20, + choices=INTEGRATION_CHOICES, + verbose_name="Интеграция", + db_index=True + ) + + external_category_sku = models.CharField( + max_length=100, + verbose_name="Артикул категории во внешней системе", + help_text="SKU или ID категории на внешней площадке" + ) + + external_category_name = models.CharField( + max_length=200, + blank=True, + verbose_name="Название категории во внешней системе", + help_text="Для справки, не обязательно" + ) + + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Создано" + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="Обновлено" + ) + + class Meta: + verbose_name = "Маппинг категории" + verbose_name_plural = "Маппинги категорий" + unique_together = [['category', 'integration_type']] + indexes = [ + models.Index(fields=['integration_type', 'external_category_sku']), + ] + + def __str__(self): + return f"{self.category.name} → {self.get_integration_type_display()}:{self.external_category_sku}" diff --git a/myproject/integrations/recommerce/mappers.py b/myproject/integrations/recommerce/mappers.py index 4d936dc..28a36d8 100644 --- a/myproject/integrations/recommerce/mappers.py +++ b/myproject/integrations/recommerce/mappers.py @@ -48,9 +48,8 @@ def to_api_product( data['name'] = product.name if 'parent_category_sku' in fields: - # TODO: Добавить поле recommerce_category_sku в модель Product или Category - # Пока пытаемся взять из атрибута, если он есть - category_sku = getattr(product, 'recommerce_category_sku', None) + # Получаем категорию через primary_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 7f94ea1..db1da29 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'] + autocomplete_fields = ['base_unit', 'primary_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', 'base_unit', 'unit', 'price', 'sale_price') + 'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'primary_category', 'base_unit', 'unit', 'price', 'sale_price') }), ('Себестоимость', { 'fields': ('cost_price_details_display',), @@ -589,6 +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'] actions = [ show_poor_quality_photos, show_excellent_quality_photos, @@ -597,7 +598,7 @@ class ProductKitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): fieldsets = ( ('Основная информация', { - 'fields': ('name', 'sku', 'slug', 'description', 'short_description', 'categories') + 'fields': ('name', 'sku', 'slug', 'description', 'short_description', 'categories', 'primary_category') }), ('Ценообразование', { 'fields': ('base_price', 'price', 'sale_price', 'price_adjustment_type', 'price_adjustment_value'), @@ -1062,10 +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'] + fieldsets = ( ('Основная информация', { - 'fields': ('name', 'sku', 'slug', 'status') + 'fields': ('name', 'sku', 'slug', 'status', 'primary_category') }), ('Описание', { 'fields': ('short_description', 'description') diff --git a/myproject/products/forms.py b/myproject/products/forms.py index 7026d2b..1113665 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -87,6 +87,15 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm): label="Категории" ) + primary_category = forms.ModelChoiceField( + queryset=ProductCategory.objects.filter(is_active=True), + required=False, + empty_label="Не выбрана", + label="Основная категория", + help_text="Используется для интеграций с внешними площадками", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + tags = forms.ModelMultipleChoiceField( queryset=ProductTag.objects.filter(is_active=True), widget=forms.CheckboxSelectMultiple, @@ -98,7 +107,7 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm): model = Product fields = [ 'name', 'sku', 'description', 'short_description', 'categories', - 'tags', 'base_unit', 'price', 'sale_price', 'status', + 'primary_category', 'tags', 'base_unit', 'price', 'sale_price', 'status', 'is_new', 'is_popular', 'is_special' ] labels = { @@ -193,6 +202,15 @@ class ProductKitForm(SKUUniqueMixin, forms.ModelForm): label="Категории" ) + primary_category = forms.ModelChoiceField( + queryset=ProductCategory.objects.filter(is_active=True), + required=False, + empty_label="Не выбрана", + label="Основная категория", + help_text="Используется для интеграций с внешними площадками", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + tags = forms.ModelMultipleChoiceField( queryset=ProductTag.objects.filter(is_active=True), widget=forms.CheckboxSelectMultiple, @@ -204,7 +222,7 @@ class ProductKitForm(SKUUniqueMixin, forms.ModelForm): model = ProductKit fields = [ 'name', 'sku', 'description', 'short_description', 'categories', - 'tags', 'sale_price', 'price_adjustment_type', 'price_adjustment_value', 'status' + 'primary_category', 'tags', 'sale_price', 'price_adjustment_type', 'price_adjustment_value', 'status' ] labels = { 'name': 'Название', @@ -671,9 +689,18 @@ class ConfigurableProductForm(SKUUniqueMixin, forms.ModelForm): """ Форма для создания и редактирования вариативного товара. """ + primary_category = forms.ModelChoiceField( + queryset=ProductCategory.objects.filter(is_active=True), + required=False, + empty_label="Не выбрана", + label="Основная категория", + help_text="Используется для интеграций с внешними площадками", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + class Meta: model = ConfigurableProduct - fields = ['name', 'sku', 'description', 'short_description', 'status'] + fields = ['name', 'sku', 'description', 'short_description', 'primary_category', 'status'] labels = { 'name': 'Название', 'sku': 'Артикул', diff --git a/myproject/products/migrations/0004_configurableproduct_primary_category_and_more.py b/myproject/products/migrations/0004_configurableproduct_primary_category_and_more.py new file mode 100644 index 0000000..dfad758 --- /dev/null +++ b/myproject/products/migrations/0004_configurableproduct_primary_category_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.0.10 on 2026-01-13 21:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0003_add_marketing_flags'), + ] + + operations = [ + migrations.AddField( + model_name='configurableproduct', + name='primary_category', + field=models.ForeignKey(blank=True, help_text='Используется для интеграций с внешними площадками', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_%(class)ss', to='products.productcategory', verbose_name='Основная категория'), + ), + migrations.AddField( + model_name='product', + name='primary_category', + field=models.ForeignKey(blank=True, help_text='Используется для интеграций с внешними площадками', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_%(class)ss', to='products.productcategory', verbose_name='Основная категория'), + ), + migrations.AddField( + model_name='productkit', + name='primary_category', + field=models.ForeignKey(blank=True, help_text='Используется для интеграций с внешними площадками', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_%(class)ss', to='products.productcategory', verbose_name='Основная категория'), + ), + ] diff --git a/myproject/products/models/base.py b/myproject/products/models/base.py index 9bf88ca..c332cef 100644 --- a/myproject/products/models/base.py +++ b/myproject/products/models/base.py @@ -207,6 +207,17 @@ class BaseProductEntity(models.Model): verbose_name="Архивировано пользователем" ) + # Основная категория для интеграций + primary_category = models.ForeignKey( + 'ProductCategory', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='primary_%(class)ss', + verbose_name="Основная категория", + help_text="Используется для интеграций с внешними площадками" + ) + # Manager objects = models.Manager() @@ -265,6 +276,43 @@ class BaseProductEntity(models.Model): """Возвращает True если товар активен""" return self.status == 'active' + def get_category_for_integration(self, integration_type: str = 'recommerce'): + """ + Получить категорию товара для конкретной интеграции. + + Приоритет: + 1. primary_category (если указана и есть маппинг) + 2. Первая категория из categories с существующим маппингом (если есть M2M) + + Args: + integration_type: Тип интеграции ('recommerce', 'woocommerce') + + Returns: + tuple: (ProductCategory, external_sku) или (None, None) + """ + from integrations.models import IntegrationCategoryMapping + + # Приоритет 1: primary_category + if self.primary_category: + mapping = IntegrationCategoryMapping.objects.filter( + category=self.primary_category, + integration_type=integration_type + ).first() + if mapping: + return self.primary_category, mapping.external_category_sku + + # Приоритет 2: первая категория из M2M (если есть) + if hasattr(self, 'categories'): + for category in self.categories.all(): + mapping = IntegrationCategoryMapping.objects.filter( + category=category, + integration_type=integration_type + ).first() + if mapping: + return category, mapping.external_category_sku + + return None, None + def save(self, *args, **kwargs): """ Автогенерация slug из name если не задан. diff --git a/myproject/products/templates/products/configurableproduct_form.html b/myproject/products/templates/products/configurableproduct_form.html index 6698c0d..487300a 100644 --- a/myproject/products/templates/products/configurableproduct_form.html +++ b/myproject/products/templates/products/configurableproduct_form.html @@ -109,6 +109,21 @@ input[name*="DELETE"] { {% endif %} + +
+ + {{ form.primary_category }} + {% if form.primary_category.help_text %} +
{{ form.primary_category.help_text }}
+ {% endif %} + {% if form.primary_category.errors %} +
{{ form.primary_category.errors.0 }}
+ {% endif %} +
+
{{ form.status }} diff --git a/myproject/products/templates/products/product_form.html b/myproject/products/templates/products/product_form.html index ea68cc0..d842bda 100644 --- a/myproject/products/templates/products/product_form.html +++ b/myproject/products/templates/products/product_form.html @@ -467,6 +467,21 @@ {% endif %}
+ +
+ + {{ form.primary_category }} + {% if form.primary_category.help_text %} +
{{ form.primary_category.help_text }}
+ {% endif %} + {% if form.primary_category.errors %} +
{{ form.primary_category.errors }}
+ {% endif %} +
+
{{ form.tags.label_tag }} diff --git a/myproject/products/templates/products/productkit_create.html b/myproject/products/templates/products/productkit_create.html index 041c91a..da84930 100644 --- a/myproject/products/templates/products/productkit_create.html +++ b/myproject/products/templates/products/productkit_create.html @@ -183,6 +183,17 @@ {% endif %}
+ +
+ + {{ form.primary_category }} + {% if form.primary_category.errors %} +
{{ form.primary_category.errors }}
+ {% endif %} +
+
diff --git a/myproject/products/templates/products/productkit_edit.html b/myproject/products/templates/products/productkit_edit.html index 718cbe1..f73a194 100644 --- a/myproject/products/templates/products/productkit_edit.html +++ b/myproject/products/templates/products/productkit_edit.html @@ -184,6 +184,17 @@ {% endif %}
+ +
+ + {{ form.primary_category }} + {% if form.primary_category.errors %} +
{{ form.primary_category.errors }}
+ {% endif %} +
+
diff --git a/myproject/products/templates/products/products_list.html b/myproject/products/templates/products/products_list.html index cea9546..0161b6a 100644 --- a/myproject/products/templates/products/products_list.html +++ b/myproject/products/templates/products/products_list.html @@ -294,9 +294,9 @@ {{ item.name }} {% endif %}
- {% if item.is_new %}{% endif %} - {% if item.is_popular %}{% endif %} - {% if item.is_special %}{% endif %} + {% if item.is_new %}Новинка{% endif %} + {% if item.is_popular %}Популярный{% endif %} + {% if item.is_special %}Акция{% endif %}
{{ item.sku }}