From 79ff523adb51e8419198ade3567ad827a8b2217d Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Tue, 30 Dec 2025 01:44:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D1=82=D0=B8=D0=B2=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=20=D0=B0=D1=82=D1=80=D0=B8=D0=B1=D1=83=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Основные изменения: - Переименование ConfigurableKitProduct → ConfigurableProduct - Добавлена поддержка Product как варианта (не только ProductKit) - Создан справочник атрибутов (ProductAttribute, ProductAttributeValue) - CRUD для управления атрибутами с inline редактированием значений - Пересозданы миграции с нуля для всех приложений - Добавлена ссылка на атрибуты в навигацию 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- myproject/accounts/migrations/0001_initial.py | 2 +- .../customers/migrations/0001_initial.py | 28 +- .../customers/migrations/0002_initial.py | 24 +- ...mer_email_alter_customer_phone_and_more.py | 44 ---- .../migrations/0004_wallet_refactoring.py | 146 ----------- .../inventory/migrations/0001_initial.py | 186 ++++++------- .../inventory/migrations/0002_initial.py | 217 ++++++--------- ...r_documentcounter_counter_type_and_more.py | 90 ------- ...0004_remove_incoming_batch_and_incoming.py | 23 -- .../0005_refactor_transfer_models.py | 38 --- myproject/inventory/signals.py | 32 ++- myproject/orders/migrations/0001_initial.py | 16 +- myproject/orders/migrations/0002_initial.py | 2 +- .../0003_remove_customer_is_recipient.py | 21 -- .../0004_make_delivery_time_optional.py | 23 -- ...dditional_contact_alter_recipient_phone.py | 24 -- ...0006_rename_additional_contact_to_notes.py | 23 -- ...0007_remove_region_from_recipient_phone.py | 19 -- ...icalorder_needs_delivery_photo_and_more.py | 33 --- myproject/products/forms.py | 154 ++++++++--- myproject/products/migrations/0001_initial.py | 156 +++++++---- myproject/products/models/__init__.py | 23 +- myproject/products/models/attributes.py | 116 ++++++++ myproject/products/models/kits.py | 143 +++++++--- .../products/attribute_confirm_delete.html | 42 +++ .../templates/products/attribute_detail.html | 196 ++++++++++++++ .../templates/products/attribute_form.html | 180 +++++++++++++ .../templates/products/attribute_list.html | 110 ++++++++ myproject/products/urls.py | 26 +- myproject/products/views/__init__.py | 46 +++- myproject/products/views/attribute_views.py | 247 ++++++++++++++++++ .../products/views/configurablekit_views.py | 104 ++++---- myproject/templates/navbar.html | 1 + myproject/tenants/migrations/0001_initial.py | 2 +- .../user_roles/migrations/0001_initial.py | 2 +- start_all.bat | 9 + 36 files changed, 1597 insertions(+), 951 deletions(-) delete mode 100644 myproject/customers/migrations/0003_alter_customer_email_alter_customer_phone_and_more.py delete mode 100644 myproject/customers/migrations/0004_wallet_refactoring.py delete mode 100644 myproject/inventory/migrations/0003_alter_documentcounter_counter_type_and_more.py delete mode 100644 myproject/inventory/migrations/0004_remove_incoming_batch_and_incoming.py delete mode 100644 myproject/inventory/migrations/0005_refactor_transfer_models.py delete mode 100644 myproject/orders/migrations/0003_remove_customer_is_recipient.py delete mode 100644 myproject/orders/migrations/0004_make_delivery_time_optional.py delete mode 100644 myproject/orders/migrations/0005_recipient_additional_contact_alter_recipient_phone.py delete mode 100644 myproject/orders/migrations/0006_rename_additional_contact_to_notes.py delete mode 100644 myproject/orders/migrations/0007_remove_region_from_recipient_phone.py delete mode 100644 myproject/orders/migrations/0008_historicalorder_needs_delivery_photo_and_more.py create mode 100644 myproject/products/models/attributes.py create mode 100644 myproject/products/templates/products/attribute_confirm_delete.html create mode 100644 myproject/products/templates/products/attribute_detail.html create mode 100644 myproject/products/templates/products/attribute_form.html create mode 100644 myproject/products/templates/products/attribute_list.html create mode 100644 myproject/products/views/attribute_views.py create mode 100644 start_all.bat diff --git a/myproject/accounts/migrations/0001_initial.py b/myproject/accounts/migrations/0001_initial.py index 16f5771..2cabdeb 100644 --- a/myproject/accounts/migrations/0001_initial.py +++ b/myproject/accounts/migrations/0001_initial.py @@ -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.utils.timezone diff --git a/myproject/customers/migrations/0001_initial.py b/myproject/customers/migrations/0001_initial.py index c4ccfcb..2f4655f 100644 --- a/myproject/customers/migrations/0001_initial.py +++ b/myproject/customers/migrations/0001_initial.py @@ -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 phonenumber_field.modelfields @@ -20,9 +20,8 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), - ('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='Телефон')), - ('wallet_balance', models.DecimalField(decimal_places=2, default=0, help_text='Остаток переплат клиента, доступный для оплаты заказов', max_digits=10, verbose_name='Баланс кошелька')), + ('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, 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='Заметки')), ('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')], }, ), + 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( name='WalletTransaction', fields=[ ('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='Тип транзакции')), + ('balance_category', models.CharField(choices=[('money', 'Реальные деньги')], default='money', max_length=20, verbose_name='Категория')), ('description', models.TextField(blank=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='Создано пользователем')), ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='wallet_transactions', to='customers.customer', verbose_name='Клиент')), ], diff --git a/myproject/customers/migrations/0002_initial.py b/myproject/customers/migrations/0002_initial.py index a57b14e..def0af7 100644 --- a/myproject/customers/migrations/0002_initial.py +++ b/myproject/customers/migrations/0002_initial.py @@ -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 from django.db import migrations, models @@ -17,7 +17,19 @@ class Migration(migrations.Migration): migrations.AddField( model_name='wallettransaction', 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( model_name='wallettransaction', @@ -31,4 +43,12 @@ class Migration(migrations.Migration): model_name='wallettransaction', 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'), + ), ] diff --git a/myproject/customers/migrations/0003_alter_customer_email_alter_customer_phone_and_more.py b/myproject/customers/migrations/0003_alter_customer_email_alter_customer_phone_and_more.py deleted file mode 100644 index 35cf7af..0000000 --- a/myproject/customers/migrations/0003_alter_customer_email_alter_customer_phone_and_more.py +++ /dev/null @@ -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')}, - }, - ), - ] diff --git a/myproject/customers/migrations/0004_wallet_refactoring.py b/myproject/customers/migrations/0004_wallet_refactoring.py deleted file mode 100644 index eaf7183..0000000 --- a/myproject/customers/migrations/0004_wallet_refactoring.py +++ /dev/null @@ -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'), - ), - ] diff --git a/myproject/inventory/migrations/0001_initial.py b/myproject/inventory/migrations/0001_initial.py index 1f4cb83..11688ef 100644 --- a/myproject/inventory/migrations/0001_initial.py +++ b/myproject/inventory/migrations/0001_initial.py @@ -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 +from django.conf import settings from django.db import migrations, models @@ -9,6 +11,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -16,7 +19,7 @@ class Migration(migrations.Migration): name='DocumentCounter', fields=[ ('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='Текущее значение')), ], options={ @@ -24,74 +27,6 @@ class Migration(migrations.Migration): '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( name='Inventory', fields=[ @@ -130,7 +65,7 @@ class Migration(migrations.Migration): 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='Количество')), - ('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='Дата резервирования')), ('released_at', models.DateTimeField(blank=True, 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( - name='StockMovement', - 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', + name='TransferDocument', 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='Номер документа')), @@ -276,7 +184,7 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='TransferItem', + name='TransferDocumentItem', 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='Количество')), @@ -287,6 +195,45 @@ class Migration(migrations.Migration): '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( name='Warehouse', fields=[ @@ -359,4 +306,43 @@ class Migration(migrations.Migration): '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'], + }, + ), ] diff --git a/myproject/inventory/migrations/0002_initial.py b/myproject/inventory/migrations/0002_initial.py index 9d3bd4d..29e2d2b 100644 --- a/myproject/inventory/migrations/0002_initial.py +++ b/myproject/inventory/migrations/0002_initial.py @@ -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 from django.conf import settings @@ -18,31 +18,6 @@ class Migration(migrations.Migration): ] 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( model_name='incomingdocumentitem', 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='Партия'), ), 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', - 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( - model_name='stockmovement', - name='order', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_movements', to='orders.order', 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='Документ перемещения'), + model_name='transformationoutput', + name='transformation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outputs', to='inventory.transformation', verbose_name='Трансформация'), ), migrations.AddIndex( 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'), ), 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', - 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( - model_name='transferbatch', + model_name='transferdocument', name='to_warehouse', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_batches_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='На склад'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_documents_to', to='inventory.warehouse', verbose_name='Склад-приемки'), ), migrations.AddField( model_name='stockbatch', @@ -260,11 +240,6 @@ class Migration(migrations.Migration): name='warehouse', 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( model_name='writeoff', name='batch', @@ -335,60 +310,40 @@ class Migration(migrations.Migration): index=models.Index(fields=['locked_by_user', 'status'], name='inventory_s_locked__88eac9_idx'), ), migrations.AddIndex( - model_name='incoming', - index=models.Index(fields=['batch'], name='inventory_i_batch_i_c50b63_idx'), + model_name='transferdocumentitem', + index=models.Index(fields=['transfer_document'], name='inventory_t_transfe_02e7fe_idx'), ), migrations.AddIndex( - model_name='incoming', - index=models.Index(fields=['product'], name='inventory_i_product_39b00d_idx'), - ), - migrations.AddIndex( - model_name='incoming', - index=models.Index(fields=['-created_at'], name='inventory_i_created_563ec0_idx'), + model_name='transferdocumentitem', + index=models.Index(fields=['product'], name='inventory_t_product_a5ed4b_idx'), ), migrations.AlterUniqueTogether( - name='incoming', - unique_together={('batch', 'product')}, + name='transferdocumentitem', + unique_together={('transfer_document', 'batch')}, ), migrations.AddIndex( - model_name='stockmovement', - index=models.Index(fields=['product'], name='inventory_s_product_cbdc37_idx'), + model_name='transformation', + index=models.Index(fields=['document_number'], name='inventory_t_documen_559778_idx'), ), migrations.AddIndex( - model_name='stockmovement', - index=models.Index(fields=['created_at'], name='inventory_s_created_05ebf5_idx'), + model_name='transformation', + index=models.Index(fields=['warehouse', 'status'], name='inventory_t_warehou_934275_idx'), ), migrations.AddIndex( - model_name='transferitem', - index=models.Index(fields=['transfer_batch'], name='inventory_t_transfe_f7479b_idx'), + model_name='transformation', + index=models.Index(fields=['-date'], name='inventory_t_date_65cfab_idx'), ), migrations.AddIndex( - model_name='transferitem', - index=models.Index(fields=['product'], name='inventory_t_product_0e0ec9_idx'), - ), - migrations.AlterUniqueTogether( - name='transferitem', - unique_together={('transfer_batch', 'batch')}, + model_name='transferdocument', + index=models.Index(fields=['document_number'], name='inventory_t_documen_d9087d_idx'), ), migrations.AddIndex( - model_name='transferbatch', - index=models.Index(fields=['document_number'], name='inventory_t_documen_143275_idx'), + model_name='transferdocument', + index=models.Index(fields=['from_warehouse', 'to_warehouse'], name='inventory_t_from_wa_118a47_idx'), ), migrations.AddIndex( - model_name='transferbatch', - index=models.Index(fields=['from_warehouse', 'to_warehouse'], name='inventory_t_from_wa_2a41f1_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'), + model_name='transferdocument', + index=models.Index(fields=['-created_at'], name='inventory_t_created_5ad653_idx'), ), migrations.AddIndex( model_name='stockbatch', @@ -458,22 +413,6 @@ class Migration(migrations.Migration): model_name='incomingdocument', 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( model_name='writeoff', index=models.Index(fields=['batch'], name='inventory_w_batch_i_b098ce_idx'), diff --git a/myproject/inventory/migrations/0003_alter_documentcounter_counter_type_and_more.py b/myproject/inventory/migrations/0003_alter_documentcounter_counter_type_and_more.py deleted file mode 100644 index 796890f..0000000 --- a/myproject/inventory/migrations/0003_alter_documentcounter_counter_type_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/myproject/inventory/migrations/0004_remove_incoming_batch_and_incoming.py b/myproject/inventory/migrations/0004_remove_incoming_batch_and_incoming.py deleted file mode 100644 index 9b9a543..0000000 --- a/myproject/inventory/migrations/0004_remove_incoming_batch_and_incoming.py +++ /dev/null @@ -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', - ), - ] diff --git a/myproject/inventory/migrations/0005_refactor_transfer_models.py b/myproject/inventory/migrations/0005_refactor_transfer_models.py deleted file mode 100644 index 7de7f1f..0000000 --- a/myproject/inventory/migrations/0005_refactor_transfer_models.py +++ /dev/null @@ -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', - ), - ] diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py index 0589c29..d0ecd57 100644 --- a/myproject/inventory/signals.py +++ b/myproject/inventory/signals.py @@ -956,23 +956,21 @@ def release_stock_on_order_delete(sender, instance, **kwargs): if r not in showcase_reservations ] - # Освобождаем только обычные резервы ПОСЛЕ успешного коммита транзакции - # Это гарантирует целостность: резервы освободятся только если удаление прошло успешно - def release_reservations(): - for res in normal_reservations: - res.status = 'released' - res.released_at = timezone.now() - res.save() - - # Витринные комплекты остаются зарезервированными, но отвязываем блокировки корзины - # НЕ трогаем order_item - он нужен если заказ снова перейдёт в completed - for res in showcase_reservations: - res.cart_lock_expires_at = None - res.locked_by_user = None - res.cart_session_id = None - res.save(update_fields=['cart_lock_expires_at', 'locked_by_user', 'cart_session_id']) - - transaction.on_commit(release_reservations) + # Освобождаем резервы СРАЗУ в pre_delete (до каскадного удаления OrderItem) + # Это предотвращает ошибку FK constraint при попытке сохранить резерв после удаления OrderItem + for res in normal_reservations: + res.status = 'released' + res.released_at = timezone.now() + res.order_item = None # Обнуляем ссылку на удаляемый OrderItem + res.save() + + # Витринные комплекты остаются зарезервированными, но отвязываем от заказа и блокировки корзины + for res in showcase_reservations: + res.order_item = None # Обнуляем ссылку на удаляемый OrderItem + res.cart_lock_expires_at = None + res.locked_by_user = None + res.cart_session_id = None + res.save(update_fields=['order_item', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id']) @receiver(post_save, sender=OrderItem) diff --git a/myproject/orders/migrations/0001_initial.py b/myproject/orders/migrations/0001_initial.py index 0d78d65..f9d11ba 100644 --- a/myproject/orders/migrations/0001_initial.py +++ b/myproject/orders/migrations/0001_initial.py @@ -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 phonenumber_field.modelfields import simple_history.models from decimal import Decimal 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='Итоговая сумма заказа')), ('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='Статус оплаты')), - ('customer_is_recipient', models.BooleanField(default=True, 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='Особые пожелания')), + ('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='Дата создания')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ], @@ -140,7 +142,8 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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='Дата создания')), ('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')), ('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='Дата доставки')), - ('time_from', models.TimeField(help_text='Начальное время временного интервала доставки', verbose_name='Время доставки от')), - ('time_to', models.TimeField(help_text='Конечное время временного интервала доставки', verbose_name='Время доставки до')), + ('time_from', models.TimeField(blank=True, help_text='Начальное время временного интервала доставки (необязательно)', null=True, 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='Стоимость доставки')), ('created_at', models.DateTimeField(auto_now_add=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='Итоговая сумма заказа')), ('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='Статус оплаты')), - ('customer_is_recipient', models.BooleanField(default=True, 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='Особые пожелания')), + ('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='Дата создания')), ('updated_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата обновления')), ('history_id', models.AutoField(primary_key=True, serialize=False)), diff --git a/myproject/orders/migrations/0002_initial.py b/myproject/orders/migrations/0002_initial.py index 63f1343..1b109c6 100644 --- a/myproject/orders/migrations/0002_initial.py +++ b/myproject/orders/migrations/0002_initial.py @@ -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 from django.conf import settings diff --git a/myproject/orders/migrations/0003_remove_customer_is_recipient.py b/myproject/orders/migrations/0003_remove_customer_is_recipient.py deleted file mode 100644 index 538fb85..0000000 --- a/myproject/orders/migrations/0003_remove_customer_is_recipient.py +++ /dev/null @@ -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', - ), - ] diff --git a/myproject/orders/migrations/0004_make_delivery_time_optional.py b/myproject/orders/migrations/0004_make_delivery_time_optional.py deleted file mode 100644 index c79a8be..0000000 --- a/myproject/orders/migrations/0004_make_delivery_time_optional.py +++ /dev/null @@ -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='Время доставки до'), - ), - ] diff --git a/myproject/orders/migrations/0005_recipient_additional_contact_alter_recipient_phone.py b/myproject/orders/migrations/0005_recipient_additional_contact_alter_recipient_phone.py deleted file mode 100644 index 4d30348..0000000 --- a/myproject/orders/migrations/0005_recipient_additional_contact_alter_recipient_phone.py +++ /dev/null @@ -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='Телефон получателя'), - ), - ] diff --git a/myproject/orders/migrations/0006_rename_additional_contact_to_notes.py b/myproject/orders/migrations/0006_rename_additional_contact_to_notes.py deleted file mode 100644 index 8ca7230..0000000 --- a/myproject/orders/migrations/0006_rename_additional_contact_to_notes.py +++ /dev/null @@ -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='Дополнительная информация'), - ), - ] diff --git a/myproject/orders/migrations/0007_remove_region_from_recipient_phone.py b/myproject/orders/migrations/0007_remove_region_from_recipient_phone.py deleted file mode 100644 index dd7a50d..0000000 --- a/myproject/orders/migrations/0007_remove_region_from_recipient_phone.py +++ /dev/null @@ -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='Телефон получателя'), - ), - ] diff --git a/myproject/orders/migrations/0008_historicalorder_needs_delivery_photo_and_more.py b/myproject/orders/migrations/0008_historicalorder_needs_delivery_photo_and_more.py deleted file mode 100644 index f9d4e92..0000000 --- a/myproject/orders/migrations/0008_historicalorder_needs_delivery_photo_and_more.py +++ /dev/null @@ -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='Необходимо фото товара'), - ), - ] diff --git a/myproject/products/forms.py b/myproject/products/forms.py index 9989445..fb5b88a 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -1,9 +1,10 @@ from django import forms from django.forms import inlineformset_factory from .models import ( - Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, + Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem, - ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute + ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute, + ProductAttribute, ProductAttributeValue ) @@ -583,12 +584,12 @@ class ProductTagForm(forms.ModelForm): # ==================== CONFIGURABLE KIT FORMS ==================== -class ConfigurableKitProductForm(forms.ModelForm): +class ConfigurableProductForm(forms.ModelForm): """ Форма для создания и редактирования вариативного товара. """ class Meta: - model = ConfigurableKitProduct + model = ConfigurableProduct fields = ['name', 'sku', 'description', 'short_description', 'status'] labels = { 'name': 'Название', @@ -620,7 +621,7 @@ class ConfigurableKitProductForm(forms.ModelForm): self.fields['status'].widget.attrs.update({'class': 'form-select'}) -class ConfigurableKitOptionForm(forms.ModelForm): +class ConfigurableProductOptionForm(forms.ModelForm): """ Форма для добавления варианта (комплекта) к вариативному товару. Атрибуты варианта выбираются динамически на основе parent_attributes. @@ -633,8 +634,8 @@ class ConfigurableKitOptionForm(forms.ModelForm): ) class Meta: - model = ConfigurableKitOption - # Убрали 'attributes' - он будет заполняться через ConfigurableKitOptionAttribute + model = ConfigurableProductOption + # Убрали 'attributes' - он будет заполняться через ConfigurableProductOptionAttribute fields = ['kit', 'is_default'] labels = { 'kit': 'Комплект', @@ -653,7 +654,7 @@ class ConfigurableKitOptionForm(forms.ModelForm): """ super().__init__(*args, **kwargs) - # Получаем instance (ConfigurableKitOption) + # Получаем instance (ConfigurableProductOption) if self.instance and self.instance.parent_id: parent = self.instance.parent # Получаем все уникальные названия атрибутов родителя @@ -683,7 +684,7 @@ class ConfigurableKitOptionForm(forms.ModelForm): self.fields[field_name].initial = current_attr.attribute -class BaseConfigurableKitOptionFormSet(forms.BaseInlineFormSet): +class BaseConfigurableProductOptionFormSet(forms.BaseInlineFormSet): def clean(self): """Проверка на дубликаты комплектов и что все атрибуты заполнены""" if any(self.errors): @@ -756,12 +757,12 @@ class BaseConfigurableKitOptionFormSet(forms.BaseInlineFormSet): # Формсет для создания вариативного товара -ConfigurableKitOptionFormSetCreate = inlineformset_factory( - ConfigurableKitProduct, - ConfigurableKitOption, - form=ConfigurableKitOptionForm, - formset=BaseConfigurableKitOptionFormSet, - fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableKitOptionAttribute +ConfigurableProductOptionFormSetCreate = inlineformset_factory( + ConfigurableProduct, + ConfigurableProductOption, + form=ConfigurableProductOptionForm, + formset=BaseConfigurableProductOptionFormSet, + fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableProductOptionAttribute extra=0, # Не требуем пустые формы (варианты скрыты в UI) can_delete=True, min_num=0, @@ -770,12 +771,12 @@ ConfigurableKitOptionFormSetCreate = inlineformset_factory( ) # Формсет для редактирования вариативного товара -ConfigurableKitOptionFormSetUpdate = inlineformset_factory( - ConfigurableKitProduct, - ConfigurableKitOption, - form=ConfigurableKitOptionForm, - formset=BaseConfigurableKitOptionFormSet, - fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableKitOptionAttribute +ConfigurableProductOptionFormSetUpdate = inlineformset_factory( + ConfigurableProduct, + ConfigurableProductOption, + form=ConfigurableProductOptionForm, + formset=BaseConfigurableProductOptionFormSet, + fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableProductOptionAttribute extra=0, # НЕ показывать пустые формы can_delete=True, 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: "Длина" - position: 0 - visible: True - - values: [50, 60, 70] (будут созданы как отдельные ConfigurableKitProductAttribute) + - values: [50, 60, 70] (будут созданы как отдельные ConfigurableProductAttribute) """ class Meta: - model = ConfigurableKitProductAttribute + model = ConfigurableProductAttribute fields = ['name', 'position', 'visible'] labels = { 'name': 'Название параметра', @@ -822,7 +823,7 @@ class ConfigurableKitProductAttributeForm(forms.ModelForm): } -class BaseConfigurableKitProductAttributeFormSet(forms.BaseInlineFormSet): +class BaseConfigurableProductAttributeFormSet(forms.BaseInlineFormSet): def clean(self): """Проверка на дубликаты параметров и что у каждого параметра есть значения""" if any(self.errors): @@ -850,11 +851,11 @@ class BaseConfigurableKitProductAttributeFormSet(forms.BaseInlineFormSet): # Формсет для создания атрибутов родительского товара (карточный интерфейс) -ConfigurableKitProductAttributeFormSetCreate = inlineformset_factory( - ConfigurableKitProduct, - ConfigurableKitProductAttribute, - form=ConfigurableKitProductAttributeForm, - formset=BaseConfigurableKitProductAttributeFormSet, +ConfigurableProductAttributeFormSetCreate = inlineformset_factory( + ConfigurableProduct, + ConfigurableProductAttribute, + form=ConfigurableProductAttributeForm, + formset=BaseConfigurableProductAttributeFormSet, # Убрали 'option' - значения будут добавляться через JavaScript в карточку fields=['name', 'position', 'visible'], extra=1, @@ -865,11 +866,11 @@ ConfigurableKitProductAttributeFormSetCreate = inlineformset_factory( ) # Формсет для редактирования атрибутов родительского товара -ConfigurableKitProductAttributeFormSetUpdate = inlineformset_factory( - ConfigurableKitProduct, - ConfigurableKitProductAttribute, - form=ConfigurableKitProductAttributeForm, - formset=BaseConfigurableKitProductAttributeFormSet, +ConfigurableProductAttributeFormSetUpdate = inlineformset_factory( + ConfigurableProduct, + ConfigurableProductAttribute, + form=ConfigurableProductAttributeForm, + formset=BaseConfigurableProductAttributeFormSet, # Убрали 'option' - значения будут добавляться через JavaScript в карточку fields=['name', 'position', 'visible'], extra=0, @@ -878,3 +879,86 @@ ConfigurableKitProductAttributeFormSetUpdate = inlineformset_factory( validate_min=False, 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, +) diff --git a/myproject/products/migrations/0001_initial.py b/myproject/products/migrations/0001_initial.py index 363760a..020c34d 100644 --- a/myproject/products/migrations/0001_initial.py +++ b/myproject/products/migrations/0001_initial.py @@ -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 products.models.photos @@ -28,6 +28,23 @@ class Migration(migrations.Migration): '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( name='ProductVariantGroup', fields=[ @@ -68,7 +85,7 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='ConfigurableKitProduct', + name='ConfigurableProduct', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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='Архивировано пользователем')), ], options={ - 'verbose_name': 'Вариативный товар (из комплектов)', - 'verbose_name_plural': 'Вариативные товары (из комплектов)', + 'verbose_name': 'Вариативный товар', + 'verbose_name_plural': 'Вариативные товары', }, ), migrations.CreateModel( - name='ConfigurableKitOption', - 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', + name='ConfigurableProductAttribute', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='Например: Цвет, Размер, Длина', 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='Порядок отображения')), ('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={ 'verbose_name': 'Атрибут вариативного товара', @@ -117,11 +121,24 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='ConfigurableKitOptionAttribute', + name='ConfigurableProductOption', fields=[ ('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='Вариант')), - ('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.configurablekitproductattribute', verbose_name='Значение атрибута')), + ('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.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={ 'verbose_name': 'Атрибут варианта', @@ -215,6 +232,33 @@ class Migration(migrations.Migration): '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( name='ProductCategory', 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='Комплект'), ), migrations.AddField( - model_name='configurablekitproductattribute', + model_name='configurableproductoption', 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( - model_name='configurablekitoption', + model_name='configurableproductattribute', 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( 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='Группа вариантов'), ), migrations.AddIndex( - model_name='configurablekitoptionattribute', - index=models.Index(fields=['option'], name='products_co_option__93b9f7_idx'), + model_name='configurableproductoptionattribute', + index=models.Index(fields=['option'], name='products_co_option__bd950a_idx'), ), migrations.AddIndex( - model_name='configurablekitoptionattribute', - index=models.Index(fields=['attribute'], name='products_co_attribu_ccc6d9_idx'), + model_name='configurableproductoptionattribute', + index=models.Index(fields=['attribute'], name='products_co_attribu_705d5a_idx'), ), migrations.AlterUniqueTogether( - name='configurablekitoptionattribute', + name='configurableproductoptionattribute', unique_together={('option', 'attribute')}, ), migrations.AlterUniqueTogether( @@ -409,6 +453,14 @@ class Migration(migrations.Migration): model_name='costpricehistory', 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( model_name='productcategory', 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'), ), migrations.AddIndex( - model_name='configurablekitproductattribute', - index=models.Index(fields=['parent', 'name'], name='products_co_parent__4a7869_idx'), + model_name='configurableproductoption', + index=models.Index(fields=['parent'], name='products_co_parent__36761a_idx'), ), migrations.AddIndex( - model_name='configurablekitproductattribute', - index=models.Index(fields=['parent', 'position'], name='products_co_parent__0904e2_idx'), + model_name='configurableproductoption', + index=models.Index(fields=['kit'], name='products_co_kit_id_9e9a00_idx'), ), migrations.AddIndex( - model_name='configurablekitproductattribute', - index=models.Index(fields=['kit'], name='products_co_kit_id_c5d506_idx'), - ), - migrations.AlterUniqueTogether( - name='configurablekitproductattribute', - unique_together={('parent', 'name', 'option', 'kit')}, + model_name='configurableproductoption', + index=models.Index(fields=['product'], name='products_co_product_4d77ae_idx'), ), migrations.AddIndex( - model_name='configurablekitoption', - index=models.Index(fields=['parent'], name='products_co_parent__56ecfa_idx'), + model_name='configurableproductoption', + 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( - model_name='configurablekitoption', - index=models.Index(fields=['kit'], name='products_co_kit_id_3fa7fe_idx'), + model_name='configurableproductattribute', + index=models.Index(fields=['parent', 'name'], name='products_co_parent__78337c_idx'), ), migrations.AddIndex( - model_name='configurablekitoption', - index=models.Index(fields=['parent', 'is_default'], name='products_co_parent__ffa4ca_idx'), + model_name='configurableproductattribute', + index=models.Index(fields=['parent', 'position'], name='products_co_parent__90f012_idx'), ), - migrations.AlterUniqueTogether( - name='configurablekitoption', - unique_together={('parent', 'kit')}, + migrations.AddIndex( + model_name='configurableproductattribute', + 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( model_name='productkitphoto', diff --git a/myproject/products/models/__init__.py b/myproject/products/models/__init__.py index 35d84bb..58595d8 100644 --- a/myproject/products/models/__init__.py +++ b/myproject/products/models/__init__.py @@ -31,8 +31,14 @@ from .variants import ProductVariantGroup, ProductVariantGroupItem # Продукты 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 @@ -60,13 +66,18 @@ __all__ = [ 'Product', 'CostPriceHistory', - # Kits + # Kits & Configurable Products 'ProductKit', 'KitItem', 'KitItemPriority', - 'ConfigurableKitProduct', - 'ConfigurableKitOption', - 'ConfigurableKitProductAttribute', + 'ConfigurableProduct', + 'ConfigurableProductOption', + 'ConfigurableProductAttribute', + 'ConfigurableProductOptionAttribute', + + # Attributes + 'ProductAttribute', + 'ProductAttributeValue', # Photos 'BasePhoto', diff --git a/myproject/products/models/attributes.py b/myproject/products/models/attributes.py new file mode 100644 index 0000000..a7be63f --- /dev/null +++ b/myproject/products/models/attributes.py @@ -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) diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py index 6a94fa7..1345b60 100644 --- a/myproject/products/models/kits.py +++ b/myproject/products/models/kits.py @@ -435,14 +435,18 @@ class KitItemPriority(models.Model): return f"{self.product.name} (приоритет {self.priority})" -class ConfigurableKitProduct(BaseProductEntity): +class ConfigurableProduct(BaseProductEntity): """ - Вариативный товар, объединяющий несколько наших ProductKit + Вариативный товар, объединяющий несколько ProductKit или Product как варианты для внешних площадок (WooCommerce и подобные). + + Примеры использования: + - Роза Фридом с вариантами длины стебля (50, 60, 70 см) — варианты это Product + - Букет "Нежность" с вариантами количества роз (15, 25, 51) — варианты это ProductKit """ class Meta: - verbose_name = "Вариативный товар (из комплектов)" - verbose_name_plural = "Вариативные товары (из комплектов)" + verbose_name = "Вариативный товар" + verbose_name_plural = "Вариативные товары" # Уникальность активного имени наследуется из BaseProductEntity def __str__(self): @@ -451,25 +455,25 @@ class ConfigurableKitProduct(BaseProductEntity): def delete(self, *args, **kwargs): """ Физическое удаление вариативного товара из БД. - При удалении удаляются только связи (ConfigurableKitOption), - но сами ProductKit остаются нетронутыми благодаря CASCADE на уровне связей. + При удалении удаляются только связи (ConfigurableProductOption), + но сами ProductKit/Product остаются нетронутыми благодаря CASCADE на уровне связей. """ # Используем super() для вызова стандартного Django delete, минуя BaseProductEntity.delete() super(BaseProductEntity, self).delete(*args, **kwargs) -class ConfigurableKitProductAttribute(models.Model): +class ConfigurableProductAttribute(models.Model): """ - Атрибут родительского вариативного товара с привязкой к ProductKit. + Атрибут родительского вариативного товара с привязкой к ProductKit или Product. - Каждое значение атрибута связано с конкретным ProductKit. + Каждое значение атрибута может быть связано с ProductKit или Product. Например: - - Длина: 50 → ProductKit (A) - - Длина: 60 → ProductKit (B) - - Длина: 70 → ProductKit (C) + - Длина: 50 → Product (Роза 50см) + - Длина: 60 → Product (Роза 60см) + - Количество: 15 роз → ProductKit (Букет 15 роз) """ parent = models.ForeignKey( - ConfigurableKitProduct, + 'ConfigurableProduct', on_delete=models.CASCADE, related_name='parent_attributes', verbose_name="Родительский товар" @@ -484,6 +488,7 @@ class ConfigurableKitProductAttribute(models.Model): verbose_name="Значение опции", help_text="Например: Красный, M, 60см" ) + # Один из двух должен быть заполнен (kit XOR product) или оба пустые kit = models.ForeignKey( ProductKit, on_delete=models.CASCADE, @@ -493,6 +498,15 @@ class ConfigurableKitProductAttribute(models.Model): blank=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( default=0, verbose_name="Порядок отображения", @@ -508,35 +522,60 @@ class ConfigurableKitProductAttribute(models.Model): verbose_name = "Атрибут вариативного товара" verbose_name_plural = "Атрибуты вариативных товаров" ordering = ['parent', 'position', 'name', 'option'] - unique_together = [['parent', 'name', 'option', 'kit']] indexes = [ models.Index(fields=['parent', 'name']), models.Index(fields=['parent', 'position']), models.Index(fields=['kit']), + models.Index(fields=['product']), ] def __str__(self): - kit_str = self.kit.name if self.kit else "no kit" - return f"{self.parent.name} - {self.name}: {self.option} ({kit_str})" + 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} ({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 формате. Пример: {"length": "60", "color": "red"} """ parent = models.ForeignKey( - ConfigurableKitProduct, + 'ConfigurableProduct', on_delete=models.CASCADE, related_name='options', verbose_name="Родитель (вариативный товар)" ) + # Один из двух должен быть заполнен (kit XOR product) kit = models.ForeignKey( ProductKit, on_delete=models.CASCADE, 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( default=dict, @@ -550,39 +589,79 @@ class ConfigurableKitOption(models.Model): ) class Meta: - verbose_name = "Вариант комплекта" - verbose_name_plural = "Варианты комплектов" - unique_together = [['parent', 'kit']] + verbose_name = "Вариант товара" + verbose_name_plural = "Варианты товаров" indexes = [ models.Index(fields=['parent']), models.Index(fields=['kit']), + models.Index(fields=['product']), 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): - 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) и - конкретным значением атрибута (ConfigurableKitProductAttribute). + Связь между вариантом (ConfigurableProductOption) и + конкретным значением атрибута (ConfigurableProductAttribute). - Вместо хранения текстового поля attributes в ConfigurableKitOption, + Вместо хранения текстового поля attributes в ConfigurableProductOption, мы создаем явные связи между вариантом и выбранными значениями атрибутов. Пример: - - option: ConfigurableKitOption (вариант "15 роз 60см") - - attribute: ConfigurableKitProductAttribute (Длина: 60) + - option: ConfigurableProductOption (вариант "15 роз 60см") + - attribute: ConfigurableProductAttribute (Длина: 60) """ option = models.ForeignKey( - ConfigurableKitOption, + 'ConfigurableProductOption', on_delete=models.CASCADE, related_name='attributes_set', verbose_name="Вариант" ) attribute = models.ForeignKey( - ConfigurableKitProductAttribute, + 'ConfigurableProductAttribute', on_delete=models.CASCADE, verbose_name="Значение атрибута" ) diff --git a/myproject/products/templates/products/attribute_confirm_delete.html b/myproject/products/templates/products/attribute_confirm_delete.html new file mode 100644 index 0000000..b6b67e3 --- /dev/null +++ b/myproject/products/templates/products/attribute_confirm_delete.html @@ -0,0 +1,42 @@ +{% extends 'base.html' %} + +{% block title %}Удалить атрибут{% endblock %} + +{% block content %} +
+
+
+
+
+
+ +
+ +
Удалить атрибут?
+ +

