refactor: Создать базовый класс BaseProductEntity и реструктурировать Product/ProductKit
Основные изменения: ## Модели (models.py) - Создан абстрактный класс BaseProductEntity с общими полями: * Идентификация: name, sku, slug * Описания: description, short_description (новое поле) * Статус: is_active, timestamps, soft delete * Managers: objects, all_objects, active - Product: * Унаследован от BaseProductEntity * sale_price переименован в price (основная цена) * Добавлено новое поле sale_price (цена со скидкой, nullable) * Добавлено property actual_price - ProductKit: * Унаследован от BaseProductEntity * fixed_price переименован в price (ручная цена) * pricing_method: 'fixed' → 'manual' * Добавлено поле sale_price (цена со скидкой) * Добавлено поле cost_price (nullable) * Добавлены properties: calculated_price, actual_price * Обновлен calculate_price_with_substitutions() ## Forms (forms.py) - ProductForm: добавлено short_description, price, sale_price - ProductKitForm: добавлено short_description, cost_price, price, sale_price ## Admin (admin.py) - ProductAdmin: обновлены list_display и fieldsets с новыми полями - ProductKitAdmin: добавлены fieldsets, метод get_price_display() ## Templates - product_form.html: добавлены поля price, sale_price, short_description - product_detail.html: показывает зачеркнутую цену + скидку + бейджик "Акция" - product_list.html: отображение цен со скидкой и бейджиком "Акция" - all_products_list.html: единообразное отображение цен - productkit_detail.html: отображение скидок с бейджиком "Акция" ## API (api_views.py) - Обновлены все endpoints для использования поля price вместо sale_price Результат: единообразная архитектура с поддержкой скидок, DRY-принцип, логичные названия полей, красивое отображение акций в UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -241,7 +241,7 @@ class ProductTagAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class ProductAdmin(admin.ModelAdmin):
|
class ProductAdmin(admin.ModelAdmin):
|
||||||
list_display = ('photo_preview', 'name', 'sku', 'get_categories_display', 'cost_price', 'sale_price', 'get_variant_groups_display', 'is_active', 'get_deleted_status')
|
list_display = ('photo_preview', 'name', 'sku', 'get_categories_display', 'cost_price', 'price', 'sale_price', 'get_variant_groups_display', 'is_active', 'get_deleted_status')
|
||||||
list_filter = (DeletedFilter, 'is_active', 'categories', 'tags', 'variant_groups')
|
list_filter = (DeletedFilter, 'is_active', 'categories', 'tags', 'variant_groups')
|
||||||
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')
|
||||||
@@ -251,10 +251,11 @@ class ProductAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Основная информация', {
|
('Основная информация', {
|
||||||
'fields': ('name', 'sku', 'variant_suffix', 'description', 'categories', 'unit')
|
'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'unit')
|
||||||
}),
|
}),
|
||||||
('Цены', {
|
('Цены', {
|
||||||
'fields': ('cost_price', 'sale_price')
|
'fields': ('cost_price', 'price', 'sale_price'),
|
||||||
|
'description': 'price - основная цена, sale_price - цена со скидкой (опционально)'
|
||||||
}),
|
}),
|
||||||
('Дополнительно', {
|
('Дополнительно', {
|
||||||
'fields': ('tags', 'variant_groups', 'is_active')
|
'fields': ('tags', 'variant_groups', 'is_active')
|
||||||
@@ -336,13 +337,43 @@ class ProductAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class ProductKitAdmin(admin.ModelAdmin):
|
class ProductKitAdmin(admin.ModelAdmin):
|
||||||
list_display = ('photo_preview', 'name', 'slug', 'get_categories_display', 'pricing_method', 'is_active', 'get_deleted_status')
|
list_display = ('photo_preview', 'name', 'slug', 'get_categories_display', 'pricing_method', 'get_price_display', 'is_active', 'get_deleted_status')
|
||||||
list_filter = (DeletedFilter, 'is_active', 'pricing_method', 'categories', 'tags')
|
list_filter = (DeletedFilter, 'is_active', 'pricing_method', 'categories', 'tags')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
filter_horizontal = ('categories', 'tags')
|
filter_horizontal = ('categories', 'tags')
|
||||||
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
|
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
|
||||||
actions = [restore_items, delete_selected, hard_delete_selected]
|
actions = [restore_items, delete_selected, hard_delete_selected]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Основная информация', {
|
||||||
|
'fields': ('name', 'sku', 'slug', 'description', 'short_description', 'categories')
|
||||||
|
}),
|
||||||
|
('Ценообразование', {
|
||||||
|
'fields': ('pricing_method', 'cost_price', 'price', 'sale_price', 'markup_percent', 'markup_amount'),
|
||||||
|
'description': 'Метод ценообразования определяет как вычисляется цена комплекта. price используется при методе "Ручная цена".'
|
||||||
|
}),
|
||||||
|
('Дополнительно', {
|
||||||
|
'fields': ('tags', 'is_active')
|
||||||
|
}),
|
||||||
|
('Удаление', {
|
||||||
|
'fields': ('deleted_at', 'deleted_by'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
'description': 'Информация о мягком удалении комплекта.'
|
||||||
|
}),
|
||||||
|
('Фото', {
|
||||||
|
'fields': ('photo_preview_large',),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_price_display(self, obj):
|
||||||
|
"""Отображение финальной цены комплекта"""
|
||||||
|
try:
|
||||||
|
return f"{obj.actual_price} ₽"
|
||||||
|
except Exception:
|
||||||
|
return "-"
|
||||||
|
get_price_display.short_description = "Цена"
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
"""Переопределяем queryset для доступа ко всем комплектам (включая удаленные)"""
|
"""Переопределяем queryset для доступа ко всем комплектам (включая удаленные)"""
|
||||||
qs = ProductKit.all_objects.all()
|
qs = ProductKit.all_objects.all()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.forms import inlineformset_factory
|
from django.forms import inlineformset_factory
|
||||||
from .models import Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, ProductKitPhoto, ProductCategoryPhoto
|
from .models import Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem
|
||||||
|
|
||||||
|
|
||||||
class ProductForm(forms.ModelForm):
|
class ProductForm(forms.ModelForm):
|
||||||
@@ -25,18 +25,20 @@ class ProductForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'sku', 'description', 'categories',
|
'name', 'sku', 'description', 'short_description', 'categories',
|
||||||
'tags', 'unit', 'cost_price', 'sale_price', 'is_active'
|
'tags', 'unit', 'cost_price', 'price', 'sale_price', 'is_active'
|
||||||
]
|
]
|
||||||
labels = {
|
labels = {
|
||||||
'name': 'Название',
|
'name': 'Название',
|
||||||
'sku': 'Артикул',
|
'sku': 'Артикул',
|
||||||
'description': 'Описание',
|
'description': 'Описание',
|
||||||
|
'short_description': 'Краткое описание',
|
||||||
'categories': 'Категории',
|
'categories': 'Категории',
|
||||||
'tags': 'Теги',
|
'tags': 'Теги',
|
||||||
'unit': 'Единица измерения',
|
'unit': 'Единица измерения',
|
||||||
'cost_price': 'Себестоимость',
|
'cost_price': 'Себестоимость',
|
||||||
'sale_price': 'Цена продажи',
|
'price': 'Основная цена',
|
||||||
|
'sale_price': 'Цена со скидкой',
|
||||||
'is_active': 'Активен'
|
'is_active': 'Активен'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +57,13 @@ class ProductForm(forms.ModelForm):
|
|||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'rows': 3
|
'rows': 3
|
||||||
})
|
})
|
||||||
|
self.fields['short_description'].widget.attrs.update({
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 2,
|
||||||
|
'placeholder': 'Краткое описание для превью и площадок'
|
||||||
|
})
|
||||||
self.fields['cost_price'].widget.attrs.update({'class': 'form-control'})
|
self.fields['cost_price'].widget.attrs.update({'class': 'form-control'})
|
||||||
|
self.fields['price'].widget.attrs.update({'class': 'form-control'})
|
||||||
self.fields['sale_price'].widget.attrs.update({'class': 'form-control'})
|
self.fields['sale_price'].widget.attrs.update({'class': 'form-control'})
|
||||||
self.fields['unit'].widget.attrs.update({'class': 'form-control'})
|
self.fields['unit'].widget.attrs.update({'class': 'form-control'})
|
||||||
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
|
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
|
||||||
@@ -82,17 +90,21 @@ class ProductKitForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ProductKit
|
model = ProductKit
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'sku', 'description', 'categories',
|
'name', 'sku', 'description', 'short_description', 'categories',
|
||||||
'tags', 'pricing_method', 'fixed_price', 'markup_percent', 'markup_amount', 'is_active'
|
'tags', 'pricing_method', 'cost_price', 'price', 'sale_price',
|
||||||
|
'markup_percent', 'markup_amount', 'is_active'
|
||||||
]
|
]
|
||||||
labels = {
|
labels = {
|
||||||
'name': 'Название',
|
'name': 'Название',
|
||||||
'sku': 'Артикул',
|
'sku': 'Артикул',
|
||||||
'description': 'Описание',
|
'description': 'Описание',
|
||||||
|
'short_description': 'Краткое описание',
|
||||||
'categories': 'Категории',
|
'categories': 'Категории',
|
||||||
'tags': 'Теги',
|
'tags': 'Теги',
|
||||||
'pricing_method': 'Метод ценообразования',
|
'pricing_method': 'Метод ценообразования',
|
||||||
'fixed_price': 'Фиксированная цена',
|
'cost_price': 'Себестоимость',
|
||||||
|
'price': 'Ручная цена',
|
||||||
|
'sale_price': 'Цена со скидкой',
|
||||||
'markup_percent': 'Процент наценки',
|
'markup_percent': 'Процент наценки',
|
||||||
'markup_amount': 'Фиксированная наценка',
|
'markup_amount': 'Фиксированная наценка',
|
||||||
'is_active': 'Активен'
|
'is_active': 'Активен'
|
||||||
@@ -113,8 +125,15 @@ class ProductKitForm(forms.ModelForm):
|
|||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'rows': 3
|
'rows': 3
|
||||||
})
|
})
|
||||||
|
self.fields['short_description'].widget.attrs.update({
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 2,
|
||||||
|
'placeholder': 'Краткое описание для превью и площадок'
|
||||||
|
})
|
||||||
self.fields['pricing_method'].widget.attrs.update({'class': 'form-control'})
|
self.fields['pricing_method'].widget.attrs.update({'class': 'form-control'})
|
||||||
self.fields['fixed_price'].widget.attrs.update({'class': 'form-control'})
|
self.fields['cost_price'].widget.attrs.update({'class': 'form-control'})
|
||||||
|
self.fields['price'].widget.attrs.update({'class': 'form-control'})
|
||||||
|
self.fields['sale_price'].widget.attrs.update({'class': 'form-control'})
|
||||||
self.fields['markup_percent'].widget.attrs.update({'class': 'form-control'})
|
self.fields['markup_percent'].widget.attrs.update({'class': 'form-control'})
|
||||||
self.fields['markup_amount'].widget.attrs.update({'class': 'form-control'})
|
self.fields['markup_amount'].widget.attrs.update({'class': 'form-control'})
|
||||||
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
|
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
|
||||||
@@ -308,3 +327,104 @@ class ProductCategoryForm(forms.ModelForm):
|
|||||||
descendants.append(child)
|
descendants.append(child)
|
||||||
descendants.extend(self._get_descendants(child))
|
descendants.extend(self._get_descendants(child))
|
||||||
return descendants
|
return descendants
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== VARIANT GROUP FORMS ====================
|
||||||
|
|
||||||
|
class ProductVariantGroupForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Форма для создания и редактирования группы вариантов товара.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = ProductVariantGroup
|
||||||
|
fields = ['name', 'description']
|
||||||
|
labels = {
|
||||||
|
'name': 'Название группы',
|
||||||
|
'description': 'Описание (для какого букета или ситуации создана)'
|
||||||
|
}
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control form-control-lg fw-semibold',
|
||||||
|
'placeholder': 'Например: Роза красная Freedom'
|
||||||
|
}),
|
||||||
|
'description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Описание группы и её использования'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ProductVariantGroupItemForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Форма для добавления товара в группу с приоритетом.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = ProductVariantGroupItem
|
||||||
|
fields = ['product', 'priority']
|
||||||
|
labels = {
|
||||||
|
'product': 'Товар',
|
||||||
|
'priority': 'Приоритет'
|
||||||
|
}
|
||||||
|
widgets = {
|
||||||
|
'product': forms.Select(attrs={'class': 'form-control form-control-sm'}),
|
||||||
|
'priority': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control form-control-sm',
|
||||||
|
'min': '1',
|
||||||
|
'placeholder': '1, 2, 3...'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Базовый формсет с валидацией на дубликаты
|
||||||
|
class BaseProductVariantGroupItemFormSet(forms.BaseInlineFormSet):
|
||||||
|
def clean(self):
|
||||||
|
"""Проверка на дубликаты товаров в группе"""
|
||||||
|
if any(self.errors):
|
||||||
|
return
|
||||||
|
|
||||||
|
products = []
|
||||||
|
|
||||||
|
for form in self.forms:
|
||||||
|
if self.can_delete and self._should_delete_form(form):
|
||||||
|
continue
|
||||||
|
|
||||||
|
product = form.cleaned_data.get('product')
|
||||||
|
|
||||||
|
# Проверка дубликатов товаров
|
||||||
|
if product:
|
||||||
|
if product in products:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
f'Товар "{product.name}" уже добавлен в группу. '
|
||||||
|
f'Каждый товар может быть добавлен только один раз.'
|
||||||
|
)
|
||||||
|
products.append(product)
|
||||||
|
|
||||||
|
|
||||||
|
# Формсет для создания групп (с пустой формой для добавления товара)
|
||||||
|
ProductVariantGroupItemFormSetCreate = inlineformset_factory(
|
||||||
|
ProductVariantGroup,
|
||||||
|
ProductVariantGroupItem,
|
||||||
|
form=ProductVariantGroupItemForm,
|
||||||
|
formset=BaseProductVariantGroupItemFormSet,
|
||||||
|
fields=['product', 'priority'],
|
||||||
|
extra=1, # Показать 1 пустую форму для первого товара
|
||||||
|
can_delete=True, # Разрешить удаление товаров
|
||||||
|
min_num=0, # Минимум 0 товаров (можно создать пустую группу)
|
||||||
|
validate_min=False, # Не требовать минимум товаров
|
||||||
|
can_delete_extra=True, # Разрешить удалять дополнительные формы
|
||||||
|
)
|
||||||
|
|
||||||
|
# Формсет для редактирования групп (без пустых форм, только существующие товары)
|
||||||
|
ProductVariantGroupItemFormSetUpdate = inlineformset_factory(
|
||||||
|
ProductVariantGroup,
|
||||||
|
ProductVariantGroupItem,
|
||||||
|
form=ProductVariantGroupItemForm,
|
||||||
|
formset=BaseProductVariantGroupItemFormSet,
|
||||||
|
fields=['product', 'priority'],
|
||||||
|
extra=0, # НЕ показывать пустые формы при редактировании
|
||||||
|
can_delete=True, # Разрешить удаление товаров
|
||||||
|
min_num=0, # Минимум 0 товаров
|
||||||
|
validate_min=False, # Не требовать минимум товаров
|
||||||
|
can_delete_extra=True, # Разрешить удалять дополнительные формы
|
||||||
|
)
|
||||||
|
|||||||
@@ -285,6 +285,131 @@ class ProductTag(models.Model):
|
|||||||
super().delete()
|
super().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseProductEntity(models.Model):
|
||||||
|
"""
|
||||||
|
Абстрактный базовый класс для Product и ProductKit.
|
||||||
|
Объединяет общие поля идентификации, описания, статуса и soft delete.
|
||||||
|
|
||||||
|
Используется как основа для:
|
||||||
|
- Product (простой товар)
|
||||||
|
- ProductKit (комплект товаров)
|
||||||
|
"""
|
||||||
|
# Идентификация
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
verbose_name="Название"
|
||||||
|
)
|
||||||
|
sku = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Артикул",
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
slug = models.SlugField(
|
||||||
|
max_length=200,
|
||||||
|
unique=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="URL-идентификатор"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Описания
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Описание"
|
||||||
|
)
|
||||||
|
short_description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Краткое описание",
|
||||||
|
help_text="Используется для карточек товаров, превью и площадок"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Статус
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name="Активен"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Временные метки
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Дата создания"
|
||||||
|
)
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name="Дата обновления"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
is_deleted = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Удален",
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
deleted_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Время удаления"
|
||||||
|
)
|
||||||
|
deleted_by = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='deleted_%(class)s_set',
|
||||||
|
verbose_name="Удален пользователем"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Managers
|
||||||
|
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
active = ActiveManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['is_active']),
|
||||||
|
models.Index(fields=['is_deleted']),
|
||||||
|
models.Index(fields=['is_deleted', 'created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
"""Мягкое удаление (soft delete)"""
|
||||||
|
user = kwargs.pop('user', None)
|
||||||
|
self.is_deleted = True
|
||||||
|
self.deleted_at = timezone.now()
|
||||||
|
if user:
|
||||||
|
self.deleted_by = user
|
||||||
|
self.save(update_fields=['is_deleted', 'deleted_at', 'deleted_by'])
|
||||||
|
return 1, {self.__class__._meta.label: 1}
|
||||||
|
|
||||||
|
def hard_delete(self):
|
||||||
|
"""Физическое удаление из БД (необратимо!)"""
|
||||||
|
super().delete()
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Автогенерация slug из name если не задан"""
|
||||||
|
if not self.slug or self.slug.strip() == '':
|
||||||
|
from unidecode import unidecode
|
||||||
|
transliterated_name = unidecode(self.name)
|
||||||
|
self.slug = slugify(transliterated_name)
|
||||||
|
|
||||||
|
# Ensure unique slug
|
||||||
|
original_slug = self.slug
|
||||||
|
counter = 1
|
||||||
|
ModelClass = self.__class__
|
||||||
|
while ModelClass.objects.filter(slug=self.slug).exclude(pk=self.pk).exists():
|
||||||
|
self.slug = f"{original_slug}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ProductVariantGroup(models.Model):
|
class ProductVariantGroup(models.Model):
|
||||||
"""
|
"""
|
||||||
Группа вариантов товара (взаимозаменяемые товары).
|
Группа вариантов товара (взаимозаменяемые товары).
|
||||||
@@ -345,9 +470,47 @@ class ProductVariantGroup(models.Model):
|
|||||||
return max_price
|
return max_price
|
||||||
|
|
||||||
|
|
||||||
class Product(models.Model):
|
class ProductVariantGroupItem(models.Model):
|
||||||
|
"""
|
||||||
|
Товар в группе вариантов с приоритетом для этой конкретной группы.
|
||||||
|
Приоритет определяет порядок выбора товара при использовании группы в комплектах.
|
||||||
|
Например: в группе "Роза красная Freedom" - роза 50см имеет приоритет 1, 60см = 2, 70см = 3.
|
||||||
|
"""
|
||||||
|
variant_group = models.ForeignKey(
|
||||||
|
ProductVariantGroup,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='items',
|
||||||
|
verbose_name="Группа вариантов"
|
||||||
|
)
|
||||||
|
product = models.ForeignKey(
|
||||||
|
'Product',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='variant_group_items',
|
||||||
|
verbose_name="Товар"
|
||||||
|
)
|
||||||
|
priority = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Меньше = выше приоритет (1 - наивысший приоритет в этой группе)"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Товар в группе вариантов"
|
||||||
|
verbose_name_plural = "Товары в группах вариантов"
|
||||||
|
ordering = ['priority', 'id']
|
||||||
|
unique_together = [['variant_group', 'product']]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['variant_group', 'priority']),
|
||||||
|
models.Index(fields=['product']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.variant_group.name} - {self.product.name} (приоритет {self.priority})"
|
||||||
|
|
||||||
|
|
||||||
|
class Product(BaseProductEntity):
|
||||||
"""
|
"""
|
||||||
Базовый товар (цветок, упаковка, аксессуар).
|
Базовый товар (цветок, упаковка, аксессуар).
|
||||||
|
Наследует общие поля из BaseProductEntity.
|
||||||
"""
|
"""
|
||||||
UNIT_CHOICES = [
|
UNIT_CHOICES = [
|
||||||
('шт', 'Штука'),
|
('шт', 'Штука'),
|
||||||
@@ -357,9 +520,7 @@ class Product(models.Model):
|
|||||||
('кг', 'Килограмм'),
|
('кг', 'Килограмм'),
|
||||||
]
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=200, verbose_name="Название")
|
# Специфичные поля Product
|
||||||
sku = models.CharField(max_length=100, blank=True, null=True, verbose_name="Артикул", db_index=True)
|
|
||||||
slug = models.SlugField(max_length=200, unique=True, blank=True, verbose_name="URL-идентификатор")
|
|
||||||
variant_suffix = models.CharField(
|
variant_suffix = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -367,68 +528,88 @@ class Product(models.Model):
|
|||||||
verbose_name="Суффикс варианта",
|
verbose_name="Суффикс варианта",
|
||||||
help_text="Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия."
|
help_text="Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия."
|
||||||
)
|
)
|
||||||
description = models.TextField(blank=True, null=True, verbose_name="Описание")
|
|
||||||
|
# Categories and Tags - остаются в Product с related_name='products'
|
||||||
categories = models.ManyToManyField(
|
categories = models.ManyToManyField(
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='products',
|
related_name='products',
|
||||||
verbose_name="Категории"
|
verbose_name="Категории"
|
||||||
)
|
)
|
||||||
tags = models.ManyToManyField(ProductTag, blank=True, related_name='products', verbose_name="Теги")
|
tags = models.ManyToManyField(
|
||||||
|
ProductTag,
|
||||||
|
blank=True,
|
||||||
|
related_name='products',
|
||||||
|
verbose_name="Теги"
|
||||||
|
)
|
||||||
variant_groups = models.ManyToManyField(
|
variant_groups = models.ManyToManyField(
|
||||||
ProductVariantGroup,
|
ProductVariantGroup,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='products',
|
related_name='products',
|
||||||
verbose_name="Группы вариантов"
|
verbose_name="Группы вариантов"
|
||||||
)
|
)
|
||||||
unit = models.CharField(max_length=10, choices=UNIT_CHOICES, default='шт', verbose_name="Единица измерения")
|
|
||||||
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Себестоимость")
|
|
||||||
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Розничная цена")
|
|
||||||
is_active = models.BooleanField(default=True, verbose_name="Активен")
|
|
||||||
in_stock = models.BooleanField(default=False, verbose_name="В наличии", db_index=True,
|
|
||||||
help_text="Автоматически обновляется при изменении остатков на складе")
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
|
||||||
|
|
||||||
# Поле для улучшенного поиска (задел на будущее)
|
unit = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=UNIT_CHOICES,
|
||||||
|
default='шт',
|
||||||
|
verbose_name="Единица измерения"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ЦЕНООБРАЗОВАНИЕ - переименованные поля
|
||||||
|
cost_price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
verbose_name="Себестоимость",
|
||||||
|
help_text="В будущем будет вычисляться автоматически из партий (FIFO)"
|
||||||
|
)
|
||||||
|
price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
verbose_name="Основная цена",
|
||||||
|
help_text="Цена продажи товара (бывшее поле sale_price)"
|
||||||
|
)
|
||||||
|
sale_price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Цена со скидкой",
|
||||||
|
help_text="Если задана, товар продается по этой цене (дешевле основной)"
|
||||||
|
)
|
||||||
|
|
||||||
|
in_stock = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="В наличии",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Автоматически обновляется при изменении остатков на складе"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Поле для улучшенного поиска
|
||||||
search_keywords = models.TextField(
|
search_keywords = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name="Ключевые слова для поиска",
|
verbose_name="Ключевые слова для поиска",
|
||||||
help_text="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную."
|
help_text="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Поля для мягкого удаления
|
|
||||||
is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True)
|
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления")
|
|
||||||
deleted_by = models.ForeignKey(
|
|
||||||
User,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='deleted_products',
|
|
||||||
verbose_name="Удален пользователем"
|
|
||||||
)
|
|
||||||
|
|
||||||
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
|
|
||||||
all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
|
|
||||||
active = ActiveManager() # Кастомный менеджер для активных товаров
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Товар"
|
verbose_name = "Товар"
|
||||||
verbose_name_plural = "Товары"
|
verbose_name_plural = "Товары"
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['is_active']),
|
|
||||||
models.Index(fields=['is_deleted']),
|
|
||||||
models.Index(fields=['is_deleted', 'created_at']),
|
|
||||||
models.Index(fields=['in_stock']),
|
models.Index(fields=['in_stock']),
|
||||||
|
models.Index(fields=['sku']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
@property
|
||||||
return self.name
|
def actual_price(self):
|
||||||
|
"""
|
||||||
|
Финальная цена для продажи.
|
||||||
|
Если есть sale_price (скидка) - возвращает его, иначе - основную цену.
|
||||||
|
"""
|
||||||
|
return self.sale_price if self.sale_price else self.price
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Автоматическое извлечение variant_suffix из названия
|
# Автоматическое извлечение variant_suffix из названия
|
||||||
# (только если не задан вручную и товар еще не сохранен с суффиксом)
|
|
||||||
if not self.variant_suffix and self.name:
|
if not self.variant_suffix and self.name:
|
||||||
from .utils.sku_generator import parse_variant_suffix
|
from .utils.sku_generator import parse_variant_suffix
|
||||||
parsed_suffix = parse_variant_suffix(self.name)
|
parsed_suffix = parse_variant_suffix(self.name)
|
||||||
@@ -439,33 +620,17 @@ class Product(models.Model):
|
|||||||
if not self.sku:
|
if not self.sku:
|
||||||
self.sku = generate_product_sku(self)
|
self.sku = generate_product_sku(self)
|
||||||
|
|
||||||
# Автоматическая генерация slug из названия с транслитерацией
|
|
||||||
if not self.slug or self.slug.strip() == '':
|
|
||||||
from unidecode import unidecode
|
|
||||||
# Транслитерируем кириллицу в латиницу, затем применяем slugify
|
|
||||||
transliterated_name = unidecode(self.name)
|
|
||||||
self.slug = slugify(transliterated_name)
|
|
||||||
|
|
||||||
# Убеждаемся, что slug уникален
|
|
||||||
original_slug = self.slug
|
|
||||||
counter = 1
|
|
||||||
while Product.objects.filter(slug=self.slug).exclude(pk=self.pk).exists():
|
|
||||||
self.slug = f"{original_slug}-{counter}"
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
# Автоматическая генерация ключевых слов для поиска
|
# Автоматическая генерация ключевых слов для поиска
|
||||||
# Собираем все релевантные данные в одну строку
|
|
||||||
keywords_parts = [
|
keywords_parts = [
|
||||||
self.name or '',
|
self.name or '',
|
||||||
self.sku or '',
|
self.sku or '',
|
||||||
self.description or '',
|
self.description or '',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Генерируем строку для поиска (только если поле пустое)
|
|
||||||
# Это позволит администратору добавлять кастомные ключевые слова вручную
|
|
||||||
if not self.search_keywords:
|
if not self.search_keywords:
|
||||||
self.search_keywords = ' '.join(filter(None, keywords_parts))
|
self.search_keywords = ' '.join(filter(None, keywords_parts))
|
||||||
|
|
||||||
|
# Вызов родительского save (генерация slug и т.д.)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# Добавляем названия категорий в search_keywords после сохранения
|
# Добавляем названия категорий в search_keywords после сохранения
|
||||||
@@ -477,18 +642,6 @@ class Product(models.Model):
|
|||||||
# Используем update чтобы избежать рекурсии
|
# Используем update чтобы избежать рекурсии
|
||||||
Product.objects.filter(pk=self.pk).update(search_keywords=self.search_keywords)
|
Product.objects.filter(pk=self.pk).update(search_keywords=self.search_keywords)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
|
||||||
"""Soft delete вместо hard delete - марк как удаленный"""
|
|
||||||
self.is_deleted = True
|
|
||||||
self.deleted_at = timezone.now()
|
|
||||||
self.save(update_fields=['is_deleted', 'deleted_at'])
|
|
||||||
# Возвращаем результат в формате Django
|
|
||||||
return 1, {self.__class__._meta.label: 1}
|
|
||||||
|
|
||||||
def hard_delete(self):
|
|
||||||
"""Полное удаление из БД (необратимо!)"""
|
|
||||||
super().delete()
|
|
||||||
|
|
||||||
def get_variant_groups(self):
|
def get_variant_groups(self):
|
||||||
"""Возвращает все группы вариантов товара"""
|
"""Возвращает все группы вариантов товара"""
|
||||||
return self.variant_groups.all()
|
return self.variant_groups.all()
|
||||||
@@ -500,91 +653,134 @@ class Product(models.Model):
|
|||||||
).exclude(id=self.id).distinct()
|
).exclude(id=self.id).distinct()
|
||||||
|
|
||||||
|
|
||||||
class ProductKit(models.Model):
|
class ProductKit(BaseProductEntity):
|
||||||
"""
|
"""
|
||||||
Шаблон комплекта / букета (рецепт).
|
Шаблон комплекта / букета (рецепт).
|
||||||
|
Наследует общие поля из BaseProductEntity.
|
||||||
"""
|
"""
|
||||||
PRICING_METHOD_CHOICES = [
|
PRICING_METHOD_CHOICES = [
|
||||||
('fixed', 'Фиксированная цена'),
|
('manual', 'Ручная цена'),
|
||||||
('from_sale_prices', 'По ценам продажи компонентов'),
|
('from_sale_prices', 'По ценам продажи компонентов'),
|
||||||
('from_cost_plus_percent', 'Себестоимость + процент наценки'),
|
('from_cost_plus_percent', 'Себестоимость + процент наценки'),
|
||||||
('from_cost_plus_amount', 'Себестоимость + фикс. наценка'),
|
('from_cost_plus_amount', 'Себестоимость + фикс. наценка'),
|
||||||
]
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=200, verbose_name="Название")
|
# Categories and Tags - остаются в ProductKit с related_name='kits'
|
||||||
sku = models.CharField(max_length=100, blank=True, null=True, verbose_name="Артикул")
|
|
||||||
slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-идентификатор")
|
|
||||||
description = models.TextField(blank=True, null=True, verbose_name="Описание")
|
|
||||||
categories = models.ManyToManyField(
|
categories = models.ManyToManyField(
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='kits',
|
related_name='kits',
|
||||||
verbose_name="Категории"
|
verbose_name="Категории"
|
||||||
)
|
)
|
||||||
tags = models.ManyToManyField(ProductTag, blank=True, related_name='kits', verbose_name="Теги")
|
tags = models.ManyToManyField(
|
||||||
is_active = models.BooleanField(default=True, verbose_name="Активен")
|
ProductTag,
|
||||||
pricing_method = models.CharField(max_length=30, choices=PRICING_METHOD_CHOICES,
|
|
||||||
default='from_sale_prices', verbose_name="Метод ценообразования")
|
|
||||||
fixed_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True,
|
|
||||||
verbose_name="Фиксированная цена")
|
|
||||||
markup_percent = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True,
|
|
||||||
verbose_name="Процент наценки")
|
|
||||||
markup_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True,
|
|
||||||
verbose_name="Фиксированная наценка")
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
|
||||||
|
|
||||||
# Поля для мягкого удаления
|
|
||||||
is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True)
|
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления")
|
|
||||||
deleted_by = models.ForeignKey(
|
|
||||||
User,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='deleted_kits',
|
related_name='kits',
|
||||||
verbose_name="Удален пользователем"
|
verbose_name="Теги"
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
|
# ЦЕНООБРАЗОВАНИЕ - специфичные поля ProductKit
|
||||||
all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
|
pricing_method = models.CharField(
|
||||||
active = ActiveManager() # Кастомный менеджер для активных комплектов
|
max_length=30,
|
||||||
|
choices=PRICING_METHOD_CHOICES,
|
||||||
|
default='from_sale_prices',
|
||||||
|
verbose_name="Метод ценообразования"
|
||||||
|
)
|
||||||
|
|
||||||
|
cost_price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Себестоимость",
|
||||||
|
help_text="Можно задать вручную или вычислить из компонентов"
|
||||||
|
)
|
||||||
|
|
||||||
|
price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Ручная цена",
|
||||||
|
help_text="Цена при методе 'Ручная цена' (бывшее поле fixed_price)"
|
||||||
|
)
|
||||||
|
|
||||||
|
sale_price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Цена со скидкой",
|
||||||
|
help_text="Если задана, комплект продается по этой цене"
|
||||||
|
)
|
||||||
|
|
||||||
|
markup_percent = models.DecimalField(
|
||||||
|
max_digits=5,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Процент наценки",
|
||||||
|
help_text="Для метода 'Себестоимость + процент наценки'"
|
||||||
|
)
|
||||||
|
|
||||||
|
markup_amount = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Фиксированная наценка",
|
||||||
|
help_text="Для метода 'Себестоимость + фиксированная наценка'"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Комплект"
|
verbose_name = "Комплект"
|
||||||
verbose_name_plural = "Комплекты"
|
verbose_name_plural = "Комплекты"
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['is_active']),
|
models.Index(fields=['pricing_method']),
|
||||||
models.Index(fields=['slug']),
|
|
||||||
models.Index(fields=['is_deleted']),
|
|
||||||
models.Index(fields=['is_deleted', 'created_at']),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
@property
|
||||||
return self.name
|
def calculated_price(self):
|
||||||
|
"""
|
||||||
|
Вычисляемая цена на основе pricing_method.
|
||||||
|
Используется, если не задана ручная цена.
|
||||||
|
"""
|
||||||
|
return self.calculate_price_with_substitutions()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actual_price(self):
|
||||||
|
"""
|
||||||
|
Финальная цена для продажи.
|
||||||
|
Приоритет: sale_price > price (ручная) > calculated_price
|
||||||
|
"""
|
||||||
|
if self.sale_price:
|
||||||
|
return self.sale_price
|
||||||
|
if self.pricing_method == 'manual' and self.price:
|
||||||
|
return self.price
|
||||||
|
return self.calculated_price
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Валидация комплекта перед сохранением"""
|
"""Валидация комплекта перед сохранением"""
|
||||||
# Проверка соответствия метода ценообразования полям
|
# Проверка соответствия метода ценообразования полям
|
||||||
if self.pricing_method == 'fixed' and not self.fixed_price:
|
if self.pricing_method == 'manual' and not self.price:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'fixed_price': 'Для метода ценообразования "fixed" необходимо указать фиксированную цену.'
|
'price': 'Для метода ценообразования "Ручная цена" необходимо указать цену.'
|
||||||
})
|
})
|
||||||
|
|
||||||
if self.pricing_method == 'from_cost_plus_percent' and (
|
if self.pricing_method == 'from_cost_plus_percent' and (
|
||||||
self.markup_percent is None or self.markup_percent < 0
|
self.markup_percent is None or self.markup_percent < 0
|
||||||
):
|
):
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'markup_percent': 'Для метода ценообразования "from_cost_plus_percent" необходимо указать процент наценки >= 0.'
|
'markup_percent': 'Для метода ценообразования "from_cost_plus_percent" необходимо указать процент наценки >= 0.'
|
||||||
})
|
})
|
||||||
|
|
||||||
if self.pricing_method == 'from_cost_plus_amount' and (
|
if self.pricing_method == 'from_cost_plus_amount' and (
|
||||||
self.markup_amount is None or self.markup_amount < 0
|
self.markup_amount is None or self.markup_amount < 0
|
||||||
):
|
):
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'markup_amount': 'Для метода ценообразования "from_cost_plus_amount" необходимо указать сумму наценки >= 0.'
|
'markup_amount': 'Для метода ценообразования "from_cost_plus_amount" необходимо указать сумму наценки >= 0.'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Проверка уникальности SKU (если задан)
|
# Проверка уникальности SKU (если задан)
|
||||||
if self.sku:
|
if self.sku:
|
||||||
# Проверяем, что SKU не используется другим комплектом (если объект уже существует)
|
# Проверяем, что SKU не используется другим комплектом (если объект уже существует)
|
||||||
@@ -601,20 +797,11 @@ class ProductKit(models.Model):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.slug:
|
# Генерация артикула для новых комплектов
|
||||||
from unidecode import unidecode
|
|
||||||
# Транслитерируем кириллицу в латиницу, затем применяем slugify
|
|
||||||
transliterated_name = unidecode(self.name)
|
|
||||||
self.slug = slugify(transliterated_name)
|
|
||||||
# Убеждаемся, что slug уникален
|
|
||||||
original_slug = self.slug
|
|
||||||
counter = 1
|
|
||||||
while ProductKit.objects.filter(slug=self.slug).exclude(pk=self.pk).exists():
|
|
||||||
self.slug = f"{original_slug}-{counter}"
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
if not self.sku:
|
if not self.sku:
|
||||||
self.sku = generate_kit_sku()
|
self.sku = generate_kit_sku()
|
||||||
|
|
||||||
|
# Вызов родительского save (генерация slug и т.д.)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_total_components_count(self):
|
def get_total_components_count(self):
|
||||||
@@ -638,16 +825,16 @@ class ProductKit(models.Model):
|
|||||||
def get_sale_price(self):
|
def get_sale_price(self):
|
||||||
"""
|
"""
|
||||||
Возвращает рассчитанную цену продажи комплекта в соответствии с выбранным методом ценообразования.
|
Возвращает рассчитанную цену продажи комплекта в соответствии с выбранным методом ценообразования.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decimal: Цена продажи комплекта
|
Decimal: Цена продажи комплекта
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return self.calculate_price_with_substitutions()
|
return self.calculate_price_with_substitutions()
|
||||||
except Exception:
|
except Exception:
|
||||||
# Если что-то пошло не так, возвращаем фиксированную цену если есть
|
# Если что-то пошло не так, возвращаем ручную цену если есть
|
||||||
if self.pricing_method == 'fixed' and self.fixed_price:
|
if self.pricing_method == 'manual' and self.price:
|
||||||
return self.fixed_price
|
return self.price
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def check_availability(self, stock_manager=None):
|
def check_availability(self, stock_manager=None):
|
||||||
@@ -678,13 +865,13 @@ class ProductKit(models.Model):
|
|||||||
def calculate_price_with_substitutions(self, stock_manager=None):
|
def calculate_price_with_substitutions(self, stock_manager=None):
|
||||||
"""
|
"""
|
||||||
Расчёт цены комплекта с учётом доступных замен компонентов.
|
Расчёт цены комплекта с учётом доступных замен компонентов.
|
||||||
|
|
||||||
Метод определяет цену комплекта, учитывая доступные товары-заменители
|
Метод определяет цену комплекта, учитывая доступные товары-заменители
|
||||||
и применяет выбранный метод ценообразования.
|
и применяет выбранный метод ценообразования.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stock_manager: Объект управления складом (если не указан, используется стандартный)
|
stock_manager: Объект управления складом (если не указан, используется стандартный)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decimal: Расчетная цена комплекта, или 0 в случае ошибки
|
Decimal: Расчетная цена комплекта, или 0 в случае ошибки
|
||||||
"""
|
"""
|
||||||
@@ -694,9 +881,9 @@ class ProductKit(models.Model):
|
|||||||
if stock_manager is None:
|
if stock_manager is None:
|
||||||
stock_manager = StockManager()
|
stock_manager = StockManager()
|
||||||
|
|
||||||
# Если указана фиксированная цена, используем её
|
# Если указана ручная цена, используем её
|
||||||
if self.pricing_method == 'fixed' and self.fixed_price:
|
if self.pricing_method == 'manual' and self.price:
|
||||||
return self.fixed_price
|
return self.price
|
||||||
|
|
||||||
total_cost = Decimal('0.00')
|
total_cost = Decimal('0.00')
|
||||||
total_sale = Decimal('0.00')
|
total_sale = Decimal('0.00')
|
||||||
@@ -712,14 +899,14 @@ class ProductKit(models.Model):
|
|||||||
|
|
||||||
if best_product:
|
if best_product:
|
||||||
item_cost = best_product.cost_price
|
item_cost = best_product.cost_price
|
||||||
item_sale = best_product.sale_price
|
item_price = best_product.price # ОБНОВЛЕНО: было sale_price, теперь price
|
||||||
item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано
|
item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано
|
||||||
|
|
||||||
# Проверяем корректность значений перед умножением
|
# Проверяем корректность значений перед умножением
|
||||||
if item_cost and item_quantity:
|
if item_cost and item_quantity:
|
||||||
total_cost += item_cost * item_quantity
|
total_cost += item_cost * item_quantity
|
||||||
if item_sale and item_quantity:
|
if item_price and item_quantity:
|
||||||
total_sale += item_sale * item_quantity
|
total_sale += item_price * item_quantity
|
||||||
except (AttributeError, TypeError, InvalidOperation) as e:
|
except (AttributeError, TypeError, InvalidOperation) as e:
|
||||||
# Логируем ошибку, но продолжаем вычисления
|
# Логируем ошибку, но продолжаем вычисления
|
||||||
import logging
|
import logging
|
||||||
@@ -735,17 +922,17 @@ class ProductKit(models.Model):
|
|||||||
return total_cost * (Decimal('1') + self.markup_percent / Decimal('100'))
|
return total_cost * (Decimal('1') + self.markup_percent / Decimal('100'))
|
||||||
elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount is not None:
|
elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount is not None:
|
||||||
return total_cost + self.markup_amount
|
return total_cost + self.markup_amount
|
||||||
elif self.pricing_method == 'fixed' and self.fixed_price:
|
elif self.pricing_method == 'manual' and self.price:
|
||||||
return self.fixed_price
|
return self.price
|
||||||
|
|
||||||
return total_sale
|
return total_sale
|
||||||
except (TypeError, InvalidOperation) as e:
|
except (TypeError, InvalidOperation) as e:
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.error(f"Ошибка при применении метода ценообразования для комплекта {self.name}: {e}")
|
logger.error(f"Ошибка при применении метода ценообразования для комплекта {self.name}: {e}")
|
||||||
# Возвращаем фиксированную цену если есть, иначе 0
|
# Возвращаем ручную цену если есть, иначе 0
|
||||||
if self.pricing_method == 'fixed' and self.fixed_price:
|
if self.pricing_method == 'manual' and self.price:
|
||||||
return self.fixed_price
|
return self.price
|
||||||
return Decimal('0.00')
|
return Decimal('0.00')
|
||||||
|
|
||||||
def calculate_cost(self):
|
def calculate_cost(self):
|
||||||
@@ -1252,40 +1439,3 @@ class ProductCategoryPhoto(models.Model):
|
|||||||
"""Получить URL оригинального изображения"""
|
"""Получить URL оригинального изображения"""
|
||||||
from .utils.image_service import ImageService
|
from .utils.image_service import ImageService
|
||||||
return ImageService.get_original_url(self.image.name)
|
return ImageService.get_original_url(self.image.name)
|
||||||
|
|
||||||
|
|
||||||
class ProductVariantGroupItem(models.Model):
|
|
||||||
"""
|
|
||||||
Товар в группе вариантов с приоритетом для этой конкретной группы.
|
|
||||||
Приоритет определяет порядок выбора товара при использовании группы в комплектах.
|
|
||||||
Например: в группе "Роза красная Freedom" - роза 50см имеет приоритет 1, 60см = 2, 70см = 3.
|
|
||||||
"""
|
|
||||||
variant_group = models.ForeignKey(
|
|
||||||
ProductVariantGroup,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='items',
|
|
||||||
verbose_name="Группа вариантов"
|
|
||||||
)
|
|
||||||
product = models.ForeignKey(
|
|
||||||
Product,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='variant_group_items',
|
|
||||||
verbose_name="Товар"
|
|
||||||
)
|
|
||||||
priority = models.PositiveIntegerField(
|
|
||||||
default=0,
|
|
||||||
help_text="Меньше = выше приоритет (1 - наивысший приоритет в этой группе)"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Товар в группе вариантов"
|
|
||||||
verbose_name_plural = "Товары в группах вариантов"
|
|
||||||
ordering = ['priority', 'id']
|
|
||||||
unique_together = [['variant_group', 'product']]
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['variant_group', 'priority']),
|
|
||||||
models.Index(fields=['product']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.variant_group.name} - {self.product.name} (приоритет {self.priority})"
|
|
||||||
|
|||||||
@@ -141,10 +141,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if item.item_type == 'product' %}
|
{% if item.sale_price %}
|
||||||
{{ item.sale_price|floatformat:2 }} руб.
|
<span class="text-decoration-line-through text-muted small">{{ item.price|floatformat:2 }} руб.</span>
|
||||||
|
<br>
|
||||||
|
<strong class="text-danger">{{ item.sale_price|floatformat:2 }} руб.</strong>
|
||||||
|
<span class="badge bg-danger ms-1">Акция</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ item.get_sale_price|floatformat:2 }} руб.
|
<strong>{{ item.actual_price|floatformat:2 }} руб.</strong>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -158,8 +158,16 @@
|
|||||||
<td>{{ product.cost_price }} руб.</td>
|
<td>{{ product.cost_price }} руб.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Цена продажи:</th>
|
<th>Цена:</th>
|
||||||
<td>{{ product.sale_price }} руб.</td>
|
<td>
|
||||||
|
{% if product.sale_price %}
|
||||||
|
<span class="text-decoration-line-through text-muted">{{ product.price }} руб.</span>
|
||||||
|
<strong class="text-danger fs-5">{{ product.sale_price }} руб.</strong>
|
||||||
|
<span class="badge bg-danger ms-2">Акция</span>
|
||||||
|
{% else %}
|
||||||
|
<strong class="fs-5">{{ product.price }} руб.</strong>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>В наличии:</th>
|
<th>В наличии:</th>
|
||||||
|
|||||||
@@ -79,6 +79,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Краткое описание -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_short_description" class="form-label">Краткое описание</label>
|
||||||
|
{{ form.short_description }}
|
||||||
|
<small class="form-text text-muted">Используется для карточек товаров, превью и площадок</small>
|
||||||
|
{% if form.short_description.errors %}
|
||||||
|
<div class="text-danger">{{ form.short_description.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Единица измерения и Статус в один ряд -->
|
<!-- Единица измерения и Статус в один ряд -->
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
@@ -114,7 +124,7 @@
|
|||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
{{ form.cost_price.label_tag }}
|
<label for="id_cost_price" class="form-label">Себестоимость</label>
|
||||||
{{ form.cost_price }}
|
{{ form.cost_price }}
|
||||||
{% if form.cost_price.help_text %}
|
{% if form.cost_price.help_text %}
|
||||||
<small class="form-text text-muted">{{ form.cost_price.help_text }}</small>
|
<small class="form-text text-muted">{{ form.cost_price.help_text }}</small>
|
||||||
@@ -124,11 +134,20 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
{{ form.sale_price.label_tag }}
|
<label for="id_price" class="form-label fw-bold">Основная цена <span class="text-danger">*</span></label>
|
||||||
{{ form.sale_price }}
|
{{ form.price }}
|
||||||
{% if form.sale_price.help_text %}
|
<small class="form-text text-muted">Цена продажи товара</small>
|
||||||
<small class="form-text text-muted">{{ form.sale_price.help_text }}</small>
|
{% if form.price.errors %}
|
||||||
|
<div class="text-danger">{{ form.price.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="id_sale_price" class="form-label">Цена со скидкой</label>
|
||||||
|
{{ form.sale_price }}
|
||||||
|
<small class="form-text text-muted">Необязательно. Если задана, товар будет продаваться по этой цене (дешевле основной)</small>
|
||||||
{% if form.sale_price.errors %}
|
{% if form.sale_price.errors %}
|
||||||
<div class="text-danger">{{ form.sale_price.errors }}</div>
|
<div class="text-danger">{{ form.sale_price.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -50,7 +50,16 @@
|
|||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ product.sale_price }} руб.</td>
|
<td>
|
||||||
|
{% if product.sale_price %}
|
||||||
|
<span class="text-decoration-line-through text-muted small">{{ product.price }} руб.</span>
|
||||||
|
<br>
|
||||||
|
<strong class="text-danger">{{ product.sale_price }} руб.</strong>
|
||||||
|
<span class="badge bg-danger ms-1">Акция</span>
|
||||||
|
{% else %}
|
||||||
|
<strong>{{ product.price }} руб.</strong>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if product.in_stock %}
|
{% if product.in_stock %}
|
||||||
<span class="badge bg-success"><i class="bi bi-check-circle"></i> В наличии</span>
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> В наличии</span>
|
||||||
|
|||||||
@@ -47,9 +47,15 @@
|
|||||||
</dd>
|
</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<dt class="col-sm-4">Цена продажи:</dt>
|
<dt class="col-sm-4">Цена:</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
<strong class="text-success fs-5">{{ kit.get_sale_price|floatformat:2 }} ₽</strong>
|
{% if kit.sale_price %}
|
||||||
|
<span class="text-decoration-line-through text-muted">{{ kit.calculated_price|floatformat:2 }} ₽</span>
|
||||||
|
<strong class="text-danger fs-5">{{ kit.sale_price|floatformat:2 }} ₽</strong>
|
||||||
|
<span class="badge bg-danger ms-2">Акция</span>
|
||||||
|
{% else %}
|
||||||
|
<strong class="text-success fs-5">{{ kit.actual_price|floatformat:2 }} ₽</strong>
|
||||||
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4">Себестоимость:</dt>
|
<dt class="col-sm-4">Себестоимость:</dt>
|
||||||
@@ -62,9 +68,9 @@
|
|||||||
<span class="badge bg-info text-dark">{{ kit.get_pricing_method_display }}</span>
|
<span class="badge bg-info text-dark">{{ kit.get_pricing_method_display }}</span>
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
{% if kit.fixed_price %}
|
{% if kit.price %}
|
||||||
<dt class="col-sm-4">Фиксированная цена:</dt>
|
<dt class="col-sm-4">Ручная цена:</dt>
|
||||||
<dd class="col-sm-8">{{ kit.fixed_price }} ₽</dd>
|
<dd class="col-sm-8">{{ kit.price }} ₽</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if kit.markup_percent %}
|
{% if kit.markup_percent %}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def search_products_and_variants(request):
|
|||||||
'id': product.id,
|
'id': product.id,
|
||||||
'text': f"{product.name} ({product.sku})" if product.sku else product.name,
|
'text': f"{product.name} ({product.sku})" if product.sku else product.name,
|
||||||
'sku': product.sku,
|
'sku': product.sku,
|
||||||
'price': str(product.sale_price) if product.sale_price else None,
|
'price': str(product.price) if product.price else None,
|
||||||
'in_stock': product.in_stock,
|
'in_stock': product.in_stock,
|
||||||
'type': 'product'
|
'type': 'product'
|
||||||
}],
|
}],
|
||||||
@@ -74,7 +74,7 @@ def search_products_and_variants(request):
|
|||||||
# Показываем последние добавленные активные товары
|
# Показываем последние добавленные активные товары
|
||||||
products = Product.objects.filter(is_active=True)\
|
products = Product.objects.filter(is_active=True)\
|
||||||
.order_by('-created_at')[:page_size]\
|
.order_by('-created_at')[:page_size]\
|
||||||
.values('id', 'name', 'sku', 'sale_price', 'in_stock')
|
.values('id', 'name', 'sku', 'price', 'in_stock')
|
||||||
|
|
||||||
for product in products:
|
for product in products:
|
||||||
text = product['name']
|
text = product['name']
|
||||||
@@ -85,7 +85,7 @@ def search_products_and_variants(request):
|
|||||||
'id': product['id'],
|
'id': product['id'],
|
||||||
'text': text,
|
'text': text,
|
||||||
'sku': product['sku'],
|
'sku': product['sku'],
|
||||||
'price': str(product['sale_price']) if product['sale_price'] else None,
|
'price': str(product['price']) if product['price'] else None,
|
||||||
'in_stock': product['in_stock']
|
'in_stock': product['in_stock']
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ def search_products_and_variants(request):
|
|||||||
start = (page - 1) * page_size
|
start = (page - 1) * page_size
|
||||||
end = start + page_size
|
end = start + page_size
|
||||||
|
|
||||||
products = products_query[start:end].values('id', 'name', 'sku', 'sale_price', 'in_stock')
|
products = products_query[start:end].values('id', 'name', 'sku', 'price', 'in_stock')
|
||||||
|
|
||||||
for product in products:
|
for product in products:
|
||||||
text = product['name']
|
text = product['name']
|
||||||
@@ -158,7 +158,7 @@ def search_products_and_variants(request):
|
|||||||
'id': product['id'],
|
'id': product['id'],
|
||||||
'text': text,
|
'text': text,
|
||||||
'sku': product['sku'],
|
'sku': product['sku'],
|
||||||
'price': str(product['sale_price']) if product['sale_price'] else None,
|
'price': str(product['price']) if product['price'] else None,
|
||||||
'in_stock': product['in_stock'],
|
'in_stock': product['in_stock'],
|
||||||
'type': 'product'
|
'type': 'product'
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user