Улучшение импорта клиентов: предобработка данных, умное слияние, прогресс-бар
- Добавлена предобработка 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:
@@ -0,0 +1,38 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django_tenants.utils import schema_context
|
||||||
|
from tenants.models import Client
|
||||||
|
from customers.models import Customer
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Удаляет всех клиентов (кроме системного) в тенанте anatol'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
tenant_schema = 'anatol'
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant = Client.objects.get(schema_name=tenant_schema)
|
||||||
|
except Client.DoesNotExist:
|
||||||
|
self.stdout.write(self.style.ERROR(f'Тенант {tenant_schema} не найден'))
|
||||||
|
return
|
||||||
|
|
||||||
|
with schema_context(tenant_schema):
|
||||||
|
# Удаляем всех клиентов кроме системного
|
||||||
|
customers_to_delete = Customer.objects.filter(is_system_customer=False)
|
||||||
|
count = customers_to_delete.count()
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
self.stdout.write(self.style.WARNING('Нет клиентов для удаления'))
|
||||||
|
return
|
||||||
|
|
||||||
|
customers_to_delete.delete()
|
||||||
|
|
||||||
|
# Проверяем что остался только системный
|
||||||
|
remaining = Customer.objects.count()
|
||||||
|
system_customer = Customer.objects.filter(is_system_customer=True).first()
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'Удалено клиентов: {count}'))
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'Осталось клиентов: {remaining}'))
|
||||||
|
|
||||||
|
if system_customer:
|
||||||
|
self.stdout.write(f'Системный клиент: {system_customer.name} ({system_customer.email})')
|
||||||
@@ -23,6 +23,8 @@ except ImportError:
|
|||||||
phonenumbers = None
|
phonenumbers = None
|
||||||
NumberParseException = Exception
|
NumberParseException = Exception
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
class CustomerExporter:
|
class CustomerExporter:
|
||||||
"""
|
"""
|
||||||
@@ -329,10 +331,32 @@ class CustomerImporter:
|
|||||||
return None
|
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:
|
if not cleaned:
|
||||||
return None
|
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: Международный формат (начинается с +)
|
# Стратегия 1: Международный формат (начинается с +)
|
||||||
if cleaned.startswith('+'):
|
if cleaned.startswith('+'):
|
||||||
@@ -343,20 +367,9 @@ class CustomerImporter:
|
|||||||
except NumberParseException:
|
except NumberParseException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Стратегия 2: Формат '8XXXXXXXXXX' (11 цифр)
|
# Стратегия 2: Пробуем распространённые регионы
|
||||||
if cleaned.startswith('8') and len(cleaned) == 11:
|
for region in ['BY', 'RU', 'UA', 'PL', 'DE']:
|
||||||
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:
|
try:
|
||||||
# Добавляем '+' если его нет
|
|
||||||
test_number = cleaned if cleaned.startswith('+') else f'+{cleaned}'
|
test_number = cleaned if cleaned.startswith('+') else f'+{cleaned}'
|
||||||
parsed = phonenumbers.parse(test_number, region)
|
parsed = phonenumbers.parse(test_number, region)
|
||||||
if phonenumbers.is_valid_number(parsed):
|
if phonenumbers.is_valid_number(parsed):
|
||||||
@@ -364,7 +377,6 @@ class CustomerImporter:
|
|||||||
except NumberParseException:
|
except NumberParseException:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Пробуем без '+'
|
|
||||||
try:
|
try:
|
||||||
parsed = phonenumbers.parse(cleaned, region)
|
parsed = phonenumbers.parse(cleaned, region)
|
||||||
if phonenumbers.is_valid_number(parsed):
|
if phonenumbers.is_valid_number(parsed):
|
||||||
@@ -374,6 +386,83 @@ class CustomerImporter:
|
|||||||
|
|
||||||
return None
|
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:
|
def _is_valid_email(self, email: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Проверка валидности email через Django EmailValidator.
|
Проверка валидности email через Django EmailValidator.
|
||||||
@@ -472,11 +561,11 @@ class CustomerImporter:
|
|||||||
self.skip_count += 1
|
self.skip_count += 1
|
||||||
return
|
return
|
||||||
|
|
||||||
# Нормализуем email
|
# Нормализуем email (с предобработкой)
|
||||||
if 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.skip_count += 1
|
||||||
self.errors.append(
|
self.errors.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -175,13 +175,30 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||||
<i class="bi bi-upload"></i> Импортировать
|
<i class="bi bi-upload"></i> Импортировать
|
||||||
</button>
|
</button>
|
||||||
<a href="{% url 'customers:customer-list' %}" class="btn btn-outline-secondary">
|
<a href="{% url 'customers:customer-list' %}" class="btn btn-outline-secondary">
|
||||||
Отмена
|
Отмена
|
||||||
</a>
|
</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Прогресс-бар (скрыт по умолчанию) -->
|
||||||
|
<div id="progressContainer" class="mt-4 d-none">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<strong>Импорт в процессе...</strong>
|
||||||
|
<span id="progressText" class="text-muted">Подготовка...</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 25px;">
|
||||||
|
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated"
|
||||||
|
role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%">
|
||||||
|
<span id="progressPercent">0%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mt-2 mb-0">
|
||||||
|
<small><i class="bi bi-exclamation-triangle text-warning"></i> Не закрывайте страницу до завершения импорта</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -308,6 +325,64 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
alert('Пожалуйста, выберите файл для импорта');
|
alert('Пожалуйста, выберите файл для импорта');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Показываем прогресс-бар и блокируем повторную отправку
|
||||||
|
showProgressBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Показать прогресс-бар и защиту от закрытия
|
||||||
|
function showProgressBar() {
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const progressContainer = document.getElementById('progressContainer');
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const progressPercent = document.getElementById('progressPercent');
|
||||||
|
const progressText = document.getElementById('progressText');
|
||||||
|
|
||||||
|
// Блокируем кнопку и форму
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Импорт...';
|
||||||
|
fileInput.disabled = true;
|
||||||
|
dropZone.style.pointerEvents = 'none';
|
||||||
|
dropZone.style.opacity = '0.6';
|
||||||
|
|
||||||
|
// Показываем прогресс-бар
|
||||||
|
progressContainer.classList.remove('d-none');
|
||||||
|
|
||||||
|
// Анимация прогресса (имитация, т.к. реальный прогресс без WebSocket сложен)
|
||||||
|
let progress = 0;
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
if (progress < 90) {
|
||||||
|
progress += Math.random() * 15;
|
||||||
|
if (progress > 90) progress = 90;
|
||||||
|
|
||||||
|
progressBar.style.width = progress + '%';
|
||||||
|
progressBar.setAttribute('aria-valuenow', progress);
|
||||||
|
progressPercent.textContent = Math.round(progress) + '%';
|
||||||
|
|
||||||
|
if (progress < 30) {
|
||||||
|
progressText.textContent = 'Чтение файла...';
|
||||||
|
} else if (progress < 60) {
|
||||||
|
progressText.textContent = 'Обработка данных...';
|
||||||
|
} else {
|
||||||
|
progressText.textContent = 'Сохранение в базу...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Сохраняем интервал для очистки при завершении страницы
|
||||||
|
window.importProgressInterval = progressInterval;
|
||||||
|
|
||||||
|
// Включаем защиту от закрытия страницы
|
||||||
|
window.importInProgress = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Предупреждение при закрытии страницы во время импорта
|
||||||
|
window.addEventListener('beforeunload', function(e) {
|
||||||
|
if (window.importInProgress) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = 'Импорт ещё не завершён. Вы уверены, что хотите покинуть страницу?';
|
||||||
|
return e.returnValue;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
11
myproject/test_customer_preprocess.csv
Normal file
11
myproject/test_customer_preprocess.csv
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Имя,Email,Телефон,Заметки
|
||||||
|
Тест1,test @gmail com,8 029 123-45-67,Пробелы в email и старый формат телефона
|
||||||
|
Тест2,user@@mail.ru,375291234567,Двойной @@ и белорусский без +
|
||||||
|
Тест3,contact.ry,+7 (921) 555-44-33,Опечатка .ry вместо .ru
|
||||||
|
Тест4,infogmail,380501234567,Нет @ но есть gmail и украинский номер
|
||||||
|
Тест5,admin/com,291234567,Слэш вместо точки и 9 цифр (Беларусь)
|
||||||
|
Тест6,user@mailru,7 921 555 44 33,Домен без точки и российский формат
|
||||||
|
Тест7,test ya ru,,Пробелы в домене (нет телефона)
|
||||||
|
Тест8,,8 (029) 999-88-77,Нет email (только телефон 8...)
|
||||||
|
Тест9,invalid,123,ОШИБКА: оба невалидны
|
||||||
|
Тест10,good@test.by,+375291111111,Валидные данные (контрольная)
|
||||||
|
Reference in New Issue
Block a user