feat(discounts): добавлено приложение скидок

Создано новое Django приложение для управления скидками:

Модели:
- BaseDiscount: абстрактный базовый класс с общими полями
- Discount: основная модель скидки (процент/фикс, на заказ/товар/категорию)
- PromoCode: промокоды для активации скидок
- DiscountApplication: история применения скидок

Сервисы:
- DiscountCalculator: расчёт скидок для корзины и заказов
- DiscountApplier: применение скидок к заказам (атомарно)
- DiscountValidator: валидация промокодов и условий

Админ-панель:
- DiscountAdmin: управление скидками
- PromoCodeAdmin: управление промокодами
- DiscountApplicationAdmin: история применения (только чтение)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 00:30:14 +03:00
parent 27cb9ba09d
commit 241625eba7
14 changed files with 1524 additions and 0 deletions

View File

@@ -0,0 +1,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'),
),
]