From b201c7131110e4220c7f955465694145b187a1cd Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sat, 3 Jan 2026 14:30:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=BE=D0=B2:=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85,=20=D1=83?= =?UTF-8?q?=D0=BC=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5,=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81-?= =?UTF-8?q?=D0=B1=D0=B0=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена предобработка 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 с примерами исправляемых ошибок --- .../commands/clear_anatol_customers.py | 38 ++++++ myproject/customers/services/import_export.py | 125 +++++++++++++++--- .../templates/customers/customer_import.html | 77 ++++++++++- myproject/test_customer_preprocess.csv | 11 ++ 4 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 myproject/customers/management/commands/clear_anatol_customers.py create mode 100644 myproject/test_customer_preprocess.csv diff --git a/myproject/customers/management/commands/clear_anatol_customers.py b/myproject/customers/management/commands/clear_anatol_customers.py new file mode 100644 index 0000000..1a7bf3b --- /dev/null +++ b/myproject/customers/management/commands/clear_anatol_customers.py @@ -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})') diff --git a/myproject/customers/services/import_export.py b/myproject/customers/services/import_export.py index 63e6fdb..2145a02 100644 --- a/myproject/customers/services/import_export.py +++ b/myproject/customers/services/import_export.py @@ -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( { diff --git a/myproject/customers/templates/customers/customer_import.html b/myproject/customers/templates/customers/customer_import.html index b3aee1f..203aaee 100644 --- a/myproject/customers/templates/customers/customer_import.html +++ b/myproject/customers/templates/customers/customer_import.html @@ -175,13 +175,30 @@ - Отмена + + +
+
+ Импорт в процессе... + Подготовка... +
+
+
+ 0% +
+
+

+ Не закрывайте страницу до завершения импорта +

+
@@ -308,6 +325,64 @@ document.addEventListener('DOMContentLoaded', function() { alert('Пожалуйста, выберите файл для импорта'); 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 = 'Импорт...'; + 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; + } }); }); diff --git a/myproject/test_customer_preprocess.csv b/myproject/test_customer_preprocess.csv new file mode 100644 index 0000000..36f3c70 --- /dev/null +++ b/myproject/test_customer_preprocess.csv @@ -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,Валидные данные (контрольная)