Добавлен асинхронный импорт товаров с параллельной загрузкой фото + исправлен баг со счётчиком 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

@@ -0,0 +1,616 @@
"""
Сервис для импорта и экспорта товаров.
Этот модуль содержит логику импорта/экспорта товаров в различных форматах (CSV, Excel).
Разделение на отдельный модуль улучшает организацию кода и следует принципам SRP.
"""
import csv
import io
from decimal import Decimal, InvalidOperation
from django.utils import timezone
from ..models import Product
try:
from openpyxl import load_workbook
except ImportError:
load_workbook = None
import re
class ProductImporter:
"""
Универсальный импорт товаров из CSV/XLSX.
Поддерживаемые форматы:
- CSV (UTF-8, заголовок в первой строке)
- XLSX (первая строка — заголовки)
Алгоритм:
- Читаем файл → получаем headers и list[dict] строк
- По headers строим маппинг на поля Product
- Для каждой строки:
- пропускаем полностью пустые строки
- ищем товар по sku, потом по name
- если update_existing=False и товар найден → пропуск
- если update_existing=True → обновляем найденного
- если не найден → создаём нового
- Вся валидация делается через Product.full_clean()
"""
FIELD_ALIASES = {
"name": ["name", "название", "наименование", "товар", "продукт", "имя"],
"sku": ["sku", "артикул", "код", "code", "article"],
"price": ["price", "цена", "ценапродажи", "стоимость", "pricesale"],
"description": ["description", "описание", "desc"],
"short_description": ["shortdescription", "краткоеописание", "короткоеописание", "краткое"],
"unit": ["unit", "единица", "ед", "едизм", "единицаизмерения"],
"cost_price": ["costprice", "себестоимость", "закупочнаяцена", "cost"],
"sale_price": ["saleprice", "ценасоскидкой", "скидка", "discount", "discountprice"],
"images": ["images", "изображения", "фото", "картинки", "photos", "pictures", "image"],
}
def __init__(self):
self.errors = []
self.success_count = 0
self.update_count = 0
self.skip_count = 0
# Сохраняем исходные данные для генерации error-файла
self.original_headers = []
self.original_rows = []
self.file_format = None
self.real_errors = []
# Для Celery: собираем задачи загрузки фото
self.photo_tasks = []
def import_from_file(self, file, update_existing: bool = False, progress_callback=None, skip_images: bool = False, schema_name: str = None) -> dict:
"""
Импорт товаров из загруженного файла.
Args:
file: UploadedFile
update_existing: обновлять ли существующих товаров (по sku/name)
progress_callback: callback для обновления прогресса (current, total, created, updated, skipped, errors)
skip_images: пропустить загрузку фото (для Celery)
schema_name: схема тенанта (для Celery задач фото)
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"}],
}
# Сохраняем формат файла
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,
"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)
# Минимальное требование: name ИЛИ sku
if not any(field in mapping for field in ("name", "sku")):
return {
"success": False,
"message": "Не удалось сопоставить обязательные поля (название или артикул).",
"created": 0,
"updated": 0,
"skipped": len(rows),
"errors": [
{
"row": None,
"reason": "No required fields (name/sku) mapped from headers",
}
],
}
for index, row in enumerate(rows, start=2): # первая строка — заголовки
self._process_row(index, row, mapping, update_existing, skip_images, schema_name)
# Обновляем прогресс (если есть callback)
if progress_callback:
progress_callback(
current=index - 1, # текущая строка
total=len(rows),
created=self.success_count,
updated=self.update_count,
skipped=self.skip_count,
errors=self.errors
)
total_errors = len(self.errors)
real_error_count = len(self.real_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,
"real_error_count": real_error_count,
"photo_tasks": self.photo_tasks, # Для Celery
}
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 _parse_decimal(self, value: str) -> Decimal | None:
"""
Парсинг decimal значения (цена, себестоимость).
Обрабатывает разные форматы: запятая/точка, пробелы.
"""
if not value:
return None
# Убираем пробелы
value = value.replace(' ', '')
# Заменяем запятую на точку
value = value.replace(',', '.')
try:
return Decimal(value)
except (InvalidOperation, ValueError):
return None
def _process_row(self, row_number: int, row: dict, mapping: dict, update_existing: bool, skip_images: bool = False, schema_name: str = None):
name = self._clean_value(row.get(mapping.get("name", ""), ""))
sku = self._clean_value(row.get(mapping.get("sku", ""), ""))
price_raw = self._clean_value(row.get(mapping.get("price", ""), ""))
description = self._clean_value(row.get(mapping.get("description", ""), ""))
short_description = self._clean_value(row.get(mapping.get("short_description", ""), ""))
unit = self._clean_value(row.get(mapping.get("unit", ""), ""))
cost_price_raw = self._clean_value(row.get(mapping.get("cost_price", ""), ""))
sale_price_raw = self._clean_value(row.get(mapping.get("sale_price", ""), ""))
images_raw = self._clean_value(row.get(mapping.get("images", ""), ""))
# Пропускаем полностью пустые строки
if not any([name, sku, price_raw, description]):
self.skip_count += 1
return
# Минимальное требование: name ИЛИ sku
if not name and not sku:
self.skip_count += 1
error_record = {
"row": row_number,
"name": name or None,
"sku": sku or None,
"reason": "Требуется хотя бы одно: название или артикул",
}
self.errors.append(error_record)
self.real_errors.append(error_record)
return
# Парсим цены
price = self._parse_decimal(price_raw)
cost_price = self._parse_decimal(cost_price_raw)
sale_price = self._parse_decimal(sale_price_raw)
# Цена обязательна
if price is None:
self.skip_count += 1
error_record = {
"row": row_number,
"name": name or None,
"sku": sku or None,
"reason": "Требуется корректная цена (price)",
}
self.errors.append(error_record)
self.real_errors.append(error_record)
return
# Единица измерения по умолчанию
if not unit:
unit = 'шт'
# Валидация единицы измерения
valid_units = [choice[0] for choice in Product.UNIT_CHOICES]
if unit not in valid_units:
unit = 'шт' # fallback
# Пытаемся найти существующего товара
existing = None
if sku:
existing = Product.objects.filter(sku=sku, status='active').first()
if existing is None and name:
existing = Product.objects.filter(name=name, status='active').first()
if existing and not update_existing:
self.skip_count += 1
self.errors.append({
"row": row_number,
"name": name or None,
"sku": sku or None,
"reason": "Товар с таким артикулом/названием уже существует, обновление отключено.",
})
return
if existing and update_existing:
# Обновление существующего товара
if name:
existing.name = name
if sku:
existing.sku = sku
if description:
existing.description = description
if short_description:
existing.short_description = short_description
if unit:
existing.unit = unit
existing.price = price
if cost_price is not None:
existing.cost_price = cost_price
if sale_price is not None:
existing.sale_price = sale_price
try:
existing.full_clean()
existing.save()
self.update_count += 1
# Обрабатываем изображения (если есть)
if images_raw:
if skip_images:
# Для Celery: собираем задачи загрузки фото
self._collect_photo_tasks(existing, images_raw)
else:
# Синхронно загружаем фото
self._process_product_images(existing, images_raw)
except Exception as exc:
self.skip_count += 1
error_record = {
"row": row_number,
"name": name or None,
"sku": sku or None,
"reason": str(exc),
}
self.errors.append(error_record)
self.real_errors.append(error_record)
return
# Создание нового товара
product = Product(
name=name or f"Товар {sku}", # fallback если нет имени
sku=sku or None,
description=description or "",
short_description=short_description or "",
unit=unit,
price=price,
cost_price=cost_price or 0, # Устанавливаем 0 вместо None (для CostPriceHistory)
sale_price=sale_price,
status='active',
)
try:
product.full_clean()
product.save()
self.success_count += 1
# Обрабатываем изображения (если есть)
if images_raw:
if skip_images:
# Для Celery: собираем задачи загрузки фото
self._collect_photo_tasks(product, images_raw)
else:
# Синхронно загружаем фото
self._process_product_images(product, images_raw)
except Exception as exc:
self.skip_count += 1
error_record = {
"row": row_number,
"name": name or None,
"sku": sku or None,
"reason": str(exc),
}
self.errors.append(error_record)
self.real_errors.append(error_record)
def _collect_photo_tasks(self, product, images_raw: str):
"""
Собираем задачи загрузки фото для параллельной обработки через Celery.
Args:
product: Объект Product
images_raw: Строка с URL изображений
"""
# Разбиваем на отдельные URL
urls = []
for line in images_raw.split('\n'):
line = line.strip()
if not line:
continue
# Если в строке несколько URL через запятую
for url in line.split(','):
url = url.strip()
if url.startswith('http'):
urls.append(url)
if not urls:
return
# Создаём задачи для каждого URL
for idx, url in enumerate(urls):
# FIX: ProductPhoto не имеет is_main, используется только order
task = {
'product_id': product.id,
'url': url,
'order': idx, # Первое фото (order=0) автоматически главное
}
self.photo_tasks.append(task)
def _process_product_images(self, product, images_raw: str):
"""
Обработка изображений товара из URL.
Поддерживаемые форматы:
- Один URL: https://example.com/image.jpg
- Несколько URL через перенос строки или запятую
Args:
product: Объект Product
images_raw: Строка с URL изображений
"""
import requests
from django.core.files.base import ContentFile
from ..models import ProductPhoto
import urllib.parse
# Разбиваем на отдельные URL
urls = []
for line in images_raw.split('\n'):
line = line.strip()
if not line:
continue
# Если в строке несколько URL через запятую
for url in line.split(','):
url = url.strip()
if url.startswith('http'):
urls.append(url)
if not urls:
return
# Скачиваем и сохраняем каждое изображение
for idx, url in enumerate(urls):
try:
# Скачиваем изображение
response = requests.get(url, timeout=10)
response.raise_for_status()
# Получаем имя файла из URL
parsed_url = urllib.parse.urlparse(url)
filename = parsed_url.path.split('/')[-1]
# Создаём ProductPhoto
# FIX: ProductPhoto не имеет is_main, используется только order
photo = ProductPhoto(
product=product,
order=idx # Первое фото (order=0) автоматически главное
)
# Сохраняем файл
photo.image.save(
filename,
ContentFile(response.content),
save=True
)
except Exception as e:
# Игнорируем ошибки загрузки изображений
# чтобы не прерывать импорт товара
continue
def generate_error_file(self) -> tuple[bytes, str] | None:
"""
Генерирует файл с ошибочными строками.
Возвращает тот же формат, что был загружен (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):
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 файл с ошибками."""
output = io.StringIO()
output.write('\ufeff') # BOM для Excel
writer = csv.DictWriter(output, fieldnames=headers, extrasaction='ignore')
writer.writeheader()
writer.writerows(rows)
content = output.getvalue().encode('utf-8')
filename = f'product_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:
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)
output = io.BytesIO()
wb.save(output)
output.seek(0)
content = output.read()
filename = f'product_import_errors_{timestamp}.xlsx'
return content, filename
except Exception:
return self._generate_csv_error_file(headers, rows, timestamp)