feat(discounts): добавлено комбинирование скидок по режимам
Добавлено поле combine_mode с тремя режимами: - stack - складывать с другими скидками - max_only - применять только максимальную - exclusive - отменяет все остальные скидки Изменения: - Модель Discount: добавлено поле combine_mode - Calculator: новый класс DiscountCombiner, методы возвращают списки скидок - Applier: создание нескольких DiscountApplication записей - Admin: отображение combine_mode с иконками - POS API: возвращает списки применённых скидок - POS UI: отображение нескольких скидок с иконками режимов Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ class DiscountAdmin(admin.ModelAdmin):
|
||||
'discount_type',
|
||||
'value_display',
|
||||
'scope',
|
||||
'combine_mode_display',
|
||||
'is_auto',
|
||||
'is_active',
|
||||
'current_usage_count',
|
||||
@@ -20,6 +21,7 @@ class DiscountAdmin(admin.ModelAdmin):
|
||||
list_filter = [
|
||||
'discount_type',
|
||||
'scope',
|
||||
'combine_mode',
|
||||
'is_auto',
|
||||
'is_active',
|
||||
]
|
||||
@@ -39,7 +41,7 @@ class DiscountAdmin(admin.ModelAdmin):
|
||||
'fields': ('name', 'description', 'is_active', 'priority')
|
||||
}),
|
||||
('Параметры скидки', {
|
||||
'fields': ('discount_type', 'value', 'scope')
|
||||
'fields': ('discount_type', 'value', 'scope', 'combine_mode')
|
||||
}),
|
||||
('Ограничения', {
|
||||
'fields': (
|
||||
@@ -70,6 +72,23 @@ class DiscountAdmin(admin.ModelAdmin):
|
||||
return f"{obj.value} руб."
|
||||
value_display.short_description = "Значение"
|
||||
|
||||
def combine_mode_display(self, obj):
|
||||
"""Отображение режима объединения с иконкой."""
|
||||
icons = {
|
||||
'stack': '📚', # слои
|
||||
'max_only': '🏆', # максимум
|
||||
'exclusive': '🚫', # запрет
|
||||
}
|
||||
labels = {
|
||||
'stack': 'Склад.',
|
||||
'max_only': 'Макс.',
|
||||
'exclusive': 'Исключ.',
|
||||
}
|
||||
icon = icons.get(obj.combine_mode, '')
|
||||
label = labels.get(obj.combine_mode, obj.combine_mode)
|
||||
return f'{icon} {label}' if icon else label
|
||||
combine_mode_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()}"
|
||||
|
||||
18
myproject/discounts/migrations/0002_add_combine_mode.py
Normal file
18
myproject/discounts/migrations/0002_add_combine_mode.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.10 on 2026-01-10 23:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('discounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='discount',
|
||||
name='combine_mode',
|
||||
field=models.CharField(choices=[('stack', 'Складывать (суммировать)'), ('max_only', 'Только максимум'), ('exclusive', 'Исключающая (отменяет остальные)')], default='max_only', help_text='stack = суммировать с другими, max_only = применить максимальную, exclusive = отменить остальные', max_length=20, verbose_name='Режим объединения'),
|
||||
),
|
||||
]
|
||||
@@ -18,6 +18,12 @@ class BaseDiscount(models.Model):
|
||||
('category', 'На категорию товаров'),
|
||||
]
|
||||
|
||||
COMBINE_MODE_CHOICES = [
|
||||
('stack', 'Складывать (суммировать)'),
|
||||
('max_only', 'Только максимум'),
|
||||
('exclusive', 'Исключающая (отменяет остальные)'),
|
||||
]
|
||||
|
||||
name = models.CharField(
|
||||
max_length=200,
|
||||
verbose_name="Название скидки"
|
||||
|
||||
@@ -48,6 +48,15 @@ class Discount(BaseDiscount):
|
||||
help_text="Применяется автоматически при выполнении условий"
|
||||
)
|
||||
|
||||
# Режим объединения с другими скидками
|
||||
combine_mode = models.CharField(
|
||||
max_length=20,
|
||||
choices=BaseDiscount.COMBINE_MODE_CHOICES,
|
||||
default='max_only',
|
||||
verbose_name="Режим объединения",
|
||||
help_text="stack = суммировать с другими, max_only = применить максимальную, exclusive = отменить остальные"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Скидка"
|
||||
verbose_name_plural = "Скидки"
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
|
||||
class DiscountApplier:
|
||||
"""
|
||||
Сервис для применения скидок к заказам.
|
||||
Поддерживает комбинирование скидок по combine_mode.
|
||||
Все операции атомарны.
|
||||
"""
|
||||
|
||||
@@ -14,6 +15,7 @@ class DiscountApplier:
|
||||
def apply_promo_code(order, promo_code, user=None):
|
||||
"""
|
||||
Применить промокод к заказу.
|
||||
Поддерживает комбинирование скидок.
|
||||
|
||||
Args:
|
||||
order: Order
|
||||
@@ -23,8 +25,8 @@ class DiscountApplier:
|
||||
Returns:
|
||||
dict: {
|
||||
'success': bool,
|
||||
'discount': Discount,
|
||||
'discount_amount': Decimal,
|
||||
'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
|
||||
'total_amount': Decimal,
|
||||
'error': str
|
||||
}
|
||||
"""
|
||||
@@ -36,9 +38,7 @@ class DiscountApplier:
|
||||
DiscountApplier._remove_order_discount_only(order)
|
||||
|
||||
# Рассчитываем скидку
|
||||
result = DiscountCalculator.calculate_order_discount(
|
||||
order, promo_code
|
||||
)
|
||||
result = DiscountCalculator.calculate_order_discount(order, promo_code)
|
||||
|
||||
if result['error']:
|
||||
return {
|
||||
@@ -46,15 +46,17 @@ class DiscountApplier:
|
||||
'error': result['error']
|
||||
}
|
||||
|
||||
discount = result['discount']
|
||||
promo = result['promo_code']
|
||||
discount_amount = result['discount_amount']
|
||||
discounts_data = result['discounts']
|
||||
total_amount = result['total_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'])
|
||||
# Применяем первую скидку в applied_discount (для обратной совместимости с Order)
|
||||
if discounts_data:
|
||||
first_discount = discounts_data[0]['discount']
|
||||
order.applied_discount = first_discount
|
||||
order.applied_promo_code = promo.code
|
||||
order.discount_amount = total_amount
|
||||
order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount'])
|
||||
|
||||
# Пересчитываем total_amount
|
||||
order.calculate_total()
|
||||
@@ -62,27 +64,31 @@ class DiscountApplier:
|
||||
# Регистрируем использование промокода
|
||||
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
|
||||
)
|
||||
# Создаем записи о применении для каждой скидки
|
||||
for disc_data in discounts_data:
|
||||
discount = disc_data['discount']
|
||||
amount = disc_data['amount']
|
||||
|
||||
# Увеличиваем счетчик использований скидки
|
||||
discount.current_usage_count += 1
|
||||
discount.save(update_fields=['current_usage_count'])
|
||||
DiscountApplication.objects.create(
|
||||
order=order,
|
||||
discount=discount,
|
||||
promo_code=promo,
|
||||
target='order',
|
||||
base_amount=order.subtotal,
|
||||
discount_amount=amount,
|
||||
final_amount=order.subtotal - 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
|
||||
'discounts': discounts_data,
|
||||
'total_amount': total_amount
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -90,6 +96,7 @@ class DiscountApplier:
|
||||
def apply_auto_discounts(order, user=None):
|
||||
"""
|
||||
Применить автоматические скидки к заказу и позициям.
|
||||
Поддерживает комбинирование скидок.
|
||||
|
||||
Args:
|
||||
order: Order
|
||||
@@ -97,7 +104,7 @@ class DiscountApplier:
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'order_discount': {...},
|
||||
'order_discounts': [...],
|
||||
'item_discounts': [...],
|
||||
'total_discount': Decimal
|
||||
}
|
||||
@@ -106,71 +113,35 @@ class DiscountApplier:
|
||||
from discounts.services.calculator import DiscountCalculator
|
||||
|
||||
result = {
|
||||
'order_discount': None,
|
||||
'order_discounts': [],
|
||||
'item_discounts': [],
|
||||
'total_discount': Decimal('0')
|
||||
}
|
||||
|
||||
# 1. Применяем скидку на заказ (если есть)
|
||||
# 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
|
||||
if order_result['discounts'] and not order_result['error']:
|
||||
total_order_amount = order_result['total_amount']
|
||||
|
||||
# Сохраняем первую скидку в applied_discount (для совместимости)
|
||||
first_discount_data = order_result['discounts'][0]
|
||||
order.applied_discount = first_discount_data['discount']
|
||||
order.discount_amount = total_order_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
|
||||
)
|
||||
# Создаем записи о применении для всех скидок
|
||||
for disc_data in order_result['discounts']:
|
||||
discount = disc_data['discount']
|
||||
amount = disc_data['amount']
|
||||
|
||||
# Увеличиваем счетчик
|
||||
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(),
|
||||
target='order',
|
||||
base_amount=order.subtotal,
|
||||
discount_amount=amount,
|
||||
final_amount=order.subtotal - amount,
|
||||
customer=order.customer,
|
||||
applied_by=user
|
||||
)
|
||||
@@ -179,12 +150,57 @@ class DiscountApplier:
|
||||
discount.current_usage_count += 1
|
||||
discount.save(update_fields=['current_usage_count'])
|
||||
|
||||
result['order_discounts'] = order_result['discounts']
|
||||
result['total_discount'] += total_order_amount
|
||||
|
||||
# 2. Применяем скидки на позиции
|
||||
available_product_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_product_discounts
|
||||
)
|
||||
|
||||
if item_result['discounts']:
|
||||
total_item_amount = item_result['total_amount']
|
||||
|
||||
# Сохраняем первую скидку в applied_discount (для совместимости)
|
||||
first_discount_data = item_result['discounts'][0]
|
||||
item.applied_discount = first_discount_data['discount']
|
||||
item.discount_amount = total_item_amount
|
||||
item.save(update_fields=['applied_discount', 'discount_amount'])
|
||||
|
||||
# Создаем записи о применении для всех скидок
|
||||
base_amount = item.price * item.quantity
|
||||
for disc_data in item_result['discounts']:
|
||||
discount = disc_data['discount']
|
||||
amount = disc_data['amount']
|
||||
|
||||
DiscountApplication.objects.create(
|
||||
order=order,
|
||||
order_item=item,
|
||||
discount=discount,
|
||||
target='order_item',
|
||||
base_amount=base_amount,
|
||||
discount_amount=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
|
||||
'discounts': item_result['discounts'],
|
||||
'total_amount': total_item_amount
|
||||
})
|
||||
result['total_discount'] += discount_amount
|
||||
result['total_discount'] += total_item_amount
|
||||
|
||||
# Пересчитываем итоговую сумму
|
||||
order.calculate_total()
|
||||
@@ -208,8 +224,9 @@ class DiscountApplier:
|
||||
discount_amount=Decimal('0')
|
||||
)
|
||||
|
||||
# Удаляем записи о применении (опционально - для истории можно оставить)
|
||||
# DiscountApplication.objects.filter(order=order).delete()
|
||||
# Удаляем записи о применении
|
||||
from discounts.models import DiscountApplication
|
||||
DiscountApplication.objects.filter(order=order).delete()
|
||||
|
||||
# Пересчитываем
|
||||
order.calculate_total()
|
||||
@@ -285,6 +302,11 @@ class DiscountApplier:
|
||||
Args:
|
||||
order: Order
|
||||
"""
|
||||
from discounts.models import DiscountApplication
|
||||
|
||||
# Удаляем записи о применении скидок к заказу
|
||||
DiscountApplication.objects.filter(order=order, target='order').delete()
|
||||
|
||||
order.applied_discount = None
|
||||
order.applied_promo_code = None
|
||||
order.discount_amount = Decimal('0')
|
||||
|
||||
@@ -3,6 +3,82 @@ from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class DiscountCombiner:
|
||||
"""
|
||||
Утилитный класс для комбинирования скидок по разным режимам.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def combine_discounts(discounts, base_amount):
|
||||
"""
|
||||
Комбинировать несколько скидок по их combine_mode.
|
||||
|
||||
Args:
|
||||
discounts: Список объектов Discount
|
||||
base_amount: Базовая сумма для расчета
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'total_discount': Decimal,
|
||||
'applied_discounts': [{'discount': Discount, 'amount': Decimal}, ...],
|
||||
'excluded_by': Discount или None,
|
||||
'combine_mode_used': str
|
||||
}
|
||||
"""
|
||||
if not discounts:
|
||||
return {
|
||||
'total_discount': Decimal('0'),
|
||||
'applied_discounts': [],
|
||||
'excluded_by': None,
|
||||
'combine_mode_used': 'none'
|
||||
}
|
||||
|
||||
# 1. Проверяем наличие exclusive скидки
|
||||
exclusive_discounts = [d for d in discounts if d.combine_mode == 'exclusive']
|
||||
if exclusive_discounts:
|
||||
# Применяем только первую exclusive скидку
|
||||
discount = exclusive_discounts[0]
|
||||
amount = discount.calculate_discount_amount(base_amount)
|
||||
return {
|
||||
'total_discount': amount,
|
||||
'applied_discounts': [{'discount': discount, 'amount': amount}],
|
||||
'excluded_by': discount,
|
||||
'combine_mode_used': 'exclusive'
|
||||
}
|
||||
|
||||
# 2. Разделяем скидки на stack и max_only
|
||||
stack_discounts = [d for d in discounts if d.combine_mode == 'stack']
|
||||
max_only_discounts = [d for d in discounts if d.combine_mode == 'max_only']
|
||||
|
||||
result = {
|
||||
'total_discount': Decimal('0'),
|
||||
'applied_discounts': [],
|
||||
'excluded_by': None,
|
||||
'combine_mode_used': 'combined'
|
||||
}
|
||||
|
||||
# 3. Для max_only применяем только максимальную
|
||||
if max_only_discounts:
|
||||
max_discount = max(
|
||||
max_only_discounts,
|
||||
key=lambda d: d.calculate_discount_amount(base_amount)
|
||||
)
|
||||
amount = max_discount.calculate_discount_amount(base_amount)
|
||||
result['applied_discounts'].append({'discount': max_discount, 'amount': amount})
|
||||
result['total_discount'] += amount
|
||||
|
||||
# 4. Для stack суммируем все
|
||||
for discount in stack_discounts:
|
||||
amount = discount.calculate_discount_amount(base_amount)
|
||||
result['applied_discounts'].append({'discount': discount, 'amount': amount})
|
||||
result['total_discount'] += amount
|
||||
|
||||
# 5. Ограничиваем итоговую скидку базовой суммой
|
||||
result['total_discount'] = min(result['total_discount'], base_amount)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class DiscountCalculator:
|
||||
"""
|
||||
Калькулятор скидок для заказов.
|
||||
@@ -52,7 +128,7 @@ class DiscountCalculator:
|
||||
@staticmethod
|
||||
def calculate_order_discount(order, promo_code=None):
|
||||
"""
|
||||
Рассчитать скидку на весь заказ.
|
||||
Рассчитать скидку на весь заказ с поддержкой комбинирования.
|
||||
|
||||
Args:
|
||||
order: Order объект
|
||||
@@ -60,9 +136,9 @@ class DiscountCalculator:
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'discount': Discount или None,
|
||||
'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
|
||||
'total_amount': Decimal,
|
||||
'promo_code': PromoCode или None,
|
||||
'discount_amount': Decimal,
|
||||
'error': str или None
|
||||
}
|
||||
"""
|
||||
@@ -71,12 +147,14 @@ class DiscountCalculator:
|
||||
|
||||
subtotal = Decimal(str(order.subtotal))
|
||||
result = {
|
||||
'discount': None,
|
||||
'discounts': [],
|
||||
'total_amount': Decimal('0'),
|
||||
'promo_code': None,
|
||||
'discount_amount': Decimal('0'),
|
||||
'error': None
|
||||
}
|
||||
|
||||
applicable_discounts = []
|
||||
|
||||
# 1. Проверяем промокод первым
|
||||
if promo_code:
|
||||
is_valid, promo, error = DiscountValidator.validate_promo_code(
|
||||
@@ -89,38 +167,36 @@ class DiscountCalculator:
|
||||
|
||||
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
|
||||
applicable_discounts.append(discount)
|
||||
else:
|
||||
# 2. Если нет промокода, собираем все автоматические скидки
|
||||
auto_discounts = DiscountCalculator.get_available_discounts(
|
||||
scope='order',
|
||||
auto_only=True
|
||||
)
|
||||
|
||||
# 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
|
||||
applicable_discounts.append(discount)
|
||||
|
||||
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
|
||||
# 3. Комбинируем скидки
|
||||
if applicable_discounts:
|
||||
combination = DiscountCombiner.combine_discounts(applicable_discounts, subtotal)
|
||||
result['discounts'] = combination['applied_discounts']
|
||||
result['total_amount'] = combination['total_discount']
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def calculate_item_discount(order_item, available_discounts=None):
|
||||
"""
|
||||
Рассчитать скидку на позицию заказа.
|
||||
Рассчитать скидку на позицию заказа с поддержкой комбинирования.
|
||||
|
||||
Args:
|
||||
order_item: OrderItem объект
|
||||
@@ -128,13 +204,13 @@ class DiscountCalculator:
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'discount': Discount или None,
|
||||
'discount_amount': Decimal,
|
||||
'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
|
||||
'total_amount': Decimal,
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
'discount': None,
|
||||
'discount_amount': Decimal('0')
|
||||
'discounts': [],
|
||||
'total_amount': Decimal('0')
|
||||
}
|
||||
|
||||
# Определяем продукт
|
||||
@@ -149,6 +225,10 @@ class DiscountCalculator:
|
||||
|
||||
base_amount = Decimal(str(order_item.price)) * Decimal(str(order_item.quantity))
|
||||
|
||||
# Собираем все применимые скидки
|
||||
applicable_discounts = []
|
||||
|
||||
# Скидки на товары
|
||||
if not available_discounts:
|
||||
available_discounts = DiscountCalculator.get_available_discounts(
|
||||
scope='product',
|
||||
@@ -156,48 +236,54 @@ class DiscountCalculator:
|
||||
)
|
||||
|
||||
for discount in available_discounts:
|
||||
# Проверяем, применяется ли скидка к этому товару
|
||||
if not discount.applies_to_product(product):
|
||||
continue
|
||||
if discount.applies_to_product(product):
|
||||
applicable_discounts.append(discount)
|
||||
|
||||
result['discount'] = discount
|
||||
result['discount_amount'] = discount.calculate_discount_amount(base_amount)
|
||||
break
|
||||
# Скидки по категориям
|
||||
category_discounts = DiscountCalculator.get_available_discounts(
|
||||
scope='category',
|
||||
auto_only=True
|
||||
)
|
||||
|
||||
# Проверяем скидки по категориям
|
||||
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) and discount not in applicable_discounts:
|
||||
applicable_discounts.append(discount)
|
||||
|
||||
for discount in category_discounts:
|
||||
if discount.applies_to_product(product):
|
||||
result['discount'] = discount
|
||||
result['discount_amount'] = discount.calculate_discount_amount(base_amount)
|
||||
break
|
||||
# Комбинируем скидки
|
||||
if applicable_discounts:
|
||||
combination = DiscountCombiner.combine_discounts(applicable_discounts, base_amount)
|
||||
result['discounts'] = combination['applied_discounts']
|
||||
result['total_amount'] = combination['total_discount']
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def calculate_cart_discounts(cart_items, promo_code=None, customer=None):
|
||||
def calculate_cart_discounts(cart_items, promo_code=None, customer=None, skip_auto_discount=False):
|
||||
"""
|
||||
Рассчитать скидки для корзины (применяется в POS до создания заказа).
|
||||
Поддерживает комбинирование скидок по combine_mode.
|
||||
|
||||
Args:
|
||||
cart_items: Список словарей {'type': 'product'|'kit', 'id': int, 'quantity': Decimal, 'price': Decimal}
|
||||
promo_code: Промокод (опционально)
|
||||
customer: Customer (опционально)
|
||||
skip_auto_discount: Пропустить автоматические скидки (опционально)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'order_discount': {...}, # Как в calculate_order_discount
|
||||
'order_discounts': [
|
||||
{'discount_id': int, 'discount_name': str, 'discount_amount': Decimal, 'combine_mode': str},
|
||||
...
|
||||
],
|
||||
'total_order_discount': Decimal,
|
||||
'item_discounts': [
|
||||
{'cart_index': int, 'discount': Discount, 'discount_amount': Decimal},
|
||||
{'cart_index': int, 'discounts': [...], 'total_discount': Decimal},
|
||||
...
|
||||
],
|
||||
'total_discount': Decimal,
|
||||
'final_total': Decimal
|
||||
'final_total': Decimal,
|
||||
'cart_subtotal': Decimal,
|
||||
'excluded_by': {'id': int, 'name': str} или None
|
||||
}
|
||||
"""
|
||||
from products.models import Product, ProductKit
|
||||
@@ -206,6 +292,18 @@ class DiscountCalculator:
|
||||
for item in cart_items:
|
||||
cart_subtotal += Decimal(str(item['price'])) * Decimal(str(item['quantity']))
|
||||
|
||||
# Если нужно пропустить авто-скидки, возвращаем пустой результат
|
||||
if skip_auto_discount:
|
||||
return {
|
||||
'order_discounts': [],
|
||||
'total_order_discount': Decimal('0'),
|
||||
'item_discounts': [],
|
||||
'total_discount': Decimal('0'),
|
||||
'final_total': cart_subtotal,
|
||||
'cart_subtotal': cart_subtotal,
|
||||
'excluded_by': None
|
||||
}
|
||||
|
||||
# Создаем фейковый объект для расчета скидки на заказ
|
||||
class FakeOrder:
|
||||
def __init__(self, subtotal, customer):
|
||||
@@ -214,12 +312,36 @@ class DiscountCalculator:
|
||||
|
||||
fake_order = FakeOrder(cart_subtotal, customer)
|
||||
|
||||
# Скидка на заказ
|
||||
order_discount = DiscountCalculator.calculate_order_discount(
|
||||
fake_order, promo_code
|
||||
)
|
||||
# Скидка на заказ (с комбинированием)
|
||||
order_result = DiscountCalculator.calculate_order_discount(fake_order, promo_code)
|
||||
|
||||
# Скидки на позиции
|
||||
# Форматируем order_discounts для ответа
|
||||
formatted_order_discounts = []
|
||||
excluded_by = None
|
||||
|
||||
for disc_data in order_result['discounts']:
|
||||
discount = disc_data['discount']
|
||||
formatted_order_discounts.append({
|
||||
'discount_id': discount.id,
|
||||
'discount_name': discount.name,
|
||||
'discount_amount': disc_data['amount'],
|
||||
'discount_type': discount.discount_type,
|
||||
'discount_value': discount.value,
|
||||
'combine_mode': discount.combine_mode
|
||||
})
|
||||
|
||||
# Проверяем исключающую скидку
|
||||
if order_result['discounts']:
|
||||
# Определяем exclusive скидку
|
||||
for disc_data in order_result['discounts']:
|
||||
if disc_data['discount'].combine_mode == 'exclusive':
|
||||
excluded_by = {
|
||||
'id': disc_data['discount'].id,
|
||||
'name': disc_data['discount'].name
|
||||
}
|
||||
break
|
||||
|
||||
# Скидки на позиции (с комбинированием)
|
||||
item_discounts = []
|
||||
items_total_discount = Decimal('0')
|
||||
|
||||
@@ -252,38 +374,51 @@ class DiscountCalculator:
|
||||
|
||||
base_amount = Decimal(str(item['price'])) * Decimal(str(item['quantity']))
|
||||
|
||||
# Проверяем скидки на товары
|
||||
applied_discount = None
|
||||
discount_amount = Decimal('0')
|
||||
# Собираем все применимые скидки для этого товара
|
||||
applicable_item_discounts = []
|
||||
|
||||
for discount in available_product_discounts:
|
||||
if discount.applies_to_product(product):
|
||||
applied_discount = discount
|
||||
discount_amount = discount.calculate_discount_amount(base_amount)
|
||||
break
|
||||
applicable_item_discounts.append(discount)
|
||||
|
||||
# Если не нашли скидку на товар, проверяем категории
|
||||
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
|
||||
for discount in available_category_discounts:
|
||||
if discount.applies_to_product(product) and discount not in applicable_item_discounts:
|
||||
applicable_item_discounts.append(discount)
|
||||
|
||||
if applied_discount:
|
||||
item_discounts.append({
|
||||
'cart_index': idx,
|
||||
'discount': applied_discount,
|
||||
'discount_amount': discount_amount
|
||||
})
|
||||
items_total_discount += discount_amount
|
||||
# Комбинируем скидки для позиции
|
||||
if applicable_item_discounts:
|
||||
combination = DiscountCombiner.combine_discounts(
|
||||
applicable_item_discounts,
|
||||
base_amount
|
||||
)
|
||||
|
||||
total_discount = order_discount['discount_amount'] + items_total_discount
|
||||
formatted_discounts = []
|
||||
for disc_data in combination['applied_discounts']:
|
||||
discount = disc_data['discount']
|
||||
formatted_discounts.append({
|
||||
'discount_id': discount.id,
|
||||
'discount_name': discount.name,
|
||||
'discount_amount': disc_data['amount'],
|
||||
'combine_mode': discount.combine_mode
|
||||
})
|
||||
|
||||
if formatted_discounts:
|
||||
item_discounts.append({
|
||||
'cart_index': idx,
|
||||
'discounts': formatted_discounts,
|
||||
'total_discount': combination['total_discount']
|
||||
})
|
||||
items_total_discount += combination['total_discount']
|
||||
|
||||
total_discount = order_result['total_amount'] + items_total_discount
|
||||
final_total = max(cart_subtotal - total_discount, Decimal('0'))
|
||||
|
||||
return {
|
||||
'order_discount': order_discount,
|
||||
'order_discounts': formatted_order_discounts,
|
||||
'total_order_discount': order_result['total_amount'],
|
||||
'item_discounts': item_discounts,
|
||||
'total_discount': total_discount,
|
||||
'final_total': final_total
|
||||
'final_total': final_total,
|
||||
'cart_subtotal': cart_subtotal,
|
||||
'excluded_by': excluded_by
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user