Добавлено поле 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>
425 lines
16 KiB
Python
425 lines
16 KiB
Python
from decimal import Decimal
|
||
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:
|
||
"""
|
||
Калькулятор скидок для заказов.
|
||
Рассчитывает применимые скидки и их суммы.
|
||
"""
|
||
|
||
@staticmethod
|
||
def get_available_discounts(scope=None, customer=None, auto_only=False):
|
||
"""
|
||
Получить список доступных скидок.
|
||
|
||
Args:
|
||
scope: 'order', 'product', 'category' или None для всех
|
||
customer: Customer для проверки условий
|
||
auto_only: Только автоматические скидки
|
||
|
||
Returns:
|
||
QuerySet[Discount]: Активные скидки, отсортированные по приоритету
|
||
"""
|
||
from discounts.models import Discount
|
||
|
||
now = timezone.now()
|
||
qs = Discount.objects.filter(is_active=True)
|
||
|
||
# Фильтр по scope
|
||
if scope:
|
||
qs = qs.filter(scope=scope)
|
||
|
||
# Фильтр по auto
|
||
if auto_only:
|
||
qs = qs.filter(is_auto=True)
|
||
|
||
# Фильтр по дате
|
||
qs = qs.filter(
|
||
models.Q(start_date__isnull=True) | models.Q(start_date__lte=now),
|
||
models.Q(end_date__isnull=True) | models.Q(end_date__gte=now)
|
||
)
|
||
|
||
# Фильтр по лимиту использований
|
||
qs = qs.filter(
|
||
models.Q(max_usage_count__isnull=True) |
|
||
models.Q(current_usage_count__lt=models.F('max_usage_count'))
|
||
)
|
||
|
||
return qs.order_by('-priority', '-created_at')
|
||
|
||
@staticmethod
|
||
def calculate_order_discount(order, promo_code=None):
|
||
"""
|
||
Рассчитать скидку на весь заказ с поддержкой комбинирования.
|
||
|
||
Args:
|
||
order: Order объект
|
||
promo_code: Строка промокода (опционально)
|
||
|
||
Returns:
|
||
dict: {
|
||
'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
|
||
'total_amount': Decimal,
|
||
'promo_code': PromoCode или None,
|
||
'error': str или None
|
||
}
|
||
"""
|
||
from discounts.models import PromoCode
|
||
from discounts.services.validator import DiscountValidator
|
||
|
||
subtotal = Decimal(str(order.subtotal))
|
||
result = {
|
||
'discounts': [],
|
||
'total_amount': Decimal('0'),
|
||
'promo_code': None,
|
||
'error': None
|
||
}
|
||
|
||
applicable_discounts = []
|
||
|
||
# 1. Проверяем промокод первым
|
||
if promo_code:
|
||
is_valid, promo, error = DiscountValidator.validate_promo_code(
|
||
promo_code, order.customer, subtotal
|
||
)
|
||
|
||
if not is_valid:
|
||
result['error'] = error
|
||
return result
|
||
|
||
discount = promo.discount
|
||
|
||
if discount.scope != 'order':
|
||
result['error'] = "Этот промокод применяется только к товарам"
|
||
return result
|
||
|
||
result['promo_code'] = promo
|
||
applicable_discounts.append(discount)
|
||
else:
|
||
# 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)
|
||
|
||
# 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 объект
|
||
available_discounts: Предварительно полученный список скидок
|
||
|
||
Returns:
|
||
dict: {
|
||
'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
|
||
'total_amount': Decimal,
|
||
}
|
||
"""
|
||
result = {
|
||
'discounts': [],
|
||
'total_amount': Decimal('0')
|
||
}
|
||
|
||
# Определяем продукт
|
||
product = None
|
||
if order_item.product:
|
||
product = order_item.product
|
||
elif order_item.product_kit:
|
||
product = order_item.product_kit
|
||
|
||
if not product:
|
||
return result
|
||
|
||
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',
|
||
auto_only=True
|
||
)
|
||
|
||
for discount in available_discounts:
|
||
if discount.applies_to_product(product):
|
||
applicable_discounts.append(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)
|
||
|
||
# Комбинируем скидки
|
||
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, 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_discounts': [
|
||
{'discount_id': int, 'discount_name': str, 'discount_amount': Decimal, 'combine_mode': str},
|
||
...
|
||
],
|
||
'total_order_discount': Decimal,
|
||
'item_discounts': [
|
||
{'cart_index': int, 'discounts': [...], 'total_discount': Decimal},
|
||
...
|
||
],
|
||
'total_discount': Decimal,
|
||
'final_total': Decimal,
|
||
'cart_subtotal': Decimal,
|
||
'excluded_by': {'id': int, 'name': str} или None
|
||
}
|
||
"""
|
||
from products.models import Product, ProductKit
|
||
|
||
cart_subtotal = Decimal('0')
|
||
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):
|
||
self.subtotal = subtotal
|
||
self.customer = customer
|
||
|
||
fake_order = FakeOrder(cart_subtotal, customer)
|
||
|
||
# Скидка на заказ (с комбинированием)
|
||
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')
|
||
|
||
available_product_discounts = list(DiscountCalculator.get_available_discounts(
|
||
scope='product',
|
||
auto_only=True
|
||
))
|
||
|
||
available_category_discounts = list(DiscountCalculator.get_available_discounts(
|
||
scope='category',
|
||
auto_only=True
|
||
))
|
||
|
||
for idx, item in enumerate(cart_items):
|
||
# Загружаем продукт
|
||
product = None
|
||
if item.get('type') == 'product':
|
||
try:
|
||
product = Product.objects.get(id=item['id'])
|
||
except Product.DoesNotExist:
|
||
pass
|
||
elif item.get('type') == 'kit':
|
||
try:
|
||
product = ProductKit.objects.get(id=item['id'])
|
||
except ProductKit.DoesNotExist:
|
||
pass
|
||
|
||
if not product:
|
||
continue
|
||
|
||
base_amount = Decimal(str(item['price'])) * Decimal(str(item['quantity']))
|
||
|
||
# Собираем все применимые скидки для этого товара
|
||
applicable_item_discounts = []
|
||
|
||
for discount in available_product_discounts:
|
||
if discount.applies_to_product(product):
|
||
applicable_item_discounts.append(discount)
|
||
|
||
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 applicable_item_discounts:
|
||
combination = DiscountCombiner.combine_discounts(
|
||
applicable_item_discounts,
|
||
base_amount
|
||
)
|
||
|
||
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_discounts': formatted_order_discounts,
|
||
'total_order_discount': order_result['total_amount'],
|
||
'item_discounts': item_discounts,
|
||
'total_discount': total_discount,
|
||
'final_total': final_total,
|
||
'cart_subtotal': cart_subtotal,
|
||
'excluded_by': excluded_by
|
||
}
|