Добавлен асинхронный импорт товаров с параллельной загрузкой фото + исправлен баг со счётчиком 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:
@@ -214,11 +214,10 @@ case "$1" in
|
||||
wait_for_postgres
|
||||
wait_for_redis
|
||||
setup_directories
|
||||
echo "Starting Celery Worker..."
|
||||
echo "Starting Celery Worker for photo processing and product import..."
|
||||
exec celery -A myproject worker \
|
||||
-l info \
|
||||
-Q celery,photo_processing \
|
||||
--concurrency=2
|
||||
--concurrency=4
|
||||
;;
|
||||
celery-beat)
|
||||
wait_for_postgres
|
||||
|
||||
45
myproject/products/migrations/0002_productimportjob.py
Normal file
45
myproject/products/migrations/0002_productimportjob.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.0.10 on 2026-01-06 03:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProductImportJob',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('task_id', models.CharField(max_length=255, unique=True, verbose_name='ID задачи Celery')),
|
||||
('file_name', models.CharField(max_length=255, verbose_name='Имя файла')),
|
||||
('file_path', models.CharField(help_text='Временный путь для обработки', max_length=500, verbose_name='Путь к файлу')),
|
||||
('update_existing', models.BooleanField(default=False, verbose_name='Обновлять существующие')),
|
||||
('status', models.CharField(choices=[('pending', 'Ожидает'), ('processing', 'Обрабатывается'), ('completed', 'Завершён'), ('failed', 'Ошибка')], db_index=True, default='pending', max_length=20, verbose_name='Статус')),
|
||||
('total_rows', models.IntegerField(default=0, verbose_name='Всего строк')),
|
||||
('processed_rows', models.IntegerField(default=0, verbose_name='Обработано строк')),
|
||||
('created_count', models.IntegerField(default=0, verbose_name='Создано товаров')),
|
||||
('updated_count', models.IntegerField(default=0, verbose_name='Обновлено товаров')),
|
||||
('skipped_count', models.IntegerField(default=0, verbose_name='Пропущено')),
|
||||
('errors_count', models.IntegerField(default=0, verbose_name='Ошибок')),
|
||||
('errors_json', models.JSONField(blank=True, default=list, verbose_name='Детали ошибок')),
|
||||
('error_message', models.TextField(blank=True, verbose_name='Сообщение об ошибке')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Завершено')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_import_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Задача импорта товаров',
|
||||
'verbose_name_plural': 'Задачи импорта товаров',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['task_id'], name='products_pr_task_id_d8dc9f_idx'), models.Index(fields=['status', '-created_at'], name='products_pr_status_326f2c_idx'), models.Index(fields=['user', '-created_at'], name='products_pr_user_id_e32ca9_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -46,6 +46,9 @@ from .units import UnitOfMeasure, ProductSalesUnit
|
||||
# Фотографии
|
||||
from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus
|
||||
|
||||
# Задачи импорта
|
||||
from .import_job import ProductImportJob
|
||||
|
||||
# Явно указываем, что экспортируется при импорте *
|
||||
__all__ = [
|
||||
# Managers
|
||||
@@ -92,4 +95,7 @@ __all__ = [
|
||||
'ProductKitPhoto',
|
||||
'ProductCategoryPhoto',
|
||||
'PhotoProcessingStatus',
|
||||
|
||||
# Import Jobs
|
||||
'ProductImportJob',
|
||||
]
|
||||
|
||||
@@ -53,6 +53,55 @@ class SKUCounter(models.Model):
|
||||
"""
|
||||
Получить следующее значение счетчика (thread-safe).
|
||||
Использует select_for_update для предотвращения race conditions.
|
||||
|
||||
DEPRECATED: Используйте peek_next_value() + increment_counter() вместо этого метода.
|
||||
Этот метод инкрементирует счётчик немедленно, что может привести к пропускам номеров
|
||||
при ошибках сохранения объекта.
|
||||
"""
|
||||
with transaction.atomic():
|
||||
counter, created = cls.objects.select_for_update().get_or_create(
|
||||
counter_type=counter_type,
|
||||
defaults={'current_value': 0}
|
||||
)
|
||||
counter.current_value += 1
|
||||
counter.save()
|
||||
return counter.current_value
|
||||
|
||||
@classmethod
|
||||
def peek_next_value(cls, counter_type):
|
||||
"""
|
||||
FIX: SKU counter bug - increment only after successful save
|
||||
|
||||
Получить следующее значение счетчика БЕЗ инкремента (thread-safe).
|
||||
Используется для генерации SKU перед сохранением объекта.
|
||||
Фактический инкремент выполняется в post_save сигнале после успешного создания.
|
||||
|
||||
Args:
|
||||
counter_type: Тип счётчика ('product', 'kit', 'category', 'configurable')
|
||||
|
||||
Returns:
|
||||
int: Следующее значение счётчика (current_value + 1)
|
||||
"""
|
||||
with transaction.atomic():
|
||||
counter, created = cls.objects.select_for_update().get_or_create(
|
||||
counter_type=counter_type,
|
||||
defaults={'current_value': 0}
|
||||
)
|
||||
return counter.current_value + 1
|
||||
|
||||
@classmethod
|
||||
def increment_counter(cls, counter_type):
|
||||
"""
|
||||
FIX: SKU counter bug - increment only after successful save
|
||||
|
||||
Инкрементировать счётчик (thread-safe).
|
||||
Вызывается в post_save сигнале после успешного создания объекта с автогенерированным SKU.
|
||||
|
||||
Args:
|
||||
counter_type: Тип счётчика ('product', 'kit', 'category', 'configurable')
|
||||
|
||||
Returns:
|
||||
int: Новое значение счётчика после инкремента
|
||||
"""
|
||||
with transaction.atomic():
|
||||
counter, created = cls.objects.select_for_update().get_or_create(
|
||||
|
||||
142
myproject/products/models/import_job.py
Normal file
142
myproject/products/models/import_job.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Модель для отслеживания задач импорта товаров.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ProductImportJob(models.Model):
|
||||
"""
|
||||
Задача импорта товаров через Celery.
|
||||
Отслеживает прогресс и результаты импорта.
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Ожидает'),
|
||||
('processing', 'Обрабатывается'),
|
||||
('completed', 'Завершён'),
|
||||
('failed', 'Ошибка'),
|
||||
]
|
||||
|
||||
# Celery task ID
|
||||
task_id = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
verbose_name="ID задачи Celery"
|
||||
)
|
||||
|
||||
# Пользователь
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='product_import_jobs',
|
||||
verbose_name="Пользователь"
|
||||
)
|
||||
|
||||
# Информация о файле
|
||||
file_name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name="Имя файла"
|
||||
)
|
||||
file_path = models.CharField(
|
||||
max_length=500,
|
||||
verbose_name="Путь к файлу",
|
||||
help_text="Временный путь для обработки"
|
||||
)
|
||||
|
||||
# Параметры импорта
|
||||
update_existing = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Обновлять существующие"
|
||||
)
|
||||
|
||||
# Статус
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='pending',
|
||||
db_index=True,
|
||||
verbose_name="Статус"
|
||||
)
|
||||
|
||||
# Прогресс
|
||||
total_rows = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name="Всего строк"
|
||||
)
|
||||
processed_rows = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name="Обработано строк"
|
||||
)
|
||||
|
||||
# Результаты
|
||||
created_count = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name="Создано товаров"
|
||||
)
|
||||
updated_count = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name="Обновлено товаров"
|
||||
)
|
||||
skipped_count = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name="Пропущено"
|
||||
)
|
||||
errors_count = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name="Ошибок"
|
||||
)
|
||||
|
||||
# Детали ошибок (JSON)
|
||||
errors_json = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
verbose_name="Детали ошибок"
|
||||
)
|
||||
|
||||
# Сообщение об ошибке (если task упал)
|
||||
error_message = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Сообщение об ошибке"
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Создано"
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name="Обновлено"
|
||||
)
|
||||
completed_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Завершено"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Задача импорта товаров"
|
||||
verbose_name_plural = "Задачи импорта товаров"
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['task_id']),
|
||||
models.Index(fields=['status', '-created_at']),
|
||||
models.Index(fields=['user', '-created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Импорт {self.file_name} ({self.get_status_display()})"
|
||||
|
||||
@property
|
||||
def progress_percent(self):
|
||||
"""Процент выполнения"""
|
||||
if self.total_rows == 0:
|
||||
return 0
|
||||
return int((self.processed_rows / self.total_rows) * 100)
|
||||
|
||||
@property
|
||||
def is_finished(self):
|
||||
"""Завершена ли задача (успешно или с ошибкой)"""
|
||||
return self.status in ['completed', 'failed']
|
||||
616
myproject/products/services/import_export.py
Normal file
616
myproject/products/services/import_export.py
Normal 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)
|
||||
@@ -2,13 +2,15 @@
|
||||
Signals для приложения products.
|
||||
|
||||
Логирует изменения себестоимости товара через CostPriceHistory.
|
||||
FIX: SKU counter bug - инкрементирует счётчик после успешного создания Product.
|
||||
"""
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.db import models
|
||||
import re
|
||||
|
||||
from .models import Product, CostPriceHistory
|
||||
from .models import Product, CostPriceHistory, SKUCounter
|
||||
|
||||
|
||||
@receiver(post_save, sender=Product)
|
||||
@@ -48,3 +50,24 @@ def log_cost_price_changes(sender, instance, created, **kwargs):
|
||||
reason='recalculation',
|
||||
notes='Себестоимость пересчитана на основе партий товара'
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Product)
|
||||
def increment_sku_counter_after_save(sender, instance, created, **kwargs):
|
||||
"""
|
||||
FIX: SKU counter bug - increment only after successful save
|
||||
|
||||
Инкрементирует счётчик SKU ПОСЛЕ успешного создания товара с автогенерированным артикулом.
|
||||
|
||||
Это предотвращает пропуски номеров при ошибках сохранения (например, валидация cost_price).
|
||||
Счётчик инкрементируется только если:
|
||||
- Товар только что создан (created=True)
|
||||
- SKU соответствует автогенерированному формату (PROD-XXXXXX)
|
||||
"""
|
||||
if not created:
|
||||
return
|
||||
|
||||
# Проверяем что SKU был автогенерирован (формат PROD-XXXXXX)
|
||||
if instance.sku and re.match(r'^PROD-\d{6}$', instance.sku):
|
||||
# Инкрементируем счётчик product
|
||||
SKUCounter.increment_counter('product')
|
||||
|
||||
@@ -371,3 +371,251 @@ def cleanup_temp_media_all(ttl_hours=None):
|
||||
except Exception as exc:
|
||||
logger.error(f"[CleanupAll] Failed to schedule: {exc}", exc_info=True)
|
||||
return {'status': 'error', 'error': str(exc)}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ИМПОРТ ТОВАРОВ
|
||||
# ============================================================================
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name='products.tasks.import_products_async',
|
||||
max_retries=0, # Не повторяем автоматически
|
||||
time_limit=3600, # 1 час максимум
|
||||
)
|
||||
def import_products_async(self, job_id, file_path, update_existing, schema_name):
|
||||
"""
|
||||
Асинхронный импорт товаров из CSV/XLSX.
|
||||
|
||||
Алгоритм:
|
||||
1. Читаем файл и парсим заголовки
|
||||
2. Для каждой строки:
|
||||
- Создаём/обновляем товар (без фото)
|
||||
- Собираем URL фото для параллельной загрузки
|
||||
- Обновляем прогресс в ProductImportJob
|
||||
3. Запускаем параллельную загрузку всех фото (group task)
|
||||
4. Удаляем временный файл
|
||||
|
||||
Args:
|
||||
job_id: ID ProductImportJob
|
||||
file_path: Путь к загруженному файлу
|
||||
update_existing: Обновлять ли существующие товары
|
||||
schema_name: Схема тенанта
|
||||
|
||||
Returns:
|
||||
dict: Результат импорта
|
||||
"""
|
||||
from .services.import_export import ProductImporter
|
||||
from .models import ProductImportJob
|
||||
from celery import group
|
||||
import os
|
||||
|
||||
try:
|
||||
# Активируем схему тенанта
|
||||
connection.set_schema(schema_name)
|
||||
logger.info(f"[Import] Activated schema: {schema_name} for job {job_id}")
|
||||
|
||||
# Загружаем задачу
|
||||
job = ProductImportJob.objects.get(id=job_id)
|
||||
job.status = 'processing'
|
||||
job.save(update_fields=['status'])
|
||||
|
||||
# Открываем файл
|
||||
if not default_storage.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
with default_storage.open(file_path, 'rb') as file:
|
||||
# Создаём importer с callback для прогресса
|
||||
importer = ProductImporter()
|
||||
|
||||
# Callback для обновления прогресса
|
||||
def progress_callback(current, total, created, updated, skipped, errors):
|
||||
job.refresh_from_db()
|
||||
job.processed_rows = current
|
||||
job.total_rows = total
|
||||
job.created_count = created
|
||||
job.updated_count = updated
|
||||
job.skipped_count = skipped
|
||||
job.errors_count = len(errors)
|
||||
job.save(update_fields=[
|
||||
'processed_rows', 'total_rows', 'created_count',
|
||||
'updated_count', 'skipped_count', 'errors_count'
|
||||
])
|
||||
|
||||
# Запускаем импорт (без фото, они загрузятся параллельно)
|
||||
result = importer.import_from_file(
|
||||
file=file,
|
||||
update_existing=update_existing,
|
||||
progress_callback=progress_callback,
|
||||
skip_images=True, # Не загружаем фото синхронно
|
||||
schema_name=schema_name # Передаём для задач фото
|
||||
)
|
||||
|
||||
# Обновляем результаты
|
||||
job.refresh_from_db()
|
||||
job.status = 'completed'
|
||||
job.created_count = result['created']
|
||||
job.updated_count = result['updated']
|
||||
job.skipped_count = result['skipped']
|
||||
job.errors_count = result.get('real_error_count', 0)
|
||||
job.errors_json = result.get('real_errors', [])
|
||||
job.completed_at = timezone.now()
|
||||
job.save()
|
||||
|
||||
# Удаляем временный файл
|
||||
try:
|
||||
if default_storage.exists(file_path):
|
||||
default_storage.delete(file_path)
|
||||
logger.info(f"[Import] Deleted temp file: {file_path}")
|
||||
except Exception as del_exc:
|
||||
logger.warning(f"[Import] Could not delete temp file: {del_exc}")
|
||||
|
||||
# Запускаем параллельную загрузку фото (если есть)
|
||||
if result.get('photo_tasks'):
|
||||
photo_tasks = result['photo_tasks']
|
||||
logger.info(f"[Import] Starting parallel download of {len(photo_tasks)} photos")
|
||||
|
||||
# FIX: is_main удалён из сигнатуры download_product_photo_async
|
||||
# Создаём группу задач для параллельной загрузки
|
||||
photo_group = group(
|
||||
download_product_photo_async.s(
|
||||
product_id=task['product_id'],
|
||||
image_url=task['url'],
|
||||
order=task['order'],
|
||||
schema_name=schema_name
|
||||
)
|
||||
for task in photo_tasks
|
||||
)
|
||||
|
||||
# Запускаем группу асинхронно
|
||||
photo_group.apply_async()
|
||||
logger.info(f"[Import] Photo download tasks submitted")
|
||||
|
||||
logger.info(f"[Import] Job {job_id} completed successfully: "
|
||||
f"created={result['created']}, updated={result['updated']}, "
|
||||
f"skipped={result['skipped']}, errors={result.get('real_error_count', 0)}")
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'job_id': job_id,
|
||||
'result': result
|
||||
}
|
||||
|
||||
except ProductImportJob.DoesNotExist:
|
||||
logger.error(f"[Import] Job {job_id} not found in schema {schema_name}")
|
||||
return {'status': 'error', 'reason': 'job_not_found'}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[Import] Job {job_id} failed: {exc}", exc_info=True)
|
||||
|
||||
# Обновляем статус задачи
|
||||
try:
|
||||
connection.set_schema(schema_name)
|
||||
job = ProductImportJob.objects.get(id=job_id)
|
||||
job.status = 'failed'
|
||||
job.error_message = str(exc)
|
||||
job.completed_at = timezone.now()
|
||||
job.save()
|
||||
except Exception as save_exc:
|
||||
logger.error(f"[Import] Could not update job status: {save_exc}")
|
||||
|
||||
# Удаляем временный файл
|
||||
try:
|
||||
if default_storage.exists(file_path):
|
||||
default_storage.delete(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'status': 'error',
|
||||
'job_id': job_id,
|
||||
'error': str(exc)
|
||||
}
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name='products.tasks.download_product_photo_async',
|
||||
max_retries=3,
|
||||
default_retry_delay=10,
|
||||
)
|
||||
def download_product_photo_async(self, product_id, image_url, order, schema_name):
|
||||
"""
|
||||
Загрузка одного фото товара по URL.
|
||||
Запускается параллельно для всех фото.
|
||||
|
||||
FIX: ProductPhoto не имеет is_main, главное фото определяется по order=0
|
||||
|
||||
Args:
|
||||
product_id: ID товара
|
||||
image_url: URL изображения
|
||||
order: Порядок фото (первое фото order=0 считается главным)
|
||||
schema_name: Схема тенанта
|
||||
|
||||
Returns:
|
||||
dict: Результат загрузки
|
||||
"""
|
||||
import requests
|
||||
from django.core.files.base import ContentFile
|
||||
from .models import Product, ProductPhoto
|
||||
import urllib.parse
|
||||
|
||||
try:
|
||||
# Активируем схему
|
||||
connection.set_schema(schema_name)
|
||||
|
||||
# Загружаем товар
|
||||
product = Product.objects.get(id=product_id)
|
||||
|
||||
# Скачиваем изображение
|
||||
response = requests.get(image_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
# Получаем имя файла
|
||||
parsed_url = urllib.parse.urlparse(image_url)
|
||||
filename = parsed_url.path.split('/')[-1]
|
||||
|
||||
# Создаём ProductPhoto
|
||||
# FIX: ProductPhoto не имеет поля is_main, используется только order
|
||||
# Первое фото (order=0) автоматически считается главным
|
||||
photo = ProductPhoto(
|
||||
product=product,
|
||||
order=order # is_main удалён — используется order для определения главного фото
|
||||
)
|
||||
|
||||
# Сохраняем файл
|
||||
# ВАЖНО: use_async=False чтобы НЕ запускать дополнительную Celery задачу
|
||||
# Обработка будет выполнена синхронно в текущей задаче
|
||||
photo.image.save(
|
||||
filename,
|
||||
ContentFile(response.content),
|
||||
save=False # Не сохраняем в БД пока
|
||||
)
|
||||
photo.save(use_async=False) # Синхронная обработка через ImageProcessor
|
||||
|
||||
logger.info(f"[PhotoDownload] Downloaded photo for product {product_id}: {image_url}")
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'product_id': product_id,
|
||||
'photo_id': photo.id,
|
||||
'url': image_url
|
||||
}
|
||||
|
||||
except Product.DoesNotExist:
|
||||
logger.error(f"[PhotoDownload] Product {product_id} not found in {schema_name}")
|
||||
return {'status': 'error', 'reason': 'product_not_found'}
|
||||
|
||||
except requests.RequestException as exc:
|
||||
logger.warning(f"[PhotoDownload] Failed to download {image_url}: {exc}")
|
||||
|
||||
# Повторяем при ошибках сети
|
||||
try:
|
||||
raise self.retry(exc=exc, countdown=10)
|
||||
except self.MaxRetriesExceededError:
|
||||
logger.error(f"[PhotoDownload] Max retries exceeded for {image_url}")
|
||||
return {'status': 'error', 'reason': 'max_retries', 'url': image_url}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[PhotoDownload] Unexpected error for {image_url}: {exc}", exc_info=True)
|
||||
return {'status': 'error', 'reason': 'unexpected', 'url': image_url, 'error': str(exc)}
|
||||
|
||||
158
myproject/products/templates/products/product_import.html
Normal file
158
myproject/products/templates/products/product_import.html
Normal file
@@ -0,0 +1,158 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Импорт товаров{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Импорт товаров</h2>
|
||||
<a href="{% url 'products:products-list' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Назад к списку
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Инструкция -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Инструкция</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2"><strong>Поддерживаемые форматы:</strong> CSV, XLSX</p>
|
||||
<p class="mb-2"><strong>Обязательные колонки:</strong></p>
|
||||
<ul class="mb-2">
|
||||
<li><code>Название</code> или <code>Артикул</code> (хотя бы одно)</li>
|
||||
<li><code>Цена</code> (обязательно для новых товаров)</li>
|
||||
</ul>
|
||||
<p class="mb-2"><strong>Опциональные колонки:</strong></p>
|
||||
<ul class="mb-2">
|
||||
<li><code>Описание</code></li>
|
||||
<li><code>Краткое описание</code></li>
|
||||
<li><code>Единица</code> (шт, м, г, л, кг)</li>
|
||||
<li><code>Себестоимость</code></li>
|
||||
<li><code>Цена со скидкой</code></li>
|
||||
<li><code>Изображения</code> (URL изображений, каждый с новой строки)</li>
|
||||
</ul>
|
||||
<p class="mb-0 text-muted">
|
||||
<small>Система автоматически распознает колонки на русском и английском языках.</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма загрузки -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="file" class="form-label">Выберите файл</label>
|
||||
<input type="file" class="form-control" id="file" name="file"
|
||||
accept=".csv,.xlsx,.xls" required>
|
||||
<div class="form-text">Формат: CSV или XLSX</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="update_existing"
|
||||
name="update_existing">
|
||||
<label class="form-check-label" for="update_existing">
|
||||
Обновлять существующие товары (по артикулу или названию)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-upload"></i> Импортировать
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Результаты импорта -->
|
||||
{% if result %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header {% if result.success %}bg-success text-white{% else %}bg-danger text-white{% endif %}">
|
||||
<h5 class="mb-0">Результаты импорта</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3 border rounded">
|
||||
<h3 class="text-success mb-0">{{ result.created }}</h3>
|
||||
<small class="text-muted">Создано</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3 border rounded">
|
||||
<h3 class="text-info mb-0">{{ result.updated }}</h3>
|
||||
<small class="text-muted">Обновлено</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3 border rounded">
|
||||
<h3 class="text-warning mb-0">{{ result.skipped }}</h3>
|
||||
<small class="text-muted">Пропущено</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3 border rounded">
|
||||
<h3 class="text-danger mb-0">{{ result.real_error_count }}</h3>
|
||||
<small class="text-muted">Ошибок</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if has_error_file %}
|
||||
<div class="mt-3">
|
||||
<a href="{% url 'products:product-import-errors-download' %}"
|
||||
class="btn btn-warning">
|
||||
<i class="bi bi-download"></i> Скачать файл с ошибками
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Детальный список ошибок -->
|
||||
{% if result.errors %}
|
||||
<div class="mt-4">
|
||||
<h6>Детальный список проблем:</h6>
|
||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-sm table-striped">
|
||||
<thead class="sticky-top bg-white">
|
||||
<tr>
|
||||
<th>Строка</th>
|
||||
<th>Название</th>
|
||||
<th>Артикул</th>
|
||||
<th>Причина</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for error in result.errors %}
|
||||
<tr>
|
||||
<td>{{ error.row|default:"-" }}</td>
|
||||
<td>{{ error.name|default:"-" }}</td>
|
||||
<td>{{ error.sku|default:"-" }}</td>
|
||||
<td class="text-danger">{{ error.reason }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Пример CSV -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Пример формата CSV</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="mb-0"><code>Название,Цена,Артикул,Описание,Единица,Себестоимость,Цена со скидкой
|
||||
Роза красная 50см,150.00,R-001,Красивая роза,шт,80.00,
|
||||
Лента атласная,50.00,L-001,,м,,45.00
|
||||
Упаковка крафт,30.00,P-001,Крафт-бумага,шт,15.00,</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
194
myproject/products/templates/products/product_import_status.html
Normal file
194
myproject/products/templates/products/product_import_status.html
Normal file
@@ -0,0 +1,194 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Статус импорта{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Статус импорта товаров</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Файл -->
|
||||
<div class="mb-3">
|
||||
<strong>Файл:</strong> {{ job.file_name }}
|
||||
</div>
|
||||
|
||||
<!-- Статус -->
|
||||
<div class="mb-3">
|
||||
<strong>Статус:</strong>
|
||||
<span id="status-badge" class="badge bg-secondary">{{ job.get_status_display }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Прогресс-бар -->
|
||||
<div class="mb-4" id="progress-container">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span><strong>Прогресс:</strong></span>
|
||||
<span id="progress-text">0 / 0 (0%)</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 25px;">
|
||||
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style="width: 0%"
|
||||
aria-valuenow="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
<span id="progress-percent">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Результаты -->
|
||||
<div id="results-container" style="display: none;">
|
||||
<h5 class="mb-3">Результаты импорта</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted">Создано</h6>
|
||||
<h3 id="created-count" class="text-success">0</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted">Обновлено</h6>
|
||||
<h3 id="updated-count" class="text-info">0</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted">Пропущено</h6>
|
||||
<h3 id="skipped-count" class="text-warning">0</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted">Ошибки</h6>
|
||||
<h3 id="errors-count" class="text-danger">0</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сообщение об ошибке -->
|
||||
<div id="error-container" class="alert alert-danger mt-3" style="display: none;">
|
||||
<strong>Ошибка:</strong>
|
||||
<p id="error-message" class="mb-0"></p>
|
||||
</div>
|
||||
|
||||
<!-- Кнопки действий -->
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'products:product-import' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Назад к импорту
|
||||
</a>
|
||||
<a href="{% url 'products:all-products' %}" class="btn btn-primary" id="view-products-btn" style="display: none;">
|
||||
<i class="bi bi-list"></i> Посмотреть товары
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const jobId = {{ job.id }};
|
||||
const statusUrl = "{% url 'products:product-import-status-api' job.id %}";
|
||||
let pollInterval = null;
|
||||
|
||||
// Обновление UI на основе данных с сервера
|
||||
function updateUI(data) {
|
||||
// Обновляем статус
|
||||
const statusBadge = document.getElementById('status-badge');
|
||||
const statusMap = {
|
||||
'pending': { class: 'bg-secondary', text: 'Ожидание' },
|
||||
'processing': { class: 'bg-primary', text: 'Обработка' },
|
||||
'completed': { class: 'bg-success', text: 'Завершено' },
|
||||
'failed': { class: 'bg-danger', text: 'Ошибка' }
|
||||
};
|
||||
|
||||
const statusInfo = statusMap[data.status] || { class: 'bg-secondary', text: data.status };
|
||||
statusBadge.className = 'badge ' + statusInfo.class;
|
||||
statusBadge.textContent = statusInfo.text;
|
||||
|
||||
// Обновляем прогресс
|
||||
const progressPercent = data.progress_percent || 0;
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressPercentText = document.getElementById('progress-percent');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
|
||||
progressBar.style.width = progressPercent + '%';
|
||||
progressBar.setAttribute('aria-valuenow', progressPercent);
|
||||
progressPercentText.textContent = progressPercent + '%';
|
||||
progressText.textContent = `${data.processed_rows} / ${data.total_rows} (${progressPercent}%)`;
|
||||
|
||||
// Убираем анимацию если завершено
|
||||
if (data.is_finished) {
|
||||
progressBar.classList.remove('progress-bar-animated');
|
||||
}
|
||||
|
||||
// Показываем результаты
|
||||
if (data.status === 'completed' || data.status === 'failed') {
|
||||
document.getElementById('results-container').style.display = 'block';
|
||||
document.getElementById('created-count').textContent = data.created_count;
|
||||
document.getElementById('updated-count').textContent = data.updated_count;
|
||||
document.getElementById('skipped-count').textContent = data.skipped_count;
|
||||
document.getElementById('errors-count').textContent = data.errors_count;
|
||||
|
||||
// Показываем кнопку просмотра товаров
|
||||
if (data.status === 'completed') {
|
||||
document.getElementById('view-products-btn').style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
|
||||
// Показываем сообщение об ошибке
|
||||
if (data.error_message) {
|
||||
const errorContainer = document.getElementById('error-container');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
errorContainer.style.display = 'block';
|
||||
errorMessage.textContent = data.error_message;
|
||||
}
|
||||
|
||||
// Останавливаем polling если задача завершена
|
||||
if (data.is_finished && pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение статуса с сервера
|
||||
function fetchStatus() {
|
||||
fetch(statusUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
updateUI(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Ошибка получения статуса:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Запускаем polling каждые 2 секунды
|
||||
fetchStatus(); // Первый запрос сразу
|
||||
pollInterval = setInterval(fetchStatus, 2000);
|
||||
|
||||
// Останавливаем polling при уходе со страницы
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -25,6 +25,11 @@
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Кнопка импорта товаров -->
|
||||
<a href="{% url 'products:product-import' %}" class="btn btn-outline-primary btn-sm me-2 mb-2 mb-md-0">
|
||||
<i class="bi bi-upload"></i> Импорт
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ urlpatterns = [
|
||||
path('product/<int:pk>/update/', views.ProductUpdateView.as_view(), name='product-update'),
|
||||
path('product/<int:pk>/delete/', views.ProductDeleteView.as_view(), name='product-delete'),
|
||||
|
||||
# Import/Export
|
||||
path('import/', views.product_import_view, name='product-import'),
|
||||
path('import/status/<int:job_id>/', views.product_import_status_view, name='product-import-status'),
|
||||
path('import/status/<int:job_id>/api/', views.product_import_status_api, name='product-import-status-api'),
|
||||
path('import/errors/download/', views.download_import_errors, name='product-import-errors-download'),
|
||||
|
||||
# Photo management for Product
|
||||
path('product/photo/<int:pk>/delete/', views.product_photo_delete, name='product-photo-delete'),
|
||||
path('product/photo/<int:pk>/set-main/', views.product_photo_set_main, name='product-photo-set-main'),
|
||||
|
||||
@@ -96,8 +96,10 @@ def generate_product_sku(product):
|
||||
"""
|
||||
from products.models import SKUCounter
|
||||
|
||||
# Получаем следующий номер из глобального счетчика
|
||||
next_number = SKUCounter.get_next_value('product')
|
||||
# FIX: SKU counter bug - increment only after successful save
|
||||
# Получаем следующий номер БЕЗ инкремента
|
||||
# Инкремент выполнится в post_save сигнале после успешного сохранения
|
||||
next_number = SKUCounter.peek_next_value('product')
|
||||
|
||||
# Форматируем номер с ведущими нулями (6 цифр)
|
||||
base_sku = f"PROD-{next_number:06d}"
|
||||
|
||||
@@ -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',
|
||||
|
||||
166
myproject/products/views/product_import_views.py
Normal file
166
myproject/products/views/product_import_views.py
Normal 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
|
||||
@@ -29,6 +29,7 @@ psycopg2-binary==2.9.11
|
||||
python-dateutil==2.9.0.post0
|
||||
python-monkey-business==1.1.0
|
||||
redis==5.0.8
|
||||
requests==2.31.0
|
||||
six==1.17.0
|
||||
sqlparse==0.5.3
|
||||
typing_extensions==4.15.0
|
||||
|
||||
Reference in New Issue
Block a user