Добавлена система фильтрации клиентов с универсальным поиском

- Реализован универсальный поиск по имени, email и телефону в одной строке
- Добавлен счетчик общего количества клиентов
- Поиск работает по нажатию Enter или кнопке 'Поиск'
- Удалены неиспользуемые фильтры django-filter
- Упрощен интерфейс списка клиентов
- Добавлена кнопка 'Очистить' для сброса поиска
This commit is contained in:
2025-12-14 22:39:32 +03:00
parent 089ccfa8ae
commit 56725e8092
9 changed files with 716 additions and 131 deletions

View File

@@ -0,0 +1,67 @@
"""
Анализ проблемных строк в XLSX файле для импорта.
Показывает первые 30 строк с проблемными телефонами.
"""
from django.core.management.base import BaseCommand
import os
try:
from openpyxl import load_workbook
except ImportError:
load_workbook = None
class Command(BaseCommand):
help = 'Анализ проблемных данных в файле импорта'
def add_arguments(self, parser):
parser.add_argument('file_path', type=str, help='Путь к файлу для анализа')
def handle(self, *args, **options):
file_path = options['file_path']
if not os.path.exists(file_path):
self.stdout.write(self.style.ERROR(f'Файл не найден: {file_path}'))
return
if load_workbook is None:
self.stdout.write(self.style.ERROR('Установите openpyxl'))
return
wb = load_workbook(file_path, read_only=True, data_only=True)
ws = wb.active
headers = []
rows_data = []
first_row = True
for idx, row in enumerate(ws.iter_rows(values_only=True), start=1):
if first_row:
headers = [str(v).strip() if v is not None else "" for v in row]
self.stdout.write(f"Заголовки: {headers}\n")
first_row = False
continue
if not any(row):
continue
row_dict = {}
for col_idx, value in enumerate(row):
if col_idx < len(headers):
header = headers[col_idx]
row_dict[header] = value
rows_data.append((idx, row_dict))
# Показываем первые 30 строк
self.stdout.write(self.style.SUCCESS(f"\nПервые 30 строк данных:\n"))
self.stdout.write("=" * 100)
for row_num, data in rows_data[:30]:
self.stdout.write(f"\n[Строка {row_num}]")
for key, val in data.items():
if val:
self.stdout.write(f" {key}: {val}")
self.stdout.write("-" * 100)
self.stdout.write(f"\n\nВсего строк с данными: {len(rows_data)}")

View File

