diff --git a/myproject/discounts/__init__.py b/myproject/discounts/__init__.py new file mode 100644 index 0000000..bc1fd66 --- /dev/null +++ b/myproject/discounts/__init__.py @@ -0,0 +1 @@ +default_app_config = 'discounts.apps.DiscountsConfig' diff --git a/myproject/discounts/admin.py b/myproject/discounts/admin.py new file mode 100644 index 0000000..6c17c67 --- /dev/null +++ b/myproject/discounts/admin.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +from django.contrib import admin +from .models import Discount, PromoCode, DiscountApplication + + +@admin.register(Discount) +class DiscountAdmin(admin.ModelAdmin): + """Админ-панель для управления скидками.""" + list_display = [ + 'name', + 'discount_type', + 'value_display', + 'scope', + 'is_auto', + 'is_active', + 'current_usage_count', + 'validity_period', + ] + + list_filter = [ + 'discount_type', + 'scope', + 'is_auto', + 'is_active', + ] + + search_fields = [ + 'name', + 'description', + ] + + readonly_fields = [ + 'current_usage_count', + 'created_at', + ] + + fieldsets = ( + ('Основная информация', { + 'fields': ('name', 'description', 'is_active', 'priority') + }), + ('Параметры скидки', { + 'fields': ('discount_type', 'value', 'scope') + }), + ('Ограничения', { + 'fields': ( + 'start_date', + 'end_date', + 'max_usage_count', + 'current_usage_count', + 'is_auto' + ) + }), + ('Условия применения', { + 'fields': ( + 'min_order_amount', + 'products', + 'categories', + 'excluded_products' + ) + }), + ('Метаданные', { + 'fields': ('created_at', 'created_by'), + 'classes': ('collapse',) + }), + ) + + def value_display(self, obj): + if obj.discount_type == 'percentage': + return f"{obj.value}%" + return f"{obj.value} руб." + value_display.short_description = "Значение" + + def validity_period(self, obj): + if obj.start_date and obj.end_date: + return f"{obj.start_date.date()} - {obj.end_date.date()}" + elif obj.start_date: + return f"с {obj.start_date.date()}" + elif obj.end_date: + return f"до {obj.end_date.date()}" + return "Бессрочная" + validity_period.short_description = "Период действия" + + +@admin.register(PromoCode) +class PromoCodeAdmin(admin.ModelAdmin): + """Админ-панель для управления промокодами.""" + list_display = [ + 'code', + 'discount_name', + 'is_active', + 'current_uses', + 'usage_limit', + 'validity_period', + ] + + list_filter = [ + 'is_active', + 'discount__scope', + ] + + search_fields = [ + 'code', + 'discount__name', + ] + + readonly_fields = [ + 'current_uses', + 'created_at', + ] + + fieldsets = ( + ('Основная информация', { + 'fields': ('code', 'discount', 'is_active') + }), + ('Ограничения', { + 'fields': ( + 'max_uses_per_user', + 'max_total_uses', + 'current_uses', + 'start_date', + 'end_date', + ) + }), + ('Метаданные', { + 'fields': ('created_at', 'created_by'), + 'classes': ('collapse',) + }), + ) + + def discount_name(self, obj): + return obj.discount.name + discount_name.short_description = "Скидка" + + def usage_limit(self, obj): + if obj.max_total_uses: + return f"{obj.current_uses} / {obj.max_total_uses}" + return str(obj.current_uses) + usage_limit.short_description = "Использования" + + def validity_period(self, obj): + if obj.start_date and obj.end_date: + return f"{obj.start_date.date()} - {obj.end_date.date()}" + elif obj.start_date: + return f"с {obj.start_date.date()}" + elif obj.end_date: + return f"до {obj.end_date.date()}" + return "Бессрочный" + validity_period.short_description = "Период действия" + + +@admin.register(DiscountApplication) +class DiscountApplicationAdmin(admin.ModelAdmin): + """Админ-панель для истории применения скидок.""" + list_display = [ + 'order_link', + 'discount_name', + 'promo_code_display', + 'target', + 'discount_amount', + 'customer', + 'applied_at', + ] + + list_filter = [ + 'target', + 'applied_at', + 'discount__discount_type', + ] + + readonly_fields = [ + 'order', + 'order_item', + 'discount', + 'promo_code', + 'target', + 'base_amount', + 'discount_amount', + 'final_amount', + 'customer', + 'applied_at', + 'applied_by', + ] + + def has_add_permission(self, request): + return False # Только чтение + + def has_change_permission(self, request, obj=None): + return False # Только чтение + + def order_link(self, obj): + from django.urls import reverse + url = reverse('admin:orders_order_change', args=[obj.order.id]) + return f'#{obj.order.order_number}' + order_link.short_description = "Заказ" + order_link.allow_tags = True + + def discount_name(self, obj): + return obj.discount.name if obj.discount else '-' + discount_name.short_description = "Скидка" + + def promo_code_display(self, obj): + return obj.promo_code.code if obj.promo_code else '-' + promo_code_display.short_description = "Промокод" diff --git a/myproject/discounts/apps.py b/myproject/discounts/apps.py new file mode 100644 index 0000000..4418314 --- /dev/null +++ b/myproject/discounts/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class DiscountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'discounts' + verbose_name = 'Скидки' diff --git a/myproject/discounts/migrations/0001_initial.py b/myproject/discounts/migrations/0001_initial.py new file mode 100644 index 0000000..e48157c --- /dev/null +++ b/myproject/discounts/migrations/0001_initial.py @@ -0,0 +1,132 @@ +# Generated by Django 5.0.10 on 2026-01-10 21:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0002_remove_customuser_first_name_and_more'), + ('customers', '0002_initial'), + ('orders', '0002_initial'), + ('products', '0002_alter_configurableproduct_archived_by_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Discount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название скидки')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('discount_type', models.CharField(choices=[('percentage', 'Процент'), ('fixed_amount', 'Фиксированная сумма')], max_length=20, verbose_name='Тип скидки')), + ('value', models.DecimalField(decimal_places=2, help_text='Процент (0-100) или сумма в рублях', max_digits=10, verbose_name='Значение')), + ('scope', models.CharField(choices=[('order', 'На весь заказ'), ('product', 'На товар'), ('category', 'На категорию товаров')], default='order', max_length=20, verbose_name='Уровень применения')), + ('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Активна')), + ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дата начала действия')), + ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='Дата окончания действия')), + ('max_usage_count', models.PositiveIntegerField(blank=True, help_text='Оставьте пустым для безлимитного использования', null=True, verbose_name='Макс. количество использований')), + ('current_usage_count', models.PositiveIntegerField(default=0, verbose_name='Текущее количество использований')), + ('priority', models.PositiveIntegerField(default=0, help_text='Более высокий приоритет применяется первым', verbose_name='Приоритет')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('min_order_amount', models.DecimalField(blank=True, decimal_places=2, help_text='Скидка применяется только если сумма заказа >= этого значения', max_digits=10, null=True, verbose_name='Мин. сумма заказа')), + ('is_auto', models.BooleanField(default=False, help_text='Применяется автоматически при выполнении условий', verbose_name='Автоматическая')), + ('categories', models.ManyToManyField(blank=True, related_name='discounts', to='products.productcategory', verbose_name='Категории')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_discounts', to='accounts.customuser', verbose_name='Создал')), + ('excluded_products', models.ManyToManyField(blank=True, related_name='excluded_from_discounts', to='products.product', verbose_name='Исключенные товары')), + ('products', models.ManyToManyField(blank=True, related_name='discounts', to='products.product', verbose_name='Товары')), + ], + options={ + 'verbose_name': 'Скидка', + 'verbose_name_plural': 'Скидки', + }, + ), + migrations.CreateModel( + name='PromoCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(help_text='Уникальный код (например: SALE2025, WINTER10)', max_length=50, unique=True, verbose_name='Код промокода')), + ('max_uses_per_user', models.PositiveIntegerField(blank=True, help_text='Оставьте пустым для безлимитного использования', null=True, verbose_name='Макс. использований на клиента')), + ('max_total_uses', models.PositiveIntegerField(blank=True, null=True, verbose_name='Макс. общее количество использований')), + ('current_uses', models.PositiveIntegerField(default=0, verbose_name='Текущее количество использований')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дата начала действия')), + ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='Дата окончания действия')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_promo_codes', to='accounts.customuser', verbose_name='Создал')), + ('discount', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='promo_codes', to='discounts.discount', verbose_name='Скидка')), + ], + options={ + 'verbose_name': 'Промокод', + 'verbose_name_plural': 'Промокоды', + }, + ), + migrations.CreateModel( + name='DiscountApplication', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('target', models.CharField(choices=[('order', 'Заказ'), ('order_item', 'Позиция заказа')], max_length=20, verbose_name='Объект применения')), + ('base_amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Базовая сумма')), + ('discount_amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма скидки')), + ('final_amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Итоговая сумма')), + ('applied_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата применения')), + ('applied_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applied_discounts', to='accounts.customuser', verbose_name='Применен пользователем')), + ('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='discount_applications', to='customers.customer', verbose_name='Клиент')), + ('discount', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to='discounts.discount', verbose_name='Скидка')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='discount_applications', to='orders.order', verbose_name='Заказ')), + ('order_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='discount_applications', to='orders.orderitem', verbose_name='Позиция заказа')), + ('promo_code', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to='discounts.promocode', verbose_name='Промокод')), + ], + options={ + 'verbose_name': 'Применение скидки', + 'verbose_name_plural': 'Применения скидок', + }, + ), + migrations.AddIndex( + model_name='discount', + index=models.Index(fields=['is_active'], name='discounts_d_is_acti_ae32b7_idx'), + ), + migrations.AddIndex( + model_name='discount', + index=models.Index(fields=['scope'], name='discounts_d_scope_2c30a7_idx'), + ), + migrations.AddIndex( + model_name='discount', + index=models.Index(fields=['discount_type'], name='discounts_d_discoun_f47d7f_idx'), + ), + migrations.AddIndex( + model_name='discount', + index=models.Index(fields=['is_auto'], name='discounts_d_is_auto_a4fe48_idx'), + ), + migrations.AddIndex( + model_name='promocode', + index=models.Index(fields=['code'], name='discounts_p_code_f0e5a6_idx'), + ), + migrations.AddIndex( + model_name='promocode', + index=models.Index(fields=['is_active'], name='discounts_p_is_acti_25d05d_idx'), + ), + migrations.AddIndex( + model_name='discountapplication', + index=models.Index(fields=['order'], name='discounts_d_order_i_2b0f24_idx'), + ), + migrations.AddIndex( + model_name='discountapplication', + index=models.Index(fields=['discount'], name='discounts_d_discoun_c0cd4d_idx'), + ), + migrations.AddIndex( + model_name='discountapplication', + index=models.Index(fields=['promo_code'], name='discounts_d_promo_c_9ce5dd_idx'), + ), + migrations.AddIndex( + model_name='discountapplication', + index=models.Index(fields=['customer'], name='discounts_d_custome_d57e7c_idx'), + ), + migrations.AddIndex( + model_name='discountapplication', + index=models.Index(fields=['applied_at'], name='discounts_d_applied_96adbb_idx'), + ), + ] diff --git a/myproject/discounts/migrations/__init__.py b/myproject/discounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/discounts/models/__init__.py b/myproject/discounts/models/__init__.py new file mode 100644 index 0000000..3a860d2 --- /dev/null +++ b/myproject/discounts/models/__init__.py @@ -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', +] diff --git a/myproject/discounts/models/application.py b/myproject/discounts/models/application.py new file mode 100644 index 0000000..5dce8fb --- /dev/null +++ b/myproject/discounts/models/application.py @@ -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})" diff --git a/myproject/discounts/models/base.py b/myproject/discounts/models/base.py new file mode 100644 index 0000000..e827141 --- /dev/null +++ b/myproject/discounts/models/base.py @@ -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 diff --git a/myproject/discounts/models/discount.py b/myproject/discounts/models/discount.py new file mode 100644 index 0000000..91e70fc --- /dev/null +++ b/myproject/discounts/models/discount.py @@ -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() diff --git a/myproject/discounts/models/promo_code.py b/myproject/discounts/models/promo_code.py new file mode 100644 index 0000000..2ab8689 --- /dev/null +++ b/myproject/discounts/models/promo_code.py @@ -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 diff --git a/myproject/discounts/services/__init__.py b/myproject/discounts/services/__init__.py new file mode 100644 index 0000000..45eb878 --- /dev/null +++ b/myproject/discounts/services/__init__.py @@ -0,0 +1,9 @@ +from .calculator import DiscountCalculator +from .applier import DiscountApplier +from .validator import DiscountValidator + +__all__ = [ + 'DiscountCalculator', + 'DiscountApplier', + 'DiscountValidator', +] diff --git a/myproject/discounts/services/applier.py b/myproject/discounts/services/applier.py new file mode 100644 index 0000000..b6bbefe --- /dev/null +++ b/myproject/discounts/services/applier.py @@ -0,0 +1,228 @@ +from decimal import Decimal +from django.db import transaction +from django.core.exceptions import ValidationError + + +class DiscountApplier: + """ + Сервис для применения скидок к заказам. + Все операции атомарны. + """ + + @staticmethod + @transaction.atomic + def apply_promo_code(order, promo_code, user=None): + """ + Применить промокод к заказу. + + Args: + order: Order + promo_code: str + user: CustomUser (применивший скидку) + + Returns: + dict: { + 'success': bool, + 'discount': Discount, + 'discount_amount': Decimal, + 'error': str + } + """ + from discounts.models import PromoCode, DiscountApplication + from discounts.services.calculator import DiscountCalculator + + # Удаляем предыдущую скидку на заказ + if order.applied_promo_code: + DiscountApplier._remove_order_discount_only(order) + + # Рассчитываем скидку + result = DiscountCalculator.calculate_order_discount( + order, promo_code + ) + + if result['error']: + return { + 'success': False, + 'error': result['error'] + } + + discount = result['discount'] + promo = result['promo_code'] + discount_amount = result['discount_amount'] + + # Применяем к заказу + order.applied_discount = discount + order.applied_promo_code = promo.code + order.discount_amount = discount_amount + order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount']) + + # Пересчитываем total_amount + order.calculate_total() + + # Регистрируем использование промокода + promo.record_usage(order.customer) + + # Создаем запись о применении + DiscountApplication.objects.create( + order=order, + discount=discount, + promo_code=promo, + target='order', + base_amount=order.subtotal, + discount_amount=discount_amount, + final_amount=order.subtotal - discount_amount, + customer=order.customer, + applied_by=user or order.modified_by + ) + + # Увеличиваем счетчик использований скидки + discount.current_usage_count += 1 + discount.save(update_fields=['current_usage_count']) + + return { + 'success': True, + 'discount': discount, + 'discount_amount': discount_amount + } + + @staticmethod + @transaction.atomic + def apply_auto_discounts(order, user=None): + """ + Применить автоматические скидки к заказу и позициям. + + Args: + order: Order + user: CustomUser + + Returns: + dict: { + 'order_discount': {...}, + 'item_discounts': [...], + 'total_discount': Decimal + } + """ + from discounts.models import Discount, DiscountApplication + from discounts.services.calculator import DiscountCalculator + + result = { + 'order_discount': None, + 'item_discounts': [], + 'total_discount': Decimal('0') + } + + # 1. Применяем скидку на заказ (если есть) + order_result = DiscountCalculator.calculate_order_discount(order) + if order_result['discount'] and not order_result['error']: + discount = order_result['discount'] + discount_amount = order_result['discount_amount'] + + order.applied_discount = discount + order.discount_amount = discount_amount + order.save(update_fields=['applied_discount', 'discount_amount']) + + # Создаем запись о применении + DiscountApplication.objects.create( + order=order, + discount=discount, + target='order', + base_amount=order.subtotal, + discount_amount=discount_amount, + final_amount=order.subtotal - discount_amount, + customer=order.customer, + applied_by=user + ) + + # Увеличиваем счетчик + discount.current_usage_count += 1 + discount.save(update_fields=['current_usage_count']) + + result['order_discount'] = { + 'discount': discount, + 'discount_amount': discount_amount + } + result['total_discount'] += discount_amount + + # 2. Применяем скидки на позиции + available_discounts = list(DiscountCalculator.get_available_discounts( + scope='product', + auto_only=True + )) + + for item in order.items.all(): + item_result = DiscountCalculator.calculate_item_discount( + item, available_discounts + ) + + if item_result['discount']: + discount = item_result['discount'] + discount_amount = item_result['discount_amount'] + + item.applied_discount = discount + item.discount_amount = discount_amount + item.save(update_fields=['applied_discount', 'discount_amount']) + + # Создаем запись о применении + DiscountApplication.objects.create( + order=order, + order_item=item, + discount=discount, + target='order_item', + base_amount=item.price * item.quantity, + discount_amount=discount_amount, + final_amount=item.get_total_price(), + customer=order.customer, + applied_by=user + ) + + # Увеличиваем счетчик + discount.current_usage_count += 1 + discount.save(update_fields=['current_usage_count']) + + result['item_discounts'].append({ + 'item': item, + 'discount': discount, + 'discount_amount': discount_amount + }) + result['total_discount'] += discount_amount + + # Пересчитываем итоговую сумму + order.calculate_total() + + return result + + @staticmethod + @transaction.atomic + def remove_discount_from_order(order): + """ + Удалить скидку с заказа. + + Args: + order: Order + """ + DiscountApplier._remove_order_discount_only(order) + + # Удаляем скидки с позиций + order.items.update( + applied_discount=None, + discount_amount=Decimal('0') + ) + + # Удаляем записи о применении (опционально - для истории можно оставить) + # DiscountApplication.objects.filter(order=order).delete() + + # Пересчитываем + order.calculate_total() + + @staticmethod + def _remove_order_discount_only(order): + """ + Удалить только скидку с заказа (не трогая позиции). + + Args: + order: Order + """ + order.applied_discount = None + order.applied_promo_code = None + order.discount_amount = Decimal('0') + order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount']) diff --git a/myproject/discounts/services/calculator.py b/myproject/discounts/services/calculator.py new file mode 100644 index 0000000..1f8d030 --- /dev/null +++ b/myproject/discounts/services/calculator.py @@ -0,0 +1,289 @@ +from decimal import Decimal +from django.db import models +from django.utils import timezone + + +class DiscountCalculator: + """ + Калькулятор скидок для заказов. + Рассчитывает применимые скидки и их суммы. + """ + + @staticmethod + def get_available_discounts(scope=None, customer=None, auto_only=False): + """ + Получить список доступных скидок. + + Args: + scope: 'order', 'product', 'category' или None для всех + customer: Customer для проверки условий + auto_only: Только автоматические скидки + + Returns: + QuerySet[Discount]: Активные скидки, отсортированные по приоритету + """ + from discounts.models import Discount + + now = timezone.now() + qs = Discount.objects.filter(is_active=True) + + # Фильтр по scope + if scope: + qs = qs.filter(scope=scope) + + # Фильтр по auto + if auto_only: + qs = qs.filter(is_auto=True) + + # Фильтр по дате + qs = qs.filter( + models.Q(start_date__isnull=True) | models.Q(start_date__lte=now), + models.Q(end_date__isnull=True) | models.Q(end_date__gte=now) + ) + + # Фильтр по лимиту использований + qs = qs.filter( + models.Q(max_usage_count__isnull=True) | + models.Q(current_usage_count__lt=models.F('max_usage_count')) + ) + + return qs.order_by('-priority', '-created_at') + + @staticmethod + def calculate_order_discount(order, promo_code=None): + """ + Рассчитать скидку на весь заказ. + + Args: + order: Order объект + promo_code: Строка промокода (опционально) + + Returns: + dict: { + 'discount': Discount или None, + 'promo_code': PromoCode или None, + 'discount_amount': Decimal, + 'error': str или None + } + """ + from discounts.models import PromoCode + from discounts.services.validator import DiscountValidator + + subtotal = Decimal(str(order.subtotal)) + result = { + 'discount': None, + 'promo_code': None, + 'discount_amount': Decimal('0'), + 'error': None + } + + # 1. Проверяем промокод первым + if promo_code: + is_valid, promo, error = DiscountValidator.validate_promo_code( + promo_code, order.customer, subtotal + ) + + if not is_valid: + result['error'] = error + return result + + discount = promo.discount + + # Проверяем scope скидки + if discount.scope != 'order': + result['error'] = "Этот промокод применяется только к товарам" + return result + + result['discount'] = discount + result['promo_code'] = promo + result['discount_amount'] = discount.calculate_discount_amount(subtotal) + return result + + # 2. Если нет промокода, проверяем автоматические скидки + auto_discounts = DiscountCalculator.get_available_discounts( + scope='order', + auto_only=True + ) + + for discount in auto_discounts: + # Проверяем мин. сумму заказа + if discount.min_order_amount and subtotal < discount.min_order_amount: + continue + + # Применяем первую подходящую автоматическую скидку + result['discount'] = discount + result['discount_amount'] = discount.calculate_discount_amount(subtotal) + break + + return result + + @staticmethod + def calculate_item_discount(order_item, available_discounts=None): + """ + Рассчитать скидку на позицию заказа. + + Args: + order_item: OrderItem объект + available_discounts: Предварительно полученный список скидок + + Returns: + dict: { + 'discount': Discount или None, + 'discount_amount': Decimal, + } + """ + result = { + 'discount': None, + 'discount_amount': Decimal('0') + } + + # Определяем продукт + product = None + if order_item.product: + product = order_item.product + elif order_item.product_kit: + product = order_item.product_kit + + if not product: + return result + + base_amount = Decimal(str(order_item.price)) * Decimal(str(order_item.quantity)) + + if not available_discounts: + available_discounts = DiscountCalculator.get_available_discounts( + scope='product', + auto_only=True + ) + + for discount in available_discounts: + # Проверяем, применяется ли скидка к этому товару + if not discount.applies_to_product(product): + continue + + result['discount'] = discount + result['discount_amount'] = discount.calculate_discount_amount(base_amount) + break + + # Проверяем скидки по категориям + if not result['discount']: + category_discounts = DiscountCalculator.get_available_discounts( + scope='category', + auto_only=True + ) + + for discount in category_discounts: + if discount.applies_to_product(product): + result['discount'] = discount + result['discount_amount'] = discount.calculate_discount_amount(base_amount) + break + + return result + + @staticmethod + def calculate_cart_discounts(cart_items, promo_code=None, customer=None): + """ + Рассчитать скидки для корзины (применяется в POS до создания заказа). + + Args: + cart_items: Список словарей {'type': 'product'|'kit', 'id': int, 'quantity': Decimal, 'price': Decimal} + promo_code: Промокод (опционально) + customer: Customer (опционально) + + Returns: + dict: { + 'order_discount': {...}, # Как в calculate_order_discount + 'item_discounts': [ + {'cart_index': int, 'discount': Discount, 'discount_amount': Decimal}, + ... + ], + 'total_discount': Decimal, + 'final_total': Decimal + } + """ + from products.models import Product, ProductKit + + cart_subtotal = Decimal('0') + for item in cart_items: + cart_subtotal += Decimal(str(item['price'])) * Decimal(str(item['quantity'])) + + # Создаем фейковый объект для расчета скидки на заказ + class FakeOrder: + def __init__(self, subtotal, customer): + self.subtotal = subtotal + self.customer = customer + + fake_order = FakeOrder(cart_subtotal, customer) + + # Скидка на заказ + order_discount = DiscountCalculator.calculate_order_discount( + fake_order, promo_code + ) + + # Скидки на позиции + item_discounts = [] + items_total_discount = Decimal('0') + + available_product_discounts = list(DiscountCalculator.get_available_discounts( + scope='product', + auto_only=True + )) + + available_category_discounts = list(DiscountCalculator.get_available_discounts( + scope='category', + auto_only=True + )) + + for idx, item in enumerate(cart_items): + # Загружаем продукт + product = None + if item.get('type') == 'product': + try: + product = Product.objects.get(id=item['id']) + except Product.DoesNotExist: + pass + elif item.get('type') == 'kit': + try: + product = ProductKit.objects.get(id=item['id']) + except ProductKit.DoesNotExist: + pass + + if not product: + continue + + base_amount = Decimal(str(item['price'])) * Decimal(str(item['quantity'])) + + # Проверяем скидки на товары + applied_discount = None + discount_amount = Decimal('0') + + for discount in available_product_discounts: + if discount.applies_to_product(product): + applied_discount = discount + discount_amount = discount.calculate_discount_amount(base_amount) + break + + # Если не нашли скидку на товар, проверяем категории + if not applied_discount: + for discount in available_category_discounts: + if discount.applies_to_product(product): + applied_discount = discount + discount_amount = discount.calculate_discount_amount(base_amount) + break + + if applied_discount: + item_discounts.append({ + 'cart_index': idx, + 'discount': applied_discount, + 'discount_amount': discount_amount + }) + items_total_discount += discount_amount + + total_discount = order_discount['discount_amount'] + items_total_discount + final_total = max(cart_subtotal - total_discount, Decimal('0')) + + return { + 'order_discount': order_discount, + 'item_discounts': item_discounts, + 'total_discount': total_discount, + 'final_total': final_total + } diff --git a/myproject/discounts/services/validator.py b/myproject/discounts/services/validator.py new file mode 100644 index 0000000..5d5ccea --- /dev/null +++ b/myproject/discounts/services/validator.py @@ -0,0 +1,101 @@ +from decimal import Decimal +from django.core.exceptions import ValidationError + + +class DiscountValidator: + """ + Сервис для валидации скидок и промокодов. + """ + + @staticmethod + def validate_promo_code(code, customer=None, order_subtotal=None): + """ + Валидировать промокод. + + Args: + code: Код промокода + customer: Customer для проверки использований + order_subtotal: Сумма заказа для проверки min_order_amount + + Returns: + tuple: (is_valid, promo_code_or_none, error_message) + """ + from discounts.models import PromoCode + + if not code or not code.strip(): + return False, None, "Промокод не указан" + + try: + promo = PromoCode.objects.get( + code__iexact=code.strip().upper(), + is_active=True + ) + except PromoCode.DoesNotExist: + return False, None, "Промокод не найден" + + # Проверяем валидность промокода + is_valid, error = promo.is_valid(customer) + if not is_valid: + return False, None, error + + # Проверяем мин. сумму заказа + if order_subtotal is not None and promo.discount.min_order_amount: + if Decimal(order_subtotal) < promo.discount.min_order_amount: + return False, None, f"Минимальная сумма заказа: {promo.discount.min_order_amount} руб." + + # Проверяем scope (только заказ, не товары) + if promo.discount.scope not in ('order', 'product', 'category'): + return False, None, "Этот тип промокода не поддерживается" + + return True, promo, None + + @staticmethod + def validate_discount_for_order(discount, order): + """ + Проверить, можно ли применить скидку к заказу. + + Args: + discount: Discount + order: Order + + Returns: + tuple: (is_valid, error_message) + """ + if not discount.is_active: + return False, "Скидка неактивна" + + # Проверяем даты + if not discount.is_valid_now(): + return False, "Скидка недействительна" + + # Проверяем мин. сумму заказа + if discount.scope == 'order' and discount.min_order_amount: + if order.subtotal < discount.min_order_amount: + return False, f"Минимальная сумма заказа: {discount.min_order_amount} руб." + + return True, None + + @staticmethod + def validate_auto_discount_for_cart(discount, cart_subtotal, customer=None): + """ + Проверить, можно ли применить автоматическую скидку к корзине. + + Args: + discount: Discount + cart_subtotal: Десятичное число - сумма корзины + customer: Customer (опционально) + + Returns: + bool: True если скидка применима + """ + if not discount.is_auto: + return False + + if not discount.is_valid_now(): + return False + + if discount.scope == 'order' and discount.min_order_amount: + if cart_subtotal < discount.min_order_amount: + return False + + return True