+ {{ object.name }} +

+ + {% if values_count > 0 %} +
+ + Будет удалено {{ values_count }} значени{{ values_count|pluralize:"е,я,й" }} +
+ {% endif %} + +
+ {% csrf_token %} +
+ + Отмена +
+
+
+
+
+
+
+{% endblock %} diff --git a/myproject/products/templates/products/attribute_detail.html b/myproject/products/templates/products/attribute_detail.html new file mode 100644 index 0000000..e568e7a --- /dev/null +++ b/myproject/products/templates/products/attribute_detail.html @@ -0,0 +1,196 @@ +{% extends 'base.html' %} + +{% block title %}Атрибут: {{ attribute.name }}{% endblock %} + +{% block content %} +
+
+
+ +
+
+ + Все атрибуты + +

+ {{ attribute.name }} +

+ {{ attribute.slug }} +
+ +
+ + {% if attribute.description %} +

{{ attribute.description }}

+ {% endif %} + + +
+
+ Значения ({{ values|length }}) + +
+
+ {% if values %} +
    + {% for value in values %} +
  • +
    + {{ value.value }} + {{ value.slug }} +
    +
    + {{ value.position }} + +
    +
  • + {% endfor %} +
+ {% else %} +
+ +

Значений пока нет

+
+ {% endif %} +
+
+ + +
+ + Создан: {{ attribute.created_at|date:"d.m.Y H:i" }} | + Обновлен: {{ attribute.updated_at|date:"d.m.Y H:i" }} +
+
+
+
+ + + + + +{% endblock %} diff --git a/myproject/products/templates/products/attribute_form.html b/myproject/products/templates/products/attribute_form.html new file mode 100644 index 0000000..0be945a --- /dev/null +++ b/myproject/products/templates/products/attribute_form.html @@ -0,0 +1,180 @@ +{% extends 'base.html' %} + +{% block title %}{% if object %}Редактировать атрибут{% else %}Создать атрибут{% endif %}{% endblock %} + +{% block content %} +
+
+
+
+
+
+ + {% if object %}Редактировать атрибут{% else %}Новый атрибут{% endif %} +
+ +
+ {% csrf_token %} + + +
+
+ + {{ form.name }} + {% if form.name.errors %} + {{ form.name.errors.0 }} + {% endif %} +
+
+ + {{ form.position }} + Порядок сортировки +
+
+ +
+ + {{ form.slug }} + {{ form.slug.help_text }} + {% if form.slug.errors %} + {{ form.slug.errors.0 }} + {% endif %} +
+ +
+ + {{ form.description }} +
+ + +
+
+ Значения атрибута +
+ + {{ value_formset.management_form }} + + +
+
Значение
+
Slug
+
Поз.
+
+
+ +
+ {% for value_form in value_formset %} +
+
+
+ {{ value_form.value }} + {% if value_form.value.errors %} + {{ value_form.value.errors.0 }} + {% endif %} +
+
+ {{ value_form.slug }} +
+
+ {{ value_form.position }} +
+
+ {% if value_form.instance.pk %} +
+ {{ value_form.DELETE }} + +
+ {% else %} + + {% endif %} +
+ {{ value_form.id }} +
+
+ {% endfor %} +
+ + + +
+ +
+ + Отмена +
+
+ + {% if object %} +
+ + Обновлено: {{ object.updated_at|date:"d.m.Y H:i" }} + + {% endif %} +
+
+
+
+
+ + +{% endblock %} diff --git a/myproject/products/templates/products/attribute_list.html b/myproject/products/templates/products/attribute_list.html new file mode 100644 index 0000000..9ce2fac --- /dev/null +++ b/myproject/products/templates/products/attribute_list.html @@ -0,0 +1,110 @@ +{% extends 'base.html' %} + +{% block title %}Атрибуты товаров{% endblock %} + +{% block content %} +
+
+
+ +
+
Атрибуты товаров
+ + Новый атрибут + +
+ + +
+ + + {% if search_query %} + + + + {% endif %} +
+ + + {% if attributes %} + +
+
Название
+
+ Значения + Действия +
+
+
+ {% for attr in attributes %} +
+
+ + {{ attr.name }} + + {{ attr.slug }} + {% if attr.description %} + {{ attr.description|truncatewords:10 }} + {% endif %} +
+
+ + {{ attr.num_values }} знач. + + +
+
+ {% endfor %} +
+ + + {% if is_paginated %} + + {% endif %} + + {% else %} +
+ +

