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 }} записей)
+ - История изменений заказа
+
+
+
+
+
+
+
+
+
+{% 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 @@
Варианты
- Заказы
+ Заказы
Клиенты