@@ -0,0 +1,158 @@
"""
Management-команда для тестового импорта клиентов из XLSX/CSV файлов.
Использование:
python manage.py test_import путь/к/файлу.xlsx --schema=anatol [--update] [--export-errors]
Примеры:
python manage.py test_import ../customers_mixflowers.by_2025-12-14_20-35-36.xlsx --schema=anatol
python manage.py test_import ../customers.csv --schema=anatol --update
python manage.py test_import ../file.xlsx --schema=anatol --export-errors
"""
from django.core.management.base import BaseCommand
from django.core.files import File
from django_tenants.utils import schema_context
from customers.services.import_export import CustomerImporter
import os
try:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
except ImportError:
Workbook = None
class Command(BaseCommand):
help = 'Тестовый импорт клиентов из XLSX/CSV файла'
def add_arguments(self, parser):
parser.add_argument('file_path', type=str, help='Путь к файлу для импорта')
parser.add_argument(
'--schema',
type=str,
required=True,
help='Имя схемы БД тенанта (пример: anatol)'
)
parser.add_argument(
'--update',
action='store_true',
help='Обновлять существующих клиентов (по email/телефону)',
)
parser.add_argument(
'--export-errors',
action='store_true',
help='Экспортировать все проблемные строки в отдельный XLSX файл',
)
def handle(self, *args, **options):
file_path = options['file_path']
schema_name = options['schema']
update_existing = options['update']
export_errors = options.get('export_errors', False)
if not os.path.exists(file_path):
self.stdout.write(self.style.ERROR(f'Файл не найден: {file_path}'))
return
self.stdout.write(f'Импорт из файла: {file_path}')
self.stdout.write(f'Схема тенанта: {schema_name}')
self.stdout.write(f'Режим обновления: {"ВКЛ" if update_existing else "ВЫКЛ"}')
self.stdout.write('-' * 60)
# Выполняем импорт в контексте схемы тенанта
with schema_context(schema_name):
importer = CustomerImporter()
with open(file_path, 'rb') as f:
# Создаём простой объект-обёртку для файла
class FakeUploadedFile:
def __init__(self, file_obj, name):
self.file = file_obj
self.name = name
def __getattr__(self, attr):
# Делегируем все остальные методы внутреннему файловому объекту
return getattr(self.file, attr)
fake_file = FakeUploadedFile(f, os.path.basename(file_path))
result = importer.import_from_file(fake_file, update_existing=update_existing)
self.stdout.write(self.style.SUCCESS(f"\n{result['message']}"))
self.stdout.write('-' * 60)
self.stdout.write(f"Создано: {result['created']}")
self.stdout.write(f"Обновлено: {result['updated']}")
self.stdout.write(f"Пропущено: {result['skipped']}")
self.stdout.write(f"Ошибок: {len(result['errors'])}")
if result['errors']:
self.stdout.write('\n' + self.style.WARNING('ОШИБКИ:'))
# Показываем первые 20 ошибок, остальные — просто счётчик
for idx, error in enumerate(result['errors'][:20], 1):
row = error.get('row', '?')
email = error.get('email', '')
phone = error.get('phone', '')
reason = error.get('reason', '')
self.stdout.write(
f" [{idx}] Строка {row}: {email or phone or '(пусто)'} - {reason}"
)
if len(result['errors']) > 20:
self.stdout.write(f" ... и ещё {len(result['errors']) - 20} ошибок")
# Экспорт ошибок в XLSX
if export_errors:
self._export_errors_to_xlsx(file_path, result['real_errors'])
def _export_errors_to_xlsx(self, original_file_path, errors):
"""
Экспортирует все проблемные строки в отдельный XLSX файл.
"""
if Workbook is None:
self.stdout.write(self.style.ERROR('\nНевозможно экспортировать ошибки: openpyxl не установлен'))
return
# Формируем имя файла для ошибок
base_name = os.path.splitext(os.path.basename(original_file_path))[0]
error_file = f"{base_name}_ERRORS.xlsx"
error_path = os.path.join(os.path.dirname(original_file_path) or '.', error_file)
# Создаём новую книгу Excel
wb = Workbook()
ws = wb.active
ws.title = "Ошибки импорта"
# Заголовки с форматированием
headers = ['Строка', 'Email', 'Телефон', 'Причина ошибки']
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
cell.alignment = Alignment(horizontal="center", vertical="center")
# Данные об ошибках
for idx, error in enumerate(errors, 2):
ws.cell(row=idx, column=1, value=error.get('row', ''))
ws.cell(row=idx, column=2, value=error.get('email', ''))
ws.cell(row=idx, column=3, value=error.get('phone', ''))
ws.cell(row=idx, column=4, value=error.get('reason', ''))
# Автоподбор ширины колонок
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value:
max_length = max(max_length, len(str(cell.value)))
except:
pass
adjusted_width = min(max_length + 2, 80)
ws.column_dimensions[column_letter].width = adjusted_width
# Сохраняем файл
try:
wb.save(error_path)
self.stdout.write(self.style.SUCCESS(f"\n✓ Файл с ошибками сохранён: {error_path}"))
self.stdout.write(f" Всего строк с ошибками: {len(errors)}")
except Exception as e:
self.stdout.write(self.style.ERROR(f"\n✗ Ошибка при сохранении файла: {e}"))

View File

