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:
2025-11-07 16:10:19 +03:00
parent a1dfb6a257
commit ec0557c8cf
15 changed files with 974 additions and 70 deletions

View File

@@ -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',)
}), }),
('Статус', { ('Статус', {

View File

@@ -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='Телефон получателя'),
),
]

View File

@@ -213,7 +213,14 @@ class Address(models.Model):
verbose_name="Имя получателя", verbose_name="Имя получателя",
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="Улица"
@@ -246,7 +253,13 @@ class Address(models.Model):
verbose_name="Инструкции для доставки", verbose_name="Инструкции для доставки",
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="Адрес по умолчанию",

View File

@@ -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', # История изменений
] ]

View File

@@ -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

View File

@@ -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):
""" """

View File

@@ -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'),
),
]

View File

@@ -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='Телефон получателя'),
),
]

View 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
View 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'),
]

View File

@@ -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)

View 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);

View File

@@ -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);
} }

View File

@@ -5,54 +5,97 @@ 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": "Товары",
"text": "Роза красная Freedom 50см (PROD-000001)", "children": [
"sku": "PROD-000001", {
"price": "150.00", "id": "product_1",
"in_stock": true "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": { "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"
return JsonResponse({ if '_' in item_id:
'results': [{ item_type, numeric_id = item_id.split('_', 1)
'id': product.id, numeric_id = int(numeric_id)
'text': f"{product.name} ({product.sku})" if product.sku else product.name, else:
'sku': product.sku, # Для обратной совместимости: если нет префикса, считаем что это product
'price': str(product.price) if product.price else None, item_type = 'product'
'actual_price': str(product.actual_price) if product.actual_price else '0', numeric_id = int(item_id)
'in_stock': product.in_stock,
'type': 'product' if item_type == 'product':
}], product = Product.objects.get(id=numeric_id, is_active=True)
'pagination': {'more': False} return JsonResponse({
}) 'results': [{
except (Product.DoesNotExist, ValueError): '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}}) 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)
# Поиск товаров (регистронезависимый поиск с приоритетом точных совпадений) # Поиск товаров и комплектов (регистронезависимый поиск с приоритетом точных совпадений)
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']: 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: удалить когда база данных будет 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
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']: 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}
}) })

View File

@@ -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>