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:
@@ -111,6 +111,15 @@ from .api_views import search_products_and_variants, validate_kit_cost, create_t
|
||||
# Каталог
|
||||
from .catalog_views import CatalogView
|
||||
|
||||
# Управление единицами измерения
|
||||
from .uom_views import (
|
||||
unit_of_measure_list,
|
||||
product_sales_unit_list,
|
||||
product_sales_unit_create,
|
||||
product_sales_unit_update,
|
||||
product_sales_unit_delete,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Утилиты
|
||||
@@ -204,4 +213,11 @@ __all__ = [
|
||||
|
||||
# Каталог
|
||||
'CatalogView',
|
||||
|
||||
# Управление единицами измерения
|
||||
'unit_of_measure_list',
|
||||
'product_sales_unit_list',
|
||||
'product_sales_unit_create',
|
||||
'product_sales_unit_update',
|
||||
'product_sales_unit_delete',
|
||||
]
|
||||
|
||||
@@ -104,7 +104,25 @@ def search_products_and_variants(request):
|
||||
numeric_id = int(item_id)
|
||||
|
||||
if item_type == 'product':
|
||||
product = Product.objects.get(id=numeric_id, status='active')
|
||||
product = Product.objects.prefetch_related('sales_units__unit').get(id=numeric_id, status='active')
|
||||
|
||||
# Сериализация единиц продажи
|
||||
sales_units_data = []
|
||||
for su in product.sales_units.filter(is_active=True).order_by('position', 'id'):
|
||||
sales_units_data.append({
|
||||
'id': su.id,
|
||||
'name': su.name,
|
||||
'unit_code': su.unit.code,
|
||||
'unit_short_name': su.unit.short_name,
|
||||
'price': str(su.price),
|
||||
'sale_price': str(su.sale_price) if su.sale_price else None,
|
||||
'actual_price': str(su.actual_price),
|
||||
'conversion_factor': str(su.conversion_factor),
|
||||
'min_quantity': str(su.min_quantity),
|
||||
'quantity_step': str(su.quantity_step),
|
||||
'is_default': su.is_default
|
||||
})
|
||||
|
||||
return JsonResponse({
|
||||
'results': [{
|
||||
'id': f'product_{product.id}',
|
||||
@@ -113,7 +131,8 @@ def search_products_and_variants(request):
|
||||
'price': str(product.price) if product.price else None,
|
||||
'actual_price': str(product.actual_price) if product.actual_price else '0',
|
||||
'in_stock': product.in_stock,
|
||||
'type': 'product'
|
||||
'type': 'product',
|
||||
'sales_units': sales_units_data
|
||||
}],
|
||||
'pagination': {'more': False}
|
||||
})
|
||||
@@ -186,29 +205,46 @@ def search_products_and_variants(request):
|
||||
|
||||
if search_type in ['all', 'product']:
|
||||
# Показываем последние добавленные активные товары
|
||||
products_qs = Product.objects.filter(status='active')
|
||||
products_qs = Product.objects.filter(status='active').prefetch_related('sales_units__unit')
|
||||
# Применяем фильтры
|
||||
products_qs = _apply_product_filters(products_qs, category_id, tag_id, in_stock_only, warehouse_id)
|
||||
products = products_qs.order_by('-created_at')[:page_size]\
|
||||
.values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
|
||||
products = products_qs.order_by('-created_at')[:page_size]
|
||||
|
||||
for product in products:
|
||||
text = product['name']
|
||||
if product['sku']:
|
||||
text += f" ({product['sku']})"
|
||||
text = product.name
|
||||
if product.sku:
|
||||
text += f" ({product.sku})"
|
||||
|
||||
# Получаем actual_price: приоритет sale_price > price
|
||||
actual_price = product['sale_price'] if product['sale_price'] else product['price']
|
||||
actual_price = product.sale_price if product.sale_price else product.price
|
||||
|
||||
# Сериализация единиц продажи
|
||||
sales_units_data = []
|
||||
for su in product.sales_units.filter(is_active=True).order_by('position', 'id'):
|
||||
sales_units_data.append({
|
||||
'id': su.id,
|
||||
'name': su.name,
|
||||
'unit_code': su.unit.code,
|
||||
'unit_short_name': su.unit.short_name,
|
||||
'price': str(su.price),
|
||||
'sale_price': str(su.sale_price) if su.sale_price else None,
|
||||
'actual_price': str(su.actual_price),
|
||||
'conversion_factor': str(su.conversion_factor),
|
||||
'min_quantity': str(su.min_quantity),
|
||||
'quantity_step': str(su.quantity_step),
|
||||
'is_default': su.is_default
|
||||
})
|
||||
|
||||
product_results.append({
|
||||
'id': f"product_{product['id']}",
|
||||
'id': f"product_{product.id}",
|
||||
'text': text,
|
||||
'sku': product['sku'],
|
||||
'price': str(product['price']) if product['price'] else None,
|
||||
'sku': product.sku,
|
||||
'price': str(product.price) if product.price else None,
|
||||
'actual_price': str(actual_price) if actual_price else '0',
|
||||
'in_stock': product['in_stock'],
|
||||
'in_stock': product.in_stock,
|
||||
'type': 'product',
|
||||
'photo_url': _get_product_photo_url(product['id'])
|
||||
'photo_url': _get_product_photo_url(product.id),
|
||||
'sales_units': sales_units_data
|
||||
})
|
||||
|
||||
if search_type in ['all', 'kit']:
|
||||
@@ -317,29 +353,50 @@ def search_products_and_variants(request):
|
||||
# Применяем дополнительные фильтры
|
||||
products_query = _apply_product_filters(products_query, category_id, tag_id, in_stock_only, warehouse_id)
|
||||
|
||||
# Добавляем prefetch для единиц продажи
|
||||
products_query = products_query.prefetch_related('sales_units__unit')
|
||||
|
||||
total_products = products_query.count()
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
|
||||
products = products_query[start:end].values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
|
||||
products = products_query[start:end]
|
||||
|
||||
for product in products:
|
||||
text = product['name']
|
||||
if product['sku']:
|
||||
text += f" ({product['sku']})"
|
||||
text = product.name
|
||||
if product.sku:
|
||||
text += f" ({product.sku})"
|
||||
|
||||
# Получаем actual_price: приоритет sale_price > price
|
||||
actual_price = product['sale_price'] if product['sale_price'] else product['price']
|
||||
actual_price = product.sale_price if product.sale_price else product.price
|
||||
|
||||
# Сериализация единиц продажи
|
||||
sales_units_data = []
|
||||
for su in product.sales_units.filter(is_active=True).order_by('position', 'id'):
|
||||
sales_units_data.append({
|
||||
'id': su.id,
|
||||
'name': su.name,
|
||||
'unit_code': su.unit.code,
|
||||
'unit_short_name': su.unit.short_name,
|
||||
'price': str(su.price),
|
||||
'sale_price': str(su.sale_price) if su.sale_price else None,
|
||||
'actual_price': str(su.actual_price),
|
||||
'conversion_factor': str(su.conversion_factor),
|
||||
'min_quantity': str(su.min_quantity),
|
||||
'quantity_step': str(su.quantity_step),
|
||||
'is_default': su.is_default
|
||||
})
|
||||
|
||||
product_results.append({
|
||||
'id': f"product_{product['id']}",
|
||||
'id': f"product_{product.id}",
|
||||
'text': text,
|
||||
'sku': product['sku'],
|
||||
'price': str(product['price']) if product['price'] else None,
|
||||
'sku': product.sku,
|
||||
'price': str(product.price) if product.price else None,
|
||||
'actual_price': str(actual_price) if actual_price else '0',
|
||||
'in_stock': product['in_stock'],
|
||||
'in_stock': product.in_stock,
|
||||
'type': 'product',
|
||||
'photo_url': _get_product_photo_url(product['id'])
|
||||
'photo_url': _get_product_photo_url(product.id),
|
||||
'sales_units': sales_units_data
|
||||
})
|
||||
|
||||
has_more = total_products > end
|
||||
@@ -1302,3 +1359,98 @@ def get_payment_methods(request):
|
||||
return JsonResponse({
|
||||
'error': f'Ошибка при загрузке способов оплаты: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
|
||||
def get_product_sales_units_api(request, product_id):
|
||||
"""
|
||||
API для получения единиц продажи товара с остатками.
|
||||
|
||||
GET /api/products/{product_id}/sales-units/?warehouse={id}
|
||||
|
||||
Параметры GET:
|
||||
- warehouse: ID склада для получения остатков (опционально)
|
||||
|
||||
Возвращает JSON:
|
||||
{
|
||||
"success": true,
|
||||
"product_id": 123,
|
||||
"product_name": "Роза красная Freedom 50см",
|
||||
"sales_units": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Ветка большая",
|
||||
"unit_code": "шт",
|
||||
"unit_short_name": "шт",
|
||||
"price": "150.00",
|
||||
"sale_price": "135.00",
|
||||
"actual_price": "135.00",
|
||||
"conversion_factor": "15.000000",
|
||||
"min_quantity": "1.000",
|
||||
"quantity_step": "1.000",
|
||||
"is_default": true,
|
||||
"available_quantity": "45.000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from decimal import Decimal
|
||||
warehouse_id = request.GET.get('warehouse', '').strip()
|
||||
|
||||
# Получаем товар с prefetch единиц продажи
|
||||
product = Product.objects.prefetch_related('sales_units__unit').get(
|
||||
id=product_id, status='active'
|
||||
)
|
||||
|
||||
# Получаем остатки со склада
|
||||
from inventory.models import Stock
|
||||
available_qty = Decimal('0')
|
||||
if warehouse_id:
|
||||
try:
|
||||
stock = Stock.objects.filter(
|
||||
product_id=product_id,
|
||||
warehouse_id=int(warehouse_id)
|
||||
).first()
|
||||
if stock:
|
||||
available_qty = stock.quantity_available - stock.quantity_reserved
|
||||
except (ValueError, Stock.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Формируем список единиц с пересчётом остатков
|
||||
sales_units_data = []
|
||||
for su in product.sales_units.filter(is_active=True).order_by('position', 'id'):
|
||||
qty_in_unit = su.convert_from_base(available_qty) if available_qty > 0 else Decimal('0')
|
||||
|
||||
sales_units_data.append({
|
||||
'id': su.id,
|
||||
'name': su.name,
|
||||
'unit_code': su.unit.code,
|
||||
'unit_short_name': su.unit.short_name,
|
||||
'price': str(su.price),
|
||||
'sale_price': str(su.sale_price) if su.sale_price else None,
|
||||
'actual_price': str(su.actual_price),
|
||||
'conversion_factor': str(su.conversion_factor),
|
||||
'min_quantity': str(su.min_quantity),
|
||||
'quantity_step': str(su.quantity_step),
|
||||
'is_default': su.is_default,
|
||||
'available_quantity': str(qty_in_unit)
|
||||
})
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'product_id': product.id,
|
||||
'product_name': product.name,
|
||||
'sales_units': sales_units_data
|
||||
})
|
||||
|
||||
except Product.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Товар не найден'
|
||||
}, status=404)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка загрузки единиц продажи: {str(e)}')
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
197
myproject/products/views/uom_views.py
Normal file
197
myproject/products/views/uom_views.py
Normal 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)
|
||||
Reference in New Issue
Block a user