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