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 %} +Первые ошибки:
+{{ error.email }}{% endif %}
+ {% if error.phone %}{{ error.phone }}{% endif %}
+ + ...и ещё {{ import_result.real_error_count|add:"-10" }} ошибок +
+ {% endif %} ++ Исправьте ошибки в файле и загрузите снова +
+