feat(integrations): добавить поле primary_category и маппинг категорий для интеграций

Добавлена поддержка выбора основной категории (primary_category) для товаров и наборов, а также новая модель IntegrationCategoryMapping для связи категорий с внешними площадками. Теперь можно указать категорию товара, которая будет использоваться при экспорте на внешние площадки (Recommerce, WooCommerce и др.), с возможностью настройки маппинга категорий для каждого типа интеграции.
This commit is contained in:
2026-01-14 01:53:38 +03:00
parent 7fd361aaf8
commit 1fb280607a
14 changed files with 288 additions and 15 deletions

View File

@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import RecommerceIntegration, WooCommerceIntegration from .models import RecommerceIntegration, WooCommerceIntegration, IntegrationCategoryMapping
@admin.register(RecommerceIntegration) @admin.register(RecommerceIntegration)
@@ -30,3 +30,19 @@ class WooCommerceIntegrationAdmin(admin.ModelAdmin):
('Синхронизация', {'fields': ('auto_sync_products', 'import_orders')}), ('Синхронизация', {'fields': ('auto_sync_products', 'import_orders')}),
('Служебное', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}), ('Служебное', {'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',)}),
)

View File

@@ -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')},
},
),
]

View File

@@ -4,6 +4,7 @@ from .marketplaces import (
WooCommerceIntegration, WooCommerceIntegration,
RecommerceIntegration, RecommerceIntegration,
) )
from .category_mappings import IntegrationCategoryMapping
__all__ = [ __all__ = [
'BaseIntegration', 'BaseIntegration',
@@ -11,4 +12,5 @@ __all__ = [
'MarketplaceIntegration', 'MarketplaceIntegration',
'WooCommerceIntegration', 'WooCommerceIntegration',
'RecommerceIntegration', 'RecommerceIntegration',
'IntegrationCategoryMapping',
] ]

View File

@@ -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}"

View File

