Dobavlen funkcional importa i eksporta klientov s validaciey i umnym sliyaniem kontaktov

This commit is contained in:
2026-01-03 13:33:34 +03:00
parent 208c6b55de
commit 3248fadffa
7 changed files with 695 additions and 19 deletions

View File

@@ -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)