Добавлено поле 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>
314 lines
12 KiB
Python
314 lines
12 KiB
Python
from decimal import Decimal
|
||
from django.db import transaction
|
||
from django.core.exceptions import ValidationError
|
||
|
||
|
||
class DiscountApplier:
|
||
"""
|
||
Сервис для применения скидок к заказам.
|
||
Поддерживает комбинирование скидок по combine_mode.
|
||
Все операции атомарны.
|
||
"""
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def apply_promo_code(order, promo_code, user=None):
|
||
"""
|
||
Применить промокод к заказу.
|
||
Поддерживает комбинирование скидок.
|
||
|
||
Args:
|
||
order: Order
|
||
promo_code: str
|
||
user: CustomUser (применивший скидку)
|
||
|
||
Returns:
|
||
dict: {
|
||
'success': bool,
|
||
'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
|
||
'total_amount': Decimal,
|
||
'error': str
|
||
}
|
||
"""
|
||
from discounts.models import PromoCode, DiscountApplication
|
||
from discounts.services.calculator import DiscountCalculator
|
||
|
||
# Удаляем предыдущую скидку на заказ
|
||
if order.applied_promo_code:
|
||
DiscountApplier._remove_order_discount_only(order)
|
||
|
||
# Рассчитываем скидку
|
||
result = DiscountCalculator.calculate_order_discount(order, promo_code)
|
||
|
||
if result['error']:
|
||
return {
|
||
'success': False,
|
||
'error': result['error']
|
||
}
|
||
|
||
promo = result['promo_code']
|
||
discounts_data = result['discounts']
|
||
total_amount = result['total_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()
|
||
|
||
# Регистрируем использование промокода
|
||
promo.record_usage(order.customer)
|
||
|
||
# Создаем записи о применении для каждой скидки
|
||
for disc_data in discounts_data:
|
||
discount = disc_data['discount']
|
||
amount = disc_data['amount']
|
||
|
||
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,
|
||
'discounts': discounts_data,
|
||
'total_amount': total_amount
|
||
}
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def apply_auto_discounts(order, user=None):
|
||
"""
|
||
Применить автоматические скидки к заказу и позициям.
|
||
Поддерживает комбинирование скидок.
|
||
|
||
Args:
|
||
order: Order
|
||
user: CustomUser
|
||
|
||
Returns:
|
||
dict: {
|
||
'order_discounts': [...],
|
||
'item_discounts': [...],
|
||
'total_discount': Decimal
|
||
}
|
||
"""
|
||
from discounts.models import Discount, DiscountApplication
|
||
from discounts.services.calculator import DiscountCalculator
|
||
|
||
result = {
|
||
'order_discounts': [],
|
||
'item_discounts': [],
|
||
'total_discount': Decimal('0')
|
||
}
|
||
|
||
# 1. Применяем скидки на заказ (может быть несколько)
|
||
order_result = DiscountCalculator.calculate_order_discount(order)
|
||
|
||
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'])
|
||
|
||
# Создаем записи о применении для всех скидок
|
||
for disc_data in order_result['discounts']:
|
||
discount = disc_data['discount']
|
||
amount = disc_data['amount']
|
||
|
||
DiscountApplication.objects.create(
|
||
order=order,
|
||
discount=discount,
|
||
target='order',
|
||
base_amount=order.subtotal,
|
||
discount_amount=amount,
|
||
final_amount=order.subtotal - amount,
|
||
customer=order.customer,
|
||
applied_by=user
|
||
)
|
||
|
||
# Увеличиваем счетчик
|
||
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,
|
||
'discounts': item_result['discounts'],
|
||
'total_amount': total_item_amount
|
||
})
|
||
result['total_discount'] += total_item_amount
|
||
|
||
# Пересчитываем итоговую сумму
|
||
order.calculate_total()
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def remove_discount_from_order(order):
|
||
"""
|
||
Удалить скидку с заказа.
|
||
|
||
Args:
|
||
order: Order
|
||
"""
|
||
DiscountApplier._remove_order_discount_only(order)
|
||
|
||
# Удаляем скидки с позиций
|
||
order.items.update(
|
||
applied_discount=None,
|
||
discount_amount=Decimal('0')
|
||
)
|
||
|
||
# Удаляем записи о применении
|
||
from discounts.models import DiscountApplication
|
||
DiscountApplication.objects.filter(order=order).delete()
|
||
|
||
# Пересчитываем
|
||
order.calculate_total()
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def apply_manual_discount(order, discount, user=None):
|
||
"""
|
||
Применить скидку вручную к заказу.
|
||
|
||
Args:
|
||
order: Order
|
||
discount: Discount
|
||
user: CustomUser (применивший скидку)
|
||
|
||
Returns:
|
||
dict: {
|
||
'success': bool,
|
||
'discount_amount': Decimal,
|
||
'error': str
|
||
}
|
||
"""
|
||
from discounts.models import DiscountApplication
|
||
|
||
# Проверяем scope скидки
|
||
if discount.scope != 'order':
|
||
return {'success': False, 'error': 'Эта скидка не применяется к заказу'}
|
||
|
||
# Проверяем мин. сумму
|
||
if discount.min_order_amount and order.subtotal < discount.min_order_amount:
|
||
return {'success': False, 'error': f'Мин. сумма заказа: {discount.min_order_amount} руб.'}
|
||
|
||
# Удаляем предыдущую скидку на заказ
|
||
DiscountApplier._remove_order_discount_only(order)
|
||
|
||
# Рассчитываем сумму
|
||
discount_amount = discount.calculate_discount_amount(Decimal(order.subtotal))
|
||
|
||
# Применяем к заказу
|
||
order.applied_discount = discount
|
||
order.discount_amount = discount_amount
|
||
order.save(update_fields=['applied_discount', 'discount_amount'])
|
||
|
||
# Пересчитываем total_amount
|
||
order.calculate_total()
|
||
|
||
# Создаем запись о применении
|
||
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
|
||
)
|
||
|
||
# Увеличиваем счетчик использований скидки
|
||
discount.current_usage_count += 1
|
||
discount.save(update_fields=['current_usage_count'])
|
||
|
||
return {
|
||
'success': True,
|
||
'discount_amount': discount_amount
|
||
}
|
||
|
||
@staticmethod
|
||
def _remove_order_discount_only(order):
|
||
"""
|
||
Удалить только скидку с заказа (не трогая позиции).
|
||
|
||
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')
|
||
order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount'])
|