Рефакторинг системы вариативных товаров и справочник атрибутов
Основные изменения: - Переименование 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:
@@ -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
|
||||||
|
|||||||
@@ -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='Клиент')),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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='Время доставки до'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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='Телефон получателя'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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='Дополнительная информация'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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='Телефон получателя'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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='Необходимо фото товара'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
116
myproject/products/models/attributes.py
Normal file
116
myproject/products/models/attributes.py
Normal 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)
|
||||||
@@ -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="Значение атрибута"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 %}
|
||||||
196
myproject/products/templates/products/attribute_detail.html
Normal file
196
myproject/products/templates/products/attribute_detail.html
Normal 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 %}
|
||||||
180
myproject/products/templates/products/attribute_form.html
Normal file
180
myproject/products/templates/products/attribute_form.html
Normal 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 %}
|
||||||
110
myproject/products/templates/products/attribute_list.html
Normal file
110
myproject/products/templates/products/attribute_list.html
Normal 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 %}">
|
||||||
|
«
|
||||||
|
</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 %}">
|
||||||
|
»
|
||||||
|
</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 %}
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
247
myproject/products/views/attribute_views.py
Normal file
247
myproject/products/views/attribute_views.py
Normal 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)})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
9
start_all.bat
Normal 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"
|
||||||
Reference in New Issue
Block a user