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

@@ -1,6 +1,16 @@
# -*- coding: utf-8 -*-
from django.contrib import admin
from .models import Order, OrderItem
from .models import Order, OrderItem, Payment
class PaymentInline(admin.TabularInline):
"""
Inline для управления платежами по заказу.
"""
model = Payment
extra = 1
fields = ['amount', 'payment_method', 'payment_date', 'created_by', 'notes']
readonly_fields = ['payment_date']
class OrderItemInline(admin.TabularInline):
@@ -27,18 +37,19 @@ class OrderAdmin(admin.ModelAdmin):
list_display = [
'order_number',
'customer',
'delivery_type',
'is_delivery',
'delivery_date',
'status',
'total_amount',
'is_paid',
'payment_status',
'amount_paid',
'created_at',
]
list_filter = [
'status',
'delivery_type',
'is_paid',
'is_delivery',
'payment_status',
'delivery_date',
'created_at',
]
@@ -58,6 +69,8 @@ class OrderAdmin(admin.ModelAdmin):
'updated_at',
'delivery_info',
'delivery_time_window',
'amount_due',
'payment_status',
]
fieldsets = (
@@ -66,7 +79,8 @@ class OrderAdmin(admin.ModelAdmin):
}),
('Доставка', {
'fields': (
'delivery_type',
'is_delivery',
'customer_is_recipient',
'delivery_address',
'pickup_shop',
'delivery_date',
@@ -78,19 +92,26 @@ class OrderAdmin(admin.ModelAdmin):
)
}),
('Оплата', {
'fields': ('payment_method', 'is_paid', 'total_amount')
'fields': (
'payment_method',
'total_amount',
'discount_amount',
'amount_paid',
'amount_due',
'payment_status',
)
}),
('Дополнительно', {
'fields': ('is_anonymous', 'special_instructions'),
'classes': ('collapse',)
}),
('Системная информация', {
'fields': ('created_at', 'updated_at'),
'fields': ('created_at', 'updated_at', 'modified_by'),
'classes': ('collapse',)
}),
)
inlines = [OrderItemInline]
inlines = [OrderItemInline, PaymentInline]
actions = [
'mark_as_confirmed',
@@ -131,6 +152,41 @@ class OrderAdmin(admin.ModelAdmin):
mark_as_paid.short_description = 'Отметить как оплаченные'
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
"""
Админ-панель для управления платежами.
"""
list_display = [
'order',
'amount',
'payment_method',
'payment_date',
'created_by',
]
list_filter = [
'payment_method',
'payment_date',
]
search_fields = [
'order__order_number',
'notes',
]
readonly_fields = ['payment_date']
fieldsets = (
('Информация о платеже', {
'fields': ('order', 'amount', 'payment_method', 'payment_date')
}),
('Дополнительно', {
'fields': ('created_by', 'notes')
}),
)
@admin.register(OrderItem)
class OrderItemAdmin(admin.ModelAdmin):
"""

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)