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