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