Files
Andrey Smakotin c070e42cab feat(discounts, orders): рефакторинг системы скидок - единый источник правды
- Добавлен combine_mode в форму создания/редактирования скидок
- Добавлена колонка "Объединение" в список скидок с иконками
- Добавлен фильтр по режиму объединения скидок
- Добавлена валидация: только одна exclusive скидка на заказ
- Удалены дублирующие поля из Order и OrderItem:
  - applied_discount, applied_promo_code, discount_amount
- Скидки теперь хранятся только в DiscountApplication
- Добавлены свойства для обратной совместимости

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:46:02 +03:00

278 lines
9.8 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 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
# Удаляем предыдущую скидку на заказ
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']
# Создаем записи о применении для каждой скидки в DiscountApplication
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'])
# Пересчитываем total_amount (использует DiscountApplication)
order.calculate_total()
# Регистрируем использование промокода
promo.record_usage(order.customer)
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']
# Создаем записи о применении для всех скидок
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']
# Создаем записи о применении для всех скидок
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=base_amount - amount,
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
"""
# Удаляем записи о применении
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))
# Создаем запись о применении
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'])
# Пересчитываем total_amount
order.calculate_total()
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.discount_amount теперь свойство, берущее из DiscountApplication)
order.calculate_total()