Files
octopus/myproject/products/models.py
Andrey Smakotin d92045c4c4 refactor: Создать базовый класс BaseProductEntity и реструктурировать Product/ProductKit
Основные изменения:

## Модели (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 <noreply@anthropic.com>
2025-10-31 00:49:01 +03:00

1442 lines
62 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.
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from django.contrib.auth import get_user_model
from .utils.sku_generator import generate_product_sku, generate_kit_sku, generate_category_sku
# Получаем User модель один раз для использования в ForeignKey
User = get_user_model()
class SKUCounter(models.Model):
"""
Глобальные счетчики для генерации уникальных номеров артикулов.
Используется для товаров (product), комплектов (kit) и категорий (category).
"""
COUNTER_TYPE_CHOICES = [
('product', 'Product Counter'),
('kit', 'Kit Counter'),
('category', 'Category Counter'),
]
counter_type = models.CharField(
max_length=20,
unique=True,
choices=COUNTER_TYPE_CHOICES,
verbose_name="Тип счетчика"
)
current_value = models.IntegerField(
default=0,
verbose_name="Текущее значение"
)
class Meta:
verbose_name = "Счетчик артикулов"
verbose_name_plural = "Счетчики артикулов"
def __str__(self):
return f"{self.get_counter_type_display()}: {self.current_value}"
@classmethod
def get_next_value(cls, counter_type):
"""
Получить следующее значение счетчика (thread-safe).
Использует select_for_update для предотвращения race conditions.
"""
with transaction.atomic():
counter, created = cls.objects.select_for_update().get_or_create(
counter_type=counter_type,
defaults={'current_value': 0}
)
counter.current_value += 1
counter.save()
return counter.current_value
class ActiveManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
class SoftDeleteQuerySet(models.QuerySet):
"""
QuerySet для мягкого удаления (soft delete).
Позволяет фильтровать удаленные элементы и восстанавливать их.
"""
def delete(self):
"""Soft delete вместо hard delete"""
return self.update(
is_deleted=True,
deleted_at=timezone.now()
)
def hard_delete(self):
"""Явный hard delete - удаляет из БД окончательно"""
return super().delete()
def restore(self):
"""Восстановление из удаленного состояния"""
return self.update(
is_deleted=False,
deleted_at=None,
deleted_by=None
)
def deleted_only(self):
"""Получить только удаленные элементы"""
return self.filter(is_deleted=True)
def not_deleted(self):
"""Получить только не удаленные элементы"""
return self.filter(is_deleted=False)
def with_deleted(self):
"""Получить все элементы включая удаленные"""
return self.all()
class SoftDeleteManager(models.Manager):
"""
Manager для работы с мягким удалением.
По умолчанию исключает удаленные элементы из запросов.
"""
def get_queryset(self):
return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False)
def deleted_only(self):
"""Получить только удаленные элементы"""
return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=True)
def all_with_deleted(self):
"""Получить все элементы включая удаленные"""
return SoftDeleteQuerySet(self.model, using=self._db).all()
class ProductCategory(models.Model):
"""
Категории товаров и комплектов (поддержка нескольких уровней не обязательна, но возможна позже).
"""
name = models.CharField(max_length=200, verbose_name="Название")
sku = models.CharField(max_length=100, blank=True, null=True, unique=True, verbose_name="Артикул", db_index=True)
slug = models.SlugField(max_length=200, unique=True, blank=True, verbose_name="URL-идентификатор")
parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True,
related_name='children', verbose_name="Родительская категория")
is_active = models.BooleanField(default=True, verbose_name="Активна")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True)
# Поля для мягкого удаления
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_categories',
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']),
]
def __str__(self):
return self.name
def clean(self):
"""Валидация категории перед сохранением"""
from django.core.exceptions import ValidationError
# 1. Защита от самоссылки
if self.parent and self.parent.pk == self.pk:
raise ValidationError({
'parent': 'Категория не может быть родителем самой себя.'
})
# 2. Защита от циклических ссылок (только для существующих категорий)
if self.parent and self.pk:
self._check_parent_chain()
# 3. Проверка активности родителя
if self.parent and not self.parent.is_active:
raise ValidationError({
'parent': 'Нельзя выбрать неактивную категорию в качестве родителя.'
})
def _check_parent_chain(self):
"""Проверяет цепочку родителей на циклы и глубину вложенности"""
from django.core.exceptions import ValidationError
from django.conf import settings
current = self.parent
depth = 0
max_depth = getattr(settings, 'MAX_CATEGORY_DEPTH', 10)
while current:
if current.pk == self.pk:
raise ValidationError({
'parent': f'Обнаружена циклическая ссылка. '
f'Категория "{self.name}" не может быть потомком самой себя.'
})
depth += 1
if depth > max_depth:
raise ValidationError({
'parent': f'Слишком глубокая вложенность категорий '
f'(максимум {max_depth} уровней).'
})
current = current.parent
def save(self, *args, **kwargs):
# Вызываем валидацию перед сохранением
self.full_clean()
# Автоматическая генерация slug из названия с транслитерацией
if not self.slug or self.slug.strip() == '':
from unidecode import unidecode
# Транслитерируем кириллицу в латиницу, затем применяем slugify
transliterated_name = unidecode(self.name)
self.slug = slugify(transliterated_name)
# Автоматическая генерация артикула при создании новой категории
if not self.sku and not self.pk:
from .utils.sku_generator import generate_category_sku
self.sku = generate_category_sku()
super().save(*args, **kwargs)
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()
class ProductTag(models.Model):
"""
Свободные теги для фильтрации и поиска.
"""
name = models.CharField(max_length=100, unique=True, verbose_name="Название")
slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True)
# Поля для мягкого удаления
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_tags',
verbose_name="Удален пользователем"
)
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
class Meta:
verbose_name = "Тег товара"
verbose_name_plural = "Теги товаров"
indexes = [
models.Index(fields=['is_deleted']),
models.Index(fields=['is_deleted', 'created_at']),
]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
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 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):
"""
Группа вариантов товара (взаимозаменяемые товары).
Например: "Роза красная Freedom" включает розы 50см, 60см, 70см.
"""
name = models.CharField(max_length=200, verbose_name="Название")
description = models.TextField(blank=True, verbose_name="Описание")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
verbose_name = "Группа вариантов"
verbose_name_plural = "Группы вариантов"
ordering = ['name']
def __str__(self):
return self.name
def get_products_count(self):
"""Возвращает количество товаров в группе"""
return self.items.count()
@property
def in_stock(self):
"""
Вариант в наличии, если хотя бы один из его товаров в наличии.
Товар в наличии, если Product.in_stock = True.
"""
return self.items.filter(product__in_stock=True).exists()
@property
def price(self):
"""
Цена варианта определяется по приоритету товаров:
1. Берётся цена товара с приоритетом 1, если он в наличии
2. Если нет - цена товара с приоритетом 2
3. И так далее по приоритетам
4. Если ни один товар не в наличии - берётся самый дорогой товар из группы
Возвращает Decimal (цену) или None если группа пуста.
"""
items = self.items.all().order_by('priority', 'id')
if not items.exists():
return None
# Ищем первый товар в наличии
for item in items:
if item.product.in_stock:
return item.product.sale_price
# Если ни один товар не в наличии - берем самый дорогой
max_price = None
for item in items:
if max_price is None or item.product.sale_price > max_price:
max_price = item.product.sale_price
return max_price
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 = [
('шт', 'Штука'),
('м', 'Метр'),
('г', 'Грамм'),
('л', 'Литр'),
('кг', 'Килограмм'),
]
# Специфичные поля Product
variant_suffix = models.CharField(
max_length=20,
blank=True,
null=True,
verbose_name="Суффикс варианта",
help_text="Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия."
)
# 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="Теги"
)
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="Себестоимость",
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="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную."
)
class Meta:
verbose_name = "Товар"
verbose_name_plural = "Товары"
indexes = [
models.Index(fields=['in_stock']),
models.Index(fields=['sku']),
]
@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)
if parsed_suffix:
self.variant_suffix = parsed_suffix
# Генерация артикула для новых товаров
if not self.sku:
self.sku = generate_product_sku(self)
# Автоматическая генерация ключевых слов для поиска
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 после сохранения
# (ManyToMany требует, чтобы объект уже существовал в БД)
if self.pk and self.categories.exists():
category_names = ' '.join([cat.name for cat in self.categories.all()])
if category_names and category_names not in self.search_keywords:
self.search_keywords = f"{self.search_keywords} {category_names}".strip()
# Используем update чтобы избежать рекурсии
Product.objects.filter(pk=self.pk).update(search_keywords=self.search_keywords)
def get_variant_groups(self):
"""Возвращает все группы вариантов товара"""
return self.variant_groups.all()
def get_similar_products(self):
"""Возвращает все товары из тех же групп вариантов (исключая себя)"""
return Product.objects.filter(
variant_groups__in=self.variant_groups.all()
).exclude(id=self.id).distinct()
class ProductKit(BaseProductEntity):
"""
Шаблон комплекта / букета (рецепт).
Наследует общие поля из BaseProductEntity.
"""
PRICING_METHOD_CHOICES = [
('manual', 'Ручная цена'),
('from_sale_prices', 'По ценам продажи компонентов'),
('from_cost_plus_percent', 'Себестоимость + процент наценки'),
('from_cost_plus_amount', 'Себестоимость + фикс. наценка'),
]
# 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="Теги"
)
# ЦЕНООБРАЗОВАНИЕ - специфичные поля 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=['pricing_method']),
]
@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 == 'manual' and not self.price:
raise ValidationError({
'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 не используется другим комплектом (если объект уже существует)
if self.pk:
if ProductKit.objects.filter(sku=self.sku).exclude(pk=self.pk).exists():
raise ValidationError({
'sku': f'Артикул "{self.sku}" уже используется другим комплектом.'
})
else:
# Для новых объектов просто проверяем, что SKU не используется
if ProductKit.objects.filter(sku=self.sku).exists():
raise ValidationError({
'sku': f'Артикул "{self.sku}" уже используется другим комплектом.'
})
def save(self, *args, **kwargs):
# Генерация артикула для новых комплектов
if not self.sku:
self.sku = generate_kit_sku()
# Вызов родительского save (генерация slug и т.д.)
super().save(*args, **kwargs)
def get_total_components_count(self):
"""
Возвращает количество компонентов (строк) в комплекте.
Returns:
int: Количество компонентов в комплекте
"""
return self.kit_items.count()
def get_components_with_variants_count(self):
"""
Возвращает количество компонентов, которые используют группы вариантов.
Returns:
int: Количество компонентов с группами вариантов
"""
return self.kit_items.filter(variant_group__isnull=False).count()
def get_sale_price(self):
"""
Возвращает рассчитанную цену продажи комплекта в соответствии с выбранным методом ценообразования.
Returns:
Decimal: Цена продажи комплекта
"""
try:
return self.calculate_price_with_substitutions()
except Exception:
# Если что-то пошло не так, возвращаем ручную цену если есть
if self.pricing_method == 'manual' and self.price:
return self.price
return 0
def check_availability(self, stock_manager=None):
"""
Проверяет доступность всего комплекта.
Комплект доступен, если для каждой позиции в комплекте
есть хотя бы один доступный вариант товара.
Args:
stock_manager: Объект управления складом (если не указан, используется стандартный)
Returns:
bool: True, если комплект полностью доступен, иначе False
"""
from .utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
for kit_item in self.kit_items.all():
best_product = kit_item.get_best_available_product(stock_manager)
if not best_product:
return False
return True
def calculate_price_with_substitutions(self, stock_manager=None):
"""
Расчёт цены комплекта с учётом доступных замен компонентов.
Метод определяет цену комплекта, учитывая доступные товары-заменители
и применяет выбранный метод ценообразования.
Args:
stock_manager: Объект управления складом (если не указан, используется стандартный)
Returns:
Decimal: Расчетная цена комплекта, или 0 в случае ошибки
"""
from decimal import Decimal, InvalidOperation
from .utils.stock_manager import StockManager
if stock_manager is None:
stock_manager = StockManager()
# Если указана ручная цена, используем её
if self.pricing_method == 'manual' and self.price:
return self.price
total_cost = Decimal('0.00')
total_sale = Decimal('0.00')
for kit_item in self.kit_items.select_related('product', 'variant_group'):
try:
best_product = kit_item.get_best_available_product(stock_manager)
if not best_product:
# Если товар недоступен, используем цену первого в списке
available_products = kit_item.get_available_products()
best_product = available_products[0] if available_products else None
if best_product:
item_cost = best_product.cost_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_price and item_quantity:
total_sale += item_price * item_quantity
except (AttributeError, TypeError, InvalidOperation) as e:
# Логируем ошибку, но продолжаем вычисления
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Ошибка при расчёте цены для комплекта {self.name} (item: {kit_item}): {e}")
continue # Пропускаем ошибочный элемент и продолжаем с остальными
# Применяем метод ценообразования
try:
if self.pricing_method == 'from_sale_prices':
return total_sale
elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent is not None:
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 == '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 == 'manual' and self.price:
return self.price
return Decimal('0.00')
def calculate_cost(self):
"""
Расчёт себестоимости комплекта на основе себестоимости компонентов.
Returns:
Decimal: Себестоимость комплекта
"""
from decimal import Decimal
total_cost = Decimal('0.00')
for kit_item in self.kit_items.select_related('product', 'variant_group'):
# Получаем продукт - либо конкретный, либо первый из группы вариантов
product = kit_item.product
if not product and kit_item.variant_group:
# Берем первый продукт из группы вариантов
product = kit_item.variant_group.products.filter(is_active=True).first()
if product:
item_cost = product.cost_price
item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано
total_cost += item_cost * item_quantity
return total_cost
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()
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):
"""
Возвращает строку для отображения названия компонента.
Returns:
str: Название компонента (либо группа вариантов, либо конкретный товар)
"""
if self.variant_group:
return f"[Варианты] {self.variant_group.name}"
return self.product.name if self.product else "Не указан"
def has_priorities_set(self):
"""
Проверяет, настроены ли приоритеты замены для данного компонента.
Returns:
bool: True, если приоритеты установлены, иначе False
"""
return self.priorities.exists()
def get_available_products(self):
"""
Возвращает список доступных товаров для этого компонента.
Если указан конкретный товар - возвращает его.
Если указаны приоритеты - возвращает товары в порядке приоритета.
Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
Returns:
list: Список доступных товаров
"""
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):
"""
Возвращает первый доступный товар по приоритету, соответствующий требованиям по количеству.
Args:
stock_manager: Объект управления складом (если не указан, используется стандартный)
Returns:
Product or None: Первый доступный товар или 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 ProductPhoto(models.Model):
"""
Модель для хранения фото товара (один товар может иметь несколько фото).
Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large.
"""
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='photos',
verbose_name="Товар")
image = models.ImageField(upload_to='products/temp/', verbose_name="Оригинальное фото")
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
verbose_name = "Фото товара"
verbose_name_plural = "Фото товаров"
ordering = ['order', '-created_at']
def __str__(self):
return f"Фото для {self.product.name}"
def save(self, *args, **kwargs):
"""
При загрузке нового изображения обрабатывает его и создает все необходимые размеры.
"""
from .utils.image_processor import ImageProcessor
is_new = not self.pk
# Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID
if is_new and self.image:
# Сохраняем объект без изображения, чтобы получить ID
temp_image = self.image
self.image = None
super().save(*args, **kwargs)
# Теперь обрабатываем изображение с известными ID
processed_paths = ImageProcessor.process_image(temp_image, 'products', entity_id=self.product.id, photo_id=self.id)
self.image = processed_paths['original']
# Обновляем только поле image, чтобы избежать рекурсии и дублирования ID
super().save(update_fields=['image'])
else:
# Проверяем старый путь для удаления, если это обновление
old_image_path = None
if self.pk:
try:
old_obj = ProductPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductPhoto.DoesNotExist:
pass
# Проверяем, нужно ли обрабатывать изображение
if self.image and old_image_path:
# Обновление существующего изображения
processed_paths = ImageProcessor.process_image(self.image, 'products', entity_id=self.product.id, photo_id=self.id)
self.image = processed_paths['original']
# Удаляем старые версии
ImageProcessor.delete_all_versions('products', old_image_path, entity_id=self.product.id, photo_id=self.id)
# Обновляем только поле image, чтобы избежать рекурсии
super().save(update_fields=['image'])
else:
# Просто сохраняем без обработки изображения
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Удаляет все версии изображения при удалении фото"""
import logging
from .utils.image_processor import ImageProcessor
logger = logging.getLogger(__name__)
if self.image:
try:
logger.info(f"[ProductPhoto.delete] Удаляем изображение: {self.image.name}")
ImageProcessor.delete_all_versions('products', self.image.name, entity_id=self.product.id, photo_id=self.id)
logger.info(f"[ProductPhoto.delete] ✓ Все версии изображения удалены")
except Exception as e:
logger.error(f"[ProductPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)
super().delete(*args, **kwargs)
def get_thumbnail_url(self):
"""Получить URL миниатюры (150x150)"""
from .utils.image_service import ImageService
return ImageService.get_thumbnail_url(self.image.name)
def get_medium_url(self):
"""Получить URL среднего размера (400x400)"""
from .utils.image_service import ImageService
return ImageService.get_medium_url(self.image.name)
def get_large_url(self):
"""Получить URL большого размера (800x800)"""
from .utils.image_service import ImageService
return ImageService.get_large_url(self.image.name)
def get_original_url(self):
"""Получить URL оригинального изображения"""
from .utils.image_service import ImageService
return ImageService.get_original_url(self.image.name)
class ProductKitPhoto(models.Model):
"""
Модель для хранения фото комплекта (один комплект может иметь несколько фото).
Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large.
"""
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='photos',
verbose_name="Комплект")
image = models.ImageField(upload_to='kits/temp/', verbose_name="Оригинальное фото")
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
verbose_name = "Фото комплекта"
verbose_name_plural = "Фото комплектов"
ordering = ['order', '-created_at']
def __str__(self):
return f"Фото для {self.kit.name}"
def save(self, *args, **kwargs):
"""
При загрузке нового изображения обрабатывает его и создает все необходимые размеры.
"""
from .utils.image_processor import ImageProcessor
is_new = not self.pk
# Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID
if is_new and self.image:
# Сохраняем объект без изображения, чтобы получить ID
temp_image = self.image
self.image = None
super().save(*args, **kwargs)
# Теперь обрабатываем изображение с известными ID
processed_paths = ImageProcessor.process_image(temp_image, 'kits', entity_id=self.kit.id, photo_id=self.id)
self.image = processed_paths['original']
# Обновляем только поле image, чтобы избежать рекурсии и дублирования ID
super().save(update_fields=['image'])
else:
# Проверяем старый путь для удаления, если это обновление
old_image_path = None
if self.pk:
try:
old_obj = ProductKitPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductKitPhoto.DoesNotExist:
pass
# Проверяем, нужно ли обрабатывать изображение
if self.image and old_image_path:
# Обновление существующего изображения
processed_paths = ImageProcessor.process_image(self.image, 'kits', entity_id=self.kit.id, photo_id=self.id)
self.image = processed_paths['original']
# Удаляем старые версии
ImageProcessor.delete_all_versions('kits', old_image_path, entity_id=self.kit.id, photo_id=self.id)
# Обновляем только поле image, чтобы избежать рекурсии
super().save(update_fields=['image'])
else:
# Просто сохраняем без обработки изображения
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Удаляет все версии изображения при удалении фото"""
import logging
from .utils.image_processor import ImageProcessor
logger = logging.getLogger(__name__)
if self.image:
try:
logger.info(f"[ProductKitPhoto.delete] Удаляем изображение: {self.image.name}")
ImageProcessor.delete_all_versions('kits', self.image.name, entity_id=self.kit.id, photo_id=self.id)
logger.info(f"[ProductKitPhoto.delete] ✓ Все версии изображения удалены")
except Exception as e:
logger.error(f"[ProductKitPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)
super().delete(*args, **kwargs)
def get_thumbnail_url(self):
"""Получить URL миниатюры (150x150)"""
from .utils.image_service import ImageService
return ImageService.get_thumbnail_url(self.image.name)
def get_medium_url(self):
"""Получить URL среднего размера (400x400)"""
from .utils.image_service import ImageService
return ImageService.get_medium_url(self.image.name)
def get_large_url(self):
"""Получить URL большого размера (800x800)"""
from .utils.image_service import ImageService
return ImageService.get_large_url(self.image.name)
def get_original_url(self):
"""Получить URL оригинального изображения"""
from .utils.image_service import ImageService
return ImageService.get_original_url(self.image.name)
class ProductCategoryPhoto(models.Model):
"""
Модель для хранения фото категории (одна категория может иметь несколько фото).
Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large.
"""
category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, related_name='photos',
verbose_name="Категория")
image = models.ImageField(upload_to='categories/temp/', verbose_name="Оригинальное фото")
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
verbose_name = "Фото категории"
verbose_name_plural = "Фото категорий"
ordering = ['order', '-created_at']
def __str__(self):
return f"Фото для {self.category.name}"
def save(self, *args, **kwargs):
"""
При загрузке нового изображения обрабатывает его и создает все необходимые размеры.
"""
from .utils.image_processor import ImageProcessor
is_new = not self.pk
# Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID
if is_new and self.image:
# Сохраняем объект без изображения, чтобы получить ID
temp_image = self.image
self.image = None
super().save(*args, **kwargs)
# Теперь обрабатываем изображение с известными ID
processed_paths = ImageProcessor.process_image(temp_image, 'categories', entity_id=self.category.id, photo_id=self.id)
self.image = processed_paths['original']
# Обновляем только поле image, чтобы избежать рекурсии и дублирования ID
super().save(update_fields=['image'])
else:
# Проверяем старый путь для удаления, если это обновление
old_image_path = None
if self.pk:
try:
old_obj = ProductCategoryPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductCategoryPhoto.DoesNotExist:
pass
# Проверяем, нужно ли обрабатывать изображение
if self.image and old_image_path:
# Обновление существующего изображения
processed_paths = ImageProcessor.process_image(self.image, 'categories', entity_id=self.category.id, photo_id=self.id)
self.image = processed_paths['original']
# Удаляем старые версии
ImageProcessor.delete_all_versions('categories', old_image_path, entity_id=self.category.id, photo_id=self.id)
# Обновляем только поле image, чтобы избежать рекурсии
super().save(update_fields=['image'])
else:
# Просто сохраняем без обработки изображения
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Удаляет все версии изображения при удалении фото"""
import logging
from .utils.image_processor import ImageProcessor
logger = logging.getLogger(__name__)
if self.image:
try:
logger.info(f"[ProductCategoryPhoto.delete] Удаляем изображение: {self.image.name}")
ImageProcessor.delete_all_versions('categories', self.image.name, entity_id=self.category.id, photo_id=self.id)
logger.info(f"[ProductCategoryPhoto.delete] ✓ Все версии изображения удалены")
except Exception as e:
logger.error(f"[ProductCategoryPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)
super().delete(*args, **kwargs)
def get_thumbnail_url(self):
"""Получить URL миниатюры (150x150)"""
from .utils.image_service import ImageService
return ImageService.get_thumbnail_url(self.image.name)
def get_medium_url(self):
"""Получить URL среднего размера (400x400)"""
from .utils.image_service import ImageService
return ImageService.get_medium_url(self.image.name)
def get_large_url(self):
"""Получить URL большого размера (800x800)"""
from .utils.image_service import ImageService
return ImageService.get_large_url(self.image.name)
def get_original_url(self):
"""Получить URL оригинального изображения"""
from .utils.image_service import ImageService
return ImageService.get_original_url(self.image.name)