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:
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
|
||||
Reference in New Issue
Block a user