@@ -5,10 +5,24 @@
Разделение на отдельный модуль улучшает организацию кода и следует принципам SRP. Разделение на отдельный модуль улучшает организацию кода и следует принципам SRP.
""" """
import csv import csv
import io
from django.http import HttpResponse from django.http import HttpResponse
from django.utils import timezone from django.utils import timezone
from ..models import Customer from ..models import Customer
try:
# Для чтения .xlsx файлов
from openpyxl import load_workbook
except ImportError:
load_workbook = None
try:
import phonenumbers
from phonenumbers import NumberParseException
except ImportError:
phonenumbers = None
NumberParseException = Exception
class CustomerExporter: class CustomerExporter:
""" """
@@ -68,39 +82,414 @@ class CustomerExporter:
class CustomerImporter: class CustomerImporter:
""" """
Класс для импорта клиентов из различных форматов. Простой универсальный импорт клиентов из CSV/XLSX.
TODO: Реализовать: Поддерживаемые форматы:
- Парсинг CSV файлов - CSV (UTF-8, заголовок в первой строке)
- Парсинг Excel файлов (.xlsx, .xls) - XLSX (первая строка — заголовки)
- Валидация данных (email, телефон)
- Обработка дубликатов Алгоритм:
- Пакетное создание клиентов - Читаем файл → получаем headers и list[dict] строк
- Отчёт об ошибках - По headers строим маппинг на поля Customer: name/email/phone/notes
- Для каждой строки:
- пропускаем полностью пустые строки
- ищем клиента по email, потом по телефону
- если update_existing=False и клиент найден → пропуск
- если update_existing=True → обновляем найденного
- если не найден → создаём нового
- Вся валидация/нормализация делается через Customer.full_clean()
""" """
FIELD_ALIASES = {
"name": ["name", "имя", "фио", "клиент", "фамилияимя"],
"email": ["email", "e-mail", "e_mail", "почта", "элпочта", "электроннаяпочта"],
"phone": ["phone", "телефон", "тел", "моб", "мобильный", "номер"],
"notes": ["notes", "заметки", "комментарий", "comment", "примечание"],
}
def __init__(self): def __init__(self):
self.errors = [] self.errors = []
self.success_count = 0 self.success_count = 0
self.update_count = 0
self.skip_count = 0 self.skip_count = 0
# Отслеживание уже обработанных email/phone для дедупликации внутри файла
def import_from_file(self, file, update_existing=False): self.processed_emails = set()
self.processed_phones = set()
# Отдельный список для реальных ошибок (не дублей из БД)
self.real_errors = []
def import_from_file(self, file, update_existing: bool = False) -> dict:
""" """
Импорт клиентов из загруженного файла. Импорт клиентов из загруженного файла.
Args: Args:
file: Загруженный файл (UploadedFile) file: UploadedFile
update_existing: Обновлять ли существующих клиентов (по email/телефону) update_existing: обновлять ли существующих клиентов (по email/телефону)
Returns:
dict: результат импорта
"""
file_format = self._detect_format(file)
if file_format is None:
return {
"success": False,
"message": "Неподдерживаемый формат файла. Ожидается CSV или XLSX.",
"created": 0,
"updated": 0,
"skipped": 0,
"errors": [{"row": None, "reason": "Unsupported file type"}],
}
if file_format == "xlsx" and load_workbook is None:
return {
"success": False,
"message": "Для импорта XLSX необходим пакет openpyxl. Установите его и повторите попытку.",
"created": 0,
"updated": 0,
"skipped": 0,
"errors": [{"row": None, "reason": "openpyxl is not installed"}],
}
try:
if file_format == "csv":
headers, rows = self._read_csv(file)
else:
headers, rows = self._read_xlsx(file)
except Exception as exc:
return {
"success": False,
"message": f"Ошибка чтения файла: {exc}",
"created": 0,
"updated": 0,
"skipped": 0,
"errors": [{"row": None, "reason": str(exc)}],
}
if not headers:
return {
"success": False,
"message": "В файле не найдены заголовки.",
"created": 0,
"updated": 0,
"skipped": 0,
"errors": [{"row": None, "reason": "Empty header row"}],
}
mapping = self._build_mapping(headers)
if not any(field in mapping for field in ("name", "email", "phone")):
return {
"success": False,
"message": "Не удалось сопоставить обязательные поля (имя, email или телефон).",
"created": 0,
"updated": 0,
"skipped": len(rows),
"errors": [
{
"row": None,
"reason": "No required fields (name/email/phone) mapped from headers",
}
],
}
for index, row in enumerate(rows, start=2): # первая строка — заголовки
self._process_row(index, row, mapping, update_existing)
total_errors = len(self.errors)
success = (self.success_count + self.update_count) > 0
if success and total_errors == 0:
message = "Импорт завершён успешно."
elif success and total_errors > 0:
message = "Импорт завершён с ошибками."
else:
message = "Не удалось импортировать данные."
return {
"success": success,
"message": message,
"created": self.success_count,
"updated": self.update_count,
"skipped": self.skip_count,
"errors": self.errors,
"real_errors": self.real_errors, # Только невалидные данные, без дублей из БД
}
def _detect_format(self, file) -> str | None:
name = (getattr(file, "name", None) or "").lower()
if name.endswith(".csv"):
return "csv"
if name.endswith(".xlsx") or name.endswith(".xls"):
return "xlsx"
return None
def _read_csv(self, file):
file.seek(0)
raw = file.read()
if isinstance(raw, bytes):
text = raw.decode("utf-8-sig")
else:
text = raw
f = io.StringIO(text)
reader = csv.DictReader(f)
headers = reader.fieldnames or []
rows = list(reader)
return headers, rows
def _read_xlsx(self, file):
file.seek(0)
wb = load_workbook(file, read_only=True, data_only=True)
ws = wb.active
headers = []
rows = []
first_row = True
for row in ws.iter_rows(values_only=True):
if first_row:
headers = [str(v).strip() if v is not None else "" for v in row]
first_row = False
continue
if not any(row):
continue
row_dict = {}
for idx, value in enumerate(row):
if idx < len(headers):
header = headers[idx] or f"col_{idx}"
row_dict[header] = value
rows.append(row_dict)
return headers, rows
def _normalize_header(self, header: str) -> str:
if header is None:
return ""
cleaned = "".join(ch for ch in str(header).strip().lower() if ch.isalnum())
return cleaned
def _build_mapping(self, headers):
mapping = {}
normalized_aliases = {
field: {self._normalize_header(a) for a in aliases}
for field, aliases in self.FIELD_ALIASES.items()
}
for header in headers:
norm = self._normalize_header(header)
if not norm:
continue
for field, alias_set in normalized_aliases.items():
if norm in alias_set and field not in mapping:
mapping[field] = header
break
return mapping
def _clean_value(self, value):
if value is None:
return ""
return str(value).strip()
def _normalize_phone(self, raw_phone: str) -> str | None:
"""
Умная нормализация телефона с попыткой различных регионов.
Стратегии:
1. Если начинается с '+' — парсим как международный
2. Если начинается с '8' и 11 цифр — пробуем BY, потом RU
3. Пробуем распространённые регионы: BY, RU, PL, DE, US
4. Если всё не удалось — возвращаем None
Returns: Returns:
dict: Результат импорта с статистикой Нормализованный телефон в E.164 формате или None
""" """
# TODO: Реализовать логику импорта if not raw_phone or not phonenumbers:
return { return None
'success': False,
'message': 'Функция импорта находится в разработке', # Убираем все символы кроме цифр и +
'created': 0, cleaned = ''.join(c for c in raw_phone if c.isdigit() or c == '+')
'updated': 0,
'skipped': 0, if not cleaned:
'errors': [] return None
}
# Стратегия 1: Международный формат (начинается с +)
if cleaned.startswith('+'):
try:
parsed = phonenumbers.parse(cleaned, None)
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except NumberParseException:
pass
# Стратегия 2: Формат '8XXXXXXXXXX' (11 цифр)
if cleaned.startswith('8') and len(cleaned) == 11:
for region in ['BY', 'RU']:
try:
parsed = phonenumbers.parse(cleaned, region)
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except NumberParseException:
continue
# Стратегия 3: Пробуем распространённые регионы
for region in ['BY', 'RU', 'PL', 'DE', 'US']:
try:
# Добавляем '+' если его нет
test_number = cleaned if cleaned.startswith('+') else f'+{cleaned}'
parsed = phonenumbers.parse(test_number, region)
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except NumberParseException:
continue
# Пробуем без '+'
try:
parsed = phonenumbers.parse(cleaned, region)
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except NumberParseException:
continue
return None
def _process_row(self, row_number: int, row: dict, mapping: dict, update_existing: bool):
name = self._clean_value(row.get(mapping.get("name", ""), ""))
email = self._clean_value(row.get(mapping.get("email", ""), ""))
phone_raw = self._clean_value(row.get(mapping.get("phone", ""), ""))
notes = self._clean_value(row.get(mapping.get("notes", ""), ""))
if not any([name, email, phone_raw, notes]):
self.skip_count += 1
return
# Нормализуем email
if email:
email = email.lower().strip()
# Проверка на дубли внутри файла
if email in self.processed_emails:
self.skip_count += 1
self.errors.append(
{
"row": row_number,
"email": email,
"phone": phone_raw or None,
"reason": "Дубль email внутри файла (уже обработан в предыдущей строке).",
"is_duplicate": True, # Дубликат внутри файла
}
)
return
# Умная нормализация телефона
phone = None
phone_normalization_failed = False
if phone_raw:
phone = self._normalize_phone(phone_raw)
if not phone:
phone_normalization_failed = True
else:
# Проверка на дубли внутри файла
if phone in self.processed_phones:
self.skip_count += 1
self.errors.append(
{
"row": row_number,
"email": email or None,
"phone": phone_raw,
"reason": "Дубль телефона внутри файла (уже обработан в предыдущей строке).",
"is_duplicate": True, # Дубликат внутри файла
}
)
return
# Если телефон не удалось нормализовать, сохраняем в notes
if phone_normalization_failed:
note_addition = f"Исходный телефон из импорта (невалидный): {phone_raw}"
if notes:
notes = f"{notes}\n{note_addition}"
else:
notes = note_addition
# Пытаемся найти существующего клиента
existing = None
if email:
existing = Customer.objects.filter(email=email).first()
if existing is None and phone:
existing = Customer.objects.filter(phone=phone).first()
if existing and not update_existing:
self.skip_count += 1
self.errors.append(
{
"row": row_number,
"email": email or None,
"phone": phone_raw or None,
"reason": "Клиент с таким email/телефоном уже существует, обновление отключено.",
"is_duplicate": True, # Помечаем как дубликат из БД
}
)
return
if existing and update_existing:
if name:
existing.name = name
if email:
existing.email = email
if phone:
existing.phone = phone
if notes:
if existing.notes:
existing.notes = f"{existing.notes}\n{notes}"
else:
existing.notes = notes
try:
existing.full_clean()
existing.save()
self.update_count += 1
if email:
self.processed_emails.add(email)
if phone:
self.processed_phones.add(phone)
except Exception as exc:
self.skip_count += 1
error_record = {
"row": row_number,
"email": email or None,
"phone": phone_raw or None,
"reason": str(exc),
"is_duplicate": False,
}
self.errors.append(error_record)
self.real_errors.append(error_record) # Реальная ошибка валидации
return
# Создание нового клиента
customer = Customer(
name=name or "",
email=email or None,
phone=phone or None, # Если не удалось нормализовать — будет None
notes=notes or "",
)
try:
customer.full_clean()
customer.save()
self.success_count += 1
if email:
self.processed_emails.add(email)
if phone:
self.processed_phones.add(phone)
except Exception as exc:
self.skip_count += 1
error_record = {
"row": row_number,
"email": email or None,
"phone": phone_raw or None,
"reason": str(exc),
"is_duplicate": False,
}
self.errors.append(error_record)
self.real_errors.append(error_record) # Реальная ошибка валидации

