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',
|
'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()}"
|
||||||
|
|||||||
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', 'На категорию товаров'),
|
('category', 'На категорию товаров'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
COMBINE_MODE_CHOICES = [
|
||||||
|
('stack', 'Складывать (суммировать)'),
|
||||||
|
('max_only', 'Только максимум'),
|
||||||
|
('exclusive', 'Исключающая (отменяет остальные)'),
|
||||||
|
]
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
verbose_name="Название скидки"
|
verbose_name="Название скидки"
|
||||||
|
|||||||
@@ -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 = "Скидки"
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
// Отправляем на сервер
|
// Отправляем на сервер
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
# Получаем автоматическую скидку (только одну для отображения)
|
# Получаем автоматическую скидку (только одну для отображения)
|
||||||
|
|||||||
Reference in New Issue
Block a user