Улучшение импорта клиентов: предобработка данных, умное слияние, прогресс-бар

- Добавлена предобработка 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:
2026-01-03 14:30:18 +03:00
parent cca9a908c9
commit b201c71311
4 changed files with 232 additions and 19 deletions

View File

@@ -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(
{