Files
octopus/myproject/products/models.py
Andrey Smakotin 2341cf57c1 feat: Реализовать систему наличия товаров и цены вариантов
Добавлена система управления наличием товаров на трёх уровнях:

1. Product.in_stock (поле БД)
   - Булево значение: есть/нет в наличии
   - Автоматически обновляется при изменении Stock
   - Используется для быстрого поиска и фильтрации товаров

2. Сигналы для синхронизации (inventory/signals.py)
   - При изменении Stock → обновляется Product.in_stock
   - Логика: товар в наличии если есть Stock с quantity_available > 0

3. ProductVariantGroup.in_stock (свойство)
   - Вариант в наличии если хотя бы один из товаров в наличии
   - Динамически рассчитывается по Product.in_stock товаров в группе

4. ProductVariantGroup.price (свойство)
   - Цена по приоритету: берём цену товара с приоритетом 1, если он в наличии
   - Если никто не в наличии: берём максимальную цену из всех товаров
   - Возвращает Decimal или None если группа пуста

Файлы:
- myproject/products/models.py: добавлено поле in_stock и свойства в ProductVariantGroup
- myproject/inventory/signals.py: добавлены сигналы для синхронизации
- myproject/products/migrations/0003_add_product_in_stock.py: миграция для поля in_stock
- VARIANT_STOCK_IMPLEMENTATION.md: полная документация архитектуры
- QUICK_REFERENCE.md: быстрая справка по использованию

Особенности:
✓ Система простая и элегантная (без костылей)
✓ Обратная совместимость не требуется
✓ Высокая производительность (индексирование, минимум JOIN'ов)
✓ Актуальные данные (сигналы гарантируют синхронизацию)
✓ Легко расширяемая (свойства можно менять без миграций)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 23:22:01 +03:00

1292 lines
61 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 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 Product(models.Model):
"""
Базовый товар (цветок, упаковка, аксессуар).
"""
UNIT_CHOICES = [
('шт', 'Штука'),
('м', 'Метр'),
('г', 'Грамм'),
('л', 'Литр'),
('кг', 'Килограмм'),
]
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-идентификатор")
variant_suffix = models.CharField(
max_length=20,
blank=True,
null=True,
verbose_name="Суффикс варианта",
help_text="Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия."
)
description = models.TextField(blank=True, null=True, verbose_name="Описание")
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="Себестоимость")
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Розничная цена")
is_active = models.BooleanField(default=True, verbose_name="Активен")
in_stock = models.BooleanField(default=False, verbose_name="В наличии", db_index=True,
help_text="Автоматически обновляется при изменении остатков на складе")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
# Поле для улучшенного поиска (задел на будущее)
search_keywords = models.TextField(
blank=True,
verbose_name="Ключевые слова для поиска",
help_text="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную."
)
# Поля для мягкого удаления
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_products',
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']),
models.Index(fields=['in_stock']),
]
def __str__(self):
return self.name
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)
# Автоматическая генерация slug из названия с транслитерацией
if not self.slug or self.slug.strip() == '':
from unidecode import unidecode
# Транслитерируем кириллицу в латиницу, затем применяем slugify
transliterated_name = unidecode(self.name)
self.slug = slugify(transliterated_name)
# Убеждаемся, что slug уникален
original_slug = self.slug
counter = 1
while Product.objects.filter(slug=self.slug).exclude(pk=self.pk).exists():
self.slug = f"{original_slug}-{counter}"
counter += 1
# Автоматическая генерация ключевых слов для поиска
# Собираем все релевантные данные в одну строку
keywords_parts = [
self.name or '',
self.sku or '',
self.description or '',
]
# Генерируем строку для поиска (только если поле пустое)
# Это позволит администратору добавлять кастомные ключевые слова вручную
if not self.search_keywords:
self.search_keywords = ' '.join(filter(None, keywords_parts))
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 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()
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(models.Model):
"""
Шаблон комплекта / букета (рецепт).
"""
PRICING_METHOD_CHOICES = [
('fixed', 'Фиксированная цена'),
('from_sale_prices', 'По ценам продажи компонентов'),
('from_cost_plus_percent', 'Себестоимость + процент наценки'),
('from_cost_plus_amount', 'Себестоимость + фикс. наценка'),
]
name = models.CharField(max_length=200, verbose_name="Название")
sku = models.CharField(max_length=100, blank=True, null=True, verbose_name="Артикул")
slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-идентификатор")
description = models.TextField(blank=True, null=True, verbose_name="Описание")
categories = models.ManyToManyField(
ProductCategory,
blank=True,
related_name='kits',
verbose_name="Категории"
)
tags = models.ManyToManyField(ProductTag, blank=True, related_name='kits', verbose_name="Теги")
is_active = models.BooleanField(default=True, verbose_name="Активен")
pricing_method = models.CharField(max_length=30, choices=PRICING_METHOD_CHOICES,
default='from_sale_prices', verbose_name="Метод ценообразования")
fixed_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True,
verbose_name="Фиксированная цена")
markup_percent = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True,
verbose_name="Процент наценки")
markup_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True,
verbose_name="Фиксированная наценка")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
# Поля для мягкого удаления
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_kits',
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=['slug']),
models.Index(fields=['is_deleted']),
models.Index(fields=['is_deleted', 'created_at']),
]
def __str__(self):
return self.name
def clean(self):
"""Валидация комплекта перед сохранением"""
# Проверка соответствия метода ценообразования полям
if self.pricing_method == 'fixed' and not self.fixed_price:
raise ValidationError({
'fixed_price': 'Для метода ценообразования "fixed" необходимо указать фиксированную цену.'
})
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.slug:
from unidecode import unidecode
# Транслитерируем кириллицу в латиницу, затем применяем slugify
transliterated_name = unidecode(self.name)
self.slug = slugify(transliterated_name)
# Убеждаемся, что slug уникален
original_slug = self.slug
counter = 1
while ProductKit.objects.filter(slug=self.slug).exclude(pk=self.pk).exists():
self.slug = f"{original_slug}-{counter}"
counter += 1
if not self.sku:
self.sku = generate_kit_sku()
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 == 'fixed' and self.fixed_price:
return self.fixed_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 == 'fixed' and self.fixed_price:
return self.fixed_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_sale = best_product.sale_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_sale and item_quantity:
total_sale += item_sale * 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 == 'fixed' and self.fixed_price:
return self.fixed_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 == 'fixed' and self.fixed_price:
return self.fixed_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)
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})"