Добавлен асинхронный импорт товаров с параллельной загрузкой фото + исправлен баг со счётчиком 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:
@@ -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']
|
||||
Reference in New Issue
Block a user