Улучшения инвентаризации: автоматическое проведение документов, оптимизация запросов и улучшения UI
- Автоматическое проведение документов списания и оприходования после завершения инвентаризации - Оптимизация SQL-запросов: устранение N+1, bulk-операции для Stock, агрегация для StockBatch и Reservation - Изменение формулы расчета разницы: (quantity_fact + quantity_reserved) - quantity_available - Переименование поля 'По факту' в 'Подсчитано (факт, свободные)' - Добавлены столбцы 'В резервах' и 'Всего на складе' в таблицу инвентаризации - Перемещение столбца 'В системе (свободно)' после 'В резервах' с визуальным выделением - Центральное выравнивание значений в столбцах таблицы - Автоматическое выделение текста при фокусе на поле ввода количества - Исправление форматирования разницы (убраны лишние нули) - Изменение статуса 'Не обработана' на 'Не проведено' - Добавление номера документа для инвентаризаций (INV-XXXXXX) - Отображение всех типов списаний в debug-странице (WriteOff, WriteOffDocument, WriteOffDocumentItem) - Улучшение отображения документов в детальном просмотре инвентаризации с возможностью перехода к ним
This commit is contained in:
504
myproject/inventory/static/inventory/js/inventory_detail.js
Normal file
504
myproject/inventory/static/inventory/js/inventory_detail.js
Normal file
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* JavaScript для страницы детального просмотра инвентаризации
|
||||
* Обрабатывает AJAX запросы для добавления, обновления и удаления строк
|
||||
*/
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
// Глобальная функция инициализации
|
||||
window.initInventoryDetailHandlers = function(inventoryId, config) {
|
||||
const handlers = new InventoryDetailHandlers(inventoryId, config);
|
||||
handlers.init();
|
||||
return handlers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Класс для обработки действий на странице инвентаризации
|
||||
*/
|
||||
function InventoryDetailHandlers(inventoryId, config) {
|
||||
this.inventoryId = inventoryId;
|
||||
this.config = config || {};
|
||||
this.debounceTimer = null;
|
||||
this.debounceDelay = 500; // Задержка для автосохранения в мс
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация всех обработчиков
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.init = function() {
|
||||
this.initQuantityInputs();
|
||||
this.initDeleteButtons();
|
||||
this.initCompleteButton();
|
||||
};
|
||||
|
||||
/**
|
||||
* Инициализация обработчиков для полей quantity_fact
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.initQuantityInputs = function() {
|
||||
const self = this;
|
||||
const tbody = document.getElementById('inventory-lines-tbody');
|
||||
|
||||
if (!tbody) return;
|
||||
|
||||
// Обработчик фокуса - выделяем весь текст для удобного ввода
|
||||
tbody.addEventListener('focus', function(e) {
|
||||
if (e.target.classList.contains('quantity-fact-input')) {
|
||||
e.target.select();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Обработчик изменения значения (debounce)
|
||||
tbody.addEventListener('input', function(e) {
|
||||
if (e.target.classList.contains('quantity-fact-input')) {
|
||||
const lineId = e.target.dataset.lineId;
|
||||
const row = e.target.closest('tr');
|
||||
|
||||
// Визуальная индикация изменения
|
||||
row.classList.add('table-warning');
|
||||
|
||||
// Очищаем предыдущий таймер
|
||||
clearTimeout(self.debounceTimer);
|
||||
|
||||
// Устанавливаем новый таймер для автосохранения
|
||||
self.debounceTimer = setTimeout(function() {
|
||||
self.updateLineQuantity(lineId, e.target.value, row);
|
||||
}, self.debounceDelay);
|
||||
}
|
||||
});
|
||||
|
||||
// Обработчик потери фокуса (сохранение сразу)
|
||||
// Используем делегирование событий для динамически добавленных элементов
|
||||
tbody.addEventListener('blur', function(e) {
|
||||
if (e.target.classList.contains('quantity-fact-input')) {
|
||||
clearTimeout(self.debounceTimer);
|
||||
const lineId = e.target.dataset.lineId;
|
||||
const row = e.target.closest('tr');
|
||||
if (lineId && row) {
|
||||
self.updateLineQuantity(lineId, e.target.value, row);
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование количества: убирает лишние нули, целые числа без дробной части
|
||||
* Аналог фильтра smart_quantity из Django
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.formatQuantity = function(value) {
|
||||
const num = parseFloat(value) || 0;
|
||||
// Проверяем, является ли число целым
|
||||
if (num % 1 === 0) {
|
||||
return num.toString();
|
||||
} else {
|
||||
// Убираем лишние нули справа и заменяем точку на запятую
|
||||
return num.toString().replace(/\.?0+$/, '').replace('.', ',');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Обновление quantity_fact через AJAX
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.updateLineQuantity = function(lineId, quantity, row) {
|
||||
const self = this;
|
||||
// Формируем URL: заменяем placeholder на реальный line_id
|
||||
// URL содержит '999' который нужно заменить на реальный line_id
|
||||
const url = this.config.updateLineUrl.replace('999', lineId);
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
const input = row.querySelector('.quantity-fact-input');
|
||||
const originalValue = input.value;
|
||||
input.disabled = true;
|
||||
|
||||
// Создаем FormData
|
||||
const formData = new FormData();
|
||||
formData.append('quantity_fact', quantity);
|
||||
formData.append('csrfmiddlewaretoken', this.getCsrfToken());
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
input.disabled = false;
|
||||
|
||||
if (data.success) {
|
||||
// Обновляем "Всего на складе" (quantity_available)
|
||||
const quantityAvailableCell = row.querySelector('td:nth-child(2)');
|
||||
if (quantityAvailableCell && data.line.quantity_available !== undefined) {
|
||||
const quantityAvailable = parseFloat(data.line.quantity_available) || 0;
|
||||
const formattedQuantityAvailable = quantityAvailable % 1 === 0
|
||||
? quantityAvailable.toString()
|
||||
: quantityAvailable.toString().replace('.', ',');
|
||||
quantityAvailableCell.textContent = formattedQuantityAvailable;
|
||||
}
|
||||
|
||||
// Обновляем "В резервах" (quantity_reserved)
|
||||
const quantityReservedCell = row.querySelector('td:nth-child(3)');
|
||||
if (quantityReservedCell && data.line.quantity_reserved !== undefined) {
|
||||
const quantityReserved = parseFloat(data.line.quantity_reserved) || 0;
|
||||
const formattedQuantityReserved = quantityReserved % 1 === 0
|
||||
? quantityReserved.toString()
|
||||
: quantityReserved.toString().replace('.', ',');
|
||||
quantityReservedCell.textContent = formattedQuantityReserved;
|
||||
}
|
||||
|
||||
// Обновляем "В системе (свободно)" (quantity_system)
|
||||
const quantitySystemCell = row.querySelector('td:nth-child(4)');
|
||||
if (quantitySystemCell && data.line.quantity_system !== undefined) {
|
||||
const quantitySystem = parseFloat(data.line.quantity_system) || 0;
|
||||
const formattedQuantitySystem = quantitySystem % 1 === 0
|
||||
? quantitySystem.toString()
|
||||
: quantitySystem.toString().replace('.', ',');
|
||||
quantitySystemCell.textContent = formattedQuantitySystem;
|
||||
}
|
||||
|
||||
// Обновляем разницу
|
||||
const differenceBadge = row.querySelector('.difference-badge');
|
||||
const difference = parseFloat(data.line.difference);
|
||||
|
||||
if (differenceBadge) {
|
||||
let badgeClass = 'bg-secondary';
|
||||
let badgeText = '0';
|
||||
|
||||
if (difference > 0) {
|
||||
badgeClass = 'bg-success';
|
||||
// Форматируем с убиранием лишних нулей
|
||||
const formatted = this.formatQuantity(difference);
|
||||
badgeText = '+' + formatted;
|
||||
} else if (difference < 0) {
|
||||
badgeClass = 'bg-danger';
|
||||
// Форматируем с убиранием лишних нулей
|
||||
badgeText = this.formatQuantity(difference);
|
||||
}
|
||||
|
||||
differenceBadge.innerHTML = '<span class="badge ' + badgeClass + '">' + badgeText + '</span>';
|
||||
}
|
||||
|
||||
// Обновляем класс строки в зависимости от разницы
|
||||
if (difference !== 0) {
|
||||
row.classList.add('table-warning');
|
||||
} else {
|
||||
row.classList.remove('table-warning');
|
||||
}
|
||||
|
||||
// Показываем уведомление об успешном сохранении
|
||||
this.showNotification('Изменения сохранены', 'success');
|
||||
} else {
|
||||
// Восстанавливаем значение при ошибке
|
||||
input.value = originalValue;
|
||||
this.showNotification('Ошибка: ' + (data.error || 'Не удалось сохранить'), 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
input.disabled = false;
|
||||
input.value = originalValue;
|
||||
this.showNotification('Ошибка при сохранении: ' + error.message, 'error');
|
||||
console.error('Error updating line:', error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Инициализация кнопок удаления строк
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.initDeleteButtons = function() {
|
||||
const self = this;
|
||||
const tbody = document.getElementById('inventory-lines-tbody');
|
||||
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.delete-line-btn')) {
|
||||
const btn = e.target.closest('.delete-line-btn');
|
||||
const lineId = btn.dataset.lineId;
|
||||
const productName = btn.dataset.productName || 'товар';
|
||||
|
||||
if (confirm('Удалить строку для товара "' + productName + '"?')) {
|
||||
self.deleteLine(lineId, btn.closest('tr'));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Удаление строки через AJAX
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.deleteLine = function(lineId, row) {
|
||||
const self = this;
|
||||
// Формируем URL: заменяем placeholder на реальный line_id
|
||||
// URL содержит '999' который нужно заменить на реальный line_id
|
||||
const url = this.config.deleteLineUrl.replace('999', lineId);
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
row.style.opacity = '0.5';
|
||||
const deleteBtn = row.querySelector('.delete-line-btn');
|
||||
if (deleteBtn) deleteBtn.disabled = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('csrfmiddlewaretoken', this.getCsrfToken());
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Удаляем строку из таблицы
|
||||
row.remove();
|
||||
|
||||
// Проверяем, не пустая ли таблица
|
||||
const tbody = document.getElementById('inventory-lines-tbody');
|
||||
if (tbody && tbody.querySelectorAll('tr').length === 0) {
|
||||
// Определяем количество столбцов: 7 если не завершена (есть столбец "Действия"), 6 если завершена
|
||||
const hasActionsColumn = document.getElementById('inventory-lines-table')?.querySelector('th:last-child')?.textContent.includes('Действия');
|
||||
const colspan = hasActionsColumn ? 7 : 6;
|
||||
tbody.innerHTML = '<tr id="empty-lines-message"><td colspan="' + colspan + '" class="text-center text-muted py-4"><i class="bi bi-info-circle"></i> Строк инвентаризации не добавлено.</td></tr>';
|
||||
}
|
||||
|
||||
// Отключаем кнопку завершения, если нет строк
|
||||
const completeBtn = document.getElementById('complete-inventory-btn');
|
||||
if (completeBtn && tbody.querySelectorAll('tr[data-line-id]').length === 0) {
|
||||
completeBtn.disabled = true;
|
||||
}
|
||||
|
||||
this.showNotification('Строка удалена', 'success');
|
||||
} else {
|
||||
row.style.opacity = '1';
|
||||
if (deleteBtn) deleteBtn.disabled = false;
|
||||
this.showNotification('Ошибка: ' + (data.error || 'Не удалось удалить'), 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
row.style.opacity = '1';
|
||||
if (deleteBtn) deleteBtn.disabled = false;
|
||||
this.showNotification('Ошибка при удалении: ' + error.message, 'error');
|
||||
console.error('Error deleting line:', error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Инициализация кнопки завершения инвентаризации
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.initCompleteButton = function() {
|
||||
const self = this;
|
||||
const btn = document.getElementById('complete-inventory-btn');
|
||||
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
self.showCompleteModal();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Показ модального окна завершения инвентаризации
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.showCompleteModal = function() {
|
||||
const tbody = document.getElementById('inventory-lines-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const lines = tbody.querySelectorAll('tr[data-line-id]');
|
||||
let deficitCount = 0;
|
||||
let surplusCount = 0;
|
||||
let deficitTotal = 0;
|
||||
let surplusTotal = 0;
|
||||
|
||||
lines.forEach(function(row) {
|
||||
const differenceInput = row.querySelector('.difference-badge .badge');
|
||||
if (differenceInput) {
|
||||
const difference = parseFloat(differenceInput.textContent.replace('+', ''));
|
||||
if (difference < 0) {
|
||||
deficitCount++;
|
||||
deficitTotal += Math.abs(difference);
|
||||
} else if (difference > 0) {
|
||||
surplusCount++;
|
||||
surplusTotal += difference;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const summary = document.getElementById('complete-summary');
|
||||
if (summary) {
|
||||
let html = '<ul class="mb-0">';
|
||||
html += '<li>Строк для обработки: <strong>' + lines.length + '</strong></li>';
|
||||
if (deficitCount > 0) {
|
||||
html += '<li>Недостач: <strong>' + deficitCount + '</strong> (всего: ' + deficitTotal.toFixed(3) + ' шт) → будет создан документ списания</li>';
|
||||
}
|
||||
if (surplusCount > 0) {
|
||||
html += '<li>Излишков: <strong>' + surplusCount + '</strong> (всего: ' + surplusTotal.toFixed(3) + ' шт) → будет создан документ оприходования</li>';
|
||||
}
|
||||
if (deficitCount === 0 && surplusCount === 0) {
|
||||
html += '<li>Расхождений не обнаружено</li>';
|
||||
}
|
||||
html += '</ul>';
|
||||
summary.innerHTML = html;
|
||||
}
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('confirmCompleteModal'));
|
||||
modal.show();
|
||||
};
|
||||
|
||||
/**
|
||||
* Глобальная функция добавления строки (вызывается из product_search_picker)
|
||||
*/
|
||||
window.addInventoryLine = function(productId) {
|
||||
const handlers = window.inventoryDetailHandlers;
|
||||
if (handlers) {
|
||||
handlers.addLine(productId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Добавление строки через AJAX
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.addLine = function(productId) {
|
||||
const self = this;
|
||||
const url = this.config.addLineUrl;
|
||||
const tbody = document.getElementById('inventory-lines-tbody');
|
||||
|
||||
if (!tbody) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('product_id', productId);
|
||||
formData.append('csrfmiddlewaretoken', this.getCsrfToken());
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
const loadingRow = document.createElement('tr');
|
||||
// Определяем количество столбцов: 8 если не завершена (есть столбец "Действия"), 7 если завершена
|
||||
const hasActionsColumn = document.getElementById('inventory-lines-table')?.querySelector('th:last-child')?.textContent.includes('Действия');
|
||||
const colspan = hasActionsColumn ? 8 : 7;
|
||||
loadingRow.innerHTML = '<td colspan="' + colspan + '" class="text-center py-2"><div class="spinner-border spinner-border-sm" role="status"></div> Добавление...</td>';
|
||||
tbody.appendChild(loadingRow);
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
loadingRow.remove();
|
||||
|
||||
if (data.success) {
|
||||
// Удаляем сообщение о пустой таблице
|
||||
const emptyMessage = document.getElementById('empty-lines-message');
|
||||
if (emptyMessage) emptyMessage.remove();
|
||||
|
||||
// Добавляем новую строку
|
||||
const newRow = self.createLineRow(data.line);
|
||||
tbody.appendChild(newRow);
|
||||
|
||||
// Включаем кнопку завершения
|
||||
const completeBtn = document.getElementById('complete-inventory-btn');
|
||||
if (completeBtn) completeBtn.disabled = false;
|
||||
|
||||
this.showNotification('Товар добавлен', 'success');
|
||||
} else {
|
||||
this.showNotification('Ошибка: ' + (data.error || 'Не удалось добавить товар'), 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
loadingRow.remove();
|
||||
this.showNotification('Ошибка при добавлении: ' + error.message, 'error');
|
||||
console.error('Error adding line:', error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Создание HTML строки для таблицы
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.createLineRow = function(lineData) {
|
||||
const row = document.createElement('tr');
|
||||
row.setAttribute('data-line-id', lineData.id);
|
||||
|
||||
const difference = parseFloat(lineData.difference);
|
||||
let differenceBadge = '<span class="badge bg-secondary">0</span>';
|
||||
if (difference > 0) {
|
||||
const formatted = this.formatQuantity(difference);
|
||||
differenceBadge = '<span class="badge bg-success">+' + formatted + '</span>';
|
||||
} else if (difference < 0) {
|
||||
const formatted = this.formatQuantity(difference);
|
||||
differenceBadge = '<span class="badge bg-danger">' + formatted + '</span>';
|
||||
}
|
||||
|
||||
if (difference !== 0) {
|
||||
row.classList.add('table-warning');
|
||||
}
|
||||
|
||||
// Форматируем quantity_fact для input type="number" (должна быть точка, а не запятая)
|
||||
const quantityFact = parseFloat(lineData.quantity_fact) || 0;
|
||||
|
||||
// Форматируем quantity_system: убираем лишние нули, целые числа без дробной части
|
||||
const quantitySystem = parseFloat(lineData.quantity_system) || 0;
|
||||
const formattedQuantitySystem = quantitySystem % 1 === 0
|
||||
? quantitySystem.toString()
|
||||
: quantitySystem.toString().replace('.', ',');
|
||||
|
||||
// Форматируем quantity_reserved: убираем лишние нули, целые числа без дробной части
|
||||
const quantityReserved = parseFloat(lineData.quantity_reserved || 0) || 0;
|
||||
const formattedQuantityReserved = quantityReserved % 1 === 0
|
||||
? quantityReserved.toString()
|
||||
: quantityReserved.toString().replace('.', ',');
|
||||
|
||||
// Форматируем quantity_available: убираем лишние нули, целые числа без дробной части
|
||||
const quantityAvailable = parseFloat(lineData.quantity_available || 0) || 0;
|
||||
const formattedQuantityAvailable = quantityAvailable % 1 === 0
|
||||
? quantityAvailable.toString()
|
||||
: quantityAvailable.toString().replace('.', ',');
|
||||
|
||||
row.innerHTML =
|
||||
'<td>' + lineData.product_name + '</td>' +
|
||||
'<td class="text-center">' + formattedQuantityAvailable + '</td>' +
|
||||
'<td class="text-center">' + formattedQuantityReserved + '</td>' +
|
||||
'<td class="bg-light fw-semibold text-center">' + formattedQuantitySystem + '</td>' +
|
||||
'<td class="text-center">' +
|
||||
'<input type="number" class="form-control form-control-sm quantity-fact-input mx-auto" ' +
|
||||
'value="' + quantityFact + '" min="0" step="0.001" ' +
|
||||
'data-line-id="' + lineData.id + '" style="width: 100px;">' +
|
||||
'</td>' +
|
||||
'<td class="text-center"><span class="difference-badge" data-line-id="' + lineData.id + '">' + differenceBadge + '</span></td>' +
|
||||
'<td class="text-center"><span class="badge bg-warning">Не проведено</span></td>' +
|
||||
'<td class="text-center">' +
|
||||
'<button class="btn btn-sm btn-danger delete-line-btn" ' +
|
||||
'data-line-id="' + lineData.id + '" ' +
|
||||
'data-product-name="' + lineData.product_name + '" ' +
|
||||
'title="Удалить строку"><i class="bi bi-trash"></i></button>' +
|
||||
'</td>';
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
/**
|
||||
* Получение CSRF токена
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.getCsrfToken = function() {
|
||||
const token = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
return token ? token.value : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Показ уведомления
|
||||
*/
|
||||
InventoryDetailHandlers.prototype.showNotification = function(message, type) {
|
||||
// Используем Bootstrap toast или простой alert
|
||||
// Можно улучшить, добавив toast уведомления
|
||||
console.log('[' + type.toUpperCase() + ']', message);
|
||||
|
||||
// Простое уведомление через alert (можно заменить на toast)
|
||||
if (type === 'error') {
|
||||
// Можно показать через Bootstrap alert
|
||||
}
|
||||
};
|
||||
|
||||
})(window);
|
||||
|
||||
Reference in New Issue
Block a user