Добавлена система фильтрации клиентов с универсальным поиском
- Реализован универсальный поиск по имени, email и телефону в одной строке - Добавлен счетчик общего количества клиентов - Поиск работает по нажатию Enter или кнопке 'Поиск' - Удалены неиспользуемые фильтры django-filter - Упрощен интерфейс списка клиентов - Добавлена кнопка 'Очистить' для сброса поиска
This commit is contained in:
67
myproject/customers/management/commands/analyze_import.py
Normal file
67
myproject/customers/management/commands/analyze_import.py
Normal file
@@ -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)}")
|
||||
158
myproject/customers/management/commands/test_import.py
Normal file
158
myproject/customers/management/commands/test_import.py
Normal file
@@ -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}"))
|
||||
Reference in New Issue
Block a user