Рефакторинг системы вариативных товаров и справочник атрибутов

Основные изменения:
- Переименование ConfigurableKitProduct → ConfigurableProduct
- Добавлена поддержка Product как варианта (не только ProductKit)
- Создан справочник атрибутов (ProductAttribute, ProductAttributeValue)
- CRUD для управления атрибутами с inline редактированием значений
- Пересозданы миграции с нуля для всех приложений
- Добавлена ссылка на атрибуты в навигацию

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 01:44:34 +03:00
parent 277a514a82
commit 79ff523adb
36 changed files with 1597 additions and 951 deletions

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-12-23 20:38 # Generated by Django 5.0.10 on 2025-12-29 22:19
import django.contrib.auth.validators import django.contrib.auth.validators
import django.utils.timezone import django.utils.timezone

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-12-23 20:38 # Generated by Django 5.0.10 on 2025-12-29 22:19
import django.db.models.deletion import django.db.models.deletion
import phonenumber_field.modelfields import phonenumber_field.modelfields
@@ -20,9 +20,8 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, max_length=200, verbose_name='Имя')), ('name', models.CharField(blank=True, max_length=200, verbose_name='Имя')),
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='Email')), ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')),
('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, unique=True, verbose_name='Телефон')), ('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, verbose_name='Телефон')),
('wallet_balance', models.DecimalField(decimal_places=2, default=0, help_text='Остаток переплат клиента, доступный для оплаты заказов', max_digits=10, verbose_name='Баланс кошелька')),
('is_system_customer', models.BooleanField(db_index=True, default=False, help_text='Автоматически созданный клиент для анонимных покупок и наличных продаж', verbose_name='Системный клиент')), ('is_system_customer', models.BooleanField(db_index=True, default=False, help_text='Автоматически созданный клиент для анонимных покупок и наличных продаж', verbose_name='Системный клиент')),
('notes', models.TextField(blank=True, help_text='Заметки о клиенте, особые предпочтения и т.д.', null=True, verbose_name='Заметки')), ('notes', models.TextField(blank=True, help_text='Заметки о клиенте, особые предпочтения и т.д.', null=True, verbose_name='Заметки')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
@@ -35,14 +34,33 @@ class Migration(migrations.Migration):
'indexes': [models.Index(fields=['name'], name='customers_c_name_f018e2_idx'), models.Index(fields=['email'], name='customers_c_email_4fdeb3_idx'), models.Index(fields=['phone'], name='customers_c_phone_8493fa_idx'), models.Index(fields=['created_at'], name='customers_c_created_1ed0f4_idx')], 'indexes': [models.Index(fields=['name'], name='customers_c_name_f018e2_idx'), models.Index(fields=['email'], name='customers_c_email_4fdeb3_idx'), models.Index(fields=['phone'], name='customers_c_phone_8493fa_idx'), models.Index(fields=['created_at'], name='customers_c_created_1ed0f4_idx')],
}, },
), ),
migrations.CreateModel(
name='ContactChannel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel_type', models.CharField(choices=[('phone', 'Телефон'), ('email', 'Email'), ('telegram', 'Telegram'), ('instagram', 'Instagram'), ('whatsapp', 'WhatsApp'), ('viber', 'Viber'), ('vk', 'ВКонтакте'), ('facebook', 'Facebook'), ('other', 'Другое')], max_length=20, verbose_name='Тип канала')),
('value', models.CharField(help_text='Username, номер телефона, email и т.д.', max_length=255, verbose_name='Значение')),
('is_primary', models.BooleanField(default=False, verbose_name='Основной')),
('notes', models.CharField(blank=True, max_length=255, verbose_name='Примечание')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_channels', to='customers.customer', verbose_name='Клиент')),
],
options={
'verbose_name': 'Канал связи',
'verbose_name_plural': 'Каналы связи',
'ordering': ['-is_primary', 'channel_type'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='WalletTransaction', name='WalletTransaction',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма')), ('signed_amount', models.DecimalField(decimal_places=2, help_text='Положительная для пополнений, отрицательная для списаний', max_digits=10, verbose_name='Сумма')),
('transaction_type', models.CharField(choices=[('deposit', 'Пополнение'), ('spend', 'Списание'), ('adjustment', 'Корректировка')], max_length=20, verbose_name='Тип транзакции')), ('transaction_type', models.CharField(choices=[('deposit', 'Пополнение'), ('spend', 'Списание'), ('adjustment', 'Корректировка')], max_length=20, verbose_name='Тип транзакции')),
('balance_category', models.CharField(choices=[('money', 'Реальные деньги')], default='money', max_length=20, verbose_name='Категория')),
('description', models.TextField(blank=True, verbose_name='Описание')), ('description', models.TextField(blank=True, verbose_name='Описание')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('balance_after', models.DecimalField(blank=True, decimal_places=2, help_text='Баланс кошелька после применения этой транзакции', max_digits=10, null=True, verbose_name='Баланс после')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Создано пользователем')), ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Создано пользователем')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='wallet_transactions', to='customers.customer', verbose_name='Клиент')), ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='wallet_transactions', to='customers.customer', verbose_name='Клиент')),
], ],

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-12-23 20:38 # Generated by Django 5.0.10 on 2025-12-29 22:19
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@@ -17,7 +17,19 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='wallettransaction', model_name='wallettransaction',
name='order', name='order',
field=models.ForeignKey(blank=True, help_text='Заказ, к которому относится транзакция (если применимо)', null=True, on_delete=django.db.models.deletion.PROTECT, to='orders.order', verbose_name='Заказ'), field=models.ForeignKey(blank=True, help_text='Заказ, к которому относится транзакция (если применимо)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='wallet_transactions', to='orders.order', verbose_name='Заказ'),
),
migrations.AddIndex(
model_name='contactchannel',
index=models.Index(fields=['channel_type', 'value'], name='customers_c_channel_179e89_idx'),
),
migrations.AddIndex(
model_name='contactchannel',
index=models.Index(fields=['customer'], name='customers_c_custome_f14e0e_idx'),
),
migrations.AlterUniqueTogether(
name='contactchannel',
unique_together={('channel_type', 'value')},
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='wallettransaction', model_name='wallettransaction',
@@ -31,4 +43,12 @@ class Migration(migrations.Migration):
model_name='wallettransaction', model_name='wallettransaction',
index=models.Index(fields=['order'], name='customers_w_order_i_5e2527_idx'), index=models.Index(fields=['order'], name='customers_w_order_i_5e2527_idx'),
), ),
migrations.AddIndex(
model_name='wallettransaction',
index=models.Index(fields=['balance_category'], name='customers_w_balance_81f0a9_idx'),
),
migrations.AddIndex(
model_name='wallettransaction',
index=models.Index(fields=['customer', 'balance_category'], name='customers_w_custome_060570_idx'),
),
] ]

View File

@@ -1,44 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-27 19:32
import django.db.models.deletion
import phonenumber_field.modelfields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customers', '0002_initial'),
]
operations = [
migrations.AlterField(
model_name='customer',
name='email',
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email'),
),
migrations.AlterField(
model_name='customer',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, verbose_name='Телефон'),
),
migrations.CreateModel(
name='ContactChannel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel_type', models.CharField(choices=[('phone', 'Телефон'), ('email', 'Email'), ('telegram', 'Telegram'), ('instagram', 'Instagram'), ('whatsapp', 'WhatsApp'), ('viber', 'Viber'), ('vk', 'ВКонтакте'), ('facebook', 'Facebook'), ('other', 'Другое')], max_length=20, verbose_name='Тип канала')),
('value', models.CharField(help_text='Username, номер телефона, email и т.д.', max_length=255, verbose_name='Значение')),
('is_primary', models.BooleanField(default=False, verbose_name='Основной')),
('notes', models.CharField(blank=True, max_length=255, verbose_name='Примечание')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_channels', to='customers.customer', verbose_name='Клиент')),
],
options={
'verbose_name': 'Канал связи',
'verbose_name_plural': 'Каналы связи',
'ordering': ['-is_primary', 'channel_type'],
'indexes': [models.Index(fields=['channel_type', 'value'], name='customers_c_channel_179e89_idx'), models.Index(fields=['customer'], name='customers_c_custome_f14e0e_idx')],
'unique_together': {('channel_type', 'value')},
},
),
]

View File

@@ -1,146 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-27 20:24
import django.db.models.deletion
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
def populate_signed_amount(apps, schema_editor):
"""
Заполняем signed_amount на основе старого amount и типа транзакции.
spend -> отрицательная сумма
deposit/adjustment -> положительная сумма
"""
WalletTransaction = apps.get_model('customers', 'WalletTransaction')
for txn in WalletTransaction.objects.all():
if txn.transaction_type == 'spend':
txn.signed_amount = -abs(txn.amount)
else:
# deposit, adjustment - положительные
txn.signed_amount = abs(txn.amount)
txn.save(update_fields=['signed_amount'])
def calculate_balance_after(apps, schema_editor):
"""
Вычисляем balance_after для всех существующих транзакций.
"""
Customer = apps.get_model('customers', 'Customer')
WalletTransaction = apps.get_model('customers', 'WalletTransaction')
for customer in Customer.objects.all():
running_balance = Decimal('0')
# Обрабатываем транзакции в хронологическом порядке
for txn in WalletTransaction.objects.filter(customer=customer).order_by('created_at'):
running_balance += txn.signed_amount or Decimal('0')
txn.balance_after = running_balance
txn.save(update_fields=['balance_after'])
def reverse_populate(apps, schema_editor):
"""Обратная операция - ничего не делаем."""
pass
class Migration(migrations.Migration):
dependencies = [
('customers', '0003_alter_customer_email_alter_customer_phone_and_more'),
('orders', '0008_historicalorder_needs_delivery_photo_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
# 1. Добавляем новые поля (signed_amount временно nullable)
migrations.AddField(
model_name='wallettransaction',
name='signed_amount',
field=models.DecimalField(
decimal_places=2,
max_digits=10,
null=True, # Временно nullable для миграции данных
help_text='Положительная для пополнений, отрицательная для списаний',
verbose_name='Сумма'
),
),
migrations.AddField(
model_name='wallettransaction',
name='balance_after',
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text='Баланс кошелька после применения этой транзакции',
max_digits=10,
null=True,
verbose_name='Баланс после'
),
),
migrations.AddField(
model_name='wallettransaction',
name='balance_category',
field=models.CharField(
choices=[('money', 'Реальные деньги')],
default='money',
max_length=20,
verbose_name='Категория'
),
),
# 2. Копируем данные из amount в signed_amount
migrations.RunPython(populate_signed_amount, reverse_populate),
# 3. Вычисляем balance_after
migrations.RunPython(calculate_balance_after, reverse_populate),
# 4. Делаем signed_amount NOT NULL
migrations.AlterField(
model_name='wallettransaction',
name='signed_amount',
field=models.DecimalField(
decimal_places=2,
max_digits=10,
help_text='Положительная для пополнений, отрицательная для списаний',
verbose_name='Сумма'
),
),
# 5. Удаляем старое поле amount
migrations.RemoveField(
model_name='wallettransaction',
name='amount',
),
# 6. Удаляем wallet_balance из Customer
migrations.RemoveField(
model_name='customer',
name='wallet_balance',
),
# 7. Обновляем связь с Order (добавляем related_name)
migrations.AlterField(
model_name='wallettransaction',
name='order',
field=models.ForeignKey(
blank=True,
help_text='Заказ, к которому относится транзакция (если применимо)',
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='wallet_transactions',
to='orders.order',
verbose_name='Заказ'
),
),
# 8. Добавляем индексы
migrations.AddIndex(
model_name='wallettransaction',
index=models.Index(fields=['balance_category'], name='customers_w_balance_81f0a9_idx'),
),
migrations.AddIndex(
model_name='wallettransaction',
index=models.Index(fields=['customer', 'balance_category'], name='customers_w_custome_060570_idx'),
),
]

View File

