diff --git a/myproject/customers/services/import_export.py b/myproject/customers/services/import_export.py index 34c3356..63e6fdb 100644 --- a/myproject/customers/services/import_export.py +++ b/myproject/customers/services/import_export.py @@ -111,12 +111,18 @@ class CustomerImporter: self.errors = [] self.success_count = 0 self.update_count = 0 + self.enriched_count = 0 # Дополнены пустые поля + self.conflicts_resolved = 0 # Альтернативные контакты через ContactChannel self.skip_count = 0 # Отслеживание уже обработанных email/phone для дедупликации внутри файла self.processed_emails = set() self.processed_phones = set() # Отдельный список для реальных ошибок (не дублей из БД) self.real_errors = [] + # Сохраняем исходные данные для генерации error-файла + self.original_headers = [] + self.original_rows = [] + self.file_format = None def import_from_file(self, file, update_existing: bool = False) -> dict: """ @@ -151,11 +157,18 @@ class CustomerImporter: "errors": [{"row": None, "reason": "openpyxl is not installed"}], } + # Сохраняем формат файла + self.file_format = file_format + try: if file_format == "csv": headers, rows = self._read_csv(file) else: headers, rows = self._read_xlsx(file) + + # Сохраняем исходные данные + self.original_headers = headers + self.original_rows = rows except Exception as exc: return { "success": False, @@ -197,6 +210,8 @@ class CustomerImporter: self._process_row(index, row, mapping, update_existing) total_errors = len(self.errors) + duplicate_count = len([e for e in self.errors if e.get('is_duplicate', False)]) + real_error_count = len(self.real_errors) success = (self.success_count + self.update_count) > 0 if success and total_errors == 0: @@ -211,9 +226,13 @@ class CustomerImporter: "message": message, "created": self.success_count, "updated": self.update_count, + "enriched": self.enriched_count, + "conflicts_resolved": self.conflicts_resolved, "skipped": self.skip_count, "errors": self.errors, "real_errors": self.real_errors, # Только невалидные данные, без дублей из БД + "duplicate_count": duplicate_count, + "real_error_count": real_error_count, } def _detect_format(self, file) -> str | None: @@ -355,6 +374,94 @@ class CustomerImporter: return None + def _is_valid_email(self, email: str) -> bool: + """ + Проверка валидности email через Django EmailValidator. + + Args: + email: Email для проверки + + Returns: + bool: True если email валиден + """ + from django.core.validators import validate_email + from django.core.exceptions import ValidationError + + if not email: + return False + + try: + validate_email(email) + return True + except ValidationError: + return False + + def _get_better_name(self, existing_name: str, new_name: str) -> str: + """ + Выбирает более полное/информативное имя. + + Приоритеты: + 1. Если одно пустое - возвращаем непустое + 2. Если новое длиннее И содержит старое - берём новое + 3. Если в новом есть пробелы (ФИО), а в старом нет - берём новое + 4. Иначе оставляем старое + + Returns: + str: Лучшее имя и флаг конфликта + """ + if not existing_name: + return new_name + if not new_name: + return existing_name + + # Если новое имя длиннее и содержит старое - используем новое + if len(new_name) > len(existing_name) and existing_name.lower() in new_name.lower(): + return new_name + + # Если в новом есть пробелы (ФИО), а в старом нет - берём новое + if ' ' in new_name and ' ' not in existing_name: + return new_name + + # Иначе оставляем существующее + return existing_name + + def _create_alternative_contact(self, customer, channel_type: str, value: str, source: str = 'Импорт'): + """ + Создаёт альтернативный контакт через ContactChannel. + + Args: + customer: Объект Customer + channel_type: Тип канала ('email' или 'phone') + value: Значение контакта + source: Источник (для notes) + + Returns: + bool: True если создан, False если уже существует + """ + from ..models import ContactChannel + + # Проверяем, не существует ли уже такой контакт + exists = ContactChannel.objects.filter( + channel_type=channel_type, + value=value + ).exists() + + if exists: + return False + + try: + ContactChannel.objects.create( + customer=customer, + channel_type=channel_type, + value=value, + is_primary=False, + notes=f'Из {source}' + ) + return True + except Exception: + # Если не удалось создать - пропускаем + return False + 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", ""), "")) @@ -412,6 +519,24 @@ class CustomerImporter: else: notes = note_addition + # Проверка: есть ли хотя бы ОДИН валидный контакт + has_valid_email = self._is_valid_email(email) + has_valid_phone = phone is not None # phone уже нормализован или None + + if not has_valid_email and not has_valid_phone: + # Нет валидных контактов - в ошибки + self.skip_count += 1 + error_record = { + "row": row_number, + "email": email or None, + "phone": phone_raw or None, + "reason": "Требуется хотя бы один: email или телефон", + "is_duplicate": False, + } + self.errors.append(error_record) + self.real_errors.append(error_record) + return + # Пытаемся найти существующего клиента existing = None if email: @@ -433,12 +558,49 @@ class CustomerImporter: return if existing and update_existing: + # Умное слияние (smart merge) + was_enriched = False # Флаг дополнения пустых полей + + # 1. Имя - выбираем лучшее if name: - existing.name = name + better_name = self._get_better_name(existing.name or '', name) + if not existing.name: + was_enriched = True + existing.name = better_name + elif better_name != existing.name: + # Конфликт имен - добавляем в notes + if name != existing.name and name.lower() not in existing.name.lower(): + alt_name_note = f"Также известен как: {name}" + if existing.notes: + if alt_name_note not in existing.notes: + existing.notes = f"{existing.notes}\n{alt_name_note}" + else: + existing.notes = alt_name_note + existing.name = better_name + + # 2. Email - дополняем или создаём ContactChannel if email: - existing.email = email + if not existing.email: + # Пустое поле - дополняем + was_enriched = True + existing.email = email + elif existing.email != email: + # Конфликт - создаём альтернативный контакт + if self._create_alternative_contact(existing, 'email', email): + self.conflicts_resolved += 1 + + # 3. Телефон - дополняем или создаём ContactChannel if phone: - existing.phone = phone + if not existing.phone: + # Пустое поле - дополняем + was_enriched = True + existing.phone = phone + elif str(existing.phone) != phone: + # Конфликт - создаём альтернативный контакт + if self._create_alternative_contact(existing, 'phone', phone): + self.conflicts_resolved += 1 + + # 4. Заметки - ВСЕГДА дописываем if notes: if existing.notes: existing.notes = f"{existing.notes}\n{notes}" @@ -448,7 +610,13 @@ class CustomerImporter: try: existing.full_clean() existing.save() - self.update_count += 1 + + # Счётчики + if was_enriched: + self.enriched_count += 1 + else: + self.update_count += 1 + if email: self.processed_emails.add(email) if phone: @@ -493,3 +661,99 @@ class CustomerImporter: } self.errors.append(error_record) self.real_errors.append(error_record) # Реальная ошибка валидации + + def generate_error_file(self) -> tuple[bytes, str] | None: + """ + Генерирует файл с ошибочными строками (только real_errors). + + Возвращает тот же формат, что был загружен (CSV или XLSX). + Добавляет колонку "Ошибка" с описанием проблемы. + + Returns: + tuple[bytes, str]: (file_content, filename) или None если нет ошибок + """ + if not self.real_errors or not self.original_headers: + return None + + # Создаём mapping row_number -> error + error_map = {err['row']: err for err in self.real_errors if err.get('row')} + + if not error_map: + return None + + # Собираем ошибочные строки + error_rows = [] + for index, row in enumerate(self.original_rows, start=2): # start=2 т.к. первая строка - заголовки + if index in error_map: + error_info = error_map[index] + # Добавляем исходную строку + колонку с ошибкой + row_with_error = dict(row) # копируем + row_with_error['Ошибка'] = error_info['reason'] + error_rows.append(row_with_error) + + if not error_rows: + return None + + # Заголовки + колонка "Ошибка" + headers_with_error = list(self.original_headers) + ['Ошибка'] + + # Генерируем файл в зависимости от формата + timestamp = timezone.now().strftime("%Y%m%d_%H%M%S") + + if self.file_format == 'csv': + return self._generate_csv_error_file(headers_with_error, error_rows, timestamp) + else: + return self._generate_xlsx_error_file(headers_with_error, error_rows, timestamp) + + def _generate_csv_error_file(self, headers: list, rows: list[dict], timestamp: str) -> tuple[bytes, str]: + """ + Генерирует CSV файл с ошибками (с BOM для Excel). + """ + output = io.StringIO() + # BOM для корректного открытия в Excel + output.write('\ufeff') + + writer = csv.DictWriter(output, fieldnames=headers, extrasaction='ignore') + writer.writeheader() + writer.writerows(rows) + + content = output.getvalue().encode('utf-8') + filename = f'customer_import_errors_{timestamp}.csv' + + return content, filename + + def _generate_xlsx_error_file(self, headers: list, rows: list[dict], timestamp: str) -> tuple[bytes, str] | None: + """ + Генерирует XLSX файл с ошибками. + """ + if load_workbook is None: + # Fallback to CSV если openpyxl не установлен + return self._generate_csv_error_file(headers, rows, timestamp) + + try: + from openpyxl import Workbook + + wb = Workbook() + ws = wb.active + ws.title = "Ошибки импорта" + + # Заголовки + ws.append(headers) + + # Данные + for row_dict in rows: + row_data = [row_dict.get(h, '') for h in headers] + ws.append(row_data) + + # Сохраняем в BytesIO + output = io.BytesIO() + wb.save(output) + output.seek(0) + + content = output.read() + filename = f'customer_import_errors_{timestamp}.xlsx' + + return content, filename + except Exception: + # Fallback to CSV при любой ошибке + return self._generate_csv_error_file(headers, rows, timestamp) diff --git a/myproject/customers/templates/customers/customer_import.html b/myproject/customers/templates/customers/customer_import.html index 9f62373..b3aee1f 100644 --- a/myproject/customers/templates/customers/customer_import.html +++ b/myproject/customers/templates/customers/customer_import.html @@ -30,19 +30,142 @@

