diff --git a/customers_mixflowers.by_2025-12-14_20-35-36.xlsx b/customers_mixflowers.by_2025-12-14_20-35-36.xlsx index 44b3e97..7fc84eb 100644 Binary files a/customers_mixflowers.by_2025-12-14_20-35-36.xlsx and b/customers_mixflowers.by_2025-12-14_20-35-36.xlsx differ diff --git a/customers_mixflowers.by_2025-12-14_20-35-36_ERRORS.xlsx b/customers_mixflowers.by_2025-12-14_20-35-36_ERRORS.xlsx new file mode 100644 index 0000000..1caf958 Binary files /dev/null and b/customers_mixflowers.by_2025-12-14_20-35-36_ERRORS.xlsx differ diff --git a/myproject/customers/management/commands/analyze_import.py b/myproject/customers/management/commands/analyze_import.py new file mode 100644 index 0000000..9f38a00 --- /dev/null +++ b/myproject/customers/management/commands/analyze_import.py @@ -0,0 +1,67 @@ +""" +Анализ проблемных строк в XLSX файле для импорта. +Показывает первые 30 строк с проблемными телефонами. +""" +from django.core.management.base import BaseCommand +import os + +try: + from openpyxl import load_workbook +except ImportError: + load_workbook = None + + +class Command(BaseCommand): + help = 'Анализ проблемных данных в файле импорта' + + def add_arguments(self, parser): + parser.add_argument('file_path', type=str, help='Путь к файлу для анализа') + + def handle(self, *args, **options): + file_path = options['file_path'] + + if not os.path.exists(file_path): + self.stdout.write(self.style.ERROR(f'Файл не найден: {file_path}')) + return + + if load_workbook is None: + self.stdout.write(self.style.ERROR('Установите openpyxl')) + return + + wb = load_workbook(file_path, read_only=True, data_only=True) + ws = wb.active + + headers = [] + rows_data = [] + + first_row = True + for idx, row in enumerate(ws.iter_rows(values_only=True), start=1): + if first_row: + headers = [str(v).strip() if v is not None else "" for v in row] + self.stdout.write(f"Заголовки: {headers}\n") + first_row = False + continue + + if not any(row): + continue + + row_dict = {} + for col_idx, value in enumerate(row): + if col_idx < len(headers): + header = headers[col_idx] + row_dict[header] = value + + rows_data.append((idx, row_dict)) + + # Показываем первые 30 строк + self.stdout.write(self.style.SUCCESS(f"\nПервые 30 строк данных:\n")) + self.stdout.write("=" * 100) + + for row_num, data in rows_data[:30]: + self.stdout.write(f"\n[Строка {row_num}]") + for key, val in data.items(): + if val: + self.stdout.write(f" {key}: {val}") + self.stdout.write("-" * 100) + + self.stdout.write(f"\n\nВсего строк с данными: {len(rows_data)}") diff --git a/myproject/customers/management/commands/test_import.py b/myproject/customers/management/commands/test_import.py new file mode 100644 index 0000000..8fb6097 --- /dev/null +++ b/myproject/customers/management/commands/test_import.py @@ -0,0 +1,158 @@ +""" +Management-команда для тестового импорта клиентов из XLSX/CSV файлов. + +Использование: + python manage.py test_import путь/к/файлу.xlsx --schema=anatol [--update] [--export-errors] + +Примеры: + python manage.py test_import ../customers_mixflowers.by_2025-12-14_20-35-36.xlsx --schema=anatol + python manage.py test_import ../customers.csv --schema=anatol --update + python manage.py test_import ../file.xlsx --schema=anatol --export-errors +""" +from django.core.management.base import BaseCommand +from django.core.files import File +from django_tenants.utils import schema_context +from customers.services.import_export import CustomerImporter +import os + +try: + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment +except ImportError: + Workbook = None + + +class Command(BaseCommand): + help = 'Тестовый импорт клиентов из XLSX/CSV файла' + + def add_arguments(self, parser): + parser.add_argument('file_path', type=str, help='Путь к файлу для импорта') + parser.add_argument( + '--schema', + type=str, + required=True, + help='Имя схемы БД тенанта (пример: anatol)' + ) + parser.add_argument( + '--update', + action='store_true', + help='Обновлять существующих клиентов (по email/телефону)', + ) + parser.add_argument( + '--export-errors', + action='store_true', + help='Экспортировать все проблемные строки в отдельный XLSX файл', + ) + + def handle(self, *args, **options): + file_path = options['file_path'] + schema_name = options['schema'] + update_existing = options['update'] + export_errors = options.get('export_errors', False) + + if not os.path.exists(file_path): + self.stdout.write(self.style.ERROR(f'Файл не найден: {file_path}')) + return + + self.stdout.write(f'Импорт из файла: {file_path}') + self.stdout.write(f'Схема тенанта: {schema_name}') + self.stdout.write(f'Режим обновления: {"ВКЛ" if update_existing else "ВЫКЛ"}') + self.stdout.write('-' * 60) + + # Выполняем импорт в контексте схемы тенанта + with schema_context(schema_name): + importer = CustomerImporter() + + with open(file_path, 'rb') as f: + # Создаём простой объект-обёртку для файла + class FakeUploadedFile: + def __init__(self, file_obj, name): + self.file = file_obj + self.name = name + + def __getattr__(self, attr): + # Делегируем все остальные методы внутреннему файловому объекту + return getattr(self.file, attr) + + fake_file = FakeUploadedFile(f, os.path.basename(file_path)) + result = importer.import_from_file(fake_file, update_existing=update_existing) + + self.stdout.write(self.style.SUCCESS(f"\n{result['message']}")) + self.stdout.write('-' * 60) + self.stdout.write(f"Создано: {result['created']}") + self.stdout.write(f"Обновлено: {result['updated']}") + self.stdout.write(f"Пропущено: {result['skipped']}") + self.stdout.write(f"Ошибок: {len(result['errors'])}") + + if result['errors']: + self.stdout.write('\n' + self.style.WARNING('ОШИБКИ:')) + # Показываем первые 20 ошибок, остальные — просто счётчик + for idx, error in enumerate(result['errors'][:20], 1): + row = error.get('row', '?') + email = error.get('email', '') + phone = error.get('phone', '') + reason = error.get('reason', '') + self.stdout.write( + f" [{idx}] Строка {row}: {email or phone or '(пусто)'} - {reason}" + ) + + if len(result['errors']) > 20: + self.stdout.write(f" ... и ещё {len(result['errors']) - 20} ошибок") + + # Экспорт ошибок в XLSX + if export_errors: + self._export_errors_to_xlsx(file_path, result['real_errors']) + + def _export_errors_to_xlsx(self, original_file_path, errors): + """ + Экспортирует все проблемные строки в отдельный XLSX файл. + """ + if Workbook is None: + self.stdout.write(self.style.ERROR('\nНевозможно экспортировать ошибки: openpyxl не установлен')) + return + + # Формируем имя файла для ошибок + base_name = os.path.splitext(os.path.basename(original_file_path))[0] + error_file = f"{base_name}_ERRORS.xlsx" + error_path = os.path.join(os.path.dirname(original_file_path) or '.', error_file) + + # Создаём новую книгу Excel + wb = Workbook() + ws = wb.active + ws.title = "Ошибки импорта" + + # Заголовки с форматированием + headers = ['Строка', 'Email', 'Телефон', 'Причина ошибки'] + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_num, value=header) + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + cell.alignment = Alignment(horizontal="center", vertical="center") + + # Данные об ошибках + for idx, error in enumerate(errors, 2): + ws.cell(row=idx, column=1, value=error.get('row', '')) + ws.cell(row=idx, column=2, value=error.get('email', '')) + ws.cell(row=idx, column=3, value=error.get('phone', '')) + ws.cell(row=idx, column=4, value=error.get('reason', '')) + + # Автоподбор ширины колонок + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if cell.value: + max_length = max(max_length, len(str(cell.value))) + except: + pass + adjusted_width = min(max_length + 2, 80) + ws.column_dimensions[column_letter].width = adjusted_width + + # Сохраняем файл + try: + wb.save(error_path) + self.stdout.write(self.style.SUCCESS(f"\n✓ Файл с ошибками сохранён: {error_path}")) + self.stdout.write(f" Всего строк с ошибками: {len(errors)}") + except Exception as e: + self.stdout.write(self.style.ERROR(f"\n✗ Ошибка при сохранении файла: {e}")) diff --git a/myproject/customers/services/import_export.py b/myproject/customers/services/import_export.py index cfd6f3e..34c3356 100644 --- a/myproject/customers/services/import_export.py +++ b/myproject/customers/services/import_export.py @@ -5,10 +5,24 @@ Разделение на отдельный модуль улучшает организацию кода и следует принципам SRP. """ import csv +import io from django.http import HttpResponse from django.utils import timezone from ..models import Customer +try: + # Для чтения .xlsx файлов + from openpyxl import load_workbook +except ImportError: + load_workbook = None + +try: + import phonenumbers + from phonenumbers import NumberParseException +except ImportError: + phonenumbers = None + NumberParseException = Exception + class CustomerExporter: """ @@ -68,39 +82,414 @@ class CustomerExporter: class CustomerImporter: """ - Класс для импорта клиентов из различных форматов. - - TODO: Реализовать: - - Парсинг CSV файлов - - Парсинг Excel файлов (.xlsx, .xls) - - Валидация данных (email, телефон) - - Обработка дубликатов - - Пакетное создание клиентов - - Отчёт об ошибках + Простой универсальный импорт клиентов из CSV/XLSX. + + Поддерживаемые форматы: + - CSV (UTF-8, заголовок в первой строке) + - XLSX (первая строка — заголовки) + + Алгоритм: + - Читаем файл → получаем headers и list[dict] строк + - По headers строим маппинг на поля Customer: name/email/phone/notes + - Для каждой строки: + - пропускаем полностью пустые строки + - ищем клиента по email, потом по телефону + - если update_existing=False и клиент найден → пропуск + - если update_existing=True → обновляем найденного + - если не найден → создаём нового + - Вся валидация/нормализация делается через Customer.full_clean() """ - + + FIELD_ALIASES = { + "name": ["name", "имя", "фио", "клиент", "фамилияимя"], + "email": ["email", "e-mail", "e_mail", "почта", "элпочта", "электроннаяпочта"], + "phone": ["phone", "телефон", "тел", "моб", "мобильный", "номер"], + "notes": ["notes", "заметки", "комментарий", "comment", "примечание"], + } + def __init__(self): self.errors = [] self.success_count = 0 + self.update_count = 0 self.skip_count = 0 - - def import_from_file(self, file, update_existing=False): + # Отслеживание уже обработанных email/phone для дедупликации внутри файла + self.processed_emails = set() + self.processed_phones = set() + # Отдельный список для реальных ошибок (не дублей из БД) + self.real_errors = [] + + def import_from_file(self, file, update_existing: bool = False) -> dict: """ Импорт клиентов из загруженного файла. - + Args: - file: Загруженный файл (UploadedFile) - update_existing: Обновлять ли существующих клиентов (по email/телефону) + file: UploadedFile + update_existing: обновлять ли существующих клиентов (по email/телефону) + + Returns: + dict: результат импорта + """ + file_format = self._detect_format(file) + + if file_format is None: + return { + "success": False, + "message": "Неподдерживаемый формат файла. Ожидается CSV или XLSX.", + "created": 0, + "updated": 0, + "skipped": 0, + "errors": [{"row": None, "reason": "Unsupported file type"}], + } + + if file_format == "xlsx" and load_workbook is None: + return { + "success": False, + "message": "Для импорта XLSX необходим пакет openpyxl. Установите его и повторите попытку.", + "created": 0, + "updated": 0, + "skipped": 0, + "errors": [{"row": None, "reason": "openpyxl is not installed"}], + } + + try: + if file_format == "csv": + headers, rows = self._read_csv(file) + else: + headers, rows = self._read_xlsx(file) + except Exception as exc: + return { + "success": False, + "message": f"Ошибка чтения файла: {exc}", + "created": 0, + "updated": 0, + "skipped": 0, + "errors": [{"row": None, "reason": str(exc)}], + } + + if not headers: + return { + "success": False, + "message": "В файле не найдены заголовки.", + "created": 0, + "updated": 0, + "skipped": 0, + "errors": [{"row": None, "reason": "Empty header row"}], + } + + mapping = self._build_mapping(headers) + + if not any(field in mapping for field in ("name", "email", "phone")): + return { + "success": False, + "message": "Не удалось сопоставить обязательные поля (имя, email или телефон).", + "created": 0, + "updated": 0, + "skipped": len(rows), + "errors": [ + { + "row": None, + "reason": "No required fields (name/email/phone) mapped from headers", + } + ], + } + + for index, row in enumerate(rows, start=2): # первая строка — заголовки + self._process_row(index, row, mapping, update_existing) + + total_errors = len(self.errors) + success = (self.success_count + self.update_count) > 0 + + if success and total_errors == 0: + message = "Импорт завершён успешно." + elif success and total_errors > 0: + message = "Импорт завершён с ошибками." + else: + message = "Не удалось импортировать данные." + + return { + "success": success, + "message": message, + "created": self.success_count, + "updated": self.update_count, + "skipped": self.skip_count, + "errors": self.errors, + "real_errors": self.real_errors, # Только невалидные данные, без дублей из БД + } + + def _detect_format(self, file) -> str | None: + name = (getattr(file, "name", None) or "").lower() + if name.endswith(".csv"): + return "csv" + if name.endswith(".xlsx") or name.endswith(".xls"): + return "xlsx" + return None + + def _read_csv(self, file): + file.seek(0) + raw = file.read() + if isinstance(raw, bytes): + text = raw.decode("utf-8-sig") + else: + text = raw + f = io.StringIO(text) + reader = csv.DictReader(f) + headers = reader.fieldnames or [] + rows = list(reader) + return headers, rows + + def _read_xlsx(self, file): + file.seek(0) + wb = load_workbook(file, read_only=True, data_only=True) + ws = wb.active + + headers = [] + rows = [] + first_row = True + + for row in ws.iter_rows(values_only=True): + if first_row: + headers = [str(v).strip() if v is not None else "" for v in row] + first_row = False + continue + + if not any(row): + continue + + row_dict = {} + for idx, value in enumerate(row): + if idx < len(headers): + header = headers[idx] or f"col_{idx}" + row_dict[header] = value + rows.append(row_dict) + + return headers, rows + + def _normalize_header(self, header: str) -> str: + if header is None: + return "" + cleaned = "".join(ch for ch in str(header).strip().lower() if ch.isalnum()) + return cleaned + + def _build_mapping(self, headers): + mapping = {} + normalized_aliases = { + field: {self._normalize_header(a) for a in aliases} + for field, aliases in self.FIELD_ALIASES.items() + } + + for header in headers: + norm = self._normalize_header(header) + if not norm: + continue + for field, alias_set in normalized_aliases.items(): + if norm in alias_set and field not in mapping: + mapping[field] = header + break + + return mapping + + def _clean_value(self, value): + if value is None: + return "" + return str(value).strip() + + def _normalize_phone(self, raw_phone: str) -> str | None: + """ + Умная нормализация телефона с попыткой различных регионов. + + Стратегии: + 1. Если начинается с '+' — парсим как международный + 2. Если начинается с '8' и 11 цифр — пробуем BY, потом RU + 3. Пробуем распространённые регионы: BY, RU, PL, DE, US + 4. Если всё не удалось — возвращаем None Returns: - dict: Результат импорта с статистикой + Нормализованный телефон в E.164 формате или None """ - # TODO: Реализовать логику импорта - return { - 'success': False, - 'message': 'Функция импорта находится в разработке', - 'created': 0, - 'updated': 0, - 'skipped': 0, - 'errors': [] - } + if not raw_phone or not phonenumbers: + return None + + # Убираем все символы кроме цифр и + + cleaned = ''.join(c for c in raw_phone if c.isdigit() or c == '+') + + if not cleaned: + return None + + # Стратегия 1: Международный формат (начинается с +) + if cleaned.startswith('+'): + try: + parsed = phonenumbers.parse(cleaned, None) + if phonenumbers.is_valid_number(parsed): + return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + except NumberParseException: + pass + + # Стратегия 2: Формат '8XXXXXXXXXX' (11 цифр) + if cleaned.startswith('8') and len(cleaned) == 11: + for region in ['BY', 'RU']: + try: + parsed = phonenumbers.parse(cleaned, region) + if phonenumbers.is_valid_number(parsed): + return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + except NumberParseException: + continue + + # Стратегия 3: Пробуем распространённые регионы + for region in ['BY', 'RU', 'PL', 'DE', 'US']: + try: + # Добавляем '+' если его нет + test_number = cleaned if cleaned.startswith('+') else f'+{cleaned}' + parsed = phonenumbers.parse(test_number, region) + if phonenumbers.is_valid_number(parsed): + return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + except NumberParseException: + continue + + # Пробуем без '+' + try: + parsed = phonenumbers.parse(cleaned, region) + if phonenumbers.is_valid_number(parsed): + return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + except NumberParseException: + continue + + return None + + 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", ""), "")) + phone_raw = self._clean_value(row.get(mapping.get("phone", ""), "")) + notes = self._clean_value(row.get(mapping.get("notes", ""), "")) + + if not any([name, email, phone_raw, notes]): + self.skip_count += 1 + return + + # Нормализуем email + if email: + email = email.lower().strip() + # Проверка на дубли внутри файла + if email in self.processed_emails: + self.skip_count += 1 + self.errors.append( + { + "row": row_number, + "email": email, + "phone": phone_raw or None, + "reason": "Дубль email внутри файла (уже обработан в предыдущей строке).", + "is_duplicate": True, # Дубликат внутри файла + } + ) + return + + # Умная нормализация телефона + phone = None + phone_normalization_failed = False + if phone_raw: + phone = self._normalize_phone(phone_raw) + if not phone: + phone_normalization_failed = True + else: + # Проверка на дубли внутри файла + if phone in self.processed_phones: + self.skip_count += 1 + self.errors.append( + { + "row": row_number, + "email": email or None, + "phone": phone_raw, + "reason": "Дубль телефона внутри файла (уже обработан в предыдущей строке).", + "is_duplicate": True, # Дубликат внутри файла + } + ) + return + + # Если телефон не удалось нормализовать, сохраняем в notes + if phone_normalization_failed: + note_addition = f"Исходный телефон из импорта (невалидный): {phone_raw}" + if notes: + notes = f"{notes}\n{note_addition}" + else: + notes = note_addition + + # Пытаемся найти существующего клиента + existing = None + if email: + existing = Customer.objects.filter(email=email).first() + if existing is None and phone: + existing = Customer.objects.filter(phone=phone).first() + + if existing and not update_existing: + self.skip_count += 1 + self.errors.append( + { + "row": row_number, + "email": email or None, + "phone": phone_raw or None, + "reason": "Клиент с таким email/телефоном уже существует, обновление отключено.", + "is_duplicate": True, # Помечаем как дубликат из БД + } + ) + return + + if existing and update_existing: + if name: + existing.name = name + if email: + existing.email = email + if phone: + existing.phone = phone + if notes: + if existing.notes: + existing.notes = f"{existing.notes}\n{notes}" + else: + existing.notes = notes + + try: + existing.full_clean() + existing.save() + self.update_count += 1 + if email: + self.processed_emails.add(email) + if phone: + self.processed_phones.add(phone) + except Exception as exc: + self.skip_count += 1 + error_record = { + "row": row_number, + "email": email or None, + "phone": phone_raw or None, + "reason": str(exc), + "is_duplicate": False, + } + self.errors.append(error_record) + self.real_errors.append(error_record) # Реальная ошибка валидации + return + + # Создание нового клиента + customer = Customer( + name=name or "", + email=email or None, + phone=phone or None, # Если не удалось нормализовать — будет None + notes=notes or "", + ) + + try: + customer.full_clean() + customer.save() + self.success_count += 1 + if email: + self.processed_emails.add(email) + if phone: + self.processed_phones.add(phone) + except Exception as exc: + self.skip_count += 1 + error_record = { + "row": row_number, + "email": email or None, + "phone": phone_raw or None, + "reason": str(exc), + "is_duplicate": False, + } + self.errors.append(error_record) + self.real_errors.append(error_record) # Реальная ошибка валидации diff --git a/myproject/customers/templates/customers/customer_list.html b/myproject/customers/templates/customers/customer_list.html index 3b69d0f..20bf7f1 100644 --- a/myproject/customers/templates/customers/customer_list.html +++ b/myproject/customers/templates/customers/customer_list.html @@ -8,7 +8,12 @@