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

@@ -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')

View File

@@ -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': 'Артикул',

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="Архивировано пользователем"
)
# Основная категория для интеграций
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 если не задан.

View File

@@ -109,6 +109,21 @@ input[name*="DELETE"] {
{% endif %}
</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">
<label for="{{ form.status.id_for_label }}" class="form-label">Статус</label>
{{ form.status }}

View File

@@ -467,6 +467,21 @@
{% endif %}
</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">
{{ form.tags.label_tag }}

View File

@@ -183,6 +183,17 @@
{% endif %}
</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">
<label class="form-label small mb-1 text-muted">{{ form.tags.label }}</label>
<div class="compact-checkboxes">

View File

@@ -184,6 +184,17 @@
{% endif %}
</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">
<label class="form-label small mb-1 text-muted">{{ form.tags.label }}</label>
<div class="compact-checkboxes">

View File

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