Добавлена система фильтрации клиентов с универсальным поиском
- Реализован универсальный поиск по имени, email и телефону в одной строке - Добавлен счетчик общего количества клиентов - Поиск работает по нажатию Enter или кнопке 'Поиск' - Удалены неиспользуемые фильтры django-filter - Упрощен интерфейс списка клиентов - Добавлена кнопка 'Очистить' для сброса поиска
This commit is contained in:
Binary file not shown.
BIN
customers_mixflowers.by_2025-12-14_20-35-36_ERRORS.xlsx
Normal file
BIN
customers_mixflowers.by_2025-12-14_20-35-36_ERRORS.xlsx
Normal file
Binary file not shown.
67
myproject/customers/management/commands/analyze_import.py
Normal file
67
myproject/customers/management/commands/analyze_import.py
Normal 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)}")
|
||||||
158
myproject/customers/management/commands/test_import.py
Normal file
158
myproject/customers/management/commands/test_import.py
Normal 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}"))
|
||||||
@@ -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 для дедупликации внутри файла
|
||||||
|
self.processed_emails = set()
|
||||||
|
self.processed_phones = set()
|
||||||
|
# Отдельный список для реальных ошибок (не дублей из БД)
|
||||||
|
self.real_errors = []
|
||||||
|
|
||||||
def import_from_file(self, file, update_existing=False):
|
def import_from_file(self, file, update_existing: bool = False) -> dict:
|
||||||
"""
|
"""
|
||||||
Импорт клиентов из загруженного файла.
|
Импорт клиентов из загруженного файла.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file: Загруженный файл (UploadedFile)
|
file: UploadedFile
|
||||||
update_existing: Обновлять ли существующих клиентов (по email/телефону)
|
update_existing: обновлять ли существующих клиентов (по email/телефону)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Результат импорта с статистикой
|
dict: результат импорта
|
||||||
"""
|
"""
|
||||||
# TODO: Реализовать логику импорта
|
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 {
|
return {
|
||||||
'success': False,
|
"success": success,
|
||||||
'message': 'Функция импорта находится в разработке',
|
"message": message,
|
||||||
'created': 0,
|
"created": self.success_count,
|
||||||
'updated': 0,
|
"updated": self.update_count,
|
||||||
'skipped': 0,
|
"skipped": self.skip_count,
|
||||||
'errors': []
|
"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:
|
||||||
|
Нормализованный телефон в E.164 формате или None
|
||||||
|
"""
|
||||||
|
if not raw_phone or not phonenumbers:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Убираем все символы кроме цифр и +
|
||||||
|
cleaned = ''.join(c for c in raw_phone if c.isdigit() or c == '+')
|
||||||
|
|
||||||
|
if not cleaned:
|
||||||
|
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) # Реальная ошибка валидации
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -28,39 +28,37 @@ 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)
|
||||||
|
|
||||||
if query:
|
# Общее количество клиентов
|
||||||
# Используем ту же логику поиска, что и в AJAX API (api_search_customers)
|
total_customers = customers.count()
|
||||||
# Это обеспечивает согласованность между веб-интерфейсом и API
|
|
||||||
|
|
||||||
|
if query:
|
||||||
# Нормализуем номер телефона
|
# Нормализуем номер телефона
|
||||||
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))
|
||||||
|
|
||||||
@@ -69,13 +67,14 @@ def customer_list(request):
|
|||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
BIN
~$customers_mixflowers.by_2025-12-14_20-35-36_ERRORS.xlsx
Normal file
BIN
~$customers_mixflowers.by_2025-12-14_20-35-36_ERRORS.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user