View File

@@ -8,7 +8,12 @@
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h1>Клиенты</h1> <div>
<h1>Клиенты</h1>
<p class="text-muted mb-0">
Всего клиентов: <strong>{{ total_customers }}</strong>
</p>
</div>
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<a href="{% url 'customers:customer-import' %}" class="btn btn-outline-success"> <a href="{% url 'customers:customer-import' %}" class="btn btn-outline-success">
<i class="bi bi-upload"></i> Импорт <i class="bi bi-upload"></i> Импорт
@@ -22,99 +27,66 @@
</div> </div>
</div> </div>
<!-- Search Form --> <!-- Поиск -->
<div class="card mb-4"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<form method="get" class="row g-3" id="search-form"> <form method="get" class="row g-2">
<div class="col-md-6"> <div class="col-md-8">
<input type="text" class="form-control" name="q" <input type="text" class="form-control" name="q" value="{{ query }}"
value="{{ query|default:'' }}" placeholder="Поиск по имени, email или телефону (минимум 3 символа)..." id="search-input"> placeholder="Поиск по имени, email или телефону..."
<small class="form-text text-muted" id="search-hint" style="display: none; color: #dc3545 !important;"> autofocus>
Введите минимум 3 символа для поиска
</small>
</div> </div>
<div class="col-md-3"> <div class="col-md-4">
<button type="submit" class="btn btn-outline-primary" id="search-btn">Поиск</button> <div class="btn-group w-100" role="group">
{% if query %} <button type="submit" class="btn btn-primary">
<a href="{% url 'customers:customer-list' %}" class="btn btn-outline-secondary">Очистить</a> <i class="bi bi-search"></i> Поиск
{% endif %} </button>
{% if query %}
<a href="{% url 'customers:customer-list' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Очистить
</a>
{% endif %}
</div>
</div> </div>
</form> </form>
<script>
document.getElementById('search-form').addEventListener('submit', function(e) {
const searchInput = document.getElementById('search-input');
const searchValue = searchInput.value.trim();
const searchHint = document.getElementById('search-hint');
// Если поле пусто или содержит менее 3 символов, не отправляем форму
if (searchValue && searchValue.length < 3) {
e.preventDefault();
searchHint.style.display = 'block';
searchInput.classList.add('is-invalid');
return false;
}
// Если поле пусто, тоже не отправляем (это будет просто пусто)
if (!searchValue) {
e.preventDefault();
return false;
}
// Все хорошо, отправляем
searchHint.style.display = 'none';
searchInput.classList.remove('is-invalid');
});
// Убираем ошибку при вводе
document.getElementById('search-input').addEventListener('input', function() {
const searchValue = this.value.trim();
const searchHint = document.getElementById('search-hint');
if (searchValue.length >= 3) {
searchHint.style.display = 'none';
this.classList.remove('is-invalid');
}
});
</script>
</div> </div>
</div> </div>
<!-- Customers Table --> <!-- Таблица клиентов -->
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
{% if page_obj %} {% if page_obj %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th>Имя</th> <th>Имя</th>
<th>Email</th> <th>Email</th>
<th>Телефон</th> <th>Телефон</th>
<th class="text-end">Действия</th> <th class="text-end">Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for customer in page_obj %} {% for customer in page_obj %}
<tr <tr
style="cursor:pointer" style="cursor:pointer"
onclick="window.location='{% url 'customers:customer-detail' customer.pk %}'" onclick="window.location='{% url 'customers:customer-detail' customer.pk %}'"
> >
<td class="fw-semibold">{{ customer.full_name }}</td> <td class="fw-semibold">{{ customer.full_name }}</td>
<td>{{ customer.email|default:'—' }}</td> <td>{{ customer.email|default:'—' }}</td>
<td>{{ customer.phone|default:'—' }}</td> <td>{{ customer.phone|default:'—' }}</td>
<td class="text-end" onclick="event.stopPropagation();"> <td class="text-end" onclick="event.stopPropagation();">
<a href="{% url 'customers:customer-detail' customer.pk %}" <a href="{% url 'customers:customer-detail' customer.pk %}"
class="btn btn-sm btn-outline-primary">👁</a> class="btn btn-sm btn-outline-primary">👁</a>
<a href="{% url 'customers:customer-update' customer.pk %}" <a href="{% url 'customers:customer-update' customer.pk %}"
class="btn btn-sm btn-outline-secondary"></a> class="btn btn-sm btn-outline-secondary"></a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
{% if page_obj.has_other_pages %} {% if page_obj.has_other_pages %}

