Files
octopus/myproject/products/models/kits.py
Andrey Smakotin 4cbc2f23e3 Добавлена автогенерация артикулов вариантов для ConfigurableProduct
Добавлено поле variant_sku в модель ConfigurableProductOption.
Артикул варианта генерируется автоматически в формате VAR-XXXXXX-V1, VAR-XXXXXX-V2 и т.д.
Счетчик не переиспользуется при удалении вариантов для защиты интеграций.
Переименован property variant_sku в variant_base_sku для основного SKU.
Обновлен шаблон с колонкой артикула варианта.
Создана миграция для добавления поля и data migration для существующих записей.
Назначение: дополнительный артикул для интеграций с внешними площадками.
2025-12-30 11:20:02 +03:00

736 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Модели для комплектов (ProductKit) и их компонентов.
Цена комплекта динамически вычисляется из actual_price компонентов.
"""
from decimal import Decimal
from django.db import models
from django.db.models import Q
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="Процент (%) или сумма (руб) в зависимости от типа корректировки"
)
# Временные комплекты
is_temporary = models.BooleanField(
default=False,
verbose_name="Временный комплект",
help_text="Временные комплекты не показываются в каталоге и создаются для конкретного заказа"
)
order = models.ForeignKey(
'orders.Order',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='temporary_kits',
verbose_name="Заказ",
help_text="Заказ, для которого создан временный комплект"
)
showcase = models.ForeignKey(
'inventory.Showcase',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='temporary_kits',
verbose_name="Витрина",
help_text="Витрина, на которой выложен временный комплект"
)
class Meta:
verbose_name = "Комплект"
verbose_name_plural = "Комплекты"
indexes = [
models.Index(fields=['is_temporary']),
models.Index(fields=['order']),
models.Index(fields=['showcase']),
]
constraints = [
# Уникальное имя для активных комплектов (исключаем архивированные и снятые)
# Примечание: временные комплекты могут иметь дубли имён (создаются для заказов)
models.UniqueConstraint(
fields=['name'],
condition=Q(status='active', is_temporary=False),
name='unique_active_kit_name'
),
]
@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
elif item.variant_group:
actual_price = item.variant_group.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
elif item.variant_group:
actual_price = item.variant_group.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 make_permanent(self):
"""
Преобразует временный комплект в постоянный.
Отвязывает от заказа и делает видимым в каталоге.
Returns:
bool: True если преобразование успешно, False если комплект уже постоянный
"""
if not self.is_temporary:
return False
self.is_temporary = False
self.order = None
self.save(update_fields=['is_temporary', 'order'])
return True
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()
def create_snapshot(self):
"""
Создает снимок текущего состояния комплекта.
Используется при добавлении комплекта в заказ для сохранения истории.
Returns:
KitSnapshot: Созданный снимок с компонентами
"""
from orders.models import KitSnapshot, KitItemSnapshot
# Создаем снимок комплекта
snapshot = KitSnapshot.objects.create(
original_kit=self,
name=self.name,
sku=self.sku or '',
description=self.description or '',
base_price=self.base_price,
price=self.price,
sale_price=self.sale_price,
price_adjustment_type=self.price_adjustment_type,
price_adjustment_value=self.price_adjustment_value,
is_temporary=self.is_temporary,
)
# Создаем снимки компонентов
for item in self.kit_items.select_related('product', 'variant_group'):
product_price = Decimal('0')
if item.product:
product_price = item.product.actual_price or Decimal('0')
elif item.variant_group:
product_price = item.variant_group.price or Decimal('0')
KitItemSnapshot.objects.create(
kit_snapshot=snapshot,
original_product=item.product, # Сохраняем ссылку для резервирования
product_name=item.product.name if item.product else '',
product_sku=item.product.sku if item.product else '',
product_price=product_price,
variant_group_name=item.variant_group.name if item.variant_group else '',
quantity=item.quantity or Decimal('1'),
)
return snapshot
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="Количество")
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(status='active'))
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})"
class ConfigurableProduct(BaseProductEntity):
"""
Вариативный товар, объединяющий несколько ProductKit или Product
как варианты для внешних площадок (WooCommerce и подобные).
Примеры использования:
- Роза Фридом с вариантами длины стебля (50, 60, 70 см) — варианты это Product
- Букет "Нежность" с вариантами количества роз (15, 25, 51) — варианты это ProductKit
"""
class Meta:
verbose_name = "Вариативный товар"
verbose_name_plural = "Вариативные товары"
# Уникальность активного имени наследуется из BaseProductEntity
def __str__(self):
return self.name
def save(self, *args, **kwargs):
"""При сохранении - генерируем артикул если не задан"""
# Генерация артикула для новых вариативных товаров
if not self.sku:
from ..utils.sku_generator import generate_configurable_sku
self.sku = generate_configurable_sku()
# Вызов родительского save (генерация slug и т.д.)
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""
Физическое удаление вариативного товара из БД.
При удалении удаляются только связи (ConfigurableProductOption),
но сами ProductKit/Product остаются нетронутыми благодаря CASCADE на уровне связей.
"""
# Используем super() для вызова стандартного Django delete, минуя BaseProductEntity.delete()
super(BaseProductEntity, self).delete(*args, **kwargs)
class ConfigurableProductAttribute(models.Model):
"""
Атрибут родительского вариативного товара с привязкой к ProductKit или Product.
Каждое значение атрибута может быть связано с ProductKit или Product.
Например:
- Длина: 50 → Product (Роза 50см)
- Длина: 60 → Product (Роза 60см)
- Количество: 15 роз → ProductKit (Букет 15 роз)
"""
parent = models.ForeignKey(
'ConfigurableProduct',
on_delete=models.CASCADE,
related_name='parent_attributes',
verbose_name="Родительский товар"
)
name = models.CharField(
max_length=150,
verbose_name="Название атрибута",
help_text="Например: Цвет, Размер, Длина"
)
option = models.CharField(
max_length=150,
verbose_name="Значение опции",
help_text="Например: Красный, M, 60см"
)
# Один из двух должен быть заполнен (kit XOR product) или оба пустые
kit = models.ForeignKey(
ProductKit,
on_delete=models.CASCADE,
related_name='as_attribute_value_in',
verbose_name="Комплект для этого значения",
help_text="Какой ProductKit связан с этим значением атрибута",
blank=True,
null=True
)
product = models.ForeignKey(
'Product',
on_delete=models.CASCADE,
related_name='as_attribute_value_in',
verbose_name="Товар для этого значения",
help_text="Какой Product связан с этим значением атрибута",
blank=True,
null=True
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Порядок отображения",
help_text="Меньше = выше в списке"
)
visible = models.BooleanField(
default=True,
verbose_name="Видимый на витрине",
help_text="Показывать ли атрибут на странице товара"
)
class Meta:
verbose_name = "Атрибут вариативного товара"
verbose_name_plural = "Атрибуты вариативных товаров"
ordering = ['parent', 'position', 'name', 'option']
indexes = [
models.Index(fields=['parent', 'name']),
models.Index(fields=['parent', 'position']),
models.Index(fields=['kit']),
models.Index(fields=['product']),
]
def __str__(self):
variant_str = self.kit.name if self.kit else (self.product.name if self.product else "no variant")
return f"{self.parent.name} - {self.name}: {self.option} ({variant_str})"
@property
def variant(self):
"""Возвращает связанный вариант (kit или product)"""
return self.kit or self.product
@property
def variant_type(self):
"""Тип варианта: 'kit', 'product' или None"""
if self.kit:
return 'kit'
elif self.product:
return 'product'
return None
class ConfigurableProductOption(models.Model):
"""
Отдельный вариант внутри ConfigurableProduct, указывающий на ProductKit ИЛИ Product.
Атрибуты варианта хранятся в структурированном JSON формате.
Пример: {"length": "60", "color": "red"}
"""
parent = models.ForeignKey(
'ConfigurableProduct',
on_delete=models.CASCADE,
related_name='options',
verbose_name="Родитель (вариативный товар)"
)
# Один из двух должен быть заполнен (kit XOR product)
kit = models.ForeignKey(
ProductKit,
on_delete=models.CASCADE,
related_name='as_configurable_option_in',
verbose_name="Комплект (вариант)",
blank=True,
null=True
)
product = models.ForeignKey(
'Product',
on_delete=models.CASCADE,
related_name='as_configurable_option_in',
verbose_name="Товар (вариант)",
blank=True,
null=True
)
attributes = models.JSONField(
default=dict,
blank=True,
verbose_name="Атрибуты варианта",
help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}'
)
is_default = models.BooleanField(
default=False,
verbose_name="Вариант по умолчанию"
)
variant_sku = models.CharField(
max_length=50,
blank=True,
verbose_name="Артикул варианта",
help_text="Дополнительный артикул для внешних площадок. Генерируется автоматически."
)
class Meta:
verbose_name = "Вариант товара"
verbose_name_plural = "Варианты товаров"
indexes = [
models.Index(fields=['parent']),
models.Index(fields=['kit']),
models.Index(fields=['product']),
models.Index(fields=['parent', 'is_default']),
models.Index(fields=['variant_sku']),
]
constraints = [
# kit XOR product — один из двух должен быть заполнен
models.CheckConstraint(
check=(
models.Q(kit__isnull=False, product__isnull=True) |
models.Q(kit__isnull=True, product__isnull=False)
),
name='configurable_option_kit_xor_product'
),
]
def __str__(self):
variant_name = self.kit.name if self.kit else (self.product.name if self.product else "N/A")
return f"{self.parent.name}{variant_name}"
def save(self, *args, **kwargs):
"""При создании - генерируем variant_sku если не задан"""
if not self.variant_sku and self.parent_id:
# Генерируем артикул варианта
self.variant_sku = self._generate_variant_sku()
super().save(*args, **kwargs)
def _generate_variant_sku(self):
"""
Генерирует артикул варианта в формате {parent.sku}-V{counter}.
Счетчик не переиспользуется при удалении вариантов (защита интеграций).
"""
import re
# Получаем все варианты родителя с заполненным variant_sku
existing_variants = ConfigurableProductOption.objects.filter(
parent=self.parent,
variant_sku__isnull=False
).exclude(pk=self.pk).values_list('variant_sku', flat=True)
# Извлекаем номера из существующих variant_sku
max_number = 0
for sku in existing_variants:
# Ищем паттерн -V\d+ в конце строки
match = re.search(r'-V(\d+)$', sku)
if match:
number = int(match.group(1))
max_number = max(max_number, number)
# Следующий номер
next_number = max_number + 1
# Формируем артикул
return f"{self.parent.sku}-V{next_number}"
@property
def variant(self):
"""Возвращает связанный вариант (kit или product)"""
return self.kit or self.product
@property
def variant_type(self):
"""Тип варианта: 'kit' или 'product'"""
return 'kit' if self.kit else 'product'
@property
def variant_name(self):
"""Название варианта"""
return self.variant.name if self.variant else None
@property
def variant_base_sku(self):
"""Основной SKU варианта (Product/ProductKit)"""
return self.variant.sku if self.variant else None
@property
def variant_price(self):
"""Цена варианта"""
if self.kit:
return self.kit.actual_price
elif self.product:
return self.product.sale_price or self.product.price
return None
class ConfigurableProductOptionAttribute(models.Model):
"""
Связь между вариантом (ConfigurableProductOption) и
конкретным значением атрибута (ConfigurableProductAttribute).
Вместо хранения текстового поля attributes в ConfigurableProductOption,
мы создаем явные связи между вариантом и выбранными значениями атрибутов.
Пример:
- option: ConfigurableProductOption (вариант "15 роз 60см")
- attribute: ConfigurableProductAttribute (Длина: 60)
"""
option = models.ForeignKey(
'ConfigurableProductOption',
on_delete=models.CASCADE,
related_name='attributes_set',
verbose_name="Вариант"
)
attribute = models.ForeignKey(
'ConfigurableProductAttribute',
on_delete=models.CASCADE,
verbose_name="Значение атрибута"
)
class Meta:
verbose_name = "Атрибут варианта"
verbose_name_plural = "Атрибуты варианта"
# Одна опция не может использовать два разных значения одного атрибута
# Например: нельзя иметь Длина=60 и Длина=70 одновременно
# Уникальность будет проверяться на уровне формы
unique_together = [['option', 'attribute']]
indexes = [
models.Index(fields=['option']),
models.Index(fields=['attribute']),
]
def __str__(self):
return f"{self.option.parent.name}{self.attribute.name}: {self.attribute.option}"