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

@@ -1,4 +1,4 @@
# Generated by Django 5.1.4 on 2025-10-28 22:47
# Generated by Django 5.0.10 on 2025-10-30 21:24
import django.db.models.deletion
from django.conf import settings
@@ -68,18 +68,21 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=200, verbose_name='Название')),
('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')),
('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')),
('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')),
('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Себестоимость')),
('sale_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Розничная цена')),
('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')),
('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='Дата обновления')),
('search_keywords', models.TextField(blank=True, help_text='Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную.', verbose_name='Ключевые слова для поиска')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удален')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Время удаления')),
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_products', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')),
('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')),
('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')),
('cost_price', models.DecimalField(decimal_places=2, help_text='В будущем будет вычисляться автоматически из партий (FIFO)', max_digits=10, verbose_name='Себестоимость')),
('price', models.DecimalField(decimal_places=2, help_text='Цена продажи товара (бывшее поле sale_price)', max_digits=10, verbose_name='Основная цена')),
('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, товар продается по этой цене (дешевле основной)', max_digits=10, null=True, verbose_name='Цена со скидкой')),
('in_stock', models.BooleanField(db_index=True, default=False, help_text='Автоматически обновляется при изменении остатков на складе', verbose_name='В наличии')),
('search_keywords', models.TextField(blank=True, help_text='Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную.', verbose_name='Ключевые слова для поиска')),
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')),
('categories', models.ManyToManyField(blank=True, related_name='products', to='products.productcategory', verbose_name='Категории')),
],
options={
@@ -107,20 +110,23 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('sku', models.CharField(blank=True, max_length=100, null=True, verbose_name='Артикул')),
('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL-идентификатор')),
('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')),
('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')),
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
('pricing_method', models.CharField(choices=[('fixed', 'Фиксированная цена'), ('from_sale_prices', 'По ценам продажи компонентов'), ('from_cost_plus_percent', 'Себестоимость + процент наценки'), ('from_cost_plus_amount', 'Себестоимость + фикс. наценка')], default='from_sale_prices', max_length=30, verbose_name='Метод ценообразования')),
('fixed_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Фиксированная цена')),
('markup_percent', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='Процент наценки')),
('markup_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=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(db_index=True, default=False, verbose_name='Удален')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Время удаления')),
('pricing_method', models.CharField(choices=[('manual', 'Ручная цена'), ('from_sale_prices', 'По ценам продажи компонентов'), ('from_cost_plus_percent', 'Себестоимость + процент наценки'), ('from_cost_plus_amount', 'Себестоимость + фикс. наценка')], default='from_sale_prices', max_length=30, verbose_name='Метод ценообразования')),
('cost_price', models.DecimalField(blank=True, decimal_places=2, help_text='Можно задать вручную или вычислить из компонентов', max_digits=10, null=True, verbose_name='Себестоимость')),
('price', models.DecimalField(blank=True, decimal_places=2, help_text="Цена при методе 'Ручная цена' (бывшее поле fixed_price)", max_digits=10, null=True, verbose_name='Ручная цена')),
('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, комплект продается по этой цене', max_digits=10, null=True, verbose_name='Цена со скидкой')),
('markup_percent', models.DecimalField(blank=True, decimal_places=2, help_text="Для метода 'Себестоимость + процент наценки'", max_digits=5, null=True, verbose_name='Процент наценки')),
('markup_amount', models.DecimalField(blank=True, decimal_places=2, help_text="Для метода 'Себестоимость + фиксированная наценка'", max_digits=10, null=True, verbose_name='Фиксированная наценка')),
('categories', models.ManyToManyField(blank=True, related_name='kits', to='products.productcategory', verbose_name='Категории')),
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_kits', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')),
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')),
],
options={
'verbose_name': 'Комплект',
@@ -204,6 +210,20 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Компоненты комплектов',
},
),
migrations.CreateModel(
name='ProductVariantGroupItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('priority', models.PositiveIntegerField(default=0, help_text='Меньше = выше приоритет (1 - наивысший приоритет в этой группе)')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variant_group_items', to='products.product', verbose_name='Товар')),
('variant_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.productvariantgroup', verbose_name='Группа вариантов')),
],
options={
'verbose_name': 'Товар в группе вариантов',
'verbose_name_plural': 'Товары в группах вариантов',
'ordering': ['priority', 'id'],
},
),
migrations.CreateModel(
name='KitItemPriority',
fields=[
@@ -241,31 +261,15 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name='productkit',
index=models.Index(fields=['is_active'], name='products_pr_is_acti_214d4f_idx'),
),
migrations.AddIndex(
model_name='productkit',
index=models.Index(fields=['slug'], name='products_pr_slug_b5e185_idx'),
),
migrations.AddIndex(
model_name='productkit',
index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_e83a83_idx'),
),
migrations.AddIndex(
model_name='productkit',
index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_1e5bec_idx'),
index=models.Index(fields=['pricing_method'], name='products_pr_pricing_8bb5a7_idx'),
),
migrations.AddIndex(
model_name='product',
index=models.Index(fields=['is_active'], name='products_pr_is_acti_ca4d9a_idx'),
index=models.Index(fields=['in_stock'], name='products_pr_in_stoc_4fee1a_idx'),
),
migrations.AddIndex(
model_name='product',
index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_3bba04_idx'),
),
migrations.AddIndex(
model_name='product',
index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_f30efb_idx'),
index=models.Index(fields=['sku'], name='products_pr_sku_ca0cdc_idx'),
),
migrations.AddIndex(
model_name='kititem',
@@ -287,4 +291,16 @@ class Migration(migrations.Migration):
model_name='kititem',
index=models.Index(fields=['kit', 'variant_group'], name='products_ki_kit_id_8199a8_idx'),
),
migrations.AddIndex(
model_name='productvariantgroupitem',
index=models.Index(fields=['variant_group', 'priority'], name='products_pr_variant_b36b47_idx'),
),
migrations.AddIndex(
model_name='productvariantgroupitem',
index=models.Index(fields=['product'], name='products_pr_product_50be04_idx'),
),
migrations.AlterUniqueTogether(
name='productvariantgroupitem',
unique_together={('variant_group', 'product')},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.10 on 2025-11-01 17:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='product',
name='cost_price',
field=models.DecimalField(decimal_places=2, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, verbose_name='Себестоимость'),
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 5.0.10 on 2025-10-29 20:14
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0002_productvariantgroupitem'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='product',
name='in_stock',
field=models.BooleanField(db_index=True, default=False, help_text='Автоматически обновляется при изменении остатков на складе', verbose_name='В наличии'),
),
migrations.AddIndex(
model_name='product',
index=models.Index(fields=['in_stock'], name='products_pr_in_stoc_4fee1a_idx'),
),
]

