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:
11
myproject/discounts/models/__init__.py
Normal file
11
myproject/discounts/models/__init__.py
Normal 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',
|
||||
]
|
||||
110
myproject/discounts/models/application.py
Normal file
110
myproject/discounts/models/application.py
Normal 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})"
|
||||
171
myproject/discounts/models/base.py
Normal file
171
myproject/discounts/models/base.py
Normal 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
|
||||
115
myproject/discounts/models/discount.py
Normal file
115
myproject/discounts/models/discount.py
Normal 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()
|
||||
147
myproject/discounts/models/promo_code.py
Normal file
147
myproject/discounts/models/promo_code.py
Normal 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
|
||||
Reference in New Issue
Block a user