feat(discounts): добавлено комбинирование скидок по режимам

Добавлено поле combine_mode с тремя режимами:
- stack - складывать с другими скидками
- max_only - применять только максимальную
- exclusive - отменяет все остальные скидки

Изменения:
- Модель Discount: добавлено поле combine_mode
- Calculator: новый класс DiscountCombiner, методы возвращают списки скидок
- Applier: создание нескольких DiscountApplication записей
- Admin: отображение combine_mode с иконками
- POS API: возвращает списки применённых скидок
- POS UI: отображение нескольких скидок с иконками режимов

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 12:56:38 +03:00
parent 293f3b58cb
commit f57e639dbe
9 changed files with 715 additions and 223 deletions

View File

@@ -1403,6 +1403,7 @@ def pos_checkout(request):
"notes": str (optional),
"promo_code": str (optional) - Промокод для скидки
"manual_discount_id": int (optional) - ID выбранной вручную скидки
"custom_discount": dict (optional) - Произвольная скидка {"value": float, "is_percent": bool}
}
"""
from orders.models import Order, OrderItem, OrderStatus
@@ -1425,6 +1426,7 @@ def pos_checkout(request):
order_notes = body.get('notes', '')
promo_code = body.get('promo_code') # Промокод для скидки
manual_discount_id = body.get('manual_discount_id') # ID выбранной вручную скидки
custom_discount = body.get('custom_discount') # Произвольная скидка {"value": float, "is_percent": bool}
if not customer_id:
return JsonResponse({'success': False, 'error': 'Не указан клиент'}, status=400)
@@ -1556,7 +1558,38 @@ def pos_checkout(request):
order.calculate_total()
# 4. Применяем скидки
if manual_discount_id:
if custom_discount:
# Применяем произвольную скидку
from decimal import Decimal
discount_value = Decimal(str(custom_discount.get('value', 0)))
is_percent = custom_discount.get('is_percent', False)
if is_percent:
# Процентная скидка
discount_amount = order.subtotal * discount_value / 100
discount_name = f'Произвольная скидка {discount_value}%'
else:
# Фиксированная скидка
discount_amount = min(discount_value, order.subtotal)
discount_name = f'Произвольная скидка {discount_value} руб.'
order.discount_amount = discount_amount
order.applied_promo_code = discount_name # Сохраняем название в поле промокода
order.save(update_fields=['discount_amount', 'applied_promo_code'])
order.calculate_total()
# Создаем запись о применении в истории скидок
from discounts.models import DiscountApplication
DiscountApplication.objects.create(
order=order,
target='order',
base_amount=order.subtotal,
discount_amount=discount_amount,
final_amount=order.subtotal - discount_amount,
customer=customer,
applied_by=request.user
)
elif manual_discount_id:
from discounts.services.applier import DiscountApplier
from discounts.models import Discount
@@ -1762,22 +1795,40 @@ def validate_promo_code(request):
def calculate_cart_discounts(request):
"""
Рассчитать скидки для корзины POS.
Поддерживает комбинирование скидок по combine_mode.
Payload JSON:
{
'items': [...],
'promo_code': str (optional),
'customer_id': int (optional)
'customer_id': int (optional),
'manual_discount_id': int (optional),
'skip_auto_discount': bool (optional)
}
Returns JSON:
{
'success': true,
'cart_subtotal': float,
'order_discount': {...},
'item_discounts': [...],
'order_discounts': [
{'discount_id': int, 'discount_name': str, 'discount_amount': float, 'combine_mode': str},
...
],
'total_order_discount': float,
'item_discounts': [
{
'cart_index': int,
'discounts': [
{'discount_id': int, 'discount_name': str, 'discount_amount': float, 'combine_mode': str},
...
],
'total_discount': float
},
...
],
'total_discount': float,
'final_total': float
'final_total': float,
'excluded_by': {'id': int, 'name': str} или None
}
"""
from discounts.services.calculator import DiscountCalculator
@@ -1788,6 +1839,7 @@ def calculate_cart_discounts(request):
items_data = data.get('items', [])
promo_code = data.get('promo_code')
customer_id = data.get('customer_id')
skip_auto_discount = data.get('skip_auto_discount', False)
customer = None
if customer_id:
@@ -1797,32 +1849,45 @@ def calculate_cart_discounts(request):
pass
result = DiscountCalculator.calculate_cart_discounts(
items_data, promo_code, customer
items_data, promo_code, customer, skip_auto_discount=skip_auto_discount
)
cart_subtotal = Decimal('0')
for item in items_data:
cart_subtotal += Decimal(str(item['price'])) * Decimal(str(item['quantity']))
# Форматируем item_discounts для JSON (Decimal -> float)
formatted_item_discounts = []
for item in result['item_discounts']:
formatted_discounts = []
for disc in item['discounts']:
formatted_discounts.append({
'discount_id': disc['discount_id'],
'discount_name': disc['discount_name'],
'discount_amount': float(disc['discount_amount']),
'combine_mode': disc['combine_mode']
})
formatted_item_discounts.append({
'cart_index': item['cart_index'],
'discounts': formatted_discounts,
'total_discount': float(item['total_discount'])
})
# Форматируем order_discounts для JSON
formatted_order_discounts = []
for disc in result['order_discounts']:
formatted_order_discounts.append({
'discount_id': disc['discount_id'],
'discount_name': disc['discount_name'],
'discount_amount': float(disc['discount_amount']),
'combine_mode': disc['combine_mode']
})
response_data = {
'success': True,
'cart_subtotal': float(cart_subtotal),
'order_discount': {
'discount_id': result['order_discount']['discount'].id if result['order_discount'].get('discount') else None,
'discount_name': result['order_discount']['discount'].name if result['order_discount'].get('discount') else None,
'discount_amount': float(result['order_discount']['discount_amount']),
'error': result['order_discount'].get('error'),
} if result['order_discount'] else None,
'item_discounts': [
{
'cart_index': i['cart_index'],
'discount_id': i['discount'].id,
'discount_name': i['discount'].name,
'discount_amount': float(i['discount_amount']),
} for i in result['item_discounts']
],
'cart_subtotal': float(result['cart_subtotal']),
'order_discounts': formatted_order_discounts,
'total_order_discount': float(result['total_order_discount']),
'item_discounts': formatted_item_discounts,
'total_discount': float(result['total_discount']),
'final_total': float(result['final_total']),
'excluded_by': result.get('excluded_by')
}
return JsonResponse(response_data)
@@ -1881,7 +1946,8 @@ def get_available_discounts(request):
'name': d.name,
'discount_type': d.discount_type,
'value': float(d.value),
'min_order_amount': float(d.min_order_amount) if d.min_order_amount else None
'min_order_amount': float(d.min_order_amount) if d.min_order_amount else None,
'combine_mode': d.combine_mode
})
# Получаем автоматическую скидку (только одну для отображения)