+ + {% if import_result %} +
+
+
+ + Результаты импорта +
+
+
+
+
+
+
{{ import_result.created }}
+ Создано +
+
+
+
+
{{ import_result.enriched }}
+ Дополнено +
+
+
+
+
{{ import_result.updated }}
+ Обновлено +
+
+
+
+
{{ import_result.conflicts_resolved }}
+ Альт. контакты +
+
+
+
+
{{ import_result.duplicate_count }}
+ Дубликатов +
+
+
+
+
{{ import_result.real_error_count }}
+ Ошибок +
+
+
+ + {% if import_result.real_errors %} +
+
+ + Обнаружено {{ import_result.real_error_count }} ошибок валидации +
+

Первые ошибки:

+
    + {% for error in import_result.real_errors|slice:":10" %} +
  • + Строка {{ error.row }}: {{ error.reason }} + {% if error.email %}{{ error.email }}{% endif %} + {% if error.phone %}{{ error.phone }}{% endif %} +
  • + {% endfor %} +
+ {% if import_result.real_error_count > 10 %} +

+ ...и ещё {{ import_result.real_error_count|add:"-10" }} ошибок +

+ {% endif %} +
+ + {% if has_error_file %} +
+ + Скачать файл с ошибками + +

+ Исправьте ошибки в файле и загрузите снова +

