Улучшения инвентаризации: автоматическое проведение документов, оптимизация запросов и улучшения 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:
2025-12-21 23:59:02 +03:00
parent bb821f9ef4
commit a8ba5ce780
16 changed files with 1619 additions and 194 deletions

View 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);