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:
@@ -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:
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user