From 94fe363cb19ab614210f4ef6863a8f280bad445a Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Tue, 23 Dec 2025 23:52:59 +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=D0=BE=D1=82=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20Delivery=20=D0=BE=D1=82=20Order,=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8F=20=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=BA=D0=B8,=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Отделена модель Delivery от Order (OneToOne связь) - Добавлены обязательные поля delivery_date, time_from, time_to в Delivery - Delivery обязательна при создании заказа (кроме черновиков) - Добавлены методы calculate_total() и reset_delivery_cost() в Order - Добавлена валидация полей доставки в OrderForm - Исправлено создание доменов - убран порт из домена в БД - Исправлен редирект после установки пароля (правильный формат URL) - Исправлена ошибка NoReverseMatch в navbar для public схемы - Удалены все старые миграции (база создается с нуля) - Обновлены views для работы с новой моделью Delivery --- myproject/accounts/migrations/0001_initial.py | 2 +- myproject/accounts/views.py | 32 ++- .../customers/migrations/0001_initial.py | 28 +- .../0002_customer_is_system_customer.py | 18 -- .../customers/migrations/0002_initial.py | 34 +++ ...customers_c_loyalty_5162a0_idx_and_more.py | 21 -- ...stomer_wallet_balance_wallettransaction.py | 41 --- .../migrations/0005_remove_total_spent.py | 17 -- .../inventory/migrations/0001_initial.py | 124 ++++++++- .../inventory/migrations/0002_initial.py | 257 +++++++++++++++++- ..._showcase_reservation_showcase_and_more.py | 50 ---- .../0004_showcase_is_default_and_more.py | 22 -- .../0005_reservation_product_kit_and_more.py | 25 -- ...servation_cart_lock_expires_at_and_more.py | 45 --- .../0007_add_showcase_item_model.py | 63 ----- .../0008_migrate_showcase_kits_to_items.py | 64 ----- .../0009_fix_showcase_items_status.py | 65 ----- .../migrations/0010_writeoff_document.py | 91 ------- ...0011_add_writeoff_status_to_reservation.py | 23 -- .../0012_change_sold_order_item_to_fk.py | 20 -- .../0013_add_receipt_type_to_incomingbatch.py | 22 -- ..._counter_type_incomingdocument_and_more.py | 91 ------- .../0015_add_inventory_foreign_keys.py | 24 -- .../0016_add_document_number_to_inventory.py | 27 -- .../0017_change_conducted_by_to_fk.py | 101 ------- ...entoryline_snapshot_difference_and_more.py | 43 --- myproject/orders/admin.py | 35 ++- myproject/orders/forms.py | 205 ++++++++------ myproject/orders/migrations/0001_initial.py | 144 +++++++--- myproject/orders/migrations/0002_initial.py | 175 ++++++++++-- ...icalorderitem_is_from_showcase_and_more.py | 44 --- ..._refactor_models_and_add_payment_method.py | 61 ----- ...istoricalorder_discount_amount_and_more.py | 21 -- ...006_transaction_delete_payment_and_more.py | 55 ---- .../orders/migrations/0007_kit_snapshots.py | 76 ------ .../migrations/0008_add_item_snapshots.py | 39 --- ...d_original_product_to_kit_item_snapshot.py | 20 -- ..._remove_address_recipient_name_and_more.py | 69 ----- .../migrations/0011_migrate_recipient_data.py | 77 ------ myproject/orders/models/__init__.py | 2 + myproject/orders/models/delivery.py | 151 ++++++++++ myproject/orders/models/order.py | 199 ++------------ .../services/delivery_cost_calculator.py | 95 ------- myproject/orders/views.py | 130 ++++++--- myproject/products/migrations/0001_initial.py | 159 ++++++++++- ...gurablekitproduct_configurablekitoption.py | 52 ---- ...configurablekitproduct_options_and_more.py | 23 -- .../0004_configurablekitproductattribute.py | 32 --- ..._alter_configurablekitoption_attributes.py | 18 -- ...0006_add_configurablekitoptionattribute.py | 28 -- .../migrations/0007_add_kit_to_attribute.py | 31 --- .../0008_productkit_showcase_and_more.py | 27 -- ...ter_productcategoryphoto_image_and_more.py | 50 ---- .../0010_alter_product_cost_price.py | 18 -- myproject/templates/navbar.html | 99 +++---- myproject/tenants/admin.py | 5 +- .../commands/activate_registration.py | 3 + .../management/commands/create_tenant.py | 3 + myproject/tenants/migrations/0001_initial.py | 5 +- ...registration_owner_notified_at_and_more.py | 28 -- .../user_roles/migrations/0001_initial.py | 2 +- 61 files changed, 1342 insertions(+), 2189 deletions(-) delete mode 100644 myproject/customers/migrations/0002_customer_is_system_customer.py create mode 100644 myproject/customers/migrations/0002_initial.py delete mode 100644 myproject/customers/migrations/0003_remove_customer_customers_c_loyalty_5162a0_idx_and_more.py delete mode 100644 myproject/customers/migrations/0004_customer_wallet_balance_wallettransaction.py delete mode 100644 myproject/customers/migrations/0005_remove_total_spent.py delete mode 100644 myproject/inventory/migrations/0003_showcase_reservation_showcase_and_more.py delete mode 100644 myproject/inventory/migrations/0004_showcase_is_default_and_more.py delete mode 100644 myproject/inventory/migrations/0005_reservation_product_kit_and_more.py delete mode 100644 myproject/inventory/migrations/0006_reservation_cart_lock_expires_at_and_more.py delete mode 100644 myproject/inventory/migrations/0007_add_showcase_item_model.py delete mode 100644 myproject/inventory/migrations/0008_migrate_showcase_kits_to_items.py delete mode 100644 myproject/inventory/migrations/0009_fix_showcase_items_status.py delete mode 100644 myproject/inventory/migrations/0010_writeoff_document.py delete mode 100644 myproject/inventory/migrations/0011_add_writeoff_status_to_reservation.py delete mode 100644 myproject/inventory/migrations/0012_change_sold_order_item_to_fk.py delete mode 100644 myproject/inventory/migrations/0013_add_receipt_type_to_incomingbatch.py delete mode 100644 myproject/inventory/migrations/0014_alter_documentcounter_counter_type_incomingdocument_and_more.py delete mode 100644 myproject/inventory/migrations/0015_add_inventory_foreign_keys.py delete mode 100644 myproject/inventory/migrations/0016_add_document_number_to_inventory.py delete mode 100644 myproject/inventory/migrations/0017_change_conducted_by_to_fk.py delete mode 100644 myproject/inventory/migrations/0018_inventoryline_snapshot_difference_and_more.py delete mode 100644 myproject/orders/migrations/0003_historicalorderitem_is_from_showcase_and_more.py delete mode 100644 myproject/orders/migrations/0004_refactor_models_and_add_payment_method.py delete mode 100644 myproject/orders/migrations/0005_remove_historicalorder_discount_amount_and_more.py delete mode 100644 myproject/orders/migrations/0006_transaction_delete_payment_and_more.py delete mode 100644 myproject/orders/migrations/0007_kit_snapshots.py delete mode 100644 myproject/orders/migrations/0008_add_item_snapshots.py delete mode 100644 myproject/orders/migrations/0009_add_original_product_to_kit_item_snapshot.py delete mode 100644 myproject/orders/migrations/0010_remove_address_recipient_name_and_more.py delete mode 100644 myproject/orders/migrations/0011_migrate_recipient_data.py create mode 100644 myproject/orders/models/delivery.py delete mode 100644 myproject/orders/services/delivery_cost_calculator.py delete mode 100644 myproject/products/migrations/0002_configurablekitproduct_configurablekitoption.py delete mode 100644 myproject/products/migrations/0003_alter_configurablekitproduct_options_and_more.py delete mode 100644 myproject/products/migrations/0004_configurablekitproductattribute.py delete mode 100644 myproject/products/migrations/0005_alter_configurablekitoption_attributes.py delete mode 100644 myproject/products/migrations/0006_add_configurablekitoptionattribute.py delete mode 100644 myproject/products/migrations/0007_add_kit_to_attribute.py delete mode 100644 myproject/products/migrations/0008_productkit_showcase_and_more.py delete mode 100644 myproject/products/migrations/0009_alter_productcategoryphoto_image_and_more.py delete mode 100644 myproject/products/migrations/0010_alter_product_cost_price.py delete mode 100644 myproject/tenants/migrations/0002_tenantregistration_owner_notified_at_and_more.py diff --git a/myproject/accounts/migrations/0001_initial.py b/myproject/accounts/migrations/0001_initial.py index f25da8e..16f5771 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-11-15 11:57 +# Generated by Django 5.0.10 on 2025-12-23 20:38 import django.contrib.auth.validators import django.utils.timezone diff --git a/myproject/accounts/views.py b/myproject/accounts/views.py index d7ed3be..9cfcdb1 100644 --- a/myproject/accounts/views.py +++ b/myproject/accounts/views.py @@ -213,7 +213,37 @@ def password_setup_confirm(request, token): ) # Перенаправить на домен тенанта - tenant_url = f'http://{tenant.schema_name}.localhost:8000/' + # Получаем домен из базы (без порта, порт добавляется в URL только для localhost) + from tenants.models import Domain + from django.conf import settings + connection.set_schema_to_public() + try: + domain_obj = Domain.objects.filter(tenant=tenant, is_primary=True).first() + if domain_obj: + domain_name = domain_obj.domain + # Убираем порт из домена если он есть (для совместимости со старыми записями) + if ':' in domain_name: + domain_name = domain_name.split(':')[0] + else: + # Fallback если домен не найден + domain_base = settings.TENANT_DOMAIN_BASE + if ':' in domain_base: + domain_base = domain_base.split(':')[0] + domain_name = f"{tenant.schema_name}.{domain_base}" + except: + domain_base = settings.TENANT_DOMAIN_BASE + if ':' in domain_base: + domain_base = domain_base.split(':')[0] + domain_name = f"{tenant.schema_name}.{domain_base}" + + # Формируем URL с правильным протоколом и портом + protocol = 'https' if settings.USE_HTTPS else 'http' + # Добавляем порт только для localhost + if 'localhost' in domain_name: + tenant_url = f'{protocol}://{domain_name}:8000/' + else: + tenant_url = f'{protocol}://{domain_name}/' + return redirect(tenant_url) else: messages.error(request, 'Пароли не совпадают.') diff --git a/myproject/customers/migrations/0001_initial.py b/myproject/customers/migrations/0001_initial.py index 689bb69..c4ccfcb 100644 --- a/myproject/customers/migrations/0001_initial.py +++ b/myproject/customers/migrations/0001_initial.py @@ -1,6 +1,8 @@ -# Generated by Django 5.0.10 on 2025-11-15 11:57 +# Generated by Django 5.0.10 on 2025-12-23 20:38 +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 = [ @@ -19,8 +22,8 @@ class Migration(migrations.Migration): ('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='Телефон')), - ('loyalty_tier', models.CharField(choices=[('no_discount', 'Без скидки'), ('bronze', 'Бронза'), ('silver', 'Серебро'), ('gold', 'Золото'), ('platinum', 'Платина')], default='no_discount', max_length=20, verbose_name='Уровень лояльности')), - ('total_spent', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Общая сумма покупок')), + ('wallet_balance', models.DecimalField(decimal_places=2, default=0, help_text='Остаток переплат клиента, доступный для оплаты заказов', max_digits=10, verbose_name='Баланс кошелька')), + ('is_system_customer', models.BooleanField(db_index=True, default=False, help_text='Автоматически созданный клиент для анонимных покупок и наличных продаж', verbose_name='Системный клиент')), ('notes', models.TextField(blank=True, help_text='Заметки о клиенте, особые предпочтения и т.д.', null=True, verbose_name='Заметки')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), @@ -29,7 +32,24 @@ class Migration(migrations.Migration): 'verbose_name': 'Клиент', 'verbose_name_plural': 'Клиенты', 'ordering': ['-created_at'], - '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'), models.Index(fields=['loyalty_tier'], name='customers_c_loyalty_5162a0_idx')], + 'indexes': [models.Index(fields=['name'], name='customers_c_name_f018e2_idx'), models.Index(fields=['email'], name='customers_c_email_4fdeb3_idx'), models.Index(fields=['phone'], name='customers_c_phone_8493fa_idx'), models.Index(fields=['created_at'], name='customers_c_created_1ed0f4_idx')], + }, + ), + migrations.CreateModel( + name='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='Сумма')), + ('transaction_type', models.CharField(choices=[('deposit', 'Пополнение'), ('spend', 'Списание'), ('adjustment', 'Корректировка')], max_length=20, verbose_name='Тип транзакции')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('created_at', models.DateTimeField(auto_now_add=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='Клиент')), + ], + options={ + 'verbose_name': 'Транзакция кошелька', + 'verbose_name_plural': 'Транзакции кошелька', + 'ordering': ['-created_at'], }, ), ] diff --git a/myproject/customers/migrations/0002_customer_is_system_customer.py b/myproject/customers/migrations/0002_customer_is_system_customer.py deleted file mode 100644 index 0c86c70..0000000 --- a/myproject/customers/migrations/0002_customer_is_system_customer.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-19 19:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('customers', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='customer', - name='is_system_customer', - field=models.BooleanField(db_index=True, default=False, help_text='Автоматически созданный клиент для анонимных покупок и наличных продаж', verbose_name='Системный клиент'), - ), - ] diff --git a/myproject/customers/migrations/0002_initial.py b/myproject/customers/migrations/0002_initial.py new file mode 100644 index 0000000..a57b14e --- /dev/null +++ b/myproject/customers/migrations/0002_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.10 on 2025-12-23 20:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('customers', '0001_initial'), + ('orders', '0001_initial'), + ] + + operations = [ + 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='Заказ'), + ), + migrations.AddIndex( + model_name='wallettransaction', + index=models.Index(fields=['customer', '-created_at'], name='customers_w_custome_572f05_idx'), + ), + migrations.AddIndex( + model_name='wallettransaction', + index=models.Index(fields=['transaction_type'], name='customers_w_transac_5fda02_idx'), + ), + migrations.AddIndex( + model_name='wallettransaction', + index=models.Index(fields=['order'], name='customers_w_order_i_5e2527_idx'), + ), + ] diff --git a/myproject/customers/migrations/0003_remove_customer_customers_c_loyalty_5162a0_idx_and_more.py b/myproject/customers/migrations/0003_remove_customer_customers_c_loyalty_5162a0_idx_and_more.py deleted file mode 100644 index 475ba74..0000000 --- a/myproject/customers/migrations/0003_remove_customer_customers_c_loyalty_5162a0_idx_and_more.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-22 13:57 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('customers', '0002_customer_is_system_customer'), - ] - - operations = [ - migrations.RemoveIndex( - model_name='customer', - name='customers_c_loyalty_5162a0_idx', - ), - migrations.RemoveField( - model_name='customer', - name='loyalty_tier', - ), - ] diff --git a/myproject/customers/migrations/0004_customer_wallet_balance_wallettransaction.py b/myproject/customers/migrations/0004_customer_wallet_balance_wallettransaction.py deleted file mode 100644 index d68f659..0000000 --- a/myproject/customers/migrations/0004_customer_wallet_balance_wallettransaction.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-26 11:34 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('customers', '0003_remove_customer_customers_c_loyalty_5162a0_idx_and_more'), - ('orders', '0004_refactor_models_and_add_payment_method'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='customer', - name='wallet_balance', - field=models.DecimalField(decimal_places=2, default=0, help_text='Остаток переплат клиента, доступный для оплаты заказов', max_digits=10, verbose_name='Баланс кошелька'), - ), - 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='Сумма')), - ('transaction_type', models.CharField(choices=[('deposit', 'Пополнение'), ('spend', 'Списание'), ('adjustment', 'Корректировка')], max_length=20, verbose_name='Тип транзакции')), - ('description', models.TextField(blank=True, verbose_name='Описание')), - ('created_at', models.DateTimeField(auto_now_add=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='Клиент')), - ('order', models.ForeignKey(blank=True, help_text='Заказ, к которому относится транзакция (если применимо)', null=True, on_delete=django.db.models.deletion.PROTECT, to='orders.order', verbose_name='Заказ')), - ], - options={ - 'verbose_name': 'Транзакция кошелька', - 'verbose_name_plural': 'Транзакции кошелька', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['customer', '-created_at'], name='customers_w_custome_572f05_idx'), models.Index(fields=['transaction_type'], name='customers_w_transac_5fda02_idx'), models.Index(fields=['order'], name='customers_w_order_i_5e2527_idx')], - }, - ), - ] diff --git a/myproject/customers/migrations/0005_remove_total_spent.py b/myproject/customers/migrations/0005_remove_total_spent.py deleted file mode 100644 index 6645250..0000000 --- a/myproject/customers/migrations/0005_remove_total_spent.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-05 21:17 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('customers', '0004_customer_wallet_balance_wallettransaction'), - ] - - operations = [ - migrations.RemoveField( - model_name='customer', - name='total_spent', - ), - ] diff --git a/myproject/inventory/migrations/0001_initial.py b/myproject/inventory/migrations/0001_initial.py index 56a4715..1f4cb83 100644 --- a/myproject/inventory/migrations/0001_initial.py +++ b/myproject/inventory/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2025-11-15 11:57 +# Generated by Django 5.0.10 on 2025-12-23 20:38 import phonenumber_field.modelfields from django.db import migrations, models @@ -16,7 +16,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', 'Перемещение товара')], max_length=20, unique=True, verbose_name='Тип счетчика')), + ('counter_type', models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара'), ('inventory', 'Инвентаризация')], max_length=20, unique=True, verbose_name='Тип счетчика')), ('current_value', models.IntegerField(default=0, verbose_name='Текущее значение')), ], options={ @@ -44,6 +44,7 @@ class Migration(migrations.Migration): 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='Дата создания')), @@ -55,13 +56,49 @@ class Migration(migrations.Migration): '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=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('document_number', models.CharField(blank=True, db_index=True, max_length=100, null=True, unique=True, verbose_name='Номер документа')), ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата инвентаризации')), ('status', models.CharField(choices=[('draft', 'Черновик'), ('processing', 'В обработке'), ('completed', 'Завершена')], default='draft', max_length=20, verbose_name='Статус')), - ('conducted_by', models.CharField(blank=True, max_length=200, null=True, verbose_name='Провел инвентаризацию')), ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), ], options={ @@ -75,9 +112,13 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('quantity_system', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество в системе')), - ('quantity_fact', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Фактическое количество')), - ('difference', models.DecimalField(decimal_places=3, default=0, editable=False, max_digits=10, verbose_name='Разница (факт - система)')), + ('quantity_fact', models.DecimalField(decimal_places=3, help_text='Количество свободных товаров, подсчитанных физически', max_digits=10, verbose_name='Подсчитано (факт, свободные)')), + ('difference', models.DecimalField(decimal_places=3, default=0, editable=False, help_text='(Подсчитано + Зарезервировано) - Всего на складе', max_digits=10, verbose_name='Итоговая разница')), ('processed', models.BooleanField(default=False, verbose_name='Обработана (создана операция)')), + ('snapshot_quantity_available', models.DecimalField(blank=True, decimal_places=3, help_text='Всего на складе на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='Всего на складе (snapshot)')), + ('snapshot_quantity_reserved', models.DecimalField(blank=True, decimal_places=3, help_text='В резервах на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='В резервах (snapshot)')), + ('snapshot_quantity_system', models.DecimalField(blank=True, decimal_places=3, help_text='В системе свободно на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='В системе свободно (snapshot)')), + ('snapshot_difference', models.DecimalField(blank=True, decimal_places=3, help_text='Итоговая разница на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='Итоговая разница (snapshot)')), ], options={ 'verbose_name': 'Строка инвентаризации', @@ -89,10 +130,12 @@ 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', 'Преобразован в продажу')], default='reserved', max_length=20, verbose_name='Статус')), + ('status', models.CharField(choices=[('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу'), ('converted_to_writeoff', 'Преобразован в списание')], default='reserved', max_length=25, 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, null=True, verbose_name='Дата преобразования в продажу')), + ('converted_at', models.DateTimeField(blank=True, help_text='Дата преобразования в продажу или списание', null=True, verbose_name='Дата преобразования')), + ('cart_lock_expires_at', models.DateTimeField(blank=True, help_text='Время истечения блокировки в корзине (для витринных комплектов)', null=True, verbose_name='Блокировка корзины истекает')), + ('cart_session_id', models.CharField(blank=True, help_text='Дополнительная идентификация сессии для надежности', max_length=100, null=True, verbose_name='ID сессии корзины')), ], options={ 'verbose_name': 'Резервирование', @@ -128,6 +171,39 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Распределения продаж по партиям', }, ), + migrations.CreateModel( + name='Showcase', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название')), + ('description', models.TextField(blank=True, null=True, verbose_name='Описание')), + ('is_active', models.BooleanField(default=True, verbose_name='Активна')), + ('is_default', models.BooleanField(default=False, 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': ['warehouse', 'name'], + }, + ), + migrations.CreateModel( + name='ShowcaseItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('available', 'Доступен'), ('in_cart', 'В корзине'), ('sold', 'Продан'), ('dismantled', 'Разобран')], db_index=True, default='available', max_length=20, verbose_name='Статус')), + ('sold_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата продажи')), + ('cart_lock_expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Блокировка истекает')), + ('cart_session_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='ID сессии корзины')), + ('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': 'Экземпляры на витрине', + }, + ), migrations.CreateModel( name='Stock', fields=[ @@ -249,4 +325,38 @@ class Migration(migrations.Migration): 'ordering': ['-date'], }, ), + migrations.CreateModel( + name='WriteOffDocument', + 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='Дата документа')), + ('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='WriteOffDocumentItem', + 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='Количество')), + ('reason', models.CharField(choices=[('damage', 'Повреждение'), ('spoilage', 'Порча'), ('shortage', 'Недостача'), ('inventory', 'Инвентаризационная недостача'), ('other', 'Другое')], default='damage', max_length=20, verbose_name='Причина списания')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + 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 055328b..9d3bd4d 100644 --- a/myproject/inventory/migrations/0002_initial.py +++ b/myproject/inventory/migrations/0002_initial.py @@ -1,6 +1,7 @@ -# Generated by Django 5.0.10 on 2025-11-15 11:57 +# Generated by Django 5.0.10 on 2025-12-23 20:38 import django.db.models.deletion +from django.conf import settings from django.db import migrations, models @@ -12,6 +13,8 @@ class Migration(migrations.Migration): ('inventory', '0001_initial'), ('orders', '0001_initial'), ('products', '0001_initial'), + ('user_roles', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -25,6 +28,36 @@ class Migration(migrations.Migration): 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', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_document_items', to='products.product', verbose_name='Товар'), + ), + migrations.AddField( + model_name='inventory', + name='conducted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inventories', to='user_roles.userrole', verbose_name='Провел инвентаризацию'), + ), + migrations.AddField( + model_name='incomingdocument', + name='inventory', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incoming_documents', to='inventory.inventory', verbose_name='Инвентаризация'), + ), migrations.AddField( model_name='inventoryline', name='inventory', @@ -35,6 +68,11 @@ class Migration(migrations.Migration): name='product', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.product', verbose_name='Товар'), ), + migrations.AddField( + model_name='reservation', + name='locked_by_user', + field=models.ForeignKey(blank=True, help_text='Кассир, который добавил комплект в корзину', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cart_locks', to=settings.AUTH_USER_MODEL, verbose_name='Заблокировано пользователем'), + ), migrations.AddField( model_name='reservation', name='order_item', @@ -45,6 +83,11 @@ class Migration(migrations.Migration): name='product', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='products.product', verbose_name='Товар'), ), + migrations.AddField( + model_name='reservation', + name='product_kit', + field=models.ForeignKey(blank=True, help_text='Временный комплект, для которого создан резерв', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='products.productkit', verbose_name='Комплект'), + ), migrations.AddField( model_name='sale', name='order', @@ -60,6 +103,36 @@ class Migration(migrations.Migration): name='sale', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='batch_allocations', to='inventory.sale', verbose_name='Продажа'), ), + migrations.AddField( + model_name='reservation', + name='showcase', + field=models.ForeignKey(blank=True, help_text='Витрина, на которой выложен букет', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.showcase', verbose_name='Витрина'), + ), + migrations.AddField( + model_name='showcaseitem', + name='locked_by_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='locked_showcase_items', to=settings.AUTH_USER_MODEL, verbose_name='Заблокировано пользователем'), + ), + migrations.AddField( + model_name='showcaseitem', + name='product_kit', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase_items', to='products.productkit', verbose_name='Шаблон комплекта'), + ), + migrations.AddField( + model_name='showcaseitem', + name='showcase', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase_items', to='inventory.showcase', verbose_name='Витрина'), + ), + migrations.AddField( + model_name='showcaseitem', + name='sold_order_item', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sold_showcase_items', to='orders.orderitem', verbose_name='Позиция заказа (продажа)'), + ), + migrations.AddField( + model_name='reservation', + name='showcase_item', + field=models.ForeignKey(blank=True, help_text='Для какого физического экземпляра создан резерв', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.showcaseitem', verbose_name='Экземпляр на витрине'), + ), migrations.AddField( model_name='stock', name='product', @@ -162,6 +235,11 @@ class Migration(migrations.Migration): name='warehouse', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stocks', to='inventory.warehouse', verbose_name='Склад'), ), + migrations.AddField( + model_name='showcase', + name='warehouse', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcases', to='inventory.warehouse', verbose_name='Склад'), + ), migrations.AddField( model_name='sale', name='warehouse', @@ -177,6 +255,11 @@ class Migration(migrations.Migration): name='warehouse', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventories', to='inventory.warehouse', verbose_name='Склад'), ), + migrations.AddField( + model_name='incomingdocument', + 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', @@ -187,6 +270,70 @@ class Migration(migrations.Migration): name='batch', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='writeoffs', to='inventory.stockbatch', verbose_name='Партия'), ), + migrations.AddField( + model_name='writeoffdocument', + name='confirmed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_writeoff_documents', to=settings.AUTH_USER_MODEL, verbose_name='Провёл'), + ), + migrations.AddField( + model_name='writeoffdocument', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_writeoff_documents', to=settings.AUTH_USER_MODEL, verbose_name='Создал'), + ), + migrations.AddField( + model_name='writeoffdocument', + name='inventory', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='writeoff_documents', to='inventory.inventory', verbose_name='Инвентаризация'), + ), + migrations.AddField( + model_name='writeoffdocument', + name='warehouse', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='writeoff_documents', to='inventory.warehouse', verbose_name='Склад'), + ), + migrations.AddField( + model_name='writeoffdocumentitem', + name='document', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.writeoffdocument', verbose_name='Документ'), + ), + migrations.AddField( + model_name='writeoffdocumentitem', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='writeoff_document_items', to='products.product', verbose_name='Товар'), + ), + migrations.AddField( + model_name='writeoffdocumentitem', + name='reservation', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='writeoff_document_item_reverse', to='inventory.reservation', verbose_name='Резерв'), + ), + migrations.AddField( + model_name='reservation', + name='writeoff_document_item', + field=models.ForeignKey(blank=True, help_text='Резерв для документа списания (черновик)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.writeoffdocumentitem', verbose_name='Позиция документа списания'), + ), + migrations.AddIndex( + model_name='incomingdocumentitem', + index=models.Index(fields=['document'], name='inventory_i_documen_96d470_idx'), + ), + migrations.AddIndex( + model_name='incomingdocumentitem', + index=models.Index(fields=['product'], name='inventory_i_product_932432_idx'), + ), + migrations.AddIndex( + model_name='showcaseitem', + index=models.Index(fields=['showcase', 'status'], name='inventory_s_showcas_116f7f_idx'), + ), + migrations.AddIndex( + model_name='showcaseitem', + index=models.Index(fields=['product_kit', 'status'], name='inventory_s_product_785870_idx'), + ), + migrations.AddIndex( + model_name='showcaseitem', + index=models.Index(fields=['status', 'cart_lock_expires_at'], name='inventory_s_status_6acf05_idx'), + ), + migrations.AddIndex( + model_name='showcaseitem', + 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'), @@ -263,6 +410,18 @@ class Migration(migrations.Migration): name='stock', unique_together={('product', 'warehouse')}, ), + migrations.AddIndex( + model_name='showcase', + index=models.Index(fields=['warehouse'], name='inventory_s_warehou_1e4a8a_idx'), + ), + migrations.AddIndex( + model_name='showcase', + index=models.Index(fields=['is_active'], name='inventory_s_is_acti_387bfb_idx'), + ), + migrations.AddIndex( + model_name='showcase', + index=models.Index(fields=['is_default'], name='inventory_s_is_defa_bf9a7c_idx'), + ), migrations.AddIndex( model_name='sale', index=models.Index(fields=['product', 'warehouse'], name='inventory_s_product_084314_idx'), @@ -275,6 +434,78 @@ class Migration(migrations.Migration): model_name='sale', index=models.Index(fields=['order'], name='inventory_s_order_i_7d13ea_idx'), ), + migrations.AddIndex( + model_name='inventory', + index=models.Index(fields=['document_number'], name='inventory_i_documen_8df782_idx'), + ), + migrations.AddIndex( + model_name='incomingdocument', + index=models.Index(fields=['document_number'], name='inventory_i_documen_5b89ad_idx'), + ), + migrations.AddIndex( + model_name='incomingdocument', + index=models.Index(fields=['warehouse', 'status'], name='inventory_i_warehou_8f141d_idx'), + ), + migrations.AddIndex( + model_name='incomingdocument', + index=models.Index(fields=['date'], name='inventory_i_date_8ace9b_idx'), + ), + migrations.AddIndex( + model_name='incomingdocument', + index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_92f322_idx'), + ), + migrations.AddIndex( + 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'), + ), + migrations.AddIndex( + model_name='writeoff', + index=models.Index(fields=['date'], name='inventory_w_date_70c7e3_idx'), + ), + migrations.AddIndex( + model_name='writeoffdocument', + index=models.Index(fields=['document_number'], name='inventory_w_documen_a9ae00_idx'), + ), + migrations.AddIndex( + model_name='writeoffdocument', + index=models.Index(fields=['warehouse', 'status'], name='inventory_w_warehou_69fbf6_idx'), + ), + migrations.AddIndex( + model_name='writeoffdocument', + index=models.Index(fields=['date'], name='inventory_w_date_a853cb_idx'), + ), + migrations.AddIndex( + model_name='writeoffdocument', + index=models.Index(fields=['-created_at'], name='inventory_w_created_02c298_idx'), + ), + migrations.AddIndex( + model_name='writeoffdocumentitem', + index=models.Index(fields=['document'], name='inventory_w_documen_d77c5e_idx'), + ), + migrations.AddIndex( + model_name='writeoffdocumentitem', + index=models.Index(fields=['product'], name='inventory_w_product_6e32fc_idx'), + ), migrations.AddIndex( model_name='reservation', index=models.Index(fields=['product', 'warehouse'], name='inventory_r_product_fa0d33_idx'), @@ -288,23 +519,27 @@ class Migration(migrations.Migration): index=models.Index(fields=['order_item'], name='inventory_r_order_i_ae991f_idx'), ), migrations.AddIndex( - model_name='incomingbatch', - index=models.Index(fields=['document_number'], name='inventory_i_documen_679096_idx'), + model_name='reservation', + index=models.Index(fields=['showcase'], name='inventory_r_showcas_bd3508_idx'), ), migrations.AddIndex( - model_name='incomingbatch', - index=models.Index(fields=['warehouse'], name='inventory_i_warehou_cc3a73_idx'), + model_name='reservation', + index=models.Index(fields=['product_kit'], name='inventory_r_product_70aed5_idx'), ), migrations.AddIndex( - model_name='incomingbatch', - index=models.Index(fields=['-created_at'], name='inventory_i_created_59ee8b_idx'), + model_name='reservation', + index=models.Index(fields=['cart_lock_expires_at'], name='inventory_r_cart_lo_e9b52a_idx'), ), migrations.AddIndex( - model_name='writeoff', - index=models.Index(fields=['batch'], name='inventory_w_batch_i_b098ce_idx'), + model_name='reservation', + index=models.Index(fields=['locked_by_user'], name='inventory_r_locked__706cbf_idx'), ), migrations.AddIndex( - model_name='writeoff', - index=models.Index(fields=['date'], name='inventory_w_date_70c7e3_idx'), + model_name='reservation', + index=models.Index(fields=['product_kit', 'cart_lock_expires_at'], name='inventory_r_product_5dacdf_idx'), + ), + migrations.AddIndex( + model_name='reservation', + index=models.Index(fields=['showcase_item'], name='inventory_r_showcas_8cfff5_idx'), ), ] diff --git a/myproject/inventory/migrations/0003_showcase_reservation_showcase_and_more.py b/myproject/inventory/migrations/0003_showcase_reservation_showcase_and_more.py deleted file mode 100644 index 6e9fb73..0000000 --- a/myproject/inventory/migrations/0003_showcase_reservation_showcase_and_more.py +++ /dev/null @@ -1,50 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-16 18:07 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0002_initial'), - ('orders', '0002_initial'), - ('products', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Showcase', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=200, verbose_name='Название')), - ('description', models.TextField(blank=True, null=True, verbose_name='Описание')), - ('is_active', models.BooleanField(default=True, verbose_name='Активна')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcases', to='inventory.warehouse', verbose_name='Склад')), - ], - options={ - 'verbose_name': 'Витрина', - 'verbose_name_plural': 'Витрины', - 'ordering': ['warehouse', 'name'], - }, - ), - migrations.AddField( - model_name='reservation', - name='showcase', - field=models.ForeignKey(blank=True, help_text='Витрина, на которой выложен букет', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.showcase', verbose_name='Витрина'), - ), - migrations.AddIndex( - model_name='reservation', - index=models.Index(fields=['showcase'], name='inventory_r_showcas_bd3508_idx'), - ), - migrations.AddIndex( - model_name='showcase', - index=models.Index(fields=['warehouse'], name='inventory_s_warehou_1e4a8a_idx'), - ), - migrations.AddIndex( - model_name='showcase', - index=models.Index(fields=['is_active'], name='inventory_s_is_acti_387bfb_idx'), - ), - ] diff --git a/myproject/inventory/migrations/0004_showcase_is_default_and_more.py b/myproject/inventory/migrations/0004_showcase_is_default_and_more.py deleted file mode 100644 index 227fb99..0000000 --- a/myproject/inventory/migrations/0004_showcase_is_default_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-20 08:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0003_showcase_reservation_showcase_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='showcase', - name='is_default', - field=models.BooleanField(default=False, verbose_name='По умолчанию'), - ), - migrations.AddIndex( - model_name='showcase', - index=models.Index(fields=['is_default'], name='inventory_s_is_defa_bf9a7c_idx'), - ), - ] diff --git a/myproject/inventory/migrations/0005_reservation_product_kit_and_more.py b/myproject/inventory/migrations/0005_reservation_product_kit_and_more.py deleted file mode 100644 index 5a9222e..0000000 --- a/myproject/inventory/migrations/0005_reservation_product_kit_and_more.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-20 12:09 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0004_showcase_is_default_and_more'), - ('orders', '0003_historicalorderitem_is_from_showcase_and_more'), - ('products', '0008_productkit_showcase_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='reservation', - name='product_kit', - field=models.ForeignKey(blank=True, help_text='Временный комплект, для которого создан резерв', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='products.productkit', verbose_name='Комплект'), - ), - migrations.AddIndex( - model_name='reservation', - index=models.Index(fields=['product_kit'], name='inventory_r_product_70aed5_idx'), - ), - ] diff --git a/myproject/inventory/migrations/0006_reservation_cart_lock_expires_at_and_more.py b/myproject/inventory/migrations/0006_reservation_cart_lock_expires_at_and_more.py deleted file mode 100644 index 674d76c..0000000 --- a/myproject/inventory/migrations/0006_reservation_cart_lock_expires_at_and_more.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-20 20:20 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0005_reservation_product_kit_and_more'), - ('orders', '0003_historicalorderitem_is_from_showcase_and_more'), - ('products', '0008_productkit_showcase_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='reservation', - name='cart_lock_expires_at', - field=models.DateTimeField(blank=True, help_text='Время истечения блокировки в корзине (для витринных комплектов)', null=True, verbose_name='Блокировка корзины истекает'), - ), - migrations.AddField( - model_name='reservation', - name='cart_session_id', - field=models.CharField(blank=True, help_text='Дополнительная идентификация сессии для надежности', max_length=100, null=True, verbose_name='ID сессии корзины'), - ), - migrations.AddField( - model_name='reservation', - name='locked_by_user', - field=models.ForeignKey(blank=True, help_text='Кассир, который добавил комплект в корзину', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cart_locks', to=settings.AUTH_USER_MODEL, verbose_name='Заблокировано пользователем'), - ), - migrations.AddIndex( - model_name='reservation', - index=models.Index(fields=['cart_lock_expires_at'], name='inventory_r_cart_lo_e9b52a_idx'), - ), - migrations.AddIndex( - model_name='reservation', - index=models.Index(fields=['locked_by_user'], name='inventory_r_locked__706cbf_idx'), - ), - migrations.AddIndex( - model_name='reservation', - index=models.Index(fields=['product_kit', 'cart_lock_expires_at'], name='inventory_r_product_5dacdf_idx'), - ), - ] diff --git a/myproject/inventory/migrations/0007_add_showcase_item_model.py b/myproject/inventory/migrations/0007_add_showcase_item_model.py deleted file mode 100644 index 715413b..0000000 --- a/myproject/inventory/migrations/0007_add_showcase_item_model.py +++ /dev/null @@ -1,63 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-09 04:19 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0006_reservation_cart_lock_expires_at_and_more'), - ('orders', '0006_transaction_delete_payment_and_more'), - ('products', '0010_alter_product_cost_price'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='ShowcaseItem', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('status', models.CharField(choices=[('available', 'Доступен'), ('in_cart', 'В корзине'), ('sold', 'Продан'), ('dismantled', 'Разобран')], db_index=True, default='available', max_length=20, verbose_name='Статус')), - ('sold_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата продажи')), - ('cart_lock_expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Блокировка истекает')), - ('cart_session_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='ID сессии корзины')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлен')), - ('locked_by_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='locked_showcase_items', to=settings.AUTH_USER_MODEL, verbose_name='Заблокировано пользователем')), - ('product_kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase_items', to='products.productkit', verbose_name='Шаблон комплекта')), - ('showcase', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase_items', to='inventory.showcase', verbose_name='Витрина')), - ('sold_order_item', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sold_showcase_item', to='orders.orderitem', verbose_name='Позиция заказа (продажа)')), - ], - options={ - 'verbose_name': 'Экземпляр на витрине', - 'verbose_name_plural': 'Экземпляры на витрине', - }, - ), - migrations.AddField( - model_name='reservation', - name='showcase_item', - field=models.ForeignKey(blank=True, help_text='Для какого физического экземпляра создан резерв', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.showcaseitem', verbose_name='Экземпляр на витрине'), - ), - migrations.AddIndex( - model_name='reservation', - index=models.Index(fields=['showcase_item'], name='inventory_r_showcas_8cfff5_idx'), - ), - migrations.AddIndex( - model_name='showcaseitem', - index=models.Index(fields=['showcase', 'status'], name='inventory_s_showcas_116f7f_idx'), - ), - migrations.AddIndex( - model_name='showcaseitem', - index=models.Index(fields=['product_kit', 'status'], name='inventory_s_product_785870_idx'), - ), - migrations.AddIndex( - model_name='showcaseitem', - index=models.Index(fields=['status', 'cart_lock_expires_at'], name='inventory_s_status_6acf05_idx'), - ), - migrations.AddIndex( - model_name='showcaseitem', - index=models.Index(fields=['locked_by_user', 'status'], name='inventory_s_locked__88eac9_idx'), - ), - ] diff --git a/myproject/inventory/migrations/0008_migrate_showcase_kits_to_items.py b/myproject/inventory/migrations/0008_migrate_showcase_kits_to_items.py deleted file mode 100644 index f1b7728..0000000 --- a/myproject/inventory/migrations/0008_migrate_showcase_kits_to_items.py +++ /dev/null @@ -1,64 +0,0 @@ -# Generated manually - Data migration for ShowcaseItem - -from django.db import migrations - - -def migrate_showcase_kits_to_items(apps, schema_editor): - """ - Для каждого существующего витринного букета (ProductKit с is_temporary=True и showcase): - 1. Создать ShowcaseItem - 2. Привязать существующие Reservation к этому ShowcaseItem - """ - ProductKit = apps.get_model('products', 'ProductKit') - ShowcaseItem = apps.get_model('inventory', 'ShowcaseItem') - Reservation = apps.get_model('inventory', 'Reservation') - - # Находим все витринные комплекты - showcase_kits = ProductKit.objects.filter( - is_temporary=True, - showcase__isnull=False - ) - - for kit in showcase_kits: - # Создаём ShowcaseItem для каждого существующего витринного букета - showcase_item = ShowcaseItem.objects.create( - showcase=kit.showcase, - product_kit=kit, - status='available' - ) - - # Привязываем существующие резервы к этому ShowcaseItem - Reservation.objects.filter( - product_kit=kit, - showcase=kit.showcase, - status='reserved' - ).update(showcase_item=showcase_item) - - -def reverse_migration(apps, schema_editor): - """ - Откат: удаляем созданные ShowcaseItem и очищаем связи в Reservation - """ - ShowcaseItem = apps.get_model('inventory', 'ShowcaseItem') - Reservation = apps.get_model('inventory', 'Reservation') - - # Очищаем связи в резервах - Reservation.objects.filter(showcase_item__isnull=False).update(showcase_item=None) - - # Удаляем все ShowcaseItem - ShowcaseItem.objects.all().delete() - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0007_add_showcase_item_model'), - ('products', '0001_initial'), # Убедимся что ProductKit существует - ] - - operations = [ - migrations.RunPython( - migrate_showcase_kits_to_items, - reverse_code=reverse_migration - ), - ] diff --git a/myproject/inventory/migrations/0009_fix_showcase_items_status.py b/myproject/inventory/migrations/0009_fix_showcase_items_status.py deleted file mode 100644 index 40fb837..0000000 --- a/myproject/inventory/migrations/0009_fix_showcase_items_status.py +++ /dev/null @@ -1,65 +0,0 @@ -# Generated manually - Fix ShowcaseItem status for already sold kits - -from django.db import migrations - - -def fix_showcase_items_status(apps, schema_editor): - """ - Исправляем статус ShowcaseItem для уже проданных комплектов. - - Логика: - - Если у ShowcaseItem нет активных резервов (status='reserved') → - это уже проданный/разобранный букет → удаляем ShowcaseItem - """ - ShowcaseItem = apps.get_model('inventory', 'ShowcaseItem') - Reservation = apps.get_model('inventory', 'Reservation') - - # Находим все ShowcaseItem в статусе 'available' - available_items = ShowcaseItem.objects.filter(status='available') - - items_to_delete = [] - - for item in available_items: - # Проверяем есть ли активные резервы для этого экземпляра - has_active_reservations = Reservation.objects.filter( - showcase_item=item, - status='reserved' - ).exists() - - # Если резервы не привязаны к showcase_item, проверяем старым способом - if not has_active_reservations: - has_active_reservations = Reservation.objects.filter( - product_kit=item.product_kit, - showcase=item.showcase, - status='reserved' - ).exists() - - if not has_active_reservations: - # Нет активных резервов - этот букет уже продан/разобран - items_to_delete.append(item.id) - - # Удаляем ShowcaseItem без активных резервов - if items_to_delete: - ShowcaseItem.objects.filter(id__in=items_to_delete).delete() - - -def reverse_migration(apps, schema_editor): - """ - Откат невозможен - удалённые ShowcaseItem не восстановить. - Но это безопасно - они относились к уже проданным букетам. - """ - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0008_migrate_showcase_kits_to_items'), - ] - - operations = [ - migrations.RunPython( - fix_showcase_items_status, - reverse_code=reverse_migration - ), - ] diff --git a/myproject/inventory/migrations/0010_writeoff_document.py b/myproject/inventory/migrations/0010_writeoff_document.py deleted file mode 100644 index 54601f2..0000000 --- a/myproject/inventory/migrations/0010_writeoff_document.py +++ /dev/null @@ -1,91 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-10 19:21 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0009_fix_showcase_items_status'), - ('products', '0010_alter_product_cost_price'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='documentcounter', - name='counter_type', - field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара')], max_length=20, unique=True, verbose_name='Тип счетчика'), - ), - migrations.CreateModel( - name='WriteOffDocument', - 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='Дата документа')), - ('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_writeoff_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_writeoff_documents', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), - ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='writeoff_documents', to='inventory.warehouse', verbose_name='Склад')), - ], - options={ - 'verbose_name': 'Документ списания', - 'verbose_name_plural': 'Документы списания', - 'ordering': ['-date', '-created_at'], - }, - ), - migrations.CreateModel( - name='WriteOffDocumentItem', - 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='Количество')), - ('reason', models.CharField(choices=[('damage', 'Повреждение'), ('spoilage', 'Порча'), ('shortage', 'Недостача'), ('inventory', 'Инвентаризационная недостача'), ('other', 'Другое')], default='damage', max_length=20, verbose_name='Причина списания')), - ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.writeoffdocument', verbose_name='Документ')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='writeoff_document_items', to='products.product', verbose_name='Товар')), - ('reservation', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='writeoff_document_item_reverse', to='inventory.reservation', verbose_name='Резерв')), - ], - options={ - 'verbose_name': 'Позиция документа списания', - 'verbose_name_plural': 'Позиции документа списания', - 'ordering': ['id'], - }, - ), - migrations.AddField( - model_name='reservation', - name='writeoff_document_item', - field=models.ForeignKey(blank=True, help_text='Резерв для документа списания (черновик)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.writeoffdocumentitem', verbose_name='Позиция документа списания'), - ), - migrations.AddIndex( - model_name='writeoffdocument', - index=models.Index(fields=['document_number'], name='inventory_w_documen_a9ae00_idx'), - ), - migrations.AddIndex( - model_name='writeoffdocument', - index=models.Index(fields=['warehouse', 'status'], name='inventory_w_warehou_69fbf6_idx'), - ), - migrations.AddIndex( - model_name='writeoffdocument', - index=models.Index(fields=['date'], name='inventory_w_date_a853cb_idx'), - ), - migrations.AddIndex( - model_name='writeoffdocument', - index=models.Index(fields=['-created_at'], name='inventory_w_created_02c298_idx'), - ), - migrations.AddIndex( - model_name='writeoffdocumentitem', - index=models.Index(fields=['document'], name='inventory_w_documen_d77c5e_idx'), - ), - migrations.AddIndex( - model_name='writeoffdocumentitem', - index=models.Index(fields=['product'], name='inventory_w_product_6e32fc_idx'), - ), - ] diff --git a/myproject/inventory/migrations/0011_add_writeoff_status_to_reservation.py b/myproject/inventory/migrations/0011_add_writeoff_status_to_reservation.py deleted file mode 100644 index c69845d..0000000 --- a/myproject/inventory/migrations/0011_add_writeoff_status_to_reservation.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-11 18:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0010_writeoff_document'), - ] - - operations = [ - migrations.AlterField( - model_name='reservation', - name='converted_at', - field=models.DateTimeField(blank=True, help_text='Дата преобразования в продажу или списание', null=True, verbose_name='Дата преобразования'), - ), - migrations.AlterField( - model_name='reservation', - name='status', - field=models.CharField(choices=[('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу'), ('converted_to_writeoff', 'Преобразован в списание')], default='reserved', max_length=25, verbose_name='Статус'), - ), - ] diff --git a/myproject/inventory/migrations/0012_change_sold_order_item_to_fk.py b/myproject/inventory/migrations/0012_change_sold_order_item_to_fk.py deleted file mode 100644 index 889e541..0000000 --- a/myproject/inventory/migrations/0012_change_sold_order_item_to_fk.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-11 19:23 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0011_add_writeoff_status_to_reservation'), - ('orders', '0006_transaction_delete_payment_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='showcaseitem', - name='sold_order_item', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sold_showcase_items', to='orders.orderitem', verbose_name='Позиция заказа (продажа)'), - ), - ] diff --git a/myproject/inventory/migrations/0013_add_receipt_type_to_incomingbatch.py b/myproject/inventory/migrations/0013_add_receipt_type_to_incomingbatch.py deleted file mode 100644 index ef2cf24..0000000 --- a/myproject/inventory/migrations/0013_add_receipt_type_to_incomingbatch.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-20 20:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0012_change_sold_order_item_to_fk'), - ] - - operations = [ - migrations.AddField( - model_name='incomingbatch', - name='receipt_type', - field=models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления'), - ), - migrations.AddIndex( - model_name='incomingbatch', - index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_ce70c1_idx'), - ), - ] diff --git a/myproject/inventory/migrations/0014_alter_documentcounter_counter_type_incomingdocument_and_more.py b/myproject/inventory/migrations/0014_alter_documentcounter_counter_type_incomingdocument_and_more.py deleted file mode 100644 index 9df6be2..0000000 --- a/myproject/inventory/migrations/0014_alter_documentcounter_counter_type_incomingdocument_and_more.py +++ /dev/null @@ -1,91 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-20 21:10 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0013_add_receipt_type_to_incomingbatch'), - ('products', '0010_alter_product_cost_price'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='documentcounter', - name='counter_type', - field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара')], max_length=20, unique=True, verbose_name='Тип счетчика'), - ), - 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='Создал')), - ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_documents', to='inventory.warehouse', 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='Документ')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_document_items', to='products.product', verbose_name='Товар')), - ], - options={ - 'verbose_name': 'Позиция документа поступления', - 'verbose_name_plural': 'Позиции документа поступления', - 'ordering': ['id'], - }, - ), - migrations.AddIndex( - model_name='incomingdocument', - index=models.Index(fields=['document_number'], name='inventory_i_documen_5b89ad_idx'), - ), - migrations.AddIndex( - model_name='incomingdocument', - index=models.Index(fields=['warehouse', 'status'], name='inventory_i_warehou_8f141d_idx'), - ), - migrations.AddIndex( - model_name='incomingdocument', - index=models.Index(fields=['date'], name='inventory_i_date_8ace9b_idx'), - ), - migrations.AddIndex( - model_name='incomingdocument', - index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_92f322_idx'), - ), - migrations.AddIndex( - model_name='incomingdocument', - index=models.Index(fields=['-created_at'], name='inventory_i_created_174930_idx'), - ), - migrations.AddIndex( - model_name='incomingdocumentitem', - index=models.Index(fields=['document'], name='inventory_i_documen_96d470_idx'), - ), - migrations.AddIndex( - model_name='incomingdocumentitem', - index=models.Index(fields=['product'], name='inventory_i_product_932432_idx'), - ), - ] diff --git a/myproject/inventory/migrations/0015_add_inventory_foreign_keys.py b/myproject/inventory/migrations/0015_add_inventory_foreign_keys.py deleted file mode 100644 index cc8d28b..0000000 --- a/myproject/inventory/migrations/0015_add_inventory_foreign_keys.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-21 18:45 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0014_alter_documentcounter_counter_type_incomingdocument_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='incomingdocument', - name='inventory', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incoming_documents', to='inventory.inventory', verbose_name='Инвентаризация'), - ), - migrations.AddField( - model_name='writeoffdocument', - name='inventory', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='writeoff_documents', to='inventory.inventory', verbose_name='Инвентаризация'), - ), - ] diff --git a/myproject/inventory/migrations/0016_add_document_number_to_inventory.py b/myproject/inventory/migrations/0016_add_document_number_to_inventory.py deleted file mode 100644 index aa1af9d..0000000 --- a/myproject/inventory/migrations/0016_add_document_number_to_inventory.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-21 19:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0015_add_inventory_foreign_keys'), - ] - - operations = [ - migrations.AddField( - model_name='inventory', - name='document_number', - field=models.CharField(blank=True, db_index=True, max_length=100, null=True, unique=True, verbose_name='Номер документа'), - ), - migrations.AlterField( - model_name='documentcounter', - name='counter_type', - field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара'), ('inventory', 'Инвентаризация')], max_length=20, unique=True, verbose_name='Тип счетчика'), - ), - migrations.AddIndex( - model_name='inventory', - index=models.Index(fields=['document_number'], name='inventory_i_documen_8df782_idx'), - ), - ] diff --git a/myproject/inventory/migrations/0017_change_conducted_by_to_fk.py b/myproject/inventory/migrations/0017_change_conducted_by_to_fk.py deleted file mode 100644 index 0055fe5..0000000 --- a/myproject/inventory/migrations/0017_change_conducted_by_to_fk.py +++ /dev/null @@ -1,101 +0,0 @@ -# Generated manually - Change conducted_by from CharField to ForeignKey - -from django.db import migrations, models -import django.db.models.deletion - - -def migrate_conducted_by_data(apps, schema_editor): - """ - Миграция данных: для существующих записей инвентаризации - найти первого пользователя с ролью "owner" (владелец) в текущем тенанте - и проставить его UserRole. Если владельца нет - оставить NULL. - """ - Inventory = apps.get_model('inventory', 'Inventory') - UserRole = apps.get_model('user_roles', 'UserRole') - Role = apps.get_model('user_roles', 'Role') - - # Находим первого владельца в текущем тенанте - try: - owner_role = Role.objects.get(code='owner', is_system=True) - owner_user_role = UserRole.objects.filter( - role=owner_role, - is_active=True - ).first() - - if owner_user_role: - # Обновляем все существующие инвентаризации, у которых есть старое текстовое значение - # Используем conducted_by_old (переименованное поле) для проверки - Inventory.objects.exclude(conducted_by_old__isnull=True).exclude(conducted_by_old='').update( - conducted_by_new=owner_user_role - ) - except Role.DoesNotExist: - # Если нет роли - оставляем NULL - pass - - -def reverse_migration(apps, schema_editor): - """ - Откат: очищаем новое поле - """ - Inventory = apps.get_model('inventory', 'Inventory') - Inventory.objects.filter(conducted_by_new__isnull=False).update(conducted_by_new=None) - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0016_add_document_number_to_inventory'), - ('user_roles', '0001_initial'), - ] - - operations = [ - # Шаг 1: Переименовываем старое поле в conducted_by_old - migrations.RenameField( - model_name='inventory', - old_name='conducted_by', - new_name='conducted_by_old', - ), - # Шаг 2: Добавляем новое поле как ForeignKey - migrations.AddField( - model_name='inventory', - name='conducted_by_new', - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name='inventories_new', - to='user_roles.userrole', - verbose_name='Провел инвентаризацию' - ), - ), - # Шаг 3: Миграция данных - migrations.RunPython( - migrate_conducted_by_data, - reverse_code=reverse_migration - ), - # Шаг 4: Удаляем старое поле - migrations.RemoveField( - model_name='inventory', - name='conducted_by_old', - ), - # Шаг 5: Переименовываем новое поле в conducted_by - migrations.RenameField( - model_name='inventory', - old_name='conducted_by_new', - new_name='conducted_by', - ), - # Шаг 6: Исправляем related_name - migrations.AlterField( - model_name='inventory', - name='conducted_by', - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name='inventories', - to='user_roles.userrole', - verbose_name='Провел инвентаризацию' - ), - ), - ] - diff --git a/myproject/inventory/migrations/0018_inventoryline_snapshot_difference_and_more.py b/myproject/inventory/migrations/0018_inventoryline_snapshot_difference_and_more.py deleted file mode 100644 index 0d7cc29..0000000 --- a/myproject/inventory/migrations/0018_inventoryline_snapshot_difference_and_more.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-22 10:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0017_change_conducted_by_to_fk'), - ] - - operations = [ - migrations.AddField( - model_name='inventoryline', - name='snapshot_difference', - field=models.DecimalField(blank=True, decimal_places=3, help_text='Итоговая разница на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='Итоговая разница (snapshot)'), - ), - migrations.AddField( - model_name='inventoryline', - name='snapshot_quantity_available', - field=models.DecimalField(blank=True, decimal_places=3, help_text='Всего на складе на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='Всего на складе (snapshot)'), - ), - migrations.AddField( - model_name='inventoryline', - name='snapshot_quantity_reserved', - field=models.DecimalField(blank=True, decimal_places=3, help_text='В резервах на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='В резервах (snapshot)'), - ), - migrations.AddField( - model_name='inventoryline', - name='snapshot_quantity_system', - field=models.DecimalField(blank=True, decimal_places=3, help_text='В системе свободно на момент завершения инвентаризации', max_digits=10, null=True, verbose_name='В системе свободно (snapshot)'), - ), - migrations.AlterField( - model_name='inventoryline', - name='difference', - field=models.DecimalField(decimal_places=3, default=0, editable=False, help_text='(Подсчитано + Зарезервировано) - Всего на складе', max_digits=10, verbose_name='Итоговая разница'), - ), - migrations.AlterField( - model_name='inventoryline', - name='quantity_fact', - field=models.DecimalField(decimal_places=3, help_text='Количество свободных товаров, подсчитанных физически', max_digits=10, verbose_name='Подсчитано (факт, свободные)'), - ), - ] diff --git a/myproject/orders/admin.py b/myproject/orders/admin.py index a21ddbf..3e8dba2 100644 --- a/myproject/orders/admin.py +++ b/myproject/orders/admin.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from django.contrib import admin from django.utils.html import format_html -from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus, Recipient +from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus, Recipient, Delivery class TransactionInline(admin.TabularInline): @@ -31,6 +31,18 @@ class OrderItemInline(admin.TabularInline): return [] +class DeliveryInline(admin.StackedInline): + """ + Inline для управления доставкой заказа. + """ + model = Delivery + extra = 0 + max_num = 1 + fields = ['delivery_type', 'address', 'pickup_warehouse', 'cost'] + verbose_name = 'Доставка' + verbose_name_plural = 'Доставка' + + @admin.register(Order) class OrderAdmin(admin.ModelAdmin): """ @@ -39,8 +51,6 @@ class OrderAdmin(admin.ModelAdmin): list_display = [ 'order_number', 'customer', - 'is_delivery', - 'delivery_date', 'status', 'total_amount', 'payment_status', @@ -50,9 +60,7 @@ class OrderAdmin(admin.ModelAdmin): list_filter = [ 'status', - 'is_delivery', 'payment_status', - 'delivery_date', 'created_at', ] @@ -62,15 +70,12 @@ class OrderAdmin(admin.ModelAdmin): 'customer__phone', 'customer__email', 'recipient__name', - 'delivery_address__street', ] readonly_fields = [ 'order_number', 'created_at', 'updated_at', - 'delivery_info', - 'delivery_time_window', 'amount_due', 'payment_status', ] @@ -79,18 +84,10 @@ class OrderAdmin(admin.ModelAdmin): ('Основная информация', { 'fields': ('order_number', 'customer', 'status') }), - ('Доставка', { + ('Получатель', { 'fields': ( - 'is_delivery', 'customer_is_recipient', - 'delivery_address', - 'pickup_warehouse', - 'delivery_date', - 'delivery_time_start', - 'delivery_time_end', - 'delivery_cost', - 'delivery_info', - 'delivery_time_window', + 'recipient', ) }), ('Оплата', { @@ -111,7 +108,7 @@ class OrderAdmin(admin.ModelAdmin): }), ) - inlines = [OrderItemInline, TransactionInline] + inlines = [OrderItemInline, DeliveryInline, TransactionInline] actions = [ 'mark_as_confirmed', diff --git a/myproject/orders/forms.py b/myproject/orders/forms.py index 021a2f6..69e520b 100644 --- a/myproject/orders/forms.py +++ b/myproject/orders/forms.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- from django import forms from django.forms import inlineformset_factory -from .models import Order, OrderItem, Transaction, Address, OrderStatus, Recipient +from .models import Order, OrderItem, Transaction, Address, OrderStatus, Recipient, Delivery from customers.models import Customer -from inventory.models import Warehouse from products.models import Product, ProductKit from decimal import Decimal @@ -123,17 +122,54 @@ class OrderForm(forms.ModelForm): label='Уточнить адрес у получателя' ) + # Поля для доставки + delivery_type = forms.ChoiceField( + choices=Delivery.DELIVERY_TYPE_CHOICES, + required=True, + widget=forms.RadioSelect(attrs={'class': 'form-check-input'}), + label='Способ доставки', + initial=Delivery.DELIVERY_TYPE_COURIER + ) + + delivery_date = forms.DateField( + required=True, + widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), + label='Дата доставки' + ) + + time_from = forms.TimeField( + required=True, + widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}), + label='Время доставки от' + ) + + time_to = forms.TimeField( + required=True, + widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}), + label='Время доставки до' + ) + + pickup_warehouse = forms.ModelChoiceField( + queryset=None, # Будет установлен в __init__ + required=False, + widget=forms.Select(attrs={'class': 'form-select'}), + label='Склад самовывоза', + empty_label='Выберите склад' + ) + + delivery_cost = forms.DecimalField( + required=False, + max_digits=10, + decimal_places=2, + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}), + label='Стоимость доставки', + initial=0 + ) + class Meta: model = Order fields = [ 'customer', - 'is_delivery', - 'delivery_address', - 'pickup_warehouse', - 'delivery_date', - 'delivery_time_start', - 'delivery_time_end', - 'delivery_cost', 'customer_is_recipient', 'recipient', 'status', @@ -141,9 +177,6 @@ class OrderForm(forms.ModelForm): 'special_instructions', ] widgets = { - 'delivery_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), - 'delivery_time_start': forms.TimeInput(attrs={'type': 'time'}, format='%H:%M'), - 'delivery_time_end': forms.TimeInput(attrs={'type': 'time'}, format='%H:%M'), 'special_instructions': forms.Textarea(attrs={'rows': 3}), } @@ -199,36 +232,12 @@ class OrderForm(forms.ModelForm): 'data-placeholder': 'Начните вводить имя, телефон или email' }) - self.fields['delivery_address'].widget.attrs.update({ - 'class': 'form-select select2', - 'data-placeholder': 'Выберите адрес доставки' - }) - # Адрес доставки не обязателен при редактировании (создаётся из отдельных полей) - self.fields['delivery_address'].required = False - - self.fields['pickup_warehouse'].widget.attrs.update({ - 'class': 'form-select select2', - 'data-placeholder': 'Выберите склад для самовывоза' - }) - self.fields['pickup_warehouse'].required = False - - # Опциональные поля даты/времени - self.fields['delivery_date'].required = False - self.fields['delivery_time_start'].required = False - self.fields['delivery_time_end'].required = False - # Подсказки - self.fields['is_delivery'].label = 'С доставкой' self.fields['customer_is_recipient'].label = 'Покупатель = получатель' # Поле получателя опционально self.fields['recipient'].required = False - # Поле ручной стоимости доставки опционально - self.fields['delivery_cost'].required = False - self.fields['delivery_cost'].label = 'Ручная стоимость доставки' - self.fields['delivery_cost'].help_text = 'Оставьте пустым для автоматического расчета' - # Инициализируем queryset для recipient_from_history if self.instance.pk and self.instance.customer: # При редактировании заказа загружаем историю получателей этого клиента @@ -240,62 +249,94 @@ class OrderForm(forms.ModelForm): orders__in=customer_orders ).distinct().order_by('-created_at') - # Инициализируем queryset для address_from_history - # Это будет переопределено в представлении после выбора клиента - if self.instance.pk and self.instance.customer: - # При редактировании заказа загружаем историю адресов этого клиента - customer_orders = Order.objects.filter( - customer=self.instance.customer, - delivery_address__isnull=False - ).order_by('-created_at') - self.fields['address_from_history'].queryset = Address.objects.filter( - orders__in=customer_orders - ).distinct().order_by('-created_at') - # Инициализируем поля получателя из существующего recipient if self.instance.pk and self.instance.recipient: recipient = self.instance.recipient self.fields['recipient_name'].initial = recipient.name or '' self.fields['recipient_phone'].initial = recipient.phone or '' - # Инициализируем поля адреса из существующего delivery_address - if self.instance.pk and self.instance.delivery_address: - address = self.instance.delivery_address - self.fields['address_street'].initial = address.street or '' - self.fields['address_building_number'].initial = address.building_number or '' - self.fields['address_apartment_number'].initial = address.apartment_number or '' - self.fields['address_entrance'].initial = address.entrance or '' - self.fields['address_floor'].initial = address.floor or '' - self.fields['address_intercom_code'].initial = address.intercom_code or '' - self.fields['address_delivery_instructions'].initial = address.delivery_instructions or '' - self.fields['address_confirm_with_recipient'].initial = address.confirm_address_with_recipient + # Инициализируем queryset для pickup_warehouse + from inventory.models import Warehouse + self.fields['pickup_warehouse'].queryset = Warehouse.objects.filter(is_active=True).order_by('name') + + # Инициализируем поля доставки из существующей Delivery + if self.instance.pk and hasattr(self.instance, 'delivery'): + delivery = self.instance.delivery + self.fields['delivery_type'].initial = delivery.delivery_type + self.fields['delivery_date'].initial = delivery.delivery_date + self.fields['time_from'].initial = delivery.time_from + self.fields['time_to'].initial = delivery.time_to + self.fields['pickup_warehouse'].initial = delivery.pickup_warehouse + self.fields['delivery_cost'].initial = delivery.cost + + def clean(self): + """Валидация формы заказа, включая обязательные поля доставки""" + cleaned_data = super().clean() + + # Проверяем, является ли заказ черновиком + status = cleaned_data.get('status') + is_draft = status and hasattr(status, 'code') and status.code == 'draft' + + # Для черновиков Delivery не обязательна + if is_draft: + return cleaned_data + + # Для не-черновиков Delivery обязательна + delivery_type = cleaned_data.get('delivery_type') + delivery_date = cleaned_data.get('delivery_date') + time_from = cleaned_data.get('time_from') + time_to = cleaned_data.get('time_to') + pickup_warehouse = cleaned_data.get('pickup_warehouse') + + # Проверяем обязательные поля доставки + if not delivery_type: + raise forms.ValidationError({'delivery_type': 'Необходимо выбрать способ доставки'}) + + if not delivery_date: + raise forms.ValidationError({'delivery_date': 'Необходимо указать дату доставки'}) + + if not time_from: + raise forms.ValidationError({'time_from': 'Необходимо указать время начала доставки'}) + + if not time_to: + raise forms.ValidationError({'time_to': 'Необходимо указать время окончания доставки'}) + + # Проверяем, что время "до" позже времени "от" + if time_from and time_to and time_from >= time_to: + raise forms.ValidationError({ + 'time_to': 'Время окончания доставки должно быть позже времени начала' + }) + + # Проверяем специфичные требования для каждого типа доставки + if delivery_type == Delivery.DELIVERY_TYPE_COURIER: + # Для курьерской доставки нужен адрес + address_mode = cleaned_data.get('address_mode') + address_from_history = cleaned_data.get('address_from_history') + address_street = cleaned_data.get('address_street', '').strip() + + has_address = ( + (address_mode == 'history' and address_from_history) or + (address_mode == 'new' and address_street) + ) + + if not has_address: + raise forms.ValidationError({ + 'address_mode': 'Для курьерской доставки необходимо указать адрес' + }) + + elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP: + # Для самовывоза нужен склад + if not pickup_warehouse: + raise forms.ValidationError({ + 'pickup_warehouse': 'Для самовывоза необходимо выбрать склад' + }) + + return cleaned_data def save(self, commit=True): - """ - Сохраняет форму с учетом автоматического/ручного расчета стоимости доставки. - Логика: - - Если delivery_cost заполнено → используется ручное значение (is_custom_delivery_cost = True) - - Если delivery_cost пустое → автоматический расчет (is_custom_delivery_cost = False) - - ВАЖНО: reset_delivery_cost() вызывается только при commit=True, - т.к. требует наличия сохраненных items в БД. - """ + """Сохраняет форму заказа.""" instance = super().save(commit=False) - # Получаем значение ручной стоимости доставки - delivery_cost = self.cleaned_data.get('delivery_cost') - - if delivery_cost is not None and delivery_cost > 0: - # Ручное значение указано - instance.set_delivery_cost(delivery_cost, is_custom=True) - else: - # Пустое поле или 0 → помечаем что нужен автоматический расчет - # НО не вызываем reset_delivery_cost() если commit=False! - instance.is_custom_delivery_cost = False - if commit: - # Автоматический расчет только при commit=True - instance.reset_delivery_cost() - if commit: instance.save() diff --git a/myproject/orders/migrations/0001_initial.py b/myproject/orders/migrations/0001_initial.py index ddbb427..0d78d65 100644 --- a/myproject/orders/migrations/0001_initial.py +++ b/myproject/orders/migrations/0001_initial.py @@ -1,7 +1,8 @@ -# Generated by Django 5.0.10 on 2025-11-15 11:57 +# Generated by Django 5.0.10 on 2025-12-23 20:38 import django.db.models.deletion import simple_history.models +from decimal import Decimal from django.conf import settings from django.db import migrations, models @@ -17,28 +18,54 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='KitItemSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('product_name', models.CharField(blank=True, max_length=200, verbose_name='Название товара')), + ('product_sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул товара')), + ('product_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Цена товара')), + ('variant_group_name', models.CharField(blank=True, max_length=200, verbose_name='Группа вариантов')), + ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), + ], + options={ + 'verbose_name': 'Снимок компонента', + 'verbose_name_plural': 'Снимки компонентов', + }, + ), + migrations.CreateModel( + name='KitSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название')), + ('sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('base_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Базовая цена')), + ('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Итоговая цена')), + ('sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Цена со скидкой')), + ('price_adjustment_type', models.CharField(default='none', max_length=20, verbose_name='Тип корректировки')), + ('price_adjustment_value', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Значение корректировки')), + ('is_temporary', models.BooleanField(default=False, verbose_name='Временный комплект')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ], + options={ + 'verbose_name': 'Снимок комплекта', + 'verbose_name_plural': 'Снимки комплектов', + 'ordering': ['-created_at'], + }, + ), migrations.CreateModel( name='Order', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('order_number', models.PositiveIntegerField(editable=False, help_text='Уникальный номер заказа', unique=True, verbose_name='Номер заказа')), - ('is_delivery', models.BooleanField(default=True, help_text='True - доставка курьером, False - самовывоз', verbose_name='С доставкой')), - ('delivery_date', models.DateField(blank=True, help_text='Может быть заполнено позже', null=True, verbose_name='Дата доставки/самовывоза')), - ('delivery_time_start', models.TimeField(blank=True, help_text='Начало временного интервала', null=True, verbose_name='Время от')), - ('delivery_time_end', models.TimeField(blank=True, help_text='Конец временного интервала', null=True, verbose_name='Время до')), - ('delivery_cost', models.DecimalField(decimal_places=2, default=0, help_text='0 для самовывоза', max_digits=10, verbose_name='Стоимость доставки')), - ('is_custom_delivery_cost', models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную')), ('is_returned', models.BooleanField(default=False, help_text='True если заказ был выполнен, но потом отменен или возвращен клиентом', verbose_name='Возвращен')), ('last_autosave_at', models.DateTimeField(blank=True, help_text='Время последнего автоматического сохранения черновика', null=True, verbose_name='Последнее автосохранение')), - ('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], default='cash_to_courier', max_length=20, verbose_name='Способ оплаты')), ('is_paid', models.BooleanField(default=False, verbose_name='Оплачен')), - ('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа включая доставку', max_digits=10, verbose_name='Итоговая сумма заказа')), - ('discount_amount', models.DecimalField(decimal_places=2, default=0, help_text='Применяется вручную или через систему скидок', max_digits=10, verbose_name='Сумма скидки')), + ('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа', max_digits=10, verbose_name='Итоговая сумма заказа')), ('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')), ('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='Покупатель является получателем')), - ('recipient_name', models.CharField(blank=True, help_text='Заполняется, если покупатель не является получателем', max_length=200, null=True, verbose_name='Имя получателя')), - ('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя', max_length=20, null=True, verbose_name='Телефон получателя')), ('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')), ('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), @@ -54,9 +81,12 @@ class Migration(migrations.Migration): name='OrderItem', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('item_name_snapshot', models.CharField(default='', max_length=200, verbose_name='Название на момент заказа')), + ('item_sku_snapshot', models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа')), ('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')), ('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')), ('is_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')), + ('is_from_showcase', models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')), ], options={ @@ -87,26 +117,59 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='Payment', + name='PaymentMethod', 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='Сумма платежа')), - ('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], max_length=20, verbose_name='Способ оплаты')), - ('payment_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время платежа')), - ('notes', models.TextField(blank=True, help_text='Дополнительная информация о платеже', null=True, verbose_name='Примечания')), + ('code', models.SlugField(help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')", unique=True, verbose_name='Код способа оплаты')), + ('name', models.CharField(max_length=100, verbose_name='Название способа оплаты')), + ('description', models.TextField(blank=True, help_text='Дополнительная информация о способе оплаты', verbose_name='Описание')), + ('is_active', models.BooleanField(default=True, help_text='Отключенные способы оплаты не отображаются при создании заказа', verbose_name='Активен')), + ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')), + ('is_system', models.BooleanField(default=False, help_text='Системные способы оплаты нельзя удалить через интерфейс', verbose_name='Системный')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ], options={ - 'verbose_name': 'Платеж', - 'verbose_name_plural': 'Платежи', - 'ordering': ['-payment_date'], + 'verbose_name': 'Способ оплаты', + 'verbose_name_plural': 'Способы оплаты', + 'ordering': ['order', 'name'], + }, + ), + migrations.CreateModel( + name='Recipient', + 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='Телефон получателя')), + ('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='Transaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transaction_type', models.CharField(choices=[('payment', 'Платёж'), ('refund', 'Возврат')], default='payment', max_length=20, verbose_name='Тип транзакции')), + ('amount', models.DecimalField(decimal_places=2, help_text="Всегда положительная. Для возврата используется transaction_type='refund'", max_digits=10, verbose_name='Сумма')), + ('transaction_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время транзакции')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), + ('reason', models.CharField(blank=True, help_text='Причина возврата или особенности платежа', max_length=255, null=True, verbose_name='Причина')), + ], + options={ + 'verbose_name': 'Транзакция', + 'verbose_name_plural': 'Транзакции', + 'ordering': ['-transaction_date'], }, ), migrations.CreateModel( name='Address', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('recipient_name', models.CharField(blank=True, help_text='Имя человека, которому будет доставлен заказ', max_length=200, null=True, verbose_name='Имя получателя')), - ('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя для уточнения адреса', max_length=20, null=True, verbose_name='Телефон получателя')), ('street', models.CharField(blank=True, max_length=255, null=True, verbose_name='Улица')), ('building_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер здания')), ('apartment_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер квартиры/офиса')), @@ -125,28 +188,38 @@ class Migration(migrations.Migration): 'indexes': [models.Index(fields=['created_at'], name='orders_addr_created_98ad97_idx')], }, ), + migrations.CreateModel( + name='Delivery', + fields=[ + ('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='Время доставки до')), + ('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='Дата обновления')), + ('address', models.ForeignKey(blank=True, help_text='Адрес для курьерской доставки. На один адрес может быть много доставок', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deliveries', to='orders.address', verbose_name='Адрес доставки')), + ('pickup_warehouse', models.ForeignKey(blank=True, help_text='Склад для самовывоза заказа', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='deliveries', to='inventory.warehouse', verbose_name='Склад самовывоза')), + ], + options={ + 'verbose_name': 'Доставка', + 'verbose_name_plural': 'Доставки', + 'ordering': ['-created_at'], + }, + ), migrations.CreateModel( name='HistoricalOrder', fields=[ ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), ('order_number', models.PositiveIntegerField(db_index=True, editable=False, help_text='Уникальный номер заказа', verbose_name='Номер заказа')), - ('is_delivery', models.BooleanField(default=True, help_text='True - доставка курьером, False - самовывоз', verbose_name='С доставкой')), - ('delivery_date', models.DateField(blank=True, help_text='Может быть заполнено позже', null=True, verbose_name='Дата доставки/самовывоза')), - ('delivery_time_start', models.TimeField(blank=True, help_text='Начало временного интервала', null=True, verbose_name='Время от')), - ('delivery_time_end', models.TimeField(blank=True, help_text='Конец временного интервала', null=True, verbose_name='Время до')), - ('delivery_cost', models.DecimalField(decimal_places=2, default=0, help_text='0 для самовывоза', max_digits=10, verbose_name='Стоимость доставки')), - ('is_custom_delivery_cost', models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную')), ('is_returned', models.BooleanField(default=False, help_text='True если заказ был выполнен, но потом отменен или возвращен клиентом', verbose_name='Возвращен')), ('last_autosave_at', models.DateTimeField(blank=True, help_text='Время последнего автоматического сохранения черновика', null=True, verbose_name='Последнее автосохранение')), - ('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], default='cash_to_courier', max_length=20, verbose_name='Способ оплаты')), ('is_paid', models.BooleanField(default=False, verbose_name='Оплачен')), - ('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа включая доставку', max_digits=10, verbose_name='Итоговая сумма заказа')), - ('discount_amount', models.DecimalField(decimal_places=2, default=0, help_text='Применяется вручную или через систему скидок', max_digits=10, verbose_name='Сумма скидки')), + ('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа', max_digits=10, verbose_name='Итоговая сумма заказа')), ('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')), ('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='Покупатель является получателем')), - ('recipient_name', models.CharField(blank=True, help_text='Заполняется, если покупатель не является получателем', max_length=200, null=True, verbose_name='Имя получателя')), - ('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя', max_length=20, null=True, verbose_name='Телефон получателя')), ('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')), ('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')), ('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата создания')), @@ -156,10 +229,8 @@ class Migration(migrations.Migration): ('history_change_reason', models.CharField(max_length=100, null=True)), ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), ('customer', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='customers.customer', verbose_name='Клиент')), - ('delivery_address', models.ForeignKey(blank=True, db_constraint=False, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.address', verbose_name='Адрес доставки')), ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), ('modified_by', models.ForeignKey(blank=True, db_constraint=False, help_text='Последний пользователь, изменивший заказ', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Изменен пользователем')), - ('pickup_warehouse', models.ForeignKey(blank=True, db_constraint=False, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='inventory.warehouse', verbose_name='Склад для самовывоза')), ], options={ 'verbose_name': 'historical Заказ', @@ -173,9 +244,12 @@ class Migration(migrations.Migration): name='HistoricalOrderItem', fields=[ ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('item_name_snapshot', models.CharField(default='', max_length=200, verbose_name='Название на момент заказа')), + ('item_sku_snapshot', models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа')), ('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')), ('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')), ('is_custom_price', models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную')), + ('is_from_showcase', models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины')), ('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата добавления')), ('history_id', models.AutoField(primary_key=True, serialize=False)), ('history_date', models.DateTimeField(db_index=True)), diff --git a/myproject/orders/migrations/0002_initial.py b/myproject/orders/migrations/0002_initial.py index 18f9660..63f1343 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-11-15 11:57 +# Generated by Django 5.0.10 on 2025-12-23 20:38 import django.db.models.deletion from django.conf import settings @@ -10,7 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('customers', '0001_initial'), + ('customers', '0002_initial'), ('inventory', '0002_initial'), ('orders', '0001_initial'), ('products', '0001_initial'), @@ -29,30 +29,55 @@ class Migration(migrations.Migration): field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='products.productkit', verbose_name='Комплект товаров'), ), migrations.AddField( - model_name='order', - name='customer', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент'), + model_name='historicalorderitem', + name='showcase', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='inventory.showcase', verbose_name='Витрина'), + ), + migrations.AddField( + model_name='kititemsnapshot', + name='original_product', + field=models.ForeignKey(blank=True, help_text='Ссылка на товар для резервирования на складе', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kit_item_snapshots', to='products.product', verbose_name='Оригинальный товар'), + ), + migrations.AddField( + model_name='kitsnapshot', + name='original_kit', + field=models.ForeignKey(blank=True, help_text='Ссылка на комплект, с которого создан снимок', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='snapshots', to='products.productkit', verbose_name='Оригинальный комплект'), + ), + migrations.AddField( + model_name='kititemsnapshot', + name='kit_snapshot', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'), + ), + migrations.AddField( + model_name='historicalorderitem', + name='kit_snapshot', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.kitsnapshot', verbose_name='Снимок комплекта'), ), migrations.AddField( model_name='order', - name='delivery_address', - field=models.OneToOneField(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='orders.address', verbose_name='Адрес доставки'), + name='customer', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент'), ), migrations.AddField( model_name='order', name='modified_by', field=models.ForeignKey(blank=True, help_text='Последний пользователь, изменивший заказ', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_orders', to=settings.AUTH_USER_MODEL, verbose_name='Изменен пользователем'), ), - migrations.AddField( - model_name='order', - name='pickup_warehouse', - field=models.ForeignKey(blank=True, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pickup_orders', to='inventory.warehouse', verbose_name='Склад для самовывоза'), - ), migrations.AddField( model_name='historicalorderitem', name='order', field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.order', verbose_name='Заказ'), ), + migrations.AddField( + model_name='delivery', + name='order', + field=models.OneToOneField(help_text='Заказ, к которому относится доставка', on_delete=django.db.models.deletion.CASCADE, related_name='delivery', to='orders.order', verbose_name='Заказ'), + ), + migrations.AddField( + model_name='orderitem', + name='kit_snapshot', + field=models.ForeignKey(blank=True, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'), + ), migrations.AddField( model_name='orderitem', name='order', @@ -68,6 +93,11 @@ class Migration(migrations.Migration): name='product_kit', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='products.productkit', verbose_name='Комплект товаров'), ), + migrations.AddField( + model_name='orderitem', + name='showcase', + field=models.ForeignKey(blank=True, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='inventory.showcase', verbose_name='Витрина'), + ), migrations.AddField( model_name='orderstatus', name='created_by', @@ -89,14 +119,83 @@ class Migration(migrations.Migration): field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.orderstatus', verbose_name='Статус заказа'), ), migrations.AddField( - model_name='payment', + model_name='paymentmethod', name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments_created', to=settings.AUTH_USER_MODEL, verbose_name='Принял платеж'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_payment_methods', to=settings.AUTH_USER_MODEL, verbose_name='Создано'), + ), + migrations.AddIndex( + model_name='recipient', + index=models.Index(fields=['phone'], name='orders_reci_phone_735356_idx'), + ), + migrations.AddIndex( + model_name='recipient', + index=models.Index(fields=['name'], name='orders_reci_name_e52d5b_idx'), + ), + migrations.AddIndex( + model_name='recipient', + index=models.Index(fields=['created_at'], name='orders_reci_created_34a391_idx'), ), migrations.AddField( - model_name='payment', + model_name='order', + name='recipient', + field=models.ForeignKey(blank=True, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.recipient', verbose_name='Получатель'), + ), + migrations.AddField( + model_name='historicalorder', + name='recipient', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.recipient', verbose_name='Получатель'), + ), + migrations.AddField( + model_name='transaction', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал'), + ), + migrations.AddField( + model_name='transaction', name='order', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='orders.order', verbose_name='Заказ'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='orders.order', verbose_name='Заказ'), + ), + migrations.AddField( + model_name='transaction', + name='payment_method', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='orders.paymentmethod', verbose_name='Способ оплаты/возврата'), + ), + migrations.AddField( + model_name='transaction', + name='related_payment', + field=models.ForeignKey(blank=True, help_text='Для возвратов - на какой платёж ссылается этот возврат', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refunds', to='orders.transaction', verbose_name='Связанный платёж'), + ), + migrations.AddIndex( + model_name='kitsnapshot', + index=models.Index(fields=['original_kit'], name='orders_kits_origina_f8d311_idx'), + ), + migrations.AddIndex( + model_name='kitsnapshot', + index=models.Index(fields=['created_at'], name='orders_kits_created_70de88_idx'), + ), + migrations.AddIndex( + model_name='kititemsnapshot', + index=models.Index(fields=['kit_snapshot'], name='orders_kiti_kit_sna_bf307e_idx'), + ), + migrations.AddIndex( + model_name='delivery', + index=models.Index(fields=['delivery_type'], name='orders_deli_deliver_ac3dc8_idx'), + ), + migrations.AddIndex( + model_name='delivery', + index=models.Index(fields=['created_at'], name='orders_deli_created_1a3ff3_idx'), + ), + migrations.AddIndex( + model_name='delivery', + index=models.Index(fields=['delivery_date'], name='orders_deli_deliver_e898e4_idx'), + ), + migrations.AddIndex( + model_name='delivery', + index=models.Index(fields=['time_from'], name='orders_deli_time_fr_916f57_idx'), + ), + migrations.AddIndex( + model_name='delivery', + index=models.Index(fields=['time_to'], name='orders_deli_time_to_7f2573_idx'), ), migrations.AddIndex( model_name='orderitem', @@ -110,6 +209,14 @@ class Migration(migrations.Migration): model_name='orderitem', index=models.Index(fields=['product_kit'], name='orders_orde_product_925b51_idx'), ), + migrations.AddIndex( + model_name='orderitem', + index=models.Index(fields=['is_from_showcase'], name='orders_orde_is_from_32d8f7_idx'), + ), + migrations.AddIndex( + model_name='orderitem', + index=models.Index(fields=['showcase'], name='orders_orde_showcas_aa97bd_idx'), + ), migrations.AddIndex( model_name='orderstatus', index=models.Index(fields=['code'], name='orders_orde_code_5e1ef7_idx'), @@ -122,6 +229,18 @@ class Migration(migrations.Migration): model_name='orderstatus', index=models.Index(fields=['order'], name='orders_orde_order_2e2930_idx'), ), + migrations.AddIndex( + model_name='paymentmethod', + index=models.Index(fields=['code'], name='orders_paym_code_f40d7e_idx'), + ), + migrations.AddIndex( + model_name='paymentmethod', + index=models.Index(fields=['is_active'], name='orders_paym_is_acti_e2be69_idx'), + ), + migrations.AddIndex( + model_name='paymentmethod', + index=models.Index(fields=['order'], name='orders_paym_order_94e282_idx'), + ), migrations.AddIndex( model_name='order', index=models.Index(fields=['customer'], name='orders_orde_custome_59b6fb_idx'), @@ -130,14 +249,6 @@ class Migration(migrations.Migration): model_name='order', index=models.Index(fields=['status'], name='orders_orde_status__eb4f00_idx'), ), - migrations.AddIndex( - model_name='order', - index=models.Index(fields=['delivery_date'], name='orders_orde_deliver_e4274f_idx'), - ), - migrations.AddIndex( - model_name='order', - index=models.Index(fields=['is_delivery'], name='orders_orde_is_deli_07c9c0_idx'), - ), migrations.AddIndex( model_name='order', index=models.Index(fields=['payment_status'], name='orders_orde_payment_bc131d_idx'), @@ -151,15 +262,19 @@ class Migration(migrations.Migration): index=models.Index(fields=['order_number'], name='orders_orde_order_n_f3ada5_idx'), ), migrations.AddIndex( - model_name='order', - index=models.Index(fields=['is_custom_delivery_cost'], name='orders_orde_is_cust_108e98_idx'), + model_name='transaction', + index=models.Index(fields=['order', '-transaction_date'], name='orders_tran_order_i_dc90ee_idx'), ), migrations.AddIndex( - model_name='payment', - index=models.Index(fields=['order'], name='orders_paym_order_i_8c8d98_idx'), + model_name='transaction', + index=models.Index(fields=['transaction_type'], name='orders_tran_transac_3d971d_idx'), ), migrations.AddIndex( - model_name='payment', - index=models.Index(fields=['payment_date'], name='orders_paym_payment_9e5ac0_idx'), + model_name='transaction', + index=models.Index(fields=['payment_method'], name='orders_tran_payment_7e354c_idx'), + ), + migrations.AddIndex( + model_name='transaction', + index=models.Index(fields=['transaction_date'], name='orders_tran_transac_1bae48_idx'), ), ] diff --git a/myproject/orders/migrations/0003_historicalorderitem_is_from_showcase_and_more.py b/myproject/orders/migrations/0003_historicalorderitem_is_from_showcase_and_more.py deleted file mode 100644 index 32145ed..0000000 --- a/myproject/orders/migrations/0003_historicalorderitem_is_from_showcase_and_more.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-16 18:07 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0003_showcase_reservation_showcase_and_more'), - ('orders', '0002_initial'), - ('products', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='historicalorderitem', - name='is_from_showcase', - field=models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины'), - ), - migrations.AddField( - model_name='historicalorderitem', - name='showcase', - field=models.ForeignKey(blank=True, db_constraint=False, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='inventory.showcase', verbose_name='Витрина'), - ), - migrations.AddField( - model_name='orderitem', - name='is_from_showcase', - field=models.BooleanField(default=False, help_text='True если товар продан с витрины', verbose_name='С витрины'), - ), - migrations.AddField( - model_name='orderitem', - name='showcase', - field=models.ForeignKey(blank=True, help_text='Витрина, с которой был продан товар', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='inventory.showcase', verbose_name='Витрина'), - ), - migrations.AddIndex( - model_name='orderitem', - index=models.Index(fields=['is_from_showcase'], name='orders_orde_is_from_32d8f7_idx'), - ), - migrations.AddIndex( - model_name='orderitem', - index=models.Index(fields=['showcase'], name='orders_orde_showcas_aa97bd_idx'), - ), - ] diff --git a/myproject/orders/migrations/0004_refactor_models_and_add_payment_method.py b/myproject/orders/migrations/0004_refactor_models_and_add_payment_method.py deleted file mode 100644 index 524f9eb..0000000 --- a/myproject/orders/migrations/0004_refactor_models_and_add_payment_method.py +++ /dev/null @@ -1,61 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-26 08:06 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('orders', '0003_historicalorderitem_is_from_showcase_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveField( - model_name='historicalorder', - name='payment_method', - ), - migrations.RemoveField( - model_name='order', - name='payment_method', - ), - migrations.CreateModel( - name='PaymentMethod', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.SlugField(help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')", unique=True, verbose_name='Код способа оплаты')), - ('name', models.CharField(max_length=100, verbose_name='Название способа оплаты')), - ('description', models.TextField(blank=True, help_text='Дополнительная информация о способе оплаты', verbose_name='Описание')), - ('is_active', models.BooleanField(default=True, help_text='Отключенные способы оплаты не отображаются при создании заказа', verbose_name='Активен')), - ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')), - ('is_system', models.BooleanField(default=False, help_text='Системные способы оплаты нельзя удалить через интерфейс', verbose_name='Системный')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_payment_methods', to=settings.AUTH_USER_MODEL, verbose_name='Создано')), - ], - options={ - 'verbose_name': 'Способ оплаты', - 'verbose_name_plural': 'Способы оплаты', - 'ordering': ['order', 'name'], - }, - ), - migrations.AlterField( - model_name='payment', - name='payment_method', - field=models.ForeignKey(help_text='Способ оплаты данного платежа', on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='orders.paymentmethod', verbose_name='Способ оплаты'), - ), - migrations.AddIndex( - model_name='paymentmethod', - index=models.Index(fields=['code'], name='orders_paym_code_f40d7e_idx'), - ), - migrations.AddIndex( - model_name='paymentmethod', - index=models.Index(fields=['is_active'], name='orders_paym_is_acti_e2be69_idx'), - ), - migrations.AddIndex( - model_name='paymentmethod', - index=models.Index(fields=['order'], name='orders_paym_order_94e282_idx'), - ), - ] diff --git a/myproject/orders/migrations/0005_remove_historicalorder_discount_amount_and_more.py b/myproject/orders/migrations/0005_remove_historicalorder_discount_amount_and_more.py deleted file mode 100644 index 0519d0b..0000000 --- a/myproject/orders/migrations/0005_remove_historicalorder_discount_amount_and_more.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-28 23:00 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('orders', '0004_refactor_models_and_add_payment_method'), - ] - - operations = [ - migrations.RemoveField( - model_name='historicalorder', - name='discount_amount', - ), - migrations.RemoveField( - model_name='order', - name='discount_amount', - ), - ] diff --git a/myproject/orders/migrations/0006_transaction_delete_payment_and_more.py b/myproject/orders/migrations/0006_transaction_delete_payment_and_more.py deleted file mode 100644 index b7da9d3..0000000 --- a/myproject/orders/migrations/0006_transaction_delete_payment_and_more.py +++ /dev/null @@ -1,55 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-29 09:42 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('orders', '0005_remove_historicalorder_discount_amount_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Transaction', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('transaction_type', models.CharField(choices=[('payment', 'Платёж'), ('refund', 'Возврат')], default='payment', max_length=20, verbose_name='Тип транзакции')), - ('amount', models.DecimalField(decimal_places=2, help_text="Всегда положительная. Для возврата используется transaction_type='refund'", max_digits=10, verbose_name='Сумма')), - ('transaction_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время транзакции')), - ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), - ('reason', models.CharField(blank=True, help_text='Причина возврата или особенности платежа', max_length=255, null=True, verbose_name='Причина')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), - ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='orders.order', verbose_name='Заказ')), - ('payment_method', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='orders.paymentmethod', verbose_name='Способ оплаты/возврата')), - ('related_payment', models.ForeignKey(blank=True, help_text='Для возвратов - на какой платёж ссылается этот возврат', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refunds', to='orders.transaction', verbose_name='Связанный платёж')), - ], - options={ - 'verbose_name': 'Транзакция', - 'verbose_name_plural': 'Транзакции', - 'ordering': ['-transaction_date'], - }, - ), - migrations.DeleteModel( - name='Payment', - ), - migrations.AddIndex( - model_name='transaction', - index=models.Index(fields=['order', '-transaction_date'], name='orders_tran_order_i_dc90ee_idx'), - ), - migrations.AddIndex( - model_name='transaction', - index=models.Index(fields=['transaction_type'], name='orders_tran_transac_3d971d_idx'), - ), - migrations.AddIndex( - model_name='transaction', - index=models.Index(fields=['payment_method'], name='orders_tran_payment_7e354c_idx'), - ), - migrations.AddIndex( - model_name='transaction', - index=models.Index(fields=['transaction_date'], name='orders_tran_transac_1bae48_idx'), - ), - ] diff --git a/myproject/orders/migrations/0007_kit_snapshots.py b/myproject/orders/migrations/0007_kit_snapshots.py deleted file mode 100644 index 0376352..0000000 --- a/myproject/orders/migrations/0007_kit_snapshots.py +++ /dev/null @@ -1,76 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-17 07:50 - -import django.db.models.deletion -from decimal import Decimal -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('orders', '0006_transaction_delete_payment_and_more'), - ('products', '0010_alter_product_cost_price'), - ] - - operations = [ - migrations.CreateModel( - name='KitSnapshot', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=200, verbose_name='Название')), - ('sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул')), - ('description', models.TextField(blank=True, verbose_name='Описание')), - ('base_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Базовая цена')), - ('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Итоговая цена')), - ('sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Цена со скидкой')), - ('price_adjustment_type', models.CharField(default='none', max_length=20, verbose_name='Тип корректировки')), - ('price_adjustment_value', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Значение корректировки')), - ('is_temporary', models.BooleanField(default=False, verbose_name='Временный комплект')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('original_kit', models.ForeignKey(blank=True, help_text='Ссылка на комплект, с которого создан снимок', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='snapshots', to='products.productkit', verbose_name='Оригинальный комплект')), - ], - options={ - 'verbose_name': 'Снимок комплекта', - 'verbose_name_plural': 'Снимки комплектов', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='KitItemSnapshot', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('product_name', models.CharField(blank=True, max_length=200, verbose_name='Название товара')), - ('product_sku', models.CharField(blank=True, max_length=100, verbose_name='Артикул товара')), - ('product_price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10, verbose_name='Цена товара')), - ('variant_group_name', models.CharField(blank=True, max_length=200, verbose_name='Группа вариантов')), - ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), - ('kit_snapshot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.kitsnapshot', verbose_name='Снимок комплекта')), - ], - options={ - 'verbose_name': 'Снимок компонента', - 'verbose_name_plural': 'Снимки компонентов', - }, - ), - migrations.AddField( - model_name='historicalorderitem', - name='kit_snapshot', - field=models.ForeignKey(blank=True, db_constraint=False, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.kitsnapshot', verbose_name='Снимок комплекта'), - ), - migrations.AddField( - model_name='orderitem', - name='kit_snapshot', - field=models.ForeignKey(blank=True, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'), - ), - migrations.AddIndex( - model_name='kitsnapshot', - index=models.Index(fields=['original_kit'], name='orders_kits_origina_f8d311_idx'), - ), - migrations.AddIndex( - model_name='kitsnapshot', - index=models.Index(fields=['created_at'], name='orders_kits_created_70de88_idx'), - ), - migrations.AddIndex( - model_name='kititemsnapshot', - index=models.Index(fields=['kit_snapshot'], name='orders_kiti_kit_sna_bf307e_idx'), - ), - ] diff --git a/myproject/orders/migrations/0008_add_item_snapshots.py b/myproject/orders/migrations/0008_add_item_snapshots.py deleted file mode 100644 index 1bf46f3..0000000 --- a/myproject/orders/migrations/0008_add_item_snapshots.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-17 11:58 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('orders', '0007_kit_snapshots'), - ] - - operations = [ - migrations.AddField( - model_name='historicalorderitem', - name='item_name_snapshot', - field=models.CharField(default='', max_length=200, verbose_name='Название на момент заказа'), - ), - migrations.AddField( - model_name='historicalorderitem', - name='item_sku_snapshot', - field=models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа'), - ), - migrations.AddField( - model_name='orderitem', - name='item_name_snapshot', - field=models.CharField(default='', max_length=200, verbose_name='Название на момент заказа'), - ), - migrations.AddField( - model_name='orderitem', - name='item_sku_snapshot', - field=models.CharField(blank=True, max_length=100, verbose_name='Артикул на момент заказа'), - ), - migrations.AlterField( - model_name='orderitem', - name='kit_snapshot', - field=models.ForeignKey(blank=True, help_text='Хранит состав комплекта на момент оформления заказа', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='orders.kitsnapshot', verbose_name='Снимок комплекта'), - ), - ] diff --git a/myproject/orders/migrations/0009_add_original_product_to_kit_item_snapshot.py b/myproject/orders/migrations/0009_add_original_product_to_kit_item_snapshot.py deleted file mode 100644 index 1f68f20..0000000 --- a/myproject/orders/migrations/0009_add_original_product_to_kit_item_snapshot.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-17 18:37 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('orders', '0008_add_item_snapshots'), - ('products', '0010_alter_product_cost_price'), - ] - - operations = [ - migrations.AddField( - model_name='kititemsnapshot', - name='original_product', - field=models.ForeignKey(blank=True, help_text='Ссылка на товар для резервирования на складе', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kit_item_snapshots', to='products.product', verbose_name='Оригинальный товар'), - ), - ] diff --git a/myproject/orders/migrations/0010_remove_address_recipient_name_and_more.py b/myproject/orders/migrations/0010_remove_address_recipient_name_and_more.py deleted file mode 100644 index 4c2ad57..0000000 --- a/myproject/orders/migrations/0010_remove_address_recipient_name_and_more.py +++ /dev/null @@ -1,69 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-22 19:32 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('orders', '0009_add_original_product_to_kit_item_snapshot'), - ] - - operations = [ - migrations.RemoveField( - model_name='address', - name='recipient_name', - ), - migrations.RemoveField( - model_name='address', - name='recipient_phone', - ), - migrations.RemoveField( - model_name='historicalorder', - name='recipient_name', - ), - migrations.RemoveField( - model_name='historicalorder', - name='recipient_phone', - ), - migrations.RemoveField( - model_name='order', - name='recipient_name', - ), - migrations.RemoveField( - model_name='order', - name='recipient_phone', - ), - migrations.AlterField( - model_name='order', - name='delivery_address', - field=models.ForeignKey(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.address', verbose_name='Адрес доставки'), - ), - migrations.CreateModel( - name='Recipient', - 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='Телефон получателя')), - ('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'], - 'indexes': [models.Index(fields=['phone'], name='orders_reci_phone_735356_idx'), models.Index(fields=['name'], name='orders_reci_name_e52d5b_idx'), models.Index(fields=['created_at'], name='orders_reci_created_34a391_idx')], - }, - ), - migrations.AddField( - model_name='historicalorder', - name='recipient', - field=models.ForeignKey(blank=True, db_constraint=False, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.recipient', verbose_name='Получатель'), - ), - migrations.AddField( - model_name='order', - name='recipient', - field=models.ForeignKey(blank=True, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.recipient', verbose_name='Получатель'), - ), - ] diff --git a/myproject/orders/migrations/0011_migrate_recipient_data.py b/myproject/orders/migrations/0011_migrate_recipient_data.py deleted file mode 100644 index 2962883..0000000 --- a/myproject/orders/migrations/0011_migrate_recipient_data.py +++ /dev/null @@ -1,77 +0,0 @@ -# Generated by Django 5.0.10 on 2025-12-22 19:32 - -from django.db import migrations - - -def migrate_recipient_data_forward(apps, schema_editor): - """ - Перенос данных получателей из старых полей Order в новую модель Recipient. - Так как поля recipient_name и recipient_phone уже удалены, - мы используем HistoricalOrder для восстановления данных. - """ - # Получаем модели - HistoricalOrder = apps.get_model('orders', 'HistoricalOrder') - Recipient = apps.get_model('orders', 'Recipient') - Order = apps.get_model('orders', 'Order') - - # Словарь для кэширования recipient'ов - recipients_cache = {} - - # Обрабатываем каждый заказ - for order in Order.objects.all(): - # Находим последнюю историческую запись для этого заказа - hist = HistoricalOrder.objects.filter( - order_number=order.order_number - ).order_by('-history_date').first() - - if not hist: - continue - - # Проверяем, есть ли данные получателя - recipient_name = getattr(hist, 'recipient_name', None) - recipient_phone = getattr(hist, 'recipient_phone', None) - - # Если получатель не указан или customer_is_recipient=True, пропускаем - if not recipient_name or not recipient_phone or order.customer_is_recipient: - continue - - # Создаем ключ для кэша - cache_key = f"{recipient_name}|{recipient_phone}" - - # Проверяем, есть ли уже такой получатель в кэше - if cache_key in recipients_cache: - recipient = recipients_cache[cache_key] - else: - # Создаем нового получателя - recipient, created = Recipient.objects.get_or_create( - name=recipient_name, - phone=recipient_phone - ) - recipients_cache[cache_key] = recipient - - # Привязываем получателя к заказу - order.recipient = recipient - order.save(update_fields=['recipient']) - - -def migrate_recipient_data_backward(apps, schema_editor): - """ - Обратная миграция - просто очищаем recipient поле в Order. - Данные вернутся из HistoricalOrder при повторном apply. - """ - Order = apps.get_model('orders', 'Order') - Order.objects.all().update(recipient=None) - - -class Migration(migrations.Migration): - - dependencies = [ - ('orders', '0010_remove_address_recipient_name_and_more'), - ] - - operations = [ - migrations.RunPython( - migrate_recipient_data_forward, - migrate_recipient_data_backward - ), - ] diff --git a/myproject/orders/models/__init__.py b/myproject/orders/models/__init__.py index a97987f..b5eaa5d 100644 --- a/myproject/orders/models/__init__.py +++ b/myproject/orders/models/__init__.py @@ -27,6 +27,7 @@ from .order import Order from .kit_snapshot import KitSnapshot, KitItemSnapshot from .order_item import OrderItem from .transaction import Transaction +from .delivery import Delivery __all__ = [ 'OrderStatus', @@ -38,4 +39,5 @@ __all__ = [ 'Transaction', 'KitSnapshot', 'KitItemSnapshot', + 'Delivery', ] diff --git a/myproject/orders/models/delivery.py b/myproject/orders/models/delivery.py new file mode 100644 index 0000000..2ca0714 --- /dev/null +++ b/myproject/orders/models/delivery.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +from django.db import models +from django.core.exceptions import ValidationError + + +class Delivery(models.Model): + """ + Модель доставки заказа. + Один заказ имеет одну доставку. + """ + + # Константы для типов доставки + DELIVERY_TYPE_COURIER = 'courier' + DELIVERY_TYPE_PICKUP = 'pickup' + + DELIVERY_TYPE_CHOICES = [ + (DELIVERY_TYPE_COURIER, 'Доставка курьером'), + (DELIVERY_TYPE_PICKUP, 'Самовывоз'), + ] + + # === Связи === + + order = models.OneToOneField( + 'orders.Order', + on_delete=models.CASCADE, + related_name='delivery', + verbose_name='Заказ', + help_text='Заказ, к которому относится доставка' + ) + + # Адрес доставки (только для курьерской доставки) + address = models.ForeignKey( + 'orders.Address', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='deliveries', + verbose_name='Адрес доставки', + help_text='Адрес для курьерской доставки. На один адрес может быть много доставок' + ) + + # Склад для самовывоза (только для самовывоза) + pickup_warehouse = models.ForeignKey( + 'inventory.Warehouse', + on_delete=models.PROTECT, + null=True, + blank=True, + related_name='deliveries', + verbose_name='Склад самовывоза', + help_text='Склад для самовывоза заказа' + ) + + # === Основные поля === + + delivery_type = models.CharField( + max_length=20, + choices=DELIVERY_TYPE_CHOICES, + default=DELIVERY_TYPE_COURIER, + verbose_name='Способ доставки', + db_index=True + ) + + # Дата и время доставки + delivery_date = models.DateField( + verbose_name='Дата доставки', + help_text='Дата, когда должна быть выполнена доставка' + ) + + time_from = models.TimeField( + verbose_name='Время доставки от', + help_text='Начальное время временного интервала доставки' + ) + + time_to = models.TimeField( + verbose_name='Время доставки до', + help_text='Конечное время временного интервала доставки' + ) + + cost = models.DecimalField( + max_digits=10, + decimal_places=2, + default=0, + verbose_name='Стоимость доставки', + help_text='Стоимость доставки в рублях. 0 для бесплатной доставки/самовывоза' + ) + + # === Метаданные === + + 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 = ['-created_at'] + indexes = [ + models.Index(fields=['delivery_type']), + models.Index(fields=['created_at']), + models.Index(fields=['delivery_date']), + models.Index(fields=['time_from']), + models.Index(fields=['time_to']), + ] + + def __str__(self): + """Строковое представление доставки""" + type_display = self.get_delivery_type_display() + return f"{type_display} для заказа #{self.order.order_number}" + + def clean(self): + """Валидация модели""" + super().clean() + + # Проверка: для курьерской доставки должен быть адрес + if self.delivery_type == self.DELIVERY_TYPE_COURIER: + if not self.address: + raise ValidationError({ + 'address': 'Для курьерской доставки необходимо указать адрес' + }) + if self.pickup_warehouse: + raise ValidationError({ + 'pickup_warehouse': 'Для курьерской доставки склад не указывается' + }) + + # Проверка: для самовывоза должен быть склад + if self.delivery_type == self.DELIVERY_TYPE_PICKUP: + if not self.pickup_warehouse: + raise ValidationError({ + 'pickup_warehouse': 'Для самовывоза необходимо указать склад' + }) + if self.address: + raise ValidationError({ + 'address': 'Для самовывоза адрес не указывается' + }) + + # Проверка: время "до" должно быть позже времени "от" + if self.time_from and self.time_to and self.time_from >= self.time_to: + raise ValidationError({ + 'time_to': 'Время окончания доставки должно быть позже времени начала' + }) + + def save(self, *args, **kwargs): + """Переопределение save для вызова валидации""" + self.full_clean() + super().save(*args, **kwargs) diff --git a/myproject/orders/models/order.py b/myproject/orders/models/order.py index 2494472..8800385 100644 --- a/myproject/orders/models/order.py +++ b/myproject/orders/models/order.py @@ -1,11 +1,8 @@ from django.db import models -from django.core.exceptions import ValidationError from accounts.models import CustomUser from customers.models import Customer -from inventory.models import Warehouse from simple_history.models import HistoricalRecords from .status import OrderStatus -from .address import Address from .recipient import Recipient @@ -31,71 +28,6 @@ class Order(models.Model): help_text="Уникальный номер заказа" ) - # Тип доставки - is_delivery = models.BooleanField( - default=True, - verbose_name="С доставкой", - help_text="True - доставка курьером, False - самовывоз" - ) - - # Адрес доставки (для курьерской доставки) - delivery_address = models.ForeignKey( - Address, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='orders', - verbose_name="Адрес доставки", - help_text="Обязательно для курьерской доставки" - ) - - # Склад для самовывоза - pickup_warehouse = models.ForeignKey( - Warehouse, - on_delete=models.PROTECT, - null=True, - blank=True, - related_name='pickup_orders', - verbose_name="Склад для самовывоза", - help_text="Обязательно для самовывоза" - ) - - # Дата и время доставки/самовывоза - delivery_date = models.DateField( - null=True, - blank=True, - verbose_name="Дата доставки/самовывоза", - help_text="Может быть заполнено позже" - ) - - delivery_time_start = models.TimeField( - null=True, - blank=True, - verbose_name="Время от", - help_text="Начало временного интервала" - ) - - delivery_time_end = models.TimeField( - null=True, - blank=True, - verbose_name="Время до", - help_text="Конец временного интервала" - ) - - delivery_cost = models.DecimalField( - max_digits=10, - decimal_places=2, - default=0, - verbose_name="Стоимость доставки", - help_text="0 для самовывоза" - ) - - is_custom_delivery_cost = models.BooleanField( - default=False, - verbose_name="Стоимость доставки установлена вручную", - help_text="True если стоимость доставки была изменена вручную" - ) - # Статус заказа status = models.ForeignKey( 'OrderStatus', @@ -135,7 +67,7 @@ class Order(models.Model): decimal_places=2, default=0, verbose_name="Итоговая сумма заказа", - help_text="Общая сумма заказа включая доставку" + help_text="Общая сумма заказа" ) # Частичная оплата @@ -192,6 +124,7 @@ class Order(models.Model): help_text="Комментарии и пожелания к заказу" ) + # Временные метки created_at = models.DateTimeField( auto_now_add=True, @@ -222,12 +155,9 @@ class Order(models.Model): indexes = [ models.Index(fields=['customer']), models.Index(fields=['status']), - models.Index(fields=['delivery_date']), - models.Index(fields=['is_delivery']), models.Index(fields=['payment_status']), models.Index(fields=['created_at']), models.Index(fields=['order_number']), - models.Index(fields=['is_custom_delivery_cost']), ] ordering = ['-created_at'] @@ -250,81 +180,6 @@ class Order(models.Model): self.order_number = 100 super().save(*args, **kwargs) - def clean(self): - """Валидация модели""" - super().clean() - - # Проверка: для самовывоза обязателен склад - if not self.is_delivery and not self.pickup_warehouse: - raise ValidationError({ - 'pickup_warehouse': 'Для самовывоза необходимо выбрать склад' - }) - - # Проверка: время окончания должно быть позже или равно времени начала - # Равные времена означают точное время доставки (например, "к 13:00") - if self.delivery_time_start and self.delivery_time_end: - if self.delivery_time_end < self.delivery_time_start: - raise ValidationError({ - 'delivery_time_end': 'Время окончания не может быть раньше времени начала' - }) - - def get_delivery_cost(self): - """ - Возвращает стоимость доставки: - - Если установлена вручную - использует ручное значение - - Если автоматическая - вычисляет на основе правил - - Returns: - Decimal: Стоимость доставки - """ - if self.is_custom_delivery_cost: - return self.delivery_cost - else: - from orders.services.delivery_cost_calculator import DeliveryCostCalculator - return DeliveryCostCalculator.calculate(self) - - def set_delivery_cost(self, cost, is_custom=True): - """ - Устанавливает стоимость доставки. - - Args: - cost: Новая стоимость доставки (Decimal) - is_custom: True если устанавливается вручную, False если автоматически - """ - self.delivery_cost = cost - self.is_custom_delivery_cost = is_custom - - def reset_delivery_cost(self): - """ - Сбрасывает стоимость доставки на автоматический расчет. - """ - from orders.services.delivery_cost_calculator import DeliveryCostCalculator - self.delivery_cost = DeliveryCostCalculator.calculate(self) - self.is_custom_delivery_cost = False - - def recalculate_delivery_cost(self): - """ - Пересчитывает стоимость доставки, если она не установлена вручную. - Используется при изменении параметров заказа (товаров, адреса и т.д.) - """ - if not self.is_custom_delivery_cost: - from orders.services.delivery_cost_calculator import DeliveryCostCalculator - self.delivery_cost = DeliveryCostCalculator.calculate(self) - - def calculate_total(self): - """Рассчитывает итоговую сумму заказа и сохраняет её в БД""" - items_total = sum(item.get_total_price() for item in self.items.all()) - - # Пересчитываем стоимость доставки если она автоматическая - self.recalculate_delivery_cost() - - self.total_amount = items_total + self.delivery_cost - - # Сохраняем изменения в БД - self.save(update_fields=['total_amount', 'delivery_cost', 'is_custom_delivery_cost']) - - return self.total_amount - def recalculate_amount_paid(self): """ Пересчитывает оплаченную сумму на основе транзакций. @@ -377,34 +232,28 @@ class Order(models.Model): """Сумма только товаров (без доставки)""" return sum(item.get_total_price() for item in self.items.all()) - @property - def delivery_cost_display(self): + def calculate_total(self): """ - Возвращает строку для отображения стоимости доставки с пометкой. - Полезно в админке и шаблонах. + Пересчитывает итоговую сумму заказа. + total_amount = subtotal + delivery_cost """ - cost = self.get_delivery_cost() - suffix = " (ручная)" if self.is_custom_delivery_cost else " (авто)" - return f"{cost} руб.{suffix}" + from decimal import Decimal + + subtotal = self.subtotal + delivery_cost = Decimal('0') + + # Получаем стоимость доставки из связанной модели Delivery + if hasattr(self, 'delivery'): + delivery_cost = self.delivery.cost + + self.total_amount = subtotal + delivery_cost + self.save(update_fields=['total_amount']) - @property - def delivery_info(self): - """Информация о доставке для отображения""" - if self.is_delivery: - if self.delivery_address: - return f"Доставка по адресу: {self.delivery_address.full_address}" - return "Доставка (адрес не указан)" - else: - if self.pickup_warehouse: - return f"Самовывоз со склада: {self.pickup_warehouse.name}" - return "Самовывоз (склад не указан)" - - @property - def delivery_time_window(self): - """Временное окно доставки""" - if self.delivery_time_start and self.delivery_time_end: - # Если времена равны - это точное время доставки - if self.delivery_time_start == self.delivery_time_end: - return f"к {self.delivery_time_start.strftime('%H:%M')}" - return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}" - return "Время не указано" + def reset_delivery_cost(self): + """ + Сбрасывает стоимость доставки. + Если есть Delivery, устанавливает cost = 0. + """ + if hasattr(self, 'delivery'): + self.delivery.cost = 0 + self.delivery.save(update_fields=['cost']) diff --git a/myproject/orders/services/delivery_cost_calculator.py b/myproject/orders/services/delivery_cost_calculator.py deleted file mode 100644 index a263643..0000000 --- a/myproject/orders/services/delivery_cost_calculator.py +++ /dev/null @@ -1,95 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Сервис для расчета стоимости доставки. -Содержит расширяемую логику вычисления на основе различных условий. -""" -from decimal import Decimal -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from orders.models import Order - - -class DeliveryCostCalculator: - """ - Калькулятор стоимости доставки. - Применяет различные правила для автоматического расчета. - """ - - # Константы для правил расчета - FREE_DELIVERY_THRESHOLD = Decimal('100.00') # Бесплатная доставка от суммы - BASE_DELIVERY_COST = Decimal('15.00') # Базовая стоимость доставки - MIN_DELIVERY_COST = Decimal('0.00') # Минимальная стоимость - - @classmethod - def calculate(cls, order: 'Order') -> Decimal: - """ - Рассчитывает стоимость доставки на основе условий заказа. - - Args: - order: Заказ для расчета - - Returns: - Decimal: Рассчитанная стоимость доставки - """ - # Самовывоз - доставка бесплатная - if not order.is_delivery: - return cls.MIN_DELIVERY_COST - - # Рассчитываем сумму товаров - items_total = sum( - item.get_total_price() - for item in order.items.all() - ) - - # Применяем правила расчета - cost = cls._apply_calculation_rules(order, items_total) - - return cost - - @classmethod - def _apply_calculation_rules(cls, order: 'Order', items_total: Decimal) -> Decimal: - """ - Применяет правила расчета стоимости доставки. - Этот метод легко расширить для добавления новых правил. - - Args: - order: Заказ - items_total: Сумма товаров в заказе - - Returns: - Decimal: Стоимость доставки - """ - # Правило 1: Бесплатная доставка при заказе от определенной суммы - if items_total >= cls.FREE_DELIVERY_THRESHOLD: - return cls.MIN_DELIVERY_COST - - # Правило 2: Базовая стоимость доставки - cost = cls.BASE_DELIVERY_COST - - # Правило 3: Можно добавить расчет по адресу - # if order.delivery_address: - # cost += cls._calculate_distance_cost(order.delivery_address) - - # Правило 4: Можно добавить надбавку за срочность - # if cls._is_urgent_delivery(order): - # cost *= Decimal('1.5') - - return cost - - @classmethod - def _calculate_distance_cost(cls, address) -> Decimal: - """ - Рассчитывает надбавку за расстояние. - Placeholder для будущей реализации с геокодингом. - """ - # TODO: Интеграция с картами для расчета расстояния - return Decimal('0.00') - - @classmethod - def _is_urgent_delivery(cls, order: 'Order') -> bool: - """ - Проверяет, является ли доставка срочной. - """ - # TODO: Логика определения срочности - return False diff --git a/myproject/orders/views.py b/myproject/orders/views.py index 022c658..e755aa6 100644 --- a/myproject/orders/views.py +++ b/myproject/orders/views.py @@ -8,7 +8,7 @@ from django.contrib.auth.decorators import login_required from django.core.exceptions import ValidationError from django.db import models, transaction from decimal import Decimal -from .models import Order, OrderItem, Address, OrderStatus, Transaction, PaymentMethod +from .models import Order, OrderItem, Address, OrderStatus, Transaction, PaymentMethod, Delivery from .forms import OrderForm, OrderItemFormSet, OrderItemForm, OrderStatusForm, TransactionForm from .filters import OrderFilter from .services.address_service import AddressService @@ -22,7 +22,7 @@ def order_list(request): """ # Базовый queryset с оптимизацией запросов orders = Order.objects.select_related( - 'customer', 'delivery_address', 'pickup_warehouse', 'status' # Добавлен 'status' для избежания N+1 + 'customer', 'delivery', 'delivery__address', 'delivery__pickup_warehouse', 'status' # Добавлен 'status' для избежания N+1 ).all() # Применяем фильтры через django-filter @@ -48,7 +48,7 @@ def order_list(request): def order_detail(request, order_number): """Детальная информация о заказе""" order = get_object_or_404( - Order.objects.select_related('customer', 'delivery_address', 'pickup_warehouse', 'modified_by', 'status') + Order.objects.select_related('customer', 'delivery', 'delivery__address', 'delivery__pickup_warehouse', 'modified_by', 'status') .prefetch_related('items__product', 'items__product_kit', 'transactions__created_by', 'transactions__payment_method'), order_number=order_number ) @@ -108,15 +108,6 @@ def order_create(request): # Если покупатель является получателем order.recipient = None - # Обрабатываем адрес доставки - if order.is_delivery: - address = AddressService.process_address_from_form(order, form.cleaned_data) - if address: - # Если адрес не существует в БД, сохраняем его - if not address.pk: - address.save() - order.delivery_address = address - # Статус берём из формы (в том числе может быть "Черновик") order.modified_by = request.user @@ -127,10 +118,53 @@ def order_create(request): formset.instance = order formset.save() - # Пересчитываем стоимость доставки если она не установлена вручную - delivery_cost = form.cleaned_data.get('delivery_cost') - if not delivery_cost or delivery_cost <= 0: - order.reset_delivery_cost() + # Проверяем, является ли заказ черновиком + is_draft = order.status and order.status.code == 'draft' + + # Создаем Delivery (обязательно, кроме черновиков) + if not is_draft: + # Получаем данные из формы (уже провалидированы) + delivery_type = form.cleaned_data.get('delivery_type') + delivery_date = form.cleaned_data.get('delivery_date') + time_from = form.cleaned_data.get('time_from') + time_to = form.cleaned_data.get('time_to') + delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0')) + pickup_warehouse = form.cleaned_data.get('pickup_warehouse') + + # Проверяем наличие обязательных полей + if not all([delivery_type, delivery_date, time_from, time_to]): + raise ValidationError('Необходимо заполнить все поля доставки') + + # Обрабатываем адрес для курьерской доставки + address = None + + if delivery_type == Delivery.DELIVERY_TYPE_COURIER: + # Для курьерской доставки нужен адрес + address = AddressService.process_address_from_form(order, form.cleaned_data) + if not address: + raise ValidationError('Для курьерской доставки необходимо указать адрес') + if not address.pk: + address.save() + elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP: + # Для самовывоза нужен склад + if not pickup_warehouse: + raise ValidationError('Для самовывоза необходимо выбрать склад') + + # Создаем Delivery + delivery = Delivery.objects.create( + order=order, + delivery_type=delivery_type, + delivery_date=delivery_date, + time_from=time_from, + time_to=time_to, + address=address, + pickup_warehouse=pickup_warehouse, + cost=delivery_cost if delivery_cost else Decimal('0') + ) + + # Пересчитываем стоимость доставки если она не установлена вручную + if not delivery.cost or delivery.cost <= 0: + order.reset_delivery_cost() # Пересчитываем итоговую стоимость order.calculate_total() @@ -233,25 +267,59 @@ def order_update(request, order_number): # Если покупатель является получателем order.recipient = None - # Обрабатываем адрес доставки - if order.is_delivery: - address = AddressService.process_address_from_form(order, form.cleaned_data) - if address: - # Если адрес не существует в БД, сохраняем его - if not address.pk: - address.save() - order.delivery_address = address - else: - # Если режим "без адреса", очищаем адрес - order.delivery_address = None - else: - # Если не доставка, очищаем адрес - order.delivery_address = None - order.modified_by = request.user order.save() formset.save() + # Проверяем, является ли заказ черновиком + is_draft = order.status and order.status.code == 'draft' + + # Создаем или обновляем Delivery (обязательно, кроме черновиков) + if not is_draft: + # Получаем данные из формы (уже провалидированы) + delivery_type = form.cleaned_data.get('delivery_type') + delivery_date = form.cleaned_data.get('delivery_date') + time_from = form.cleaned_data.get('time_from') + time_to = form.cleaned_data.get('time_to') + delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0')) + pickup_warehouse = form.cleaned_data.get('pickup_warehouse') + + # Проверяем наличие обязательных полей + if not all([delivery_type, delivery_date, time_from, time_to]): + raise ValidationError('Необходимо заполнить все поля доставки') + + # Обрабатываем адрес для курьерской доставки + address = None + + if delivery_type == Delivery.DELIVERY_TYPE_COURIER: + # Для курьерской доставки нужен адрес + address = AddressService.process_address_from_form(order, form.cleaned_data) + if not address: + raise ValidationError('Для курьерской доставки необходимо указать адрес') + if not address.pk: + address.save() + elif delivery_type == Delivery.DELIVERY_TYPE_PICKUP: + # Для самовывоза нужен склад + if not pickup_warehouse: + raise ValidationError('Для самовывоза необходимо выбрать склад') + + # Создаем или обновляем Delivery + delivery, created = Delivery.objects.update_or_create( + order=order, + defaults={ + 'delivery_type': delivery_type, + 'delivery_date': delivery_date, + 'time_from': time_from, + 'time_to': time_to, + 'address': address, + 'pickup_warehouse': pickup_warehouse, + 'cost': delivery_cost if delivery_cost else Decimal('0') + } + ) + elif hasattr(order, 'delivery'): + # Если заказ стал черновиком, удаляем Delivery + order.delivery.delete() + # Пересчитываем итоговую стоимость order.calculate_total() order.update_payment_status() diff --git a/myproject/products/migrations/0001_initial.py b/myproject/products/migrations/0001_initial.py index 7a50306..363760a 100644 --- a/myproject/products/migrations/0001_initial.py +++ b/myproject/products/migrations/0001_initial.py @@ -1,6 +1,7 @@ -# Generated by Django 5.0.10 on 2025-11-15 11:57 +# Generated by Django 5.0.10 on 2025-12-23 20:38 import django.db.models.deletion +import products.models.photos from django.conf import settings from django.db import migrations, models @@ -10,6 +11,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('inventory', '0001_initial'), ('orders', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -65,6 +67,67 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Счетчики артикулов', }, ), + migrations.CreateModel( + name='ConfigurableKitProduct', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название')), + ('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')), + ('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')), + ('description', models.TextField(blank=True, null=True, verbose_name='Описание')), + ('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')), + ('status', models.CharField(choices=[('active', 'Активный'), ('archived', 'Архивный'), ('discontinued', 'Снят')], db_index=True, default='active', max_length=20, verbose_name='Статус')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')), + ('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')), + ], + options={ + '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', + 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='Родительский товар')), + ], + options={ + 'verbose_name': 'Атрибут вариативного товара', + 'verbose_name_plural': 'Атрибуты вариативных товаров', + 'ordering': ['parent', 'position', 'name', 'option'], + }, + ), + migrations.CreateModel( + name='ConfigurableKitOptionAttribute', + 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='Значение атрибута')), + ], + options={ + 'verbose_name': 'Атрибут варианта', + 'verbose_name_plural': 'Атрибуты варианта', + }, + ), migrations.CreateModel( name='PhotoProcessingStatus', fields=[ @@ -102,7 +165,7 @@ class Migration(migrations.Migration): ('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')), ('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')), ('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')), - ('cost_price', models.DecimalField(decimal_places=2, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, verbose_name='Себестоимость')), + ('cost_price', models.DecimalField(blank=True, decimal_places=2, default=0, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, null=True, verbose_name='Себестоимость')), ('price', models.DecimalField(decimal_places=2, help_text='Цена продажи товара (бывшее поле sale_price)', max_digits=10, verbose_name='Основная цена')), ('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, товар продается по этой цене (дешевле основной)', max_digits=10, null=True, verbose_name='Цена со скидкой')), ('in_stock', models.BooleanField(db_index=True, default=False, help_text='Автоматически обновляется при изменении остатков на складе', verbose_name='В наличии')), @@ -133,6 +196,25 @@ class Migration(migrations.Migration): name='product', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kit_items_direct', to='products.product', verbose_name='Конкретный товар'), ), + migrations.CreateModel( + name='CostPriceHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('old_cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Старая себестоимость')), + ('new_cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Новая себестоимость')), + ('reason', models.CharField(choices=[('incoming', 'Поступление товара'), ('batch_edit', 'Редактирование партии'), ('batch_delete', 'Удаление партии'), ('recalculation', 'Пересчет себестоимости'), ('system', 'Системная корректировка')], max_length=20, verbose_name='Причина изменения')), + ('related_object_id', models.IntegerField(blank=True, help_text='Например, ID партии (StockBatch) для поступлений', null=True, verbose_name='ID связанного объекта')), + ('related_object_type', models.CharField(blank=True, help_text="Например, 'StockBatch' для партий", max_length=50, verbose_name='Тип связанного объекта')), + ('notes', models.TextField(blank=True, help_text='Дополнительная информация об изменении', verbose_name='Примечания')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время изменения')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cost_price_history', to='products.product', verbose_name='Товар')), + ], + options={ + 'verbose_name': 'История себестоимости', + 'verbose_name_plural': 'Истории себестоимости', + 'ordering': ['-created_at'], + }, + ), migrations.CreateModel( name='ProductCategory', fields=[ @@ -164,7 +246,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('image', models.ImageField(upload_to='categories/temp/', verbose_name='Оригинальное фото')), + ('image', models.ImageField(upload_to=products.models.photos.get_category_photo_upload_path, verbose_name='Оригинальное фото')), ('quality_level', models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества')), ('quality_warning', models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт', verbose_name='Требует обновления')), ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productcategory', verbose_name='Категория')), @@ -197,6 +279,7 @@ 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='Архивировано пользователем')), ('categories', models.ManyToManyField(blank=True, related_name='kits', to='products.productcategory', verbose_name='Категории')), ('order', models.ForeignKey(blank=True, help_text='Заказ, для которого создан временный комплект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temporary_kits', to='orders.order', verbose_name='Заказ')), + ('showcase', models.ForeignKey(blank=True, help_text='Витрина, на которой выложен временный комплект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temporary_kits', to='inventory.showcase', verbose_name='Витрина')), ], options={ 'verbose_name': 'Комплект', @@ -208,13 +291,23 @@ class Migration(migrations.Migration): name='kit', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productkit', verbose_name='Комплект'), ), + migrations.AddField( + model_name='configurablekitproductattribute', + 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='Комплект для этого значения'), + ), + migrations.AddField( + model_name='configurablekitoption', + name='kit', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.productkit', verbose_name='Комплект (вариант)'), + ), migrations.CreateModel( name='ProductKitPhoto', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('image', models.ImageField(upload_to='kits/temp/', verbose_name='Оригинальное фото')), + ('image', models.ImageField(upload_to=products.models.photos.get_kit_photo_upload_path, verbose_name='Оригинальное фото')), ('quality_level', models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества')), ('quality_warning', models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт', verbose_name='Требует обновления')), ('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productkit', verbose_name='Комплект')), @@ -231,7 +324,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('image', models.ImageField(upload_to='products/temp/', verbose_name='Оригинальное фото')), + ('image', models.ImageField(upload_to=products.models.photos.get_product_photo_upload_path, verbose_name='Оригинальное фото')), ('quality_level', models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества')), ('quality_warning', models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт (poor или very_poor)', verbose_name='Требует обновления')), ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.product', verbose_name='Товар')), @@ -292,10 +385,30 @@ class Migration(migrations.Migration): name='variant_group', 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'), + ), + migrations.AddIndex( + model_name='configurablekitoptionattribute', + index=models.Index(fields=['attribute'], name='products_co_attribu_ccc6d9_idx'), + ), + migrations.AlterUniqueTogether( + name='configurablekitoptionattribute', + unique_together={('option', 'attribute')}, + ), migrations.AlterUniqueTogether( name='kititempriority', unique_together={('kit_item', 'product')}, ), + migrations.AddIndex( + model_name='costpricehistory', + index=models.Index(fields=['product', '-created_at'], name='products_co_product_3320c9_idx'), + ), + migrations.AddIndex( + model_name='costpricehistory', + index=models.Index(fields=['reason'], name='products_co_reason_959ee1_idx'), + ), migrations.AddIndex( model_name='productcategory', index=models.Index(fields=['is_active'], name='products_pr_is_acti_226e74_idx'), @@ -324,6 +437,38 @@ class Migration(migrations.Migration): model_name='productcategoryphoto', 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'), + ), + migrations.AddIndex( + model_name='configurablekitproductattribute', + index=models.Index(fields=['parent', 'position'], name='products_co_parent__0904e2_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')}, + ), + migrations.AddIndex( + model_name='configurablekitoption', + index=models.Index(fields=['parent'], name='products_co_parent__56ecfa_idx'), + ), + migrations.AddIndex( + model_name='configurablekitoption', + index=models.Index(fields=['kit'], name='products_co_kit_id_3fa7fe_idx'), + ), + migrations.AddIndex( + model_name='configurablekitoption', + index=models.Index(fields=['parent', 'is_default'], name='products_co_parent__ffa4ca_idx'), + ), + migrations.AlterUniqueTogether( + name='configurablekitoption', + unique_together={('parent', 'kit')}, + ), migrations.AddIndex( model_name='productkitphoto', index=models.Index(fields=['quality_level'], name='products_pr_quality_b03c5c_idx'), @@ -356,6 +501,10 @@ class Migration(migrations.Migration): model_name='productkit', index=models.Index(fields=['order'], name='products_pr_order_i_2b5675_idx'), ), + migrations.AddIndex( + model_name='productkit', + index=models.Index(fields=['showcase'], name='products_pr_showcas_08c1ca_idx'), + ), migrations.AddConstraint( model_name='productkit', constraint=models.UniqueConstraint(condition=models.Q(('is_temporary', False), ('status', 'active')), fields=('name',), name='unique_active_kit_name'), diff --git a/myproject/products/migrations/0002_configurablekitproduct_configurablekitoption.py b/myproject/products/migrations/0002_configurablekitproduct_configurablekitoption.py deleted file mode 100644 index a7c904e..0000000 --- a/myproject/products/migrations/0002_configurablekitproduct_configurablekitoption.py +++ /dev/null @@ -1,52 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-17 19:29 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='ConfigurableKitProduct', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=200, verbose_name='Название')), - ('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')), - ('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')), - ('description', models.TextField(blank=True, null=True, verbose_name='Описание')), - ('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')), - ('status', models.CharField(choices=[('active', 'Активный'), ('archived', 'Архивный'), ('discontinued', 'Снят')], db_index=True, default='active', max_length=20, verbose_name='Статус')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')), - ('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')), - ], - options={ - '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.TextField(blank=True, verbose_name='Атрибуты варианта (для внешних площадок)')), - ('is_default', models.BooleanField(default=False, verbose_name='Вариант по умолчанию')), - ('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.productkit', 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': 'Варианты комплектов', - 'indexes': [models.Index(fields=['parent'], name='products_co_parent__56ecfa_idx'), models.Index(fields=['kit'], name='products_co_kit_id_3fa7fe_idx'), models.Index(fields=['parent', 'is_default'], name='products_co_parent__ffa4ca_idx')], - 'unique_together': {('parent', 'kit')}, - }, - ), - ] diff --git a/myproject/products/migrations/0003_alter_configurablekitproduct_options_and_more.py b/myproject/products/migrations/0003_alter_configurablekitproduct_options_and_more.py deleted file mode 100644 index 6455340..0000000 --- a/myproject/products/migrations/0003_alter_configurablekitproduct_options_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-17 19:30 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0002_configurablekitproduct_configurablekitoption'), - ] - - operations = [ - migrations.AlterModelOptions( - name='configurablekitproduct', - options={'verbose_name': 'Вариативный товар (из комплектов)', 'verbose_name_plural': 'Вариативные товары (из комплектов)'}, - ), - migrations.AlterField( - model_name='configurablekitoption', - name='parent', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurablekitproduct', verbose_name='Родитель (вариативный товар)'), - ), - ] diff --git a/myproject/products/migrations/0004_configurablekitproductattribute.py b/myproject/products/migrations/0004_configurablekitproductattribute.py deleted file mode 100644 index 15886f9..0000000 --- a/myproject/products/migrations/0004_configurablekitproductattribute.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-17 21:38 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0003_alter_configurablekitproduct_options_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='ConfigurableKitProductAttribute', - 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='Родительский товар')), - ], - options={ - 'verbose_name': 'Атрибут вариативного товара', - 'verbose_name_plural': 'Атрибуты вариативных товаров', - 'ordering': ['parent', 'position', 'name', 'option'], - 'indexes': [models.Index(fields=['parent', 'name'], name='products_co_parent__4a7869_idx'), models.Index(fields=['parent', 'position'], name='products_co_parent__0904e2_idx')], - 'unique_together': {('parent', 'name', 'option')}, - }, - ), - ] diff --git a/myproject/products/migrations/0005_alter_configurablekitoption_attributes.py b/myproject/products/migrations/0005_alter_configurablekitoption_attributes.py deleted file mode 100644 index 7fb0e3c..0000000 --- a/myproject/products/migrations/0005_alter_configurablekitoption_attributes.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-18 15:49 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0004_configurablekitproductattribute'), - ] - - operations = [ - migrations.AlterField( - model_name='configurablekitoption', - name='attributes', - field=models.JSONField(blank=True, default=dict, help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}', verbose_name='Атрибуты варианта'), - ), - ] diff --git a/myproject/products/migrations/0006_add_configurablekitoptionattribute.py b/myproject/products/migrations/0006_add_configurablekitoptionattribute.py deleted file mode 100644 index 90eec69..0000000 --- a/myproject/products/migrations/0006_add_configurablekitoptionattribute.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-18 16:48 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0005_alter_configurablekitoption_attributes'), - ] - - operations = [ - migrations.CreateModel( - name='ConfigurableKitOptionAttribute', - 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.configurablekitproductattribute', verbose_name='Значение атрибута')), - ('option', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attributes_set', to='products.configurablekitoption', verbose_name='Вариант')), - ], - options={ - 'verbose_name': 'Атрибут варианта', - 'verbose_name_plural': 'Атрибуты варианта', - 'unique_together': {('option', 'attribute')}, - 'indexes': [models.Index(fields=['option'], name='products_co_option__93b9f7_idx'), models.Index(fields=['attribute'], name='products_co_attribu_ccc6d9_idx')], - }, - ), - ] diff --git a/myproject/products/migrations/0007_add_kit_to_attribute.py b/myproject/products/migrations/0007_add_kit_to_attribute.py deleted file mode 100644 index 4e8ce77..0000000 --- a/myproject/products/migrations/0007_add_kit_to_attribute.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-18 18:13 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0006_add_configurablekitoptionattribute'), - ] - - operations = [ - migrations.AlterUniqueTogether( - name='configurablekitproductattribute', - unique_together=set(), - ), - migrations.AddField( - model_name='configurablekitproductattribute', - 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='Комплект для этого значения'), - ), - migrations.AlterUniqueTogether( - name='configurablekitproductattribute', - unique_together={('parent', 'name', 'option', 'kit')}, - ), - migrations.AddIndex( - model_name='configurablekitproductattribute', - index=models.Index(fields=['kit'], name='products_co_kit_id_c5d506_idx'), - ), - ] diff --git a/myproject/products/migrations/0008_productkit_showcase_and_more.py b/myproject/products/migrations/0008_productkit_showcase_and_more.py deleted file mode 100644 index a77d864..0000000 --- a/myproject/products/migrations/0008_productkit_showcase_and_more.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-20 11:24 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0004_showcase_is_default_and_more'), - ('orders', '0003_historicalorderitem_is_from_showcase_and_more'), - ('products', '0007_add_kit_to_attribute'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='productkit', - name='showcase', - field=models.ForeignKey(blank=True, help_text='Витрина, на которой выложен временный комплект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temporary_kits', to='inventory.showcase', verbose_name='Витрина'), - ), - migrations.AddIndex( - model_name='productkit', - index=models.Index(fields=['showcase'], name='products_pr_showcas_08c1ca_idx'), - ), - ] diff --git a/myproject/products/migrations/0009_alter_productcategoryphoto_image_and_more.py b/myproject/products/migrations/0009_alter_productcategoryphoto_image_and_more.py deleted file mode 100644 index 24f5e6a..0000000 --- a/myproject/products/migrations/0009_alter_productcategoryphoto_image_and_more.py +++ /dev/null @@ -1,50 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-23 19:15 - -import django.db.models.deletion -import products.models.photos -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0008_productkit_showcase_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='productcategoryphoto', - name='image', - field=models.ImageField(upload_to=products.models.photos.get_category_photo_upload_path, verbose_name='Оригинальное фото'), - ), - migrations.AlterField( - model_name='productkitphoto', - name='image', - field=models.ImageField(upload_to=products.models.photos.get_kit_photo_upload_path, verbose_name='Оригинальное фото'), - ), - migrations.AlterField( - model_name='productphoto', - name='image', - field=models.ImageField(upload_to=products.models.photos.get_product_photo_upload_path, verbose_name='Оригинальное фото'), - ), - migrations.CreateModel( - name='CostPriceHistory', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('old_cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Старая себестоимость')), - ('new_cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Новая себестоимость')), - ('reason', models.CharField(choices=[('incoming', 'Поступление товара'), ('batch_edit', 'Редактирование партии'), ('batch_delete', 'Удаление партии'), ('recalculation', 'Пересчет себестоимости'), ('system', 'Системная корректировка')], max_length=20, verbose_name='Причина изменения')), - ('related_object_id', models.IntegerField(blank=True, help_text='Например, ID партии (StockBatch) для поступлений', null=True, verbose_name='ID связанного объекта')), - ('related_object_type', models.CharField(blank=True, help_text="Например, 'StockBatch' для партий", max_length=50, verbose_name='Тип связанного объекта')), - ('notes', models.TextField(blank=True, help_text='Дополнительная информация об изменении', verbose_name='Примечания')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время изменения')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cost_price_history', to='products.product', verbose_name='Товар')), - ], - options={ - 'verbose_name': 'История себестоимости', - 'verbose_name_plural': 'Истории себестоимости', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['product', '-created_at'], name='products_co_product_3320c9_idx'), models.Index(fields=['reason'], name='products_co_reason_959ee1_idx')], - }, - ), - ] diff --git a/myproject/products/migrations/0010_alter_product_cost_price.py b/myproject/products/migrations/0010_alter_product_cost_price.py deleted file mode 100644 index 19bb8b3..0000000 --- a/myproject/products/migrations/0010_alter_product_cost_price.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-23 19:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0009_alter_productcategoryphoto_image_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='product', - name='cost_price', - field=models.DecimalField(blank=True, decimal_places=2, default=0, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, null=True, verbose_name='Себестоимость'), - ), - ] diff --git a/myproject/templates/navbar.html b/myproject/templates/navbar.html index 17ba9ca..d01ff94 100644 --- a/myproject/templates/navbar.html +++ b/myproject/templates/navbar.html @@ -17,58 +17,61 @@