diff --git a/myproject/customers/admin.py b/myproject/customers/admin.py index ad0e0c5..5a2c56c 100644 --- a/myproject/customers/admin.py +++ b/myproject/customers/admin.py @@ -78,13 +78,16 @@ class AddressAdmin(admin.ModelAdmin): """Административный интерфейс для управления адресами доставки""" list_display = ( 'recipient_name', + 'recipient_phone', 'full_address', 'customer', 'district', + 'confirm_address_with_recipient', 'is_default' ) list_filter = ( 'is_default', + 'confirm_address_with_recipient', 'district', 'created_at' ) @@ -100,13 +103,13 @@ class AddressAdmin(admin.ModelAdmin): fieldsets = ( ('Информация о получателе', { - 'fields': ('customer', 'recipient_name') + 'fields': ('customer', 'recipient_name', 'recipient_phone') }), ('Адрес доставки', { 'fields': ('street', 'building_number', 'apartment_number', 'district') }), ('Дополнительная информация', { - 'fields': ('delivery_instructions',), + 'fields': ('delivery_instructions', 'confirm_address_with_recipient'), 'classes': ('collapse',) }), ('Статус', { diff --git a/myproject/customers/migrations/0002_address_confirm_address_with_recipient_and_more.py b/myproject/customers/migrations/0002_address_confirm_address_with_recipient_and_more.py new file mode 100644 index 0000000..1dde004 --- /dev/null +++ b/myproject/customers/migrations/0002_address_confirm_address_with_recipient_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.10 on 2025-11-06 20:54 + +import phonenumber_field.modelfields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='address', + name='confirm_address_with_recipient', + field=models.BooleanField(default=False, help_text='Курьер должен уточнить адрес у получателя перед доставкой', verbose_name='Уточнить адрес у получателя'), + ), + migrations.AddField( + model_name='address', + name='recipient_phone', + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Контактный телефон получателя для уточнения адреса', max_length=128, null=True, region=None, verbose_name='Телефон получателя'), + ), + ] diff --git a/myproject/customers/models.py b/myproject/customers/models.py index 859c445..04d77a6 100644 --- a/myproject/customers/models.py +++ b/myproject/customers/models.py @@ -213,7 +213,14 @@ class Address(models.Model): verbose_name="Имя получателя", help_text="Имя человека, которому будет доставлен заказ" ) - + + recipient_phone = PhoneNumberField( + blank=True, + null=True, + verbose_name="Телефон получателя", + help_text="Контактный телефон получателя для уточнения адреса" + ) + street = models.CharField( max_length=255, verbose_name="Улица" @@ -246,7 +253,13 @@ class Address(models.Model): verbose_name="Инструкции для доставки", help_text="Дополнительные инструкции для курьера (домофон, подъезд и т.д.)" ) - + + confirm_address_with_recipient = models.BooleanField( + default=False, + verbose_name="Уточнить адрес у получателя", + help_text="Курьер должен уточнить адрес у получателя перед доставкой" + ) + is_default = models.BooleanField( default=False, verbose_name="Адрес по умолчанию", diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index 7dd9365..9ab9c7a 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -64,6 +64,7 @@ TENANT_APPS = [ 'django.contrib.auth', # Дублируем для tenant схем # Приложения с бизнес-логикой (изолированные для каждого магазина) + 'simple_history', # История изменений для каждого тенанта 'nested_admin', 'customers', # Клиенты магазина 'shops', # Точки магазина/самовывоза @@ -96,6 +97,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'simple_history.middleware.HistoryRequestMiddleware', # История изменений ] diff --git a/myproject/myproject/urls.py b/myproject/myproject/urls.py index 2fc1096..6f9f273 100644 --- a/myproject/myproject/urls.py +++ b/myproject/myproject/urls.py @@ -20,7 +20,7 @@ urlpatterns = [ path('products/', include('products.urls')), # Управление товарами path('customers/', include('customers.urls')), # Управление клиентами path('inventory/', include('inventory.urls')), # Управление складом - # path('orders/', include('orders.urls')), # TODO: Создать URL-конфиг для заказов + path('orders/', include('orders.urls')), # Управление заказами ] # Serve media files during development diff --git a/myproject/orders/admin.py b/myproject/orders/admin.py index 270a850..fa1eca1 100644 --- a/myproject/orders/admin.py +++ b/myproject/orders/admin.py @@ -1,6 +1,16 @@ # -*- coding: utf-8 -*- from django.contrib import admin -from .models import Order, OrderItem +from .models import Order, OrderItem, Payment + + +class PaymentInline(admin.TabularInline): + """ + Inline для управления платежами по заказу. + """ + model = Payment + extra = 1 + fields = ['amount', 'payment_method', 'payment_date', 'created_by', 'notes'] + readonly_fields = ['payment_date'] class OrderItemInline(admin.TabularInline): @@ -27,18 +37,19 @@ class OrderAdmin(admin.ModelAdmin): list_display = [ 'order_number', 'customer', - 'delivery_type', + 'is_delivery', 'delivery_date', 'status', 'total_amount', - 'is_paid', + 'payment_status', + 'amount_paid', 'created_at', ] list_filter = [ 'status', - 'delivery_type', - 'is_paid', + 'is_delivery', + 'payment_status', 'delivery_date', 'created_at', ] @@ -58,6 +69,8 @@ class OrderAdmin(admin.ModelAdmin): 'updated_at', 'delivery_info', 'delivery_time_window', + 'amount_due', + 'payment_status', ] fieldsets = ( @@ -66,7 +79,8 @@ class OrderAdmin(admin.ModelAdmin): }), ('Доставка', { 'fields': ( - 'delivery_type', + 'is_delivery', + 'customer_is_recipient', 'delivery_address', 'pickup_shop', 'delivery_date', @@ -78,19 +92,26 @@ class OrderAdmin(admin.ModelAdmin): ) }), ('Оплата', { - 'fields': ('payment_method', 'is_paid', 'total_amount') + 'fields': ( + 'payment_method', + 'total_amount', + 'discount_amount', + 'amount_paid', + 'amount_due', + 'payment_status', + ) }), ('Дополнительно', { 'fields': ('is_anonymous', 'special_instructions'), 'classes': ('collapse',) }), ('Системная информация', { - 'fields': ('created_at', 'updated_at'), + 'fields': ('created_at', 'updated_at', 'modified_by'), 'classes': ('collapse',) }), ) - inlines = [OrderItemInline] + inlines = [OrderItemInline, PaymentInline] actions = [ 'mark_as_confirmed', @@ -131,6 +152,41 @@ class OrderAdmin(admin.ModelAdmin): mark_as_paid.short_description = 'Отметить как оплаченные' +@admin.register(Payment) +class PaymentAdmin(admin.ModelAdmin): + """ + Админ-панель для управления платежами. + """ + list_display = [ + 'order', + 'amount', + 'payment_method', + 'payment_date', + 'created_by', + ] + + list_filter = [ + 'payment_method', + 'payment_date', + ] + + search_fields = [ + 'order__order_number', + 'notes', + ] + + readonly_fields = ['payment_date'] + + fieldsets = ( + ('Информация о платеже', { + 'fields': ('order', 'amount', 'payment_method', 'payment_date') + }), + ('Дополнительно', { + 'fields': ('created_by', 'notes') + }), + ) + + @admin.register(OrderItem) class OrderItemAdmin(admin.ModelAdmin): """ diff --git a/myproject/orders/migrations/0002_historicalorder_payment_and_more.py b/myproject/orders/migrations/0002_historicalorder_payment_and_more.py new file mode 100644 index 0000000..3190d16 --- /dev/null +++ b/myproject/orders/migrations/0002_historicalorder_payment_and_more.py @@ -0,0 +1,173 @@ +# Generated by Django 5.0.10 on 2025-11-06 20:54 + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0002_address_confirm_address_with_recipient_and_more'), + ('orders', '0001_initial'), + ('shops', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalOrder', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('order_number', models.CharField(db_index=True, editable=False, help_text='Уникальный номер заказа для отображения клиенту', max_length=50, 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='Стоимость доставки')), + ('status', models.CharField(choices=[('new', 'Новый'), ('confirmed', 'Подтвержден'), ('in_assembly', 'В сборке'), ('in_delivery', 'В доставке'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='new', max_length=20, 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='Сумма скидки')), + ('amount_paid', models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено')), + ('payment_status', models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты')), + ('customer_is_recipient', models.BooleanField(default=True, help_text='Если отмечено, данные получателя не требуются отдельно', verbose_name='Покупатель является получателем')), + ('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')), + ('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')), + ('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(blank=True, editable=False, verbose_name='Дата обновления')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ], + options={ + 'verbose_name': 'historical Заказ', + 'verbose_name_plural': 'historical Заказы', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='Payment', + 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='Примечания')), + ], + options={ + 'verbose_name': 'Платеж', + 'verbose_name_plural': 'Платежи', + 'ordering': ['-payment_date'], + }, + ), + migrations.RemoveIndex( + model_name='order', + name='orders_orde_deliver_f68568_idx', + ), + migrations.RemoveField( + model_name='order', + name='delivery_type', + ), + migrations.AddField( + model_name='order', + name='amount_paid', + field=models.DecimalField(decimal_places=2, default=0, help_text='Сумма, внесенная клиентом', max_digits=10, verbose_name='Оплачено'), + ), + migrations.AddField( + model_name='order', + name='customer_is_recipient', + field=models.BooleanField(default=True, help_text='Если отмечено, данные получателя не требуются отдельно', verbose_name='Покупатель является получателем'), + ), + migrations.AddField( + model_name='order', + name='discount_amount', + field=models.DecimalField(decimal_places=2, default=0, help_text='Применяется вручную или через систему скидок', max_digits=10, verbose_name='Сумма скидки'), + ), + migrations.AddField( + model_name='order', + name='is_delivery', + field=models.BooleanField(default=True, help_text='True - доставка курьером, False - самовывоз', 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='payment_status', + field=models.CharField(choices=[('unpaid', 'Не оплачен'), ('partial', 'Частично оплачен'), ('paid', 'Оплачен полностью')], default='unpaid', help_text='Обновляется автоматически при добавлении платежей', max_length=20, verbose_name='Статус оплаты'), + ), + migrations.AlterField( + model_name='order', + name='delivery_date', + field=models.DateField(blank=True, help_text='Может быть заполнено позже', null=True, verbose_name='Дата доставки/самовывоза'), + ), + migrations.AlterField( + model_name='order', + name='delivery_time_end', + field=models.TimeField(blank=True, help_text='Конец временного интервала', null=True, verbose_name='Время до'), + ), + migrations.AlterField( + model_name='order', + name='delivery_time_start', + field=models.TimeField(blank=True, help_text='Начало временного интервала', null=True, verbose_name='Время от'), + ), + 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'), + ), + migrations.AddField( + model_name='historicalorder', + name='customer', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='customers.customer', verbose_name='Клиент'), + ), + migrations.AddField( + 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='customers.address', verbose_name='Адрес доставки'), + ), + migrations.AddField( + model_name='historicalorder', + name='history_user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='historicalorder', + name='modified_by', + field=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='Изменен пользователем'), + ), + migrations.AddField( + model_name='historicalorder', + name='pickup_shop', + field=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='Точка самовывоза'), + ), + migrations.AddField( + model_name='payment', + 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='Принял платеж'), + ), + migrations.AddField( + model_name='payment', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='orders.order', verbose_name='Заказ'), + ), + migrations.AddIndex( + model_name='payment', + index=models.Index(fields=['order'], name='orders_paym_order_i_8c8d98_idx'), + ), + migrations.AddIndex( + model_name='payment', + index=models.Index(fields=['payment_date'], name='orders_paym_payment_9e5ac0_idx'), + ), + ] diff --git a/myproject/orders/migrations/0003_historicalorder_recipient_name_and_more.py b/myproject/orders/migrations/0003_historicalorder_recipient_name_and_more.py new file mode 100644 index 0000000..2bad29c --- /dev/null +++ b/myproject/orders/migrations/0003_historicalorder_recipient_name_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.10 on 2025-11-06 21:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0002_historicalorder_payment_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='historicalorder', + name='recipient_name', + field=models.CharField(blank=True, help_text='Заполняется, если покупатель не является получателем', max_length=200, null=True, verbose_name='Имя получателя'), + ), + migrations.AddField( + model_name='historicalorder', + name='recipient_phone', + field=models.CharField(blank=True, help_text='Контактный телефон получателя', max_length=20, null=True, verbose_name='Телефон получателя'), + ), + migrations.AddField( + model_name='order', + name='recipient_name', + field=models.CharField(blank=True, help_text='Заполняется, если покупатель не является получателем', max_length=200, null=True, verbose_name='Имя получателя'), + ), + migrations.AddField( + model_name='order', + name='recipient_phone', + field=models.CharField(blank=True, help_text='Контактный телефон получателя', max_length=20, null=True, verbose_name='Телефон получателя'), + ), + ] diff --git a/myproject/orders/templates/orders/order_confirm_delete.html b/myproject/orders/templates/orders/order_confirm_delete.html new file mode 100644 index 0000000..ecb2fc9 --- /dev/null +++ b/myproject/orders/templates/orders/order_confirm_delete.html @@ -0,0 +1,66 @@ +{% extends 'base.html' %} + +{% block title %}Подтверждение удаления заказа{% endblock %} + +{% block content %} +
+
+
+
+
+