Атрибутов пока нет

+

Создайте первый атрибут, например "Длина стебля"

+ + Создать атрибут + +
+ {% endif %} +
+
+
+{% endblock %} diff --git a/myproject/products/urls.py b/myproject/products/urls.py index c2a3c2f..54bab3d 100644 --- a/myproject/products/urls.py +++ b/myproject/products/urls.py @@ -86,14 +86,26 @@ urlpatterns = [ path('tags//update/', views.ProductTagUpdateView.as_view(), name='tag-update'), path('tags//delete/', views.ProductTagDeleteView.as_view(), name='tag-delete'), - # CRUD URLs for ConfigurableKitProduct - path('configurable-kits/', views.ConfigurableKitProductListView.as_view(), name='configurablekit-list'), - path('configurable-kits/create/', views.ConfigurableKitProductCreateView.as_view(), name='configurablekit-create'), - path('configurable-kits//', views.ConfigurableKitProductDetailView.as_view(), name='configurablekit-detail'), - path('configurable-kits//update/', views.ConfigurableKitProductUpdateView.as_view(), name='configurablekit-update'), - path('configurable-kits//delete/', views.ConfigurableKitProductDeleteView.as_view(), name='configurablekit-delete'), + # CRUD URLs for ProductAttribute (справочник атрибутов) + path('attributes/', views.ProductAttributeListView.as_view(), name='attribute-list'), + path('attributes/create/', views.ProductAttributeCreateView.as_view(), name='attribute-create'), + path('attributes//', views.ProductAttributeDetailView.as_view(), name='attribute-detail'), + path('attributes//update/', views.ProductAttributeUpdateView.as_view(), name='attribute-update'), + path('attributes//delete/', views.ProductAttributeDeleteView.as_view(), name='attribute-delete'), + + # API для атрибутов + path('api/attributes/create/', views.create_attribute_api, name='api-attribute-create'), + path('api/attributes//values/add/', views.add_attribute_value_api, name='attribute-add-value'), + path('api/attributes//values//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//', views.ConfigurableProductDetailView.as_view(), name='configurablekit-detail'), + path('configurable-kits//update/', views.ConfigurableProductUpdateView.as_view(), name='configurablekit-update'), + path('configurable-kits//delete/', views.ConfigurableProductDeleteView.as_view(), name='configurablekit-delete'), - # API для управления вариантами ConfigurableKitProduct + # API для управления вариантами ConfigurableProduct path('configurable-kits//options/add/', views.add_option_to_configurable, name='configurablekit-add-option'), path('configurable-kits//options//remove/', views.remove_option_from_configurable, name='configurablekit-remove-option'), path('configurable-kits//options//set-default/', views.set_option_as_default, name='configurablekit-set-default-option'), diff --git a/myproject/products/views/__init__.py b/myproject/products/views/__init__.py index 99ac042..e843f85 100644 --- a/myproject/products/views/__init__.py +++ b/myproject/products/views/__init__.py @@ -80,18 +80,30 @@ from .tag_views import ( ProductTagDeleteView, ) -# CRUD представления для ConfigurableKitProduct +# CRUD представления для ConfigurableProduct from .configurablekit_views import ( - ConfigurableKitProductListView, - ConfigurableKitProductCreateView, - ConfigurableKitProductDetailView, - ConfigurableKitProductUpdateView, - ConfigurableKitProductDeleteView, + ConfigurableProductListView, + ConfigurableProductCreateView, + ConfigurableProductDetailView, + ConfigurableProductUpdateView, + ConfigurableProductDeleteView, add_option_to_configurable, remove_option_from_configurable, 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 представления from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_api @@ -162,16 +174,26 @@ __all__ = [ 'ProductTagUpdateView', 'ProductTagDeleteView', - # ConfigurableKitProduct CRUD - 'ConfigurableKitProductListView', - 'ConfigurableKitProductCreateView', - 'ConfigurableKitProductDetailView', - 'ConfigurableKitProductUpdateView', - 'ConfigurableKitProductDeleteView', + # ConfigurableProduct CRUD + 'ConfigurableProductListView', + 'ConfigurableProductCreateView', + 'ConfigurableProductDetailView', + 'ConfigurableProductUpdateView', + 'ConfigurableProductDeleteView', 'add_option_to_configurable', 'remove_option_from_configurable', 'set_option_as_default', + # ProductAttribute CRUD + 'ProductAttributeListView', + 'ProductAttributeCreateView', + 'ProductAttributeDetailView', + 'ProductAttributeUpdateView', + 'ProductAttributeDeleteView', + 'create_attribute_api', + 'add_attribute_value_api', + 'delete_attribute_value_api', + # API 'search_products_and_variants', 'validate_kit_cost', diff --git a/myproject/products/views/attribute_views.py b/myproject/products/views/attribute_views.py new file mode 100644 index 0000000..0b382c6 --- /dev/null +++ b/myproject/products/views/attribute_views.py @@ -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)}) diff --git a/myproject/products/views/configurablekit_views.py b/myproject/products/views/configurablekit_views.py index b9b9519..87ab024 100644 --- a/myproject/products/views/configurablekit_views.py +++ b/myproject/products/views/configurablekit_views.py @@ -1,5 +1,5 @@ """ -CRUD представления для вариативных товаров (ConfigurableKitProduct). +CRUD представления для вариативных товаров (ConfigurableProduct). """ from django.contrib import messages 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 user_roles.mixins import ManagerOwnerRequiredMixin -from ..models import ConfigurableKitProduct, ConfigurableKitOption, ProductKit, ConfigurableKitProductAttribute +from ..models import ConfigurableProduct, ConfigurableProductOption, ProductKit, ConfigurableProductAttribute from ..forms import ( - ConfigurableKitProductForm, - ConfigurableKitOptionFormSetCreate, - ConfigurableKitOptionFormSetUpdate, - ConfigurableKitProductAttributeFormSetCreate, - ConfigurableKitProductAttributeFormSetUpdate + ConfigurableProductForm, + ConfigurableProductOptionFormSetCreate, + ConfigurableProductOptionFormSetUpdate, + ConfigurableProductAttributeFormSetCreate, + ConfigurableProductAttributeFormSetUpdate ) -class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView): - model = ConfigurableKitProduct +class ConfigurableProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView): + model = ConfigurableProduct template_name = 'products/configurablekit_list.html' context_object_name = 'configurable_kits' paginate_by = 20 @@ -33,7 +33,7 @@ class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMix queryset = super().get_queryset().prefetch_related( Prefetch( 'options', - queryset=ConfigurableKitOption.objects.select_related('kit') + queryset=ConfigurableProductOption.objects.select_related('kit') ) ) @@ -80,8 +80,8 @@ class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMix return context -class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView): - model = ConfigurableKitProduct +class ConfigurableProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView): + model = ConfigurableProduct template_name = 'products/configurablekit_detail.html' context_object_name = 'configurable_kit' @@ -89,7 +89,7 @@ class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredM return super().get_queryset().prefetch_related( Prefetch( 'options', - queryset=ConfigurableKitOption.objects.select_related('kit').order_by('id') + queryset=ConfigurableProductOption.objects.select_related('kit').order_by('id') ), 'parent_attributes' ) @@ -104,9 +104,9 @@ class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredM return context -class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView): - model = ConfigurableKitProduct - form_class = ConfigurableKitProductForm +class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView): + model = ConfigurableProduct + form_class = ConfigurableProductForm template_name = 'products/configurablekit_form.html' def get_context_data(self, **kwargs): @@ -116,12 +116,12 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM if 'option_formset' in kwargs: context['option_formset'] = kwargs['option_formset'] elif self.request.POST: - context['option_formset'] = ConfigurableKitOptionFormSetCreate( + context['option_formset'] = ConfigurableProductOptionFormSetCreate( self.request.POST, prefix='options' ) else: - context['option_formset'] = ConfigurableKitOptionFormSetCreate( + context['option_formset'] = ConfigurableProductOptionFormSetCreate( prefix='options' ) @@ -129,12 +129,12 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM if 'attribute_formset' in kwargs: context['attribute_formset'] = kwargs['attribute_formset'] elif self.request.POST: - context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate( + context['attribute_formset'] = ConfigurableProductAttributeFormSetCreate( self.request.POST, prefix='attributes' ) else: - context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate( + context['attribute_formset'] = ConfigurableProductAttributeFormSetCreate( prefix='attributes' ) @@ -147,14 +147,14 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM return context def form_valid(self, form): - from products.models.kits import ConfigurableKitOptionAttribute + from products.models.kits import ConfigurableProductOptionAttribute # Пересоздаём formsets с POST данными - option_formset = ConfigurableKitOptionFormSetCreate( + option_formset = ConfigurableProductOptionFormSetCreate( self.request.POST, prefix='options' ) - attribute_formset = ConfigurableKitProductAttributeFormSetCreate( + attribute_formset = ConfigurableProductAttributeFormSetCreate( self.request.POST, prefix='attributes' ) @@ -212,7 +212,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM # Сохраняем выбранные атрибуты для этого варианта for field_name, field_value in option_form.cleaned_data.items(): if field_name.startswith('attribute_') and field_value: - ConfigurableKitOptionAttribute.objects.create( + ConfigurableProductOptionAttribute.objects.create( option=option, attribute=field_value ) @@ -250,7 +250,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM 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') @@ -293,7 +293,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM except (json.JSONDecodeError, TypeError): kit_ids = [] - # Создаём ConfigurableKitProductAttribute для каждого значения + # Создаём ConfigurableProductAttribute для каждого значения for value_idx, value in enumerate(values): if value and value.strip(): # Получаем соответствующий ID комплекта @@ -317,7 +317,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM # Комплект не найден - создаём без привязки pass - ConfigurableKitProductAttribute.objects.create(**create_kwargs) + ConfigurableProductAttribute.objects.create(**create_kwargs) 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}) -class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView): - model = ConfigurableKitProduct - form_class = ConfigurableKitProductForm +class ConfigurableProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView): + model = ConfigurableProduct + form_class = ConfigurableProductForm template_name = 'products/configurablekit_form.html' def get_context_data(self, **kwargs): @@ -388,13 +388,13 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM if 'option_formset' in kwargs: context['option_formset'] = kwargs['option_formset'] elif self.request.POST: - context['option_formset'] = ConfigurableKitOptionFormSetUpdate( + context['option_formset'] = ConfigurableProductOptionFormSetUpdate( self.request.POST, instance=self.object, prefix='options' ) else: - context['option_formset'] = ConfigurableKitOptionFormSetUpdate( + context['option_formset'] = ConfigurableProductOptionFormSetUpdate( instance=self.object, prefix='options' ) @@ -403,13 +403,13 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM if 'attribute_formset' in kwargs: context['attribute_formset'] = kwargs['attribute_formset'] elif self.request.POST: - context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate( + context['attribute_formset'] = ConfigurableProductAttributeFormSetUpdate( self.request.POST, instance=self.object, prefix='attributes' ) else: - context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate( + context['attribute_formset'] = ConfigurableProductAttributeFormSetUpdate( instance=self.object, prefix='attributes' ) @@ -423,15 +423,15 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM return context def form_valid(self, form): - from products.models.kits import ConfigurableKitOptionAttribute + from products.models.kits import ConfigurableProductOptionAttribute # Пересоздаём formsets с POST данными - option_formset = ConfigurableKitOptionFormSetUpdate( + option_formset = ConfigurableProductOptionFormSetUpdate( self.request.POST, instance=self.object, prefix='options' ) - attribute_formset = ConfigurableKitProductAttributeFormSetUpdate( + attribute_formset = ConfigurableProductAttributeFormSetUpdate( self.request.POST, instance=self.object, prefix='attributes' @@ -489,7 +489,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM # Сохраняем выбранные атрибуты для этого варианта for field_name, field_value in option_form.cleaned_data.items(): if field_name.startswith('attribute_') and field_value: - ConfigurableKitOptionAttribute.objects.create( + ConfigurableProductOptionAttribute.objects.create( option=option, attribute=field_value ) @@ -527,7 +527,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM 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') @@ -570,7 +570,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM except (json.JSONDecodeError, TypeError): kit_ids = [] - # Создаём ConfigurableKitProductAttribute для каждого значения + # Создаём ConfigurableProductAttribute для каждого значения for value_idx, value in enumerate(values): if value and value.strip(): # Получаем соответствующий ID комплекта @@ -594,7 +594,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM # Комплект не найден - создаём без привязки pass - ConfigurableKitProductAttribute.objects.create(**create_kwargs) + ConfigurableProductAttribute.objects.create(**create_kwargs) 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}) -class ConfigurableKitProductDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView): - model = ConfigurableKitProduct +class ConfigurableProductDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView): + model = ConfigurableProduct template_name = 'products/configurablekit_confirm_delete.html' 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') attributes = request.POST.get('attributes', '') 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) # Проверяем, не добавлен ли уже этот комплект - 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) # Если is_default=True, снимаем флаг с других 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, kit=kit, attributes=attributes, @@ -720,8 +720,8 @@ def remove_option_from_configurable(request, pk, option_id): """ Удалить вариант из вариативного товара. """ - configurable = get_object_or_404(ConfigurableKitProduct, pk=pk) - option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable) + configurable = get_object_or_404(ConfigurableProduct, pk=pk) + option = get_object_or_404(ConfigurableProductOption, pk=option_id, parent=configurable) option.delete() @@ -734,11 +734,11 @@ def set_option_as_default(request, pk, option_id): """ Установить вариант как по умолчанию. """ - configurable = get_object_or_404(ConfigurableKitProduct, pk=pk) - option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable) + configurable = get_object_or_404(ConfigurableProduct, pk=pk) + 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 diff --git a/myproject/templates/navbar.html b/myproject/templates/navbar.html index d01ff94..d4b9424 100644 --- a/myproject/templates/navbar.html +++ b/myproject/templates/navbar.html @@ -32,6 +32,7 @@
  • Категории
  • Теги
  • Варианты (группы)
  • +
  • Атрибуты
  • diff --git a/myproject/tenants/migrations/0001_initial.py b/myproject/tenants/migrations/0001_initial.py index 7487a7a..5321fb9 100644 --- a/myproject/tenants/migrations/0001_initial.py +++ b/myproject/tenants/migrations/0001_initial.py @@ -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.db.models.deletion diff --git a/myproject/user_roles/migrations/0001_initial.py b/myproject/user_roles/migrations/0001_initial.py index 1ac758d..3ecaa5e 100644 --- a/myproject/user_roles/migrations/0001_initial.py +++ b/myproject/user_roles/migrations/0001_initial.py @@ -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 from django.conf import settings diff --git a/start_all.bat b/start_all.bat new file mode 100644 index 0000000..c714287 --- /dev/null +++ b/start_all.bat @@ -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" \ No newline at end of file