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,101 @@
from decimal import Decimal
from django.core.exceptions import ValidationError
class DiscountValidator:
"""
Сервис для валидации скидок и промокодов.
"""
@staticmethod
def validate_promo_code(code, customer=None, order_subtotal=None):
"""
Валидировать промокод.
Args:
code: Код промокода
customer: Customer для проверки использований
order_subtotal: Сумма заказа для проверки min_order_amount
Returns:
tuple: (is_valid, promo_code_or_none, error_message)
"""
from discounts.models import PromoCode
if not code or not code.strip():
return False, None, "Промокод не указан"
try:
promo = PromoCode.objects.get(
code__iexact=code.strip().upper(),
is_active=True
)
except PromoCode.DoesNotExist:
return False, None, "Промокод не найден"
# Проверяем валидность промокода
is_valid, error = promo.is_valid(customer)
if not is_valid:
return False, None, error
# Проверяем мин. сумму заказа
if order_subtotal is not None and promo.discount.min_order_amount:
if Decimal(order_subtotal) < promo.discount.min_order_amount:
return False, None, f"Минимальная сумма заказа: {promo.discount.min_order_amount} руб."
# Проверяем scope (только заказ, не товары)
if promo.discount.scope not in ('order', 'product', 'category'):
return False, None, "Этот тип промокода не поддерживается"
return True, promo, None
@staticmethod
def validate_discount_for_order(discount, order):
"""
Проверить, можно ли применить скидку к заказу.
Args:
discount: Discount
order: Order
Returns:
tuple: (is_valid, error_message)
"""
if not discount.is_active:
return False, "Скидка неактивна"
# Проверяем даты
if not discount.is_valid_now():
return False, "Скидка недействительна"
# Проверяем мин. сумму заказа
if discount.scope == 'order' and discount.min_order_amount:
if order.subtotal < discount.min_order_amount:
return False, f"Минимальная сумма заказа: {discount.min_order_amount} руб."
return True, None
@staticmethod
def validate_auto_discount_for_cart(discount, cart_subtotal, customer=None):
"""
Проверить, можно ли применить автоматическую скидку к корзине.
Args:
discount: Discount
cart_subtotal: Десятичное число - сумма корзины
customer: Customer (опционально)
Returns:
bool: True если скидка применима
"""
if not discount.is_auto:
return False
if not discount.is_valid_now():
return False
if discount.scope == 'order' and discount.min_order_amount:
if cart_subtotal < discount.min_order_amount:
return False
return True