+ Подтверждение удаления +

+
+
+
+ + Внимание! Вы собираетесь удалить заказ. +
+ +
Заказ {{ order.order_number }}
+ +
+
Клиент:
+
{{ order.customer.name }}
+ +
Дата создания:
+
{{ order.created_at|date:"d.m.Y H:i" }}
+ +
Статус:
+
{{ order.get_status_display }}
+ +
Сумма заказа:
+
{{ order.total_amount }} руб.
+ +
Товаров в заказе:
+
{{ order.items.count }}
+
+ +
+ Это действие нельзя отменить!
+ Будут удалены все связанные данные: +
    +
  • Все позиции заказа ({{ order.items.count }} шт.)
  • +
  • История платежей ({{ order.payments.count }} записей)
  • +
  • История изменений заказа
  • +
+
+ +
+ {% csrf_token %} +
+ + Отмена + + +
+
+
+
+
+
+
+{% endblock %} diff --git a/myproject/orders/urls.py b/myproject/orders/urls.py new file mode 100644 index 0000000..31b3442 --- /dev/null +++ b/myproject/orders/urls.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +from django.urls import path +from . import views + +app_name = 'orders' + +urlpatterns = [ + path('', views.order_list, name='order-list'), + path('create/', views.order_create, name='order-create'), + path('/', views.order_detail, name='order-detail'), + path('/edit/', views.order_update, name='order-update'), + path('/delete/', views.order_delete, name='order-delete'), +] diff --git a/myproject/orders/views.py b/myproject/orders/views.py index 91ea44a..c636660 100644 --- a/myproject/orders/views.py +++ b/myproject/orders/views.py @@ -1,3 +1,155 @@ -from django.shortcuts import render +# -*- coding: utf-8 -*- +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib import messages +from django.core.paginator import Paginator +from django.db.models import Q +from .models import Order, OrderItem +from .forms import OrderForm, OrderItemFormSet -# Create your views here. + +def order_list(request): + """Список всех заказов с фильтрацией и поиском""" + orders = Order.objects.select_related('customer', 'delivery_address', 'pickup_shop').all() + + # Поиск + search_query = request.GET.get('search', '') + if search_query: + orders = orders.filter( + Q(order_number__icontains=search_query) | + Q(customer__name__icontains=search_query) | + Q(customer__phone__icontains=search_query) | + Q(customer__email__icontains=search_query) + ) + + # Фильтр по статусу + status_filter = request.GET.get('status', '') + if status_filter: + orders = orders.filter(status=status_filter) + + # Фильтр по типу доставки + delivery_filter = request.GET.get('delivery_type', '') + if delivery_filter == 'delivery': + orders = orders.filter(is_delivery=True) + elif delivery_filter == 'pickup': + orders = orders.filter(is_delivery=False) + + # Сортировка + orders = orders.order_by('-created_at') + + # Пагинация + paginator = Paginator(orders, 25) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'page_obj': page_obj, + 'search_query': search_query, + 'status_filter': status_filter, + 'delivery_filter': delivery_filter, + 'status_choices': Order.STATUS_CHOICES, + } + + return render(request, 'orders/order_list.html', context) + + +def order_detail(request, pk): + """Детальная информация о заказе""" + order = get_object_or_404( + Order.objects.select_related('customer', 'delivery_address', 'pickup_shop', 'modified_by') + .prefetch_related('items__product', 'items__product_kit', 'payments__created_by'), + pk=pk + ) + + context = { + 'order': order, + } + + return render(request, 'orders/order_detail.html', context) + + +def order_create(request): + """Создание нового заказа""" + if request.method == 'POST': + form = OrderForm(request.POST) + formset = OrderItemFormSet(request.POST) + + if form.is_valid() and formset.is_valid(): + order = form.save(commit=False) + order.save() + + # Сохраняем позиции заказа + formset.instance = order + formset.save() + + # Пересчитываем итоговую сумму + order.calculate_total() + order.save() + + messages.success(request, f'Заказ #{order.order_number} успешно создан!') + return redirect('orders:order-detail', pk=order.pk) + else: + messages.error(request, 'Пожалуйста, исправьте ошибки в форме.') + else: + form = OrderForm() + formset = OrderItemFormSet() + + context = { + 'form': form, + 'formset': formset, + 'title': 'Создание заказа', + 'button_text': 'Создать заказ', + } + + return render(request, 'orders/order_form.html', context) + + +def order_update(request, pk): + """Редактирование заказа""" + order = get_object_or_404(Order, pk=pk) + + if request.method == 'POST': + form = OrderForm(request.POST, instance=order) + formset = OrderItemFormSet(request.POST, instance=order) + + if form.is_valid() and formset.is_valid(): + order = form.save() + formset.save() + + # Пересчитываем итоговую сумму + order.calculate_total() + order.save() + + messages.success(request, f'Заказ #{order.order_number} успешно обновлен!') + return redirect('orders:order-detail', pk=order.pk) + else: + messages.error(request, 'Пожалуйста, исправьте ошибки в форме.') + else: + form = OrderForm(instance=order) + formset = OrderItemFormSet(instance=order) + + context = { + 'form': form, + 'formset': formset, + 'order': order, + 'title': f'Редактирование заказа #{order.order_number}', + 'button_text': 'Сохранить изменения', + } + + return render(request, 'orders/order_form.html', context) + + +def order_delete(request, pk): + """Удаление заказа с подтверждением""" + order = get_object_or_404(Order, pk=pk) + + if request.method == 'POST': + order_number = order.order_number + order.delete() + messages.success(request, f'Заказ #{order_number} успешно удален.') + return redirect('orders:order-list') + + context = { + 'order': order, + } + + return render(request, 'orders/order_confirm_delete.html', context) diff --git a/myproject/products/static/products/js/select2-product-search.js b/myproject/products/static/products/js/select2-product-search.js new file mode 100644 index 0000000..a3ab73c --- /dev/null +++ b/myproject/products/static/products/js/select2-product-search.js @@ -0,0 +1,148 @@ +/** + * Select2 Product Search Module + * Переиспользуемый модуль для инициализации Select2 с AJAX поиском товаров + */ + +(function(window) { + 'use strict'; + + // Форматирование результата в выпадающем списке + function formatSelectResult(item) { + if (item.loading) return item.text; + + // Если это группа (header для товаров/комплектов), просто возвращаем текст + if (item.children) { + return item.text; + } + + var $container = $('
'); + + // Левая часть: иконка + название + var $left = $('
'); + + // Добавляем иконку в зависимости от типа + if (item.type === 'product') { + $left.append($('').text('🌹')); + } else if (item.type === 'kit') { + $left.append($('').text('💐')); + } else if (item.type === 'variant') { + $left.append($('').text('🔄')); + } + + // Название + var $name = $('').text(item.text); + $left.append($name); + $container.append($left); + + // Правая часть: цена и статус наличия + if (item.actual_price || item.price) { + var priceText = item.actual_price || item.price; + var $price = $('').text(priceText + ' руб.'); + $container.append($price); + } + + // Индикатор наличия для товаров + if (item.type === 'product' && item.in_stock !== undefined) { + if (item.in_stock) { + $container.append($('').text('В наличии')); + } else { + $container.append($('').text('Нет')); + } + } + + return $container; + } + + // Форматирование выбранного элемента + function formatSelectSelection(item) { + // Добавляем иконку для выбранного элемента + if (item.type === 'product') { + return '🌹 ' + item.text; + } else if (item.type === 'kit') { + return '💐 ' + item.text; + } else if (item.type === 'variant') { + return '🔄 ' + item.text; + } + return item.text || item.id; + } + + /** + * Инициализирует Select2 для элемента с AJAX поиском товаров + * @param {Element|jQuery} element - DOM элемент или jQuery объект select + * @param {string} type - Тип поиска ('product', 'variant', 'kit' или 'all') + * @param {string} apiUrl - URL API для поиска + * @param {Object} preloadedData - Предзагруженные данные товара + */ + window.initProductSelect2 = function(element, type, apiUrl, preloadedData) { + if (!element) return; + + // Преобразуем в jQuery если нужно + var $element = $(element); + + // Если уже инициализирован, пропускаем + if ($element.data('select2')) { + return; + } + + var placeholders = { + 'product': 'Начните вводить название товара...', + 'variant': 'Начните вводить название группы...', + 'kit': 'Начните вводить название комплекта...', + 'all': 'Начните вводить название товара или комплекта...' + }; + + var config = { + theme: 'bootstrap-5', + placeholder: placeholders[type] || 'Выберите...', + allowClear: true, + language: 'ru', + minimumInputLength: 0, + ajax: { + url: apiUrl, + dataType: 'json', + delay: 250, + data: function (params) { + return { + q: params.term || '', + type: type, + page: params.page || 1 + }; + }, + processResults: function (data) { + return { + results: data.results, + pagination: { + more: data.pagination.more + } + }; + }, + cache: true + }, + templateResult: formatSelectResult, + templateSelection: formatSelectSelection + }; + + // Если есть предзагруженные данные, создаем option с ними + if (preloadedData) { + var option = new Option(preloadedData.text, preloadedData.id, true, true); + // Сохраняем дополнительные данные для форматирования + $(option).data('data', preloadedData); + $element.append(option); + } + + $element.select2(config); + }; + + /** + * Инициализирует Select2 для всех элементов с данным селектором + * @param {string} selector - CSS селектор элементов + * @param {string} type - Тип поиска ('product' или 'variant') + * @param {string} apiUrl - URL API для поиска + */ + window.initAllProductSelect2 = function(selector, type, apiUrl) { + document.querySelectorAll(selector).forEach(function(element) { + window.initProductSelect2(element, type, apiUrl); + }); + }; + +})(window); diff --git a/myproject/products/templates/products/js/select2-product-search.js b/myproject/products/templates/products/js/select2-product-search.js index 59c7d80..a3ab73c 100644 --- a/myproject/products/templates/products/js/select2-product-search.js +++ b/myproject/products/templates/products/js/select2-product-search.js @@ -10,11 +10,44 @@ function formatSelectResult(item) { if (item.loading) return item.text; - var $container = $('
'); - $container.text(item.text); + // Если это группа (header для товаров/комплектов), просто возвращаем текст + if (item.children) { + return item.text; + } - if (item.price) { - $container.append($('
').text(item.price + ' руб.')); + var $container = $('
'); + + // Левая часть: иконка + название + var $left = $('
'); + + // Добавляем иконку в зависимости от типа + if (item.type === 'product') { + $left.append($('').text('🌹')); + } else if (item.type === 'kit') { + $left.append($('').text('💐')); + } else if (item.type === 'variant') { + $left.append($('').text('🔄')); + } + + // Название + var $name = $('').text(item.text); + $left.append($name); + $container.append($left); + + // Правая часть: цена и статус наличия + if (item.actual_price || item.price) { + var priceText = item.actual_price || item.price; + var $price = $('').text(priceText + ' руб.'); + $container.append($price); + } + + // Индикатор наличия для товаров + if (item.type === 'product' && item.in_stock !== undefined) { + if (item.in_stock) { + $container.append($('').text('В наличии')); + } else { + $container.append($('').text('Нет')); + } } return $container; @@ -22,13 +55,21 @@ // Форматирование выбранного элемента function formatSelectSelection(item) { + // Добавляем иконку для выбранного элемента + if (item.type === 'product') { + return '🌹 ' + item.text; + } else if (item.type === 'kit') { + return '💐 ' + item.text; + } else if (item.type === 'variant') { + return '🔄 ' + item.text; + } return item.text || item.id; } /** * Инициализирует Select2 для элемента с AJAX поиском товаров * @param {Element|jQuery} element - DOM элемент или jQuery объект select - * @param {string} type - Тип поиска ('product' или 'variant') + * @param {string} type - Тип поиска ('product', 'variant', 'kit' или 'all') * @param {string} apiUrl - URL API для поиска * @param {Object} preloadedData - Предзагруженные данные товара */ @@ -45,7 +86,9 @@ var placeholders = { 'product': 'Начните вводить название товара...', - 'variant': 'Начните вводить название группы...' + 'variant': 'Начните вводить название группы...', + 'kit': 'Начните вводить название комплекта...', + 'all': 'Начните вводить название товара или комплекта...' }; var config = { @@ -82,6 +125,8 @@ // Если есть предзагруженные данные, создаем option с ними if (preloadedData) { var option = new Option(preloadedData.text, preloadedData.id, true, true); + // Сохраняем дополнительные данные для форматирования + $(option).data('data', preloadedData); $element.append(option); } diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index 16d2d5c..cd2a3d3 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -5,54 +5,97 @@ from django.http import JsonResponse from django.db import models from django.core.cache import cache -from ..models import Product, ProductVariantGroup +from ..models import Product, ProductVariantGroup, ProductKit def search_products_and_variants(request): """ - API endpoint для поиска товаров и групп вариантов (совместимость с Select2). - Используется для автокомплита при добавлении компонентов в комплект. + API endpoint для поиска товаров, групп вариантов и комплектов (совместимость с Select2). + Используется для автокомплита при добавлении компонентов в комплект и товаров в заказ. Параметры GET: - q: строка поиска (term в Select2) - - id: ID товара для получения его данных - - type: 'product' или 'variant' (опционально) + - id: ID товара/комплекта для получения его данных (формат: "product_123" или "kit_456") + - type: 'product', 'variant', 'kit' или 'all' (опционально, по умолчанию 'all') - page: номер страницы для пагинации (по умолчанию 1) - Возвращает JSON в формате Select2: + Возвращает JSON в формате Select2 с группировкой: { "results": [ { - "id": 1, - "text": "Роза красная Freedom 50см (PROD-000001)", - "sku": "PROD-000001", - "price": "150.00", - "in_stock": true + "text": "Товары", + "children": [ + { + "id": "product_1", + "text": "Роза красная Freedom 50см (PROD-000001)", + "sku": "PROD-000001", + "price": "150.00", + "actual_price": "135.00", + "in_stock": true, + "type": "product" + } + ] + }, + { + "text": "Комплекты", + "children": [ + { + "id": "kit_1", + "text": "Букет 'Нежность' (KIT-000001)", + "sku": "KIT-000001", + "price": "2500.00", + "actual_price": "2500.00", + "type": "kit" + } + ] } ], "pagination": { - "more": true + "more": false } } """ - # Если передан ID товара - получаем его данные напрямую - product_id = request.GET.get('id', '').strip() - if product_id: + # Если передан ID товара/комплекта - получаем его данные напрямую + item_id = request.GET.get('id', '').strip() + if item_id: try: - product = Product.objects.get(id=int(product_id), is_active=True) - return JsonResponse({ - 'results': [{ - 'id': product.id, - 'text': f"{product.name} ({product.sku})" if product.sku else product.name, - 'sku': product.sku, - 'price': str(product.price) if product.price else None, - 'actual_price': str(product.actual_price) if product.actual_price else '0', - 'in_stock': product.in_stock, - 'type': 'product' - }], - 'pagination': {'more': False} - }) - except (Product.DoesNotExist, ValueError): + # Проверяем формат ID: "product_123" или "kit_456" или просто "123" + if '_' in item_id: + item_type, numeric_id = item_id.split('_', 1) + numeric_id = int(numeric_id) + else: + # Для обратной совместимости: если нет префикса, считаем что это product + item_type = 'product' + numeric_id = int(item_id) + + if item_type == 'product': + product = Product.objects.get(id=numeric_id, is_active=True) + return JsonResponse({ + 'results': [{ + 'id': f'product_{product.id}', + 'text': f"{product.name} ({product.sku})" if product.sku else product.name, + 'sku': product.sku, + 'price': str(product.price) if product.price else None, + 'actual_price': str(product.actual_price) if product.actual_price else '0', + 'in_stock': product.in_stock, + 'type': 'product' + }], + 'pagination': {'more': False} + }) + elif item_type == 'kit': + kit = ProductKit.objects.get(id=numeric_id, is_active=True) + return JsonResponse({ + 'results': [{ + 'id': f'kit_{kit.id}', + 'text': f"{kit.name} ({kit.sku})" if kit.sku else kit.name, + 'sku': kit.sku, + 'price': str(kit.price) if kit.price else None, + 'actual_price': str(kit.actual_price) if kit.actual_price else '0', + 'type': 'kit' + }], + 'pagination': {'more': False} + }) + except (Product.DoesNotExist, ProductKit.DoesNotExist, ValueError): return JsonResponse({'results': [], 'pagination': {'more': False}}) query = request.GET.get('q', '').strip() @@ -62,15 +105,18 @@ def search_products_and_variants(request): results = [] - # Если поиска нет - показываем популярные товары + # Если поиска нет - показываем популярные товары и комплекты if not query or len(query) < 2: # Кэшируем популярные товары на 1 час - cache_key = f'popular_products_{search_type}' + cache_key = f'popular_items_{search_type}' cached_results = cache.get(cache_key) if cached_results: return JsonResponse(cached_results) + product_results = [] + kit_results = [] + if search_type in ['all', 'product']: # Показываем последние добавленные активные товары products = Product.objects.filter(is_active=True)\ @@ -85,8 +131,8 @@ def search_products_and_variants(request): # Получаем actual_price: приоритет sale_price > price actual_price = product['sale_price'] if product['sale_price'] else product['price'] - results.append({ - 'id': product['id'], + product_results.append({ + 'id': f"product_{product['id']}", 'text': text, 'sku': product['sku'], 'price': str(product['price']) if product['price'] else None, @@ -95,6 +141,48 @@ def search_products_and_variants(request): 'type': 'product' }) + if search_type in ['all', 'kit']: + # Показываем последние добавленные активные комплекты + kits = ProductKit.objects.filter(is_active=True)\ + .order_by('-created_at')[:page_size]\ + .values('id', 'name', 'sku', 'price', 'sale_price') + + for kit in kits: + text = kit['name'] + if kit['sku']: + text += f" ({kit['sku']})" + + # Получаем actual_price: приоритет sale_price > price + actual_price = kit['sale_price'] if kit['sale_price'] else kit['price'] + + kit_results.append({ + 'id': f"kit_{kit['id']}", + 'text': text, + 'sku': kit['sku'], + 'price': str(kit['price']) if kit['price'] else None, + 'actual_price': str(actual_price) if actual_price else '0', + 'type': 'kit' + }) + + # Формируем результат с группировкой или без + if search_type == 'all' and (product_results or kit_results): + # С группировкой + grouped_results = [] + if product_results: + grouped_results.append({ + 'text': 'Товары', + 'children': product_results + }) + if kit_results: + grouped_results.append({ + 'text': 'Комплекты', + 'children': kit_results + }) + results = grouped_results + else: + # Без группировки (когда ищем только product или только kit) + results = product_results + kit_results + response_data = { 'results': results, 'pagination': {'more': False} @@ -102,14 +190,19 @@ def search_products_and_variants(request): cache.set(cache_key, response_data, 3600) return JsonResponse(response_data) - # Поиск товаров (регистронезависимый поиск с приоритетом точных совпадений) + # Поиск товаров и комплектов (регистронезависимый поиск с приоритетом точных совпадений) + product_results = [] + kit_results = [] + has_more = False + + # Нормализуем запрос - убираем лишние пробелы + query_normalized = ' '.join(query.split()) + + from django.db.models import Case, When, IntegerField + from django.conf import settings + + # Поиск товаров if search_type in ['all', 'product']: - # Нормализуем запрос - убираем лишние пробелы - query_normalized = ' '.join(query.split()) - - from django.db.models import Case, When, IntegerField - from django.conf import settings - # ВРЕМЕННЫЙ ФИХ для SQLite: удалить когда база данных будет PostgreSQL # SQLite не поддерживает регистронезависимый поиск для кириллицы в LIKE if 'sqlite' in settings.DATABASES['default']['ENGINE']: @@ -163,8 +256,8 @@ def search_products_and_variants(request): # Получаем actual_price: приоритет sale_price > price actual_price = product['sale_price'] if product['sale_price'] else product['price'] - results.append({ - 'id': product['id'], + product_results.append({ + 'id': f"product_{product['id']}", 'text': text, 'sku': product['sku'], 'price': str(product['price']) if product['price'] else None, @@ -174,10 +267,67 @@ def search_products_and_variants(request): }) has_more = total_products > end - else: - has_more = False + + # Поиск комплектов + if search_type in ['all', 'kit']: + # Используем аналогичную логику для комплектов + if 'sqlite' in settings.DATABASES['default']['ENGINE']: + from django.db.models.functions import Lower + query_lower = query_normalized.lower() + + kits_query = ProductKit.objects.annotate( + name_lower=Lower('name'), + sku_lower=Lower('sku'), + description_lower=Lower('description') + ).filter( + models.Q(name_lower__contains=query_lower) | + models.Q(sku_lower__contains=query_lower) | + models.Q(description_lower__contains=query_lower), + is_active=True + ).annotate( + relevance=Case( + When(name_lower=query_lower, then=3), + When(name_lower__startswith=query_lower, then=2), + default=1, + output_field=IntegerField() + ) + ).order_by('-relevance', 'name') + else: + kits_query = ProductKit.objects.filter( + models.Q(name__icontains=query_normalized) | + models.Q(sku__icontains=query_normalized) | + models.Q(description__icontains=query_normalized), + is_active=True + ).annotate( + relevance=Case( + When(name__iexact=query_normalized, then=3), + When(name__istartswith=query_normalized, then=2), + default=1, + output_field=IntegerField() + ) + ).order_by('-relevance', 'name') + + kits = kits_query[:page_size].values('id', 'name', 'sku', 'price', 'sale_price') + + for kit in kits: + text = kit['name'] + if kit['sku']: + text += f" ({kit['sku']})" + + # Получаем actual_price: приоритет sale_price > price + actual_price = kit['sale_price'] if kit['sale_price'] else kit['price'] + + kit_results.append({ + 'id': f"kit_{kit['id']}", + 'text': text, + 'sku': kit['sku'], + 'price': str(kit['price']) if kit['price'] else None, + 'actual_price': str(actual_price) if actual_price else '0', + 'type': 'kit' + }) # Поиск групп вариантов + variant_results = [] if search_type in ['all', 'variant']: variants = ProductVariantGroup.objects.filter( models.Q(name__icontains=query) | @@ -186,16 +336,42 @@ def search_products_and_variants(request): for variant in variants: count = variant.products.filter(is_active=True).count() - results.append({ + variant_results.append({ 'id': variant.id, 'text': f"{variant.name} ({count} вариантов)", 'type': 'variant', 'count': count }) + # Формируем финальный результат с группировкой или без + # Для 'all' показываем только товары и комплекты (без вариантов) + if search_type == 'all': + if product_results or kit_results: + # С группировкой для заказов (товары + комплекты) + grouped_results = [] + if product_results: + grouped_results.append({ + 'text': 'Товары', + 'children': product_results + }) + if kit_results: + grouped_results.append({ + 'text': 'Комплекты', + 'children': kit_results + }) + final_results = grouped_results + else: + final_results = [] + elif search_type == 'variant': + # Только варианты + final_results = variant_results + else: + # Без группировки для специфичного поиска (product или kit) + final_results = product_results + kit_results + variant_results + return JsonResponse({ - 'results': results, - 'pagination': {'more': has_more if search_type == 'product' else False} + 'results': final_results, + 'pagination': {'more': has_more} }) diff --git a/myproject/templates/navbar.html b/myproject/templates/navbar.html index 08997de..a5dc2eb 100644 --- a/myproject/templates/navbar.html +++ b/myproject/templates/navbar.html @@ -19,7 +19,7 @@ Варианты