Улучшение импорта клиентов: предобработка данных, умное слияние, прогресс-бар
- Добавлена предобработка email перед валидацией: * Исправление типичных опечаток (mail ru -> mail.ru, .ry -> .ru) * Удаление пробелов и двойных @@ * Умное добавление @ для популярных доменов * Исправление доменов без точки (gmail -> gmail.com) - Улучшена нормализация телефонов: * Умное добавление кода страны (+375, +7, +380) * Конверсия старого формата 8XXXXXXXXXX -> +7XXXXXXXXXX * Проверка длины номера (10-15 символов) * Поддержка локальных белорусских номеров (9 цифр) - Реализована идемпотентность импорта: * Notes не раздуваются при повторных импортах (метод _append_unique_note) * ContactChannel не дублируется для одного клиента * Проверка существования альтернативных контактов по customer+type+value - Добавлен прогресс-бар и защита от закрытия: * Визуальный прогресс-бар с анимацией и динамическим текстом * Блокировка формы во время импорта * Предупреждение браузера при попытке закрыть страницу - Создана команда clear_anatol_customers для тестирования - Добавлен тестовый файл test_customer_preprocess.csv с примерами исправляемых ошибок
This commit is contained in:
@@ -23,6 +23,8 @@ except ImportError:
|
||||
phonenumbers = None
|
||||
NumberParseException = Exception
|
||||
|
||||
import re
|
||||
|
||||
|
||||
class CustomerExporter:
|
||||
"""
|
||||
@@ -329,10 +331,32 @@ class CustomerImporter:
|
||||
return None
|
||||
|
||||
# Убираем все символы кроме цифр и +
|
||||
cleaned = ''.join(c for c in raw_phone if c.isdigit() or c == '+')
|
||||
cleaned = re.sub(r'[^\d+]', '', str(raw_phone))
|
||||
|
||||
if not cleaned:
|
||||
return None
|
||||
|
||||
# Проверка длины
|
||||
if len(cleaned) < 10 or len(cleaned) > 15:
|
||||
return None
|
||||
|
||||
# Умное добавление кода страны
|
||||
if not cleaned.startswith('+'):
|
||||
# Белорусские номера (375...)
|
||||
if cleaned.startswith('375'):
|
||||
cleaned = '+' + cleaned
|
||||
# Российские номера (7XXXXXXXXXX)
|
||||
elif cleaned.startswith('7') and len(cleaned) == 11:
|
||||
cleaned = '+' + cleaned
|
||||
# Украинские номера (380...)
|
||||
elif cleaned.startswith('380'):
|
||||
cleaned = '+' + cleaned
|
||||
# Старый формат 8XXXXXXXXXX -> +7XXXXXXXXXX
|
||||
elif cleaned.startswith('8') and len(cleaned) == 11:
|
||||
cleaned = '+7' + cleaned[1:]
|
||||
# 9 цифр - предполагаем Беларусь
|
||||
elif len(cleaned) == 9:
|
||||
cleaned = '+375' + cleaned
|
||||
|
||||
# Стратегия 1: Международный формат (начинается с +)
|
||||
if cleaned.startswith('+'):
|
||||
@@ -343,20 +367,9 @@ class CustomerImporter:
|
||||
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']:
|
||||
# Стратегия 2: Пробуем распространённые регионы
|
||||
for region in ['BY', 'RU', 'UA', 'PL', 'DE']:
|
||||
try:
|
||||
# Добавляем '+' если его нет
|
||||
test_number = cleaned if cleaned.startswith('+') else f'+{cleaned}'
|
||||
parsed = phonenumbers.parse(test_number, region)
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
@@ -364,7 +377,6 @@ class CustomerImporter:
|
||||
except NumberParseException:
|
||||
continue
|
||||
|
||||
# Пробуем без '+'
|
||||
try:
|
||||
parsed = phonenumbers.parse(cleaned, region)
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
@@ -374,6 +386,83 @@ class CustomerImporter:
|
||||
|
||||
return None
|
||||
|
||||
def _preprocess_email(self, email: str) -> str | None:
|
||||
"""
|
||||
Предобработка email перед валидацией.
|
||||
Исправляет типичные ошибки и опечатки.
|
||||
|
||||
Args:
|
||||
email: Исходный email
|
||||
|
||||
Returns:
|
||||
str | None: Очищенный email или None если пустой
|
||||
"""
|
||||
if not email or not isinstance(email, str):
|
||||
return None
|
||||
|
||||
email = email.strip()
|
||||
|
||||
if not email:
|
||||
return None
|
||||
|
||||
# Удаляем лишний текст в конце (фото, комментарии)
|
||||
email = re.sub(r'\s+фото.*$', '', email, flags=re.IGNORECASE)
|
||||
email = re.sub(r'\s+ф\d+$', '', email)
|
||||
|
||||
# Убираем пробелы внутри email
|
||||
email = email.replace(' ', '')
|
||||
|
||||
# Двойные @@ -> одинарный @
|
||||
email = email.replace('@@', '@')
|
||||
|
||||
# Исправляем пробелы вокруг @
|
||||
email = re.sub(r'\s*@\s*', '@', email)
|
||||
|
||||
# Исправляем типичные опечатки в доменах
|
||||
email = email.replace('.ry', '.ru')
|
||||
email = email.replace('mail ru', 'mail.ru')
|
||||
email = email.replace('ya ru', 'ya.ru')
|
||||
email = email.replace('tut by', 'tut.by')
|
||||
email = email.replace('gmail.co.m', 'gmail.com')
|
||||
email = email.replace('/com', '.com')
|
||||
|
||||
# Если нет @, но есть gmail/mail/etc - пытаемся добавить
|
||||
if '@' not in email:
|
||||
if 'gmail' in email.lower():
|
||||
email = re.sub(r'gmail', '@gmail', email, flags=re.IGNORECASE)
|
||||
elif 'mail.ru' in email.lower():
|
||||
email = re.sub(r'mail\.ru', '@mail.ru', email, flags=re.IGNORECASE)
|
||||
elif 'yandex' in email.lower():
|
||||
email = re.sub(r'yandex', '@yandex', email, flags=re.IGNORECASE)
|
||||
|
||||
# Исправляем домены без точки
|
||||
if '@' in email:
|
||||
parts = email.split('@')
|
||||
if len(parts) == 2:
|
||||
local, domain = parts
|
||||
domain_lower = domain.lower()
|
||||
|
||||
# Если домен слишком короткий - невалидный
|
||||
if len(domain) < 4:
|
||||
return None
|
||||
|
||||
# Если нет точки в домене - пытаемся исправить
|
||||
if '.' not in domain:
|
||||
domain_map = {
|
||||
'gmail': 'gmail.com',
|
||||
'mailru': 'mail.ru',
|
||||
'yaru': 'ya.ru',
|
||||
'tutby': 'tut.by',
|
||||
'yandexru': 'yandex.ru',
|
||||
}
|
||||
domain = domain_map.get(domain_lower, None)
|
||||
if not domain:
|
||||
# Неизвестный домен без точки
|
||||
return None
|
||||
email = f"{local}@{domain}"
|
||||
|
||||
return email.lower() if email else None
|
||||
|
||||
def _is_valid_email(self, email: str) -> bool:
|
||||
"""
|
||||
Проверка валидности email через Django EmailValidator.
|
||||
@@ -472,11 +561,11 @@ class CustomerImporter:
|
||||
self.skip_count += 1
|
||||
return
|
||||
|
||||
# Нормализуем email
|
||||
# Нормализуем email (с предобработкой)
|
||||
if email:
|
||||
email = email.lower().strip()
|
||||
email = self._preprocess_email(email)
|
||||
# Проверка на дубли внутри файла
|
||||
if email in self.processed_emails:
|
||||
if email and email in self.processed_emails:
|
||||
self.skip_count += 1
|
||||
self.errors.append(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user