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:
2026-01-02 12:35:01 +03:00
parent 5b68f14bb4
commit e831c4fb6e
19 changed files with 1574 additions and 52 deletions

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

View File

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