feat: Добавлена функциональность управления заказами и улучшен поиск товаров
Заказы: - Добавлены миграции для исторических записей с полями оплаты и получателя - Расширен admin для заказов с инлайнами товаров/комплектов - Реализованы представления списка, создания, редактирования и удаления заказов - Добавлен шаблон подтверждения удаления заказа - Настроены URL-маршруты для работы с заказами Клиенты: - Добавлена миграция с новыми полями адресов и подтверждений - Обновлена модель клиентов с дополнительными полями - Улучшен admin для работы с клиентами Товары: - Значительно улучшен API поиска товаров с поддержкой фильтрации - Добавлен Select2 виджет для динамического поиска товаров - Создан статический JS файл для интеграции Select2 - Оптимизирована обработка запросов и ответов API Прочее: - Добавлены новые настройки в settings.py - Обновлена навигация в navbar.html - Обновлены URL-маршруты проекта 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -78,13 +78,16 @@ class AddressAdmin(admin.ModelAdmin):
|
|||||||
"""Административный интерфейс для управления адресами доставки"""
|
"""Административный интерфейс для управления адресами доставки"""
|
||||||
list_display = (
|
list_display = (
|
||||||
'recipient_name',
|
'recipient_name',
|
||||||
|
'recipient_phone',
|
||||||
'full_address',
|
'full_address',
|
||||||
'customer',
|
'customer',
|
||||||
'district',
|
'district',
|
||||||
|
'confirm_address_with_recipient',
|
||||||
'is_default'
|
'is_default'
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
'is_default',
|
'is_default',
|
||||||
|
'confirm_address_with_recipient',
|
||||||
'district',
|
'district',
|
||||||
'created_at'
|
'created_at'
|
||||||
)
|
)
|
||||||
@@ -100,13 +103,13 @@ class AddressAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Информация о получателе', {
|
('Информация о получателе', {
|
||||||
'fields': ('customer', 'recipient_name')
|
'fields': ('customer', 'recipient_name', 'recipient_phone')
|
||||||
}),
|
}),
|
||||||
('Адрес доставки', {
|
('Адрес доставки', {
|
||||||
'fields': ('street', 'building_number', 'apartment_number', 'district')
|
'fields': ('street', 'building_number', 'apartment_number', 'district')
|
||||||
}),
|
}),
|
||||||
('Дополнительная информация', {
|
('Дополнительная информация', {
|
||||||
'fields': ('delivery_instructions',),
|
'fields': ('delivery_instructions', 'confirm_address_with_recipient'),
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
('Статус', {
|
('Статус', {
|
||||||
|
|||||||
@@ -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='Телефон получателя'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -214,6 +214,13 @@ class Address(models.Model):
|
|||||||
help_text="Имя человека, которому будет доставлен заказ"
|
help_text="Имя человека, которому будет доставлен заказ"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recipient_phone = PhoneNumberField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Телефон получателя",
|
||||||
|
help_text="Контактный телефон получателя для уточнения адреса"
|
||||||
|
)
|
||||||
|
|
||||||
street = models.CharField(
|
street = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
verbose_name="Улица"
|
verbose_name="Улица"
|
||||||
@@ -247,6 +254,12 @@ class Address(models.Model):
|
|||||||
help_text="Дополнительные инструкции для курьера (домофон, подъезд и т.д.)"
|
help_text="Дополнительные инструкции для курьера (домофон, подъезд и т.д.)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
confirm_address_with_recipient = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Уточнить адрес у получателя",
|
||||||
|
help_text="Курьер должен уточнить адрес у получателя перед доставкой"
|
||||||
|
)
|
||||||
|
|
||||||
is_default = models.BooleanField(
|
is_default = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name="Адрес по умолчанию",
|
verbose_name="Адрес по умолчанию",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ TENANT_APPS = [
|
|||||||
'django.contrib.auth', # Дублируем для tenant схем
|
'django.contrib.auth', # Дублируем для tenant схем
|
||||||
|
|
||||||
# Приложения с бизнес-логикой (изолированные для каждого магазина)
|
# Приложения с бизнес-логикой (изолированные для каждого магазина)
|
||||||
|
'simple_history', # История изменений для каждого тенанта
|
||||||
'nested_admin',
|
'nested_admin',
|
||||||
'customers', # Клиенты магазина
|
'customers', # Клиенты магазина
|
||||||
'shops', # Точки магазина/самовывоза
|
'shops', # Точки магазина/самовывоза
|
||||||
@@ -96,6 +97,7 @@ MIDDLEWARE = [
|
|||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
'simple_history.middleware.HistoryRequestMiddleware', # История изменений
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ urlpatterns = [
|
|||||||
path('products/', include('products.urls')), # Управление товарами
|
path('products/', include('products.urls')), # Управление товарами
|
||||||
path('customers/', include('customers.urls')), # Управление клиентами
|
path('customers/', include('customers.urls')), # Управление клиентами
|
||||||
path('inventory/', include('inventory.urls')), # Управление складом
|
path('inventory/', include('inventory.urls')), # Управление складом
|
||||||
# path('orders/', include('orders.urls')), # TODO: Создать URL-конфиг для заказов
|
path('orders/', include('orders.urls')), # Управление заказами
|
||||||
]
|
]
|
||||||
|
|
||||||
# Serve media files during development
|
# Serve media files during development
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from django.contrib import admin
|
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):
|
class OrderItemInline(admin.TabularInline):
|
||||||
@@ -27,18 +37,19 @@ class OrderAdmin(admin.ModelAdmin):
|
|||||||
list_display = [
|
list_display = [
|
||||||
'order_number',
|
'order_number',
|
||||||
'customer',
|
'customer',
|
||||||
'delivery_type',
|
'is_delivery',
|
||||||
'delivery_date',
|
'delivery_date',
|
||||||
'status',
|
'status',
|
||||||
'total_amount',
|
'total_amount',
|
||||||
'is_paid',
|
'payment_status',
|
||||||
|
'amount_paid',
|
||||||
'created_at',
|
'created_at',
|
||||||
]
|
]
|
||||||
|
|
||||||
list_filter = [
|
list_filter = [
|
||||||
'status',
|
'status',
|
||||||
'delivery_type',
|
'is_delivery',
|
||||||
'is_paid',
|
'payment_status',
|
||||||
'delivery_date',
|
'delivery_date',
|
||||||
'created_at',
|
'created_at',
|
||||||
]
|
]
|
||||||
@@ -58,6 +69,8 @@ class OrderAdmin(admin.ModelAdmin):
|
|||||||
'updated_at',
|
'updated_at',
|
||||||
'delivery_info',
|
'delivery_info',
|
||||||
'delivery_time_window',
|
'delivery_time_window',
|
||||||
|
'amount_due',
|
||||||
|
'payment_status',
|
||||||
]
|
]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@@ -66,7 +79,8 @@ class OrderAdmin(admin.ModelAdmin):
|
|||||||
}),
|
}),
|
||||||
('Доставка', {
|
('Доставка', {
|
||||||
'fields': (
|
'fields': (
|
||||||
'delivery_type',
|
'is_delivery',
|
||||||
|
'customer_is_recipient',
|
||||||
'delivery_address',
|
'delivery_address',
|
||||||
'pickup_shop',
|
'pickup_shop',
|
||||||
'delivery_date',
|
'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'),
|
'fields': ('is_anonymous', 'special_instructions'),
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
('Системная информация', {
|
('Системная информация', {
|
||||||
'fields': ('created_at', 'updated_at'),
|
'fields': ('created_at', 'updated_at', 'modified_by'),
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
inlines = [OrderItemInline]
|
inlines = [OrderItemInline, PaymentInline]
|
||||||
|
|
||||||
actions = [
|
actions = [
|
||||||
'mark_as_confirmed',
|
'mark_as_confirmed',
|
||||||
@@ -131,6 +152,41 @@ class OrderAdmin(admin.ModelAdmin):
|
|||||||
mark_as_paid.short_description = 'Отметить как оплаченные'
|
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)
|
@admin.register(OrderItem)
|
||||||
class OrderItemAdmin(admin.ModelAdmin):
|
class OrderItemAdmin(admin.ModelAdmin):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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='Телефон получателя'),
|
||||||
|
),
|
||||||
|
]
|
||||||
66
myproject/orders/templates/orders/order_confirm_delete.html
Normal file
66
myproject/orders/templates/orders/order_confirm_delete.html
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Подтверждение удаления заказа{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card mt-5">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> Подтверждение удаления
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-circle"></i>
|
||||||
|
<strong>Внимание!</strong> Вы собираетесь удалить заказ.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5>Заказ {{ order.order_number }}</h5>
|
||||||
|
|
||||||
|
<dl class="row mt-3">
|
||||||
|
<dt class="col-sm-4">Клиент:</dt>
|
||||||
|
<dd class="col-sm-8">{{ order.customer.name }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Дата создания:</dt>
|
||||||
|
<dd class="col-sm-8">{{ order.created_at|date:"d.m.Y H:i" }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Статус:</dt>
|
||||||
|
<dd class="col-sm-8">{{ order.get_status_display }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Сумма заказа:</dt>
|
||||||
|
<dd class="col-sm-8"><strong>{{ order.total_amount }} руб.</strong></dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Товаров в заказе:</dt>
|
||||||
|
<dd class="col-sm-8">{{ order.items.count }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="alert alert-danger mt-3">
|
||||||
|
<strong>Это действие нельзя отменить!</strong><br>
|
||||||
|
Будут удалены все связанные данные:
|
||||||
|
<ul class="mb-0 mt-2">
|
||||||
|
<li>Все позиции заказа ({{ order.items.count }} шт.)</li>
|
||||||
|
<li>История платежей ({{ order.payments.count }} записей)</li>
|
||||||
|
<li>История изменений заказа</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="mt-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{% url 'orders:order-detail' order.pk %}" class="btn btn-secondary btn-lg">
|
||||||
|
<i class="bi bi-arrow-left"></i> Отмена
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-danger btn-lg">
|
||||||
|
<i class="bi bi-trash"></i> Да, удалить заказ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
13
myproject/orders/urls.py
Normal file
13
myproject/orders/urls.py
Normal file
@@ -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('<int:pk>/', views.order_detail, name='order-detail'),
|
||||||
|
path('<int:pk>/edit/', views.order_update, name='order-update'),
|
||||||
|
path('<int:pk>/delete/', views.order_delete, name='order-delete'),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
148
myproject/products/static/products/js/select2-product-search.js
Normal file
148
myproject/products/static/products/js/select2-product-search.js
Normal file
@@ -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 = $('<div class="select2-result-item d-flex justify-content-between align-items-center">');
|
||||||
|
|
||||||
|
// Левая часть: иконка + название
|
||||||
|
var $left = $('<div class="d-flex align-items-center">');
|
||||||
|
|
||||||
|
// Добавляем иконку в зависимости от типа
|
||||||
|
if (item.type === 'product') {
|
||||||
|
$left.append($('<span class="me-2">').text('🌹'));
|
||||||
|
} else if (item.type === 'kit') {
|
||||||
|
$left.append($('<span class="me-2">').text('💐'));
|
||||||
|
} else if (item.type === 'variant') {
|
||||||
|
$left.append($('<span class="me-2">').text('🔄'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Название
|
||||||
|
var $name = $('<span>').text(item.text);
|
||||||
|
$left.append($name);
|
||||||
|
$container.append($left);
|
||||||
|
|
||||||
|
// Правая часть: цена и статус наличия
|
||||||
|
if (item.actual_price || item.price) {
|
||||||
|
var priceText = item.actual_price || item.price;
|
||||||
|
var $price = $('<span class="text-muted small">').text(priceText + ' руб.');
|
||||||
|
$container.append($price);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индикатор наличия для товаров
|
||||||
|
if (item.type === 'product' && item.in_stock !== undefined) {
|
||||||
|
if (item.in_stock) {
|
||||||
|
$container.append($('<span class="badge bg-success ms-2 small">').text('В наличии'));
|
||||||
|
} else {
|
||||||
|
$container.append($('<span class="badge bg-secondary ms-2 small">').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);
|
||||||
@@ -10,11 +10,44 @@
|
|||||||
function formatSelectResult(item) {
|
function formatSelectResult(item) {
|
||||||
if (item.loading) return item.text;
|
if (item.loading) return item.text;
|
||||||
|
|
||||||
var $container = $('<div class="select2-result-item">');
|
// Если это группа (header для товаров/комплектов), просто возвращаем текст
|
||||||
$container.text(item.text);
|
if (item.children) {
|
||||||
|
return item.text;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.price) {
|
var $container = $('<div class="select2-result-item d-flex justify-content-between align-items-center">');
|
||||||
$container.append($('<div class="text-muted small">').text(item.price + ' руб.'));
|
|
||||||
|
// Левая часть: иконка + название
|
||||||
|
var $left = $('<div class="d-flex align-items-center">');
|
||||||
|
|
||||||
|
// Добавляем иконку в зависимости от типа
|
||||||
|
if (item.type === 'product') {
|
||||||
|
$left.append($('<span class="me-2">').text('🌹'));
|
||||||
|
} else if (item.type === 'kit') {
|
||||||
|
$left.append($('<span class="me-2">').text('💐'));
|
||||||
|
} else if (item.type === 'variant') {
|
||||||
|
$left.append($('<span class="me-2">').text('🔄'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Название
|
||||||
|
var $name = $('<span>').text(item.text);
|
||||||
|
$left.append($name);
|
||||||
|
$container.append($left);
|
||||||
|
|
||||||
|
// Правая часть: цена и статус наличия
|
||||||
|
if (item.actual_price || item.price) {
|
||||||
|
var priceText = item.actual_price || item.price;
|
||||||
|
var $price = $('<span class="text-muted small">').text(priceText + ' руб.');
|
||||||
|
$container.append($price);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индикатор наличия для товаров
|
||||||
|
if (item.type === 'product' && item.in_stock !== undefined) {
|
||||||
|
if (item.in_stock) {
|
||||||
|
$container.append($('<span class="badge bg-success ms-2 small">').text('В наличии'));
|
||||||
|
} else {
|
||||||
|
$container.append($('<span class="badge bg-secondary ms-2 small">').text('Нет'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $container;
|
return $container;
|
||||||
@@ -22,13 +55,21 @@
|
|||||||
|
|
||||||
// Форматирование выбранного элемента
|
// Форматирование выбранного элемента
|
||||||
function formatSelectSelection(item) {
|
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;
|
return item.text || item.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Инициализирует Select2 для элемента с AJAX поиском товаров
|
* Инициализирует Select2 для элемента с AJAX поиском товаров
|
||||||
* @param {Element|jQuery} element - DOM элемент или jQuery объект select
|
* @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 {string} apiUrl - URL API для поиска
|
||||||
* @param {Object} preloadedData - Предзагруженные данные товара
|
* @param {Object} preloadedData - Предзагруженные данные товара
|
||||||
*/
|
*/
|
||||||
@@ -45,7 +86,9 @@
|
|||||||
|
|
||||||
var placeholders = {
|
var placeholders = {
|
||||||
'product': 'Начните вводить название товара...',
|
'product': 'Начните вводить название товара...',
|
||||||
'variant': 'Начните вводить название группы...'
|
'variant': 'Начните вводить название группы...',
|
||||||
|
'kit': 'Начните вводить название комплекта...',
|
||||||
|
'all': 'Начните вводить название товара или комплекта...'
|
||||||
};
|
};
|
||||||
|
|
||||||
var config = {
|
var config = {
|
||||||
@@ -82,6 +125,8 @@
|
|||||||
// Если есть предзагруженные данные, создаем option с ними
|
// Если есть предзагруженные данные, создаем option с ними
|
||||||
if (preloadedData) {
|
if (preloadedData) {
|
||||||
var option = new Option(preloadedData.text, preloadedData.id, true, true);
|
var option = new Option(preloadedData.text, preloadedData.id, true, true);
|
||||||
|
// Сохраняем дополнительные данные для форматирования
|
||||||
|
$(option).data('data', preloadedData);
|
||||||
$element.append(option);
|
$element.append(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,44 +5,74 @@ from django.http import JsonResponse
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
from ..models import Product, ProductVariantGroup
|
from ..models import Product, ProductVariantGroup, ProductKit
|
||||||
|
|
||||||
|
|
||||||
def search_products_and_variants(request):
|
def search_products_and_variants(request):
|
||||||
"""
|
"""
|
||||||
API endpoint для поиска товаров и групп вариантов (совместимость с Select2).
|
API endpoint для поиска товаров, групп вариантов и комплектов (совместимость с Select2).
|
||||||
Используется для автокомплита при добавлении компонентов в комплект.
|
Используется для автокомплита при добавлении компонентов в комплект и товаров в заказ.
|
||||||
|
|
||||||
Параметры GET:
|
Параметры GET:
|
||||||
- q: строка поиска (term в Select2)
|
- q: строка поиска (term в Select2)
|
||||||
- id: ID товара для получения его данных
|
- id: ID товара/комплекта для получения его данных (формат: "product_123" или "kit_456")
|
||||||
- type: 'product' или 'variant' (опционально)
|
- type: 'product', 'variant', 'kit' или 'all' (опционально, по умолчанию 'all')
|
||||||
- page: номер страницы для пагинации (по умолчанию 1)
|
- page: номер страницы для пагинации (по умолчанию 1)
|
||||||
|
|
||||||
Возвращает JSON в формате Select2:
|
Возвращает JSON в формате Select2 с группировкой:
|
||||||
{
|
{
|
||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"text": "Товары",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "product_1",
|
||||||
"text": "Роза красная Freedom 50см (PROD-000001)",
|
"text": "Роза красная Freedom 50см (PROD-000001)",
|
||||||
"sku": "PROD-000001",
|
"sku": "PROD-000001",
|
||||||
"price": "150.00",
|
"price": "150.00",
|
||||||
"in_stock": true
|
"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": {
|
"pagination": {
|
||||||
"more": true
|
"more": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
# Если передан ID товара - получаем его данные напрямую
|
# Если передан ID товара/комплекта - получаем его данные напрямую
|
||||||
product_id = request.GET.get('id', '').strip()
|
item_id = request.GET.get('id', '').strip()
|
||||||
if product_id:
|
if item_id:
|
||||||
try:
|
try:
|
||||||
product = Product.objects.get(id=int(product_id), is_active=True)
|
# Проверяем формат 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({
|
return JsonResponse({
|
||||||
'results': [{
|
'results': [{
|
||||||
'id': product.id,
|
'id': f'product_{product.id}',
|
||||||
'text': f"{product.name} ({product.sku})" if product.sku else product.name,
|
'text': f"{product.name} ({product.sku})" if product.sku else product.name,
|
||||||
'sku': product.sku,
|
'sku': product.sku,
|
||||||
'price': str(product.price) if product.price else None,
|
'price': str(product.price) if product.price else None,
|
||||||
@@ -52,7 +82,20 @@ def search_products_and_variants(request):
|
|||||||
}],
|
}],
|
||||||
'pagination': {'more': False}
|
'pagination': {'more': False}
|
||||||
})
|
})
|
||||||
except (Product.DoesNotExist, ValueError):
|
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}})
|
return JsonResponse({'results': [], 'pagination': {'more': False}})
|
||||||
|
|
||||||
query = request.GET.get('q', '').strip()
|
query = request.GET.get('q', '').strip()
|
||||||
@@ -62,15 +105,18 @@ def search_products_and_variants(request):
|
|||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Если поиска нет - показываем популярные товары
|
# Если поиска нет - показываем популярные товары и комплекты
|
||||||
if not query or len(query) < 2:
|
if not query or len(query) < 2:
|
||||||
# Кэшируем популярные товары на 1 час
|
# Кэшируем популярные товары на 1 час
|
||||||
cache_key = f'popular_products_{search_type}'
|
cache_key = f'popular_items_{search_type}'
|
||||||
cached_results = cache.get(cache_key)
|
cached_results = cache.get(cache_key)
|
||||||
|
|
||||||
if cached_results:
|
if cached_results:
|
||||||
return JsonResponse(cached_results)
|
return JsonResponse(cached_results)
|
||||||
|
|
||||||
|
product_results = []
|
||||||
|
kit_results = []
|
||||||
|
|
||||||
if search_type in ['all', 'product']:
|
if search_type in ['all', 'product']:
|
||||||
# Показываем последние добавленные активные товары
|
# Показываем последние добавленные активные товары
|
||||||
products = Product.objects.filter(is_active=True)\
|
products = Product.objects.filter(is_active=True)\
|
||||||
@@ -85,8 +131,8 @@ def search_products_and_variants(request):
|
|||||||
# Получаем actual_price: приоритет sale_price > price
|
# Получаем actual_price: приоритет sale_price > price
|
||||||
actual_price = product['sale_price'] if product['sale_price'] else product['price']
|
actual_price = product['sale_price'] if product['sale_price'] else product['price']
|
||||||
|
|
||||||
results.append({
|
product_results.append({
|
||||||
'id': product['id'],
|
'id': f"product_{product['id']}",
|
||||||
'text': text,
|
'text': text,
|
||||||
'sku': product['sku'],
|
'sku': product['sku'],
|
||||||
'price': str(product['price']) if product['price'] else None,
|
'price': str(product['price']) if product['price'] else None,
|
||||||
@@ -95,6 +141,48 @@ def search_products_and_variants(request):
|
|||||||
'type': 'product'
|
'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 = {
|
response_data = {
|
||||||
'results': results,
|
'results': results,
|
||||||
'pagination': {'more': False}
|
'pagination': {'more': False}
|
||||||
@@ -102,14 +190,19 @@ def search_products_and_variants(request):
|
|||||||
cache.set(cache_key, response_data, 3600)
|
cache.set(cache_key, response_data, 3600)
|
||||||
return JsonResponse(response_data)
|
return JsonResponse(response_data)
|
||||||
|
|
||||||
# Поиск товаров (регистронезависимый поиск с приоритетом точных совпадений)
|
# Поиск товаров и комплектов (регистронезависимый поиск с приоритетом точных совпадений)
|
||||||
if search_type in ['all', 'product']:
|
product_results = []
|
||||||
|
kit_results = []
|
||||||
|
has_more = False
|
||||||
|
|
||||||
# Нормализуем запрос - убираем лишние пробелы
|
# Нормализуем запрос - убираем лишние пробелы
|
||||||
query_normalized = ' '.join(query.split())
|
query_normalized = ' '.join(query.split())
|
||||||
|
|
||||||
from django.db.models import Case, When, IntegerField
|
from django.db.models import Case, When, IntegerField
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Поиск товаров
|
||||||
|
if search_type in ['all', 'product']:
|
||||||
# ВРЕМЕННЫЙ ФИХ для SQLite: удалить когда база данных будет PostgreSQL
|
# ВРЕМЕННЫЙ ФИХ для SQLite: удалить когда база данных будет PostgreSQL
|
||||||
# SQLite не поддерживает регистронезависимый поиск для кириллицы в LIKE
|
# SQLite не поддерживает регистронезависимый поиск для кириллицы в LIKE
|
||||||
if 'sqlite' in settings.DATABASES['default']['ENGINE']:
|
if 'sqlite' in settings.DATABASES['default']['ENGINE']:
|
||||||
@@ -163,8 +256,8 @@ def search_products_and_variants(request):
|
|||||||
# Получаем actual_price: приоритет sale_price > price
|
# Получаем actual_price: приоритет sale_price > price
|
||||||
actual_price = product['sale_price'] if product['sale_price'] else product['price']
|
actual_price = product['sale_price'] if product['sale_price'] else product['price']
|
||||||
|
|
||||||
results.append({
|
product_results.append({
|
||||||
'id': product['id'],
|
'id': f"product_{product['id']}",
|
||||||
'text': text,
|
'text': text,
|
||||||
'sku': product['sku'],
|
'sku': product['sku'],
|
||||||
'price': str(product['price']) if product['price'] else None,
|
'price': str(product['price']) if product['price'] else None,
|
||||||
@@ -174,10 +267,67 @@ def search_products_and_variants(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
has_more = total_products > end
|
has_more = total_products > end
|
||||||
|
|
||||||
|
# Поиск комплектов
|
||||||
|
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:
|
else:
|
||||||
has_more = False
|
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']:
|
if search_type in ['all', 'variant']:
|
||||||
variants = ProductVariantGroup.objects.filter(
|
variants = ProductVariantGroup.objects.filter(
|
||||||
models.Q(name__icontains=query) |
|
models.Q(name__icontains=query) |
|
||||||
@@ -186,16 +336,42 @@ def search_products_and_variants(request):
|
|||||||
|
|
||||||
for variant in variants:
|
for variant in variants:
|
||||||
count = variant.products.filter(is_active=True).count()
|
count = variant.products.filter(is_active=True).count()
|
||||||
results.append({
|
variant_results.append({
|
||||||
'id': variant.id,
|
'id': variant.id,
|
||||||
'text': f"{variant.name} ({count} вариантов)",
|
'text': f"{variant.name} ({count} вариантов)",
|
||||||
'type': 'variant',
|
'type': 'variant',
|
||||||
'count': count
|
'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({
|
return JsonResponse({
|
||||||
'results': results,
|
'results': final_results,
|
||||||
'pagination': {'more': has_more if search_type == 'product' else False}
|
'pagination': {'more': has_more}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<a class="nav-link" href="{% url 'products:variantgroup-list' %}">Варианты</a>
|
<a class="nav-link" href="{% url 'products:variantgroup-list' %}">Варианты</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Заказы</a>
|
<a class="nav-link" href="{% url 'orders:order-list' %}">Заказы</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'customers:customer-list' %}">Клиенты</a>
|
<a class="nav-link" href="{% url 'customers:customer-list' %}">Клиенты</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user