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,11 @@
from .base import BaseDiscount
from .discount import Discount
from .promo_code import PromoCode
from .application import DiscountApplication
__all__ = [
'BaseDiscount',
'Discount',
'PromoCode',
'DiscountApplication',
]

View File

@@ -0,0 +1,110 @@
from django.db import models
class DiscountApplication(models.Model):
"""
История применения скидок к заказам и позициям.
Используется для аналитики и отчётов.
"""
DISCOUNT_TARGET_CHOICES = [
('order', 'Заказ'),
('order_item', 'Позиция заказа'),
]
order = models.ForeignKey(
'orders.Order',
on_delete=models.CASCADE,
related_name='discount_applications',
verbose_name="Заказ"
)
order_item = models.ForeignKey(
'orders.OrderItem',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='discount_applications',
verbose_name="Позиция заказа"
)
discount = models.ForeignKey(
'Discount',
on_delete=models.SET_NULL,
null=True,
related_name='applications',
verbose_name="Скидка"
)
promo_code = models.ForeignKey(
'PromoCode',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='applications',
verbose_name="Промокод"
)
target = models.CharField(
max_length=20,
choices=DISCOUNT_TARGET_CHOICES,
verbose_name="Объект применения"
)
base_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Базовая сумма"
)
discount_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Сумма скидки"
)
final_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Итоговая сумма"
)
customer = models.ForeignKey(
'customers.Customer',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='discount_applications',
verbose_name="Клиент"
)
applied_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата применения"
)
applied_by = models.ForeignKey(
'accounts.CustomUser',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='applied_discounts',
verbose_name="Применен пользователем"
)
class Meta:
verbose_name = "Применение скидки"
verbose_name_plural = "Применения скидок"
indexes = [
models.Index(fields=['order']),
models.Index(fields=['discount']),
models.Index(fields=['promo_code']),
models.Index(fields=['customer']),
models.Index(fields=['applied_at']),
]
def __str__(self):
target_info = f"Заказ #{self.order.order_number}"
if self.order_item:
target_info += f", {self.order_item.item_name_snapshot}"
return f"{self.discount.name} -> {target_info} (-{self.discount_amount})"

View File

@@ -0,0 +1,171 @@
from django.db import models
from django.core.exceptions import ValidationError
class BaseDiscount(models.Model):
"""
Абстрактный базовый класс для всех типов скидок.
Содержит общие поля и логику валидации.
"""
DISCOUNT_TYPE_CHOICES = [
('percentage', 'Процент'),
('fixed_amount', 'Фиксированная сумма'),
]
SCOPE_CHOICES = [
('order', 'На весь заказ'),
('product', 'На товар'),
('category', 'На категорию товаров'),
]
name = models.CharField(
max_length=200,
verbose_name="Название скидки"
)
description = models.TextField(
blank=True,
verbose_name="Описание"
)
discount_type = models.CharField(
max_length=20,
choices=DISCOUNT_TYPE_CHOICES,
verbose_name="Тип скидки"
)
value = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Значение",
help_text="Процент (0-100) или сумма в рублях"
)
scope = models.CharField(
max_length=20,
choices=SCOPE_CHOICES,
default='order',
verbose_name="Уровень применения"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активна",
db_index=True
)
start_date = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата начала действия"
)
end_date = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата окончания действия"
)
max_usage_count = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name="Макс. количество использований",
help_text="Оставьте пустым для безлимитного использования"
)
current_usage_count = models.PositiveIntegerField(
default=0,
verbose_name="Текущее количество использований"
)
priority = models.PositiveIntegerField(
default=0,
verbose_name="Приоритет",
help_text="Более высокий приоритет применяется первым"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
created_by = models.ForeignKey(
'accounts.CustomUser',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_discounts',
verbose_name="Создал"
)
class Meta:
abstract = True
ordering = ['-priority', '-created_at']
indexes = [
models.Index(fields=['is_active']),
models.Index(fields=['scope']),
models.Index(fields=['discount_type']),
]
def __str__(self):
if self.discount_type == 'percentage':
return f"{self.name} ({self.value}%)"
return f"{self.name} (-{self.value} руб.)"
def clean(self):
"""Валидация значений скидки"""
if self.discount_type == 'percentage':
if self.value < 0 or self.value > 100:
raise ValidationError({
'value': 'Процентная скидка должна быть от 0 до 100'
})
elif self.discount_type == 'fixed_amount':
if self.value < 0:
raise ValidationError({
'value': 'Фиксированная скидка не может быть отрицательной'
})
if self.start_date and self.end_date and self.start_date > self.end_date:
raise ValidationError({
'end_date': 'Дата окончания не может быть раньше даты начала'
})
def calculate_discount_amount(self, base_amount):
"""
Вычислить сумму скидки для заданной базовой суммы.
Args:
base_amount: Десятичное число - базовая сумма
Returns:
Decimal: Сумма скидки (не может превышать base_amount)
"""
if self.discount_type == 'percentage':
return base_amount * self.value / 100
else: # fixed_amount
return min(self.value, base_amount) # Скидка не может превышать сумму
def is_valid_now(self):
"""
Проверить, что скидка активна в текущий момент времени.
Returns:
bool: True если скидка активна
"""
from django.utils import timezone
if not self.is_active:
return False
now = timezone.now()
if self.start_date and now < self.start_date:
return False
if self.end_date and now > self.end_date:
return False
if self.max_usage_count and self.current_usage_count >= self.max_usage_count:
return False
return True

View File

@@ -0,0 +1,115 @@
from django.db import models
from .base import BaseDiscount
class Discount(BaseDiscount):
"""
Основная модель скидки.
Наследует все поля из BaseDiscount и добавляет специфические параметры.
"""
# Для scope='order' - минимальная сумма заказа
min_order_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name="Мин. сумма заказа",
help_text="Скидка применяется только если сумма заказа >= этого значения"
)
# Для scope='product' и scope='category' - товары и категории
products = models.ManyToManyField(
'products.Product',
blank=True,
related_name='discounts',
verbose_name="Товары"
)
categories = models.ManyToManyField(
'products.ProductCategory',
blank=True,
related_name='discounts',
verbose_name="Категории"
)
# Исключения (товары, к которым скидка НЕ применяется)
excluded_products = models.ManyToManyField(
'products.Product',
blank=True,
related_name='excluded_from_discounts',
verbose_name="Исключенные товары"
)
# Автоматическая скидка (не требует промокода)
is_auto = models.BooleanField(
default=False,
verbose_name="Автоматическая",
help_text="Применяется автоматически при выполнении условий"
)
class Meta:
verbose_name = "Скидка"
verbose_name_plural = "Скидки"
indexes = [
models.Index(fields=['is_active']),
models.Index(fields=['scope']),
models.Index(fields=['discount_type']),
models.Index(fields=['is_auto']),
]
def applies_to_product(self, product):
"""
Проверить, применяется ли скидка к товару.
Args:
product: Объект Product
Returns:
bool: True если скидка применяется к товару
"""
# Проверяем исключения
if self.excluded_products.filter(id=product.id).exists():
return False
# Если scope='product', проверяем прямое соответствие
if self.scope == 'product':
return self.products.filter(id=product.id).exists()
# Если scope='category', проверяем категории товара
if self.scope == 'category':
if not self.categories.exists():
return False
product_categories = product.categories.all()
return self.categories.filter(id__in=product_categories).exists()
return False
def get_applicable_products(self):
"""
Получить queryset товаров, к которым применяется эта скидка.
Returns:
QuerySet: Товары, к которым применяется скидка
"""
from products.models import Product
if self.scope == 'product':
qs = self.products.all()
# Исключаем исключенные товары
if self.excluded_products.exists():
qs = qs.exclude(id__in=self.excluded_products.values_list('id', flat=True))
return qs
if self.scope == 'category':
# Товары из указанных категорий
product_ids = Product.objects.filter(
categories__in=self.categories.all()
).values_list('id', flat=True).distinct()
# Исключаем исключенные товары
if self.excluded_products.exists():
excluded_ids = self.excluded_products.values_list('id', flat=True)
product_ids = set(product_ids) - set(excluded_ids)
return Product.objects.filter(id__in=product_ids)
return Product.objects.none()

