From d92045c4c484b14082f39e22509ae0608584dce3 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Fri, 31 Oct 2025 00:49:01 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D1=81=D1=81=20BaseProductEntity=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20Product/ProductKit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Основные изменения: ## Модели (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 --- myproject/products/admin.py | 39 +- myproject/products/forms.py | 136 ++++- myproject/products/models.py | 502 ++++++++++++------ .../templates/products/all_products_list.html | 9 +- .../templates/products/product_detail.html | 12 +- .../templates/products/product_form.html | 29 +- .../templates/products/product_list.html | 11 +- .../templates/products/productkit_detail.html | 16 +- myproject/products/views/api_views.py | 10 +- 9 files changed, 555 insertions(+), 209 deletions(-) diff --git a/myproject/products/admin.py b/myproject/products/admin.py index 77cae34..c9e2886 100644 --- a/myproject/products/admin.py +++ b/myproject/products/admin.py @@ -241,7 +241,7 @@ class ProductTagAdmin(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') search_fields = ('name', 'sku', 'description', 'search_keywords') filter_horizontal = ('categories', 'tags', 'variant_groups') @@ -251,10 +251,11 @@ class ProductAdmin(admin.ModelAdmin): 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') @@ -336,13 +337,43 @@ class ProductAdmin(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') prepopulated_fields = {'slug': ('name',)} filter_horizontal = ('categories', 'tags') readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by') 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): """Переопределяем queryset для доступа ко всем комплектам (включая удаленные)""" qs = ProductKit.all_objects.all() diff --git a/myproject/products/forms.py b/myproject/products/forms.py index 6506ea4..8e19e0a 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -1,6 +1,6 @@ from django import forms 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): @@ -25,18 +25,20 @@ class ProductForm(forms.ModelForm): class Meta: model = Product fields = [ - 'name', 'sku', 'description', 'categories', - 'tags', 'unit', 'cost_price', 'sale_price', 'is_active' + 'name', 'sku', 'description', 'short_description', 'categories', + 'tags', 'unit', 'cost_price', 'price', 'sale_price', 'is_active' ] labels = { 'name': 'Название', 'sku': 'Артикул', 'description': 'Описание', + 'short_description': 'Краткое описание', 'categories': 'Категории', 'tags': 'Теги', 'unit': 'Единица измерения', 'cost_price': 'Себестоимость', - 'sale_price': 'Цена продажи', + 'price': 'Основная цена', + 'sale_price': 'Цена со скидкой', 'is_active': 'Активен' } @@ -55,7 +57,13 @@ class ProductForm(forms.ModelForm): 'class': 'form-control', '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['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['is_active'].widget.attrs.update({'class': 'form-check-input'}) @@ -82,17 +90,21 @@ class ProductKitForm(forms.ModelForm): class Meta: model = ProductKit fields = [ - 'name', 'sku', 'description', 'categories', - 'tags', 'pricing_method', 'fixed_price', 'markup_percent', 'markup_amount', 'is_active' + 'name', 'sku', 'description', 'short_description', 'categories', + 'tags', 'pricing_method', 'cost_price', 'price', 'sale_price', + 'markup_percent', 'markup_amount', 'is_active' ] labels = { 'name': 'Название', 'sku': 'Артикул', 'description': 'Описание', + 'short_description': 'Краткое описание', 'categories': 'Категории', 'tags': 'Теги', 'pricing_method': 'Метод ценообразования', - 'fixed_price': 'Фиксированная цена', + 'cost_price': 'Себестоимость', + 'price': 'Ручная цена', + 'sale_price': 'Цена со скидкой', 'markup_percent': 'Процент наценки', 'markup_amount': 'Фиксированная наценка', 'is_active': 'Активен' @@ -113,8 +125,15 @@ class ProductKitForm(forms.ModelForm): 'class': 'form-control', '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['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_amount'].widget.attrs.update({'class': 'form-control'}) self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'}) @@ -308,3 +327,104 @@ class ProductCategoryForm(forms.ModelForm): descendants.append(child) descendants.extend(self._get_descendants(child)) 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, # Разрешить удалять дополнительные формы +) diff --git a/myproject/products/models.py b/myproject/products/models.py index d9cb285..382d7d5 100644 --- a/myproject/products/models.py +++ b/myproject/products/models.py @@ -285,6 +285,131 @@ class ProductTag(models.Model): 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): """ Группа вариантов товара (взаимозаменяемые товары). @@ -345,9 +470,47 @@ class ProductVariantGroup(models.Model): 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 = [ ('шт', 'Штука'), @@ -357,9 +520,7 @@ class Product(models.Model): ('кг', 'Килограмм'), ] - 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-идентификатор") + # Специфичные поля Product variant_suffix = models.CharField( max_length=20, blank=True, @@ -367,68 +528,88 @@ class Product(models.Model): verbose_name="Суффикс варианта", 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( ProductCategory, blank=True, related_name='products', 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( ProductVariantGroup, blank=True, related_name='products', 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( blank=True, verbose_name="Ключевые слова для поиска", 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: verbose_name = "Товар" verbose_name_plural = "Товары" 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=['sku']), ] - def __str__(self): - return self.name + @property + def actual_price(self): + """ + Финальная цена для продажи. + Если есть sale_price (скидка) - возвращает его, иначе - основную цену. + """ + return self.sale_price if self.sale_price else self.price def save(self, *args, **kwargs): # Автоматическое извлечение variant_suffix из названия - # (только если не задан вручную и товар еще не сохранен с суффиксом) if not self.variant_suffix and self.name: from .utils.sku_generator import parse_variant_suffix parsed_suffix = parse_variant_suffix(self.name) @@ -439,33 +620,17 @@ class Product(models.Model): if not self.sku: 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 = [ self.name or '', self.sku or '', self.description or '', ] - # Генерируем строку для поиска (только если поле пустое) - # Это позволит администратору добавлять кастомные ключевые слова вручную if not self.search_keywords: self.search_keywords = ' '.join(filter(None, keywords_parts)) + # Вызов родительского save (генерация slug и т.д.) super().save(*args, **kwargs) # Добавляем названия категорий в search_keywords после сохранения @@ -477,18 +642,6 @@ class Product(models.Model): # Используем update чтобы избежать рекурсии 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): """Возвращает все группы вариантов товара""" return self.variant_groups.all() @@ -500,91 +653,134 @@ class Product(models.Model): ).exclude(id=self.id).distinct() -class ProductKit(models.Model): +class ProductKit(BaseProductEntity): """ Шаблон комплекта / букета (рецепт). + Наследует общие поля из BaseProductEntity. """ PRICING_METHOD_CHOICES = [ - ('fixed', 'Фиксированная цена'), + ('manual', 'Ручная цена'), ('from_sale_prices', 'По ценам продажи компонентов'), ('from_cost_plus_percent', 'Себестоимость + процент наценки'), ('from_cost_plus_amount', 'Себестоимость + фикс. наценка'), ] - name = models.CharField(max_length=200, verbose_name="Название") - 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 and Tags - остаются в ProductKit с related_name='kits' categories = models.ManyToManyField( ProductCategory, blank=True, related_name='kits', verbose_name="Категории" ) - tags = models.ManyToManyField(ProductTag, blank=True, related_name='kits', verbose_name="Теги") - is_active = models.BooleanField(default=True, verbose_name="Активен") - 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, + tags = models.ManyToManyField( + ProductTag, blank=True, - related_name='deleted_kits', - verbose_name="Удален пользователем" + related_name='kits', + verbose_name="Теги" ) - objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные) - all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные) - active = ActiveManager() # Кастомный менеджер для активных комплектов + # ЦЕНООБРАЗОВАНИЕ - специфичные поля ProductKit + pricing_method = models.CharField( + 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: verbose_name = "Комплект" verbose_name_plural = "Комплекты" indexes = [ - models.Index(fields=['is_active']), - models.Index(fields=['slug']), - models.Index(fields=['is_deleted']), - models.Index(fields=['is_deleted', 'created_at']), + models.Index(fields=['pricing_method']), ] - def __str__(self): - return self.name + @property + 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): """Валидация комплекта перед сохранением""" # Проверка соответствия метода ценообразования полям - if self.pricing_method == 'fixed' and not self.fixed_price: + if self.pricing_method == 'manual' and not self.price: raise ValidationError({ - 'fixed_price': 'Для метода ценообразования "fixed" необходимо указать фиксированную цену.' + 'price': 'Для метода ценообразования "Ручная цена" необходимо указать цену.' }) - + if self.pricing_method == 'from_cost_plus_percent' and ( self.markup_percent is None or self.markup_percent < 0 ): raise ValidationError({ 'markup_percent': 'Для метода ценообразования "from_cost_plus_percent" необходимо указать процент наценки >= 0.' }) - + if self.pricing_method == 'from_cost_plus_amount' and ( self.markup_amount is None or self.markup_amount < 0 ): raise ValidationError({ 'markup_amount': 'Для метода ценообразования "from_cost_plus_amount" необходимо указать сумму наценки >= 0.' }) - + # Проверка уникальности SKU (если задан) if self.sku: # Проверяем, что SKU не используется другим комплектом (если объект уже существует) @@ -601,20 +797,11 @@ class ProductKit(models.Model): }) 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: self.sku = generate_kit_sku() + + # Вызов родительского save (генерация slug и т.д.) super().save(*args, **kwargs) def get_total_components_count(self): @@ -638,16 +825,16 @@ class ProductKit(models.Model): def get_sale_price(self): """ Возвращает рассчитанную цену продажи комплекта в соответствии с выбранным методом ценообразования. - + Returns: Decimal: Цена продажи комплекта """ try: return self.calculate_price_with_substitutions() except Exception: - # Если что-то пошло не так, возвращаем фиксированную цену если есть - if self.pricing_method == 'fixed' and self.fixed_price: - return self.fixed_price + # Если что-то пошло не так, возвращаем ручную цену если есть + if self.pricing_method == 'manual' and self.price: + return self.price return 0 def check_availability(self, stock_manager=None): @@ -678,13 +865,13 @@ class ProductKit(models.Model): def calculate_price_with_substitutions(self, stock_manager=None): """ Расчёт цены комплекта с учётом доступных замен компонентов. - + Метод определяет цену комплекта, учитывая доступные товары-заменители и применяет выбранный метод ценообразования. - + Args: stock_manager: Объект управления складом (если не указан, используется стандартный) - + Returns: Decimal: Расчетная цена комплекта, или 0 в случае ошибки """ @@ -694,9 +881,9 @@ class ProductKit(models.Model): if stock_manager is None: stock_manager = StockManager() - # Если указана фиксированная цена, используем её - if self.pricing_method == 'fixed' and self.fixed_price: - return self.fixed_price + # Если указана ручная цена, используем её + if self.pricing_method == 'manual' and self.price: + return self.price total_cost = Decimal('0.00') total_sale = Decimal('0.00') @@ -712,14 +899,14 @@ class ProductKit(models.Model): if best_product: 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, если количество не указано - + # Проверяем корректность значений перед умножением if item_cost and item_quantity: total_cost += item_cost * item_quantity - if item_sale and item_quantity: - total_sale += item_sale * item_quantity + if item_price and item_quantity: + total_sale += item_price * item_quantity except (AttributeError, TypeError, InvalidOperation) as e: # Логируем ошибку, но продолжаем вычисления import logging @@ -735,17 +922,17 @@ class ProductKit(models.Model): 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: return total_cost + self.markup_amount - elif self.pricing_method == 'fixed' and self.fixed_price: - return self.fixed_price + elif self.pricing_method == 'manual' and self.price: + return self.price return total_sale except (TypeError, InvalidOperation) as e: import logging logger = logging.getLogger(__name__) logger.error(f"Ошибка при применении метода ценообразования для комплекта {self.name}: {e}") - # Возвращаем фиксированную цену если есть, иначе 0 - if self.pricing_method == 'fixed' and self.fixed_price: - return self.fixed_price + # Возвращаем ручную цену если есть, иначе 0 + if self.pricing_method == 'manual' and self.price: + return self.price return Decimal('0.00') def calculate_cost(self): @@ -1252,40 +1439,3 @@ class ProductCategoryPhoto(models.Model): """Получить URL оригинального изображения""" from .utils.image_service import ImageService 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})" diff --git a/myproject/products/templates/products/all_products_list.html b/myproject/products/templates/products/all_products_list.html index 6feb73a..4dfd55e 100644 --- a/myproject/products/templates/products/all_products_list.html +++ b/myproject/products/templates/products/all_products_list.html @@ -141,10 +141,13 @@ {% endif %} - {% if item.item_type == 'product' %} - {{ item.sale_price|floatformat:2 }} руб. + {% if item.sale_price %} + {{ item.price|floatformat:2 }} руб. +
+ {{ item.sale_price|floatformat:2 }} руб. + Акция {% else %} - {{ item.get_sale_price|floatformat:2 }} руб. + {{ item.actual_price|floatformat:2 }} руб. {% endif %} diff --git a/myproject/products/templates/products/product_detail.html b/myproject/products/templates/products/product_detail.html index 006ec5d..d0c885f 100644 --- a/myproject/products/templates/products/product_detail.html +++ b/myproject/products/templates/products/product_detail.html @@ -158,8 +158,16 @@ {{ product.cost_price }} руб. - Цена продажи: - {{ product.sale_price }} руб. + Цена: + + {% if product.sale_price %} + {{ product.price }} руб. + {{ product.sale_price }} руб. + Акция + {% else %} + {{ product.price }} руб. + {% endif %} + В наличии: diff --git a/myproject/products/templates/products/product_form.html b/myproject/products/templates/products/product_form.html index 7353130..2233a63 100644 --- a/myproject/products/templates/products/product_form.html +++ b/myproject/products/templates/products/product_form.html @@ -79,6 +79,16 @@ {% endif %} + +
+ + {{ form.short_description }} + Используется для карточек товаров, превью и площадок + {% if form.short_description.errors %} +
{{ form.short_description.errors }}
+ {% endif %} +
+
@@ -114,7 +124,7 @@
- {{ form.cost_price.label_tag }} + {{ form.cost_price }} {% if form.cost_price.help_text %} {{ form.cost_price.help_text }} @@ -124,11 +134,20 @@ {% endif %}
- {{ form.sale_price.label_tag }} - {{ form.sale_price }} - {% if form.sale_price.help_text %} - {{ form.sale_price.help_text }} + + {{ form.price }} + Цена продажи товара + {% if form.price.errors %} +
{{ form.price.errors }}
{% endif %} +
+
+ +
+
+ + {{ form.sale_price }} + Необязательно. Если задана, товар будет продаваться по этой цене (дешевле основной) {% if form.sale_price.errors %}
{{ form.sale_price.errors }}
{% endif %} diff --git a/myproject/products/templates/products/product_list.html b/myproject/products/templates/products/product_list.html index bf8237a..5790598 100644 --- a/myproject/products/templates/products/product_list.html +++ b/myproject/products/templates/products/product_list.html @@ -50,7 +50,16 @@ - {% endif %} - {{ product.sale_price }} руб. + + {% if product.sale_price %} + {{ product.price }} руб. +
+ {{ product.sale_price }} руб. + Акция + {% else %} + {{ product.price }} руб. + {% endif %} + {% if product.in_stock %} В наличии diff --git a/myproject/products/templates/products/productkit_detail.html b/myproject/products/templates/products/productkit_detail.html index 58a215b..135f83b 100644 --- a/myproject/products/templates/products/productkit_detail.html +++ b/myproject/products/templates/products/productkit_detail.html @@ -47,9 +47,15 @@ {% endif %} -
Цена продажи:
+
Цена:
- {{ kit.get_sale_price|floatformat:2 }} ₽ + {% if kit.sale_price %} + {{ kit.calculated_price|floatformat:2 }} ₽ + {{ kit.sale_price|floatformat:2 }} ₽ + Акция + {% else %} + {{ kit.actual_price|floatformat:2 }} ₽ + {% endif %}
Себестоимость:
@@ -62,9 +68,9 @@ {{ kit.get_pricing_method_display }} - {% if kit.fixed_price %} -
Фиксированная цена:
-
{{ kit.fixed_price }} ₽
+ {% if kit.price %} +
Ручная цена:
+
{{ kit.price }} ₽
{% endif %} {% if kit.markup_percent %} diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index 2b9f6c2..81ecaa0 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -45,7 +45,7 @@ def search_products_and_variants(request): 'id': product.id, 'text': f"{product.name} ({product.sku})" if product.sku else product.name, '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, 'type': 'product' }], @@ -74,7 +74,7 @@ def search_products_and_variants(request): # Показываем последние добавленные активные товары products = Product.objects.filter(is_active=True)\ .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: text = product['name'] @@ -85,7 +85,7 @@ def search_products_and_variants(request): 'id': product['id'], 'text': text, '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'] }) @@ -147,7 +147,7 @@ def search_products_and_variants(request): start = (page - 1) * 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: text = product['name'] @@ -158,7 +158,7 @@ def search_products_and_variants(request): 'id': product['id'], 'text': text, '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'], 'type': 'product' })