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

@@ -45,16 +45,33 @@ class WarehouseForm(forms.ModelForm):
class SaleForm(forms.ModelForm):
class Meta:
model = Sale
fields = ['product', 'warehouse', 'quantity', 'sale_price', 'order', 'document_number']
fields = ['product', 'warehouse', 'sales_unit', 'quantity', 'sale_price', 'order', 'document_number']
widgets = {
'product': forms.Select(attrs={'class': 'form-control'}),
'warehouse': forms.Select(attrs={'class': 'form-control'}),
'sales_unit': forms.Select(attrs={'class': 'form-control'}),
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
'sale_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'order': forms.Select(attrs={'class': 'form-control'}),
'document_number': forms.TextInput(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Динамический queryset для sales_unit
if self.instance.pk and self.instance.product:
from products.models import ProductSalesUnit
self.fields['sales_unit'].queryset = ProductSalesUnit.objects.filter(
product=self.instance.product,
is_active=True
).order_by('position', 'id')
else:
from products.models import ProductSalesUnit
self.fields['sales_unit'].queryset = ProductSalesUnit.objects.none()
self.fields['sales_unit'].required = False
def clean_quantity(self):
quantity = self.cleaned_data.get('quantity')
if quantity and quantity <= 0:

View File

@@ -52,6 +52,22 @@
</div>
</div>
<!-- Единица продажи (отображается только для товаров с единицами) -->
<div class="row" id="sales-unit-row" style="display: none;">
<div class="col-md-12 mb-3">
<label for="{{ form.sales_unit.id_for_label }}" class="form-label">
{{ form.sales_unit.label }}
</label>
{{ form.sales_unit }}
<div class="available-qty-display mt-1"></div>
{% if form.sales_unit.errors %}
<div class="invalid-feedback d-block">
{% for error in form.sales_unit.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.quantity.id_for_label }}" class="form-label">
@@ -126,4 +142,83 @@
width: 100%;
}
</style>
{% load static %}
<script src="{% static 'products/js/sales-units.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const productField = document.querySelector('[name="product"]');
const warehouseField = document.querySelector('[name="warehouse"]');
const salesUnitField = document.querySelector('[name="sales_unit"]');
const salePriceField = document.querySelector('[name="sale_price"]');
const salesUnitRow = document.getElementById('sales-unit-row');
const formElement = document.querySelector('form');
// Функция загрузки единиц продажи
async function loadSalesUnits() {
const productId = productField.value;
const warehouseId = warehouseField.value;
// Если товар не выбран - скрываем единицы
if (!productId) {
salesUnitRow.style.display = 'none';
salesUnitField.innerHTML = '<option value="">---------</option>';
return;
}
try {
// Загружаем единицы продажи
const result = await window.SalesUnitsModule.fetchSalesUnits(productId, warehouseId);
if (result.success && result.sales_units && result.sales_units.length > 0) {
// Создаем options для select
const selectHtml = window.SalesUnitsModule.createSalesUnitSelect(result.sales_units);
// Извлекаем options из HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = selectHtml;
const newSelect = tempDiv.querySelector('select');
// Очищаем существующий select и добавляем новые options
salesUnitField.innerHTML = '';
Array.from(newSelect.options).forEach(opt => {
salesUnitField.add(opt.cloneNode(true));
});
// Показываем блок с единицами
salesUnitRow.style.display = 'block';
// Выбираем дефолтную единицу если есть
const defaultUnit = result.sales_units.find(u => u.is_default);
if (defaultUnit) {
salesUnitField.value = defaultUnit.id;
}
// Инициализируем обработчики
window.SalesUnitsModule.initializeSalesUnitHandlers(formElement);
} else {
// Нет единиц - скрываем блок
salesUnitRow.style.display = 'none';
salesUnitField.innerHTML = '<option value="">---------</option>';
}
} catch (error) {
console.error('[Sale Form] Ошибка загрузки единиц продажи:', error);
salesUnitRow.style.display = 'none';
}
}
// Загружаем единицы при изменении товара или склада
if (productField) {
productField.addEventListener('change', loadSalesUnits);
}
if (warehouseField) {
warehouseField.addEventListener('change', loadSalesUnits);
}
// Загружаем единицы при загрузке страницы (если товар уже выбран)
if (productField && productField.value) {
loadSalesUnits();
}
});
</script>
{% endblock %}