diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e4b001b..bb34c6d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(dir /b /s settings.py)", "Bash(git add:*)", "Bash(..venvScriptspython.exe manage.py check)", - "Bash(python:*)" + "Bash(python:*)", + "Bash(dir:*)" ], "deny": [], "ask": [] diff --git a/myproject/accounts/migrations/0001_initial.py b/myproject/accounts/migrations/0001_initial.py index bc09017..41e786a 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-09 22:18 +# Generated by Django 5.0.10 on 2025-11-13 13:12 import django.contrib.auth.validators import django.utils.timezone diff --git a/myproject/customers/migrations/0001_initial.py b/myproject/customers/migrations/0001_initial.py index bd41ede..53e8edb 100644 --- a/myproject/customers/migrations/0001_initial.py +++ b/myproject/customers/migrations/0001_initial.py @@ -1,6 +1,5 @@ -# Generated by Django 5.0.10 on 2025-11-09 22:18 +# Generated by Django 5.0.10 on 2025-11-13 13:12 -import django.db.models.deletion import phonenumber_field.modelfields from django.db import migrations, models @@ -18,7 +17,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(blank=True, max_length=200, verbose_name='Имя')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='Email')), + ('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='Общая сумма покупок')), @@ -33,28 +32,4 @@ class Migration(migrations.Migration): 'indexes': [models.Index(fields=['name'], name='customers_c_name_f018e2_idx'), models.Index(fields=['email'], name='customers_c_email_4fdeb3_idx'), models.Index(fields=['phone'], name='customers_c_phone_8493fa_idx'), models.Index(fields=['created_at'], name='customers_c_created_1ed0f4_idx'), models.Index(fields=['loyalty_tier'], name='customers_c_loyalty_5162a0_idx')], }, ), - migrations.CreateModel( - name='Address', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('recipient_name', models.CharField(help_text='Имя человека, которому будет доставлен заказ', max_length=200, verbose_name='Имя получателя')), - ('recipient_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Контактный телефон получателя для уточнения адреса', max_length=128, null=True, region=None, verbose_name='Телефон получателя')), - ('street', models.CharField(max_length=255, verbose_name='Улица')), - ('building_number', models.CharField(max_length=20, verbose_name='Номер здания')), - ('apartment_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер квартиры/офиса')), - ('district', models.CharField(blank=True, help_text='Район в Минске для удобства доставки', max_length=100, null=True, verbose_name='Район')), - ('delivery_instructions', models.TextField(blank=True, help_text='Дополнительные инструкции для курьера (домофон, подъезд и т.д.)', null=True, verbose_name='Инструкции для доставки')), - ('confirm_address_with_recipient', models.BooleanField(default=False, help_text='Курьер должен уточнить адрес у получателя перед доставкой', verbose_name='Уточнить адрес у получателя')), - ('is_default', models.BooleanField(default=False, help_text='Использовать этот адрес для доставки по умолчанию', verbose_name='Адрес по умолчанию')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='customers.customer', verbose_name='Клиент')), - ], - options={ - 'verbose_name': 'Адрес доставки', - 'verbose_name_plural': 'Адреса доставки', - 'ordering': ['-is_default', '-created_at'], - 'indexes': [models.Index(fields=['customer'], name='customers_a_custome_53b543_idx'), models.Index(fields=['is_default'], name='customers_a_is_defa_631851_idx'), models.Index(fields=['district'], name='customers_a_distric_ac47d5_idx')], - }, - ), ] diff --git a/myproject/customers/migrations/0002_remove_address_model.py b/myproject/customers/migrations/0002_remove_address_model.py deleted file mode 100644 index 0c33e49..0000000 --- a/myproject/customers/migrations/0002_remove_address_model.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-10 23:09 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('customers', '0001_initial'), - ('orders', '0003_remove_address_model'), - ] - - operations = [ - migrations.DeleteModel( - name='Address', - ), - ] diff --git a/myproject/customers/migrations/0003_alter_customer_email.py b/myproject/customers/migrations/0003_alter_customer_email.py deleted file mode 100644 index a91fb02..0000000 --- a/myproject/customers/migrations/0003_alter_customer_email.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-11 14:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('customers', '0002_remove_address_model'), - ] - - operations = [ - migrations.AlterField( - model_name='customer', - name='email', - field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email'), - ), - ] diff --git a/myproject/customers/migrations/0004_alter_customer_email.py b/myproject/customers/migrations/0004_alter_customer_email.py deleted file mode 100644 index 0fc8602..0000000 --- a/myproject/customers/migrations/0004_alter_customer_email.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-11 14:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('customers', '0003_alter_customer_email'), - ] - - operations = [ - migrations.AlterField( - model_name='customer', - name='email', - field=models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='Email'), - ), - ] diff --git a/myproject/inventory/migrations/0001_initial.py b/myproject/inventory/migrations/0001_initial.py index 50406ea..e054955 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-09 22:18 +# Generated by Django 5.0.10 on 2025-11-13 13:12 from django.db import migrations, models diff --git a/myproject/inventory/migrations/0002_initial.py b/myproject/inventory/migrations/0002_initial.py index 07da123..0998eae 100644 --- a/myproject/inventory/migrations/0002_initial.py +++ b/myproject/inventory/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2025-11-09 22:18 +# Generated by Django 5.0.10 on 2025-11-13 13:12 import django.db.models.deletion from django.db import migrations, models diff --git a/myproject/orders/filters.py b/myproject/orders/filters.py index 078e7bb..caf0a6d 100644 --- a/myproject/orders/filters.py +++ b/myproject/orders/filters.py @@ -6,7 +6,7 @@ import django_filters from django import forms from django.db.models import Q -from .models import Order +from .models import Order, OrderStatus class OrderFilter(django_filters.FilterSet): @@ -74,8 +74,8 @@ class OrderFilter(django_filters.FilterSet): ) # Фильтр по статусу - status = django_filters.ChoiceFilter( - choices=Order.STATUS_CHOICES, + status = django_filters.ModelChoiceFilter( + queryset=OrderStatus.objects.all().order_by('order', 'name'), empty_label='Все статусы', label='Статус', widget=forms.Select(attrs={'class': 'form-select'}) diff --git a/myproject/orders/forms.py b/myproject/orders/forms.py index 88e5e63..a7a6a51 100644 --- a/myproject/orders/forms.py +++ b/myproject/orders/forms.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from django import forms from django.forms import inlineformset_factory -from .models import Order, OrderItem, Address +from .models import Order, OrderItem, Address, OrderStatus from customers.models import Customer from shops.models import Shop from products.models import Product, ProductKit @@ -281,6 +281,81 @@ OrderItemFormSet = inlineformset_factory( ) +# === СТАТУСЫ ЗАКАЗОВ === + +class OrderStatusForm(forms.ModelForm): + """Форма для создания и редактирования статусов заказов""" + + class Meta: + model = OrderStatus + fields = [ + 'name', + 'code', + 'label', + 'color', + 'description', + 'is_positive_end', + 'is_negative_end', + ] + widgets = { + 'name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Например: Выполнен, В процессе' + }), + 'code': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Например: completed, in_progress' + }), + 'label': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Метка для отображения (опционально)' + }), + 'color': forms.TextInput(attrs={ + 'class': 'form-control', + 'type': 'color' + }), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Описание статуса (опционально)' + }), + 'is_positive_end': forms.CheckboxInput(attrs={ + 'class': 'form-check-input' + }), + 'is_negative_end': forms.CheckboxInput(attrs={ + 'class': 'form-check-input' + }), + } + + def clean(self): + cleaned_data = super().clean() + + # Нельзя быть одновременно положительным и отрицательным концом + if cleaned_data.get('is_positive_end') and cleaned_data.get('is_negative_end'): + raise forms.ValidationError( + "Статус не может быть одновременно положительным и отрицательным концом" + ) + + # Системные статусы нельзя редактировать код + if self.instance.pk and self.instance.is_system: + original_code = OrderStatus.objects.get(pk=self.instance.pk).code + new_code = cleaned_data.get('code') + if original_code != new_code: + raise forms.ValidationError( + "Нельзя менять код системного статуса" + ) + + return cleaned_data + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Если редактируем системный статус - делаем код readonly + if self.instance.pk and self.instance.is_system: + self.fields['code'].widget.attrs['readonly'] = True + self.fields['code'].help_text = "Код системного статуса нельзя менять" + + # === ВРЕМЕННЫЕ КОМПЛЕКТЫ === class TemporaryKitForm(forms.ModelForm): diff --git a/myproject/orders/management/commands/create_order_statuses.py b/myproject/orders/management/commands/create_order_statuses.py new file mode 100644 index 0000000..478f6b7 --- /dev/null +++ b/myproject/orders/management/commands/create_order_statuses.py @@ -0,0 +1,89 @@ +""" +Management command для создания системных статусов заказов. + +Использование: + python manage.py create_order_statuses + python manage.py create_order_statuses --reset + python manage.py create_order_statuses --schema grach +""" +from django.core.management.base import BaseCommand +from django.db import connection +from django_tenants.utils import get_tenant_model +from orders.services.order_status_service import OrderStatusService +from orders.models import OrderStatus + + +class Command(BaseCommand): + """ + Создает системные статусы заказов для текущего тенанта. + """ + help = 'Создает системные статусы заказов для текущего тенанта' + + def add_arguments(self, parser): + """Добавляем опциональные аргументы""" + parser.add_argument( + '--reset', + action='store_true', + help='Удалить все статусы перед созданием (осторожно!)', + ) + parser.add_argument( + '--schema', + type=str, + help='Имя схемы тенанта (по умолчанию текущий тенант)', + ) + + def handle(self, *args, **options): + """Основной обработчик команды""" + # Если указана схема, переходим на нее + schema_name = options.get('schema') + if schema_name: + Tenant = get_tenant_model() + try: + tenant = Tenant.objects.get(schema_name=schema_name) + connection.set_tenant(tenant) + self.stdout.write( + self.style.SUCCESS(f'Переключился на тенант: {tenant.name}') + ) + except Tenant.DoesNotExist: + self.stdout.write( + self.style.ERROR(f'Тенант со схемой {schema_name} не найден') + ) + return + + if options['reset']: + count = OrderStatus.objects.count() + self.stdout.write( + self.style.WARNING(f'Удаляю {count} статусов...') + ) + OrderStatus.objects.all().delete() + + self.stdout.write('Создаю системные статусы...') + + try: + OrderStatusService.create_default_statuses() + self.stdout.write( + self.style.SUCCESS('Системные статусы успешно созданы') + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f'Ошибка при создании статусов: {str(e)}') + ) + return + + # Выводим информацию о созданных статусах + statuses = OrderStatus.objects.all().order_by('order') + self.stdout.write( + self.style.SUCCESS(f'\nВсего создано {statuses.count()} статусов:') + ) + + for status in statuses: + status_type = 'Системный' if status.is_system else 'Пользовательский' + end_type = '' + if status.is_positive_end: + end_type = ' [Успешный]' + elif status.is_negative_end: + end_type = ' [Отрицательный]' + + self.stdout.write( + f' - {status.name:<20} ({status.code:<15}) - {status_type}{end_type}' + ) diff --git a/myproject/orders/migrations/0001_initial.py b/myproject/orders/migrations/0001_initial.py index bf035e4..913e999 100644 --- a/myproject/orders/migrations/0001_initial.py +++ b/myproject/orders/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2025-11-09 22:18 +# Generated by Django 5.0.10 on 2025-11-13 13:12 import django.db.models.deletion import simple_history.models @@ -17,6 +17,28 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='OrderStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Название статуса')), + ('code', models.SlugField(help_text="Уникальный идентификатор (например: 'completed', 'cancelled')", unique=True, verbose_name='Код статуса')), + ('label', models.CharField(blank=True, max_length=100, verbose_name='Метка для отображения')), + ('is_system', models.BooleanField(default=False, help_text='True для встроенных статусов (draft, completed, cancelled)', verbose_name='Системный статус')), + ('is_positive_end', models.BooleanField(default=False, help_text='True если это финальный успешный статус (Выполнен)', verbose_name='Положительный конец')), + ('is_negative_end', models.BooleanField(default=False, help_text='True если это финальный отрицательный статус (Отменен)', verbose_name='Отрицательный конец')), + ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')), + ('color', models.CharField(blank=True, default='#808080', help_text='Например: #FF5733', max_length=7, verbose_name='Цвет (hex)')), + ('description', models.TextField(blank=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': ['order', 'name'], + }, + ), migrations.CreateModel( name='Payment', fields=[ @@ -32,6 +54,30 @@ class Migration(migrations.Migration): 'ordering': ['-payment_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='Номер квартиры/офиса')), + ('entrance', models.CharField(blank=True, help_text='Номер подъезда/входа', max_length=20, null=True, verbose_name='Подъезд')), + ('floor', models.CharField(blank=True, max_length=20, null=True, verbose_name='Этаж')), + ('intercom_code', models.CharField(blank=True, help_text='Код домофона для входа в здание', max_length=100, null=True, verbose_name='Код домофона')), + ('delivery_instructions', models.TextField(blank=True, help_text='Дополнительные инструкции для курьера', null=True, verbose_name='Инструкции для доставки')), + ('confirm_address_with_recipient', models.BooleanField(default=False, help_text='Курьер должен уточнить адрес у получателя перед доставкой', verbose_name='Уточнить адрес у получателя')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ], + options={ + 'verbose_name': 'Адрес доставки', + 'verbose_name_plural': 'Адреса доставки', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['created_at'], name='orders_addr_created_98ad97_idx')], + }, + ), migrations.CreateModel( name='HistoricalOrder', fields=[ @@ -42,7 +88,8 @@ class Migration(migrations.Migration): ('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='Стоимость доставки')), - ('status', models.CharField(choices=[('draft', 'Черновик'), ('new', 'Новый'), ('confirmed', 'Подтвержден'), ('in_assembly', 'В сборке'), ('in_delivery', 'В доставке'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='new', max_length=20, 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='Оплачен')), @@ -62,7 +109,7 @@ 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='customers.address', 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_shop', models.ForeignKey(blank=True, db_constraint=False, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='shops.shop', verbose_name='Точка самовывоза')), @@ -85,7 +132,8 @@ class Migration(migrations.Migration): ('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='Стоимость доставки')), - ('status', models.CharField(choices=[('draft', 'Черновик'), ('new', 'Новый'), ('confirmed', 'Подтвержден'), ('in_assembly', 'В сборке'), ('in_delivery', 'В доставке'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='new', max_length=20, 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='Оплачен')), @@ -101,7 +149,7 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент')), - ('delivery_address', models.ForeignKey(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.address', verbose_name='Адрес доставки')), + ('delivery_address', models.OneToOneField(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='orders.address', verbose_name='Адрес доставки')), ('modified_by', 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='Изменен пользователем')), ('pickup_shop', models.ForeignKey(blank=True, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pickup_orders', to='shops.shop', verbose_name='Точка самовывоза')), ], diff --git a/myproject/orders/migrations/0002_initial.py b/myproject/orders/migrations/0002_initial.py index 36ab455..bb42ea6 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-09 22:18 +# Generated by Django 5.0.10 on 2025-11-13 13:12 import django.db.models.deletion from django.conf import settings @@ -26,6 +26,26 @@ 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='orderstatus', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_order_statuses', to=settings.AUTH_USER_MODEL, verbose_name='Создано'), + ), + migrations.AddField( + model_name='orderstatus', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_order_statuses', to=settings.AUTH_USER_MODEL, verbose_name='Последнее изменение'), + ), + migrations.AddField( + model_name='order', + name='status', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='orders.orderstatus', verbose_name='Статус заказа'), + ), + migrations.AddField( + model_name='historicalorder', + name='status', + 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', name='created_by', @@ -36,13 +56,37 @@ class Migration(migrations.Migration): name='order', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='orders.order', verbose_name='Заказ'), ), + migrations.AddIndex( + model_name='orderitem', + index=models.Index(fields=['order'], name='orders_orde_order_i_5d347b_idx'), + ), + migrations.AddIndex( + model_name='orderitem', + index=models.Index(fields=['product'], name='orders_orde_product_32ff41_idx'), + ), + migrations.AddIndex( + model_name='orderitem', + index=models.Index(fields=['product_kit'], name='orders_orde_product_925b51_idx'), + ), + migrations.AddIndex( + model_name='orderstatus', + index=models.Index(fields=['code'], name='orders_orde_code_5e1ef7_idx'), + ), + migrations.AddIndex( + model_name='orderstatus', + index=models.Index(fields=['is_system'], name='orders_orde_is_syst_2f5b85_idx'), + ), + migrations.AddIndex( + model_name='orderstatus', + index=models.Index(fields=['order'], name='orders_orde_order_2e2930_idx'), + ), migrations.AddIndex( model_name='order', index=models.Index(fields=['customer'], name='orders_orde_custome_59b6fb_idx'), ), migrations.AddIndex( model_name='order', - index=models.Index(fields=['status'], name='orders_orde_status_c6dd84_idx'), + index=models.Index(fields=['status'], name='orders_orde_status__eb4f00_idx'), ), migrations.AddIndex( model_name='order', @@ -65,16 +109,8 @@ class Migration(migrations.Migration): index=models.Index(fields=['order_number'], name='orders_orde_order_n_f3ada5_idx'), ), migrations.AddIndex( - model_name='orderitem', - index=models.Index(fields=['order'], name='orders_orde_order_i_5d347b_idx'), - ), - migrations.AddIndex( - model_name='orderitem', - index=models.Index(fields=['product'], name='orders_orde_product_32ff41_idx'), - ), - migrations.AddIndex( - model_name='orderitem', - index=models.Index(fields=['product_kit'], name='orders_orde_product_925b51_idx'), + model_name='order', + index=models.Index(fields=['is_custom_delivery_cost'], name='orders_orde_is_cust_108e98_idx'), ), migrations.AddIndex( model_name='payment', diff --git a/myproject/orders/migrations/0003_remove_address_model.py b/myproject/orders/migrations/0003_remove_address_model.py deleted file mode 100644 index d65a0b8..0000000 --- a/myproject/orders/migrations/0003_remove_address_model.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-10 23:09 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('orders', '0002_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Address', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('recipient_name', models.CharField(help_text='Имя человека, которому будет доставлен заказ', max_length=200, verbose_name='Имя получателя')), - ('recipient_phone', models.CharField(blank=True, help_text='Контактный телефон получателя для уточнения адреса', max_length=20, null=True, verbose_name='Телефон получателя')), - ('street', models.CharField(max_length=255, verbose_name='Улица')), - ('building_number', models.CharField(max_length=20, verbose_name='Номер здания')), - ('apartment_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер квартиры/офиса')), - ('district', models.CharField(blank=True, help_text='Район в Минске для удобства доставки', max_length=100, null=True, verbose_name='Район')), - ('delivery_instructions', models.TextField(blank=True, help_text='Дополнительные инструкции для курьера (домофон, подъезд и т.д.)', null=True, verbose_name='Инструкции для доставки')), - ('confirm_address_with_recipient', models.BooleanField(default=False, help_text='Курьер должен уточнить адрес у получателя перед доставкой', verbose_name='Уточнить адрес у получателя')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ], - options={ - 'verbose_name': 'Адрес доставки', - 'verbose_name_plural': 'Адреса доставки', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['district'], name='orders_addr_distric_fd94e9_idx')], - }, - ), - migrations.AlterField( - model_name='historicalorder', - name='delivery_address', - field=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='Адрес доставки'), - ), - migrations.AlterField( - 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='Адрес доставки'), - ), - ] diff --git a/myproject/orders/migrations/0004_remove_address_orders_addr_distric_fd94e9_idx_and_more.py b/myproject/orders/migrations/0004_remove_address_orders_addr_distric_fd94e9_idx_and_more.py deleted file mode 100644 index eb58a27..0000000 --- a/myproject/orders/migrations/0004_remove_address_orders_addr_distric_fd94e9_idx_and_more.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-10 23:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('orders', '0003_remove_address_model'), - ] - - operations = [ - migrations.RemoveIndex( - model_name='address', - name='orders_addr_distric_fd94e9_idx', - ), - migrations.RemoveField( - model_name='address', - name='district', - ), - migrations.AddField( - model_name='address', - name='entrance', - field=models.CharField(blank=True, help_text='Номер подъезда/входа', max_length=20, null=True, verbose_name='Подъезд'), - ), - migrations.AddField( - model_name='address', - name='floor', - field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Этаж'), - ), - migrations.AddField( - model_name='address', - name='intercom_code', - field=models.CharField(blank=True, help_text='Код домофона для входа в здание', max_length=100, null=True, verbose_name='Код домофона'), - ), - migrations.AlterField( - model_name='address', - name='delivery_instructions', - field=models.TextField(blank=True, help_text='Дополнительные инструкции для курьера', null=True, verbose_name='Инструкции для доставки'), - ), - migrations.AddIndex( - model_name='address', - index=models.Index(fields=['created_at'], name='orders_addr_created_98ad97_idx'), - ), - ] diff --git a/myproject/orders/migrations/0005_add_custom_delivery_cost_flag.py b/myproject/orders/migrations/0005_add_custom_delivery_cost_flag.py deleted file mode 100644 index 45bea62..0000000 --- a/myproject/orders/migrations/0005_add_custom_delivery_cost_flag.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 5.0.10 on 2025-11-11 09:52 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('customers', '0002_remove_address_model'), - ('orders', '0004_remove_address_orders_addr_distric_fd94e9_idx_and_more'), - ('shops', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='historicalorder', - name='is_custom_delivery_cost', - field=models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную'), - ), - migrations.AddField( - model_name='order', - name='is_custom_delivery_cost', - field=models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную'), - ), - migrations.AlterField( - model_name='address', - name='building_number', - field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер здания'), - ), - migrations.AlterField( - model_name='address', - name='recipient_name', - field=models.CharField(blank=True, help_text='Имя человека, которому будет доставлен заказ', max_length=200, null=True, verbose_name='Имя получателя'), - ), - migrations.AlterField( - model_name='address', - name='street', - field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Улица'), - ), - migrations.AddIndex( - model_name='order', - index=models.Index(fields=['is_custom_delivery_cost'], name='orders_orde_is_cust_108e98_idx'), - ), - ] diff --git a/myproject/orders/models.py b/myproject/orders/models.py index 6e4fa5e..f6912ae 100644 --- a/myproject/orders/models.py +++ b/myproject/orders/models.py @@ -7,6 +7,99 @@ from shops.models import Shop from simple_history.models import HistoricalRecords +class OrderStatus(models.Model): + """ + Статус заказа, управляется отдельно для каждого тенанта. + Благодаря django-tenants в TENANT_APPS, данные изолированы по схемам. + """ + name = models.CharField( + max_length=100, + verbose_name="Название статуса" + ) + + code = models.SlugField( + unique=True, + verbose_name="Код статуса", + help_text="Уникальный идентификатор (например: 'completed', 'cancelled')" + ) + + label = models.CharField( + max_length=100, + verbose_name="Метка для отображения", + blank=True + ) + + is_system = models.BooleanField( + default=False, + verbose_name="Системный статус", + help_text="True для встроенных статусов (draft, completed, cancelled)" + ) + + is_positive_end = models.BooleanField( + default=False, + verbose_name="Положительный конец", + help_text="True если это финальный успешный статус (Выполнен)" + ) + + is_negative_end = models.BooleanField( + default=False, + verbose_name="Отрицательный конец", + help_text="True если это финальный отрицательный статус (Отменен)" + ) + + order = models.PositiveIntegerField( + default=0, + verbose_name="Порядок отображения" + ) + + color = models.CharField( + max_length=7, + blank=True, + default='#808080', + verbose_name="Цвет (hex)", + help_text="Например: #FF5733" + ) + + description = models.TextField( + blank=True, + verbose_name="Описание" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + created_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='created_order_statuses', + verbose_name="Создано" + ) + + updated_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='updated_order_statuses', + verbose_name="Последнее изменение" + ) + + class Meta: + verbose_name = "Статус заказа" + verbose_name_plural = "Статусы заказов" + ordering = ['order', 'name'] + indexes = [ + models.Index(fields=['code']), + models.Index(fields=['is_system']), + models.Index(fields=['order']), + ] + + def __str__(self): + return self.name + + class Address(models.Model): """ Модель адреса доставки для заказа цветочного магазина в Минске. @@ -233,23 +326,22 @@ class Order(models.Model): ) # Статус заказа - STATUS_CHOICES = [ - ('draft', 'Черновик'), - ('new', 'Новый'), - ('confirmed', 'Подтвержден'), - ('in_assembly', 'В сборке'), - ('in_delivery', 'В доставке'), - ('delivered', 'Доставлен'), - ('cancelled', 'Отменен'), - ] - - status = models.CharField( - max_length=20, - choices=STATUS_CHOICES, - default='new', + status = models.ForeignKey( + 'OrderStatus', + on_delete=models.PROTECT, + related_name='orders', + null=True, + blank=True, verbose_name="Статус заказа" ) + # Флаг для отслеживания возвратов + is_returned = models.BooleanField( + default=False, + verbose_name="Возвращен", + help_text="True если заказ был выполнен, но потом отменен или возвращен клиентом" + ) + # Автосохранение (для черновиков) last_autosave_at = models.DateTimeField( null=True, @@ -496,7 +588,7 @@ class Order(models.Model): def is_draft(self): """Проверяет, является ли заказ черновиком""" - return self.status == 'draft' + return self.status and self.status.code == 'draft' @property def amount_due(self): diff --git a/myproject/orders/services/order_status_service.py b/myproject/orders/services/order_status_service.py new file mode 100644 index 0000000..8ddcd87 --- /dev/null +++ b/myproject/orders/services/order_status_service.py @@ -0,0 +1,220 @@ +""" +Сервис для управления статусами заказов. +Содержит бизнес-логику для работы со статусами и их переходами. +""" +from django.db import transaction +from orders.models import OrderStatus, Order + + +class OrderStatusService: + """Сервис для работы со статусами заказов""" + + @staticmethod + def get_default_status(): + """ + Возвращает статус по умолчанию для новых заказов ('new') + """ + try: + return OrderStatus.objects.get(code='new', is_system=True) + except OrderStatus.DoesNotExist: + return None + + @staticmethod + def get_draft_status(): + """Возвращает системный статус 'draft' (черновик)""" + try: + return OrderStatus.objects.get(code='draft', is_system=True) + except OrderStatus.DoesNotExist: + return None + + @staticmethod + def get_system_status(code): + """Получить системный статус по коду""" + try: + return OrderStatus.objects.get(code=code, is_system=True) + except OrderStatus.DoesNotExist: + return None + + @staticmethod + def create_default_statuses(): + """ + Создает системные статусы для тенанта. + Вызывается при первом использовании или миграции. + """ + default_statuses = [ + { + 'code': 'draft', + 'name': 'Черновик', + 'label': 'Черновик', + 'is_system': True, + 'order': 0, + 'color': '#9E9E9E', + 'description': 'Заказ находится в процессе создания/редактирования' + }, + { + 'code': 'new', + 'name': 'Новый', + 'label': 'Новый', + 'is_system': True, + 'order': 10, + 'color': '#2196F3', + 'description': 'Новый заказ, ожидающий обработки' + }, + { + 'code': 'confirmed', + 'name': 'Подтвержден', + 'label': 'Подтвержден', + 'is_system': True, + 'order': 20, + 'color': '#FF9800', + 'description': 'Заказ подтвержден и одобрен' + }, + { + 'code': 'in_assembly', + 'name': 'В сборке', + 'label': 'В сборке', + 'is_system': True, + 'order': 30, + 'color': '#FF9800', + 'description': 'Заказ находится в процессе сборки/подготовки' + }, + { + 'code': 'in_delivery', + 'name': 'В доставке', + 'label': 'В доставке', + 'is_system': True, + 'order': 40, + 'color': '#9C27B0', + 'description': 'Заказ в пути к клиенту' + }, + { + 'code': 'completed', + 'name': 'Выполнен', + 'label': 'Выполнен', + 'is_system': True, + 'is_positive_end': True, + 'order': 50, + 'color': '#4CAF50', + 'description': 'Заказ успешно доставлен/выполнен' + }, + { + 'code': 'return', + 'name': 'Возврат', + 'label': 'Возврат', + 'is_system': True, + 'order': 60, + 'color': '#FF5722', + 'description': 'Заказ возвращен клиентом' + }, + { + 'code': 'cancelled', + 'name': 'Отменен', + 'label': 'Отменен', + 'is_system': True, + 'is_negative_end': True, + 'order': 70, + 'color': '#F44336', + 'description': 'Заказ отменен' + }, + ] + + for status_data in default_statuses: + OrderStatus.objects.get_or_create( + code=status_data['code'], + defaults=status_data + ) + + @staticmethod + @transaction.atomic + def change_order_status(order, new_status, user, notes=""): + """ + Меняет статус заказа и выполняет соответствующую бизнес-логику. + + Args: + order (Order): Экземпляр заказа + new_status (OrderStatus): Новый статус + user (CustomUser): Пользователь, делающий изменение + notes (str): Опциональные заметки + + Returns: + Order: Обновленный экземпляр заказа + + Raises: + ValueError: Если статус не может быть применен + """ + old_status = order.status + order.status = new_status + order.modified_by = user + order.save() + + # Запустить бизнес-логику в зависимости от нового статуса + if new_status.code == 'completed': + _handle_order_completion(order, user) + + elif new_status.code == 'cancelled': + _handle_order_cancellation(order, old_status, user) + + elif new_status.code == 'return': + _handle_order_return(order, user) + + return order + + @staticmethod + def get_all_statuses(): + """Возвращает все статусы, отсортированные по порядку""" + return OrderStatus.objects.all().order_by('order', 'name') + + @staticmethod + def get_system_statuses(): + """Возвращает только системные статусы""" + return OrderStatus.objects.filter(is_system=True).order_by('order') + + @staticmethod + def get_custom_statuses(): + """Возвращает только пользовательские статусы""" + return OrderStatus.objects.filter(is_system=False).order_by('order', 'name') + + +def _handle_order_completion(order, user): + """ + Обработка при переводе в статус 'Выполнен'. + Здесь происходит списание товаров со склада. + + TODO: Интеграция с inventory приложением + """ + # from inventory.services import InventoryService + # InventoryService.process_order_completion(order) + pass + + +def _handle_order_cancellation(order, old_status, user): + """ + Обработка при переводе в статус 'Отменен'. + Если заказ был выполнен - возвращаем товары и деньги. + """ + if old_status and old_status.code == 'completed': + # Заказ был выполнен - нужно вернуть товары и деньги + order.is_returned = True + order.save() + + # TODO: Интеграция с inventory - возврат товаров + # InventoryService.process_order_return(order) + + # TODO: Интеграция с платежами - создать возврат + # PaymentService.create_refund(order) + else: + # Заказ отменен до выполнения - просто отменить резервы + # TODO: InventoryService.cancel_order_reservation(order) + pass + + +def _handle_order_return(order, user): + """ + Обработка при переводе в статус 'Возврат'. + Это промежуточный статус перед окончательной отменой. + """ + order.is_returned = True + order.save() + + # TODO: Интеграция с inventory - вернуть товары на склад + # InventoryService.process_order_return(order) diff --git a/myproject/orders/templates/orders/status_confirm_delete.html b/myproject/orders/templates/orders/status_confirm_delete.html new file mode 100644 index 0000000..1064e4a --- /dev/null +++ b/myproject/orders/templates/orders/status_confirm_delete.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Удалить статус{% endblock %} + +{% block content %} +
Вы собираетесь удалить статус:
+
+ Код: {{ object.code }}
+
+ Описание:
+ {{ object.description }}
+
{{ object.code }}{{ error }}
+ {% endfor %} ++ {{ form.instance.name|default:"Название" }} +
+{{ form.instance.code|default:"код" }}
+ + {% if form.instance.is_system %} + Системный + {% else %} + Пользовательский + {% endif %} +
++ {% if form.instance.is_positive_end %} + ✓ Успешный конец + {% elif form.instance.is_negative_end %} + ✗ Отрицательный конец + {% else %} + Промежуточный + {% endif %} +
+Управление статусами для заказов вашего магазина
+| № | +Название | +Код | +Тип | +Конец | +Цвет | +Заказов | +Действия | +
|---|---|---|---|---|---|---|---|
| + {{ status.order }} + | +
+ {{ status.name }}
+ {% if status.description %}
+ + {{ status.description|truncatewords:10 }} + {% endif %} + |
+
+ {{ status.code }}
+ |
+ + {% if status.is_system %} + Системный + {% else %} + Пользовательский + {% endif %} + | ++ {% if status.is_positive_end %} + ✓ Успешный + {% elif status.is_negative_end %} + ✗ Отрицательный + {% else %} + — + {% endif %} + | ++ + | ++ {{ status.orders_count }} + | ++ + | +
| + Статусы не найдены + | +|||||||