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:
2026-01-11 12:56:38 +03:00
parent 293f3b58cb
commit f57e639dbe
9 changed files with 715 additions and 223 deletions

View File

@@ -11,6 +11,7 @@ class DiscountAdmin(admin.ModelAdmin):
'discount_type', 'discount_type',
'value_display', 'value_display',
'scope', 'scope',
'combine_mode_display',
'is_auto', 'is_auto',
'is_active', 'is_active',
'current_usage_count', 'current_usage_count',
@@ -20,6 +21,7 @@ class DiscountAdmin(admin.ModelAdmin):
list_filter = [ list_filter = [
'discount_type', 'discount_type',
'scope', 'scope',
'combine_mode',
'is_auto', 'is_auto',
'is_active', 'is_active',
] ]
@@ -39,7 +41,7 @@ class DiscountAdmin(admin.ModelAdmin):
'fields': ('name', 'description', 'is_active', 'priority') 'fields': ('name', 'description', 'is_active', 'priority')
}), }),
('Параметры скидки', { ('Параметры скидки', {
'fields': ('discount_type', 'value', 'scope') 'fields': ('discount_type', 'value', 'scope', 'combine_mode')
}), }),
('Ограничения', { ('Ограничения', {
'fields': ( 'fields': (
@@ -70,6 +72,23 @@ class DiscountAdmin(admin.ModelAdmin):
return f"{obj.value} руб." return f"{obj.value} руб."
value_display.short_description = "Значение" 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): def validity_period(self, obj):
if obj.start_date and obj.end_date: if obj.start_date and obj.end_date:
return f"{obj.start_date.date()} - {obj.end_date.date()}" return f"{obj.start_date.date()} - {obj.end_date.date()}"

View 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='Режим объединения'),
),
]

View File

@@ -18,6 +18,12 @@ class BaseDiscount(models.Model):
('category', 'На категорию товаров'), ('category', 'На категорию товаров'),
] ]
COMBINE_MODE_CHOICES = [
('stack', 'Складывать (суммировать)'),
('max_only', 'Только максимум'),
('exclusive', 'Исключающая (отменяет остальные)'),
]
name = models.CharField( name = models.CharField(
max_length=200, max_length=200,
verbose_name="Название скидки" verbose_name="Название скидки"

View File

@@ -48,6 +48,15 @@ class Discount(BaseDiscount):
help_text="Применяется автоматически при выполнении условий" 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: class Meta:
verbose_name = "Скидка" verbose_name = "Скидка"
verbose_name_plural = "Скидки" verbose_name_plural = "Скидки"

View File

@@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
class DiscountApplier: class DiscountApplier:
""" """
Сервис для применения скидок к заказам. Сервис для применения скидок к заказам.
Поддерживает комбинирование скидок по combine_mode.
Все операции атомарны. Все операции атомарны.
""" """
@@ -14,6 +15,7 @@ class DiscountApplier:
def apply_promo_code(order, promo_code, user=None): def apply_promo_code(order, promo_code, user=None):
""" """
Применить промокод к заказу. Применить промокод к заказу.
Поддерживает комбинирование скидок.
Args: Args:
order: Order order: Order
@@ -23,8 +25,8 @@ class DiscountApplier:
Returns: Returns:
dict: { dict: {
'success': bool, 'success': bool,
'discount': Discount, 'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
'discount_amount': Decimal, 'total_amount': Decimal,
'error': str 'error': str
} }
""" """
@@ -36,9 +38,7 @@ class DiscountApplier:
DiscountApplier._remove_order_discount_only(order) DiscountApplier._remove_order_discount_only(order)
# Рассчитываем скидку # Рассчитываем скидку
result = DiscountCalculator.calculate_order_discount( result = DiscountCalculator.calculate_order_discount(order, promo_code)
order, promo_code
)
if result['error']: if result['error']:
return { return {
@@ -46,14 +46,16 @@ class DiscountApplier:
'error': result['error'] 'error': result['error']
} }
discount = result['discount']
promo = result['promo_code'] promo = result['promo_code']
discount_amount = result['discount_amount'] discounts_data = result['discounts']
total_amount = result['total_amount']
# Применяем к заказу # Применяем первую скидку в applied_discount (для обратной совместимости с Order)
order.applied_discount = discount if discounts_data:
first_discount = discounts_data[0]['discount']
order.applied_discount = first_discount
order.applied_promo_code = promo.code order.applied_promo_code = promo.code
order.discount_amount = discount_amount order.discount_amount = total_amount
order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount']) order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount'])
# Пересчитываем total_amount # Пересчитываем total_amount
@@ -62,15 +64,19 @@ class DiscountApplier:
# Регистрируем использование промокода # Регистрируем использование промокода
promo.record_usage(order.customer) promo.record_usage(order.customer)
# Создаем запись о применении # Создаем записи о применении для каждой скидки
for disc_data in discounts_data:
discount = disc_data['discount']
amount = disc_data['amount']
DiscountApplication.objects.create( DiscountApplication.objects.create(
order=order, order=order,
discount=discount, discount=discount,
promo_code=promo, promo_code=promo,
target='order', target='order',
base_amount=order.subtotal, base_amount=order.subtotal,
discount_amount=discount_amount, discount_amount=amount,
final_amount=order.subtotal - discount_amount, final_amount=order.subtotal - amount,
customer=order.customer, customer=order.customer,
applied_by=user or order.modified_by applied_by=user or order.modified_by
) )
@@ -81,8 +87,8 @@ class DiscountApplier:
return { return {
'success': True, 'success': True,
'discount': discount, 'discounts': discounts_data,
'discount_amount': discount_amount 'total_amount': total_amount
} }
@staticmethod @staticmethod
@@ -90,6 +96,7 @@ class DiscountApplier:
def apply_auto_discounts(order, user=None): def apply_auto_discounts(order, user=None):
""" """
Применить автоматические скидки к заказу и позициям. Применить автоматические скидки к заказу и позициям.
Поддерживает комбинирование скидок.
Args: Args:
order: Order order: Order
@@ -97,7 +104,7 @@ class DiscountApplier:
Returns: Returns:
dict: { dict: {
'order_discount': {...}, 'order_discounts': [...],
'item_discounts': [...], 'item_discounts': [...],
'total_discount': Decimal 'total_discount': Decimal
} }
@@ -106,29 +113,35 @@ class DiscountApplier:
from discounts.services.calculator import DiscountCalculator from discounts.services.calculator import DiscountCalculator
result = { result = {
'order_discount': None, 'order_discounts': [],
'item_discounts': [], 'item_discounts': [],
'total_discount': Decimal('0') 'total_discount': Decimal('0')
} }
# 1. Применяем скидку на заказ (если есть) # 1. Применяем скидки на заказ (может быть несколько)
order_result = DiscountCalculator.calculate_order_discount(order) 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 if order_result['discounts'] and not order_result['error']:
order.discount_amount = discount_amount 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']) order.save(update_fields=['applied_discount', 'discount_amount'])
# Создаем запись о применении # Создаем записи о применении для всех скидок
for disc_data in order_result['discounts']:
discount = disc_data['discount']
amount = disc_data['amount']
DiscountApplication.objects.create( DiscountApplication.objects.create(
order=order, order=order,
discount=discount, discount=discount,
target='order', target='order',
base_amount=order.subtotal, base_amount=order.subtotal,
discount_amount=discount_amount, discount_amount=amount,
final_amount=order.subtotal - discount_amount, final_amount=order.subtotal - amount,
customer=order.customer, customer=order.customer,
applied_by=user applied_by=user
) )
@@ -137,39 +150,42 @@ class DiscountApplier:
discount.current_usage_count += 1 discount.current_usage_count += 1
discount.save(update_fields=['current_usage_count']) discount.save(update_fields=['current_usage_count'])
result['order_discount'] = { result['order_discounts'] = order_result['discounts']
'discount': discount, result['total_discount'] += total_order_amount
'discount_amount': discount_amount
}
result['total_discount'] += discount_amount
# 2. Применяем скидки на позиции # 2. Применяем скидки на позиции
available_discounts = list(DiscountCalculator.get_available_discounts( available_product_discounts = list(DiscountCalculator.get_available_discounts(
scope='product', scope='product',
auto_only=True auto_only=True
)) ))
for item in order.items.all(): for item in order.items.all():
item_result = DiscountCalculator.calculate_item_discount( item_result = DiscountCalculator.calculate_item_discount(
item, available_discounts item, available_product_discounts
) )
if item_result['discount']: if item_result['discounts']:
discount = item_result['discount'] total_item_amount = item_result['total_amount']
discount_amount = item_result['discount_amount']
item.applied_discount = discount # Сохраняем первую скидку в applied_discount (для совместимости)
item.discount_amount = discount_amount 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']) 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( DiscountApplication.objects.create(
order=order, order=order,
order_item=item, order_item=item,
discount=discount, discount=discount,
target='order_item', target='order_item',
base_amount=item.price * item.quantity, base_amount=base_amount,
discount_amount=discount_amount, discount_amount=amount,
final_amount=item.get_total_price(), final_amount=item.get_total_price(),
customer=order.customer, customer=order.customer,
applied_by=user applied_by=user
@@ -181,10 +197,10 @@ class DiscountApplier:
result['item_discounts'].append({ result['item_discounts'].append({
'item': item, 'item': item,
'discount': discount, 'discounts': item_result['discounts'],
'discount_amount': discount_amount 'total_amount': total_item_amount
}) })
result['total_discount'] += discount_amount result['total_discount'] += total_item_amount
# Пересчитываем итоговую сумму # Пересчитываем итоговую сумму
order.calculate_total() order.calculate_total()
@@ -208,8 +224,9 @@ class DiscountApplier:
discount_amount=Decimal('0') discount_amount=Decimal('0')
) )
# Удаляем записи о применении (опционально - для истории можно оставить) # Удаляем записи о применении
# DiscountApplication.objects.filter(order=order).delete() from discounts.models import DiscountApplication
DiscountApplication.objects.filter(order=order).delete()
# Пересчитываем # Пересчитываем
order.calculate_total() order.calculate_total()
@@ -285,6 +302,11 @@ class DiscountApplier:
Args: Args:
order: Order order: Order
""" """
from discounts.models import DiscountApplication
# Удаляем записи о применении скидок к заказу
DiscountApplication.objects.filter(order=order, target='order').delete()
order.applied_discount = None order.applied_discount = None
order.applied_promo_code = None order.applied_promo_code = None
order.discount_amount = Decimal('0') order.discount_amount = Decimal('0')

