fix: Улучшения системы ценообразования комплектов

Исправлены 4 проблемы:
1. Расчёт цены первого товара - улучшена валидация в getProductPrice и calculateFinalPrice
2. Отображение actual_price в Select2 вместо обычной цены
3. Количество по умолчанию = 1 для новых форм компонентов
4. Auto-select текста при клике на поле количества для удобства редактирования

Изменённые файлы:
- products/forms.py: добавлен __init__ в KitItemForm для quantity.initial = 1
- products/templates/includes/select2-product-init.html: обновлена formatSelectResult
- products/templates/productkit_create.html: добавлен focus handler для auto-select
- products/templates/productkit_edit.html: добавлен focus handler для auto-select

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-02 19:04:03 +03:00
parent c84a372f98
commit 6c8af5ab2c
120 changed files with 9035 additions and 3036 deletions

View File

@@ -0,0 +1,178 @@
"""
Базовые модели для products приложения.
Содержит SKUCounter и BaseProductEntity (абстрактный базовый класс).
"""
from django.db import models, transaction
from django.utils import timezone
from django.contrib.auth import get_user_model
from .managers import ActiveManager, SoftDeleteManager, SoftDeleteQuerySet
# Получаем 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 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() == '':
# Используем централизованный сервис для генерации slug
from ..services.slug_service import SlugService
self.slug = SlugService.generate_unique_slug(
self.name,
self.__class__,
self.pk
)
super().save(*args, **kwargs)