+
+ {% endif %} + {% endif %} + + {% if import_result.duplicate_count > 0 %} +
+ + Пропущено дубликатов: {{ import_result.duplicate_count }} + (клиенты с такими email/телефонами уже существуют) +
+ {% endif %} +
+
+ {% endif %} +
-
+ {% csrf_token %} +
- - - Поддерживаемые форматы: CSV, Excel (.xlsx, .xls) - + +
+ + +
+ +

+ Перетащите файл сюда или + нажмите для выбора +

+

+ + Поддерживаемые форматы: CSV, Excel (.xlsx, .xls)
+ Также можно вставить файл через Ctrl+V +
+

+
+ +
+ +

+ +
+ +

+ +
+
@@ -52,12 +175,7 @@
-
- - Внимание! Функция импорта находится в разработке. -
- - @@ -70,4 +188,142 @@
+ + + + {% endblock %} diff --git a/myproject/customers/urls.py b/myproject/customers/urls.py index 26a0080..df260af 100644 --- a/myproject/customers/urls.py +++ b/myproject/customers/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ path('', views.customer_list, name='customer-list'), path('create/', views.customer_create, name='customer-create'), path('import/', views.customer_import, name='customer-import'), + path('import/download-errors/', views.customer_import_download_errors, name='customer-import-download-errors'), path('export/', views.customer_export, name='customer-export'), path('/', views.customer_detail, name='customer-detail'), path('/delete/', views.customer_delete, name='customer-delete'), diff --git a/myproject/customers/views.py b/myproject/customers/views.py index 5a595a3..874e92e 100644 --- a/myproject/customers/views.py +++ b/myproject/customers/views.py @@ -737,13 +737,76 @@ def customer_import(request): """ Импорт клиентов из CSV/Excel файла. """ + import os + from pathlib import Path + from django.conf import settings from .services.import_export import CustomerImporter if request.method == 'POST': + file = request.FILES.get('file') + update_existing = request.POST.get('update_existing') == 'on' + + if not file: + messages.error(request, 'Файл не был загружен.') + return redirect('customers:customer-import') + + # Выполняем импорт importer = CustomerImporter() - # TODO: Обработка загруженного файла - messages.info(request, 'Функция импорта в разработке') - return redirect('customers:customer-list') + result = importer.import_from_file(file, update_existing=update_existing) + + # Формируем сообщения о результате + if result['success']: + success_parts = [] + if result['created'] > 0: + success_parts.append(f"создано {result['created']}") + if result['enriched'] > 0: + success_parts.append(f"дополнено {result['enriched']}") + if result['updated'] > 0: + success_parts.append(f"обновлено {result['updated']}") + + success_msg = f"Импорт завершён: {', '.join(success_parts) if success_parts else 'нет изменений'}" + + if result.get('duplicate_count', 0) > 0: + success_msg += f", пропущено дубликатов: {result['duplicate_count']}" + if result.get('conflicts_resolved', 0) > 0: + success_msg += f", создано альтернативных контактов: {result['conflicts_resolved']}" + + messages.success(request, success_msg) + else: + messages.error(request, result['message']) + + # Если есть реальные ошибки валидации - генерируем файл + if result.get('real_error_count', 0) > 0: + error_file_data = importer.generate_error_file() + + if error_file_data: + content, filename = error_file_data + + # Сохраняем временный файл + temp_dir = Path(settings.MEDIA_ROOT) / 'temp_imports' + temp_dir.mkdir(parents=True, exist_ok=True) + + temp_file_path = temp_dir / filename + with open(temp_file_path, 'wb') as f: + f.write(content) + + # Сохраняем путь в сессии + request.session['import_error_file'] = str(temp_file_path) + request.session['import_error_filename'] = filename + + messages.warning( + request, + f'Обнаружено {result["real_error_count"]} ошибок валидации. ' + f'Скачайте файл с ошибками для исправления.' + ) + + # Передаём результаты в шаблон + context = { + 'title': 'Импорт клиентов', + 'import_result': result, + 'has_error_file': 'import_error_file' in request.session, + } + return render(request, 'customers/customer_import.html', context) context = { 'title': 'Импорт клиентов', @@ -751,6 +814,71 @@ def customer_import(request): return render(request, 'customers/customer_import.html', context) +@login_required +@manager_or_owner_required +def customer_import_download_errors(request): + """ + Скачивание файла с ошибками импорта и немедленное удаление. + """ + import os + from django.http import FileResponse, Http404 + + file_path = request.session.get('import_error_file') + filename = request.session.get('import_error_filename', 'errors.csv') + + if not file_path or not os.path.exists(file_path): + messages.error(request, 'Файл с ошибками не найден или уже был удалён.') + return redirect('customers:customer-import') + + try: + # Открываем файл для чтения + response = FileResponse( + open(file_path, 'rb'), + as_attachment=True, + filename=filename + ) + + # Удаляем из сессии + del request.session['import_error_file'] + del request.session['import_error_filename'] + + # Планируем удаление файла после отправки + # (FileResponse закроет файл автоматически, затем удаляем) + def cleanup_file(): + try: + if os.path.exists(file_path): + os.remove(file_path) + except Exception: + pass + + # Django FileResponse автоматически закрывает файл после отправки + # Используем middleware или сигнал для очистки, но проще - удалим сразу после response + # Поскольку FileResponse читает файл в память при малом размере, удаляем сразу + import atexit + atexit.register(cleanup_file) + + # Альтернатива: читаем файл в память и сразу удаляем + with open(file_path, 'rb') as f: + file_content = f.read() + + # Удаляем файл немедленно + try: + os.remove(file_path) + except Exception: + pass + + # Возвращаем содержимое из памяти + from django.http import HttpResponse + response = HttpResponse(file_content, content_type='application/octet-stream') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + + return response + + except Exception as e: + messages.error(request, f'Ошибка при скачивании файла: {str(e)}') + return redirect('customers:customer-import') + + @login_required @manager_or_owner_required def customer_export(request): diff --git a/myproject/test_customer_import.csv b/myproject/test_customer_import.csv new file mode 100644 index 0000000..8795600 --- /dev/null +++ b/myproject/test_customer_import.csv @@ -0,0 +1,6 @@ +Имя,Email,Телефон,Заметки +Иван Иванов,ivan@test.by,+375291234567,Тестовый клиент +Петр Петров,petr@test.by,80291234568,VIP клиент +Мария,maria@invalid,invalid_phone,Ошибка: невалидный email и телефон +,duplicate@test.by,+375291234567,Ошибка: дубль телефона со строкой 2 +Сидоров,sidorov@test.by,,Без телефона - норм diff --git a/myproject/test_customer_import_smart_merge.csv b/myproject/test_customer_import_smart_merge.csv new file mode 100644 index 0000000..587c691 --- /dev/null +++ b/myproject/test_customer_import_smart_merge.csv @@ -0,0 +1,12 @@ +Имя,Email,Телефон,Заметки +Иван,ivan@test.by,,Первый клиент - только email +Иван Иванов,ivan@test.by,+375291234567,Дополним телефон к первому (enriched) +Пётр Петров,petr@test.by,+375291234568,Новый клиент +Пётр,petr@test.by,+375299999999,Конфликт телефона - создаст ContactChannel +Мария,maria@invalid.com,invalid_phone,Невалидный email и телефон - ошибка +Сидоров,,+375297777777,Только телефон +Сидоров Иван,,+375297777777,Дополним имя к предыдущему (enriched) +Анна,anna@test.by,+375296666666,Новый клиент +Аня,anna@test.by,+375295555555,Конфликт телефона и имени - ContactChannel + notes +Олег Олегов,oleg@test.by,,Только с email +Олег Олегов,oleg2@test.by,,Конфликт email - создаст ContactChannel diff --git a/myproject/test_customer_validation.csv b/myproject/test_customer_validation.csv new file mode 100644 index 0000000..ff1b06a --- /dev/null +++ b/myproject/test_customer_validation.csv @@ -0,0 +1,9 @@ +Имя,Email,Телефон,Заметки +Иван,ivan@test.by,,Валидный email - создастся +Пётр,,+375291234567,Валидный телефон - создастся +Мария,maria@test.by,invalid_phone,Email валиден - создастся (телефон в notes) +Олег,invalid@,+375291111111,Телефон валиден - создастся (email невалиден) +Сидоров,,,ОШИБКА: нет контактов +Анна,invalid@,12345,ОШИБКА: оба невалидны +Виктор,,abc,ОШИБКА: оба невалидны/пусты +Константин,bad-email,259284548,ОШИБКА: email плохой и телефон невалидный