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:
330
myproject/products/models/kits.py
Normal file
330
myproject/products/models/kits.py
Normal 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})"
|
||||
Reference in New Issue
Block a user