@@ -48,9 +48,8 @@ def to_api_product(
data['name'] = product.name data['name'] = product.name
if 'parent_category_sku' in fields: if 'parent_category_sku' in fields:
# TODO: Добавить поле recommerce_category_sku в модель Product или Category # Получаем категорию через primary_category или fallback на M2M categories
# Пока пытаемся взять из атрибута, если он есть _, category_sku = product.get_category_for_integration('recommerce')
category_sku = getattr(product, 'recommerce_category_sku', None)
if category_sku: if category_sku:
data['parent_category_sku'] = category_sku data['parent_category_sku'] = category_sku

View File

@@ -396,7 +396,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
search_fields = ('name', 'sku', 'description', 'search_keywords') search_fields = ('name', 'sku', 'description', 'search_keywords')
filter_horizontal = ('categories', 'tags', 'variant_groups') filter_horizontal = ('categories', 'tags', 'variant_groups')
readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by', 'cost_price', 'cost_price_details_display') 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 = [ actions = [
restore_items, restore_items,
delete_selected, delete_selected,
@@ -408,7 +408,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
fieldsets = ( 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',), 'fields': ('cost_price_details_display',),
@@ -589,6 +589,7 @@ class ProductKitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
filter_horizontal = ('categories', 'tags') filter_horizontal = ('categories', 'tags')
readonly_fields = ('photo_preview_large', 'base_price', 'archived_at', 'archived_by', 'order') readonly_fields = ('photo_preview_large', 'base_price', 'archived_at', 'archived_by', 'order')
autocomplete_fields = ['primary_category']
actions = [ actions = [
show_poor_quality_photos, show_poor_quality_photos,
show_excellent_quality_photos, show_excellent_quality_photos,
@@ -597,7 +598,7 @@ class ProductKitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
fieldsets = ( 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'), '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') search_fields = ('name', 'sku', 'description')
readonly_fields = ('created_at', 'updated_at', 'slug') readonly_fields = ('created_at', 'updated_at', 'slug')
inlines = [ConfigurableProductOptionInline, ConfigurableProductAttributeInline] inlines = [ConfigurableProductOptionInline, ConfigurableProductAttributeInline]
autocomplete_fields = ['primary_category']
fieldsets = ( fieldsets = (
('Основная информация', { ('Основная информация', {
'fields': ('name', 'sku', 'slug', 'status') 'fields': ('name', 'sku', 'slug', 'status', 'primary_category')
}), }),
('Описание', { ('Описание', {
'fields': ('short_description', 'description') 'fields': ('short_description', 'description')

View File

@@ -87,6 +87,15 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
label="Категории" 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( tags = forms.ModelMultipleChoiceField(
queryset=ProductTag.objects.filter(is_active=True), queryset=ProductTag.objects.filter(is_active=True),
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
@@ -98,7 +107,7 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
model = Product model = Product
fields = [ fields = [
'name', 'sku', 'description', 'short_description', 'categories', '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' 'is_new', 'is_popular', 'is_special'
] ]
labels = { labels = {
@@ -193,6 +202,15 @@ class ProductKitForm(SKUUniqueMixin, forms.ModelForm):
label="Категории" 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( tags = forms.ModelMultipleChoiceField(
queryset=ProductTag.objects.filter(is_active=True), queryset=ProductTag.objects.filter(is_active=True),
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
@@ -204,7 +222,7 @@ class ProductKitForm(SKUUniqueMixin, forms.ModelForm):
model = ProductKit model = ProductKit
fields = [ fields = [
'name', 'sku', 'description', 'short_description', 'categories', '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 = { labels = {
'name': 'Название', '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: class Meta:
model = ConfigurableProduct model = ConfigurableProduct
fields = ['name', 'sku', 'description', 'short_description', 'status'] fields = ['name', 'sku', 'description', 'short_description', 'primary_category', 'status']
labels = { labels = {
'name': 'Название', 'name': 'Название',
'sku': 'Артикул', 'sku': 'Артикул',

View File

@@ -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='Основная категория'),
),
]

View File

@@ -207,6 +207,17 @@ class BaseProductEntity(models.Model):
verbose_name="Архивировано пользователем" 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 # Manager
objects = models.Manager() objects = models.Manager()
@@ -265,6 +276,43 @@ class BaseProductEntity(models.Model):
"""Возвращает True если товар активен""" """Возвращает True если товар активен"""
return self.status == 'active' 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): def save(self, *args, **kwargs):
""" """
Автогенерация slug из name если не задан. Автогенерация slug из name если не задан.

View File

@@ -109,6 +109,21 @@ input[name*="DELETE"] {
{% endif %} {% endif %}
</div> </div>
<!-- Основная категория для интеграций -->
<div class="mb-3">
<label for="id_primary_category" class="form-label">
Основная категория
<small class="text-muted">(для интеграций)</small>
</label>
{{ form.primary_category }}
{% if form.primary_category.help_text %}
<div class="form-text">{{ form.primary_category.help_text }}</div>
{% endif %}
{% if form.primary_category.errors %}
<div class="text-danger small">{{ form.primary_category.errors.0 }}</div>
{% endif %}
</div>
<div class="mb-0"> <div class="mb-0">
<label for="{{ form.status.id_for_label }}" class="form-label">Статус</label> <label for="{{ form.status.id_for_label }}" class="form-label">Статус</label>
{{ form.status }} {{ form.status }}

View File

@@ -467,6 +467,21 @@
{% endif %} {% endif %}
</div> </div>
<!-- Основная категория для интеграций -->
<div class="mb-3">
<label for="id_primary_category" class="form-label">
Основная категория
<small class="text-muted">(для интеграций)</small>
</label>
{{ form.primary_category }}
{% if form.primary_category.help_text %}
<div class="form-text">{{ form.primary_category.help_text }}</div>
{% endif %}
{% if form.primary_category.errors %}
<div class="text-danger">{{ form.primary_category.errors }}</div>
{% endif %}
</div>
<!-- Теги --> <!-- Теги -->
<div class="mb-3"> <div class="mb-3">
{{ form.tags.label_tag }} {{ form.tags.label_tag }}

View File

@@ -183,6 +183,17 @@
{% endif %} {% endif %}
</div> </div>
<!-- Основная категория для интеграций -->
<div class="mb-2">
<label for="id_primary_category" class="form-label small mb-1 text-muted">
Основная категория <small>(для интеграций)</small>
</label>
{{ form.primary_category }}
{% if form.primary_category.errors %}
<div class="text-danger small mt-1">{{ form.primary_category.errors }}</div>
{% endif %}
</div>
<div class="mb-0"> <div class="mb-0">
<label class="form-label small mb-1 text-muted">{{ form.tags.label }}</label> <label class="form-label small mb-1 text-muted">{{ form.tags.label }}</label>
<div class="compact-checkboxes"> <div class="compact-checkboxes">

View File

@@ -184,6 +184,17 @@
{% endif %} {% endif %}
</div> </div>
<!-- Основная категория для интеграций -->
<div class="mb-2">
<label for="id_primary_category" class="form-label small mb-1 text-muted">
Основная категория <small>(для интеграций)</small>
</label>
{{ form.primary_category }}
{% if form.primary_category.errors %}
<div class="text-danger small mt-1">{{ form.primary_category.errors }}</div>
{% endif %}
</div>
<div class="mb-0"> <div class="mb-0">
<label class="form-label small mb-1 text-muted">{{ form.tags.label }}</label> <label class="form-label small mb-1 text-muted">{{ form.tags.label }}</label>
<div class="compact-checkboxes"> <div class="compact-checkboxes">

View File

@@ -294,9 +294,9 @@
<a href="{% url 'products:productkit-detail' item.pk %}">{{ item.name }}</a> <a href="{% url 'products:productkit-detail' item.pk %}">{{ item.name }}</a>
{% endif %} {% endif %}
<div class="mt-1"> <div class="mt-1">
{% if item.is_new %}<span class="badge bg-warning text-dark" title="Новинка"><i class="bi bi-stars"></i></span>{% endif %} {% if item.is_new %}<span class="badge bg-warning text-dark" style="font-size: 0.65rem;">Новинка</span>{% endif %}
{% if item.is_popular %}<span class="badge bg-danger" title="Популярный"><i class="bi bi-fire"></i></span>{% endif %} {% if item.is_popular %}<span class="badge bg-danger" style="font-size: 0.65rem;">Популярный</span>{% endif %}
{% if item.is_special %}<span class="badge bg-success" title="Спецпредложение"><i class="bi bi-percent"></i></span>{% endif %} {% if item.is_special %}<span class="badge bg-success" style="font-size: 0.65rem;">Акция</span>{% endif %}
</div> </div>
</td> </td>
<td><code class="small">{{ item.sku }}</code></td> <td><code class="small">{{ item.sku }}</code></td>