Создано новое Django приложение для управления скидками: Модели: - BaseDiscount: абстрактный базовый класс с общими полями - Discount: основная модель скидки (процент/фикс, на заказ/товар/категорию) - PromoCode: промокоды для активации скидок - DiscountApplication: история применения скидок Сервисы: - DiscountCalculator: расчёт скидок для корзины и заказов - DiscountApplier: применение скидок к заказам (атомарно) - DiscountValidator: валидация промокодов и условий Админ-панель: - DiscountAdmin: управление скидками - PromoCodeAdmin: управление промокодами - DiscountApplicationAdmin: история применения (только чтение) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
290 lines
10 KiB
Python
290 lines
10 KiB
Python
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
|
|
}
|