feat(products): реализована система единиц продажи на фронтенде
Добавлена полноценная интеграция единиц измерения (UoM) для продажи товаров в разных единицах с автоматическим пересчётом цен и остатков. ## Основные изменения: ### Backend - Расширен API поиска товаров (api_views.py): добавлена сериализация sales_units - Создан новый endpoint get_product_sales_units_api для загрузки единиц с остатками - Добавлено поле sales_unit в OrderItemForm и SaleForm с валидацией - Созданы CRUD views для управления единицами продажи (uom_views.py) - Обновлена ProductForm: использует base_unit вместо устаревшего unit ### Frontend - Создан модуль sales-units.js с функциями для работы с единицами - Интегрирован в select2-product-search.js: автозагрузка единиц при выборе товара - Добавлены контейнеры для единиц в order_form.html и sale_form.html - Реализовано автоматическое обновление цены при смене единицы продажи - При выборе базовой единицы цена возвращается к базовой цене товара ### UI - Добавлены страницы управления единицами продажи в навбар - Созданы шаблоны: sales_unit_list.html, sales_unit_form.html, sales_unit_delete.html - Добавлены фильтры по товару, единице, активности и дефолтности ## Исправленные ошибки: - Порядок инициализации: обработчики устанавливаются ДО триггера события change - Цена корректно обновляется при выборе единицы продажи - При выборе "Базовая единица" возвращается базовая цена товара 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
249
myproject/products/static/products/js/sales-units.js
Normal file
249
myproject/products/static/products/js/sales-units.js
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Модуль для работы с единицами продажи товаров (Sales Units)
|
||||
* Управляет загрузкой, отображением и валидацией единиц продажи
|
||||
*/
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Загружает единицы продажи для товара с сервера
|
||||
* @param {number} productId - ID товара
|
||||
* @param {number|null} warehouseId - ID склада для получения остатков (опционально)
|
||||
* @returns {Promise<Object>} Промис с данными единиц продажи
|
||||
*/
|
||||
async function fetchSalesUnits(productId, warehouseId = null) {
|
||||
try {
|
||||
let url = `/products/api/products/${productId}/sales-units/`;
|
||||
if (warehouseId) {
|
||||
url += `?warehouse=${warehouseId}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Ошибка загрузки единиц продажи');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('[SalesUnits] Ошибка при загрузке единиц:', error);
|
||||
return {success: false, error: error.message, sales_units: []};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает HTML select элемент с единицами продажи
|
||||
* @param {Array} salesUnits - Массив единиц продажи
|
||||
* @param {number|null} selectedId - ID выбранной единицы (опционально)
|
||||
* @returns {string} HTML строка с select элементом
|
||||
*/
|
||||
function createSalesUnitSelect(salesUnits, selectedId = null) {
|
||||
if (!salesUnits || salesUnits.length === 0) {
|
||||
return '<select class="form-control" disabled><option>Нет единиц продажи</option></select>';
|
||||
}
|
||||
|
||||
let html = '<select class="form-control sales-unit-selector" name="sales_unit">';
|
||||
html += '<option value="">Базовая единица</option>';
|
||||
|
||||
salesUnits.forEach(unit => {
|
||||
const selected = (selectedId && unit.id === selectedId) ? 'selected' : '';
|
||||
const isDefault = unit.is_default ? ' (по умолч.)' : '';
|
||||
const price = unit.sale_price || unit.price;
|
||||
|
||||
html += `<option value="${unit.id}" ${selected}
|
||||
data-price="${unit.actual_price}"
|
||||
data-conversion="${unit.conversion_factor}"
|
||||
data-min-qty="${unit.min_quantity}"
|
||||
data-step="${unit.quantity_step}"
|
||||
data-available="${unit.available_quantity || ''}">
|
||||
${unit.name} (${unit.unit_short_name}) - ${price} руб.${isDefault}
|
||||
</option>`;
|
||||
});
|
||||
|
||||
html += '</select>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет цену при выборе единицы продажи
|
||||
* @param {HTMLSelectElement} selectElement - Select элемент с единицами
|
||||
* @param {HTMLInputElement} priceInput - Input элемент для цены
|
||||
* @param {HTMLInputElement} isCustomPriceInput - Hidden input для флага кастомной цены
|
||||
*/
|
||||
function updatePriceOnUnitChange(selectElement, priceInput, isCustomPriceInput) {
|
||||
if (!priceInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOption = selectElement.options[selectElement.selectedIndex];
|
||||
const isManuallyModified = isCustomPriceInput && isCustomPriceInput.value === 'true';
|
||||
|
||||
// Если цена была изменена вручную - не обновляем
|
||||
if (isManuallyModified) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Если выбрана базовая единица (пустое значение) - возвращаем базовую цену товара
|
||||
if (!selectedOption || !selectedOption.value) {
|
||||
const baseProductPrice = priceInput.dataset.baseProductPrice;
|
||||
if (baseProductPrice) {
|
||||
priceInput.value = baseProductPrice;
|
||||
priceInput.dataset.originalPrice = baseProductPrice;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Если выбрана единица продажи - устанавливаем её цену
|
||||
const newPrice = selectedOption.getAttribute('data-price');
|
||||
if (newPrice) {
|
||||
priceInput.value = newPrice;
|
||||
priceInput.dataset.originalPrice = newPrice;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отображает доступное количество товара в выбранной единице
|
||||
* @param {HTMLSelectElement} selectElement - Select элемент с единицами
|
||||
* @param {HTMLElement} displayElement - Элемент для отображения остатков
|
||||
*/
|
||||
function displayAvailableQuantity(selectElement, displayElement) {
|
||||
if (!displayElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOption = selectElement.options[selectElement.selectedIndex];
|
||||
|
||||
if (!selectedOption || !selectedOption.value) {
|
||||
displayElement.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const availableQty = selectedOption.getAttribute('data-available');
|
||||
const unitShortName = selectedOption.text.match(/\(([^)]+)\)/)?.[1] || 'шт';
|
||||
|
||||
if (availableQty && parseFloat(availableQty) > 0) {
|
||||
displayElement.innerHTML = `<small class="text-muted">Доступно: ${availableQty} ${unitShortName}</small>`;
|
||||
} else {
|
||||
displayElement.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует количество согласно минимуму и шагу единицы продажи
|
||||
* @param {HTMLInputElement} quantityInput - Input элемент для количества
|
||||
* @param {HTMLSelectElement} salesUnitSelect - Select элемент с единицами
|
||||
* @returns {boolean} true если валидация прошла успешно
|
||||
*/
|
||||
function validateQuantity(quantityInput, salesUnitSelect) {
|
||||
const selectedOption = salesUnitSelect.options[salesUnitSelect.selectedIndex];
|
||||
|
||||
// Если единица не выбрана - валидируем только положительность
|
||||
if (!selectedOption || !selectedOption.value) {
|
||||
return quantityInput.value > 0;
|
||||
}
|
||||
|
||||
const quantity = parseFloat(quantityInput.value);
|
||||
const minQty = parseFloat(selectedOption.getAttribute('data-min-qty'));
|
||||
const step = parseFloat(selectedOption.getAttribute('data-step'));
|
||||
|
||||
// Проверка минимального количества
|
||||
if (quantity < minQty) {
|
||||
alert(`Минимальное количество: ${minQty}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверка шага (кратности)
|
||||
if (step && step > 0) {
|
||||
const remainder = quantity % step;
|
||||
// Используем небольшую погрешность для сравнения float
|
||||
if (remainder > 0.0001 && (step - remainder) > 0.0001) {
|
||||
alert(`Количество должно быть кратно ${step}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует обработчики событий для единиц продажи в форме
|
||||
* @param {HTMLElement} formElement - Элемент формы или родительский контейнер
|
||||
*/
|
||||
function initializeSalesUnitHandlers(formElement) {
|
||||
const salesUnitSelect = formElement.querySelector('.sales-unit-selector');
|
||||
const priceInput = formElement.querySelector('[name$="-price"], [name="price"]');
|
||||
const quantityInput = formElement.querySelector('[name$="-quantity"], [name="quantity"]');
|
||||
const isCustomPriceInput = formElement.querySelector('[name$="-is_custom_price"], [name="is_custom_price"]');
|
||||
const availableQtyDisplay = formElement.querySelector('.available-qty-display');
|
||||
|
||||
if (!salesUnitSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// При смене единицы - обновить цену и остатки
|
||||
salesUnitSelect.addEventListener('change', function() {
|
||||
updatePriceOnUnitChange(salesUnitSelect, priceInput, isCustomPriceInput);
|
||||
displayAvailableQuantity(salesUnitSelect, availableQtyDisplay);
|
||||
});
|
||||
|
||||
// Отследить ручное изменение цены
|
||||
if (priceInput) {
|
||||
priceInput.addEventListener('change', function() {
|
||||
const originalPrice = priceInput.dataset.originalPrice;
|
||||
if (originalPrice && parseFloat(priceInput.value) !== parseFloat(originalPrice)) {
|
||||
if (isCustomPriceInput) {
|
||||
isCustomPriceInput.value = 'true';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Валидация количества при потере фокуса
|
||||
if (quantityInput) {
|
||||
quantityInput.addEventListener('blur', function() {
|
||||
validateQuantity(quantityInput, salesUnitSelect);
|
||||
});
|
||||
}
|
||||
|
||||
// Начальное отображение остатков и обновление цены если единица уже выбрана
|
||||
if (salesUnitSelect.value) {
|
||||
displayAvailableQuantity(salesUnitSelect, availableQtyDisplay);
|
||||
// Начальное обновление цены (страховка на случай если событие change не сработает)
|
||||
updatePriceOnUnitChange(salesUnitSelect, priceInput, isCustomPriceInput);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает и вставляет контейнер для единиц продажи в форму
|
||||
* @param {HTMLElement} targetElement - Элемент, после которого вставить контейнер
|
||||
* @returns {HTMLElement} Созданный контейнер
|
||||
*/
|
||||
function createSalesUnitContainer(targetElement) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'mb-3 sales-unit-container';
|
||||
container.style.display = 'none';
|
||||
container.innerHTML = `
|
||||
<label class="form-label">Единица продажи</label>
|
||||
<div class="sales-unit-selector-wrapper"></div>
|
||||
<div class="available-qty-display mt-1"></div>
|
||||
`;
|
||||
|
||||
targetElement.insertAdjacentElement('afterend', container);
|
||||
return container;
|
||||
}
|
||||
|
||||
// Экспорт публичного API
|
||||
window.SalesUnitsModule = {
|
||||
fetchSalesUnits,
|
||||
createSalesUnitSelect,
|
||||
updatePriceOnUnitChange,
|
||||
displayAvailableQuantity,
|
||||
validateQuantity,
|
||||
initializeSalesUnitHandlers,
|
||||
createSalesUnitContainer
|
||||
};
|
||||
|
||||
console.log('[SalesUnits] Модуль инициализирован');
|
||||
|
||||
})(window);
|
||||
@@ -191,11 +191,24 @@
|
||||
if (type === 'product') {
|
||||
if (productField) productField.value = id;
|
||||
if (kitField) kitField.value = '';
|
||||
if (priceField) priceField.value = originalPrice;
|
||||
if (priceField) {
|
||||
priceField.value = originalPrice;
|
||||
// Сохраняем базовую цену товара в data-атрибут для возврата при выборе базовой единицы
|
||||
priceField.dataset.baseProductPrice = originalPrice;
|
||||
}
|
||||
|
||||
// НОВОЕ: Загружаем единицы продажи для товара
|
||||
loadAndDisplaySalesUnits(id, form, data);
|
||||
} else if (type === 'kit') {
|
||||
if (kitField) kitField.value = id;
|
||||
if (productField) productField.value = '';
|
||||
if (priceField) priceField.value = originalPrice;
|
||||
|
||||
// Скрываем единицы продажи для комплектов
|
||||
var salesUnitContainer = form.querySelector('.sales-unit-container');
|
||||
if (salesUnitContainer) {
|
||||
salesUnitContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем оригинальную цену в data-атрибуте
|
||||
@@ -232,4 +245,87 @@
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Загружает и отображает единицы продажи для выбранного товара
|
||||
* @param {string} productId - ID товара
|
||||
* @param {HTMLElement} form - Элемент формы
|
||||
* @param {Object} productData - Данные товара из Select2
|
||||
*/
|
||||
async function loadAndDisplaySalesUnits(productId, form, productData) {
|
||||
// Проверяем наличие модуля SalesUnitsModule
|
||||
if (typeof window.SalesUnitsModule === 'undefined') {
|
||||
console.warn('[Select2] SalesUnitsModule не загружен');
|
||||
return;
|
||||
}
|
||||
|
||||
var salesUnitContainer = form.querySelector('.sales-unit-container');
|
||||
if (!salesUnitContainer) {
|
||||
console.warn('[Select2] Контейнер .sales-unit-container не найден в форме');
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
salesUnitContainer.innerHTML = '<div class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden">Загрузка...</span></div>';
|
||||
salesUnitContainer.style.display = 'block';
|
||||
|
||||
try {
|
||||
// Проверяем, есть ли единицы в данных товара (если API уже вернул их)
|
||||
var salesUnits = productData.sales_units;
|
||||
|
||||
// Если единиц нет в данных, загружаем через отдельный API
|
||||
if (!salesUnits || salesUnits.length === 0) {
|
||||
var result = await window.SalesUnitsModule.fetchSalesUnits(productId);
|
||||
if (result.success) {
|
||||
salesUnits = result.sales_units;
|
||||
} else {
|
||||
throw new Error(result.error || 'Не удалось загрузить единицы продажи');
|
||||
}
|
||||
}
|
||||
|
||||
// Если единиц нет - скрываем контейнер
|
||||
if (!salesUnits || salesUnits.length === 0) {
|
||||
salesUnitContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем select с единицами
|
||||
var selectHtml = window.SalesUnitsModule.createSalesUnitSelect(salesUnits);
|
||||
salesUnitContainer.innerHTML = `
|
||||
<label class="form-label">Единица продажи</label>
|
||||
${selectHtml}
|
||||
<div class="available-qty-display mt-1"></div>
|
||||
`;
|
||||
salesUnitContainer.style.display = 'block';
|
||||
|
||||
// Находим созданный select и скрытое поле
|
||||
var salesUnitSelect = salesUnitContainer.querySelector('.sales-unit-selector');
|
||||
var hiddenSalesUnitField = form.querySelector('[name$="-sales_unit"]');
|
||||
|
||||
if (salesUnitSelect && hiddenSalesUnitField) {
|
||||
// Синхронизируем visible select с hidden field
|
||||
salesUnitSelect.addEventListener('change', function() {
|
||||
hiddenSalesUnitField.value = salesUnitSelect.value;
|
||||
});
|
||||
}
|
||||
|
||||
// СНАЧАЛА инициализируем обработчики из модуля SalesUnitsModule
|
||||
window.SalesUnitsModule.initializeSalesUnitHandlers(form);
|
||||
|
||||
// ПОТОМ выбираем дефолтную единицу и триггерим событие
|
||||
if (salesUnitSelect && hiddenSalesUnitField) {
|
||||
var defaultUnit = salesUnits.find(function(u) { return u.is_default; });
|
||||
if (defaultUnit) {
|
||||
salesUnitSelect.value = defaultUnit.id;
|
||||
hiddenSalesUnitField.value = defaultUnit.id;
|
||||
// Теперь событие сработает ПОСЛЕ установки обработчиков
|
||||
salesUnitSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Select2] Ошибка загрузки единиц продажи:', error);
|
||||
salesUnitContainer.innerHTML = '<small class="text-danger">Ошибка загрузки единиц продажи</small>';
|
||||
}
|
||||
}
|
||||
|
||||
})(window);
|
||||
|
||||
Reference in New Issue
Block a user