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,197 @@
"""
Views для управления единицами измерения (Unit of Measure)
"""
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.shortcuts import render, redirect, get_object_or_404
from django.db.models import Q, Count
from django.core.paginator import Paginator
from django.urls import reverse
from products.models import UnitOfMeasure, ProductSalesUnit
from products.forms import ProductSalesUnitForm
@login_required
def unit_of_measure_list(request):
"""
Список всех единиц измерения с возможностью фильтрации и поиска
"""
# Получаем параметры фильтрации
search_query = request.GET.get('q', '').strip()
is_active_filter = request.GET.get('is_active', '')
# Базовый queryset
units = UnitOfMeasure.objects.all()
# Аннотируем количество использований
units = units.annotate(
usage_count=Count('productsalesunit')
)
# Применяем фильтры
if search_query:
units = units.filter(
Q(code__icontains=search_query) |
Q(name__icontains=search_query) |
Q(short_name__icontains=search_query)
)
if is_active_filter:
units = units.filter(is_active=(is_active_filter == 'true'))
# Сортировка
units = units.order_by('position', 'code')
# Пагинация
paginator = Paginator(units, 50)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
context = {
'page_obj': page_obj,
'search_query': search_query,
'is_active_filter': is_active_filter,
'total_units': units.count(),
}
return render(request, 'products/uom/unit_list.html', context)
@login_required
def product_sales_unit_list(request):
"""
Список всех единиц продажи товаров с возможностью фильтрации
"""
# Получаем параметры фильтрации
search_query = request.GET.get('q', '').strip()
unit_filter = request.GET.get('unit', '')
is_active_filter = request.GET.get('is_active', '')
is_default_filter = request.GET.get('is_default', '')
# Базовый queryset
sales_units = ProductSalesUnit.objects.select_related(
'product', 'unit'
).all()
# Применяем фильтры
if search_query:
sales_units = sales_units.filter(
Q(product__name__icontains=search_query) |
Q(product__sku__icontains=search_query) |
Q(name__icontains=search_query)
)
if unit_filter:
sales_units = sales_units.filter(unit_id=unit_filter)
if is_active_filter:
sales_units = sales_units.filter(is_active=(is_active_filter == 'true'))
if is_default_filter:
sales_units = sales_units.filter(is_default=(is_default_filter == 'true'))
# Сортировка
sales_units = sales_units.order_by('product__name', 'position')
# Пагинация
paginator = Paginator(sales_units, 50)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
# Для фильтра единиц
all_units = UnitOfMeasure.objects.filter(is_active=True).order_by('position', 'code')
context = {
'page_obj': page_obj,
'search_query': search_query,
'unit_filter': unit_filter,
'is_active_filter': is_active_filter,
'is_default_filter': is_default_filter,
'all_units': all_units,
'total_sales_units': sales_units.count(),
}
return render(request, 'products/uom/sales_unit_list.html', context)
@login_required
def product_sales_unit_create(request):
"""
Создание новой единицы продажи
"""
if request.method == 'POST':
form = ProductSalesUnitForm(request.POST)
if form.is_valid():
sales_unit = form.save()
messages.success(
request,
f'Единица продажи "{sales_unit.name}" для товара "{sales_unit.product.name}" успешно создана!'
)
return redirect('products:sales-unit-list')
else:
# Предзаполнение товара если передан в параметрах
initial = {}
product_id = request.GET.get('product')
if product_id:
initial['product'] = product_id
form = ProductSalesUnitForm(initial=initial)
context = {
'form': form,
'title': 'Создание единицы продажи',
'submit_text': 'Создать'
}
return render(request, 'products/uom/sales_unit_form.html', context)
@login_required
def product_sales_unit_update(request, pk):
"""
Редактирование единицы продажи
"""
sales_unit = get_object_or_404(ProductSalesUnit, pk=pk)
if request.method == 'POST':
form = ProductSalesUnitForm(request.POST, instance=sales_unit)
if form.is_valid():
sales_unit = form.save()
messages.success(
request,
f'Единица продажи "{sales_unit.name}" успешно обновлена!'
)
return redirect('products:sales-unit-list')
else:
form = ProductSalesUnitForm(instance=sales_unit)
context = {
'form': form,
'sales_unit': sales_unit,
'title': f'Редактирование: {sales_unit.name}',
'submit_text': 'Сохранить'
}
return render(request, 'products/uom/sales_unit_form.html', context)
@login_required
def product_sales_unit_delete(request, pk):
"""
Удаление единицы продажи
"""
sales_unit = get_object_or_404(ProductSalesUnit, pk=pk)
if request.method == 'POST':
product_name = sales_unit.product.name
unit_name = sales_unit.name
sales_unit.delete()
messages.success(
request,
f'Единица продажи "{unit_name}" для товара "{product_name}" успешно удалена!'
)
return redirect('products:sales-unit-list')
context = {
'sales_unit': sales_unit,
}
return render(request, 'products/uom/sales_unit_delete.html', context)