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:
9
myproject/discounts/services/__init__.py
Normal file
9
myproject/discounts/services/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .calculator import DiscountCalculator
|
||||
from .applier import DiscountApplier
|
||||
from .validator import DiscountValidator
|
||||
|
||||
__all__ = [
|
||||
'DiscountCalculator',
|
||||
'DiscountApplier',
|
||||
'DiscountValidator',
|
||||
]
|
||||
228
myproject/discounts/services/applier.py
Normal file
228
myproject/discounts/services/applier.py
Normal 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'])
|
||||
289
myproject/discounts/services/calculator.py
Normal file
289
myproject/discounts/services/calculator.py
Normal file
@@ -0,0 +1,289 @@
|
||||
from decimal import Decimal
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class DiscountCalculator:
|
||||
"""
|
||||
Калькулятор скидок для заказов.
|
||||
Рассчитывает применимые скидки и их суммы.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_available_discounts(scope=None, customer=None, auto_only=False):
|
||||
"""
|
||||
Получить список доступных скидок.
|
||||
|
||||
Args:
|
||||
scope: 'order', 'product', 'category' или None для всех
|
||||
customer: Customer для проверки условий
|
||||
auto_only: Только автоматические скидки
|
||||
|
||||
Returns:
|
||||
QuerySet[Discount]: Активные скидки, отсортированные по приоритету
|
||||
"""
|
||||
from discounts.models import Discount
|
||||
|
||||
now = timezone.now()
|
||||
qs = Discount.objects.filter(is_active=True)
|
||||
|
||||
# Фильтр по scope
|
||||
if scope:
|
||||
qs = qs.filter(scope=scope)
|
||||
|
||||
# Фильтр по auto
|
||||
if auto_only:
|
||||
qs = qs.filter(is_auto=True)
|
||||
|
||||
# Фильтр по дате
|
||||
qs = qs.filter(
|
||||
models.Q(start_date__isnull=True) | models.Q(start_date__lte=now),
|
||||
models.Q(end_date__isnull=True) | models.Q(end_date__gte=now)
|
||||
)
|
||||
|
||||
# Фильтр по лимиту использований
|
||||
qs = qs.filter(
|
||||
models.Q(max_usage_count__isnull=True) |
|
||||
models.Q(current_usage_count__lt=models.F('max_usage_count'))
|
||||
)
|
||||
|
||||
return qs.order_by('-priority', '-created_at')
|
||||
|
||||
@staticmethod
|
||||
def calculate_order_discount(order, promo_code=None):
|
||||
"""
|
||||
Рассчитать скидку на весь заказ.
|
||||
|
||||
Args:
|
||||
order: Order объект
|
||||
promo_code: Строка промокода (опционально)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'discount': Discount или None,
|
||||
'promo_code': PromoCode или None,
|
||||
'discount_amount': Decimal,
|
||||
'error': str или None
|
||||
}
|
||||
"""
|
||||
from discounts.models import PromoCode
|
||||
from discounts.services.validator import DiscountValidator
|
||||
|
||||
subtotal = Decimal(str(order.subtotal))
|
||||
result = {
|
||||
'discount': None,
|
||||
'promo_code': None,
|
||||
'discount_amount': Decimal('0'),
|
||||
'error': None
|
||||
}
|
||||
|
||||
# 1. Проверяем промокод первым
|
||||
if promo_code:
|
||||
is_valid, promo, error = DiscountValidator.validate_promo_code(
|
||||
promo_code, order.customer, subtotal
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
result['error'] = error
|
||||
return result
|
||||
|
||||
discount = promo.discount
|
||||
|
||||
# Проверяем scope скидки
|
||||
if discount.scope != 'order':
|
||||
result['error'] = "Этот промокод применяется только к товарам"
|
||||
return result
|
||||
|
||||
result['discount'] = discount
|
||||
result['promo_code'] = promo
|
||||
result['discount_amount'] = discount.calculate_discount_amount(subtotal)
|
||||
return result
|
||||
|
||||
# 2. Если нет промокода, проверяем автоматические скидки
|
||||
auto_discounts = DiscountCalculator.get_available_discounts(
|
||||
scope='order',
|
||||
auto_only=True
|
||||
)
|
||||
|
||||
for discount in auto_discounts:
|
||||
# Проверяем мин. сумму заказа
|
||||
if discount.min_order_amount and subtotal < discount.min_order_amount:
|
||||
continue
|
||||
|
||||
# Применяем первую подходящую автоматическую скидку
|
||||
result['discount'] = discount
|
||||
result['discount_amount'] = discount.calculate_discount_amount(subtotal)
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def calculate_item_discount(order_item, available_discounts=None):
|
||||
"""
|
||||
Рассчитать скидку на позицию заказа.
|
||||
|
||||
Args:
|
||||
order_item: OrderItem объект
|
||||
available_discounts: Предварительно полученный список скидок
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'discount': Discount или None,
|
||||
'discount_amount': Decimal,
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
'discount': None,
|
||||
'discount_amount': Decimal('0')
|
||||
}
|
||||
|
||||
# Определяем продукт
|
||||
product = None
|
||||
if order_item.product:
|
||||
product = order_item.product
|
||||
elif order_item.product_kit:
|
||||
product = order_item.product_kit
|
||||
|
||||
if not product:
|
||||
return result
|
||||
|
||||
base_amount = Decimal(str(order_item.price)) * Decimal(str(order_item.quantity))
|
||||
|
||||
if not available_discounts:
|
||||
available_discounts = DiscountCalculator.get_available_discounts(
|
||||
scope='product',
|
||||
auto_only=True
|
||||
)
|
||||
|
||||
for discount in available_discounts:
|
||||
# Проверяем, применяется ли скидка к этому товару
|
||||
if not discount.applies_to_product(product):
|
||||
continue
|
||||
|
||||
result['discount'] = discount
|
||||
result['discount_amount'] = discount.calculate_discount_amount(base_amount)
|
||||
break
|
||||
|
||||
# Проверяем скидки по категориям
|
||||
if not result['discount']:
|
||||
category_discounts = DiscountCalculator.get_available_discounts(
|
||||
scope='category',
|
||||
auto_only=True
|
||||
)
|
||||
|
||||
for discount in category_discounts:
|
||||
if discount.applies_to_product(product):
|
||||
result['discount'] = discount
|
||||
result['discount_amount'] = discount.calculate_discount_amount(base_amount)
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def calculate_cart_discounts(cart_items, promo_code=None, customer=None):
|
||||
"""
|
||||
Рассчитать скидки для корзины (применяется в POS до создания заказа).
|
||||
|
||||
Args:
|
||||
cart_items: Список словарей {'type': 'product'|'kit', 'id': int, 'quantity': Decimal, 'price': Decimal}
|
||||
promo_code: Промокод (опционально)
|
||||
customer: Customer (опционально)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'order_discount': {...}, # Как в calculate_order_discount
|
||||
'item_discounts': [
|
||||
{'cart_index': int, 'discount': Discount, 'discount_amount': Decimal},
|
||||
...
|
||||
],
|
||||
'total_discount': Decimal,
|
||||
'final_total': Decimal
|
||||
}
|
||||
"""
|
||||
from products.models import Product, ProductKit
|
||||
|
||||
cart_subtotal = Decimal('0')
|
||||
for item in cart_items:
|
||||
cart_subtotal += Decimal(str(item['price'])) * Decimal(str(item['quantity']))
|
||||
|
||||
# Создаем фейковый объект для расчета скидки на заказ
|
||||
class FakeOrder:
|
||||
def __init__(self, subtotal, customer):
|
||||
self.subtotal = subtotal
|
||||
self.customer = customer
|
||||
|
||||
fake_order = FakeOrder(cart_subtotal, customer)
|
||||
|
||||
# Скидка на заказ
|
||||
order_discount = DiscountCalculator.calculate_order_discount(
|
||||
fake_order, promo_code
|
||||
)
|
||||
|
||||
# Скидки на позиции
|
||||
item_discounts = []
|
||||
items_total_discount = Decimal('0')
|
||||
|
||||
available_product_discounts = list(DiscountCalculator.get_available_discounts(
|
||||
scope='product',
|
||||
auto_only=True
|
||||
))
|
||||
|
||||
available_category_discounts = list(DiscountCalculator.get_available_discounts(
|
||||
scope='category',
|
||||
auto_only=True
|
||||
))
|
||||
|
||||
for idx, item in enumerate(cart_items):
|
||||
# Загружаем продукт
|
||||
product = None
|
||||
if item.get('type') == 'product':
|
||||
try:
|
||||
product = Product.objects.get(id=item['id'])
|
||||
except Product.DoesNotExist:
|
||||
pass
|
||||
elif item.get('type') == 'kit':
|
||||
try:
|
||||
product = ProductKit.objects.get(id=item['id'])
|
||||
except ProductKit.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not product:
|
||||
continue
|
||||
|
||||
base_amount = Decimal(str(item['price'])) * Decimal(str(item['quantity']))
|
||||
|
||||
# Проверяем скидки на товары
|
||||
applied_discount = None
|
||||
discount_amount = Decimal('0')
|
||||
|
||||
for discount in available_product_discounts:
|
||||
if discount.applies_to_product(product):
|
||||
applied_discount = discount
|
||||
discount_amount = discount.calculate_discount_amount(base_amount)
|
||||
break
|
||||
|
||||
# Если не нашли скидку на товар, проверяем категории
|
||||
if not applied_discount:
|
||||
for discount in available_category_discounts:
|
||||
if discount.applies_to_product(product):
|
||||
applied_discount = discount
|
||||
discount_amount = discount.calculate_discount_amount(base_amount)
|
||||
break
|
||||
|
||||
if applied_discount:
|
||||
item_discounts.append({
|
||||
'cart_index': idx,
|
||||
'discount': applied_discount,
|
||||
'discount_amount': discount_amount
|
||||
})
|
||||
items_total_discount += discount_amount
|
||||
|
||||
total_discount = order_discount['discount_amount'] + items_total_discount
|
||||
final_total = max(cart_subtotal - total_discount, Decimal('0'))
|
||||
|
||||
return {
|
||||
'order_discount': order_discount,
|
||||
'item_discounts': item_discounts,
|
||||
'total_discount': total_discount,
|
||||
'final_total': final_total
|
||||
}
|
||||
101
myproject/discounts/services/validator.py
Normal file
101
myproject/discounts/services/validator.py
Normal 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
|
||||
Reference in New Issue
Block a user