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

@@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
class DiscountApplier:
"""
Сервис для применения скидок к заказам.
Поддерживает комбинирование скидок по combine_mode.
Все операции атомарны.
"""
@@ -14,6 +15,7 @@ class DiscountApplier:
def apply_promo_code(order, promo_code, user=None):
"""
Применить промокод к заказу.
Поддерживает комбинирование скидок.
Args:
order: Order
@@ -23,8 +25,8 @@ class DiscountApplier:
Returns:
dict: {
'success': bool,
'discount': Discount,
'discount_amount': Decimal,
'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
'total_amount': Decimal,
'error': str
}
"""
@@ -36,9 +38,7 @@ class DiscountApplier:
DiscountApplier._remove_order_discount_only(order)
# Рассчитываем скидку
result = DiscountCalculator.calculate_order_discount(
order, promo_code
)
result = DiscountCalculator.calculate_order_discount(order, promo_code)
if result['error']:
return {
@@ -46,15 +46,17 @@ class DiscountApplier:
'error': result['error']
}
discount = result['discount']
promo = result['promo_code']
discount_amount = result['discount_amount']
discounts_data = result['discounts']
total_amount = result['total_amount']
# Применяем к заказу
order.applied_discount = discount
order.applied_promo_code = promo.code
order.discount_amount = discount_amount
order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount'])
# Применяем первую скидку в applied_discount (для обратной совместимости с Order)
if discounts_data:
first_discount = discounts_data[0]['discount']
order.applied_discount = first_discount
order.applied_promo_code = promo.code
order.discount_amount = total_amount
order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount'])
# Пересчитываем total_amount
order.calculate_total()
@@ -62,27 +64,31 @@ class DiscountApplier:
# Регистрируем использование промокода
promo.record_usage(order.customer)
# Создаем запись о применении
DiscountApplication.objects.create(
order=order,
discount=discount,
promo_code=promo,
target='order',
base_amount=order.subtotal,
discount_amount=discount_amount,
final_amount=order.subtotal - discount_amount,
customer=order.customer,
applied_by=user or order.modified_by
)
# Создаем записи о применении для каждой скидки
for disc_data in discounts_data:
discount = disc_data['discount']
amount = disc_data['amount']
# Увеличиваем счетчик использований скидки
discount.current_usage_count += 1
discount.save(update_fields=['current_usage_count'])
DiscountApplication.objects.create(
order=order,
discount=discount,
promo_code=promo,
target='order',
base_amount=order.subtotal,
discount_amount=amount,
final_amount=order.subtotal - amount,
customer=order.customer,
applied_by=user or order.modified_by
)
# Увеличиваем счетчик использований скидки
discount.current_usage_count += 1
discount.save(update_fields=['current_usage_count'])
return {
'success': True,
'discount': discount,
'discount_amount': discount_amount
'discounts': discounts_data,
'total_amount': total_amount
}
@staticmethod
@@ -90,6 +96,7 @@ class DiscountApplier:
def apply_auto_discounts(order, user=None):
"""
Применить автоматические скидки к заказу и позициям.
Поддерживает комбинирование скидок.
Args:
order: Order
@@ -97,7 +104,7 @@ class DiscountApplier:
Returns:
dict: {
'order_discount': {...},
'order_discounts': [...],
'item_discounts': [...],
'total_discount': Decimal
}
@@ -106,71 +113,35 @@ class DiscountApplier:
from discounts.services.calculator import DiscountCalculator
result = {
'order_discount': None,
'order_discounts': [],
'item_discounts': [],
'total_discount': Decimal('0')
}
# 1. Применяем скидку на заказ (если есть)
# 1. Применяем скидки на заказ (может быть несколько)
order_result = DiscountCalculator.calculate_order_discount(order)
if order_result['discount'] and not order_result['error']:
discount = order_result['discount']
discount_amount = order_result['discount_amount']
order.applied_discount = discount
order.discount_amount = discount_amount
if order_result['discounts'] and not order_result['error']:
total_order_amount = order_result['total_amount']
# Сохраняем первую скидку в applied_discount (для совместимости)
first_discount_data = order_result['discounts'][0]
order.applied_discount = first_discount_data['discount']
order.discount_amount = total_order_amount
order.save(update_fields=['applied_discount', 'discount_amount'])
# Создаем запись о применении
DiscountApplication.objects.create(
order=order,
discount=discount,
target='order',
base_amount=order.subtotal,
discount_amount=discount_amount,
final_amount=order.subtotal - discount_amount,
customer=order.customer,
applied_by=user
)
# Создаем записи о применении для всех скидок
for disc_data in order_result['discounts']:
discount = disc_data['discount']
amount = disc_data['amount']
# Увеличиваем счетчик
discount.current_usage_count += 1
discount.save(update_fields=['current_usage_count'])
result['order_discount'] = {
'discount': discount,
'discount_amount': discount_amount
}
result['total_discount'] += discount_amount
# 2. Применяем скидки на позиции
available_discounts = list(DiscountCalculator.get_available_discounts(
scope='product',
auto_only=True
))
for item in order.items.all():
item_result = DiscountCalculator.calculate_item_discount(
item, available_discounts
)
if item_result['discount']:
discount = item_result['discount']
discount_amount = item_result['discount_amount']
item.applied_discount = discount
item.discount_amount = discount_amount
item.save(update_fields=['applied_discount', 'discount_amount'])
# Создаем запись о применении
DiscountApplication.objects.create(
order=order,
order_item=item,
discount=discount,
target='order_item',
base_amount=item.price * item.quantity,
discount_amount=discount_amount,
final_amount=item.get_total_price(),
target='order',
base_amount=order.subtotal,
discount_amount=amount,
final_amount=order.subtotal - amount,
customer=order.customer,
applied_by=user
)
@@ -179,12 +150,57 @@ class DiscountApplier:
discount.current_usage_count += 1
discount.save(update_fields=['current_usage_count'])
result['order_discounts'] = order_result['discounts']
result['total_discount'] += total_order_amount
# 2. Применяем скидки на позиции
available_product_discounts = list(DiscountCalculator.get_available_discounts(
scope='product',
auto_only=True
))
for item in order.items.all():
item_result = DiscountCalculator.calculate_item_discount(
item, available_product_discounts
)
if item_result['discounts']:
total_item_amount = item_result['total_amount']
# Сохраняем первую скидку в applied_discount (для совместимости)
first_discount_data = item_result['discounts'][0]
item.applied_discount = first_discount_data['discount']
item.discount_amount = total_item_amount
item.save(update_fields=['applied_discount', 'discount_amount'])
# Создаем записи о применении для всех скидок
base_amount = item.price * item.quantity
for disc_data in item_result['discounts']:
discount = disc_data['discount']
amount = disc_data['amount']
DiscountApplication.objects.create(
order=order,
order_item=item,
discount=discount,
target='order_item',
base_amount=base_amount,
discount_amount=amount,
final_amount=item.get_total_price(),
customer=order.customer,
applied_by=user
)
# Увеличиваем счетчик
discount.current_usage_count += 1
discount.save(update_fields=['current_usage_count'])
result['item_discounts'].append({
'item': item,
'discount': discount,
'discount_amount': discount_amount
'discounts': item_result['discounts'],
'total_amount': total_item_amount
})
result['total_discount'] += discount_amount
result['total_discount'] += total_item_amount
# Пересчитываем итоговую сумму
order.calculate_total()
@@ -208,8 +224,9 @@ class DiscountApplier:
discount_amount=Decimal('0')
)
# Удаляем записи о применении (опционально - для истории можно оставить)
# DiscountApplication.objects.filter(order=order).delete()
# Удаляем записи о применении
from discounts.models import DiscountApplication
DiscountApplication.objects.filter(order=order).delete()
# Пересчитываем
order.calculate_total()
@@ -285,6 +302,11 @@ class DiscountApplier:
Args:
order: Order
"""
from discounts.models import DiscountApplication
# Удаляем записи о применении скидок к заказу
DiscountApplication.objects.filter(order=order, target='order').delete()
order.applied_discount = None
order.applied_promo_code = None
order.discount_amount = Decimal('0')