Files
octopus/myproject/products/models/products.py
Andrey Smakotin 079bd23829 feat: Добавить трёхуровневую защиту от дубликатов имён товаров, категорий, тегов и комплектов
Реализована полная система обеспечения уникальности названий:

1. **Уровень БД (Model Constraints)** - добавлены UniqueConstraint для:
   - Product: уникальность имени среди активных товаров
   - ProductCategory: уникальность имени среди активных категорий
   - ProductTag: уникальность имени только для активных тегов (неактивные могут повторяться)
   - ProductKit: уникальность имени среди активных, непроизвременных комплектов

2. **Уровень формы (Form Validation)** - добавлены clean() методы для:
   - ProductForm, ProductKitForm, ProductCategoryForm, ProductTagForm
   - Валидация до попытки сохранения в БД
   - Сохранение введённых данных при ошибке валидации

3. **Уровень представления (IntegrityError Handling)** - добавлена обработка в views:
   - ProductCategoryCreateView, ProductCategoryUpdateView
   - ProductTagCreateView, ProductTagUpdateView
   - ProductKitCreateView, ProductKitUpdateView
   - create_tag_api: защита от race conditions с fallback поиском

Три уровня защиты гарантируют:
- Профилактика ошибок на уровне формы
- Обработка исключительных ситуаций в views
- Защита БД от одновременных запросов (race conditions)
- Пользователь видит понятное сообщение об ошибке вместо 500 ошибки

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 13:49:52 +03:00

159 lines
6.2 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.
"""
Модель Product - базовый товар (цветок, упаковка, аксессуар).
"""
from django.db import models
from django.db.models import Q
from .base import BaseProductEntity
from .categories import ProductCategory, ProductTag
from .variants import ProductVariantGroup
from ..services.product_service import ProductSaveService
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']),
]
constraints = [
# Уникальное имя для активных товаров (исключаем удалённые)
models.UniqueConstraint(
fields=['name'],
condition=Q(is_deleted=False),
name='unique_active_product_name'
),
]
@property
def actual_price(self):
"""
Финальная цена для продажи.
Если есть sale_price (скидка) - возвращает его, иначе - основную цену.
"""
return self.sale_price if self.sale_price else self.price
@property
def cost_price_details(self):
"""
Детали расчета себестоимости для отображения в UI.
Показывает разбивку по партиям и сравнение кешированной/рассчитанной стоимости.
Returns:
dict: {
'cached_cost': Decimal, # Кешированная себестоимость (из БД)
'calculated_cost': Decimal, # Рассчитанная себестоимость (из партий)
'is_synced': bool, # Совпадают ли значения
'total_quantity': Decimal, # Общее количество в партиях
'batches': [...] # Список партий с деталями
}
"""
from ..services.cost_calculator import ProductCostCalculator
return ProductCostCalculator.get_cost_details(self)
def save(self, *args, **kwargs):
# Используем сервис для подготовки к сохранению
ProductSaveService.prepare_product_for_save(self)
# Вызов родительского save (генерация slug и т.д.)
super().save(*args, **kwargs)
# Обновление поисковых слов с категориями (после сохранения)
ProductSaveService.update_search_keywords_with_categories(self)
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()