Добавлен асинхронный импорт товаров с параллельной загрузкой фото + исправлен баг со счётчиком SKU

- Реализован импорт Product из CSV/XLSX через Celery с прогресс-баром
- Параллельная загрузка фото товаров с внешних URL (масштабируемость до 500+ товаров)
- Добавлена модель ProductImportJob для отслеживания статуса импорта
- Создан таск download_product_photo_async для загрузки фото в фоне
- Интеграция с существующим ImageProcessor (синхронная обработка через use_async=False)
- Добавлены view и template для импорта с real-time обновлением через AJAX

FIX: Исправлен баг со счётчиком SKU - инкремент только после успешного сохранения
- Добавлен SKUCounter.peek_next_value() - возвращает следующий номер БЕЗ инкремента
- Добавлен SKUCounter.increment_counter() - инкрементирует счётчик
- generate_product_sku() использует peek_next_value() вместо get_next_value()
- Добавлен post_save сигнал increment_sku_counter_after_save() для инкремента после создания
- Предотвращает пропуски номеров при ошибках валидации (например cost_price NULL)

FIX: Исправлена ошибка с is_main в ProductPhoto
- ProductPhoto не имеет поля is_main, используется только order
- Первое фото (order=0) автоматически считается главным
- Удалён параметр is_main из download_product_photo_async и _collect_photo_tasks

Изменены файлы:
- products/models/base.py - методы для управления счётчиком SKU
- products/models/import_job.py - модель для отслеживания импорта
- products/services/import_export.py - сервис импорта с поддержкой Celery
- products/tasks.py - таски для асинхронного импорта и загрузки фото
- products/signals.py - сигнал для инкремента счётчика после сохранения
- products/utils/sku_generator.py - использование peek_next_value()
- products/views/product_import_views.py - view для импорта
- products/templates/products/product_import*.html - UI для импорта
- docker/entrypoint.sh - настройка Celery worker (concurrency=4)
- requirements.txt - добавлен requests для загрузки фото
This commit is contained in:
2026-01-06 07:10:12 +03:00
parent d44ae0b598
commit 0f19542ac9
16 changed files with 1678 additions and 6 deletions

View File

@@ -41,6 +41,14 @@ from .product_views import (
CombinedProductListView,
)
# Импорт товаров
from .product_import_views import (
product_import_view,
product_import_status_view,
product_import_status_api,
download_import_errors,
)
# CRUD представления для ProductKit
from .productkit_views import (
ProductKitListView,
@@ -153,6 +161,10 @@ __all__ = [
'ProductDeleteView',
'CombinedProductListView',
# Product Import/Export
'product_import_view',
'download_import_errors',
# ProductKit CRUD
'ProductKitListView',
'ProductKitCreateView',

View File

@@ -0,0 +1,166 @@
"""
View для импорта товаров из CSV/XLSX файлов.
"""
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, JsonResponse
from django.views.decorators.http import require_http_methods
from django.core.files.storage import default_storage
from django.db import connection
import os
from ..models import ProductImportJob
from ..tasks import import_products_async
@login_required
@require_http_methods(["GET", "POST"])
def product_import_view(request):
"""
Импорт товаров из CSV/XLSX файла (асинхронно через Celery).
GET: показывает форму загрузки
POST: сохраняет файл и запускает асинхронную задачу
"""
if request.method == "POST":
uploaded_file = request.FILES.get('file')
update_existing = request.POST.get('update_existing') == 'on'
if not uploaded_file:
messages.error(request, "Пожалуйста, выберите файл для загрузки.")
return render(request, 'products/product_import.html')
try:
# Сохраняем файл во временную директорию
file_name = uploaded_file.name
temp_dir = 'products/import_temp'
file_path = os.path.join(temp_dir, f"{request.user.id}_{file_name}")
# Создаём директорию если не существует
full_dir = os.path.dirname(default_storage.path(file_path))
os.makedirs(full_dir, exist_ok=True)
# Сохраняем файл
saved_path = default_storage.save(file_path, uploaded_file)
# Получаем schema_name для мультитенантности
schema_name = connection.schema_name
# Создаём задачу импорта
job = ProductImportJob.objects.create(
task_id='', # Заполнится после запуска
user=request.user,
file_name=file_name,
file_path=saved_path,
update_existing=update_existing,
status='pending'
)
# Запускаем асинхронную задачу Celery
task = import_products_async.delay(
job_id=job.id,
file_path=saved_path,
update_existing=update_existing,
schema_name=schema_name
)
# Обновляем task_id
job.task_id = task.id
job.save(update_fields=['task_id'])
messages.success(request, f"Импорт запущен! Отслеживайте прогресс...")
# Редирект на страницу статуса
return redirect('products:product-import-status', job_id=job.id)
except Exception as exc:
messages.error(request, f"Ошибка запуска импорта: {exc}")
return render(request, 'products/product_import.html')
# GET request
return render(request, 'products/product_import.html')
@login_required
def product_import_status_view(request, job_id):
"""
Страница отслеживания статуса импорта.
"""
job = get_object_or_404(ProductImportJob, id=job_id)
context = {
'job': job,
}
return render(request, 'products/product_import_status.html', context)
@login_required
def product_import_status_api(request, job_id):
"""
API endpoint для получения статуса импорта (для AJAX polling).
"""
job = get_object_or_404(ProductImportJob, id=job_id)
data = {
'status': job.status,
'progress_percent': job.progress_percent,
'total_rows': job.total_rows,
'processed_rows': job.processed_rows,
'created_count': job.created_count,
'updated_count': job.updated_count,
'skipped_count': job.skipped_count,
'errors_count': job.errors_count,
'error_message': job.error_message,
'is_finished': job.status in ['completed', 'failed'],
}
return JsonResponse(data)
@login_required
def download_import_errors(request):
"""
Скачивание файла с ошибками импорта.
Генерирует файл с невалидными строками из последнего импорта.
"""
import_errors = request.session.get('import_errors', [])
if not import_errors:
messages.error(request, "Нет ошибок для скачивания.")
return redirect('products:product_import')
# Создаём временный importer для генерации файла
# (нужны original_headers и original_rows, но их нет в сессии)
# Упрощённый вариант: возвращаем просто список ошибок в CSV
import csv
from io import StringIO
from django.utils import timezone
output = StringIO()
output.write('\ufeff') # BOM
writer = csv.writer(output)
writer.writerow(['Строка', 'Название', 'Артикул', 'Причина ошибки'])
for error in import_errors:
writer.writerow([
error.get('row', ''),
error.get('name', ''),
error.get('sku', ''),
error.get('reason', ''),
])
content = output.getvalue().encode('utf-8')
response = HttpResponse(content, content_type='text/csv; charset=utf-8')
timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")
response['Content-Disposition'] = f'attachment; filename="product_import_errors_{timestamp}.csv"'
# Очищаем сессию
request.session.pop('import_errors', None)
request.session.pop('import_has_error_file', None)
return response