feat(integrations): добавить поле primary_category и маппинг категорий для интеграций
Добавлена поддержка выбора основной категории (primary_category) для товаров и наборов, а также новая модель IntegrationCategoryMapping для связи категорий с внешними площадками. Теперь можно указать категорию товара, которая будет использоваться при экспорте на внешние площадки (Recommerce, WooCommerce и др.), с возможностью настройки маппинга категорий для каждого типа интеграции.
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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': 'Артикул',
|
||||
|
||||
@@ -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='Основная категория'),
|
||||
),
|
||||
]
|
||||
@@ -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 если не задан.
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user