From 3248fadffaeb19af04d30896fa8a7d9af116528c Mon Sep 17 00:00:00 2001
From: Andrey Smakotin
Date: Sat, 3 Jan 2026 13:33:34 +0300
Subject: [PATCH] Dobavlen funkcional importa i eksporta klientov s validaciey
i umnym sliyaniem kontaktov
---
myproject/customers/services/import_export.py | 272 ++++++++++++++++-
.../templates/customers/customer_import.html | 280 +++++++++++++++++-
myproject/customers/urls.py | 1 +
myproject/customers/views.py | 134 ++++++++-
myproject/test_customer_import.csv | 6 +
.../test_customer_import_smart_merge.csv | 12 +
myproject/test_customer_validation.csv | 9 +
7 files changed, 695 insertions(+), 19 deletions(-)
create mode 100644 myproject/test_customer_import.csv
create mode 100644 myproject/test_customer_import_smart_merge.csv
create mode 100644 myproject/test_customer_validation.csv
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 %}
+
+
+
+
+
{% 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 плохой и телефон невалидный