- Добавлено поле KitItem.unit_price для хранения зафиксированной цены - Витринные комплекты больше не обновляются при изменении цен товаров - Добавлен красный индикатор на карточке если цена неактуальна - Добавлен warning в модалке редактирования с кнопкой "Актуализировать" Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
817 lines
34 KiB
Python
817 lines
34 KiB
Python
"""
|
||
Модели для комплектов (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
|
||
|
||
|
||
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
|
||
|
||
@property
|
||
def main_photo(self):
|
||
"""
|
||
Главное фото комплекта (is_main=True).
|
||
Используется в карточках, каталоге, превью.
|
||
|
||
Returns:
|
||
ProductKitPhoto | None: Главное фото или None если фото нет
|
||
"""
|
||
return self.photos.filter(is_main=True).first()
|
||
|
||
def recalculate_base_price(self):
|
||
"""
|
||
Пересчитать сумму actual_price всех компонентов.
|
||
Вызывается автоматически при изменении цены товара (через signal).
|
||
"""
|
||
if not self.pk:
|
||
return # Новый объект еще не сохранен
|
||
|
||
total = Decimal('0')
|
||
for item in self.kit_items.all():
|
||
qty = item.quantity or Decimal('1')
|
||
if item.product:
|
||
# Используем зафиксированную цену если есть, иначе актуальную цену товара
|
||
if item.unit_price is not None:
|
||
unit_price = item.unit_price
|
||
else:
|
||
unit_price = item.product.actual_price or Decimal('0')
|
||
total += unit_price * qty
|
||
elif item.variant_group:
|
||
# Для variant_group unit_price не используется (только для продуктов)
|
||
actual_price = item.variant_group.price or Decimal('0')
|
||
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):
|
||
"""
|
||
Проверяет доступность всего комплекта (возвращает True/False).
|
||
Для обратной совместимости. Использует calculate_available_quantity().
|
||
"""
|
||
return self.calculate_available_quantity() > 0
|
||
|
||
def calculate_available_quantity(self, warehouse=None):
|
||
"""
|
||
Рассчитывает максимальное количество комплектов, которое можно собрать
|
||
на основе свободных остатков компонентов на складе.
|
||
|
||
Args:
|
||
warehouse: Склад для проверки остатков. Если None, суммируются остатки по всем складам.
|
||
|
||
Returns:
|
||
Decimal: Максимальное количество комплектов (0 если хоть один компонент недоступен)
|
||
"""
|
||
from inventory.models import Stock
|
||
|
||
if not self.kit_items.exists():
|
||
return Decimal('0')
|
||
|
||
min_available = None
|
||
|
||
for kit_item in self.kit_items.select_related('product', 'variant_group'):
|
||
# Определяем товар для проверки
|
||
product = None
|
||
if kit_item.product:
|
||
product = kit_item.product
|
||
elif kit_item.variant_group:
|
||
# Берём первый активный товар из группы вариантов
|
||
available_products = kit_item.get_available_products()
|
||
product = available_products[0] if available_products else None
|
||
|
||
if not product:
|
||
# Если товар не найден - комплект недоступен
|
||
return Decimal('0')
|
||
|
||
# Получаем остатки на складе
|
||
stock_filter = {'product': product}
|
||
if warehouse:
|
||
stock_filter['warehouse'] = warehouse
|
||
|
||
stocks = Stock.objects.filter(**stock_filter)
|
||
|
||
# Суммируем свободное количество (available - reserved)
|
||
total_free = Decimal('0')
|
||
for stock in stocks:
|
||
free_qty = stock.quantity_available - stock.quantity_reserved
|
||
total_free += free_qty
|
||
|
||
# Вычисляем сколько комплектов можно собрать из этого компонента
|
||
component_quantity = kit_item.quantity or Decimal('1')
|
||
if component_quantity <= 0:
|
||
return Decimal('0')
|
||
|
||
kits_from_this_component = total_free / component_quantity
|
||
|
||
# Ищем минимум (узкое место)
|
||
if min_available is None or kits_from_this_component < min_available:
|
||
min_available = kits_from_this_component
|
||
|
||
# Возвращаем целую часть (нельзя собрать половину комплекта)
|
||
return Decimal(int(min_available)) if min_available is not None else Decimal('0')
|
||
|
||
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="Количество")
|
||
unit_price = 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=['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}"
|