Files
octopus/myproject/discounts/services/calculator.py
Andrey Smakotin f57e639dbe 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>
2026-01-11 12:56:38 +03:00

425 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}