View File

@@ -3,6 +3,82 @@ from django.db import models
from django.utils import timezone 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: class DiscountCalculator:
""" """
Калькулятор скидок для заказов. Калькулятор скидок для заказов.
@@ -52,7 +128,7 @@ class DiscountCalculator:
@staticmethod @staticmethod
def calculate_order_discount(order, promo_code=None): def calculate_order_discount(order, promo_code=None):
""" """
Рассчитать скидку на весь заказ. Рассчитать скидку на весь заказ с поддержкой комбинирования.
Args: Args:
order: Order объект order: Order объект
@@ -60,9 +136,9 @@ class DiscountCalculator:
Returns: Returns:
dict: { dict: {
'discount': Discount или None, 'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
'total_amount': Decimal,
'promo_code': PromoCode или None, 'promo_code': PromoCode или None,
'discount_amount': Decimal,
'error': str или None 'error': str или None
} }
""" """
@@ -71,12 +147,14 @@ class DiscountCalculator:
subtotal = Decimal(str(order.subtotal)) subtotal = Decimal(str(order.subtotal))
result = { result = {
'discount': None, 'discounts': [],
'total_amount': Decimal('0'),
'promo_code': None, 'promo_code': None,
'discount_amount': Decimal('0'),
'error': None 'error': None
} }
applicable_discounts = []
# 1. Проверяем промокод первым # 1. Проверяем промокод первым
if promo_code: if promo_code:
is_valid, promo, error = DiscountValidator.validate_promo_code( is_valid, promo, error = DiscountValidator.validate_promo_code(
@@ -89,38 +167,36 @@ class DiscountCalculator:
discount = promo.discount discount = promo.discount
# Проверяем scope скидки
if discount.scope != 'order': if discount.scope != 'order':
result['error'] = "Этот промокод применяется только к товарам" result['error'] = "Этот промокод применяется только к товарам"
return result return result
result['discount'] = discount
result['promo_code'] = promo result['promo_code'] = promo
result['discount_amount'] = discount.calculate_discount_amount(subtotal) applicable_discounts.append(discount)
return result else:
# 2. Если нет промокода, собираем все автоматические скидки
# 2. Если нет промокода, проверяем автоматические скидки
auto_discounts = DiscountCalculator.get_available_discounts( auto_discounts = DiscountCalculator.get_available_discounts(
scope='order', scope='order',
auto_only=True auto_only=True
) )
for discount in auto_discounts: for discount in auto_discounts:
# Проверяем мин. сумму заказа
if discount.min_order_amount and subtotal < discount.min_order_amount: if discount.min_order_amount and subtotal < discount.min_order_amount:
continue continue
applicable_discounts.append(discount)
# Применяем первую подходящую автоматическую скидку # 3. Комбинируем скидки
result['discount'] = discount if applicable_discounts:
result['discount_amount'] = discount.calculate_discount_amount(subtotal) combination = DiscountCombiner.combine_discounts(applicable_discounts, subtotal)
break result['discounts'] = combination['applied_discounts']
result['total_amount'] = combination['total_discount']
return result return result
@staticmethod @staticmethod
def calculate_item_discount(order_item, available_discounts=None): def calculate_item_discount(order_item, available_discounts=None):
""" """
Рассчитать скидку на позицию заказа. Рассчитать скидку на позицию заказа с поддержкой комбинирования.
Args: Args:
order_item: OrderItem объект order_item: OrderItem объект
@@ -128,13 +204,13 @@ class DiscountCalculator:
Returns: Returns:
dict: { dict: {
'discount': Discount или None, 'discounts': [{'discount': Discount, 'amount': Decimal}, ...],
'discount_amount': Decimal, 'total_amount': Decimal,
} }
""" """
result = { result = {
'discount': None, 'discounts': [],
'discount_amount': Decimal('0') 'total_amount': Decimal('0')
} }
# Определяем продукт # Определяем продукт
@@ -149,6 +225,10 @@ class DiscountCalculator:
base_amount = Decimal(str(order_item.price)) * Decimal(str(order_item.quantity)) base_amount = Decimal(str(order_item.price)) * Decimal(str(order_item.quantity))
# Собираем все применимые скидки
applicable_discounts = []
# Скидки на товары
if not available_discounts: if not available_discounts:
available_discounts = DiscountCalculator.get_available_discounts( available_discounts = DiscountCalculator.get_available_discounts(
scope='product', scope='product',
@@ -156,48 +236,54 @@ class DiscountCalculator:
) )
for discount in available_discounts: for discount in available_discounts:
# Проверяем, применяется ли скидка к этому товару if discount.applies_to_product(product):
if not discount.applies_to_product(product): applicable_discounts.append(discount)
continue
result['discount'] = discount # Скидки по категориям
result['discount_amount'] = discount.calculate_discount_amount(base_amount)
break
# Проверяем скидки по категориям
if not result['discount']:
category_discounts = DiscountCalculator.get_available_discounts( category_discounts = DiscountCalculator.get_available_discounts(
scope='category', scope='category',
auto_only=True auto_only=True
) )
for discount in category_discounts: for discount in category_discounts:
if discount.applies_to_product(product): if discount.applies_to_product(product) and discount not in applicable_discounts:
result['discount'] = discount applicable_discounts.append(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 return result
@staticmethod @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 до создания заказа). Рассчитать скидки для корзины (применяется в POS до создания заказа).
Поддерживает комбинирование скидок по combine_mode.
Args: Args:
cart_items: Список словарей {'type': 'product'|'kit', 'id': int, 'quantity': Decimal, 'price': Decimal} cart_items: Список словарей {'type': 'product'|'kit', 'id': int, 'quantity': Decimal, 'price': Decimal}
promo_code: Промокод (опционально) promo_code: Промокод (опционально)
customer: Customer (опционально) customer: Customer (опционально)
skip_auto_discount: Пропустить автоматические скидки (опционально)
Returns: Returns:
dict: { 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': [ 'item_discounts': [
{'cart_index': int, 'discount': Discount, 'discount_amount': Decimal}, {'cart_index': int, 'discounts': [...], 'total_discount': Decimal},
... ...
], ],
'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 from products.models import Product, ProductKit
@@ -206,6 +292,18 @@ class DiscountCalculator:
for item in cart_items: for item in cart_items:
cart_subtotal += Decimal(str(item['price'])) * Decimal(str(item['quantity'])) 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: class FakeOrder:
def __init__(self, subtotal, customer): def __init__(self, subtotal, customer):
@@ -214,12 +312,36 @@ class DiscountCalculator:
fake_order = FakeOrder(cart_subtotal, customer) fake_order = FakeOrder(cart_subtotal, customer)
# Скидка на заказ # Скидка на заказ (с комбинированием)
order_discount = DiscountCalculator.calculate_order_discount( order_result = DiscountCalculator.calculate_order_discount(fake_order, promo_code)
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 = [] item_discounts = []
items_total_discount = Decimal('0') items_total_discount = Decimal('0')
@@ -252,38 +374,51 @@ class DiscountCalculator:
base_amount = Decimal(str(item['price'])) * Decimal(str(item['quantity'])) base_amount = Decimal(str(item['price'])) * Decimal(str(item['quantity']))
# Проверяем скидки на товары # Собираем все применимые скидки для этого товара
applied_discount = None applicable_item_discounts = []
discount_amount = Decimal('0')
for discount in available_product_discounts: for discount in available_product_discounts:
if discount.applies_to_product(product): if discount.applies_to_product(product):
applied_discount = discount applicable_item_discounts.append(discount)
discount_amount = discount.calculate_discount_amount(base_amount)
break
# Если не нашли скидку на товар, проверяем категории
if not applied_discount:
for discount in available_category_discounts: for discount in available_category_discounts:
if discount.applies_to_product(product): if discount.applies_to_product(product) and discount not in applicable_item_discounts:
applied_discount = discount applicable_item_discounts.append(discount)
discount_amount = discount.calculate_discount_amount(base_amount)
break
if applied_discount: # Комбинируем скидки для позиции
if applicable_item_discounts:
combination = DiscountCombiner.combine_discounts(
applicable_item_discounts,
base_amount
)
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({ item_discounts.append({
'cart_index': idx, 'cart_index': idx,
'discount': applied_discount, 'discounts': formatted_discounts,
'discount_amount': discount_amount 'total_discount': combination['total_discount']
}) })
items_total_discount += discount_amount items_total_discount += combination['total_discount']
total_discount = order_discount['discount_amount'] + items_total_discount total_discount = order_result['total_amount'] + items_total_discount
final_total = max(cart_subtotal - total_discount, Decimal('0')) final_total = max(cart_subtotal - total_discount, Decimal('0'))
return { return {
'order_discount': order_discount, 'order_discounts': formatted_order_discounts,
'total_order_discount': order_result['total_amount'],
'item_discounts': item_discounts, 'item_discounts': item_discounts,
'total_discount': total_discount, 'total_discount': total_discount,
'final_total': final_total 'final_total': final_total,
'cart_subtotal': cart_subtotal,
'excluded_by': excluded_by
} }

View File

@@ -2294,12 +2294,15 @@ let paymentWidget = null;
// Переменные состояния скидок // Переменные состояния скидок
let appliedPromoCode = null; // примененный промокод let appliedPromoCode = null; // примененный промокод
let appliedManualDiscount = null; // выбранная вручную скидка let appliedManualDiscount = null; // выбранная вручную скидка (из списка)
let appliedCustomDiscount = null; // произвольная скидка {value: number, isPercent: boolean}
let availableDiscounts = []; // список доступных скидок let availableDiscounts = []; // список доступных скидок
let skipAutoDiscount = false; // флаг отмены авто-скидки
let cartDiscounts = { let cartDiscounts = {
orderDiscount: null, // скидка на заказ orderDiscounts: [], // скидки на заказ (теперь массив)
itemDiscounts: [], // скидки на позиции itemDiscounts: [], // скидки на позиции
totalDiscount: 0 // общая сумма скидки totalDiscount: 0, // общая сумма скидки
excludedBy: null // исключающая скидка
}; };
// При открытии модалки checkout // При открытии модалки checkout
@@ -2372,7 +2375,9 @@ function reinitPaymentWidget(mode) {
function resetDiscounts() { function resetDiscounts() {
appliedPromoCode = null; appliedPromoCode = null;
appliedManualDiscount = null; appliedManualDiscount = null;
appliedCustomDiscount = null;
availableDiscounts = []; availableDiscounts = [];
skipAutoDiscount = false;
cartDiscounts = { cartDiscounts = {
orderDiscount: null, orderDiscount: null,
itemDiscounts: [], itemDiscounts: [],
@@ -2393,6 +2398,14 @@ function resetDiscounts() {
document.getElementById('manualDiscountContainer').style.display = 'none'; document.getElementById('manualDiscountContainer').style.display = 'none';
document.getElementById('discountsSummary').style.display = 'none'; document.getElementById('discountsSummary').style.display = 'none';
document.getElementById('itemDiscountsBreakdown').innerHTML = ''; document.getElementById('itemDiscountsBreakdown').innerHTML = '';
// Сбрасываем произвольную скидку
document.getElementById('customDiscountInput').value = '';
document.getElementById('customDiscountIsPercent').checked = true;
document.getElementById('customDiscountError').style.display = 'none';
document.getElementById('customDiscountError').textContent = '';
document.getElementById('applyCustomDiscountBtn').style.display = 'inline-block';
document.getElementById('removeCustomDiscountBtn').style.display = 'none';
} }
// Проверить автоматические скидки // Проверить автоматические скидки
@@ -2416,7 +2429,8 @@ async function checkAutoDiscounts() {
body: JSON.stringify({ body: JSON.stringify({
items: items, items: items,
customer_id: customer.id !== SYSTEM_CUSTOMER.id ? customer.id : null, customer_id: customer.id !== SYSTEM_CUSTOMER.id ? customer.id : null,
manual_discount_id: appliedManualDiscount?.id || null manual_discount_id: appliedManualDiscount?.id || null,
skip_auto_discount: skipAutoDiscount
}) })
}); });
@@ -2424,8 +2438,9 @@ async function checkAutoDiscounts() {
if (result.success) { if (result.success) {
cartDiscounts.totalDiscount = result.total_discount || 0; cartDiscounts.totalDiscount = result.total_discount || 0;
cartDiscounts.orderDiscount = result.order_discount; cartDiscounts.orderDiscounts = result.order_discounts || [];
cartDiscounts.itemDiscounts = result.item_discounts || []; cartDiscounts.itemDiscounts = result.item_discounts || [];
cartDiscounts.excludedBy = result.excluded_by || null;
updateDiscountsUI(result); updateDiscountsUI(result);
} }
@@ -2434,6 +2449,26 @@ async function checkAutoDiscounts() {
} }
} }
// Получить иконку для режима объединения
function getCombineModeIcon(mode) {
const icons = {
'stack': '<i class="bi bi-layers" title="Складывать (суммировать)"></i>',
'max_only': '<i class="bi bi-trophy" title="Только максимум"></i>',
'exclusive': '<i class="bi bi-x-circle" title="Исключающая (отменяет остальные)"></i>'
};
return icons[mode] || '';
}
// Получить описание режима объединения
function getCombineModeTitle(mode) {
const titles = {
'stack': 'Складывается с другими скидками',
'max_only': 'Применяется только максимальная из этого типа',
'exclusive': 'Отменяет все другие скидки'
};
return titles[mode] || mode;
}
// Обновить UI скидок // Обновить UI скидок
function updateDiscountsUI(result) { function updateDiscountsUI(result) {
const autoContainer = document.getElementById('autoDiscountsContainer'); const autoContainer = document.getElementById('autoDiscountsContainer');
@@ -2447,33 +2482,57 @@ function updateDiscountsUI(result) {
let hasDiscounts = false; let hasDiscounts = false;
// 1. Скидка на заказ (автоматическая) // 1. Скидки на заказ (теперь может быть несколько)
if (result.order_discount && result.order_discount.discount_id) { const orderDiscounts = result.order_discounts || [];
if (orderDiscounts.length > 0) {
hasDiscounts = true; hasDiscounts = true;
autoContainer.style.display = 'block'; autoContainer.style.display = 'block';
orderDiscounts.forEach(disc => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'd-flex justify-content-between'; div.className = 'd-flex justify-content-between align-items-center w-100';
const modeIcon = getCombineModeIcon(disc.combine_mode);
div.innerHTML = ` div.innerHTML = `
<span>${result.order_discount.discount_name}</span> <span>${modeIcon} ${disc.discount_name}</span>
<span class="text-success">-${result.order_discount.discount_amount.toFixed(2)} руб.</span> <span class="text-success">-${disc.discount_amount.toFixed(2)} руб.</span>
`; `;
autoList.appendChild(div); autoList.appendChild(div);
} else { });
autoContainer.style.display = 'none';
// Показываем информацию о комбинировании
if (orderDiscounts.length > 1) {
const infoDiv = document.createElement('div');
infoDiv.className = 'text-muted small mt-1';
infoDiv.innerHTML = '<i class="bi bi-info-circle"></i> Скидки скомбинированы';
autoList.appendChild(infoDiv);
} }
// 2. Скидки на позиции // Показываем кнопку отмены (только если еще не пропущена)
if (result.item_discounts && result.item_discounts.length > 0) { document.getElementById('skipAutoDiscountBtn').style.display = 'block';
} else {
autoContainer.style.display = 'none';
document.getElementById('skipAutoDiscountBtn').style.display = 'none';
}
// 2. Скидки на позиции (новый формат с массивом discounts)
const itemDiscounts = result.item_discounts || [];
if (itemDiscounts.length > 0) {
hasDiscounts = true; hasDiscounts = true;
result.item_discounts.forEach(item => { itemDiscounts.forEach(item => {
if (item.discounts && item.discounts.length > 0) {
const discNames = item.discounts.map(d => {
const modeIcon = getCombineModeIcon(d.combine_mode);
return `${modeIcon} ${d.discount_name}`;
}).join(', ');
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'text-muted'; div.className = 'text-muted small';
div.innerHTML = `${item.discount_name}: -${item.discount_amount.toFixed(2)} руб.`; div.innerHTML = `${discNames}: <span class="text-success">-${item.total_discount.toFixed(2)} руб.</span>`;
itemBreakdown.appendChild(div); itemBreakdown.appendChild(div);
}
}); });
} }
// 3. Ручная скидка // 3. Ручная скидка (из списка)
if (appliedManualDiscount) { if (appliedManualDiscount) {
hasDiscounts = true; hasDiscounts = true;
document.getElementById('manualDiscountContainer').style.display = 'block'; document.getElementById('manualDiscountContainer').style.display = 'block';
@@ -2484,19 +2543,54 @@ function updateDiscountsUI(result) {
document.getElementById('manualDiscountContainer').style.display = 'none'; document.getElementById('manualDiscountContainer').style.display = 'none';
} }
// 4. Произвольная скидка
if (appliedCustomDiscount) {
hasDiscounts = true;
const customDiscountAmount = appliedCustomDiscount.isPercent
? (result.cart_subtotal || 0) * appliedCustomDiscount.value / 100
: appliedCustomDiscount.value;
const discountText = appliedCustomDiscount.isPercent
? `-${appliedCustomDiscount.value}% (-${customDiscountAmount.toFixed(2)} руб.)`
: `-${customDiscountAmount.toFixed(2)} руб.`;
// Показываем в summary или добавляем как отдельную строку
const customDiv = document.createElement('div');
customDiv.className = 'd-flex justify-content-between align-items-center mt-1';
customDiv.innerHTML = `
<span class="badge bg-primary">Произвольная скидка ${discountText}</span>
`;
itemBreakdown.appendChild(customDiv);
}
// Показываем/скрываем summary // Показываем/скрываем summary
if (hasDiscounts) { if (hasDiscounts) {
summaryDiv.style.display = 'block'; summaryDiv.style.display = 'block';
document.getElementById('discountsSubtotal').textContent = document.getElementById('discountsSubtotal').textContent =
(result.cart_subtotal || 0).toFixed(2) + ' руб.'; (result.cart_subtotal || 0).toFixed(2) + ' руб.';
// Рассчитываем итоговую скидку с учетом произвольной
let totalDiscount = result.total_discount || 0;
if (appliedCustomDiscount) {
const customDiscountAmount = appliedCustomDiscount.isPercent
? (result.cart_subtotal || 0) * appliedCustomDiscount.value / 100
: appliedCustomDiscount.value;
totalDiscount += customDiscountAmount;
}
document.getElementById('discountsTotalDiscount').textContent = document.getElementById('discountsTotalDiscount').textContent =
'-' + (result.total_discount || 0).toFixed(2) + ' руб.'; '-' + totalDiscount.toFixed(2) + ' руб.';
} else { } else {
summaryDiv.style.display = 'none'; summaryDiv.style.display = 'none';
} }
// Обновляем итоговую цену // Обновляем итоговую цену
const finalTotal = Math.max(0, (result.cart_subtotal || 0) - (result.total_discount || 0)); let totalDiscount = result.total_discount || 0;
if (appliedCustomDiscount) {
const customDiscountAmount = appliedCustomDiscount.isPercent
? (result.cart_subtotal || 0) * appliedCustomDiscount.value / 100
: appliedCustomDiscount.value;
totalDiscount += customDiscountAmount;
}
const finalTotal = Math.max(0, (result.cart_subtotal || 0) - totalDiscount);
document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.'; document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.';
// Пересчитываем виджет оплаты // Пересчитываем виджет оплаты
@@ -2537,12 +2631,15 @@ function renderDiscountsDropdown(discounts) {
const li = document.createElement('li'); const li = document.createElement('li');
const valueText = d.discount_type === 'percentage' ? `${d.value}%` : `${d.value} руб.`; const valueText = d.discount_type === 'percentage' ? `${d.value}%` : `${d.value} руб.`;
const minText = d.min_order_amount ? `(от ${d.min_order_amount} руб.)` : ''; const minText = d.min_order_amount ? `(от ${d.min_order_amount} руб.)` : '';
const modeIcon = getCombineModeIcon(d.combine_mode);
const modeTitle = getCombineModeTitle(d.combine_mode);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = '#'; a.href = '#';
a.className = 'dropdown-item d-flex justify-content-between'; a.className = 'dropdown-item d-flex justify-content-between';
a.title = modeTitle;
a.innerHTML = ` a.innerHTML = `
<span>${d.name}</span> <span>${modeIcon} ${d.name}</span>
<span class="text-success">${valueText} ${minText}</span> <span class="text-success">${valueText} ${minText}</span>
`; `;
a.onclick = (e) => { a.onclick = (e) => {
@@ -2577,6 +2674,12 @@ document.getElementById('removeManualDiscountBtn').addEventListener('click', asy
await loadAvailableDiscounts(); await loadAvailableDiscounts();
}); });
// Отменить автоматическую скидку
document.getElementById('skipAutoDiscountBtn').addEventListener('click', async () => {
skipAutoDiscount = true;
await checkAutoDiscounts();
});
// Применить промокод // Применить промокод
async function applyPromoCode() { async function applyPromoCode() {
const code = document.getElementById('promoCodeInput').value.trim().toUpperCase(); const code = document.getElementById('promoCodeInput').value.trim().toUpperCase();
@@ -2657,8 +2760,9 @@ async function recalculateDiscountsWithPromo(promoCode) {
if (result.success) { if (result.success) {
cartDiscounts.totalDiscount = result.total_discount || 0; cartDiscounts.totalDiscount = result.total_discount || 0;
cartDiscounts.orderDiscount = result.order_discount; cartDiscounts.orderDiscounts = result.order_discounts || [];
cartDiscounts.itemDiscounts = result.item_discounts || []; cartDiscounts.itemDiscounts = result.item_discounts || [];
cartDiscounts.excludedBy = result.excluded_by || null;
updateDiscountsUI(result); updateDiscountsUI(result);
} }
} catch (error) { } catch (error) {
@@ -2708,6 +2812,90 @@ document.getElementById('promoCodeInput').addEventListener('keypress', (e) => {
} }
}); });
// ===== ПРОИЗВОЛЬНАЯ СКИДКА =====
// Применить произвольную скидку
async function applyCustomDiscount() {
const input = document.getElementById('customDiscountInput');
const isPercent = document.getElementById('customDiscountIsPercent').checked;
const errorDiv = document.getElementById('customDiscountError');
const value = parseFloat(input.value);
// Валидация
if (isNaN(value) || value <= 0) {
errorDiv.textContent = 'Введите корректное значение скидки';
errorDiv.style.display = 'block';
return;
}
if (isPercent && value > 100) {
errorDiv.textContent = 'Процент не может превышать 100%';
errorDiv.style.display = 'block';
return;
}
// Проверяем сумму корзины
let cartTotal = 0;
cart.forEach((item) => {
cartTotal += item.qty * item.price;
});
if (!isPercent && value > cartTotal) {
errorDiv.textContent = `Скидка не может превышать сумму заказа (${cartTotal.toFixed(2)} руб.)`;
errorDiv.style.display = 'block';
return;
}
// Сохраняем произвольную скидку
appliedCustomDiscount = { value, isPercent };
// Сбрасываем другие типы скидок (взаимоисключающие)
appliedPromoCode = null;
appliedManualDiscount = null;
// Обновляем UI
errorDiv.style.display = 'none';
document.getElementById('applyCustomDiscountBtn').style.display = 'none';
document.getElementById('removeCustomDiscountBtn').style.display = 'inline-block';
document.getElementById('promoCodeInput').value = '';
document.getElementById('promoCodeSuccess').style.display = 'none';
document.getElementById('promoCodeError').style.display = 'none';
// Пересчитываем скидки
await checkAutoDiscounts();
}
// Удалить произвольную скидку
async function removeCustomDiscount() {
appliedCustomDiscount = null;
// Обновляем UI
document.getElementById('customDiscountInput').value = '';
document.getElementById('applyCustomDiscountBtn').style.display = 'inline-block';
document.getElementById('removeCustomDiscountBtn').style.display = 'none';
document.getElementById('customDiscountError').style.display = 'none';
// Пересчитываем скидки
await checkAutoDiscounts();
}
// Обработчики кнопок произвольной скидки
document.getElementById('applyCustomDiscountBtn').addEventListener('click', applyCustomDiscount);
document.getElementById('removeCustomDiscountBtn').addEventListener('click', removeCustomDiscount);
document.getElementById('customDiscountInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyCustomDiscount();
}
});
document.getElementById('customDiscountInput').addEventListener('input', () => {
document.getElementById('customDiscountError').style.display = 'none';
});
async function initPaymentWidget(mode, data) { async function initPaymentWidget(mode, data) {
const paymentMethods = [ const paymentMethods = [
{ id: 1, code: 'account_balance', name: 'С баланса счёта' }, { id: 1, code: 'account_balance', name: 'С баланса счёта' },
@@ -2778,7 +2966,11 @@ async function handleCheckoutSubmit(paymentsData) {
payments: paymentsData, payments: paymentsData,
notes: document.getElementById('orderNote').value.trim(), notes: document.getElementById('orderNote').value.trim(),
promo_code: appliedPromoCode?.code || null, promo_code: appliedPromoCode?.code || null,
manual_discount_id: appliedManualDiscount?.id || null manual_discount_id: appliedManualDiscount?.id || null,
custom_discount: appliedCustomDiscount ? {
value: appliedCustomDiscount.value,
is_percent: appliedCustomDiscount.isPercent
} : null
}; };
// Отправляем на сервер // Отправляем на сервер

View File

@@ -349,13 +349,18 @@
<div class="mb-2 pb-2 border-bottom"> <div class="mb-2 pb-2 border-bottom">
<small class="text-muted d-block mb-1">Скидки</small> <small class="text-muted d-block mb-1">Скидки</small>
<!-- Автоматические скидки (read-only) --> <!-- Автоматические скидки -->
<div id="autoDiscountsContainer" class="mb-2" style="display: none;"> <div id="autoDiscountsContainer" class="mb-2" style="display: none;">
<div class="alert alert-success py-1 px-2 mb-1" style="font-size: 0.85rem;"> <div class="d-flex justify-content-between align-items-center">
<div class="alert alert-success py-1 px-2 mb-0" style="font-size: 0.85rem; flex: 1;">
<i class="bi bi-magic me-1"></i> <i class="bi bi-magic me-1"></i>
<strong>Автоматически:</strong> <strong>Автоматически:</strong>
<div id="autoDiscountsList"></div> <div id="autoDiscountsList"></div>
</div> </div>
<button type="button" class="btn btn-sm btn-link text-danger p-1 ms-2" id="skipAutoDiscountBtn" style="display: none; width: 32px; height: 32px;" title="Отменить скидку">
<i class="bi bi-x-lg fs-5"></i>
</button>
</div>
</div> </div>
<!-- Выбранная скидка вручную --> <!-- Выбранная скидка вручную -->
@@ -383,6 +388,26 @@
</ul> </ul>
</div> </div>
<!-- Произвольная скидка -->
<div class="input-group input-group-sm mb-2">
<span class="input-group-text"><i class="bi bi-percent"></i></span>
<input type="number" class="form-control" id="customDiscountInput"
placeholder="Сумма или %" min="0" step="0.01">
<button class="btn btn-outline-secondary" type="button" id="applyCustomDiscountBtn">
<i class="bi bi-check-lg"></i>
</button>
<button class="btn btn-outline-danger" type="button" id="removeCustomDiscountBtn" style="display: none;">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="form-check form-check-sm mb-2">
<input class="form-check-input" type="checkbox" id="customDiscountIsPercent" checked>
<label class="form-check-label small" for="customDiscountIsPercent">
Процент (снимите галочку для фиксированной суммы в рублях)
</label>
</div>
<div id="customDiscountError" class="text-danger small mb-2" style="display: none;"></div>
<!-- Промокод --> <!-- Промокод -->
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-ticket-perforated"></i></span> <span class="input-group-text"><i class="bi bi-ticket-perforated"></i></span>

View File

@@ -1403,6 +1403,7 @@ def pos_checkout(request):
"notes": str (optional), "notes": str (optional),
"promo_code": str (optional) - Промокод для скидки "promo_code": str (optional) - Промокод для скидки
"manual_discount_id": int (optional) - ID выбранной вручную скидки "manual_discount_id": int (optional) - ID выбранной вручную скидки
"custom_discount": dict (optional) - Произвольная скидка {"value": float, "is_percent": bool}
} }
""" """
from orders.models import Order, OrderItem, OrderStatus from orders.models import Order, OrderItem, OrderStatus
@@ -1425,6 +1426,7 @@ def pos_checkout(request):
order_notes = body.get('notes', '') order_notes = body.get('notes', '')
promo_code = body.get('promo_code') # Промокод для скидки promo_code = body.get('promo_code') # Промокод для скидки
manual_discount_id = body.get('manual_discount_id') # ID выбранной вручную скидки manual_discount_id = body.get('manual_discount_id') # ID выбранной вручную скидки
custom_discount = body.get('custom_discount') # Произвольная скидка {"value": float, "is_percent": bool}
if not customer_id: if not customer_id:
return JsonResponse({'success': False, 'error': 'Не указан клиент'}, status=400) return JsonResponse({'success': False, 'error': 'Не указан клиент'}, status=400)
@@ -1556,7 +1558,38 @@ def pos_checkout(request):
order.calculate_total() order.calculate_total()
# 4. Применяем скидки # 4. Применяем скидки
if manual_discount_id: if custom_discount:
# Применяем произвольную скидку
from decimal import Decimal
discount_value = Decimal(str(custom_discount.get('value', 0)))
is_percent = custom_discount.get('is_percent', False)
if is_percent:
# Процентная скидка
discount_amount = order.subtotal * discount_value / 100
discount_name = f'Произвольная скидка {discount_value}%'
else:
# Фиксированная скидка
discount_amount = min(discount_value, order.subtotal)
discount_name = f'Произвольная скидка {discount_value} руб.'
order.discount_amount = discount_amount
order.applied_promo_code = discount_name # Сохраняем название в поле промокода
order.save(update_fields=['discount_amount', 'applied_promo_code'])
order.calculate_total()
# Создаем запись о применении в истории скидок
from discounts.models import DiscountApplication
DiscountApplication.objects.create(
order=order,
target='order',
base_amount=order.subtotal,
discount_amount=discount_amount,
final_amount=order.subtotal - discount_amount,
customer=customer,
applied_by=request.user
)
elif manual_discount_id:
from discounts.services.applier import DiscountApplier from discounts.services.applier import DiscountApplier
from discounts.models import Discount from discounts.models import Discount
@@ -1762,22 +1795,40 @@ def validate_promo_code(request):
def calculate_cart_discounts(request): def calculate_cart_discounts(request):
""" """
Рассчитать скидки для корзины POS. Рассчитать скидки для корзины POS.
Поддерживает комбинирование скидок по combine_mode.
Payload JSON: Payload JSON:
{ {
'items': [...], 'items': [...],
'promo_code': str (optional), 'promo_code': str (optional),
'customer_id': int (optional) 'customer_id': int (optional),
'manual_discount_id': int (optional),
'skip_auto_discount': bool (optional)
} }
Returns JSON: Returns JSON:
{ {
'success': true, 'success': true,
'cart_subtotal': float, 'cart_subtotal': float,
'order_discount': {...}, 'order_discounts': [
'item_discounts': [...], {'discount_id': int, 'discount_name': str, 'discount_amount': float, 'combine_mode': str},
...
],
'total_order_discount': float,
'item_discounts': [
{
'cart_index': int,
'discounts': [
{'discount_id': int, 'discount_name': str, 'discount_amount': float, 'combine_mode': str},
...
],
'total_discount': float
},
...
],
'total_discount': float, 'total_discount': float,
'final_total': float 'final_total': float,
'excluded_by': {'id': int, 'name': str} или None
} }
""" """
from discounts.services.calculator import DiscountCalculator from discounts.services.calculator import DiscountCalculator
@@ -1788,6 +1839,7 @@ def calculate_cart_discounts(request):
items_data = data.get('items', []) items_data = data.get('items', [])
promo_code = data.get('promo_code') promo_code = data.get('promo_code')
customer_id = data.get('customer_id') customer_id = data.get('customer_id')
skip_auto_discount = data.get('skip_auto_discount', False)
customer = None customer = None
if customer_id: if customer_id:
@@ -1797,32 +1849,45 @@ def calculate_cart_discounts(request):
pass pass
result = DiscountCalculator.calculate_cart_discounts( result = DiscountCalculator.calculate_cart_discounts(
items_data, promo_code, customer items_data, promo_code, customer, skip_auto_discount=skip_auto_discount
) )
cart_subtotal = Decimal('0') # Форматируем item_discounts для JSON (Decimal -> float)
for item in items_data: formatted_item_discounts = []
cart_subtotal += Decimal(str(item['price'])) * Decimal(str(item['quantity'])) for item in result['item_discounts']:
formatted_discounts = []
for disc in item['discounts']:
formatted_discounts.append({
'discount_id': disc['discount_id'],
'discount_name': disc['discount_name'],
'discount_amount': float(disc['discount_amount']),
'combine_mode': disc['combine_mode']
})
formatted_item_discounts.append({
'cart_index': item['cart_index'],
'discounts': formatted_discounts,
'total_discount': float(item['total_discount'])
})
# Форматируем order_discounts для JSON
formatted_order_discounts = []
for disc in result['order_discounts']:
formatted_order_discounts.append({
'discount_id': disc['discount_id'],
'discount_name': disc['discount_name'],
'discount_amount': float(disc['discount_amount']),
'combine_mode': disc['combine_mode']
})
response_data = { response_data = {
'success': True, 'success': True,
'cart_subtotal': float(cart_subtotal), 'cart_subtotal': float(result['cart_subtotal']),
'order_discount': { 'order_discounts': formatted_order_discounts,
'discount_id': result['order_discount']['discount'].id if result['order_discount'].get('discount') else None, 'total_order_discount': float(result['total_order_discount']),
'discount_name': result['order_discount']['discount'].name if result['order_discount'].get('discount') else None, 'item_discounts': formatted_item_discounts,
'discount_amount': float(result['order_discount']['discount_amount']),
'error': result['order_discount'].get('error'),
} if result['order_discount'] else None,
'item_discounts': [
{
'cart_index': i['cart_index'],
'discount_id': i['discount'].id,
'discount_name': i['discount'].name,
'discount_amount': float(i['discount_amount']),
} for i in result['item_discounts']
],
'total_discount': float(result['total_discount']), 'total_discount': float(result['total_discount']),
'final_total': float(result['final_total']), 'final_total': float(result['final_total']),
'excluded_by': result.get('excluded_by')
} }
return JsonResponse(response_data) return JsonResponse(response_data)
@@ -1881,7 +1946,8 @@ def get_available_discounts(request):
'name': d.name, 'name': d.name,
'discount_type': d.discount_type, 'discount_type': d.discount_type,
'value': float(d.value), 'value': float(d.value),
'min_order_amount': float(d.min_order_amount) if d.min_order_amount else None 'min_order_amount': float(d.min_order_amount) if d.min_order_amount else None,
'combine_mode': d.combine_mode
}) })
# Получаем автоматическую скидку (только одну для отображения) # Получаем автоматическую скидку (только одну для отображения)