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:
1
myproject/discounts/__init__.py
Normal file
1
myproject/discounts/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
default_app_config = 'discounts.apps.DiscountsConfig'
|
||||||
203
myproject/discounts/admin.py
Normal file
203
myproject/discounts/admin.py
Normal file
@@ -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'<a href="{url}">#{obj.order.order_number}</a>'
|
||||||
|
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 = "Промокод"
|
||||||
7
myproject/discounts/apps.py
Normal file
7
myproject/discounts/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DiscountsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'discounts'
|
||||||
|
verbose_name = 'Скидки'
|
||||||
132
myproject/discounts/migrations/0001_initial.py
Normal file
132
myproject/discounts/migrations/0001_initial.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
myproject/discounts/migrations/__init__.py
Normal file
0
myproject/discounts/migrations/__init__.py
Normal file
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
|
||||||
9
myproject/discounts/services/__init__.py
Normal file
9
myproject/discounts/services/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from .calculator import DiscountCalculator
|
||||||
|
from .applier import DiscountApplier
|
||||||
|
from .validator import DiscountValidator
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'DiscountCalculator',
|
||||||
|
'DiscountApplier',
|
||||||
|
'DiscountValidator',
|
||||||
|
]
|
||||||
228
myproject/discounts/services/applier.py
Normal file
228
myproject/discounts/services/applier.py
Normal file
@@ -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'])
|
||||||
289
myproject/discounts/services/calculator.py
Normal file
289
myproject/discounts/services/calculator.py
Normal file
@@ -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
|
||||||
|
}
|
||||||
101
myproject/discounts/services/validator.py
Normal file
101
myproject/discounts/services/validator.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user