View File

@@ -0,0 +1,79 @@
# Generated by Django 5.0.10 on 2025-11-02 11:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0002_alter_product_cost_price'),
]
operations = [
migrations.AddField(
model_name='productcategoryphoto',
name='quality_level',
field=models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества'),
),
migrations.AddField(
model_name='productcategoryphoto',
name='quality_warning',
field=models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт', verbose_name='Требует обновления'),
),
migrations.AddField(
model_name='productkitphoto',
name='quality_level',
field=models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества'),
),
migrations.AddField(
model_name='productkitphoto',
name='quality_warning',
field=models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт', verbose_name='Требует обновления'),
),
migrations.AddField(
model_name='productphoto',
name='quality_level',
field=models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества'),
),
migrations.AddField(
model_name='productphoto',
name='quality_warning',
field=models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт (poor или very_poor)', verbose_name='Требует обновления'),
),
migrations.AddIndex(
model_name='productcategoryphoto',
index=models.Index(fields=['quality_level'], name='products_pr_quality_ab44c2_idx'),
),
migrations.AddIndex(
model_name='productcategoryphoto',
index=models.Index(fields=['quality_warning'], name='products_pr_quality_d7c69b_idx'),
),
migrations.AddIndex(
model_name='productcategoryphoto',
index=models.Index(fields=['quality_warning', 'category'], name='products_pr_quality_fc505a_idx'),
),
migrations.AddIndex(
model_name='productkitphoto',
index=models.Index(fields=['quality_level'], name='products_pr_quality_b03c5c_idx'),
),
migrations.AddIndex(
model_name='productkitphoto',
index=models.Index(fields=['quality_warning'], name='products_pr_quality_2aa941_idx'),
),
migrations.AddIndex(
model_name='productkitphoto',
index=models.Index(fields=['quality_warning', 'kit'], name='products_pr_quality_867664_idx'),
),
migrations.AddIndex(
model_name='productphoto',
index=models.Index(fields=['quality_level'], name='products_pr_quality_d8f85c_idx'),
),
migrations.AddIndex(
model_name='productphoto',
index=models.Index(fields=['quality_warning'], name='products_pr_quality_defb5a_idx'),
),
migrations.AddIndex(
model_name='productphoto',
index=models.Index(fields=['quality_warning', 'product'], name='products_pr_quality_6e8b51_idx'),
),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.0.10 on 2025-11-02 15:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0003_productcategoryphoto_quality_level_and_more'),
]
operations = [
migrations.RemoveIndex(
model_name='productkit',
name='products_pr_pricing_8bb5a7_idx',
),
migrations.RemoveField(
model_name='productkit',
name='cost_price',
),
migrations.RemoveField(
model_name='productkit',
name='markup_amount',
),
migrations.RemoveField(
model_name='productkit',
name='markup_percent',
),
migrations.RemoveField(
model_name='productkit',
name='pricing_method',
),
migrations.AddField(
model_name='productkit',
name='base_price',
field=models.DecimalField(decimal_places=2, default=0, help_text='Сумма actual_price всех компонентов. Пересчитывается автоматически.', max_digits=10, verbose_name='Базовая цена'),
),
migrations.AddField(
model_name='productkit',
name='price_adjustment_type',
field=models.CharField(choices=[('none', 'Без изменения'), ('increase_percent', 'Увеличить на %'), ('increase_amount', 'Увеличить на сумму'), ('decrease_percent', 'Уменьшить на %'), ('decrease_amount', 'Уменьшить на сумму')], default='none', max_length=20, verbose_name='Тип корректировки цены'),
),
migrations.AddField(
model_name='productkit',
name='price_adjustment_value',
field=models.DecimalField(decimal_places=2, default=0, help_text='Процент (%) или сумма (руб) в зависимости от типа корректировки', max_digits=10, verbose_name='Значение корректировки'),
),
migrations.AlterField(
model_name='productkit',
name='price',
field=models.DecimalField(decimal_places=2, default=0, help_text='Базовая цена с учетом корректировок. Вычисляется автоматически.', max_digits=10, verbose_name='Итоговая цена'),
),
]

View File

@@ -1,43 +0,0 @@
# Generated migration to fix Product.in_stock based on Stock.quantity_available
from django.db import migrations
def update_product_in_stock(apps, schema_editor):
"""
Пересчитать Product.in_stock на основе Stock.quantity_available.
Товар в наличии если есть хотя бы один Stock с quantity_available > 0.
"""
Product = apps.get_model('products', 'Product')
Stock = apps.get_model('inventory', 'Stock')
# Получаем товары которые должны быть в наличии
products_with_stock = Stock.objects.filter(
quantity_available__gt=0
).values_list('product_id', flat=True).distinct()
products_with_stock_ids = set(products_with_stock)
# Обновляем все товары
for product in Product.objects.all():
new_status = product.id in products_with_stock_ids
if product.in_stock != new_status:
product.in_stock = new_status
product.save(update_fields=['in_stock'])
def reverse_update(apps, schema_editor):
"""Обратная миграция: сбросить все in_stock в False"""
Product = apps.get_model('products', 'Product')
Product.objects.all().update(in_stock=False)
class Migration(migrations.Migration):
dependencies = [
('products', '0003_add_product_in_stock'),
]
operations = [
migrations.RunPython(update_product_in_stock, reverse_update),
]