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

View 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 %}

View File

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