fix: Улучшения системы ценообразования комплектов
Исправлены 4 проблемы: 1. Расчёт цены первого товара - улучшена валидация в getProductPrice и calculateFinalPrice 2. Отображение actual_price в Select2 вместо обычной цены 3. Количество по умолчанию = 1 для новых форм компонентов 4. Auto-select текста при клике на поле количества для удобства редактирования Изменённые файлы: - products/forms.py: добавлен __init__ в KitItemForm для quantity.initial = 1 - products/templates/includes/select2-product-init.html: обновлена formatSelectResult - products/templates/productkit_create.html: добавлен focus handler для auto-select - products/templates/productkit_edit.html: добавлен focus handler для auto-select 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -59,8 +59,18 @@ from .category_views import (
|
||||
ProductCategoryDeleteView,
|
||||
)
|
||||
|
||||
# CRUD представления для ProductVariantGroup
|
||||
from .variant_group_views import (
|
||||
ProductVariantGroupListView,
|
||||
ProductVariantGroupCreateView,
|
||||
ProductVariantGroupDetailView,
|
||||
ProductVariantGroupUpdateView,
|
||||
ProductVariantGroupDeleteView,
|
||||
product_variant_group_item_move,
|
||||
)
|
||||
|
||||
# API представления
|
||||
from .api_views import search_products_and_variants
|
||||
from .api_views import search_products_and_variants, validate_kit_cost
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -109,6 +119,15 @@ __all__ = [
|
||||
'ProductCategoryUpdateView',
|
||||
'ProductCategoryDeleteView',
|
||||
|
||||
# ProductVariantGroup CRUD
|
||||
'ProductVariantGroupListView',
|
||||
'ProductVariantGroupCreateView',
|
||||
'ProductVariantGroupDetailView',
|
||||
'ProductVariantGroupUpdateView',
|
||||
'ProductVariantGroupDeleteView',
|
||||
'product_variant_group_item_move',
|
||||
|
||||
# API
|
||||
'search_products_and_variants',
|
||||
'validate_kit_cost',
|
||||
]
|
||||
|
||||
@@ -46,6 +46,7 @@ def search_products_and_variants(request):
|
||||
'text': f"{product.name} ({product.sku})" if product.sku else product.name,
|
||||
'sku': product.sku,
|
||||
'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'
|
||||
}],
|
||||
@@ -74,19 +75,24 @@ def search_products_and_variants(request):
|
||||
# Показываем последние добавленные активные товары
|
||||
products = Product.objects.filter(is_active=True)\
|
||||
.order_by('-created_at')[:page_size]\
|
||||
.values('id', 'name', 'sku', 'price', 'in_stock')
|
||||
.values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
|
||||
|
||||
for product in products:
|
||||
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']
|
||||
|
||||
results.append({
|
||||
'id': product['id'],
|
||||
'text': text,
|
||||
'sku': product['sku'],
|
||||
'price': str(product['price']) if product['price'] else None,
|
||||
'in_stock': product['in_stock']
|
||||
'actual_price': str(actual_price) if actual_price else '0',
|
||||
'in_stock': product['in_stock'],
|
||||
'type': 'product'
|
||||
})
|
||||
|
||||
response_data = {
|
||||
@@ -147,18 +153,22 @@ def search_products_and_variants(request):
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
|
||||
products = products_query[start:end].values('id', 'name', 'sku', 'price', 'in_stock')
|
||||
products = products_query[start:end].values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
|
||||
|
||||
for product in products:
|
||||
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']
|
||||
|
||||
results.append({
|
||||
'id': product['id'],
|
||||
'text': text,
|
||||
'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'],
|
||||
'type': 'product'
|
||||
})
|
||||
@@ -187,3 +197,156 @@ def search_products_and_variants(request):
|
||||
'results': results,
|
||||
'pagination': {'more': has_more if search_type == 'product' else False}
|
||||
})
|
||||
|
||||
|
||||
def validate_kit_cost(request):
|
||||
"""
|
||||
AJAX endpoint для валидации себестоимости комплекта в реальном времени.
|
||||
|
||||
Принимает список компонентов и возвращает информацию о валидности себестоимости,
|
||||
доступных методах ценообразования и проблемах.
|
||||
|
||||
Request (JSON POST):
|
||||
{
|
||||
'components': [
|
||||
{
|
||||
'product_id': int or null,
|
||||
'variant_group_id': int or null,
|
||||
'quantity': float
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Response (JSON):
|
||||
{
|
||||
'is_valid': bool,
|
||||
'total_cost': float or null,
|
||||
'problems': [
|
||||
{
|
||||
'component_name': str,
|
||||
'reason': str
|
||||
},
|
||||
...
|
||||
],
|
||||
'available_methods': {
|
||||
'manual': bool,
|
||||
'from_sale_prices': bool,
|
||||
'from_cost_plus_percent': bool,
|
||||
'from_cost_plus_amount': bool
|
||||
}
|
||||
}
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||||
|
||||
try:
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
data = json.loads(request.body)
|
||||
components = data.get('components', [])
|
||||
|
||||
if not components:
|
||||
return JsonResponse({
|
||||
'is_valid': False,
|
||||
'total_cost': None,
|
||||
'problems': [{
|
||||
'component_name': 'Комплект',
|
||||
'reason': 'Комплект не содержит компонентов'
|
||||
}],
|
||||
'available_methods': {
|
||||
'manual': True,
|
||||
'from_sale_prices': True,
|
||||
'from_cost_plus_percent': False,
|
||||
'from_cost_plus_amount': False
|
||||
}
|
||||
})
|
||||
|
||||
# Валидируем каждый компонент
|
||||
total_cost = Decimal('0.00')
|
||||
problems = []
|
||||
|
||||
for idx, component in enumerate(components):
|
||||
product_id = component.get('product_id')
|
||||
variant_group_id = component.get('variant_group_id')
|
||||
quantity = Decimal(str(component.get('quantity', 1)))
|
||||
|
||||
product = None
|
||||
product_name = ''
|
||||
|
||||
# Получаем товар
|
||||
if product_id:
|
||||
try:
|
||||
product = Product.objects.get(id=product_id)
|
||||
product_name = product.name
|
||||
except Product.DoesNotExist:
|
||||
problems.append({
|
||||
'component_name': f'Товар #{product_id}',
|
||||
'reason': 'Товар не найден'
|
||||
})
|
||||
continue
|
||||
|
||||
elif variant_group_id:
|
||||
try:
|
||||
variant_group = ProductVariantGroup.objects.get(id=variant_group_id)
|
||||
product = variant_group.products.filter(is_active=True).first()
|
||||
if variant_group:
|
||||
product_name = f"[Варианты] {variant_group.name}"
|
||||
except ProductVariantGroup.DoesNotExist:
|
||||
problems.append({
|
||||
'component_name': f'Группа вариантов #{variant_group_id}',
|
||||
'reason': 'Группа не найдена'
|
||||
})
|
||||
continue
|
||||
|
||||
if not product:
|
||||
problems.append({
|
||||
'component_name': product_name or f'Компонент {idx + 1}',
|
||||
'reason': 'Товар не выбран или группа пуста'
|
||||
})
|
||||
continue
|
||||
|
||||
# Проверяем себестоимость
|
||||
if product.cost_price is None:
|
||||
problems.append({
|
||||
'component_name': product_name,
|
||||
'reason': 'Себестоимость не определена'
|
||||
})
|
||||
continue
|
||||
|
||||
if product.cost_price <= 0:
|
||||
problems.append({
|
||||
'component_name': product_name,
|
||||
'reason': 'Себестоимость равна 0'
|
||||
})
|
||||
continue
|
||||
|
||||
# Добавляем в сумму
|
||||
if quantity > 0:
|
||||
total_cost += product.cost_price * quantity
|
||||
|
||||
# Определяем, какие методы доступны
|
||||
is_cost_valid = len(problems) == 0
|
||||
available_methods = {
|
||||
'manual': True,
|
||||
'from_sale_prices': True,
|
||||
'from_cost_plus_percent': is_cost_valid,
|
||||
'from_cost_plus_amount': is_cost_valid
|
||||
}
|
||||
|
||||
return JsonResponse({
|
||||
'is_valid': is_cost_valid,
|
||||
'total_cost': float(total_cost) if is_cost_valid else None,
|
||||
'problems': problems,
|
||||
'available_methods': available_methods
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'error': 'Invalid JSON'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@@ -108,10 +108,13 @@ class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateVi
|
||||
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
|
||||
selected_products[key] = {
|
||||
'id': product.id,
|
||||
'text': text,
|
||||
'price': str(product.sale_price) if product.sale_price else None
|
||||
'price': str(product.price) if product.price else None,
|
||||
'actual_price': str(actual_price) if actual_price else '0'
|
||||
}
|
||||
except Product.DoesNotExist:
|
||||
pass
|
||||
@@ -137,7 +140,7 @@ class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateVi
|
||||
# Получаем формсет из POST с правильным префиксом
|
||||
kititem_formset = KitItemFormSetCreate(self.request.POST, prefix='kititem')
|
||||
|
||||
# Проверяем валидность основной формы и формсета
|
||||
# Проверяем валидность основной формы
|
||||
if not form.is_valid():
|
||||
messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме комплекта.')
|
||||
return self.form_invalid(form)
|
||||
@@ -150,7 +153,7 @@ class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateVi
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Сохраняем основную форму (комплект)
|
||||
self.object = form.save(commit=True) # Явно сохраняем в БД
|
||||
self.object = form.save(commit=True)
|
||||
|
||||
# Убеждаемся что объект в БД
|
||||
if not self.object.pk:
|
||||
@@ -160,6 +163,15 @@ class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateVi
|
||||
kititem_formset.instance = self.object
|
||||
saved_items = kititem_formset.save()
|
||||
|
||||
# ТЕПЕРЬ (после сохранения комплекта) проверяем валидность ценообразования
|
||||
from ..validators.kit_validators import KitValidator
|
||||
is_method_valid, pricing_warning = KitValidator.validate_pricing_method_availability(self.object)
|
||||
|
||||
if not is_method_valid and pricing_warning:
|
||||
# Метод был переключен - сохраняем изменения
|
||||
self.object.save()
|
||||
messages.warning(self.request, pricing_warning)
|
||||
|
||||
# Обработка фотографий
|
||||
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user