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

@@ -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)