@@ -1,6 +1,8 @@
# Generated by Django 5.0.10 on 2025-12-23 20:38 # Generated by Django 5.0.10 on 2025-12-29 22:19
import django.db.models.deletion
import phonenumber_field.modelfields import phonenumber_field.modelfields
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -9,6 +11,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
@@ -16,7 +19,7 @@ class Migration(migrations.Migration):
name='DocumentCounter', name='DocumentCounter',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('counter_type', models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара'), ('inventory', 'Инвентаризация')], max_length=20, unique=True, verbose_name='Тип счетчика')), ('counter_type', models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара'), ('inventory', 'Инвентаризация'), ('transformation', 'Трансформация товара')], max_length=20, unique=True, verbose_name='Тип счетчика')),
('current_value', models.IntegerField(default=0, verbose_name='Текущее значение')), ('current_value', models.IntegerField(default=0, verbose_name='Текущее значение')),
], ],
options={ options={
@@ -24,74 +27,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Счетчики документов', 'verbose_name_plural': 'Счетчики документов',
}, },
), ),
migrations.CreateModel(
name='Incoming',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Закупочная цена')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
],
options={
'verbose_name': 'Товар в поступлении',
'verbose_name_plural': 'Товары в поступлениях',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='IncomingBatch',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
('receipt_type', models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления')),
('supplier_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Наименование поставщика')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Партия поступления',
'verbose_name_plural': 'Партии поступлений',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='IncomingDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('confirmed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
('date', models.DateField(help_text='Дата, к которой относится поступление', verbose_name='Дата документа')),
('receipt_type', models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления')),
('supplier_name', models.CharField(blank=True, help_text="Заполняется для типа 'Поступление от поставщика'", max_length=200, null=True, verbose_name='Наименование поставщика')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('confirmed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата проведения')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
],
options={
'verbose_name': 'Документ поступления',
'verbose_name_plural': 'Документы поступления',
'ordering': ['-date', '-created_at'],
},
),
migrations.CreateModel(
name='IncomingDocumentItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Закупочная цена')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
],
options={
'verbose_name': 'Позиция документа поступления',
'verbose_name_plural': 'Позиции документа поступления',
'ordering': ['id'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='Inventory', name='Inventory',
fields=[ fields=[
@@ -130,7 +65,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('status', models.CharField(choices=[('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу'), ('converted_to_writeoff', 'Преобразован в списание')], default='reserved', max_length=25, verbose_name='Статус')), ('status', models.CharField(choices=[('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу'), ('converted_to_writeoff', 'Преобразован в списание'), ('converted_to_transformation', 'Преобразован в трансформацию')], default='reserved', max_length=30, verbose_name='Статус')),
('reserved_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата резервирования')), ('reserved_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата резервирования')),
('released_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата освобождения')), ('released_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата освобождения')),
('converted_at', models.DateTimeField(blank=True, help_text='Дата преобразования в продажу или списание', null=True, verbose_name='Дата преобразования')), ('converted_at', models.DateTimeField(blank=True, help_text='Дата преобразования в продажу или списание', null=True, verbose_name='Дата преобразования')),
@@ -234,34 +169,7 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='StockMovement', name='TransferDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('change', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Изменение')),
('reason', models.CharField(choices=[('purchase', 'Закупка'), ('sale', 'Продажа'), ('write_off', 'Списание'), ('adjustment', 'Корректировка')], max_length=20, verbose_name='Причина')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
],
options={
'verbose_name': 'Движение товара',
'verbose_name_plural': 'Движения товаров',
},
),
migrations.CreateModel(
name='Transfer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('document_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Номер документа')),
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата операции')),
],
options={
'verbose_name': 'Перемещение',
'verbose_name_plural': 'Перемещения',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='TransferBatch',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')), ('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
@@ -276,7 +184,7 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='TransferItem', name='TransferDocumentItem',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
@@ -287,6 +195,45 @@ class Migration(migrations.Migration):
'ordering': ['id'], 'ordering': ['id'],
}, },
), ),
migrations.CreateModel(
name='Transformation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('completed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('comment', 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='Обновлён')),
],
options={
'verbose_name': 'Трансформация товара',
'verbose_name_plural': 'Трансформации товаров',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='TransformationInput',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
],
options={
'verbose_name': 'Входной товар трансформации',
'verbose_name_plural': 'Входные товары трансформации',
},
),
migrations.CreateModel(
name='TransformationOutput',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
],
options={
'verbose_name': 'Выходной товар трансформации',
'verbose_name_plural': 'Выходные товары трансформации',
},
),
migrations.CreateModel( migrations.CreateModel(
name='Warehouse', name='Warehouse',
fields=[ fields=[
@@ -359,4 +306,43 @@ class Migration(migrations.Migration):
'ordering': ['id'], 'ordering': ['id'],
}, },
), ),
migrations.CreateModel(
name='IncomingDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('confirmed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
('date', models.DateField(help_text='Дата, к которой относится поступление', verbose_name='Дата документа')),
('receipt_type', models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления')),
('supplier_name', models.CharField(blank=True, help_text="Заполняется для типа 'Поступление от поставщика'", max_length=200, null=True, verbose_name='Наименование поставщика')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('confirmed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата проведения')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
('confirmed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_incoming_documents', to=settings.AUTH_USER_MODEL, verbose_name='Провёл')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_incoming_documents', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
],
options={
'verbose_name': 'Документ поступления',
'verbose_name_plural': 'Документы поступления',
'ordering': ['-date', '-created_at'],
},
),
migrations.CreateModel(
name='IncomingDocumentItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Закупочная цена')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.incomingdocument', verbose_name='Документ')),
],
options={
'verbose_name': 'Позиция документа поступления',
'verbose_name_plural': 'Позиции документа поступления',
'ordering': ['id'],
},
),
] ]

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-12-23 20:38 # Generated by Django 5.0.10 on 2025-12-29 22:19
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
@@ -18,31 +18,6 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.AddField(
model_name='incoming',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incomings', to='products.product', verbose_name='Товар'),
),
migrations.AddField(
model_name='incoming',
name='batch',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.incomingbatch', verbose_name='Партия'),
),
migrations.AddField(
model_name='incomingdocument',
name='confirmed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_incoming_documents', to=settings.AUTH_USER_MODEL, verbose_name='Провёл'),
),
migrations.AddField(
model_name='incomingdocument',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_incoming_documents', to=settings.AUTH_USER_MODEL, verbose_name='Создал'),
),
migrations.AddField(
model_name='incomingdocumentitem',
name='document',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.incomingdocument', verbose_name='Документ'),
),
migrations.AddField( migrations.AddField(
model_name='incomingdocumentitem', model_name='incomingdocumentitem',
name='product', name='product',
@@ -149,49 +124,59 @@ class Migration(migrations.Migration):
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sale_allocations', to='inventory.stockbatch', verbose_name='Партия'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sale_allocations', to='inventory.stockbatch', verbose_name='Партия'),
), ),
migrations.AddField( migrations.AddField(
model_name='incoming', model_name='transferdocumentitem',
name='batch',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_document_items', to='inventory.stockbatch', verbose_name='Исходная партия (FIFO)'),
),
migrations.AddField(
model_name='transferdocumentitem',
name='new_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_document_items_created', to='inventory.stockbatch', verbose_name='Созданная партия на целевом складе'),
),
migrations.AddField(
model_name='transferdocumentitem',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_document_items', to='products.product', verbose_name='Товар'),
),
migrations.AddField(
model_name='transferdocumentitem',
name='transfer_document',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.transferdocument', verbose_name='Документ перемещения'),
),
migrations.AddField(
model_name='transformation',
name='employee',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transformations', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник'),
),
migrations.AddField(
model_name='transformationinput',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformation_inputs', to='products.product', verbose_name='Товар'),
),
migrations.AddField(
model_name='transformationinput',
name='transformation',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inputs', to='inventory.transformation', verbose_name='Трансформация'),
),
migrations.AddField(
model_name='reservation',
name='transformation_input',
field=models.ForeignKey(blank=True, help_text='Резерв для входного товара трансформации (черновик)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.transformationinput', verbose_name='Входной товар трансформации'),
),
migrations.AddField(
model_name='transformationoutput',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformation_outputs', to='products.product', verbose_name='Товар'),
),
migrations.AddField(
model_name='transformationoutput',
name='stock_batch', name='stock_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incomings', to='inventory.stockbatch', verbose_name='Складская партия'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transformation_outputs', to='inventory.stockbatch', verbose_name='Созданная партия'),
), ),
migrations.AddField( migrations.AddField(
model_name='stockmovement', model_name='transformationoutput',
name='order', name='transformation',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_movements', to='orders.order', verbose_name='Заказ'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outputs', to='inventory.transformation', verbose_name='Трансформация'),
),
migrations.AddField(
model_name='stockmovement',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movements', to='products.product', verbose_name='Товар'),
),
migrations.AddField(
model_name='transfer',
name='batch',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfers', to='inventory.stockbatch', verbose_name='Партия'),
),
migrations.AddField(
model_name='transfer',
name='new_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_sources', to='inventory.stockbatch', verbose_name='Новая партия'),
),
migrations.AddField(
model_name='transferitem',
name='batch',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_items', to='inventory.stockbatch', verbose_name='Исходная партия (FIFO)'),
),
migrations.AddField(
model_name='transferitem',
name='new_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_items_created', to='inventory.stockbatch', verbose_name='Созданная партия на целевом складе'),
),
migrations.AddField(
model_name='transferitem',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_items', to='products.product', verbose_name='Товар'),
),
migrations.AddField(
model_name='transferitem',
name='transfer_batch',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.transferbatch', verbose_name='Документ перемещения'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='warehouse', model_name='warehouse',
@@ -206,24 +191,19 @@ class Migration(migrations.Migration):
index=models.Index(fields=['is_pickup_point'], name='inventory_w_is_pick_e86268_idx'), index=models.Index(fields=['is_pickup_point'], name='inventory_w_is_pick_e86268_idx'),
), ),
migrations.AddField( migrations.AddField(
model_name='transferbatch', model_name='transformation',
name='warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformations', to='inventory.warehouse', verbose_name='Склад'),
),
migrations.AddField(
model_name='transferdocument',
name='from_warehouse', name='from_warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_batches_from', to='inventory.warehouse', verbose_name='Склад-отгрузки'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_documents_from', to='inventory.warehouse', verbose_name='Склад-отгрузки'),
), ),
migrations.AddField( migrations.AddField(
model_name='transferbatch', model_name='transferdocument',
name='to_warehouse', name='to_warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_batches_to', to='inventory.warehouse', verbose_name='Склад-приемки'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_documents_to', to='inventory.warehouse', verbose_name='Склад-приемки'),
),
migrations.AddField(
model_name='transfer',
name='from_warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfers_from', to='inventory.warehouse', verbose_name='Из склада'),
),
migrations.AddField(
model_name='transfer',
name='to_warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfers_to', to='inventory.warehouse', verbose_name='На склад'),
), ),
migrations.AddField( migrations.AddField(
model_name='stockbatch', model_name='stockbatch',
@@ -260,11 +240,6 @@ class Migration(migrations.Migration):
name='warehouse', name='warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_documents', to='inventory.warehouse', verbose_name='Склад'), field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_documents', to='inventory.warehouse', verbose_name='Склад'),
), ),
migrations.AddField(
model_name='incomingbatch',
name='warehouse',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incoming_batches', to='inventory.warehouse', verbose_name='Склад'),
),
migrations.AddField( migrations.AddField(
model_name='writeoff', model_name='writeoff',
name='batch', name='batch',
@@ -335,60 +310,40 @@ class Migration(migrations.Migration):
index=models.Index(fields=['locked_by_user', 'status'], name='inventory_s_locked__88eac9_idx'), index=models.Index(fields=['locked_by_user', 'status'], name='inventory_s_locked__88eac9_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='incoming', model_name='transferdocumentitem',
index=models.Index(fields=['batch'], name='inventory_i_batch_i_c50b63_idx'), index=models.Index(fields=['transfer_document'], name='inventory_t_transfe_02e7fe_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='incoming', model_name='transferdocumentitem',
index=models.Index(fields=['product'], name='inventory_i_product_39b00d_idx'), index=models.Index(fields=['product'], name='inventory_t_product_a5ed4b_idx'),
),
migrations.AddIndex(
model_name='incoming',
index=models.Index(fields=['-created_at'], name='inventory_i_created_563ec0_idx'),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='incoming', name='transferdocumentitem',
unique_together={('batch', 'product')}, unique_together={('transfer_document', 'batch')},
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='stockmovement', model_name='transformation',
index=models.Index(fields=['product'], name='inventory_s_product_cbdc37_idx'), index=models.Index(fields=['document_number'], name='inventory_t_documen_559778_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='stockmovement', model_name='transformation',
index=models.Index(fields=['created_at'], name='inventory_s_created_05ebf5_idx'), index=models.Index(fields=['warehouse', 'status'], name='inventory_t_warehou_934275_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='transferitem', model_name='transformation',
index=models.Index(fields=['transfer_batch'], name='inventory_t_transfe_f7479b_idx'), index=models.Index(fields=['-date'], name='inventory_t_date_65cfab_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='transferitem', model_name='transferdocument',
index=models.Index(fields=['product'], name='inventory_t_product_0e0ec9_idx'), index=models.Index(fields=['document_number'], name='inventory_t_documen_d9087d_idx'),
),
migrations.AlterUniqueTogether(
name='transferitem',
unique_together={('transfer_batch', 'batch')},
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='transferbatch', model_name='transferdocument',
index=models.Index(fields=['document_number'], name='inventory_t_documen_143275_idx'), index=models.Index(fields=['from_warehouse', 'to_warehouse'], name='inventory_t_from_wa_118a47_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='transferbatch', model_name='transferdocument',
index=models.Index(fields=['from_warehouse', 'to_warehouse'], name='inventory_t_from_wa_2a41f1_idx'), index=models.Index(fields=['-created_at'], name='inventory_t_created_5ad653_idx'),
),
migrations.AddIndex(
model_name='transferbatch',
index=models.Index(fields=['-created_at'], name='inventory_t_created_b6fd05_idx'),
),
migrations.AddIndex(
model_name='transfer',
index=models.Index(fields=['from_warehouse', 'to_warehouse'], name='inventory_t_from_wa_578feb_idx'),
),
migrations.AddIndex(
model_name='transfer',
index=models.Index(fields=['date'], name='inventory_t_date_e1402d_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='stockbatch', model_name='stockbatch',
@@ -458,22 +413,6 @@ class Migration(migrations.Migration):
model_name='incomingdocument', model_name='incomingdocument',
index=models.Index(fields=['-created_at'], name='inventory_i_created_174930_idx'), index=models.Index(fields=['-created_at'], name='inventory_i_created_174930_idx'),
), ),
migrations.AddIndex(
model_name='incomingbatch',
index=models.Index(fields=['document_number'], name='inventory_i_documen_679096_idx'),
),
migrations.AddIndex(
model_name='incomingbatch',
index=models.Index(fields=['warehouse'], name='inventory_i_warehou_cc3a73_idx'),
),
migrations.AddIndex(
model_name='incomingbatch',
index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_ce70c1_idx'),
),
migrations.AddIndex(
model_name='incomingbatch',
index=models.Index(fields=['-created_at'], name='inventory_i_created_59ee8b_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='writeoff', model_name='writeoff',
index=models.Index(fields=['batch'], name='inventory_w_batch_i_b098ce_idx'), index=models.Index(fields=['batch'], name='inventory_w_batch_i_b098ce_idx'),

View File

@@ -1,90 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-25 14:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0002_initial'),
('products', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='documentcounter',
name='counter_type',
field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара'), ('inventory', 'Инвентаризация'), ('transformation', 'Трансформация товара')], max_length=20, unique=True, verbose_name='Тип счетчика'),
),
migrations.AlterField(
model_name='reservation',
name='status',
field=models.CharField(choices=[('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу'), ('converted_to_writeoff', 'Преобразован в списание'), ('converted_to_transformation', 'Преобразован в трансформацию')], default='reserved', max_length=30, verbose_name='Статус'),
),
migrations.CreateModel(
name='Transformation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('completed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('comment', 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='Обновлён')),
('employee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transformations', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformations', to='inventory.warehouse', verbose_name='Склад')),
],
options={
'verbose_name': 'Трансформация товара',
'verbose_name_plural': 'Трансформации товаров',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='TransformationInput',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformation_inputs', to='products.product', verbose_name='Товар')),
('transformation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inputs', to='inventory.transformation', verbose_name='Трансформация')),
],
options={
'verbose_name': 'Входной товар трансформации',
'verbose_name_plural': 'Входные товары трансформации',
},
),
migrations.AddField(
model_name='reservation',
name='transformation_input',
field=models.ForeignKey(blank=True, help_text='Резерв для входного товара трансформации (черновик)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.transformationinput', verbose_name='Входной товар трансформации'),
),
migrations.CreateModel(
name='TransformationOutput',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transformation_outputs', to='products.product', verbose_name='Товар')),
('stock_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transformation_outputs', to='inventory.stockbatch', verbose_name='Созданная партия')),
('transformation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outputs', to='inventory.transformation', verbose_name='Трансформация')),
],
options={
'verbose_name': 'Выходной товар трансформации',
'verbose_name_plural': 'Выходные товары трансформации',
},
),
migrations.AddIndex(
model_name='transformation',
index=models.Index(fields=['document_number'], name='inventory_t_documen_559778_idx'),
),
migrations.AddIndex(
model_name='transformation',
index=models.Index(fields=['warehouse', 'status'], name='inventory_t_warehou_934275_idx'),
),
migrations.AddIndex(
model_name='transformation',
index=models.Index(fields=['-date'], name='inventory_t_date_65cfab_idx'),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-26 14:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('inventory', '0003_alter_documentcounter_counter_type_and_more'),
]
operations = [
migrations.RemoveField(
model_name='incomingbatch',
name='warehouse',
),
migrations.DeleteModel(
name='Incoming',
),
migrations.DeleteModel(
name='IncomingBatch',
),
]

View File

@@ -1,38 +0,0 @@
# Generated migration for Transfer models refactoring
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('inventory', '0004_remove_incoming_batch_and_incoming'),
('products', '0001_initial'),
]
operations = [
# 1. Удаление устаревших моделей ПЕРЕД переименованием
migrations.DeleteModel(
name='Transfer',
),
migrations.DeleteModel(
name='StockMovement',
),
# 2. Переименование моделей
migrations.RenameModel(
old_name='TransferBatch',
new_name='TransferDocument',
),
migrations.RenameModel(
old_name='TransferItem',
new_name='TransferDocumentItem',
),
# 3. Переименование поля transfer_batch → transfer_document в TransferDocumentItem
migrations.RenameField(
model_name='transferdocumentitem',
old_name='transfer_batch',
new_name='transfer_document',
),
]

View File

@@ -956,23 +956,21 @@ def release_stock_on_order_delete(sender, instance, **kwargs):
if r not in showcase_reservations if r not in showcase_reservations
] ]
# Освобождаем только обычные резервы ПОСЛЕ успешного коммита транзакции # Освобождаем резервы СРАЗУ в pre_delete (до каскадного удаления OrderItem)
# Это гарантирует целостность: резервы освободятся только если удаление прошло успешно # Это предотвращает ошибку FK constraint при попытке сохранить резерв после удаления OrderItem
def release_reservations(): for res in normal_reservations:
for res in normal_reservations: res.status = 'released'
res.status = 'released' res.released_at = timezone.now()
res.released_at = timezone.now() res.order_item = None # Обнуляем ссылку на удаляемый OrderItem
res.save() res.save()
# Витринные комплекты остаются зарезервированными, но отвязываем блокировки корзины # Витринные комплекты остаются зарезервированными, но отвязываем от заказа и блокировки корзины
# НЕ трогаем order_item - он нужен если заказ снова перейдёт в completed for res in showcase_reservations:
for res in showcase_reservations: res.order_item = None # Обнуляем ссылку на удаляемый OrderItem
res.cart_lock_expires_at = None res.cart_lock_expires_at = None
res.locked_by_user = None res.locked_by_user = None
res.cart_session_id = None res.cart_session_id = None
res.save(update_fields=['cart_lock_expires_at', 'locked_by_user', 'cart_session_id']) res.save(update_fields=['order_item', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
transaction.on_commit(release_reservations)
@receiver(post_save, sender=OrderItem) @receiver(post_save, sender=OrderItem)

View File

@@ -1,6 +1,7 @@
# Generated by Django 5.0.10 on 2025-12-23 20:38 # Generated by Django 5.0.10 on 2025-12-29 22:19
import django.db.models.deletion import django.db.models.deletion
import phonenumber_field.modelfields
import simple_history.models import simple_history.models
from decimal import Decimal from decimal import Decimal
from django.conf import settings from django.conf import settings
@@ -65,9 +66,10 @@ class Migration(migrations.Migration):
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа', max_digits=10, verbose_name='Итоговая сумма заказа')), ('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа', max_digits=10, verbose_name='Итоговая сумма заказа')),
('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')), ('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')),
('payment_status', models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты')), ('payment_status', models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты')),
('customer_is_recipient', models.BooleanField(default=True, help_text='Если отмечено, данные получателя не требуются отдельно', verbose_name='Покупатель является получателем')),
('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')), ('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')),
('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')), ('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')),
('needs_product_photo', models.BooleanField(default=False, help_text='Требуется фотография товара перед отправкой', verbose_name='Необходимо фото товара')),
('needs_delivery_photo', models.BooleanField(default=False, help_text='Требуется фотография процесса вручения заказа', verbose_name='Необходимо фото вручения')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
], ],
@@ -140,7 +142,8 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='ФИО или название организации получателя', max_length=200, verbose_name='Имя получателя')), ('name', models.CharField(help_text='ФИО или название организации получателя', max_length=200, verbose_name='Имя получателя')),
('phone', models.CharField(help_text='Контактный телефон для связи с получателем', max_length=20, verbose_name='Телефон получателя')), ('phone', phonenumber_field.modelfields.PhoneNumberField(help_text='Контактный телефон для связи с получателем. Введите в любом формате, будет автоматически преобразован', max_length=128, region=None, verbose_name='Телефон получателя')),
('notes', models.CharField(blank=True, help_text='Мессенджер, соцсеть или другая информация о получателе (необязательно)', max_length=200, null=True, verbose_name='Дополнительная информация')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
], ],
@@ -194,8 +197,8 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('delivery_type', models.CharField(choices=[('courier', 'Доставка курьером'), ('pickup', 'Самовывоз')], db_index=True, default='courier', max_length=20, verbose_name='Способ доставки')), ('delivery_type', models.CharField(choices=[('courier', 'Доставка курьером'), ('pickup', 'Самовывоз')], db_index=True, default='courier', max_length=20, verbose_name='Способ доставки')),
('delivery_date', models.DateField(help_text='Дата, когда должна быть выполнена доставка', verbose_name='Дата доставки')), ('delivery_date', models.DateField(help_text='Дата, когда должна быть выполнена доставка', verbose_name='Дата доставки')),
('time_from', models.TimeField(help_text='Начальное время временного интервала доставки', verbose_name='Время доставки от')), ('time_from', models.TimeField(blank=True, help_text='Начальное время временного интервала доставки (необязательно)', null=True, verbose_name='Время доставки от')),
('time_to', models.TimeField(help_text='Конечное время временного интервала доставки', verbose_name='Время доставки до')), ('time_to', models.TimeField(blank=True, help_text='Конечное время временного интервала доставки (необязательно)', null=True, verbose_name='Время доставки до')),
('cost', models.DecimalField(decimal_places=2, default=0, help_text='Стоимость доставки в рублях. 0 для бесплатной доставки/самовывоза', max_digits=10, verbose_name='Стоимость доставки')), ('cost', models.DecimalField(decimal_places=2, default=0, help_text='Стоимость доставки в рублях. 0 для бесплатной доставки/самовывоза', max_digits=10, verbose_name='Стоимость доставки')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
@@ -219,9 +222,10 @@ class Migration(migrations.Migration):
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа', max_digits=10, verbose_name='Итоговая сумма заказа')), ('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа', max_digits=10, verbose_name='Итоговая сумма заказа')),
('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')), ('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')),
('payment_status', models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты')), ('payment_status', models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты')),
('customer_is_recipient', models.BooleanField(default=True, help_text='Если отмечено, данные получателя не требуются отдельно', verbose_name='Покупатель является получателем')),
('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')), ('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')),
('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')), ('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')),
('needs_product_photo', models.BooleanField(default=False, help_text='Требуется фотография товара перед отправкой', verbose_name='Необходимо фото товара')),
('needs_delivery_photo', models.BooleanField(default=False, help_text='Требуется фотография процесса вручения заказа', verbose_name='Необходимо фото вручения')),
('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата создания')), ('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата обновления')), ('updated_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата обновления')),
('history_id', models.AutoField(primary_key=True, serialize=False)), ('history_id', models.AutoField(primary_key=True, serialize=False)),

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-12-23 20:38 # Generated by Django 5.0.10 on 2025-12-29 22:19
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-24 10:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('orders', '0002_initial'),
]
operations = [
migrations.RemoveField(
model_name='historicalorder',
name='customer_is_recipient',
),
migrations.RemoveField(
model_name='order',
name='customer_is_recipient',
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-24 15:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0003_remove_customer_is_recipient'),
]
operations = [
migrations.AlterField(
model_name='delivery',
name='time_from',
field=models.TimeField(blank=True, help_text='Начальное время временного интервала доставки (необязательно)', null=True, verbose_name='Время доставки от'),
),
migrations.AlterField(
model_name='delivery',
name='time_to',
field=models.TimeField(blank=True, help_text='Конечное время временного интервала доставки (необязательно)', null=True, verbose_name='Время доставки до'),
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-24 21:48
import phonenumber_field.modelfields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0004_make_delivery_time_optional'),
]
operations = [
migrations.AddField(
model_name='recipient',
name='additional_contact',
field=models.CharField(blank=True, help_text='Мессенджер, соцсеть или другая контактная информация (необязательно)', max_length=200, null=True, verbose_name='Дополнительный контакт'),
),
migrations.AlterField(
model_name='recipient',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(help_text='Контактный телефон для связи с получателем. Введите в любом формате, будет автоматически преобразован', max_length=128, region='BY', verbose_name='Телефон получателя'),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-24 21:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0005_recipient_additional_contact_alter_recipient_phone'),
]
operations = [
migrations.RenameField(
model_name='recipient',
old_name='additional_contact',
new_name='notes',
),
migrations.AlterField(
model_name='recipient',
name='notes',
field=models.CharField(blank=True, help_text='Мессенджер, соцсеть или другая информация о получателе (необязательно)', max_length=200, null=True, verbose_name='Дополнительная информация'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-24 21:56
import phonenumber_field.modelfields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('orders', '0006_rename_additional_contact_to_notes'),
]
operations = [
migrations.AlterField(
model_name='recipient',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(help_text='Контактный телефон для связи с получателем. Введите в любом формате, будет автоматически преобразован', max_length=128, region=None, verbose_name='Телефон получателя'),
),
]

View File

@@ -1,33 +0,0 @@
# Generated by Django 5.0.10 on 2025-12-25 08:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0007_remove_region_from_recipient_phone'),
]
operations = [
migrations.AddField(
model_name='historicalorder',
name='needs_delivery_photo',
field=models.BooleanField(default=False, help_text='Требуется фотография процесса вручения заказа', verbose_name='Необходимо фото вручения'),
),
migrations.AddField(
model_name='historicalorder',
name='needs_product_photo',
field=models.BooleanField(default=False, help_text='Требуется фотография товара перед отправкой', verbose_name='Необходимо фото товара'),
),
migrations.AddField(
model_name='order',
name='needs_delivery_photo',
field=models.BooleanField(default=False, help_text='Требуется фотография процесса вручения заказа', verbose_name='Необходимо фото вручения'),
),
migrations.AddField(
model_name='order',
name='needs_product_photo',
field=models.BooleanField(default=False, help_text='Требуется фотография товара перед отправкой', verbose_name='Необходимо фото товара'),
),
]

View File

@@ -3,7 +3,8 @@ from django.forms import inlineformset_factory
from .models import ( from .models import (
Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem,
ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem, ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem,
ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute,
ProductAttribute, ProductAttributeValue
) )
@@ -583,12 +584,12 @@ class ProductTagForm(forms.ModelForm):
# ==================== CONFIGURABLE KIT FORMS ==================== # ==================== CONFIGURABLE KIT FORMS ====================
class ConfigurableKitProductForm(forms.ModelForm): class ConfigurableProductForm(forms.ModelForm):
""" """
Форма для создания и редактирования вариативного товара. Форма для создания и редактирования вариативного товара.
""" """
class Meta: class Meta:
model = ConfigurableKitProduct model = ConfigurableProduct
fields = ['name', 'sku', 'description', 'short_description', 'status'] fields = ['name', 'sku', 'description', 'short_description', 'status']
labels = { labels = {
'name': 'Название', 'name': 'Название',
@@ -620,7 +621,7 @@ class ConfigurableKitProductForm(forms.ModelForm):
self.fields['status'].widget.attrs.update({'class': 'form-select'}) self.fields['status'].widget.attrs.update({'class': 'form-select'})
class ConfigurableKitOptionForm(forms.ModelForm): class ConfigurableProductOptionForm(forms.ModelForm):
""" """
Форма для добавления варианта (комплекта) к вариативному товару. Форма для добавления варианта (комплекта) к вариативному товару.
Атрибуты варианта выбираются динамически на основе parent_attributes. Атрибуты варианта выбираются динамически на основе parent_attributes.
@@ -633,8 +634,8 @@ class ConfigurableKitOptionForm(forms.ModelForm):
) )
class Meta: class Meta:
model = ConfigurableKitOption model = ConfigurableProductOption
# Убрали 'attributes' - он будет заполняться через ConfigurableKitOptionAttribute # Убрали 'attributes' - он будет заполняться через ConfigurableProductOptionAttribute
fields = ['kit', 'is_default'] fields = ['kit', 'is_default']
labels = { labels = {
'kit': 'Комплект', 'kit': 'Комплект',
@@ -653,7 +654,7 @@ class ConfigurableKitOptionForm(forms.ModelForm):
""" """
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Получаем instance (ConfigurableKitOption) # Получаем instance (ConfigurableProductOption)
if self.instance and self.instance.parent_id: if self.instance and self.instance.parent_id:
parent = self.instance.parent parent = self.instance.parent
# Получаем все уникальные названия атрибутов родителя # Получаем все уникальные названия атрибутов родителя
@@ -683,7 +684,7 @@ class ConfigurableKitOptionForm(forms.ModelForm):
self.fields[field_name].initial = current_attr.attribute self.fields[field_name].initial = current_attr.attribute
class BaseConfigurableKitOptionFormSet(forms.BaseInlineFormSet): class BaseConfigurableProductOptionFormSet(forms.BaseInlineFormSet):
def clean(self): def clean(self):
"""Проверка на дубликаты комплектов и что все атрибуты заполнены""" """Проверка на дубликаты комплектов и что все атрибуты заполнены"""
if any(self.errors): if any(self.errors):
@@ -756,12 +757,12 @@ class BaseConfigurableKitOptionFormSet(forms.BaseInlineFormSet):
# Формсет для создания вариативного товара # Формсет для создания вариативного товара
ConfigurableKitOptionFormSetCreate = inlineformset_factory( ConfigurableProductOptionFormSetCreate = inlineformset_factory(
ConfigurableKitProduct, ConfigurableProduct,
ConfigurableKitOption, ConfigurableProductOption,
form=ConfigurableKitOptionForm, form=ConfigurableProductOptionForm,
formset=BaseConfigurableKitOptionFormSet, formset=BaseConfigurableProductOptionFormSet,
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableKitOptionAttribute fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableProductOptionAttribute
extra=0, # Не требуем пустые формы (варианты скрыты в UI) extra=0, # Не требуем пустые формы (варианты скрыты в UI)
can_delete=True, can_delete=True,
min_num=0, min_num=0,
@@ -770,12 +771,12 @@ ConfigurableKitOptionFormSetCreate = inlineformset_factory(
) )
# Формсет для редактирования вариативного товара # Формсет для редактирования вариативного товара
ConfigurableKitOptionFormSetUpdate = inlineformset_factory( ConfigurableProductOptionFormSetUpdate = inlineformset_factory(
ConfigurableKitProduct, ConfigurableProduct,
ConfigurableKitOption, ConfigurableProductOption,
form=ConfigurableKitOptionForm, form=ConfigurableProductOptionForm,
formset=BaseConfigurableKitOptionFormSet, formset=BaseConfigurableProductOptionFormSet,
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableKitOptionAttribute fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableProductOptionAttribute
extra=0, # НЕ показывать пустые формы extra=0, # НЕ показывать пустые формы
can_delete=True, can_delete=True,
min_num=0, min_num=0,
@@ -786,7 +787,7 @@ ConfigurableKitOptionFormSetUpdate = inlineformset_factory(
# === Формы для атрибутов родительского вариативного товара === # === Формы для атрибутов родительского вариативного товара ===
class ConfigurableKitProductAttributeForm(forms.ModelForm): class ConfigurableProductAttributeForm(forms.ModelForm):
""" """
Форма для добавления атрибута родительского товара в карточном интерфейсе. Форма для добавления атрибута родительского товара в карточном интерфейсе.
На фронтенде: одна карточка параметра (имя + позиция + видимость) На фронтенде: одна карточка параметра (имя + позиция + видимость)
@@ -796,10 +797,10 @@ class ConfigurableKitProductAttributeForm(forms.ModelForm):
- name: "Длина" - name: "Длина"
- position: 0 - position: 0
- visible: True - visible: True
- values: [50, 60, 70] (будут созданы как отдельные ConfigurableKitProductAttribute) - values: [50, 60, 70] (будут созданы как отдельные ConfigurableProductAttribute)
""" """
class Meta: class Meta:
model = ConfigurableKitProductAttribute model = ConfigurableProductAttribute
fields = ['name', 'position', 'visible'] fields = ['name', 'position', 'visible']
labels = { labels = {
'name': 'Название параметра', 'name': 'Название параметра',
@@ -822,7 +823,7 @@ class ConfigurableKitProductAttributeForm(forms.ModelForm):
} }
class BaseConfigurableKitProductAttributeFormSet(forms.BaseInlineFormSet): class BaseConfigurableProductAttributeFormSet(forms.BaseInlineFormSet):
def clean(self): def clean(self):
"""Проверка на дубликаты параметров и что у каждого параметра есть значения""" """Проверка на дубликаты параметров и что у каждого параметра есть значения"""
if any(self.errors): if any(self.errors):
@@ -850,11 +851,11 @@ class BaseConfigurableKitProductAttributeFormSet(forms.BaseInlineFormSet):
# Формсет для создания атрибутов родительского товара (карточный интерфейс) # Формсет для создания атрибутов родительского товара (карточный интерфейс)
ConfigurableKitProductAttributeFormSetCreate = inlineformset_factory( ConfigurableProductAttributeFormSetCreate = inlineformset_factory(
ConfigurableKitProduct, ConfigurableProduct,
ConfigurableKitProductAttribute, ConfigurableProductAttribute,
form=ConfigurableKitProductAttributeForm, form=ConfigurableProductAttributeForm,
formset=BaseConfigurableKitProductAttributeFormSet, formset=BaseConfigurableProductAttributeFormSet,
# Убрали 'option' - значения будут добавляться через JavaScript в карточку # Убрали 'option' - значения будут добавляться через JavaScript в карточку
fields=['name', 'position', 'visible'], fields=['name', 'position', 'visible'],
extra=1, extra=1,
@@ -865,11 +866,11 @@ ConfigurableKitProductAttributeFormSetCreate = inlineformset_factory(
) )
# Формсет для редактирования атрибутов родительского товара # Формсет для редактирования атрибутов родительского товара
ConfigurableKitProductAttributeFormSetUpdate = inlineformset_factory( ConfigurableProductAttributeFormSetUpdate = inlineformset_factory(
ConfigurableKitProduct, ConfigurableProduct,
ConfigurableKitProductAttribute, ConfigurableProductAttribute,
form=ConfigurableKitProductAttributeForm, form=ConfigurableProductAttributeForm,
formset=BaseConfigurableKitProductAttributeFormSet, formset=BaseConfigurableProductAttributeFormSet,
# Убрали 'option' - значения будут добавляться через JavaScript в карточку # Убрали 'option' - значения будут добавляться через JavaScript в карточку
fields=['name', 'position', 'visible'], fields=['name', 'position', 'visible'],
extra=0, extra=0,
@@ -878,3 +879,86 @@ ConfigurableKitProductAttributeFormSetUpdate = inlineformset_factory(
validate_min=False, validate_min=False,
can_delete_extra=True, can_delete_extra=True,
) )
# ==========================================
# Формы для справочника атрибутов
# ==========================================
class ProductAttributeForm(forms.ModelForm):
"""Форма для создания и редактирования атрибута"""
class Meta:
model = ProductAttribute
fields = ['name', 'slug', 'description', 'position']
labels = {
'name': 'Название',
'slug': 'Slug (URL)',
'description': 'Описание',
'position': 'Позиция'
}
help_texts = {
'slug': 'Оставьте пустым для автогенерации'
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'Например: Длина стебля'
})
self.fields['slug'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'Автоматически'
})
self.fields['slug'].required = False
self.fields['description'].widget.attrs.update({
'class': 'form-control',
'rows': 2,
'placeholder': 'Опциональное описание'
})
self.fields['position'].widget.attrs.update({
'class': 'form-control',
'style': 'width: 100px;'
})
class ProductAttributeValueForm(forms.ModelForm):
"""Форма для значения атрибута (inline)"""
class Meta:
model = ProductAttributeValue
fields = ['value', 'slug', 'position']
labels = {
'value': 'Значение',
'slug': 'Slug',
'position': 'Позиция'
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['value'].widget.attrs.update({
'class': 'form-control form-control-sm',
'placeholder': 'Например: 50'
})
self.fields['slug'].widget.attrs.update({
'class': 'form-control form-control-sm',
'placeholder': 'Авто'
})
self.fields['slug'].required = False
self.fields['position'].widget.attrs.update({
'class': 'form-control form-control-sm',
'style': 'width: 70px;'
})
ProductAttributeValueFormSet = inlineformset_factory(
ProductAttribute,
ProductAttributeValue,
form=ProductAttributeValueForm,
fields=['value', 'slug', 'position'],
extra=3,
can_delete=True,
min_num=0,
validate_min=False,
)

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-12-23 20:38 # Generated by Django 5.0.10 on 2025-12-29 22:19
import django.db.models.deletion import django.db.models.deletion
import products.models.photos import products.models.photos
@@ -28,6 +28,23 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Компоненты комплектов', 'verbose_name_plural': 'Компоненты комплектов',
}, },
), ),
migrations.CreateModel(
name='ProductAttribute',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Например: Длина стебля, Цвет, Размер', max_length=100, unique=True, verbose_name='Название')),
('slug', models.SlugField(blank=True, help_text='Автоматически генерируется из названия', max_length=100, unique=True, verbose_name='Slug')),
('description', models.TextField(blank=True, help_text='Опциональное описание атрибута', verbose_name='Описание')),
('position', models.PositiveIntegerField(default=0, help_text='Порядок отображения в списке', verbose_name='Позиция')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Атрибут товара',
'verbose_name_plural': 'Атрибуты товаров',
'ordering': ['position', 'name'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='ProductVariantGroup', name='ProductVariantGroup',
fields=[ fields=[
@@ -68,7 +85,7 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='ConfigurableKitProduct', name='ConfigurableProduct',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')), ('name', models.CharField(max_length=200, verbose_name='Название')),
@@ -83,32 +100,19 @@ class Migration(migrations.Migration):
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')), ('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')),
], ],
options={ options={
'verbose_name': 'Вариативный товар (из комплектов)', 'verbose_name': 'Вариативный товар',
'verbose_name_plural': 'Вариативные товары (из комплектов)', 'verbose_name_plural': 'Вариативные товары',
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='ConfigurableKitOption', name='ConfigurableProductAttribute',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attributes', models.JSONField(blank=True, default=dict, help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}', verbose_name='Атрибуты варианта')),
('is_default', models.BooleanField(default=False, verbose_name='Вариант по умолчанию')),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurablekitproduct', verbose_name='Родитель (вариативный товар)')),
],
options={
'verbose_name': 'Вариант комплекта',
'verbose_name_plural': 'Варианты комплектов',
},
),
migrations.CreateModel(
name='ConfigurableKitProductAttribute',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Например: Цвет, Размер, Длина', max_length=150, verbose_name='Название атрибута')), ('name', models.CharField(help_text='Например: Цвет, Размер, Длина', max_length=150, verbose_name='Название атрибута')),
('option', models.CharField(help_text='Например: Красный, M, 60см', max_length=150, verbose_name='Значение опции')), ('option', models.CharField(help_text='Например: Красный, M, 60см', max_length=150, verbose_name='Значение опции')),
('position', models.PositiveIntegerField(default=0, help_text='Меньше = выше в списке', verbose_name='Порядок отображения')), ('position', models.PositiveIntegerField(default=0, help_text='Меньше = выше в списке', verbose_name='Порядок отображения')),
('visible', models.BooleanField(default=True, help_text='Показывать ли атрибут на странице товара', verbose_name='Видимый на витрине')), ('visible', models.BooleanField(default=True, help_text='Показывать ли атрибут на странице товара', verbose_name='Видимый на витрине')),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parent_attributes', to='products.configurablekitproduct', verbose_name='Родительский товар')), ('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parent_attributes', to='products.configurableproduct', verbose_name='Родительский товар')),
], ],
options={ options={
'verbose_name': 'Атрибут вариативного товара', 'verbose_name': 'Атрибут вариативного товара',
@@ -117,11 +121,24 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='ConfigurableKitOptionAttribute', name='ConfigurableProductOption',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('option', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attributes_set', to='products.configurablekitoption', verbose_name='Вариант')), ('attributes', models.JSONField(blank=True, default=dict, help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}', verbose_name='Атрибуты варианта')),
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.configurablekitproductattribute', verbose_name='Значение атрибута')), ('is_default', models.BooleanField(default=False, verbose_name='Вариант по умолчанию')),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurableproduct', verbose_name='Родитель (вариативный товар)')),
],
options={
'verbose_name': 'Вариант товара',
'verbose_name_plural': 'Варианты товаров',
},
),
migrations.CreateModel(
name='ConfigurableProductOptionAttribute',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.configurableproductattribute', verbose_name='Значение атрибута')),
('option', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attributes_set', to='products.configurableproductoption', verbose_name='Вариант')),
], ],
options={ options={
'verbose_name': 'Атрибут варианта', 'verbose_name': 'Атрибут варианта',
@@ -215,6 +232,33 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'], 'ordering': ['-created_at'],
}, },
), ),
migrations.AddField(
model_name='configurableproductoption',
name='product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.product', verbose_name='Товар (вариант)'),
),
migrations.AddField(
model_name='configurableproductattribute',
name='product',
field=models.ForeignKey(blank=True, help_text='Какой Product связан с этим значением атрибута', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_attribute_value_in', to='products.product', verbose_name='Товар для этого значения'),
),
migrations.CreateModel(
name='ProductAttributeValue',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.CharField(help_text='Например: 50, 60, 70 (для длины) или Красный, Белый (для цвета)', max_length=100, verbose_name='Значение')),
('slug', models.SlugField(blank=True, max_length=100, verbose_name='Slug')),
('position', models.PositiveIntegerField(default=0, help_text='Порядок отображения в списке значений', verbose_name='Позиция')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='products.productattribute', verbose_name='Атрибут')),
],
options={
'verbose_name': 'Значение атрибута',
'verbose_name_plural': 'Значения атрибутов',
'ordering': ['position', 'value'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='ProductCategory', name='ProductCategory',
fields=[ fields=[
@@ -292,14 +336,14 @@ class Migration(migrations.Migration):
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productkit', verbose_name='Комплект'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productkit', verbose_name='Комплект'),
), ),
migrations.AddField( migrations.AddField(
model_name='configurablekitproductattribute', model_name='configurableproductoption',
name='kit', name='kit',
field=models.ForeignKey(blank=True, help_text='Какой ProductKit связан с этим значением атрибута', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_attribute_value_in', to='products.productkit', verbose_name='Комплект для этого значения'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.productkit', verbose_name='Комплект (вариант)'),
), ),
migrations.AddField( migrations.AddField(
model_name='configurablekitoption', model_name='configurableproductattribute',
name='kit', name='kit',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.productkit', verbose_name='Комплект (вариант)'), field=models.ForeignKey(blank=True, help_text='Какой ProductKit связан с этим значением атрибута', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_attribute_value_in', to='products.productkit', verbose_name='Комплект для этого значения'),
), ),
migrations.CreateModel( migrations.CreateModel(
name='ProductKitPhoto', name='ProductKitPhoto',
@@ -386,15 +430,15 @@ class Migration(migrations.Migration):
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.productvariantgroup', verbose_name='Группа вариантов'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.productvariantgroup', verbose_name='Группа вариантов'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='configurablekitoptionattribute', model_name='configurableproductoptionattribute',
index=models.Index(fields=['option'], name='products_co_option__93b9f7_idx'), index=models.Index(fields=['option'], name='products_co_option__bd950a_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='configurablekitoptionattribute', model_name='configurableproductoptionattribute',
index=models.Index(fields=['attribute'], name='products_co_attribu_ccc6d9_idx'), index=models.Index(fields=['attribute'], name='products_co_attribu_705d5a_idx'),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='configurablekitoptionattribute', name='configurableproductoptionattribute',
unique_together={('option', 'attribute')}, unique_together={('option', 'attribute')},
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
@@ -409,6 +453,14 @@ class Migration(migrations.Migration):
model_name='costpricehistory', model_name='costpricehistory',
index=models.Index(fields=['reason'], name='products_co_reason_959ee1_idx'), index=models.Index(fields=['reason'], name='products_co_reason_959ee1_idx'),
), ),
migrations.AddIndex(
model_name='productattributevalue',
index=models.Index(fields=['attribute', 'position'], name='products_pr_attribu_460f9e_idx'),
),
migrations.AlterUniqueTogether(
name='productattributevalue',
unique_together={('attribute', 'value')},
),
migrations.AddIndex( migrations.AddIndex(
model_name='productcategory', model_name='productcategory',
index=models.Index(fields=['is_active'], name='products_pr_is_acti_226e74_idx'), index=models.Index(fields=['is_active'], name='products_pr_is_acti_226e74_idx'),
@@ -438,36 +490,40 @@ class Migration(migrations.Migration):
index=models.Index(fields=['quality_warning', 'category'], name='products_pr_quality_fc505a_idx'), index=models.Index(fields=['quality_warning', 'category'], name='products_pr_quality_fc505a_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='configurablekitproductattribute', model_name='configurableproductoption',
index=models.Index(fields=['parent', 'name'], name='products_co_parent__4a7869_idx'), index=models.Index(fields=['parent'], name='products_co_parent__36761a_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='configurablekitproductattribute', model_name='configurableproductoption',
index=models.Index(fields=['parent', 'position'], name='products_co_parent__0904e2_idx'), index=models.Index(fields=['kit'], name='products_co_kit_id_9e9a00_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='configurablekitproductattribute', model_name='configurableproductoption',
index=models.Index(fields=['kit'], name='products_co_kit_id_c5d506_idx'), index=models.Index(fields=['product'], name='products_co_product_4d77ae_idx'),
),
migrations.AlterUniqueTogether(
name='configurablekitproductattribute',
unique_together={('parent', 'name', 'option', 'kit')},
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='configurablekitoption', model_name='configurableproductoption',
index=models.Index(fields=['parent'], name='products_co_parent__56ecfa_idx'), index=models.Index(fields=['parent', 'is_default'], name='products_co_parent__be8830_idx'),
),
migrations.AddConstraint(
model_name='configurableproductoption',
constraint=models.CheckConstraint(check=models.Q(models.Q(('kit__isnull', False), ('product__isnull', True)), models.Q(('kit__isnull', True), ('product__isnull', False)), _connector='OR'), name='configurable_option_kit_xor_product'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='configurablekitoption', model_name='configurableproductattribute',
index=models.Index(fields=['kit'], name='products_co_kit_id_3fa7fe_idx'), index=models.Index(fields=['parent', 'name'], name='products_co_parent__78337c_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='configurablekitoption', model_name='configurableproductattribute',
index=models.Index(fields=['parent', 'is_default'], name='products_co_parent__ffa4ca_idx'), index=models.Index(fields=['parent', 'position'], name='products_co_parent__90f012_idx'),
), ),
migrations.AlterUniqueTogether( migrations.AddIndex(
name='configurablekitoption', model_name='configurableproductattribute',
unique_together={('parent', 'kit')}, index=models.Index(fields=['kit'], name='products_co_kit_id_db7ebb_idx'),
),
migrations.AddIndex(
model_name='configurableproductattribute',
index=models.Index(fields=['product'], name='products_co_product_68c16a_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='productkitphoto', model_name='productkitphoto',

View File

@@ -31,8 +31,14 @@ from .variants import ProductVariantGroup, ProductVariantGroupItem
# Продукты # Продукты
from .products import Product, CostPriceHistory from .products import Product, CostPriceHistory
# Комплекты # Комплекты и вариативные товары
from .kits import ProductKit, KitItem, KitItemPriority, ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute from .kits import (
ProductKit, KitItem, KitItemPriority,
ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute, ConfigurableProductOptionAttribute,
)
# Атрибуты
from .attributes import ProductAttribute, ProductAttributeValue
# Фотографии # Фотографии
from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus
@@ -60,13 +66,18 @@ __all__ = [
'Product', 'Product',
'CostPriceHistory', 'CostPriceHistory',
# Kits # Kits & Configurable Products
'ProductKit', 'ProductKit',
'KitItem', 'KitItem',
'KitItemPriority', 'KitItemPriority',
'ConfigurableKitProduct', 'ConfigurableProduct',
'ConfigurableKitOption', 'ConfigurableProductOption',
'ConfigurableKitProductAttribute', 'ConfigurableProductAttribute',
'ConfigurableProductOptionAttribute',
# Attributes
'ProductAttribute',
'ProductAttributeValue',
# Photos # Photos
'BasePhoto', 'BasePhoto',

View File

@@ -0,0 +1,116 @@
"""
Модели для справочника атрибутов товаров.
Используется для создания переиспользуемых атрибутов (Длина стебля, Цвет, Размер и т.д.)
"""
from django.db import models
from django.utils.text import slugify
from unidecode import unidecode
class ProductAttribute(models.Model):
"""
Справочник атрибутов для вариативных товаров.
Примеры: Длина стебля, Цвет, Размер, Упаковка.
"""
name = models.CharField(
max_length=100,
unique=True,
verbose_name="Название",
help_text="Например: Длина стебля, Цвет, Размер"
)
slug = models.SlugField(
max_length=100,
unique=True,
blank=True,
verbose_name="Slug",
help_text="Автоматически генерируется из названия"
)
description = models.TextField(
blank=True,
verbose_name="Описание",
help_text="Опциональное описание атрибута"
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Позиция",
help_text="Порядок отображения в списке"
)
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 = ['position', 'name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(unidecode(self.name))
super().save(*args, **kwargs)
@property
def values_count(self):
"""Количество значений у атрибута"""
return self.values.count()
class ProductAttributeValue(models.Model):
"""
Значения атрибутов.
Примеры для атрибута "Длина стебля": 50, 60, 70, 80.
"""
attribute = models.ForeignKey(
ProductAttribute,
on_delete=models.CASCADE,
related_name='values',
verbose_name="Атрибут"
)
value = models.CharField(
max_length=100,
verbose_name="Значение",
help_text="Например: 50, 60, 70 (для длины) или Красный, Белый (для цвета)"
)
slug = models.SlugField(
max_length=100,
blank=True,
verbose_name="Slug"
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Позиция",
help_text="Порядок отображения в списке значений"
)
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 = ['position', 'value']
unique_together = ['attribute', 'value']
indexes = [
models.Index(fields=['attribute', 'position']),
]
def __str__(self):
return f"{self.attribute.name}: {self.value}"
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(unidecode(self.value))
super().save(*args, **kwargs)

View File

@@ -435,14 +435,18 @@ class KitItemPriority(models.Model):
return f"{self.product.name} (приоритет {self.priority})" return f"{self.product.name} (приоритет {self.priority})"
class ConfigurableKitProduct(BaseProductEntity): class ConfigurableProduct(BaseProductEntity):
""" """
Вариативный товар, объединяющий несколько наших ProductKit Вариативный товар, объединяющий несколько ProductKit или Product
как варианты для внешних площадок (WooCommerce и подобные). как варианты для внешних площадок (WooCommerce и подобные).
Примеры использования:
- Роза Фридом с вариантами длины стебля (50, 60, 70 см) — варианты это Product
- Букет "Нежность" с вариантами количества роз (15, 25, 51) — варианты это ProductKit
""" """
class Meta: class Meta:
verbose_name = "Вариативный товар (из комплектов)" verbose_name = "Вариативный товар"
verbose_name_plural = "Вариативные товары (из комплектов)" verbose_name_plural = "Вариативные товары"
# Уникальность активного имени наследуется из BaseProductEntity # Уникальность активного имени наследуется из BaseProductEntity
def __str__(self): def __str__(self):
@@ -451,25 +455,25 @@ class ConfigurableKitProduct(BaseProductEntity):
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
""" """
Физическое удаление вариативного товара из БД. Физическое удаление вариативного товара из БД.
При удалении удаляются только связи (ConfigurableKitOption), При удалении удаляются только связи (ConfigurableProductOption),
но сами ProductKit остаются нетронутыми благодаря CASCADE на уровне связей. но сами ProductKit/Product остаются нетронутыми благодаря CASCADE на уровне связей.
""" """
# Используем super() для вызова стандартного Django delete, минуя BaseProductEntity.delete() # Используем super() для вызова стандартного Django delete, минуя BaseProductEntity.delete()
super(BaseProductEntity, self).delete(*args, **kwargs) super(BaseProductEntity, self).delete(*args, **kwargs)
class ConfigurableKitProductAttribute(models.Model): class ConfigurableProductAttribute(models.Model):
""" """
Атрибут родительского вариативного товара с привязкой к ProductKit. Атрибут родительского вариативного товара с привязкой к ProductKit или Product.
Каждое значение атрибута связано с конкретным ProductKit. Каждое значение атрибута может быть связано с ProductKit или Product.
Например: Например:
- Длина: 50 → ProductKit (A) - Длина: 50 → Product (Роза 50см)
- Длина: 60 → ProductKit (B) - Длина: 60 → Product (Роза 60см)
- Длина: 70 → ProductKit (C) - Количество: 15 роз → ProductKit (Букет 15 роз)
""" """
parent = models.ForeignKey( parent = models.ForeignKey(
ConfigurableKitProduct, 'ConfigurableProduct',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='parent_attributes', related_name='parent_attributes',
verbose_name="Родительский товар" verbose_name="Родительский товар"
@@ -484,6 +488,7 @@ class ConfigurableKitProductAttribute(models.Model):
verbose_name="Значение опции", verbose_name="Значение опции",
help_text="Например: Красный, M, 60см" help_text="Например: Красный, M, 60см"
) )
# Один из двух должен быть заполнен (kit XOR product) или оба пустые
kit = models.ForeignKey( kit = models.ForeignKey(
ProductKit, ProductKit,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -493,6 +498,15 @@ class ConfigurableKitProductAttribute(models.Model):
blank=True, blank=True,
null=True null=True
) )
product = models.ForeignKey(
'Product',
on_delete=models.CASCADE,
related_name='as_attribute_value_in',
verbose_name="Товар для этого значения",
help_text="Какой Product связан с этим значением атрибута",
blank=True,
null=True
)
position = models.PositiveIntegerField( position = models.PositiveIntegerField(
default=0, default=0,
verbose_name="Порядок отображения", verbose_name="Порядок отображения",
@@ -508,35 +522,60 @@ class ConfigurableKitProductAttribute(models.Model):
verbose_name = "Атрибут вариативного товара" verbose_name = "Атрибут вариативного товара"
verbose_name_plural = "Атрибуты вариативных товаров" verbose_name_plural = "Атрибуты вариативных товаров"
ordering = ['parent', 'position', 'name', 'option'] ordering = ['parent', 'position', 'name', 'option']
unique_together = [['parent', 'name', 'option', 'kit']]
indexes = [ indexes = [
models.Index(fields=['parent', 'name']), models.Index(fields=['parent', 'name']),
models.Index(fields=['parent', 'position']), models.Index(fields=['parent', 'position']),
models.Index(fields=['kit']), models.Index(fields=['kit']),
models.Index(fields=['product']),
] ]
def __str__(self): def __str__(self):
kit_str = self.kit.name if self.kit else "no kit" variant_str = self.kit.name if self.kit else (self.product.name if self.product else "no variant")
return f"{self.parent.name} - {self.name}: {self.option} ({kit_str})" return f"{self.parent.name} - {self.name}: {self.option} ({variant_str})"
@property
def variant(self):
"""Возвращает связанный вариант (kit или product)"""
return self.kit or self.product
@property
def variant_type(self):
"""Тип варианта: 'kit', 'product' или None"""
if self.kit:
return 'kit'
elif self.product:
return 'product'
return None
class ConfigurableKitOption(models.Model): class ConfigurableProductOption(models.Model):
""" """
Отдельный вариант внутри ConfigurableKitProduct, указывающий на конкретный ProductKit. Отдельный вариант внутри ConfigurableProduct, указывающий на ProductKit ИЛИ Product.
Атрибуты варианта хранятся в структурированном JSON формате. Атрибуты варианта хранятся в структурированном JSON формате.
Пример: {"length": "60", "color": "red"} Пример: {"length": "60", "color": "red"}
""" """
parent = models.ForeignKey( parent = models.ForeignKey(
ConfigurableKitProduct, 'ConfigurableProduct',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='options', related_name='options',
verbose_name="Родитель (вариативный товар)" verbose_name="Родитель (вариативный товар)"
) )
# Один из двух должен быть заполнен (kit XOR product)
kit = models.ForeignKey( kit = models.ForeignKey(
ProductKit, ProductKit,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='as_configurable_option_in', related_name='as_configurable_option_in',
verbose_name="Комплект (вариант)" verbose_name="Комплект (вариант)",
blank=True,
null=True
)
product = models.ForeignKey(
'Product',
on_delete=models.CASCADE,
related_name='as_configurable_option_in',
verbose_name="Товар (вариант)",
blank=True,
null=True
) )
attributes = models.JSONField( attributes = models.JSONField(
default=dict, default=dict,
@@ -550,39 +589,79 @@ class ConfigurableKitOption(models.Model):
) )
class Meta: class Meta:
verbose_name = "Вариант комплекта" verbose_name = "Вариант товара"
verbose_name_plural = "Варианты комплектов" verbose_name_plural = "Варианты товаров"
unique_together = [['parent', 'kit']]
indexes = [ indexes = [
models.Index(fields=['parent']), models.Index(fields=['parent']),
models.Index(fields=['kit']), models.Index(fields=['kit']),
models.Index(fields=['product']),
models.Index(fields=['parent', 'is_default']), models.Index(fields=['parent', 'is_default']),
] ]
constraints = [
# kit XOR product — один из двух должен быть заполнен
models.CheckConstraint(
check=(
models.Q(kit__isnull=False, product__isnull=True) |
models.Q(kit__isnull=True, product__isnull=False)
),
name='configurable_option_kit_xor_product'
),
]
def __str__(self): def __str__(self):
return f"{self.parent.name}{self.kit.name}" variant_name = self.kit.name if self.kit else (self.product.name if self.product else "N/A")
return f"{self.parent.name}{variant_name}"
@property
def variant(self):
"""Возвращает связанный вариант (kit или product)"""
return self.kit or self.product
@property
def variant_type(self):
"""Тип варианта: 'kit' или 'product'"""
return 'kit' if self.kit else 'product'
@property
def variant_name(self):
"""Название варианта"""
return self.variant.name if self.variant else None
@property
def variant_sku(self):
"""SKU варианта"""
return self.variant.sku if self.variant else None
@property
def variant_price(self):
"""Цена варианта"""
if self.kit:
return self.kit.actual_price
elif self.product:
return self.product.sale_price or self.product.price
return None
class ConfigurableKitOptionAttribute(models.Model): class ConfigurableProductOptionAttribute(models.Model):
""" """
Связь между вариантом (ConfigurableKitOption) и Связь между вариантом (ConfigurableProductOption) и
конкретным значением атрибута (ConfigurableKitProductAttribute). конкретным значением атрибута (ConfigurableProductAttribute).
Вместо хранения текстового поля attributes в ConfigurableKitOption, Вместо хранения текстового поля attributes в ConfigurableProductOption,
мы создаем явные связи между вариантом и выбранными значениями атрибутов. мы создаем явные связи между вариантом и выбранными значениями атрибутов.
Пример: Пример:
- option: ConfigurableKitOption (вариант "15 роз 60см") - option: ConfigurableProductOption (вариант "15 роз 60см")
- attribute: ConfigurableKitProductAttribute (Длина: 60) - attribute: ConfigurableProductAttribute (Длина: 60)
""" """
option = models.ForeignKey( option = models.ForeignKey(
ConfigurableKitOption, 'ConfigurableProductOption',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='attributes_set', related_name='attributes_set',
verbose_name="Вариант" verbose_name="Вариант"
) )
attribute = models.ForeignKey( attribute = models.ForeignKey(
ConfigurableKitProductAttribute, 'ConfigurableProductAttribute',
on_delete=models.CASCADE, on_delete=models.CASCADE,
verbose_name="Значение атрибута" verbose_name="Значение атрибута"
) )

View File

@@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% block title %}Удалить атрибут{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-sm-6 col-md-5 col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-body p-4 text-center">
<div class="text-danger mb-3">
<i class="bi bi-exclamation-triangle fs-1"></i>
</div>
<h5 class="mb-3">Удалить атрибут?</h5>
<p class="mb-2">
<strong>{{ object.name }}</strong>
</p>
{% if values_count > 0 %}
<div class="alert alert-warning py-2 small">
<i class="bi bi-exclamation-circle"></i>
Будет удалено <strong>{{ values_count }}</strong> значени{{ values_count|pluralize:"е,я,й" }}
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Удалить
</button>
<a href="{% url 'products:attribute-list' %}" class="btn btn-light btn-sm">Отмена</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,196 @@
{% extends 'base.html' %}
{% block title %}Атрибут: {{ attribute.name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Заголовок -->
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<a href="{% url 'products:attribute-list' %}" class="text-decoration-none text-muted small">
<i class="bi bi-arrow-left"></i> Все атрибуты
</a>
<h4 class="mb-0 mt-1">
<i class="bi bi-sliders text-primary"></i> {{ attribute.name }}
</h4>
<small class="text-muted">{{ attribute.slug }}</small>
</div>
<div class="btn-group">
<a href="{% url 'products:attribute-update' attribute.pk %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil"></i> Изменить
</a>
<a href="{% url 'products:attribute-delete' attribute.pk %}" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash"></i>
</a>
</div>
</div>
{% if attribute.description %}
<p class="text-muted mb-4">{{ attribute.description }}</p>
{% endif %}
<!-- Значения атрибута -->
<div class="card shadow-sm border-0">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span><i class="bi bi-list-ul"></i> Значения ({{ values|length }})</span>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addValueModal">
<i class="bi bi-plus"></i> Добавить
</button>
</div>
<div class="card-body p-0">
{% if values %}
<ul class="list-group list-group-flush">
{% for value in values %}
<li class="list-group-item d-flex justify-content-between align-items-center py-2">
<div>
<span class="fw-medium">{{ value.value }}</span>
<small class="text-muted ms-2">{{ value.slug }}</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark" title="Порядок сортировки"><i class="bi bi-arrows-vertical"></i> {{ value.position }}</span>
<button type="button" class="btn btn-outline-danger btn-sm py-0 px-1 delete-value-btn"
data-value-id="{{ value.pk }}" title="Удалить">
<i class="bi bi-trash"></i>
</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-center text-muted py-4">
<i class="bi bi-list-ul fs-2 opacity-25"></i>
<p class="mb-0 mt-2">Значений пока нет</p>
</div>
{% endif %}
</div>
</div>
<!-- Метаданные -->
<div class="text-muted small mt-3 text-center">
<i class="bi bi-clock-history"></i>
Создан: {{ attribute.created_at|date:"d.m.Y H:i" }} |
Обновлен: {{ attribute.updated_at|date:"d.m.Y H:i" }}
</div>
</div>
</div>
</div>
<!-- Модальное окно добавления значения -->
<div class="modal fade" id="addValueModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Добавить значение</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Значение</label>
<input type="text" id="new-value-input" class="form-control" placeholder="Например: 50">
</div>
<div id="add-value-message"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary btn-sm" id="add-value-btn">
<i class="bi bi-plus"></i> Добавить
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const addBtn = document.getElementById('add-value-btn');
const valueInput = document.getElementById('new-value-input');
const messageDiv = document.getElementById('add-value-message');
// Добавление значения
addBtn.addEventListener('click', async function() {
const value = valueInput.value.trim();
if (!value) {
showMessage('Введите значение', 'danger');
return;
}
addBtn.disabled = true;
addBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const response = await fetch('{% url "products:attribute-add-value" attribute.pk %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({value: value})
});
const data = await response.json();
if (data.success) {
showMessage('Значение добавлено', 'success');
setTimeout(() => window.location.reload(), 500);
} else {
showMessage(data.error, 'danger');
resetBtn();
}
} catch (error) {
showMessage('Ошибка сети', 'danger');
resetBtn();
}
});
// Удаление значения
document.querySelectorAll('.delete-value-btn').forEach(btn => {
btn.addEventListener('click', async function() {
if (!confirm('Удалить это значение?')) return;
const valueId = this.dataset.valueId;
this.disabled = true;
try {
const response = await fetch(`{% url "products:attribute-delete-value" attribute.pk 0 %}`.replace('/0/', `/${valueId}/`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
}
});
const data = await response.json();
if (data.success) {
this.closest('li').remove();
} else {
alert(data.error);
this.disabled = false;
}
} catch (error) {
alert('Ошибка сети');
this.disabled = false;
}
});
});
function showMessage(text, type) {
messageDiv.innerHTML = `<div class="alert alert-${type} py-1 px-2 small mb-0">${text}</div>`;
}
function resetBtn() {
addBtn.disabled = false;
addBtn.innerHTML = '<i class="bi bi-plus"></i> Добавить';
valueInput.focus();
}
// Enter для добавления
valueInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addBtn.click();
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,180 @@
{% extends 'base.html' %}
{% block title %}{% if object %}Редактировать атрибут{% else %}Создать атрибут{% endif %}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm border-0">
<div class="card-body p-4">
<h5 class="text-center mb-4">
<i class="bi bi-sliders{% if not object %}-fill{% endif %} text-primary"></i>
{% if object %}Редактировать атрибут{% else %}Новый атрибут{% endif %}
</h5>
<form method="post">
{% csrf_token %}
<!-- Основные поля атрибута -->
<div class="row mb-3">
<div class="col-md-8">
<label class="form-label">Название <span class="text-danger">*</span></label>
{{ form.name }}
{% if form.name.errors %}
<small class="text-danger">{{ form.name.errors.0 }}</small>
{% endif %}
</div>
<div class="col-md-4">
<label class="form-label"><i class="bi bi-arrows-vertical"></i> Позиция</label>
{{ form.position }}
<small class="text-muted">Порядок сортировки</small>
</div>
</div>
<div class="mb-3">
<label class="form-label">Slug (URL)</label>
{{ form.slug }}
<small class="text-muted">{{ form.slug.help_text }}</small>
{% if form.slug.errors %}
<small class="text-danger d-block">{{ form.slug.errors.0 }}</small>
{% endif %}
</div>
<div class="mb-4">
<label class="form-label">Описание</label>
{{ form.description }}
</div>
<!-- Значения атрибута (inline formset) -->
<hr>
<h6 class="mb-3">
<i class="bi bi-list-ul"></i> Значения атрибута
</h6>
{{ value_formset.management_form }}
<!-- Заголовки столбцов -->
<div class="row align-items-center g-2 mb-2 small text-muted fw-medium">
<div class="col">Значение</div>
<div class="col-auto" style="width: 120px;">Slug</div>
<div class="col-auto" style="width: 80px;" title="Порядок сортировки"><i class="bi bi-arrows-vertical"></i> Поз.</div>
<div class="col-auto" style="width: 40px;"></div>
</div>
<div id="value-formset">
{% for value_form in value_formset %}
<div class="value-row mb-2 {% if value_form.instance.pk %}{% else %}empty-form{% endif %}">
<div class="row align-items-center g-2">
<div class="col">
{{ value_form.value }}
{% if value_form.value.errors %}
<small class="text-danger">{{ value_form.value.errors.0 }}</small>
{% endif %}
</div>
<div class="col-auto" style="width: 120px;">
{{ value_form.slug }}
</div>
<div class="col-auto" style="width: 80px;">
{{ value_form.position }}
</div>
<div class="col-auto">
{% if value_form.instance.pk %}
<div class="form-check">
{{ value_form.DELETE }}
<label class="form-check-label text-danger small" for="{{ value_form.DELETE.id_for_label }}">
<i class="bi bi-trash"></i>
</label>
</div>
{% else %}
<button type="button" class="btn btn-outline-danger btn-sm remove-value-row">
<i class="bi bi-x"></i>
</button>
{% endif %}
</div>
{{ value_form.id }}
</div>
</div>
{% endfor %}
</div>
<button type="button" id="add-value-btn" class="btn btn-outline-secondary btn-sm mt-2">
<i class="bi bi-plus"></i> Добавить значение
</button>
<hr class="my-4">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2"></i>
{% if object %}Сохранить{% else %}Создать{% endif %}
</button>
<a href="{% url 'products:attribute-list' %}" class="btn btn-light btn-sm">Отмена</a>
</div>
</form>
{% if object %}
<hr class="my-3">
<small class="text-muted d-block text-center">
<i class="bi bi-clock-history"></i> Обновлено: {{ object.updated_at|date:"d.m.Y H:i" }}
</small>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const formset = document.getElementById('value-formset');
const addBtn = document.getElementById('add-value-btn');
const totalForms = document.querySelector('[name="values-TOTAL_FORMS"]');
// Шаблон для новой строки
function createValueRow(index) {
const row = document.createElement('div');
row.className = 'value-row mb-2';
row.innerHTML = `
<div class="row align-items-center g-2">
<div class="col">
<input type="text" name="values-${index}-value" class="form-control form-control-sm" placeholder="Например: 50">
</div>
<div class="col-auto" style="width: 120px;">
<input type="text" name="values-${index}-slug" class="form-control form-control-sm" placeholder="Авто">
</div>
<div class="col-auto" style="width: 80px;">
<input type="number" name="values-${index}-position" value="0" class="form-control form-control-sm" style="width: 70px;">
</div>
<div class="col-auto">
<button type="button" class="btn btn-outline-danger btn-sm remove-value-row">
<i class="bi bi-x"></i>
</button>
</div>
<input type="hidden" name="values-${index}-id" value="">
</div>
`;
return row;
}
// Добавление новой строки
addBtn.addEventListener('click', function() {
const currentTotal = parseInt(totalForms.value);
const newRow = createValueRow(currentTotal);
formset.appendChild(newRow);
totalForms.value = currentTotal + 1;
// Фокус на новое поле
newRow.querySelector('input[type="text"]').focus();
});
// Удаление строки (делегирование событий)
formset.addEventListener('click', function(e) {
if (e.target.closest('.remove-value-row')) {
const row = e.target.closest('.value-row');
row.remove();
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends 'base.html' %}
{% block title %}Атрибуты товаров{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Заголовок и кнопка создания -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0"><i class="bi bi-sliders text-primary"></i> Атрибуты товаров</h5>
<a href="{% url 'products:attribute-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg"></i> Новый атрибут
</a>
</div>
<!-- Поиск -->
<form method="get" class="d-flex gap-2 mb-3">
<input type="text" class="form-control form-control-sm" name="search"
value="{{ search_query }}" placeholder="Поиск по названию...">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-search"></i>
</button>
{% if search_query %}
<a href="{% url 'products:attribute-list' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x"></i>
</a>
{% endif %}
</form>
<!-- Список атрибутов -->
{% if attributes %}
<!-- Заголовки столбцов -->
<div class="d-flex align-items-center py-2 px-3 bg-light border rounded-top small fw-medium text-muted">
<div class="flex-grow-1">Название</div>
<div class="d-flex align-items-center gap-2">
<span style="min-width: 70px; text-align: center;">Значения</span>
<span style="min-width: 60px; text-align: center;">Действия</span>
</div>
</div>
<div class="list-group list-group-flush border border-top-0 rounded-bottom">
{% for attr in attributes %}
<div class="list-group-item d-flex align-items-center py-2 px-3">
<div class="flex-grow-1">
<a href="{% url 'products:attribute-detail' attr.pk %}" class="text-decoration-none fw-medium">
{{ attr.name }}
</a>
<small class="text-muted ms-2">{{ attr.slug }}</small>
{% if attr.description %}
<small class="text-muted d-block">{{ attr.description|truncatewords:10 }}</small>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary" title="Количество значений">
{{ attr.num_values }} знач.
</span>
<div class="btn-group btn-group-sm">
<a href="{% url 'products:attribute-update' attr.pk %}"
class="btn btn-outline-secondary btn-sm py-0 px-1" title="Изменить">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'products:attribute-delete' attr.pk %}"
class="btn btn-outline-danger btn-sm py-0 px-1" title="Удалить">
<i class="bi bi-trash"></i>
</a>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Пагинация -->
{% if is_paginated %}
<nav class="mt-3">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}">
&laquo;
</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }}/{{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}">
&raquo;
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-sliders fs-1 opacity-25"></i>
<p class="mb-0 mt-2">Атрибутов пока нет</p>
<p class="small">Создайте первый атрибут, например "Длина стебля"</p>
<a href="{% url 'products:attribute-create' %}" class="btn btn-primary btn-sm mt-2">
<i class="bi bi-plus-lg"></i> Создать атрибут
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -86,14 +86,26 @@ urlpatterns = [
path('tags/<int:pk>/update/', views.ProductTagUpdateView.as_view(), name='tag-update'), path('tags/<int:pk>/update/', views.ProductTagUpdateView.as_view(), name='tag-update'),
path('tags/<int:pk>/delete/', views.ProductTagDeleteView.as_view(), name='tag-delete'), path('tags/<int:pk>/delete/', views.ProductTagDeleteView.as_view(), name='tag-delete'),
# CRUD URLs for ConfigurableKitProduct # CRUD URLs for ProductAttribute (справочник атрибутов)
path('configurable-kits/', views.ConfigurableKitProductListView.as_view(), name='configurablekit-list'), path('attributes/', views.ProductAttributeListView.as_view(), name='attribute-list'),
path('configurable-kits/create/', views.ConfigurableKitProductCreateView.as_view(), name='configurablekit-create'), path('attributes/create/', views.ProductAttributeCreateView.as_view(), name='attribute-create'),
path('configurable-kits/<int:pk>/', views.ConfigurableKitProductDetailView.as_view(), name='configurablekit-detail'), path('attributes/<int:pk>/', views.ProductAttributeDetailView.as_view(), name='attribute-detail'),
path('configurable-kits/<int:pk>/update/', views.ConfigurableKitProductUpdateView.as_view(), name='configurablekit-update'), path('attributes/<int:pk>/update/', views.ProductAttributeUpdateView.as_view(), name='attribute-update'),
path('configurable-kits/<int:pk>/delete/', views.ConfigurableKitProductDeleteView.as_view(), name='configurablekit-delete'), path('attributes/<int:pk>/delete/', views.ProductAttributeDeleteView.as_view(), name='attribute-delete'),
# API для управления вариантами ConfigurableKitProduct # API для атрибутов
path('api/attributes/create/', views.create_attribute_api, name='api-attribute-create'),
path('api/attributes/<int:pk>/values/add/', views.add_attribute_value_api, name='attribute-add-value'),
path('api/attributes/<int:pk>/values/<int:value_id>/delete/', views.delete_attribute_value_api, name='attribute-delete-value'),
# CRUD URLs for ConfigurableProduct
path('configurable-kits/', views.ConfigurableProductListView.as_view(), name='configurablekit-list'),
path('configurable-kits/create/', views.ConfigurableProductCreateView.as_view(), name='configurablekit-create'),
path('configurable-kits/<int:pk>/', views.ConfigurableProductDetailView.as_view(), name='configurablekit-detail'),
path('configurable-kits/<int:pk>/update/', views.ConfigurableProductUpdateView.as_view(), name='configurablekit-update'),
path('configurable-kits/<int:pk>/delete/', views.ConfigurableProductDeleteView.as_view(), name='configurablekit-delete'),
# API для управления вариантами ConfigurableProduct
path('configurable-kits/<int:pk>/options/add/', views.add_option_to_configurable, name='configurablekit-add-option'), path('configurable-kits/<int:pk>/options/add/', views.add_option_to_configurable, name='configurablekit-add-option'),
path('configurable-kits/<int:pk>/options/<int:option_id>/remove/', views.remove_option_from_configurable, name='configurablekit-remove-option'), path('configurable-kits/<int:pk>/options/<int:option_id>/remove/', views.remove_option_from_configurable, name='configurablekit-remove-option'),
path('configurable-kits/<int:pk>/options/<int:option_id>/set-default/', views.set_option_as_default, name='configurablekit-set-default-option'), path('configurable-kits/<int:pk>/options/<int:option_id>/set-default/', views.set_option_as_default, name='configurablekit-set-default-option'),

View File

@@ -80,18 +80,30 @@ from .tag_views import (
ProductTagDeleteView, ProductTagDeleteView,
) )
# CRUD представления для ConfigurableKitProduct # CRUD представления для ConfigurableProduct
from .configurablekit_views import ( from .configurablekit_views import (
ConfigurableKitProductListView, ConfigurableProductListView,
ConfigurableKitProductCreateView, ConfigurableProductCreateView,
ConfigurableKitProductDetailView, ConfigurableProductDetailView,
ConfigurableKitProductUpdateView, ConfigurableProductUpdateView,
ConfigurableKitProductDeleteView, ConfigurableProductDeleteView,
add_option_to_configurable, add_option_to_configurable,
remove_option_from_configurable, remove_option_from_configurable,
set_option_as_default, set_option_as_default,
) )
# CRUD представления для ProductAttribute (справочник атрибутов)
from .attribute_views import (
ProductAttributeListView,
ProductAttributeCreateView,
ProductAttributeDetailView,
ProductAttributeUpdateView,
ProductAttributeDeleteView,
create_attribute_api,
add_attribute_value_api,
delete_attribute_value_api,
)
# API представления # API представления
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_api from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_api
@@ -162,16 +174,26 @@ __all__ = [
'ProductTagUpdateView', 'ProductTagUpdateView',
'ProductTagDeleteView', 'ProductTagDeleteView',
# ConfigurableKitProduct CRUD # ConfigurableProduct CRUD
'ConfigurableKitProductListView', 'ConfigurableProductListView',
'ConfigurableKitProductCreateView', 'ConfigurableProductCreateView',
'ConfigurableKitProductDetailView', 'ConfigurableProductDetailView',
'ConfigurableKitProductUpdateView', 'ConfigurableProductUpdateView',
'ConfigurableKitProductDeleteView', 'ConfigurableProductDeleteView',
'add_option_to_configurable', 'add_option_to_configurable',
'remove_option_from_configurable', 'remove_option_from_configurable',
'set_option_as_default', 'set_option_as_default',
# ProductAttribute CRUD
'ProductAttributeListView',
'ProductAttributeCreateView',
'ProductAttributeDetailView',
'ProductAttributeUpdateView',
'ProductAttributeDeleteView',
'create_attribute_api',
'add_attribute_value_api',
'delete_attribute_value_api',
# API # API
'search_products_and_variants', 'search_products_and_variants',
'validate_kit_cost', 'validate_kit_cost',

View File

@@ -0,0 +1,247 @@
"""
CRUD представления для справочника атрибутов товаров (ProductAttribute, ProductAttributeValue).
"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
from django.urls import reverse_lazy
from django.db.models import Q, Count
from django.db import IntegrityError
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
import json
from ..models import ProductAttribute, ProductAttributeValue
from ..forms import ProductAttributeForm, ProductAttributeValueFormSet
class ProductAttributeListView(LoginRequiredMixin, ListView):
"""Список всех атрибутов с поиском"""
model = ProductAttribute
template_name = 'products/attribute_list.html'
context_object_name = 'attributes'
paginate_by = 20
def get_queryset(self):
queryset = super().get_queryset()
# Аннотируем количество значений для каждого атрибута
queryset = queryset.annotate(
num_values=Count('values', distinct=True)
)
# Поиск по названию и slug
search_query = self.request.GET.get('search')
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(slug__icontains=search_query)
)
return queryset.order_by('position', 'name')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
return context
class ProductAttributeDetailView(LoginRequiredMixin, DetailView):
"""Детальная информация об атрибуте с его значениями"""
model = ProductAttribute
template_name = 'products/attribute_detail.html'
context_object_name = 'attribute'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
attribute = self.get_object()
# Получаем все значения атрибута
context['values'] = attribute.values.all().order_by('position', 'value')
return context
class ProductAttributeCreateView(LoginRequiredMixin, CreateView):
"""Создание нового атрибута с inline значениями"""
model = ProductAttribute
form_class = ProductAttributeForm
template_name = 'products/attribute_form.html'
success_url = reverse_lazy('products:attribute-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.POST:
context['value_formset'] = ProductAttributeValueFormSet(self.request.POST, instance=self.object)
else:
context['value_formset'] = ProductAttributeValueFormSet(instance=self.object)
return context
def form_valid(self, form):
context = self.get_context_data()
value_formset = context['value_formset']
try:
self.object = form.save()
if value_formset.is_valid():
value_formset.instance = self.object
value_formset.save()
else:
return self.form_invalid(form)
messages.success(self.request, f'Атрибут "{self.object.name}" успешно создан.')
return super().form_valid(form)
except IntegrityError as e:
error_msg = str(e).lower()
if 'unique' in error_msg:
messages.error(
self.request,
f'Ошибка: атрибут с таким названием уже существует.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении атрибута. Пожалуйста, проверьте введённые данные.'
)
return self.form_invalid(form)
class ProductAttributeUpdateView(LoginRequiredMixin, UpdateView):
"""Редактирование существующего атрибута с inline значениями"""
model = ProductAttribute
form_class = ProductAttributeForm
template_name = 'products/attribute_form.html'
success_url = reverse_lazy('products:attribute-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.POST:
context['value_formset'] = ProductAttributeValueFormSet(self.request.POST, instance=self.object)
else:
context['value_formset'] = ProductAttributeValueFormSet(instance=self.object)
return context
def form_valid(self, form):
context = self.get_context_data()
value_formset = context['value_formset']
try:
self.object = form.save()
if value_formset.is_valid():
value_formset.save()
else:
return self.form_invalid(form)
messages.success(self.request, f'Атрибут "{self.object.name}" успешно обновлен.')
return super().form_valid(form)
except IntegrityError as e:
error_msg = str(e).lower()
if 'unique' in error_msg:
messages.error(
self.request,
f'Ошибка: атрибут с таким названием уже существует.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении атрибута. Пожалуйста, проверьте введённые данные.'
)
return self.form_invalid(form)
class ProductAttributeDeleteView(LoginRequiredMixin, DeleteView):
"""Удаление атрибута с подтверждением"""
model = ProductAttribute
template_name = 'products/attribute_confirm_delete.html'
success_url = reverse_lazy('products:attribute-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
attribute = self.get_object()
# Количество значений
context['values_count'] = attribute.values.count()
return context
def delete(self, request, *args, **kwargs):
attribute = self.get_object()
attribute_name = attribute.name
response = super().delete(request, *args, **kwargs)
messages.success(request, f'Атрибут "{attribute_name}" успешно удален.')
return response
# API endpoints
@login_required
@require_POST
def create_attribute_api(request):
"""API для быстрого создания атрибута"""
try:
data = json.loads(request.body)
name = data.get('name', '').strip()
if not name:
return JsonResponse({'success': False, 'error': 'Название обязательно'})
attribute = ProductAttribute.objects.create(name=name)
return JsonResponse({
'success': True,
'id': attribute.pk,
'name': attribute.name,
'slug': attribute.slug
})
except IntegrityError:
return JsonResponse({'success': False, 'error': 'Атрибут с таким названием уже существует'})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})
@login_required
@require_POST
def add_attribute_value_api(request, pk):
"""API для добавления значения к атрибуту"""
try:
data = json.loads(request.body)
value = data.get('value', '').strip()
if not value:
return JsonResponse({'success': False, 'error': 'Значение обязательно'})
attribute = ProductAttribute.objects.get(pk=pk)
attr_value = ProductAttributeValue.objects.create(
attribute=attribute,
value=value
)
return JsonResponse({
'success': True,
'id': attr_value.pk,
'value': attr_value.value,
'slug': attr_value.slug
})
except ProductAttribute.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Атрибут не найден'})
except IntegrityError:
return JsonResponse({'success': False, 'error': 'Такое значение уже существует'})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})
@login_required
@require_POST
def delete_attribute_value_api(request, pk, value_id):
"""API для удаления значения атрибута"""
try:
value = ProductAttributeValue.objects.get(pk=value_id, attribute_id=pk)
value.delete()
return JsonResponse({'success': True})
except ProductAttributeValue.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Значение не найдено'})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})

View File

@@ -1,5 +1,5 @@
""" """
CRUD представления для вариативных товаров (ConfigurableKitProduct). CRUD представления для вариативных товаров (ConfigurableProduct).
""" """
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@@ -13,18 +13,18 @@ from django.contrib.auth.decorators import login_required
from django.db import transaction from django.db import transaction
from user_roles.mixins import ManagerOwnerRequiredMixin from user_roles.mixins import ManagerOwnerRequiredMixin
from ..models import ConfigurableKitProduct, ConfigurableKitOption, ProductKit, ConfigurableKitProductAttribute from ..models import ConfigurableProduct, ConfigurableProductOption, ProductKit, ConfigurableProductAttribute
from ..forms import ( from ..forms import (
ConfigurableKitProductForm, ConfigurableProductForm,
ConfigurableKitOptionFormSetCreate, ConfigurableProductOptionFormSetCreate,
ConfigurableKitOptionFormSetUpdate, ConfigurableProductOptionFormSetUpdate,
ConfigurableKitProductAttributeFormSetCreate, ConfigurableProductAttributeFormSetCreate,
ConfigurableKitProductAttributeFormSetUpdate ConfigurableProductAttributeFormSetUpdate
) )
class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView): class ConfigurableProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
model = ConfigurableKitProduct model = ConfigurableProduct
template_name = 'products/configurablekit_list.html' template_name = 'products/configurablekit_list.html'
context_object_name = 'configurable_kits' context_object_name = 'configurable_kits'
paginate_by = 20 paginate_by = 20
@@ -33,7 +33,7 @@ class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMix
queryset = super().get_queryset().prefetch_related( queryset = super().get_queryset().prefetch_related(
Prefetch( Prefetch(
'options', 'options',
queryset=ConfigurableKitOption.objects.select_related('kit') queryset=ConfigurableProductOption.objects.select_related('kit')
) )
) )
@@ -80,8 +80,8 @@ class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMix
return context return context
class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView): class ConfigurableProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView):
model = ConfigurableKitProduct model = ConfigurableProduct
template_name = 'products/configurablekit_detail.html' template_name = 'products/configurablekit_detail.html'
context_object_name = 'configurable_kit' context_object_name = 'configurable_kit'
@@ -89,7 +89,7 @@ class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredM
return super().get_queryset().prefetch_related( return super().get_queryset().prefetch_related(
Prefetch( Prefetch(
'options', 'options',
queryset=ConfigurableKitOption.objects.select_related('kit').order_by('id') queryset=ConfigurableProductOption.objects.select_related('kit').order_by('id')
), ),
'parent_attributes' 'parent_attributes'
) )
@@ -104,9 +104,9 @@ class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredM
return context return context
class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView): class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView):
model = ConfigurableKitProduct model = ConfigurableProduct
form_class = ConfigurableKitProductForm form_class = ConfigurableProductForm
template_name = 'products/configurablekit_form.html' template_name = 'products/configurablekit_form.html'
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -116,12 +116,12 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
if 'option_formset' in kwargs: if 'option_formset' in kwargs:
context['option_formset'] = kwargs['option_formset'] context['option_formset'] = kwargs['option_formset']
elif self.request.POST: elif self.request.POST:
context['option_formset'] = ConfigurableKitOptionFormSetCreate( context['option_formset'] = ConfigurableProductOptionFormSetCreate(
self.request.POST, self.request.POST,
prefix='options' prefix='options'
) )
else: else:
context['option_formset'] = ConfigurableKitOptionFormSetCreate( context['option_formset'] = ConfigurableProductOptionFormSetCreate(
prefix='options' prefix='options'
) )
@@ -129,12 +129,12 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
if 'attribute_formset' in kwargs: if 'attribute_formset' in kwargs:
context['attribute_formset'] = kwargs['attribute_formset'] context['attribute_formset'] = kwargs['attribute_formset']
elif self.request.POST: elif self.request.POST:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate( context['attribute_formset'] = ConfigurableProductAttributeFormSetCreate(
self.request.POST, self.request.POST,
prefix='attributes' prefix='attributes'
) )
else: else:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate( context['attribute_formset'] = ConfigurableProductAttributeFormSetCreate(
prefix='attributes' prefix='attributes'
) )
@@ -147,14 +147,14 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
return context return context
def form_valid(self, form): def form_valid(self, form):
from products.models.kits import ConfigurableKitOptionAttribute from products.models.kits import ConfigurableProductOptionAttribute
# Пересоздаём formsets с POST данными # Пересоздаём formsets с POST данными
option_formset = ConfigurableKitOptionFormSetCreate( option_formset = ConfigurableProductOptionFormSetCreate(
self.request.POST, self.request.POST,
prefix='options' prefix='options'
) )
attribute_formset = ConfigurableKitProductAttributeFormSetCreate( attribute_formset = ConfigurableProductAttributeFormSetCreate(
self.request.POST, self.request.POST,
prefix='attributes' prefix='attributes'
) )
@@ -212,7 +212,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
# Сохраняем выбранные атрибуты для этого варианта # Сохраняем выбранные атрибуты для этого варианта
for field_name, field_value in option_form.cleaned_data.items(): for field_name, field_value in option_form.cleaned_data.items():
if field_name.startswith('attribute_') and field_value: if field_name.startswith('attribute_') and field_value:
ConfigurableKitOptionAttribute.objects.create( ConfigurableProductOptionAttribute.objects.create(
option=option, option=option,
attribute=field_value attribute=field_value
) )
@@ -250,7 +250,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
from products.models.kits import ProductKit from products.models.kits import ProductKit
# Сначала удаляем все старые атрибуты # Сначала удаляем все старые атрибуты
ConfigurableKitProductAttribute.objects.filter(parent=self.object).delete() ConfigurableProductAttribute.objects.filter(parent=self.object).delete()
# Получаем количество карточек параметров # Получаем количество карточек параметров
total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0') total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0')
@@ -293,7 +293,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
kit_ids = [] kit_ids = []
# Создаём ConfigurableKitProductAttribute для каждого значения # Создаём ConfigurableProductAttribute для каждого значения
for value_idx, value in enumerate(values): for value_idx, value in enumerate(values):
if value and value.strip(): if value and value.strip():
# Получаем соответствующий ID комплекта # Получаем соответствующий ID комплекта
@@ -317,7 +317,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
# Комплект не найден - создаём без привязки # Комплект не найден - создаём без привязки
pass pass
ConfigurableKitProductAttribute.objects.create(**create_kwargs) ConfigurableProductAttribute.objects.create(**create_kwargs)
def _validate_variant_kits(self, option_formset): def _validate_variant_kits(self, option_formset):
""" """
@@ -376,9 +376,9 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk}) return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk})
class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView): class ConfigurableProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView):
model = ConfigurableKitProduct model = ConfigurableProduct
form_class = ConfigurableKitProductForm form_class = ConfigurableProductForm
template_name = 'products/configurablekit_form.html' template_name = 'products/configurablekit_form.html'
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -388,13 +388,13 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
if 'option_formset' in kwargs: if 'option_formset' in kwargs:
context['option_formset'] = kwargs['option_formset'] context['option_formset'] = kwargs['option_formset']
elif self.request.POST: elif self.request.POST:
context['option_formset'] = ConfigurableKitOptionFormSetUpdate( context['option_formset'] = ConfigurableProductOptionFormSetUpdate(
self.request.POST, self.request.POST,
instance=self.object, instance=self.object,
prefix='options' prefix='options'
) )
else: else:
context['option_formset'] = ConfigurableKitOptionFormSetUpdate( context['option_formset'] = ConfigurableProductOptionFormSetUpdate(
instance=self.object, instance=self.object,
prefix='options' prefix='options'
) )
@@ -403,13 +403,13 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
if 'attribute_formset' in kwargs: if 'attribute_formset' in kwargs:
context['attribute_formset'] = kwargs['attribute_formset'] context['attribute_formset'] = kwargs['attribute_formset']
elif self.request.POST: elif self.request.POST:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate( context['attribute_formset'] = ConfigurableProductAttributeFormSetUpdate(
self.request.POST, self.request.POST,
instance=self.object, instance=self.object,
prefix='attributes' prefix='attributes'
) )
else: else:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate( context['attribute_formset'] = ConfigurableProductAttributeFormSetUpdate(
instance=self.object, instance=self.object,
prefix='attributes' prefix='attributes'
) )
@@ -423,15 +423,15 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
return context return context
def form_valid(self, form): def form_valid(self, form):
from products.models.kits import ConfigurableKitOptionAttribute from products.models.kits import ConfigurableProductOptionAttribute
# Пересоздаём formsets с POST данными # Пересоздаём formsets с POST данными
option_formset = ConfigurableKitOptionFormSetUpdate( option_formset = ConfigurableProductOptionFormSetUpdate(
self.request.POST, self.request.POST,
instance=self.object, instance=self.object,
prefix='options' prefix='options'
) )
attribute_formset = ConfigurableKitProductAttributeFormSetUpdate( attribute_formset = ConfigurableProductAttributeFormSetUpdate(
self.request.POST, self.request.POST,
instance=self.object, instance=self.object,
prefix='attributes' prefix='attributes'
@@ -489,7 +489,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
# Сохраняем выбранные атрибуты для этого варианта # Сохраняем выбранные атрибуты для этого варианта
for field_name, field_value in option_form.cleaned_data.items(): for field_name, field_value in option_form.cleaned_data.items():
if field_name.startswith('attribute_') and field_value: if field_name.startswith('attribute_') and field_value:
ConfigurableKitOptionAttribute.objects.create( ConfigurableProductOptionAttribute.objects.create(
option=option, option=option,
attribute=field_value attribute=field_value
) )
@@ -527,7 +527,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
from products.models.kits import ProductKit from products.models.kits import ProductKit
# Сначала удаляем все старые атрибуты # Сначала удаляем все старые атрибуты
ConfigurableKitProductAttribute.objects.filter(parent=self.object).delete() ConfigurableProductAttribute.objects.filter(parent=self.object).delete()
# Получаем количество карточек параметров # Получаем количество карточек параметров
total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0') total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0')
@@ -570,7 +570,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
kit_ids = [] kit_ids = []
# Создаём ConfigurableKitProductAttribute для каждого значения # Создаём ConfigurableProductAttribute для каждого значения
for value_idx, value in enumerate(values): for value_idx, value in enumerate(values):
if value and value.strip(): if value and value.strip():
# Получаем соответствующий ID комплекта # Получаем соответствующий ID комплекта
@@ -594,7 +594,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
# Комплект не найден - создаём без привязки # Комплект не найден - создаём без привязки
pass pass
ConfigurableKitProductAttribute.objects.create(**create_kwargs) ConfigurableProductAttribute.objects.create(**create_kwargs)
def _validate_variant_kits(self, option_formset): def _validate_variant_kits(self, option_formset):
""" """
@@ -653,8 +653,8 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk}) return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk})
class ConfigurableKitProductDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView): class ConfigurableProductDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView):
model = ConfigurableKitProduct model = ConfigurableProduct
template_name = 'products/configurablekit_confirm_delete.html' template_name = 'products/configurablekit_confirm_delete.html'
success_url = reverse_lazy('products:configurablekit-list') success_url = reverse_lazy('products:configurablekit-list')
@@ -671,7 +671,7 @@ def add_option_to_configurable(request, pk):
""" """
Добавить вариант (комплект) к вариативному товару. Добавить вариант (комплект) к вариативному товару.
""" """
configurable = get_object_or_404(ConfigurableKitProduct, pk=pk) configurable = get_object_or_404(ConfigurableProduct, pk=pk)
kit_id = request.POST.get('kit_id') kit_id = request.POST.get('kit_id')
attributes = request.POST.get('attributes', '') attributes = request.POST.get('attributes', '')
is_default = request.POST.get('is_default') == 'true' is_default = request.POST.get('is_default') == 'true'
@@ -685,15 +685,15 @@ def add_option_to_configurable(request, pk):
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404) return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
# Проверяем, не добавлен ли уже этот комплект # Проверяем, не добавлен ли уже этот комплект
if ConfigurableKitOption.objects.filter(parent=configurable, kit=kit).exists(): if ConfigurableProductOption.objects.filter(parent=configurable, kit=kit).exists():
return JsonResponse({'success': False, 'error': 'Этот комплект уже добавлен как вариант'}, status=400) return JsonResponse({'success': False, 'error': 'Этот комплект уже добавлен как вариант'}, status=400)
# Если is_default=True, снимаем флаг с других # Если is_default=True, снимаем флаг с других
if is_default: if is_default:
ConfigurableKitOption.objects.filter(parent=configurable, is_default=True).update(is_default=False) ConfigurableProductOption.objects.filter(parent=configurable, is_default=True).update(is_default=False)
# Создаём вариант # Создаём вариант
option = ConfigurableKitOption.objects.create( option = ConfigurableProductOption.objects.create(
parent=configurable, parent=configurable,
kit=kit, kit=kit,
attributes=attributes, attributes=attributes,
@@ -720,8 +720,8 @@ def remove_option_from_configurable(request, pk, option_id):
""" """
Удалить вариант из вариативного товара. Удалить вариант из вариативного товара.
""" """
configurable = get_object_or_404(ConfigurableKitProduct, pk=pk) configurable = get_object_or_404(ConfigurableProduct, pk=pk)
option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable) option = get_object_or_404(ConfigurableProductOption, pk=option_id, parent=configurable)
option.delete() option.delete()
@@ -734,11 +734,11 @@ def set_option_as_default(request, pk, option_id):
""" """
Установить вариант как по умолчанию. Установить вариант как по умолчанию.
""" """
configurable = get_object_or_404(ConfigurableKitProduct, pk=pk) configurable = get_object_or_404(ConfigurableProduct, pk=pk)
option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable) option = get_object_or_404(ConfigurableProductOption, pk=option_id, parent=configurable)
# Снимаем флаг со всех других # Снимаем флаг со всех других
ConfigurableKitOption.objects.filter(parent=configurable).update(is_default=False) ConfigurableProductOption.objects.filter(parent=configurable).update(is_default=False)
# Устанавливаем текущий # Устанавливаем текущий
option.is_default = True option.is_default = True

View File

@@ -32,6 +32,7 @@
<li><a class="dropdown-item" href="{% url 'products:category-list' %}">Категории</a></li> <li><a class="dropdown-item" href="{% url 'products:category-list' %}">Категории</a></li>
<li><a class="dropdown-item" href="{% url 'products:tag-list' %}">Теги</a></li> <li><a class="dropdown-item" href="{% url 'products:tag-list' %}">Теги</a></li>
<li><a class="dropdown-item" href="{% url 'products:variantgroup-list' %}">Варианты (группы)</a></li> <li><a class="dropdown-item" href="{% url 'products:variantgroup-list' %}">Варианты (группы)</a></li>
<li><a class="dropdown-item" href="{% url 'products:attribute-list' %}">Атрибуты</a></li>
</ul> </ul>
</li> </li>

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-12-23 20:38 # Generated by Django 5.0.10 on 2025-12-29 22:19
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2025-12-23 20:38 # Generated by Django 5.0.10 on 2025-12-29 22:19
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings

9
start_all.bat Normal file
View File

@@ -0,0 +1,9 @@
@echo off
REM Запуск Django сервера в новом окне
start cmd /k "venv\Scripts\activate && cd myproject && python manage.py runserver"
REM Запуск Celery worker в новом окне
start cmd /k "venv\Scripts\activate && .\start_celery.bat"
REM Запуск Celery beat в новом окне
start cmd /k "venv\Scripts\activate && cd myproject && celery -A myproject beat -l info"