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,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