fix: Улучшения системы ценообразования комплектов

Исправлены 4 проблемы:
1. Расчёт цены первого товара - улучшена валидация в getProductPrice и calculateFinalPrice
2. Отображение actual_price в Select2 вместо обычной цены
3. Количество по умолчанию = 1 для новых форм компонентов
4. Auto-select текста при клике на поле количества для удобства редактирования

Изменённые файлы:
- products/forms.py: добавлен __init__ в KitItemForm для quantity.initial = 1
- products/templates/includes/select2-product-init.html: обновлена formatSelectResult
- products/templates/productkit_create.html: добавлен focus handler для auto-select
- products/templates/productkit_edit.html: добавлен focus handler для auto-select

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-02 19:04:03 +03:00
parent c84a372f98
commit 6c8af5ab2c
120 changed files with 9035 additions and 3036 deletions

View File

@@ -0,0 +1,330 @@
"""
Модели для комплектов (ProductKit) и их компонентов.
Цена комплекта динамически вычисляется из actual_price компонентов.
"""
from decimal import Decimal
from django.db import models
from django.utils import timezone
from django.core.exceptions import ValidationError
from .base import BaseProductEntity
from .categories import ProductCategory, ProductTag
from .variants import ProductVariantGroup
from .products import Product
from ..utils.sku_generator import generate_kit_sku
from ..services.kit_availability import KitAvailabilityChecker
class ProductKit(BaseProductEntity):
"""
Шаблон комплекта / букета (рецепт).
Наследует общие поля из BaseProductEntity.
Цена комплекта = сумма actual_price всех компонентов + корректировка.
Корректировка может быть увеличением или уменьшением на % или фиксированную сумму.
"""
ADJUSTMENT_TYPE_CHOICES = [
('none', 'Без изменения'),
('increase_percent', 'Увеличить на %'),
('increase_amount', 'Увеличить на сумму'),
('decrease_percent', 'Уменьшить на %'),
('decrease_amount', 'Уменьшить на сумму'),
]
# Categories and Tags
categories = models.ManyToManyField(
ProductCategory,
blank=True,
related_name='kits',
verbose_name="Категории"
)
tags = models.ManyToManyField(
ProductTag,
blank=True,
related_name='kits',
verbose_name="Теги"
)
# ЦЕНООБРАЗОВАНИЕ - новый подход
base_price = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Базовая цена",
help_text="Сумма actual_price всех компонентов. Пересчитывается автоматически."
)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Итоговая цена",
help_text="Базовая цена с учетом корректировок. Вычисляется автоматически."
)
sale_price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name="Цена со скидкой",
help_text="Если задана, комплект продается по этой цене"
)
price_adjustment_type = models.CharField(
max_length=20,
choices=ADJUSTMENT_TYPE_CHOICES,
default='none',
verbose_name="Тип корректировки цены"
)
price_adjustment_value = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Значение корректировки",
help_text="Процент (%) или сумма (руб) в зависимости от типа корректировки"
)
class Meta:
verbose_name = "Комплект"
verbose_name_plural = "Комплекты"
@property
def actual_price(self):
"""
Финальная цена для продажи.
Приоритет: sale_price > price (рассчитанная)
"""
if self.sale_price:
return self.sale_price
return self.price
def recalculate_base_price(self):
"""
Пересчитать сумму actual_price всех компонентов.
Вызывается автоматически при изменении цены товара (через signal).
"""
if not self.pk:
return # Новый объект еще не сохранен
total = Decimal('0')
for item in self.kit_items.all():
if item.product:
actual_price = item.product.actual_price or Decimal('0')
qty = item.quantity or Decimal('1')
total += actual_price * qty
self.base_price = total
# Обновляем финальную цену
self.price = self.calculate_final_price()
self.save(update_fields=['base_price', 'price'])
def calculate_final_price(self):
"""
Вычислить финальную цену с учетом корректировок.
Returns:
Decimal: Итоговая цена комплекта
"""
if self.price_adjustment_type == 'none':
return self.base_price
adjustment_value = self.price_adjustment_value or Decimal('0')
if 'percent' in self.price_adjustment_type:
adjustment = self.base_price * adjustment_value / Decimal('100')
else: # 'amount'
adjustment = adjustment_value
if 'increase' in self.price_adjustment_type:
return self.base_price + adjustment
else: # 'decrease'
return max(Decimal('0'), self.base_price - adjustment)
def save(self, *args, **kwargs):
"""При сохранении - пересчитываем финальную цену"""
# Генерация артикула для новых комплектов
if not self.sku:
self.sku = generate_kit_sku()
# Если объект уже существует и имеет компоненты, пересчитываем base_price
if self.pk and self.kit_items.exists():
# Пересчитаем базовую цену из компонентов
total = Decimal('0')
for item in self.kit_items.all():
if item.product:
actual_price = item.product.actual_price or Decimal('0')
qty = item.quantity or Decimal('1')
total += actual_price * qty
self.base_price = total
# Устанавливаем финальную цену в поле price
self.price = self.calculate_final_price()
# Вызов родительского save (генерация slug и т.д.)
super().save(*args, **kwargs)
def get_total_components_count(self):
"""Возвращает количество компонентов (строк) в комплекте"""
return self.kit_items.count()
def get_components_with_variants_count(self):
"""Возвращает количество компонентов, которые используют группы вариантов"""
return self.kit_items.filter(variant_group__isnull=False).count()
def check_availability(self, stock_manager=None):
"""
Проверяет доступность всего комплекта.
Делегирует проверку в сервис.
"""
return KitAvailabilityChecker.check_availability(self, stock_manager)
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'])
return 1, {self.__class__._meta.label: 1}
def hard_delete(self):
"""Полное удаление из БД (необратимо!)"""
super().delete()
class KitItem(models.Model):
"""
Состав комплекта: связь между ProductKit и Product или ProductVariantGroup.
Позиция может быть либо конкретным товаром, либо группой вариантов.
"""
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
verbose_name="Комплект")
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='kit_items_direct',
verbose_name="Конкретный товар"
)
variant_group = models.ForeignKey(
ProductVariantGroup,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='kit_items',
verbose_name="Группа вариантов"
)
quantity = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True, verbose_name="Количество")
notes = models.CharField(
max_length=200,
blank=True,
verbose_name="Примечание"
)
class Meta:
verbose_name = "Компонент комплекта"
verbose_name_plural = "Компоненты комплектов"
indexes = [
models.Index(fields=['kit']),
models.Index(fields=['product']),
models.Index(fields=['variant_group']),
models.Index(fields=['kit', 'product']),
models.Index(fields=['kit', 'variant_group']),
]
def __str__(self):
return f"{self.kit.name} - {self.get_display_name()}"
def clean(self):
"""Валидация: должен быть указан либо product, либо variant_group (но не оба)"""
if self.product and self.variant_group:
raise ValidationError(
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
)
if not self.product and not self.variant_group:
raise ValidationError(
"Необходимо указать либо товар, либо группу вариантов."
)
def get_display_name(self):
"""Возвращает строку для отображения названия компонента"""
if self.variant_group:
return f"[Варианты] {self.variant_group.name}"
return self.product.name if self.product else "Не указан"
def has_priorities_set(self):
"""Проверяет, настроены ли приоритеты замены для данного компонента"""
return self.priorities.exists()
def get_available_products(self):
"""
Возвращает список доступных товаров для этого компонента.
Если указан конкретный товар - возвращает его.
Если указаны приоритеты - возвращает товары в порядке приоритета.
Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
"""
if self.product:
# Если указан конкретный товар, возвращаем только его
return [self.product]
if self.variant_group:
# Если есть настроенные приоритеты, используем их
if self.has_priorities_set():
return [
priority.product
for priority in self.priorities.select_related('product').order_by('priority', 'id')
]
# Иначе возвращаем все товары из группы
return list(self.variant_group.products.filter(is_active=True))
return []
def get_best_available_product(self, stock_manager=None):
"""
Возвращает первый доступный товар по приоритету, соответствующий требованиям по количеству.
"""
from ..utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
available_products = self.get_available_products()
for product in available_products:
if stock_manager.check_stock(product, self.quantity):
return product
return None
class KitItemPriority(models.Model):
"""
Приоритеты товаров для конкретной позиции букета.
Позволяет настроить индивидуальные приоритеты замен для каждого букета.
"""
kit_item = models.ForeignKey(
KitItem,
on_delete=models.CASCADE,
related_name='priorities',
verbose_name="Позиция в букете"
)
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
verbose_name="Товар"
)
priority = models.PositiveIntegerField(
default=0,
help_text="Меньше = выше приоритет (0 - наивысший)"
)
class Meta:
verbose_name = "Приоритет варианта"
verbose_name_plural = "Приоритеты вариантов"
ordering = ['priority', 'id']
unique_together = ['kit_item', 'product']
def __str__(self):
return f"{self.product.name} (приоритет {self.priority})"