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

@@ -4,7 +4,7 @@ from .models import (
Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem,
ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem,
ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute,
ProductAttribute, ProductAttributeValue
ProductAttribute, ProductAttributeValue, ProductSalesUnit, UnitOfMeasure
)
@@ -94,7 +94,7 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
model = Product
fields = [
'name', 'sku', 'description', 'short_description', 'categories',
'tags', 'unit', 'price', 'sale_price', 'status'
'tags', 'base_unit', 'price', 'sale_price', 'status'
]
labels = {
'name': 'Название',
@@ -103,11 +103,14 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
'short_description': 'Краткое описание',
'categories': 'Категории',
'tags': 'Теги',
'unit': 'Единица измерения',
'base_unit': 'Единица измерения',
'price': 'Основная цена',
'sale_price': 'Цена со скидкой',
'status': 'Статус'
}
widgets = {
'base_unit': forms.Select(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -131,9 +134,17 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
})
self.fields['price'].widget.attrs.update({'class': 'form-control'})
self.fields['sale_price'].widget.attrs.update({'class': 'form-control'})
self.fields['unit'].widget.attrs.update({'class': 'form-control'})
self.fields['base_unit'].widget.attrs.update({'class': 'form-control'})
self.fields['status'].widget.attrs.update({'class': 'form-control'})
# Фильтруем только активные единицы измерения
from .models import UnitOfMeasure
self.fields['base_unit'].queryset = UnitOfMeasure.objects.filter(
is_active=True
).order_by('position', 'code')
self.fields['base_unit'].required = False
self.fields['base_unit'].help_text = 'Базовая единица для учета товара на складе. На основе этой единицы можно создать единицы продажи.'
def clean_name(self):
"""Валидация уникальности имени для активных товаров"""
name = self.cleaned_data.get('name')
@@ -1026,3 +1037,94 @@ ProductAttributeValueFormSet = inlineformset_factory(
min_num=0,
validate_min=False,
)
class ProductSalesUnitForm(forms.ModelForm):
"""
Форма для создания и редактирования единицы продажи товара
"""
class Meta:
model = ProductSalesUnit
fields = [
'product', 'unit', 'name', 'conversion_factor',
'price', 'sale_price', 'min_quantity', 'quantity_step',
'is_default', 'is_active', 'position'
]
labels = {
'product': 'Товар',
'unit': 'Единица измерения',
'name': 'Название',
'conversion_factor': 'Коэффициент конверсии',
'price': 'Цена продажи',
'sale_price': 'Цена со скидкой',
'min_quantity': 'Минимальное количество',
'quantity_step': 'Шаг количества',
'is_default': 'По умолчанию',
'is_active': 'Активна',
'position': 'Порядок сортировки'
}
widgets = {
'product': forms.Select(attrs={'class': 'form-control'}),
'unit': forms.Select(attrs={'class': 'form-control'}),
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Например: Ветка большая, Стебель средний'
}),
'conversion_factor': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.000001',
'min': '0.000001',
'placeholder': '15.0'
}),
'price': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.01',
'min': '0',
'placeholder': '0.00'
}),
'sale_price': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.01',
'min': '0',
'placeholder': 'Оставьте пустым если нет скидки'
}),
'min_quantity': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.001',
'min': '0.001',
'value': '1'
}),
'quantity_step': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.001',
'min': '0.001',
'value': '1'
}),
'is_default': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'position': forms.NumberInput(attrs={'class': 'form-control', 'value': '0'}),
}
help_texts = {
'conversion_factor': 'Сколько единиц продажи получается из 1 базовой единицы товара. Например: 15 (из 1 банча получается 15 больших веток)',
'price': 'Цена за одну единицу продажи',
'sale_price': 'Опционально: цена со скидкой (должна быть меньше основной)',
'min_quantity': 'Минимальное количество для заказа',
'quantity_step': 'С каким шагом можно заказывать (например: 0.5, 1)',
'is_default': 'Эта единица будет выбрана по умолчанию при добавлении товара в заказ',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Фильтруем только активные единицы измерения
self.fields['unit'].queryset = UnitOfMeasure.objects.filter(
is_active=True
).order_by('position', 'code')
# Фильтруем только активные товары
self.fields['product'].queryset = Product.objects.filter(
status='active'
).order_by('name')
# Сделать sale_price необязательным
self.fields['sale_price'].required = False