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

@@ -3,6 +3,82 @@ from django.db import models
from django.utils import timezone
class DiscountCombiner:
"""
Утилитный класс для комбинирования скидок по разным режимам.
"""
@staticmethod
def combine_discounts(discounts, base_amount):
"""
Комбинировать несколько скидок по их combine_mode.
Args:
discounts: Список объектов Discount
base_amount: Базовая сумма для расчета
Returns:
dict: {
'total_discount': Decimal,
'applied_discounts': [{'discount': Discount, 'amount': Decimal}, ...],
'excluded_by': Discount или None,
'combine_mode_used': str
}
"""
if not discounts:
return {
'total_discount': Decimal('0'),
'applied_discounts': [],
'excluded_by': None,
'combine_mode_used': 'none'
}
# 1. Проверяем наличие exclusive скидки
exclusive_discounts = [d for d in discounts if d.combine_mode == 'exclusive']
if exclusive_discounts:
# Применяем только первую exclusive скидку
discount = exclusive_discounts[0]
amount = discount.calculate_discount_amount(base_amount)
return {
'total_discount': amount,
'applied_discounts': [{'discount': discount, 'amount': amount}],
'excluded_by': discount,
'combine_mode_used': 'exclusive'
}
# 2. Разделяем скидки на stack и max_only
stack_discounts = [d for d in discounts if d.combine_mode == 'stack']
max_only_discounts = [d for d in discounts if d.combine_mode == 'max_only']
result = {
'total_discount': Decimal('0'),
'applied_discounts': [],
'excluded_by': None,
'combine_mode_used': 'combined'
}
# 3. Для max_only применяем только максимальную
if max_only_discounts:
max_discount = max(
max_only_discounts,
key=lambda d: d.calculate_discount_amount(base_amount)
)
amount = max_discount.calculate_discount_amount(base_amount)
result['applied_discounts'].append({'discount': max_discount, 'amount': amount})
result['total_discount'] += amount
# 4. Для stack суммируем все
for discount in stack_discounts:
amount = discount.calculate_discount_amount(base_amount)
result['applied_discounts'].append({'discount': discount, 'amount': amount})
result['total_discount'] += amount
# 5. Ограничиваем итоговую скидку базовой суммой
result['total_discount'] = min(result['total_discount'], base_amount)
return result
class DiscountCalculator:
"""
Калькулятор скидок для заказов.
@@ -52,7 +128,7 @@ class DiscountCalculator:
@staticmethod
def calculate_order_discount(order, promo_code=None):
"""
Рассчитать скидку на весь заказ.
Рассчитать скидку на весь заказ с поддержкой комбинирования.
Args:
order: Order объект
@@ -60,9 +136,9 @@ class DiscountCalculator:
Returns:
dict: {
'discount': Discount или None,
'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
'total_amount': Decimal,
'promo_code': PromoCode или None,
'discount_amount': Decimal,
'error': str или None
}
"""
@@ -71,12 +147,14 @@ class DiscountCalculator:
subtotal = Decimal(str(order.subtotal))
result = {
'discount': None,
'discounts': [],
'total_amount': Decimal('0'),
'promo_code': None,
'discount_amount': Decimal('0'),
'error': None
}
applicable_discounts = []
# 1. Проверяем промокод первым
if promo_code:
is_valid, promo, error = DiscountValidator.validate_promo_code(
@@ -89,38 +167,36 @@ class DiscountCalculator:
discount = promo.discount
# Проверяем scope скидки
if discount.scope != 'order':
result['error'] = "Этот промокод применяется только к товарам"
return result
result['discount'] = discount
result['promo_code'] = promo
result['discount_amount'] = discount.calculate_discount_amount(subtotal)
return result
applicable_discounts.append(discount)
else:
# 2. Если нет промокода, собираем все автоматические скидки
auto_discounts = DiscountCalculator.get_available_discounts(
scope='order',
auto_only=True
)
# 2. Если нет промокода, проверяем автоматические скидки
auto_discounts = DiscountCalculator.get_available_discounts(
scope='order',
auto_only=True
)
for discount in auto_discounts:
if discount.min_order_amount and subtotal < discount.min_order_amount:
continue
applicable_discounts.append(discount)
for discount in auto_discounts:
# Проверяем мин. сумму заказа
if discount.min_order_amount and subtotal < discount.min_order_amount:
continue
# Применяем первую подходящую автоматическую скидку
result['discount'] = discount
result['discount_amount'] = discount.calculate_discount_amount(subtotal)
break
# 3. Комбинируем скидки
if applicable_discounts:
combination = DiscountCombiner.combine_discounts(applicable_discounts, subtotal)
result['discounts'] = combination['applied_discounts']
result['total_amount'] = combination['total_discount']
return result
@staticmethod
def calculate_item_discount(order_item, available_discounts=None):
"""
Рассчитать скидку на позицию заказа.
Рассчитать скидку на позицию заказа с поддержкой комбинирования.
Args:
order_item: OrderItem объект
@@ -128,13 +204,13 @@ class DiscountCalculator:
Returns:
dict: {
'discount': Discount или None,
'discount_amount': Decimal,
'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
'total_amount': Decimal,
}
"""
result = {
'discount': None,
'discount_amount': Decimal('0')
'discounts': [],
'total_amount': Decimal('0')
}
# Определяем продукт
@@ -149,6 +225,10 @@ class DiscountCalculator:
base_amount = Decimal(str(order_item.price)) * Decimal(str(order_item.quantity))
# Собираем все применимые скидки
applicable_discounts = []
# Скидки на товары
if not available_discounts:
available_discounts = DiscountCalculator.get_available_discounts(
scope='product',
@@ -156,48 +236,54 @@ class DiscountCalculator:
)
for discount in available_discounts:
# Проверяем, применяется ли скидка к этому товару
if not discount.applies_to_product(product):
continue
if discount.applies_to_product(product):
applicable_discounts.append(discount)
result['discount'] = discount
result['discount_amount'] = discount.calculate_discount_amount(base_amount)
break
# Скидки по категориям
category_discounts = DiscountCalculator.get_available_discounts(
scope='category',
auto_only=True
)
# Проверяем скидки по категориям
if not result['discount']:
category_discounts = DiscountCalculator.get_available_discounts(
scope='category',
auto_only=True
)
for discount in category_discounts:
if discount.applies_to_product(product) and discount not in applicable_discounts:
applicable_discounts.append(discount)
for discount in category_discounts:
if discount.applies_to_product(product):
result['discount'] = discount
result['discount_amount'] = discount.calculate_discount_amount(base_amount)
break
# Комбинируем скидки
if applicable_discounts:
combination = DiscountCombiner.combine_discounts(applicable_discounts, base_amount)
result['discounts'] = combination['applied_discounts']
result['total_amount'] = combination['total_discount']
return result
@staticmethod
def calculate_cart_discounts(cart_items, promo_code=None, customer=None):
def calculate_cart_discounts(cart_items, promo_code=None, customer=None, skip_auto_discount=False):
"""
Рассчитать скидки для корзины (применяется в POS до создания заказа).
Поддерживает комбинирование скидок по combine_mode.
Args:
cart_items: Список словарей {'type': 'product'|'kit', 'id': int, 'quantity': Decimal, 'price': Decimal}
promo_code: Промокод (опционально)
customer: Customer (опционально)
skip_auto_discount: Пропустить автоматические скидки (опционально)
Returns:
dict: {
'order_discount': {...}, # Как в calculate_order_discount
'order_discounts': [
{'discount_id': int, 'discount_name': str, 'discount_amount': Decimal, 'combine_mode': str},
...
],
'total_order_discount': Decimal,
'item_discounts': [
{'cart_index': int, 'discount': Discount, 'discount_amount': Decimal},
{'cart_index': int, 'discounts': [...], 'total_discount': Decimal},
...
],
'total_discount': Decimal,
'final_total': Decimal
'final_total': Decimal,
'cart_subtotal': Decimal,
'excluded_by': {'id': int, 'name': str} или None
}
"""
from products.models import Product, ProductKit
@@ -206,6 +292,18 @@ class DiscountCalculator:
for item in cart_items:
cart_subtotal += Decimal(str(item['price'])) * Decimal(str(item['quantity']))
# Если нужно пропустить авто-скидки, возвращаем пустой результат
if skip_auto_discount:
return {
'order_discounts': [],
'total_order_discount': Decimal('0'),
'item_discounts': [],
'total_discount': Decimal('0'),
'final_total': cart_subtotal,
'cart_subtotal': cart_subtotal,
'excluded_by': None
}
# Создаем фейковый объект для расчета скидки на заказ
class FakeOrder:
def __init__(self, subtotal, customer):
@@ -214,12 +312,36 @@ class DiscountCalculator:
fake_order = FakeOrder(cart_subtotal, customer)
# Скидка на заказ
order_discount = DiscountCalculator.calculate_order_discount(
fake_order, promo_code
)
# Скидка на заказ (с комбинированием)
order_result = DiscountCalculator.calculate_order_discount(fake_order, promo_code)
# Скидки на позиции
# Форматируем order_discounts для ответа
formatted_order_discounts = []
excluded_by = None
for disc_data in order_result['discounts']:
discount = disc_data['discount']
formatted_order_discounts.append({
'discount_id': discount.id,
'discount_name': discount.name,
'discount_amount': disc_data['amount'],
'discount_type': discount.discount_type,
'discount_value': discount.value,
'combine_mode': discount.combine_mode
})
# Проверяем исключающую скидку
if order_result['discounts']:
# Определяем exclusive скидку
for disc_data in order_result['discounts']:
if disc_data['discount'].combine_mode == 'exclusive':
excluded_by = {
'id': disc_data['discount'].id,
'name': disc_data['discount'].name
}
break
# Скидки на позиции (с комбинированием)
item_discounts = []
items_total_discount = Decimal('0')
@@ -252,38 +374,51 @@ class DiscountCalculator:
base_amount = Decimal(str(item['price'])) * Decimal(str(item['quantity']))
# Проверяем скидки на товары
applied_discount = None
discount_amount = Decimal('0')
# Собираем все применимые скидки для этого товара
applicable_item_discounts = []
for discount in available_product_discounts:
if discount.applies_to_product(product):
applied_discount = discount
discount_amount = discount.calculate_discount_amount(base_amount)
break
applicable_item_discounts.append(discount)
# Если не нашли скидку на товар, проверяем категории
if not applied_discount:
for discount in available_category_discounts:
if discount.applies_to_product(product):
applied_discount = discount
discount_amount = discount.calculate_discount_amount(base_amount)
break
for discount in available_category_discounts:
if discount.applies_to_product(product) and discount not in applicable_item_discounts:
applicable_item_discounts.append(discount)
if applied_discount:
item_discounts.append({
'cart_index': idx,
'discount': applied_discount,
'discount_amount': discount_amount
})
items_total_discount += discount_amount
# Комбинируем скидки для позиции
if applicable_item_discounts:
combination = DiscountCombiner.combine_discounts(
applicable_item_discounts,
base_amount
)
total_discount = order_discount['discount_amount'] + items_total_discount
formatted_discounts = []
for disc_data in combination['applied_discounts']:
discount = disc_data['discount']
formatted_discounts.append({
'discount_id': discount.id,
'discount_name': discount.name,
'discount_amount': disc_data['amount'],
'combine_mode': discount.combine_mode
})
if formatted_discounts:
item_discounts.append({
'cart_index': idx,
'discounts': formatted_discounts,
'total_discount': combination['total_discount']
})
items_total_discount += combination['total_discount']
total_discount = order_result['total_amount'] + items_total_discount
final_total = max(cart_subtotal - total_discount, Decimal('0'))
return {
'order_discount': order_discount,
'order_discounts': formatted_order_discounts,
'total_order_discount': order_result['total_amount'],
'item_discounts': item_discounts,
'total_discount': total_discount,
'final_total': final_total
'final_total': final_total,
'cart_subtotal': cart_subtotal,
'excluded_by': excluded_by
}