View File

@@ -0,0 +1,147 @@
from django.db import models
from django.core.exceptions import ValidationError
from django.utils import timezone
class PromoCode(models.Model):
"""
Промокод для активации скидки.
Связывает код с одной скидкой.
"""
code = models.CharField(
max_length=50,
unique=True,
verbose_name="Код промокода",
help_text="Уникальный код (например: SALE2025, WINTER10)"
)
discount = models.ForeignKey(
'Discount',
on_delete=models.CASCADE,
related_name='promo_codes',
verbose_name="Скидка"
)
# Ограничения использования
max_uses_per_user = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name="Макс. использований на клиента",
help_text="Оставьте пустым для безлимитного использования"
)
max_total_uses = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name="Макс. общее количество использований"
)
current_uses = models.PositiveIntegerField(
default=0,
verbose_name="Текущее количество использований"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активен"
)
start_date = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата начала действия"
)
end_date = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата окончания действия"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
created_by = models.ForeignKey(
'accounts.CustomUser',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_promo_codes',
verbose_name="Создал"
)
class Meta:
verbose_name = "Промокод"
verbose_name_plural = "Промокоды"
indexes = [
models.Index(fields=['code']),
models.Index(fields=['is_active']),
]
def __str__(self):
return f"{self.code} -> {self.discount.name}"
def clean(self):
"""Валидация промокода"""
super().clean()
if self.code:
self.code = self.code.strip().upper()
def save(self, *args, **kwargs):
"""Приводим код к верхнему регистру при сохранении"""
if self.code:
self.code = self.code.strip().upper()
super().save(*args, **kwargs)
def is_valid(self, customer=None):
"""
Проверить валидность промокода.
Args:
customer: Customer для проверки использований на пользователя
Returns:
tuple: (is_valid, error_message)
"""
now = timezone.now()
if not self.is_active:
return False, "Промокод неактивен"
if self.start_date and now < self.start_date:
return False, "Промокод еще не начал действовать"
if self.end_date and now > self.end_date:
return False, "Промокод истек"
if self.max_total_uses and self.current_uses >= self.max_total_uses:
return False, "Промокод полностью использован"
if customer and self.max_uses_per_user:
# Проверяем использования этим клиентом
uses = DiscountApplication.objects.filter(
promo_code=self,
customer=customer
).count()
if uses >= self.max_uses_per_user:
return False, f"Вы уже использовали этот промокод максимальное количество раз ({self.max_uses_per_user})"
return True, None
def record_usage(self, customer=None):
"""
Зарегистрировать использование промокода.
Args:
customer: Customer (опционально)
"""
self.current_uses += 1
self.save(update_fields=['current_uses'])
# Импортируем здесь, чтобы избежать циклического импорта
from .application import DiscountApplication