feat(discounts): добавлено приложение скидок

Создано новое Django приложение для управления скидками:

Модели:
- BaseDiscount: абстрактный базовый класс с общими полями
- Discount: основная модель скидки (процент/фикс, на заказ/товар/категорию)
- PromoCode: промокоды для активации скидок
- DiscountApplication: история применения скидок

Сервисы:
- DiscountCalculator: расчёт скидок для корзины и заказов
- DiscountApplier: применение скидок к заказам (атомарно)
- DiscountValidator: валидация промокодов и условий

Админ-панель:
- DiscountAdmin: управление скидками
- PromoCodeAdmin: управление промокодами
- DiscountApplicationAdmin: история применения (только чтение)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 00:30:14 +03:00
parent 27cb9ba09d
commit 241625eba7
14 changed files with 1524 additions and 0 deletions

View File

@@ -0,0 +1,228 @@
from decimal import Decimal
from django.db import transaction
from django.core.exceptions import ValidationError
class DiscountApplier:
"""
Сервис для применения скидок к заказам.
Все операции атомарны.
"""
@staticmethod
@transaction.atomic
def apply_promo_code(order, promo_code, user=None):
"""
Применить промокод к заказу.
Args:
order: Order
promo_code: str
user: CustomUser (применивший скидку)
Returns:
dict: {
'success': bool,
'discount': Discount,
'discount_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']
}
discount = result['discount']
promo = result['promo_code']
discount_amount = result['discount_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'])
# Пересчитываем total_amount
order.calculate_total()
# Регистрируем использование промокода
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
)
# Увеличиваем счетчик использований скидки
discount.current_usage_count += 1
discount.save(update_fields=['current_usage_count'])
return {
'success': True,
'discount': discount,
'discount_amount': discount_amount
}
@staticmethod
@transaction.atomic
def apply_auto_discounts(order, user=None):
"""
Применить автоматические скидки к заказу и позициям.
Args:
order: Order
user: CustomUser
Returns:
dict: {
'order_discount': {...},
'item_discounts': [...],
'total_discount': Decimal
}
"""
from discounts.models import Discount, DiscountApplication
from discounts.services.calculator import DiscountCalculator
result = {
'order_discount': None,
'item_discounts': [],
'total_discount': Decimal('0')
}
# 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
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
)
# Увеличиваем счетчик
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(),
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
})
result['total_discount'] += discount_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')
)
# Удаляем записи о применении (опционально - для истории можно оставить)
# DiscountApplication.objects.filter(order=order).delete()
# Пересчитываем
order.calculate_total()
@staticmethod
def _remove_order_discount_only(order):
"""
Удалить только скидку с заказа (не трогая позиции).
Args:
order: Order
"""
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'])