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

@@ -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',
]

View File

@@ -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(

View 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']