View File

@@ -28,54 +28,53 @@ def normalize_query_phone(q):
def customer_list(request): def customer_list(request):
"""Список всех клиентов""" """Список всех клиентов"""
query = request.GET.get('q') query = request.GET.get('q', '').strip()
# Исключаем системного клиента из списка # Исключаем системного клиента из списка
customers = Customer.objects.filter(is_system_customer=False) customers = Customer.objects.filter(is_system_customer=False)
# Общее количество клиентов
total_customers = customers.count()
if query: if query:
# Используем ту же логику поиска, что и в AJAX API (api_search_customers)
# Это обеспечивает согласованность между веб-интерфейсом и API
# Нормализуем номер телефона # Нормализуем номер телефона
phone_normalized = normalize_query_phone(query) phone_normalized = normalize_query_phone(query)
# Определяем стратегию поиска # Определяем стратегию поиска
strategy, search_value = determine_search_strategy(query) strategy, search_value = determine_search_strategy(query)
# Строим Q-объект для поиска (единая функция) # Строим Q-объект для поиска
q_objects = build_customer_search_query(query, strategy, search_value) q_objects = build_customer_search_query(query, strategy, search_value)
# Добавляем поиск по телефону (умная логика) # Добавляем поиск по телефону
if phone_normalized: if phone_normalized:
q_objects |= Q(phone__icontains=phone_normalized) q_objects |= Q(phone__icontains=phone_normalized)
# Проверяем, похож ли query на номер телефона (только цифры и минимум 3 цифры) # Поиск по цифрам телефона
query_digits = ''.join(c for c in query if c.isdigit()) 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 should_search_by_phone_digits = is_query_phone_only(query) and len(query_digits) >= 3
if should_search_by_phone_digits: if should_search_by_phone_digits:
# Ищем клиентов, чьи телефоны содержат введенные цифры
# Используем LIKE запрос вместо Python loop для оптимизации при большом количестве клиентов
customers_by_phone = Customer.objects.filter( customers_by_phone = Customer.objects.filter(
phone__isnull=False, phone__isnull=False,
phone__icontains=query_digits # Простой поиск по цифрам в phone строке phone__icontains=query_digits
) )
if customers_by_phone.exists(): if customers_by_phone.exists():
q_objects |= Q(pk__in=customers_by_phone.values_list('pk', flat=True)) q_objects |= Q(pk__in=customers_by_phone.values_list('pk', flat=True))
customers = customers.filter(q_objects) customers = customers.filter(q_objects)
customers = customers.order_by('-created_at') customers = customers.order_by('-created_at')
# Пагинация # Пагинация
paginator = Paginator(customers, 25) # 25 клиентов на страницу paginator = Paginator(customers, 25)
page_number = request.GET.get('page') page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
context = { context = {
'page_obj': page_obj, 'page_obj': page_obj,
'query': query, 'query': query,
'total_customers': total_customers,
} }
return render(request, 'customers/customer_list.html', context) return render(request, 'customers/customer_list.html', context)

View File

@@ -15,11 +15,13 @@ django-nested-admin==4.1.5
django-phonenumber-field==8.3.0 django-phonenumber-field==8.3.0
django-simple-history==3.10.1 django-simple-history==3.10.1
django-tenants==3.7.0 django-tenants==3.7.0
et_xmlfile==2.0.0
kombu==5.6.0 kombu==5.6.0
openpyxl==3.1.5
packaging==25.0 packaging==25.0
phonenumbers==9.0.17 phonenumbers==9.0.17
pillow>=12.0.0 pillow==12.0.0
pillow-heif>=0.15.0 pillow_heif==1.1.1
prompt_toolkit==3.0.52 prompt_toolkit==3.0.52
psycopg2-binary==2.9.11 psycopg2-binary==2.9.11
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
@@ -32,5 +34,3 @@ tzdata==2025.2
Unidecode==1.4.0 Unidecode==1.4.0
vine==5.1.0 vine==5.1.0
wcwidth==0.2.14 wcwidth==0.2.14
gunicorn==21.2.0
whitenoise==6.6.0