- Устранён N+1 для статусов заказов: добавлен select_related('status')
- Расчёт total_debt перенесён на сторону БД через aggregate с Greatest/Coalesce
- Избежана загрузка всех активных заказов в память для подсчёта долга
- Количество активных заказов теперь считается через count() без загрузки данных
- Ожидаемый эффект: минус 10+ запросов на страницу, быстрее рендер при большом количестве заказов
567 lines
23 KiB
Python
567 lines
23 KiB
Python
from django.shortcuts import render, get_object_or_404, redirect
|
||
from django.contrib import messages
|
||
from django.core.paginator import Paginator
|
||
from django.core.exceptions import ValidationError, PermissionDenied
|
||
from django.db.models import Q, Sum, F, Value, DecimalField
|
||
from django.db.models.functions import Greatest, Coalesce
|
||
from django.http import JsonResponse
|
||
from django.views.decorators.http import require_http_methods
|
||
from django.contrib.auth.decorators import login_required
|
||
import phonenumbers
|
||
import json
|
||
from decimal import Decimal
|
||
from .models import Customer
|
||
from .forms import CustomerForm
|
||
|
||
|
||
def normalize_query_phone(q):
|
||
"""Normalize phone number for search"""
|
||
try:
|
||
parsed = phonenumbers.parse(q, "BY")
|
||
if phonenumbers.is_valid_number(parsed):
|
||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||
return q
|
||
except:
|
||
return q
|
||
|
||
|
||
def customer_list(request):
|
||
"""Список всех клиентов"""
|
||
query = request.GET.get('q')
|
||
# Исключаем системного клиента из списка
|
||
customers = Customer.objects.filter(is_system_customer=False)
|
||
|
||
if query:
|
||
# Используем ту же логику поиска, что и в AJAX API (api_search_customers)
|
||
# Это обеспечивает согласованность между веб-интерфейсом и API
|
||
|
||
# Нормализуем номер телефона
|
||
phone_normalized = normalize_query_phone(query)
|
||
|
||
# Определяем стратегию поиска
|
||
strategy, search_value = determine_search_strategy(query)
|
||
|
||
# Строим Q-объект для поиска (единая функция)
|
||
q_objects = build_customer_search_query(query, strategy, search_value)
|
||
|
||
# Добавляем поиск по телефону (умная логика)
|
||
if phone_normalized:
|
||
q_objects |= Q(phone__icontains=phone_normalized)
|
||
|
||
# Проверяем, похож ли query на номер телефона (только цифры и минимум 3 цифры)
|
||
query_digits = ''.join(c for c in query if c.isdigit())
|
||
should_search_by_phone_digits = is_query_phone_only(query) and len(query_digits) >= 3
|
||
|
||
if should_search_by_phone_digits:
|
||
# Ищем клиентов, чьи телефоны содержат введенные цифры
|
||
# Используем LIKE запрос вместо Python loop для оптимизации при большом количестве клиентов
|
||
customers_by_phone = Customer.objects.filter(
|
||
phone__isnull=False,
|
||
phone__icontains=query_digits # Простой поиск по цифрам в phone строке
|
||
)
|
||
|
||
if customers_by_phone.exists():
|
||
q_objects |= Q(pk__in=customers_by_phone.values_list('pk', flat=True))
|
||
|
||
customers = customers.filter(q_objects)
|
||
|
||
customers = customers.order_by('-created_at')
|
||
|
||
# Пагинация
|
||
paginator = Paginator(customers, 25) # 25 клиентов на страницу
|
||
page_number = request.GET.get('page')
|
||
page_obj = paginator.get_page(page_number)
|
||
|
||
context = {
|
||
'page_obj': page_obj,
|
||
'query': query,
|
||
}
|
||
return render(request, 'customers/customer_list.html', context)
|
||
|
||
|
||
def customer_detail(request, pk):
|
||
"""Детали клиента"""
|
||
customer = get_object_or_404(Customer, pk=pk)
|
||
|
||
# Для системного клиента показываем специальную заглушку
|
||
if customer.is_system_customer:
|
||
return render(request, 'customers/customer_system.html')
|
||
|
||
# Рассчитываем общий долг по активным заказам на стороне БД
|
||
total_debt_result = customer.orders.exclude(payment_status='paid').aggregate(
|
||
total_debt=Coalesce(
|
||
Sum(Greatest(F('total_amount') - F('amount_paid'), Value(0), output_field=DecimalField())),
|
||
Value(0),
|
||
output_field=DecimalField()
|
||
)
|
||
)
|
||
total_debt = total_debt_result['total_debt'] or Decimal('0')
|
||
|
||
# Количество активных заказов
|
||
active_orders_count = customer.orders.exclude(payment_status='paid').count()
|
||
|
||
# История транзакций кошелька (последние 20)
|
||
from .models import WalletTransaction
|
||
wallet_transactions = WalletTransaction.objects.filter(
|
||
customer=customer
|
||
).select_related('order', 'created_by').order_by('-created_at')[:20]
|
||
|
||
# История заказов с пагинацией и оптимизацией запросов
|
||
orders_list = customer.orders.select_related('status').order_by('-created_at')
|
||
paginator = Paginator(orders_list, 10) # 10 заказов на страницу
|
||
page_number = request.GET.get('page')
|
||
orders_page = paginator.get_page(page_number)
|
||
|
||
context = {
|
||
'customer': customer,
|
||
'total_debt': total_debt,
|
||
'active_orders_count': active_orders_count,
|
||
'wallet_transactions': wallet_transactions,
|
||
'orders_page': orders_page,
|
||
}
|
||
return render(request, 'customers/customer_detail.html', context)
|
||
|
||
|
||
def customer_create(request):
|
||
"""Создание нового клиента"""
|
||
if request.method == 'POST':
|
||
form = CustomerForm(request.POST)
|
||
if form.is_valid():
|
||
customer = form.save()
|
||
messages.success(request, f'Клиент {customer.full_name} успешно создан.')
|
||
return redirect('customers:customer-detail', pk=customer.pk)
|
||
else:
|
||
form = CustomerForm()
|
||
|
||
return render(request, 'customers/customer_form.html', {'form': form, 'is_creating': True})
|
||
|
||
|
||
def customer_update(request, pk):
|
||
"""Редактирование клиента"""
|
||
customer = get_object_or_404(Customer, pk=pk)
|
||
|
||
# Проверяем, не системный ли это клиент
|
||
if customer.is_system_customer:
|
||
messages.warning(request, 'Системный клиент не может быть изменен. Он создается автоматически и необходим для корректной работы системы.')
|
||
return redirect('customers:customer-detail', pk=pk)
|
||
|
||
if request.method == 'POST':
|
||
form = CustomerForm(request.POST, instance=customer)
|
||
if form.is_valid():
|
||
try:
|
||
form.save()
|
||
messages.success(request, f'Клиент {customer.full_name} успешно обновлён.')
|
||
return redirect('customers:customer-detail', pk=customer.pk)
|
||
except ValidationError as e:
|
||
messages.error(request, str(e))
|
||
else:
|
||
form = CustomerForm(instance=customer)
|
||
|
||
return render(request, 'customers/customer_form.html', {'form': form, 'is_creating': False})
|
||
|
||
|
||
def customer_delete(request, pk):
|
||
"""Удаление клиента"""
|
||
customer = get_object_or_404(Customer, pk=pk)
|
||
|
||
# Проверяем, не системный ли это клиент
|
||
if customer.is_system_customer:
|
||
messages.error(request, 'Невозможно удалить системного клиента. Он необходим для корректной работы системы.')
|
||
return redirect('customers:customer-detail', pk=pk)
|
||
|
||
if request.method == 'POST':
|
||
customer_name = customer.full_name
|
||
try:
|
||
customer.delete()
|
||
messages.success(request, f'Клиент {customer_name} успешно удален.')
|
||
return redirect('customers:customer-list')
|
||
except ValidationError as e:
|
||
messages.error(request, str(e))
|
||
return redirect('customers:customer-detail', pk=pk)
|
||
|
||
context = {
|
||
'customer': customer
|
||
}
|
||
return render(request, 'customers/customer_confirm_delete.html', context)
|
||
|
||
|
||
# === AJAX API ENDPOINTS ===
|
||
|
||
def determine_search_strategy(query):
|
||
"""
|
||
Определяет стратегию поиска на основе содержимого query.
|
||
|
||
На основе наличия и позиции символа @ определяет, по каким полям и как искать.
|
||
|
||
Возвращает tuple (strategy, search_value):
|
||
- 'name_only': поиск только по имени (для очень коротких запросов)
|
||
- 'email_prefix': поиск по началу email (query заканчивается на @)
|
||
- 'email_domain': поиск по домену (query начинается с @)
|
||
- 'email_full': полный поиск по email (query содержит обе части)
|
||
- 'universal': поиск везде (имя + email, для средних запросов)
|
||
|
||
Примеры:
|
||
- 'team_x3m@' → ('email_prefix', 'team_x3m')
|
||
- '@bk' → ('email_domain', 'bk')
|
||
- 'test@bk.ru' → ('email_full', 'test@bk.ru')
|
||
- 'natul' → ('universal', 'natul')
|
||
- 'te' → ('name_only', 'te')
|
||
"""
|
||
if '@' in query:
|
||
local, _, domain = query.partition('@')
|
||
|
||
if not local:
|
||
# Начинается с @: ищем по домену
|
||
return ('email_domain', domain)
|
||
|
||
if not domain:
|
||
# Заканчивается на @: ищем по префиксу email
|
||
return ('email_prefix', local)
|
||
|
||
# Есть и локальная часть, и домен: полный поиск
|
||
return ('email_full', query)
|
||
|
||
else:
|
||
# Нет @: определяем по длине
|
||
if len(query) < 2:
|
||
return ('name_only', query)
|
||
|
||
if len(query) >= 3:
|
||
return ('universal', query)
|
||
|
||
# 2 символа: только имя (слишком мало для поиска по email)
|
||
return ('name_only', query)
|
||
|
||
|
||
def is_query_phone_only(query):
|
||
"""
|
||
Проверяет, содержит ли query только символы телефонного номера.
|
||
|
||
Возвращает True, если query состоит ТОЛЬКО из:
|
||
- цифр: 0-9
|
||
- телефонных символов: +, -, (, ), пробелов
|
||
|
||
Возвращает False, если есть буквы или другие символы (означает, что это поиск по имени/email).
|
||
|
||
Примеры:
|
||
- '295' → True (только цифры)
|
||
- '+375291234567' → True (цифры и телефонные символы)
|
||
- '(029) 123-45' → True (цифры и телефонные символы)
|
||
- 'x3m' → False (содержит буквы)
|
||
- 'team_x3m' → False (содержит буквы)
|
||
- 'Иван' → False (содержит буквы)
|
||
"""
|
||
if not query:
|
||
return False
|
||
|
||
# Проверяем, что query содержит ТОЛЬКО цифры и телефонные символы
|
||
phone_chars = set('0123456789+- ().')
|
||
return all(c in phone_chars for c in query)
|
||
|
||
|
||
def build_customer_search_query(query, strategy, search_value):
|
||
"""
|
||
Строит Q-объект для поиска клиентов на основе стратегии.
|
||
|
||
Используется в customer_list() и api_search_customers() для единообразия.
|
||
|
||
Args:
|
||
query: Исходный поисковый запрос (для fallback)
|
||
strategy: Стратегия поиска (из determine_search_strategy)
|
||
search_value: Значение для поиска (из determine_search_strategy)
|
||
|
||
Returns:
|
||
Q-объект для фильтрации Customer.objects
|
||
"""
|
||
if strategy == 'name_only':
|
||
return Q(name__icontains=search_value)
|
||
|
||
elif strategy == 'email_prefix':
|
||
# Query вида "team_x3m@" — ищем email, начинающиеся с "team_x3m"
|
||
return Q(email__istartswith=search_value)
|
||
|
||
elif strategy == 'email_domain':
|
||
# Query вида "@bk" — ищем все email с доменом "@bk.*"
|
||
return Q(email__icontains=f'@{search_value}')
|
||
|
||
elif strategy == 'email_full':
|
||
# Query вида "test@bk.ru" — полный поиск по email
|
||
return Q(email__icontains=search_value)
|
||
|
||
elif strategy == 'universal':
|
||
# Query вида "natul" (3+ символов) — ищем везде
|
||
return Q(name__icontains=search_value) | Q(email__icontains=search_value)
|
||
|
||
else:
|
||
# На случай неизвестной стратегии (не должно быть)
|
||
return Q(name__icontains=query)
|
||
|
||
|
||
@require_http_methods(["GET"])
|
||
def api_search_customers(request):
|
||
"""
|
||
AJAX endpoint для поиска клиента по имени, телефону или email.
|
||
|
||
Параметры GET:
|
||
- q: поисковая строка
|
||
|
||
Возвращает JSON с результатами поиска:
|
||
{
|
||
"results": [
|
||
{"id": 1, "text": "Иван Петров (+375291234567)", "name": "Иван Петров", "phone": "+375291234567", "email": "ivan@example.com"},
|
||
...
|
||
],
|
||
"pagination": {"more": false}
|
||
}
|
||
|
||
Если ничего не найдено и заданы параметры поиска, возвращает:
|
||
{
|
||
"results": [
|
||
{"id": null, "text": "Создать клиента: 'Поиск'", "is_create_option": true, "search_text": "Поиск"}
|
||
],
|
||
"pagination": {"more": false}
|
||
}
|
||
"""
|
||
query = request.GET.get('q', '').strip()
|
||
|
||
if not query or len(query) < 1:
|
||
return JsonResponse({
|
||
'results': [],
|
||
'pagination': {'more': False}
|
||
})
|
||
|
||
# Пытаемся нормализовать номер телефона для поиска
|
||
phone_normalized = normalize_query_phone(query)
|
||
|
||
# Для поиска по телефону: извлекаем только цифры и ищем по ним
|
||
# Это позволит найти клиента независимо от формата ввода
|
||
query_digits = ''.join(c for c in query if c.isdigit())
|
||
|
||
# Ищем по имени, email или телефону
|
||
# Используем Q-объекты для OR условий
|
||
|
||
# Определяем стратегию поиска на основе содержимого query
|
||
strategy, search_value = determine_search_strategy(query)
|
||
|
||
# Строим Q-объект для поиска (единая функция, используется везде)
|
||
q_objects = build_customer_search_query(query, strategy, search_value)
|
||
|
||
# Для поиска по номерам телефонов: применяем умную логику
|
||
# Ищем по телефону ТОЛЬКО если query состоит из ТОЛЬКО цифр и телефонных символов
|
||
# (никаких букв — потому что если есть буквы, это явно поиск по имени/email, не по телефону)
|
||
|
||
if phone_normalized:
|
||
q_objects |= Q(phone__icontains=phone_normalized)
|
||
|
||
# Проверяем, похож ли query на номер телефона (только цифры/+/-/() и минимум 3 цифры)
|
||
should_search_by_phone_digits = is_query_phone_only(query) and len(query_digits) >= 3
|
||
|
||
if should_search_by_phone_digits:
|
||
# Ищем клиентов, чьи телефоны содержат введенные цифры
|
||
# Используем LIKE запрос вместо Python loop для оптимизации при большом количестве клиентов
|
||
# Номер телефона в E.164 формате: +375291234567
|
||
# Мы ищем по цифрам, поэтому можно просто использовать LIKE с цифрами
|
||
customers_by_phone = Customer.objects.filter(
|
||
phone__isnull=False,
|
||
phone__icontains=query_digits # Простой поиск по цифрам в phone строке
|
||
)
|
||
|
||
if customers_by_phone.exists():
|
||
q_objects |= Q(pk__in=customers_by_phone.values_list('pk', flat=True))
|
||
|
||
# Исключаем системного клиента из результатов поиска
|
||
customers = Customer.objects.filter(q_objects).filter(is_system_customer=False).distinct().order_by('name')[:20]
|
||
|
||
results = []
|
||
|
||
# Добавляем найденные клиентов
|
||
for customer in customers:
|
||
phone_display = str(customer.phone) if customer.phone else ''
|
||
text = customer.name
|
||
if phone_display:
|
||
text += f' ({phone_display})'
|
||
|
||
results.append({
|
||
'id': customer.pk,
|
||
'text': text,
|
||
'name': customer.name,
|
||
'phone': phone_display,
|
||
'email': customer.email,
|
||
})
|
||
|
||
# Если ничего не найдено, предлагаем создать нового клиента
|
||
if not results:
|
||
results.append({
|
||
'id': '__create_new__', # Специальный ID для опции создания
|
||
'text': f'Создать клиента: "{query}"',
|
||
'is_create_option': True,
|
||
'search_text': query,
|
||
})
|
||
|
||
return JsonResponse({
|
||
'results': results,
|
||
'pagination': {'more': False}
|
||
})
|
||
|
||
|
||
@require_http_methods(["POST"])
|
||
def api_create_customer(request):
|
||
"""
|
||
AJAX endpoint для создания нового клиента.
|
||
|
||
Принимает POST JSON:
|
||
{
|
||
"name": "Иван Петров",
|
||
"phone": "+375291234567",
|
||
"email": "ivan@example.com"
|
||
}
|
||
|
||
Возвращает JSON:
|
||
{
|
||
"success": true,
|
||
"id": 123,
|
||
"name": "Иван Петров",
|
||
"phone": "+375291234567",
|
||
"email": "ivan@example.com"
|
||
}
|
||
|
||
При ошибке:
|
||
{
|
||
"success": false,
|
||
"error": "Клиент с таким номером телефона уже существует"
|
||
}
|
||
"""
|
||
try:
|
||
data = json.loads(request.body)
|
||
|
||
# Нормализуем данные
|
||
name = data.get('name', '').strip() if data.get('name') else ''
|
||
phone = data.get('phone', '').strip() if data.get('phone') else ''
|
||
email = data.get('email', '').strip() if data.get('email') else ''
|
||
|
||
# Подготавливаем данные для формы
|
||
form_data = {
|
||
'name': name,
|
||
'phone': phone if phone else None,
|
||
'email': email if email else None,
|
||
}
|
||
|
||
# Используем форму для валидации и создания
|
||
form = CustomerForm(data=form_data)
|
||
|
||
if form.is_valid():
|
||
# Сохраняем клиента через форму (автоматически вызывает все валидации)
|
||
customer = form.save()
|
||
|
||
phone_display = str(customer.phone) if customer.phone else ''
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'id': customer.pk,
|
||
'name': customer.name,
|
||
'phone': phone_display,
|
||
'email': customer.email if customer.email else '',
|
||
}, status=201)
|
||
else:
|
||
# Собираем ошибки валидации с указанием полей
|
||
errors = []
|
||
field_labels = {
|
||
'name': 'Имя клиента',
|
||
'phone': 'Телефон',
|
||
'email': 'Email',
|
||
}
|
||
|
||
for field, field_errors in form.errors.items():
|
||
field_label = field_labels.get(field, field)
|
||
for error in field_errors:
|
||
errors.append(f'{field_label}: {error}')
|
||
|
||
# Возвращаем все ошибки
|
||
error_message = '<br>'.join(errors) if errors else 'Ошибка валидации данных'
|
||
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': error_message,
|
||
'errors': form.errors # Добавляем детальную информацию об ошибках
|
||
}, status=400)
|
||
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Некорректный JSON'
|
||
}, status=400)
|
||
except Exception as e:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': f'Ошибка сервера: {str(e)}'
|
||
}, status=500)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def wallet_deposit(request, pk):
|
||
"""Пополнение кошелька клиента"""
|
||
if not request.user.is_staff:
|
||
raise PermissionDenied("У вас нет прав для изменения баланса кошелька клиента.")
|
||
|
||
customer = get_object_or_404(Customer, pk=pk)
|
||
|
||
if customer.is_system_customer:
|
||
messages.error(request, 'Операции с кошельком недоступны для системного клиента.')
|
||
return redirect('customers:customer-detail', pk=pk)
|
||
|
||
amount_str = request.POST.get('amount') or ''
|
||
description = (request.POST.get('description') or '').strip()
|
||
|
||
try:
|
||
amount = Decimal(amount_str.replace(',', '.'))
|
||
except Exception:
|
||
messages.error(request, 'Некорректное значение суммы для пополнения.')
|
||
return redirect('customers:customer-detail', pk=pk)
|
||
|
||
try:
|
||
customer.adjust_wallet(amount, description, request.user)
|
||
messages.success(request, f'Кошелёк клиента пополнен на {amount:.2f} руб.')
|
||
except ValueError as e:
|
||
messages.error(request, str(e))
|
||
except ValidationError as e:
|
||
messages.error(request, '; '.join(e.messages) if hasattr(e, 'messages') else str(e))
|
||
|
||
return redirect('customers:customer-detail', pk=pk)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def wallet_withdraw(request, pk):
|
||
"""Возврат / списание с кошелька клиента"""
|
||
if not request.user.is_staff:
|
||
raise PermissionDenied("У вас нет прав для изменения баланса кошелька клиента.")
|
||
|
||
customer = get_object_or_404(Customer, pk=pk)
|
||
|
||
if customer.is_system_customer:
|
||
messages.error(request, 'Операции с кошельком недоступны для системного клиента.')
|
||
return redirect('customers:customer-detail', pk=pk)
|
||
|
||
amount_str = request.POST.get('amount') or ''
|
||
description = (request.POST.get('description') or '').strip()
|
||
|
||
try:
|
||
amount = Decimal(amount_str.replace(',', '.'))
|
||
except Exception:
|
||
messages.error(request, 'Некорректное значение суммы для списания.')
|
||
return redirect('customers:customer-detail', pk=pk)
|
||
|
||
# Для списания делаем сумму отрицательной
|
||
withdraw_amount = -amount
|
||
|
||
try:
|
||
customer.adjust_wallet(withdraw_amount, description, request.user)
|
||
messages.success(request, f'С кошелька клиента списано {amount:.2f} руб.')
|
||
except ValueError as e:
|
||
messages.error(request, str(e))
|
||
except ValidationError as e:
|
||
messages.error(request, '; '.join(e.messages) if hasattr(e, 'messages') else str(e))
|
||
|
||
return redirect('customers:customer-detail', pk=pk)
|