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 }