Dobavlen funkcional importa i eksporta klientov s validaciey i umnym sliyaniem kontaktov
This commit is contained in:
@@ -111,12 +111,18 @@ class CustomerImporter:
|
|||||||
self.errors = []
|
self.errors = []
|
||||||
self.success_count = 0
|
self.success_count = 0
|
||||||
self.update_count = 0
|
self.update_count = 0
|
||||||
|
self.enriched_count = 0 # Дополнены пустые поля
|
||||||
|
self.conflicts_resolved = 0 # Альтернативные контакты через ContactChannel
|
||||||
self.skip_count = 0
|
self.skip_count = 0
|
||||||
# Отслеживание уже обработанных email/phone для дедупликации внутри файла
|
# Отслеживание уже обработанных email/phone для дедупликации внутри файла
|
||||||
self.processed_emails = set()
|
self.processed_emails = set()
|
||||||
self.processed_phones = set()
|
self.processed_phones = set()
|
||||||
# Отдельный список для реальных ошибок (не дублей из БД)
|
# Отдельный список для реальных ошибок (не дублей из БД)
|
||||||
self.real_errors = []
|
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:
|
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"}],
|
"errors": [{"row": None, "reason": "openpyxl is not installed"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Сохраняем формат файла
|
||||||
|
self.file_format = file_format
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if file_format == "csv":
|
if file_format == "csv":
|
||||||
headers, rows = self._read_csv(file)
|
headers, rows = self._read_csv(file)
|
||||||
else:
|
else:
|
||||||
headers, rows = self._read_xlsx(file)
|
headers, rows = self._read_xlsx(file)
|
||||||
|
|
||||||
|
# Сохраняем исходные данные
|
||||||
|
self.original_headers = headers
|
||||||
|
self.original_rows = rows
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -197,6 +210,8 @@ class CustomerImporter:
|
|||||||
self._process_row(index, row, mapping, update_existing)
|
self._process_row(index, row, mapping, update_existing)
|
||||||
|
|
||||||
total_errors = len(self.errors)
|
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
|
success = (self.success_count + self.update_count) > 0
|
||||||
|
|
||||||
if success and total_errors == 0:
|
if success and total_errors == 0:
|
||||||
@@ -211,9 +226,13 @@ class CustomerImporter:
|
|||||||
"message": message,
|
"message": message,
|
||||||
"created": self.success_count,
|
"created": self.success_count,
|
||||||
"updated": self.update_count,
|
"updated": self.update_count,
|
||||||
|
"enriched": self.enriched_count,
|
||||||
|
"conflicts_resolved": self.conflicts_resolved,
|
||||||
"skipped": self.skip_count,
|
"skipped": self.skip_count,
|
||||||
"errors": self.errors,
|
"errors": self.errors,
|
||||||
"real_errors": self.real_errors, # Только невалидные данные, без дублей из БД
|
"real_errors": self.real_errors, # Только невалидные данные, без дублей из БД
|
||||||
|
"duplicate_count": duplicate_count,
|
||||||
|
"real_error_count": real_error_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _detect_format(self, file) -> str | None:
|
def _detect_format(self, file) -> str | None:
|
||||||
@@ -355,6 +374,94 @@ class CustomerImporter:
|
|||||||
|
|
||||||
return None
|
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):
|
def _process_row(self, row_number: int, row: dict, mapping: dict, update_existing: bool):
|
||||||
name = self._clean_value(row.get(mapping.get("name", ""), ""))
|
name = self._clean_value(row.get(mapping.get("name", ""), ""))
|
||||||
email = self._clean_value(row.get(mapping.get("email", ""), ""))
|
email = self._clean_value(row.get(mapping.get("email", ""), ""))
|
||||||
@@ -412,6 +519,24 @@ class CustomerImporter:
|
|||||||
else:
|
else:
|
||||||
notes = note_addition
|
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
|
existing = None
|
||||||
if email:
|
if email:
|
||||||
@@ -433,12 +558,49 @@ class CustomerImporter:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if existing and update_existing:
|
if existing and update_existing:
|
||||||
|
# Умное слияние (smart merge)
|
||||||
|
was_enriched = False # Флаг дополнения пустых полей
|
||||||
|
|
||||||
|
# 1. Имя - выбираем лучшее
|
||||||
if name:
|
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:
|
if email:
|
||||||
|
if not existing.email:
|
||||||
|
# Пустое поле - дополняем
|
||||||
|
was_enriched = True
|
||||||
existing.email = email
|
existing.email = email
|
||||||
|
elif existing.email != email:
|
||||||
|
# Конфликт - создаём альтернативный контакт
|
||||||
|
if self._create_alternative_contact(existing, 'email', email):
|
||||||
|
self.conflicts_resolved += 1
|
||||||
|
|
||||||
|
# 3. Телефон - дополняем или создаём ContactChannel
|
||||||
if phone:
|
if phone:
|
||||||
|
if not existing.phone:
|
||||||
|
# Пустое поле - дополняем
|
||||||
|
was_enriched = True
|
||||||
existing.phone = phone
|
existing.phone = phone
|
||||||
|
elif str(existing.phone) != phone:
|
||||||
|
# Конфликт - создаём альтернативный контакт
|
||||||
|
if self._create_alternative_contact(existing, 'phone', phone):
|
||||||
|
self.conflicts_resolved += 1
|
||||||
|
|
||||||
|
# 4. Заметки - ВСЕГДА дописываем
|
||||||
if notes:
|
if notes:
|
||||||
if existing.notes:
|
if existing.notes:
|
||||||
existing.notes = f"{existing.notes}\n{notes}"
|
existing.notes = f"{existing.notes}\n{notes}"
|
||||||
@@ -448,7 +610,13 @@ class CustomerImporter:
|
|||||||
try:
|
try:
|
||||||
existing.full_clean()
|
existing.full_clean()
|
||||||
existing.save()
|
existing.save()
|
||||||
|
|
||||||
|
# Счётчики
|
||||||
|
if was_enriched:
|
||||||
|
self.enriched_count += 1
|
||||||
|
else:
|
||||||
self.update_count += 1
|
self.update_count += 1
|
||||||
|
|
||||||
if email:
|
if email:
|
||||||
self.processed_emails.add(email)
|
self.processed_emails.add(email)
|
||||||
if phone:
|
if phone:
|
||||||
@@ -493,3 +661,99 @@ class CustomerImporter:
|
|||||||
}
|
}
|
||||||
self.errors.append(error_record)
|
self.errors.append(error_record)
|
||||||
self.real_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)
|
||||||
|
|||||||
@@ -30,19 +30,142 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Результаты импорта -->
|
||||||
|
{% if import_result %}
|
||||||
|
<div class="card mb-4 border-{{ import_result.success|yesno:'success,danger' }}">
|
||||||
|
<div class="card-header bg-{{ import_result.success|yesno:'success,danger' }} text-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-{{ import_result.success|yesno:'check-circle,exclamation-triangle' }}"></i>
|
||||||
|
Результаты импорта
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-center p-3 bg-light rounded">
|
||||||
|
<div class="h2 mb-0 text-success">{{ import_result.created }}</div>
|
||||||
|
<small class="text-muted">Создано</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-center p-3 bg-light rounded">
|
||||||
|
<div class="h2 mb-0 text-primary">{{ import_result.enriched }}</div>
|
||||||
|
<small class="text-muted">Дополнено</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-center p-3 bg-light rounded">
|
||||||
|
<div class="h2 mb-0 text-info">{{ import_result.updated }}</div>
|
||||||
|
<small class="text-muted">Обновлено</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-center p-3 bg-light rounded">
|
||||||
|
<div class="h2 mb-0 text-secondary">{{ import_result.conflicts_resolved }}</div>
|
||||||
|
<small class="text-muted">Альт. контакты</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-center p-3 bg-light rounded">
|
||||||
|
<div class="h2 mb-0 text-warning">{{ import_result.duplicate_count }}</div>
|
||||||
|
<small class="text-muted">Дубликатов</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-center p-3 bg-light rounded">
|
||||||
|
<div class="h2 mb-0 text-danger">{{ import_result.real_error_count }}</div>
|
||||||
|
<small class="text-muted">Ошибок</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if import_result.real_errors %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
Обнаружено {{ import_result.real_error_count }} ошибок валидации
|
||||||
|
</h6>
|
||||||
|
<p class="mb-2">Первые ошибки:</p>
|
||||||
|
<ul class="mb-0">
|
||||||
|
{% for error in import_result.real_errors|slice:":10" %}
|
||||||
|
<li>
|
||||||
|
<strong>Строка {{ error.row }}:</strong> {{ error.reason }}
|
||||||
|
{% if error.email %}<code>{{ error.email }}</code>{% endif %}
|
||||||
|
{% if error.phone %}<code>{{ error.phone }}</code>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if import_result.real_error_count > 10 %}
|
||||||
|
<p class="mb-0 mt-2 text-muted">
|
||||||
|
<small>...и ещё {{ import_result.real_error_count|add:"-10" }} ошибок</small>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if has_error_file %}
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="{% url 'customers:customer-import-download-errors' %}" class="btn btn-danger btn-lg">
|
||||||
|
<i class="bi bi-download"></i> Скачать файл с ошибками
|
||||||
|
</a>
|
||||||
|
<p class="text-muted mt-2 mb-0">
|
||||||
|
<small>Исправьте ошибки в файле и загрузите снова</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if import_result.duplicate_count > 0 %}
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
Пропущено дубликатов: {{ import_result.duplicate_count }}
|
||||||
|
(клиенты с такими email/телефонами уже существуют)
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Форма загрузки -->
|
<!-- Форма загрузки -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data" id="importForm">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Drag & Drop зона -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="file" class="form-label">Выберите файл</label>
|
<label for="file" class="form-label">Выберите файл</label>
|
||||||
<input type="file" class="form-control" id="file" name="file"
|
|
||||||
|
<div id="dropZone" class="border border-2 border-dashed rounded p-4 text-center position-relative"
|
||||||
|
style="min-height: 150px; cursor: pointer; transition: all 0.3s;">
|
||||||
|
<input type="file" class="form-control d-none" id="file" name="file"
|
||||||
accept=".csv,.xlsx,.xls" required>
|
accept=".csv,.xlsx,.xls" required>
|
||||||
<small class="form-text text-muted">
|
|
||||||
Поддерживаемые форматы: CSV, Excel (.xlsx, .xls)
|
<div id="dropZoneContent">
|
||||||
|
<i class="bi bi-cloud-upload fs-1 text-muted"></i>
|
||||||
|
<p class="mt-3 mb-2">
|
||||||
|
<strong>Перетащите файл сюда</strong> или
|
||||||
|
<span class="text-primary">нажмите для выбора</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
<small>
|
||||||
|
Поддерживаемые форматы: CSV, Excel (.xlsx, .xls)<br>
|
||||||
|
Также можно вставить файл через <kbd>Ctrl+V</kbd>
|
||||||
</small>
|
</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="filePreview" class="d-none">
|
||||||
|
<i class="bi bi-file-earmark-spreadsheet fs-1 text-success"></i>
|
||||||
|
<p class="mt-3 mb-0">
|
||||||
|
<strong id="fileName"></strong>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted" id="fileSize"></small>
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger mt-2" id="removeFile">
|
||||||
|
<i class="bi bi-x-circle"></i> Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-check mb-3">
|
<div class="form-check mb-3">
|
||||||
@@ -52,12 +175,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-exclamation-triangle"></i>
|
|
||||||
<strong>Внимание!</strong> Функция импорта находится в разработке.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" disabled>
|
|
||||||
<i class="bi bi-upload"></i> Импортировать
|
<i class="bi bi-upload"></i> Импортировать
|
||||||
</button>
|
</button>
|
||||||
<a href="{% url 'customers:customer-list' %}" class="btn btn-outline-secondary">
|
<a href="{% url 'customers:customer-list' %}" class="btn btn-outline-secondary">
|
||||||
@@ -70,4 +188,142 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const dropZone = document.getElementById('dropZone');
|
||||||
|
const fileInput = document.getElementById('file');
|
||||||
|
const dropZoneContent = document.getElementById('dropZoneContent');
|
||||||
|
const filePreview = document.getElementById('filePreview');
|
||||||
|
const fileName = document.getElementById('fileName');
|
||||||
|
const fileSize = document.getElementById('fileSize');
|
||||||
|
const removeFileBtn = document.getElementById('removeFile');
|
||||||
|
const importForm = document.getElementById('importForm');
|
||||||
|
|
||||||
|
// Клик по зоне = открыть диалог выбора файла
|
||||||
|
dropZone.addEventListener('click', function(e) {
|
||||||
|
if (e.target.id !== 'removeFile' && !e.target.closest('#removeFile')) {
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработка выбора файла через input
|
||||||
|
fileInput.addEventListener('change', function(e) {
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
|
showFilePreview(e.target.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag & Drop события
|
||||||
|
dropZone.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dropZone.classList.add('border-primary', 'bg-light');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragleave', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dropZone.classList.remove('border-primary', 'bg-light');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dropZone.classList.remove('border-primary', 'bg-light');
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
if (isValidFile(file)) {
|
||||||
|
// Присваиваем файл в input через DataTransfer
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(file);
|
||||||
|
fileInput.files = dataTransfer.files;
|
||||||
|
showFilePreview(file);
|
||||||
|
} else {
|
||||||
|
alert('Неподдерживаемый формат файла. Используйте CSV или Excel (.xlsx, .xls)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paste через Ctrl+V
|
||||||
|
document.addEventListener('paste', function(e) {
|
||||||
|
const items = e.clipboardData.items;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
|
||||||
|
// Проверяем, есть ли файл в буфере
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file && isValidFile(file)) {
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(file);
|
||||||
|
fileInput.files = dataTransfer.files;
|
||||||
|
showFilePreview(file);
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Показать превью файла
|
||||||
|
function showFilePreview(file) {
|
||||||
|
fileName.textContent = file.name;
|
||||||
|
fileSize.textContent = formatFileSize(file.size);
|
||||||
|
dropZoneContent.classList.add('d-none');
|
||||||
|
filePreview.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удалить файл
|
||||||
|
removeFileBtn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
fileInput.value = '';
|
||||||
|
filePreview.classList.add('d-none');
|
||||||
|
dropZoneContent.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверка формата файла
|
||||||
|
function isValidFile(file) {
|
||||||
|
const validExtensions = ['.csv', '.xlsx', '.xls'];
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
return validExtensions.some(ext => fileName.endsWith(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматирование размера файла
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация перед отправкой
|
||||||
|
importForm.addEventListener('submit', function(e) {
|
||||||
|
if (!fileInput.files || fileInput.files.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Пожалуйста, выберите файл для импорта');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#dropZone {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dropZone:hover {
|
||||||
|
border-color: var(--bs-primary) !important;
|
||||||
|
background-color: rgba(var(--bs-primary-rgb), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dropZone.border-primary {
|
||||||
|
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ urlpatterns = [
|
|||||||
path('', views.customer_list, name='customer-list'),
|
path('', views.customer_list, name='customer-list'),
|
||||||
path('create/', views.customer_create, name='customer-create'),
|
path('create/', views.customer_create, name='customer-create'),
|
||||||
path('import/', views.customer_import, name='customer-import'),
|
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('export/', views.customer_export, name='customer-export'),
|
||||||
path('<int:pk>/', views.customer_detail, name='customer-detail'),
|
path('<int:pk>/', views.customer_detail, name='customer-detail'),
|
||||||
path('<int:pk>/delete/', views.customer_delete, name='customer-delete'),
|
path('<int:pk>/delete/', views.customer_delete, name='customer-delete'),
|
||||||
|
|||||||
@@ -737,13 +737,76 @@ def customer_import(request):
|
|||||||
"""
|
"""
|
||||||
Импорт клиентов из CSV/Excel файла.
|
Импорт клиентов из CSV/Excel файла.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from django.conf import settings
|
||||||
from .services.import_export import CustomerImporter
|
from .services.import_export import CustomerImporter
|
||||||
|
|
||||||
if request.method == 'POST':
|
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()
|
importer = CustomerImporter()
|
||||||
# TODO: Обработка загруженного файла
|
result = importer.import_from_file(file, update_existing=update_existing)
|
||||||
messages.info(request, 'Функция импорта в разработке')
|
|
||||||
return redirect('customers:customer-list')
|
# Формируем сообщения о результате
|
||||||
|
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 = {
|
context = {
|
||||||
'title': 'Импорт клиентов',
|
'title': 'Импорт клиентов',
|
||||||
@@ -751,6 +814,71 @@ def customer_import(request):
|
|||||||
return render(request, 'customers/customer_import.html', context)
|
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
|
@login_required
|
||||||
@manager_or_owner_required
|
@manager_or_owner_required
|
||||||
def customer_export(request):
|
def customer_export(request):
|
||||||
|
|||||||
6
myproject/test_customer_import.csv
Normal file
6
myproject/test_customer_import.csv
Normal file
@@ -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,,Без телефона - норм
|
||||||
|
12
myproject/test_customer_import_smart_merge.csv
Normal file
12
myproject/test_customer_import_smart_merge.csv
Normal file
@@ -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
|
||||||
|
9
myproject/test_customer_validation.csv
Normal file
9
myproject/test_customer_validation.csv
Normal file
@@ -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 плохой и телефон невалидный
|
||||||
|
Reference in New Issue
Block a user