diff --git a/myproject/discounts/admin.py b/myproject/discounts/admin.py index 6c17c67..916a4f4 100644 --- a/myproject/discounts/admin.py +++ b/myproject/discounts/admin.py @@ -11,6 +11,7 @@ class DiscountAdmin(admin.ModelAdmin): 'discount_type', 'value_display', 'scope', + 'combine_mode_display', 'is_auto', 'is_active', 'current_usage_count', @@ -20,6 +21,7 @@ class DiscountAdmin(admin.ModelAdmin): list_filter = [ 'discount_type', 'scope', + 'combine_mode', 'is_auto', 'is_active', ] @@ -39,7 +41,7 @@ class DiscountAdmin(admin.ModelAdmin): 'fields': ('name', 'description', 'is_active', 'priority') }), ('Параметры скидки', { - 'fields': ('discount_type', 'value', 'scope') + 'fields': ('discount_type', 'value', 'scope', 'combine_mode') }), ('Ограничения', { 'fields': ( @@ -70,6 +72,23 @@ class DiscountAdmin(admin.ModelAdmin): return f"{obj.value} руб." value_display.short_description = "Значение" + def combine_mode_display(self, obj): + """Отображение режима объединения с иконкой.""" + icons = { + 'stack': '📚', # слои + 'max_only': '🏆', # максимум + 'exclusive': '🚫', # запрет + } + labels = { + 'stack': 'Склад.', + 'max_only': 'Макс.', + 'exclusive': 'Исключ.', + } + icon = icons.get(obj.combine_mode, '') + label = labels.get(obj.combine_mode, obj.combine_mode) + return f'{icon} {label}' if icon else label + combine_mode_display.short_description = 'Объединение' + def validity_period(self, obj): if obj.start_date and obj.end_date: return f"{obj.start_date.date()} - {obj.end_date.date()}" diff --git a/myproject/discounts/migrations/0002_add_combine_mode.py b/myproject/discounts/migrations/0002_add_combine_mode.py new file mode 100644 index 0000000..5c9e161 --- /dev/null +++ b/myproject/discounts/migrations/0002_add_combine_mode.py @@ -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='Режим объединения'), + ), + ] diff --git a/myproject/discounts/models/base.py b/myproject/discounts/models/base.py index e827141..26523b6 100644 --- a/myproject/discounts/models/base.py +++ b/myproject/discounts/models/base.py @@ -18,6 +18,12 @@ class BaseDiscount(models.Model): ('category', 'На категорию товаров'), ] + COMBINE_MODE_CHOICES = [ + ('stack', 'Складывать (суммировать)'), + ('max_only', 'Только максимум'), + ('exclusive', 'Исключающая (отменяет остальные)'), + ] + name = models.CharField( max_length=200, verbose_name="Название скидки" diff --git a/myproject/discounts/models/discount.py b/myproject/discounts/models/discount.py index 91e70fc..b5f1dad 100644 --- a/myproject/discounts/models/discount.py +++ b/myproject/discounts/models/discount.py @@ -48,6 +48,15 @@ class Discount(BaseDiscount): help_text="Применяется автоматически при выполнении условий" ) + # Режим объединения с другими скидками + combine_mode = models.CharField( + max_length=20, + choices=BaseDiscount.COMBINE_MODE_CHOICES, + default='max_only', + verbose_name="Режим объединения", + help_text="stack = суммировать с другими, max_only = применить максимальную, exclusive = отменить остальные" + ) + class Meta: verbose_name = "Скидка" verbose_name_plural = "Скидки" diff --git a/myproject/discounts/services/applier.py b/myproject/discounts/services/applier.py index 5bd0574..f4cc175 100644 --- a/myproject/discounts/services/applier.py +++ b/myproject/discounts/services/applier.py @@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError class DiscountApplier: """ Сервис для применения скидок к заказам. + Поддерживает комбинирование скидок по combine_mode. Все операции атомарны. """ @@ -14,6 +15,7 @@ class DiscountApplier: def apply_promo_code(order, promo_code, user=None): """ Применить промокод к заказу. + Поддерживает комбинирование скидок. Args: order: Order @@ -23,8 +25,8 @@ class DiscountApplier: Returns: dict: { 'success': bool, - 'discount': Discount, - 'discount_amount': Decimal, + 'discounts': [{'discount': Discount, 'amount': Decimal}, ...], + 'total_amount': Decimal, 'error': str } """ @@ -36,9 +38,7 @@ class DiscountApplier: DiscountApplier._remove_order_discount_only(order) # Рассчитываем скидку - result = DiscountCalculator.calculate_order_discount( - order, promo_code - ) + result = DiscountCalculator.calculate_order_discount(order, promo_code) if result['error']: return { @@ -46,15 +46,17 @@ class DiscountApplier: 'error': result['error'] } - discount = result['discount'] promo = result['promo_code'] - discount_amount = result['discount_amount'] + discounts_data = result['discounts'] + total_amount = result['total_amount'] - # Применяем к заказу - order.applied_discount = discount - order.applied_promo_code = promo.code - order.discount_amount = discount_amount - order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount']) + # Применяем первую скидку в applied_discount (для обратной совместимости с Order) + if discounts_data: + first_discount = discounts_data[0]['discount'] + order.applied_discount = first_discount + order.applied_promo_code = promo.code + order.discount_amount = total_amount + order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount']) # Пересчитываем total_amount order.calculate_total() @@ -62,27 +64,31 @@ class DiscountApplier: # Регистрируем использование промокода promo.record_usage(order.customer) - # Создаем запись о применении - DiscountApplication.objects.create( - order=order, - discount=discount, - promo_code=promo, - target='order', - base_amount=order.subtotal, - discount_amount=discount_amount, - final_amount=order.subtotal - discount_amount, - customer=order.customer, - applied_by=user or order.modified_by - ) + # Создаем записи о применении для каждой скидки + for disc_data in discounts_data: + discount = disc_data['discount'] + amount = disc_data['amount'] - # Увеличиваем счетчик использований скидки - discount.current_usage_count += 1 - discount.save(update_fields=['current_usage_count']) + DiscountApplication.objects.create( + order=order, + discount=discount, + promo_code=promo, + target='order', + base_amount=order.subtotal, + discount_amount=amount, + final_amount=order.subtotal - amount, + customer=order.customer, + applied_by=user or order.modified_by + ) + + # Увеличиваем счетчик использований скидки + discount.current_usage_count += 1 + discount.save(update_fields=['current_usage_count']) return { 'success': True, - 'discount': discount, - 'discount_amount': discount_amount + 'discounts': discounts_data, + 'total_amount': total_amount } @staticmethod @@ -90,6 +96,7 @@ class DiscountApplier: def apply_auto_discounts(order, user=None): """ Применить автоматические скидки к заказу и позициям. + Поддерживает комбинирование скидок. Args: order: Order @@ -97,7 +104,7 @@ class DiscountApplier: Returns: dict: { - 'order_discount': {...}, + 'order_discounts': [...], 'item_discounts': [...], 'total_discount': Decimal } @@ -106,71 +113,35 @@ class DiscountApplier: from discounts.services.calculator import DiscountCalculator result = { - 'order_discount': None, + 'order_discounts': [], 'item_discounts': [], 'total_discount': Decimal('0') } - # 1. Применяем скидку на заказ (если есть) + # 1. Применяем скидки на заказ (может быть несколько) order_result = DiscountCalculator.calculate_order_discount(order) - if order_result['discount'] and not order_result['error']: - discount = order_result['discount'] - discount_amount = order_result['discount_amount'] - order.applied_discount = discount - order.discount_amount = discount_amount + if order_result['discounts'] and not order_result['error']: + total_order_amount = order_result['total_amount'] + + # Сохраняем первую скидку в applied_discount (для совместимости) + first_discount_data = order_result['discounts'][0] + order.applied_discount = first_discount_data['discount'] + order.discount_amount = total_order_amount order.save(update_fields=['applied_discount', 'discount_amount']) - # Создаем запись о применении - DiscountApplication.objects.create( - order=order, - discount=discount, - target='order', - base_amount=order.subtotal, - discount_amount=discount_amount, - final_amount=order.subtotal - discount_amount, - customer=order.customer, - applied_by=user - ) + # Создаем записи о применении для всех скидок + for disc_data in order_result['discounts']: + discount = disc_data['discount'] + amount = disc_data['amount'] - # Увеличиваем счетчик - discount.current_usage_count += 1 - discount.save(update_fields=['current_usage_count']) - - result['order_discount'] = { - 'discount': discount, - 'discount_amount': discount_amount - } - result['total_discount'] += discount_amount - - # 2. Применяем скидки на позиции - available_discounts = list(DiscountCalculator.get_available_discounts( - scope='product', - auto_only=True - )) - - for item in order.items.all(): - item_result = DiscountCalculator.calculate_item_discount( - item, available_discounts - ) - - if item_result['discount']: - discount = item_result['discount'] - discount_amount = item_result['discount_amount'] - - item.applied_discount = discount - item.discount_amount = discount_amount - item.save(update_fields=['applied_discount', 'discount_amount']) - - # Создаем запись о применении DiscountApplication.objects.create( order=order, - order_item=item, discount=discount, - target='order_item', - base_amount=item.price * item.quantity, - discount_amount=discount_amount, - final_amount=item.get_total_price(), + target='order', + base_amount=order.subtotal, + discount_amount=amount, + final_amount=order.subtotal - amount, customer=order.customer, applied_by=user ) @@ -179,12 +150,57 @@ class DiscountApplier: discount.current_usage_count += 1 discount.save(update_fields=['current_usage_count']) + result['order_discounts'] = order_result['discounts'] + result['total_discount'] += total_order_amount + + # 2. Применяем скидки на позиции + available_product_discounts = list(DiscountCalculator.get_available_discounts( + scope='product', + auto_only=True + )) + + for item in order.items.all(): + item_result = DiscountCalculator.calculate_item_discount( + item, available_product_discounts + ) + + if item_result['discounts']: + total_item_amount = item_result['total_amount'] + + # Сохраняем первую скидку в applied_discount (для совместимости) + first_discount_data = item_result['discounts'][0] + item.applied_discount = first_discount_data['discount'] + item.discount_amount = total_item_amount + item.save(update_fields=['applied_discount', 'discount_amount']) + + # Создаем записи о применении для всех скидок + base_amount = item.price * item.quantity + for disc_data in item_result['discounts']: + discount = disc_data['discount'] + amount = disc_data['amount'] + + DiscountApplication.objects.create( + order=order, + order_item=item, + discount=discount, + target='order_item', + base_amount=base_amount, + discount_amount=amount, + final_amount=item.get_total_price(), + customer=order.customer, + applied_by=user + ) + + # Увеличиваем счетчик + discount.current_usage_count += 1 + discount.save(update_fields=['current_usage_count']) + result['item_discounts'].append({ 'item': item, - 'discount': discount, - 'discount_amount': discount_amount + 'discounts': item_result['discounts'], + 'total_amount': total_item_amount }) - result['total_discount'] += discount_amount + result['total_discount'] += total_item_amount # Пересчитываем итоговую сумму order.calculate_total() @@ -208,8 +224,9 @@ class DiscountApplier: discount_amount=Decimal('0') ) - # Удаляем записи о применении (опционально - для истории можно оставить) - # DiscountApplication.objects.filter(order=order).delete() + # Удаляем записи о применении + from discounts.models import DiscountApplication + DiscountApplication.objects.filter(order=order).delete() # Пересчитываем order.calculate_total() @@ -285,6 +302,11 @@ class DiscountApplier: Args: order: Order """ + from discounts.models import DiscountApplication + + # Удаляем записи о применении скидок к заказу + DiscountApplication.objects.filter(order=order, target='order').delete() + order.applied_discount = None order.applied_promo_code = None order.discount_amount = Decimal('0') diff --git a/myproject/discounts/services/calculator.py b/myproject/discounts/services/calculator.py index 1f8d030..06f99d5 100644 --- a/myproject/discounts/services/calculator.py +++ b/myproject/discounts/services/calculator.py @@ -3,6 +3,82 @@ from django.db import models from django.utils import timezone +class DiscountCombiner: + """ + Утилитный класс для комбинирования скидок по разным режимам. + """ + + @staticmethod + def combine_discounts(discounts, base_amount): + """ + Комбинировать несколько скидок по их combine_mode. + + Args: + discounts: Список объектов Discount + base_amount: Базовая сумма для расчета + + Returns: + dict: { + 'total_discount': Decimal, + 'applied_discounts': [{'discount': Discount, 'amount': Decimal}, ...], + 'excluded_by': Discount или None, + 'combine_mode_used': str + } + """ + if not discounts: + return { + 'total_discount': Decimal('0'), + 'applied_discounts': [], + 'excluded_by': None, + 'combine_mode_used': 'none' + } + + # 1. Проверяем наличие exclusive скидки + exclusive_discounts = [d for d in discounts if d.combine_mode == 'exclusive'] + if exclusive_discounts: + # Применяем только первую exclusive скидку + discount = exclusive_discounts[0] + amount = discount.calculate_discount_amount(base_amount) + return { + 'total_discount': amount, + 'applied_discounts': [{'discount': discount, 'amount': amount}], + 'excluded_by': discount, + 'combine_mode_used': 'exclusive' + } + + # 2. Разделяем скидки на stack и max_only + stack_discounts = [d for d in discounts if d.combine_mode == 'stack'] + max_only_discounts = [d for d in discounts if d.combine_mode == 'max_only'] + + result = { + 'total_discount': Decimal('0'), + 'applied_discounts': [], + 'excluded_by': None, + 'combine_mode_used': 'combined' + } + + # 3. Для max_only применяем только максимальную + if max_only_discounts: + max_discount = max( + max_only_discounts, + key=lambda d: d.calculate_discount_amount(base_amount) + ) + amount = max_discount.calculate_discount_amount(base_amount) + result['applied_discounts'].append({'discount': max_discount, 'amount': amount}) + result['total_discount'] += amount + + # 4. Для stack суммируем все + for discount in stack_discounts: + amount = discount.calculate_discount_amount(base_amount) + result['applied_discounts'].append({'discount': discount, 'amount': amount}) + result['total_discount'] += amount + + # 5. Ограничиваем итоговую скидку базовой суммой + result['total_discount'] = min(result['total_discount'], base_amount) + + return result + + class DiscountCalculator: """ Калькулятор скидок для заказов. @@ -52,7 +128,7 @@ class DiscountCalculator: @staticmethod def calculate_order_discount(order, promo_code=None): """ - Рассчитать скидку на весь заказ. + Рассчитать скидку на весь заказ с поддержкой комбинирования. Args: order: Order объект @@ -60,9 +136,9 @@ class DiscountCalculator: Returns: dict: { - 'discount': Discount или None, + 'discounts': [{'discount': Discount, 'amount': Decimal}, ...], + 'total_amount': Decimal, 'promo_code': PromoCode или None, - 'discount_amount': Decimal, 'error': str или None } """ @@ -71,12 +147,14 @@ class DiscountCalculator: subtotal = Decimal(str(order.subtotal)) result = { - 'discount': None, + 'discounts': [], + 'total_amount': Decimal('0'), 'promo_code': None, - 'discount_amount': Decimal('0'), 'error': None } + applicable_discounts = [] + # 1. Проверяем промокод первым if promo_code: is_valid, promo, error = DiscountValidator.validate_promo_code( @@ -89,38 +167,36 @@ class DiscountCalculator: discount = promo.discount - # Проверяем scope скидки if discount.scope != 'order': result['error'] = "Этот промокод применяется только к товарам" return result - result['discount'] = discount result['promo_code'] = promo - result['discount_amount'] = discount.calculate_discount_amount(subtotal) - return result + applicable_discounts.append(discount) + else: + # 2. Если нет промокода, собираем все автоматические скидки + auto_discounts = DiscountCalculator.get_available_discounts( + scope='order', + auto_only=True + ) - # 2. Если нет промокода, проверяем автоматические скидки - auto_discounts = DiscountCalculator.get_available_discounts( - scope='order', - auto_only=True - ) + for discount in auto_discounts: + if discount.min_order_amount and subtotal < discount.min_order_amount: + continue + applicable_discounts.append(discount) - for discount in auto_discounts: - # Проверяем мин. сумму заказа - if discount.min_order_amount and subtotal < discount.min_order_amount: - continue - - # Применяем первую подходящую автоматическую скидку - result['discount'] = discount - result['discount_amount'] = discount.calculate_discount_amount(subtotal) - break + # 3. Комбинируем скидки + if applicable_discounts: + combination = DiscountCombiner.combine_discounts(applicable_discounts, subtotal) + result['discounts'] = combination['applied_discounts'] + result['total_amount'] = combination['total_discount'] return result @staticmethod def calculate_item_discount(order_item, available_discounts=None): """ - Рассчитать скидку на позицию заказа. + Рассчитать скидку на позицию заказа с поддержкой комбинирования. Args: order_item: OrderItem объект @@ -128,13 +204,13 @@ class DiscountCalculator: Returns: dict: { - 'discount': Discount или None, - 'discount_amount': Decimal, + 'discounts': [{'discount': Discount, 'amount': Decimal}, ...], + 'total_amount': Decimal, } """ result = { - 'discount': None, - 'discount_amount': Decimal('0') + 'discounts': [], + 'total_amount': Decimal('0') } # Определяем продукт @@ -149,6 +225,10 @@ class DiscountCalculator: base_amount = Decimal(str(order_item.price)) * Decimal(str(order_item.quantity)) + # Собираем все применимые скидки + applicable_discounts = [] + + # Скидки на товары if not available_discounts: available_discounts = DiscountCalculator.get_available_discounts( scope='product', @@ -156,48 +236,54 @@ class DiscountCalculator: ) for discount in available_discounts: - # Проверяем, применяется ли скидка к этому товару - if not discount.applies_to_product(product): - continue + if discount.applies_to_product(product): + applicable_discounts.append(discount) - result['discount'] = discount - result['discount_amount'] = discount.calculate_discount_amount(base_amount) - break + # Скидки по категориям + category_discounts = DiscountCalculator.get_available_discounts( + scope='category', + auto_only=True + ) - # Проверяем скидки по категориям - if not result['discount']: - category_discounts = DiscountCalculator.get_available_discounts( - scope='category', - auto_only=True - ) + for discount in category_discounts: + if discount.applies_to_product(product) and discount not in applicable_discounts: + applicable_discounts.append(discount) - for discount in category_discounts: - if discount.applies_to_product(product): - result['discount'] = discount - result['discount_amount'] = discount.calculate_discount_amount(base_amount) - break + # Комбинируем скидки + if applicable_discounts: + combination = DiscountCombiner.combine_discounts(applicable_discounts, base_amount) + result['discounts'] = combination['applied_discounts'] + result['total_amount'] = combination['total_discount'] return result @staticmethod - def calculate_cart_discounts(cart_items, promo_code=None, customer=None): + def calculate_cart_discounts(cart_items, promo_code=None, customer=None, skip_auto_discount=False): """ Рассчитать скидки для корзины (применяется в POS до создания заказа). + Поддерживает комбинирование скидок по combine_mode. Args: cart_items: Список словарей {'type': 'product'|'kit', 'id': int, 'quantity': Decimal, 'price': Decimal} promo_code: Промокод (опционально) customer: Customer (опционально) + skip_auto_discount: Пропустить автоматические скидки (опционально) Returns: dict: { - 'order_discount': {...}, # Как в calculate_order_discount + 'order_discounts': [ + {'discount_id': int, 'discount_name': str, 'discount_amount': Decimal, 'combine_mode': str}, + ... + ], + 'total_order_discount': Decimal, 'item_discounts': [ - {'cart_index': int, 'discount': Discount, 'discount_amount': Decimal}, + {'cart_index': int, 'discounts': [...], 'total_discount': Decimal}, ... ], 'total_discount': Decimal, - 'final_total': Decimal + 'final_total': Decimal, + 'cart_subtotal': Decimal, + 'excluded_by': {'id': int, 'name': str} или None } """ from products.models import Product, ProductKit @@ -206,6 +292,18 @@ class DiscountCalculator: for item in cart_items: cart_subtotal += Decimal(str(item['price'])) * Decimal(str(item['quantity'])) + # Если нужно пропустить авто-скидки, возвращаем пустой результат + if skip_auto_discount: + return { + 'order_discounts': [], + 'total_order_discount': Decimal('0'), + 'item_discounts': [], + 'total_discount': Decimal('0'), + 'final_total': cart_subtotal, + 'cart_subtotal': cart_subtotal, + 'excluded_by': None + } + # Создаем фейковый объект для расчета скидки на заказ class FakeOrder: def __init__(self, subtotal, customer): @@ -214,12 +312,36 @@ class DiscountCalculator: fake_order = FakeOrder(cart_subtotal, customer) - # Скидка на заказ - order_discount = DiscountCalculator.calculate_order_discount( - fake_order, promo_code - ) + # Скидка на заказ (с комбинированием) + order_result = DiscountCalculator.calculate_order_discount(fake_order, promo_code) - # Скидки на позиции + # Форматируем order_discounts для ответа + formatted_order_discounts = [] + excluded_by = None + + for disc_data in order_result['discounts']: + discount = disc_data['discount'] + formatted_order_discounts.append({ + 'discount_id': discount.id, + 'discount_name': discount.name, + 'discount_amount': disc_data['amount'], + 'discount_type': discount.discount_type, + 'discount_value': discount.value, + 'combine_mode': discount.combine_mode + }) + + # Проверяем исключающую скидку + if order_result['discounts']: + # Определяем exclusive скидку + for disc_data in order_result['discounts']: + if disc_data['discount'].combine_mode == 'exclusive': + excluded_by = { + 'id': disc_data['discount'].id, + 'name': disc_data['discount'].name + } + break + + # Скидки на позиции (с комбинированием) item_discounts = [] items_total_discount = Decimal('0') @@ -252,38 +374,51 @@ class DiscountCalculator: base_amount = Decimal(str(item['price'])) * Decimal(str(item['quantity'])) - # Проверяем скидки на товары - applied_discount = None - discount_amount = Decimal('0') + # Собираем все применимые скидки для этого товара + applicable_item_discounts = [] for discount in available_product_discounts: if discount.applies_to_product(product): - applied_discount = discount - discount_amount = discount.calculate_discount_amount(base_amount) - break + applicable_item_discounts.append(discount) - # Если не нашли скидку на товар, проверяем категории - if not applied_discount: - for discount in available_category_discounts: - if discount.applies_to_product(product): - applied_discount = discount - discount_amount = discount.calculate_discount_amount(base_amount) - break + for discount in available_category_discounts: + if discount.applies_to_product(product) and discount not in applicable_item_discounts: + applicable_item_discounts.append(discount) - if applied_discount: - item_discounts.append({ - 'cart_index': idx, - 'discount': applied_discount, - 'discount_amount': discount_amount - }) - items_total_discount += discount_amount + # Комбинируем скидки для позиции + if applicable_item_discounts: + combination = DiscountCombiner.combine_discounts( + applicable_item_discounts, + base_amount + ) - total_discount = order_discount['discount_amount'] + items_total_discount + formatted_discounts = [] + for disc_data in combination['applied_discounts']: + discount = disc_data['discount'] + formatted_discounts.append({ + 'discount_id': discount.id, + 'discount_name': discount.name, + 'discount_amount': disc_data['amount'], + 'combine_mode': discount.combine_mode + }) + + if formatted_discounts: + item_discounts.append({ + 'cart_index': idx, + 'discounts': formatted_discounts, + 'total_discount': combination['total_discount'] + }) + items_total_discount += combination['total_discount'] + + total_discount = order_result['total_amount'] + items_total_discount final_total = max(cart_subtotal - total_discount, Decimal('0')) return { - 'order_discount': order_discount, + 'order_discounts': formatted_order_discounts, + 'total_order_discount': order_result['total_amount'], 'item_discounts': item_discounts, 'total_discount': total_discount, - 'final_total': final_total + 'final_total': final_total, + 'cart_subtotal': cart_subtotal, + 'excluded_by': excluded_by } diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index ffc47c3..6746f9d 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -2294,12 +2294,15 @@ let paymentWidget = null; // Переменные состояния скидок let appliedPromoCode = null; // примененный промокод -let appliedManualDiscount = null; // выбранная вручную скидка +let appliedManualDiscount = null; // выбранная вручную скидка (из списка) +let appliedCustomDiscount = null; // произвольная скидка {value: number, isPercent: boolean} let availableDiscounts = []; // список доступных скидок +let skipAutoDiscount = false; // флаг отмены авто-скидки let cartDiscounts = { - orderDiscount: null, // скидка на заказ + orderDiscounts: [], // скидки на заказ (теперь массив) itemDiscounts: [], // скидки на позиции - totalDiscount: 0 // общая сумма скидки + totalDiscount: 0, // общая сумма скидки + excludedBy: null // исключающая скидка }; // При открытии модалки checkout @@ -2372,7 +2375,9 @@ function reinitPaymentWidget(mode) { function resetDiscounts() { appliedPromoCode = null; appliedManualDiscount = null; + appliedCustomDiscount = null; availableDiscounts = []; + skipAutoDiscount = false; cartDiscounts = { orderDiscount: null, itemDiscounts: [], @@ -2393,6 +2398,14 @@ function resetDiscounts() { document.getElementById('manualDiscountContainer').style.display = 'none'; document.getElementById('discountsSummary').style.display = 'none'; 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({ items: items, 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) { cartDiscounts.totalDiscount = result.total_discount || 0; - cartDiscounts.orderDiscount = result.order_discount; + cartDiscounts.orderDiscounts = result.order_discounts || []; cartDiscounts.itemDiscounts = result.item_discounts || []; + cartDiscounts.excludedBy = result.excluded_by || null; updateDiscountsUI(result); } @@ -2434,6 +2449,26 @@ async function checkAutoDiscounts() { } } +// Получить иконку для режима объединения +function getCombineModeIcon(mode) { + const icons = { + 'stack': '', + 'max_only': '', + 'exclusive': '' + }; + return icons[mode] || ''; +} + +// Получить описание режима объединения +function getCombineModeTitle(mode) { + const titles = { + 'stack': 'Складывается с другими скидками', + 'max_only': 'Применяется только максимальная из этого типа', + 'exclusive': 'Отменяет все другие скидки' + }; + return titles[mode] || mode; +} + // Обновить UI скидок function updateDiscountsUI(result) { const autoContainer = document.getElementById('autoDiscountsContainer'); @@ -2447,33 +2482,57 @@ function updateDiscountsUI(result) { let hasDiscounts = false; - // 1. Скидка на заказ (автоматическая) - if (result.order_discount && result.order_discount.discount_id) { + // 1. Скидки на заказ (теперь может быть несколько) + const orderDiscounts = result.order_discounts || []; + if (orderDiscounts.length > 0) { hasDiscounts = true; autoContainer.style.display = 'block'; - const div = document.createElement('div'); - div.className = 'd-flex justify-content-between'; - div.innerHTML = ` - ${result.order_discount.discount_name} - -${result.order_discount.discount_amount.toFixed(2)} руб. - `; - autoList.appendChild(div); + + orderDiscounts.forEach(disc => { + const div = document.createElement('div'); + div.className = 'd-flex justify-content-between align-items-center w-100'; + const modeIcon = getCombineModeIcon(disc.combine_mode); + div.innerHTML = ` + ${modeIcon} ${disc.discount_name} + -${disc.discount_amount.toFixed(2)} руб. + `; + autoList.appendChild(div); + }); + + // Показываем информацию о комбинировании + if (orderDiscounts.length > 1) { + const infoDiv = document.createElement('div'); + infoDiv.className = 'text-muted small mt-1'; + infoDiv.innerHTML = ' Скидки скомбинированы'; + autoList.appendChild(infoDiv); + } + + // Показываем кнопку отмены (только если еще не пропущена) + document.getElementById('skipAutoDiscountBtn').style.display = 'block'; } else { autoContainer.style.display = 'none'; + document.getElementById('skipAutoDiscountBtn').style.display = 'none'; } - // 2. Скидки на позиции - if (result.item_discounts && result.item_discounts.length > 0) { + // 2. Скидки на позиции (новый формат с массивом discounts) + const itemDiscounts = result.item_discounts || []; + if (itemDiscounts.length > 0) { hasDiscounts = true; - result.item_discounts.forEach(item => { - const div = document.createElement('div'); - div.className = 'text-muted'; - div.innerHTML = `• ${item.discount_name}: -${item.discount_amount.toFixed(2)} руб.`; - itemBreakdown.appendChild(div); + 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'); + div.className = 'text-muted small'; + div.innerHTML = `• ${discNames}: -${item.total_discount.toFixed(2)} руб.`; + itemBreakdown.appendChild(div); + } }); } - // 3. Ручная скидка + // 3. Ручная скидка (из списка) if (appliedManualDiscount) { hasDiscounts = true; document.getElementById('manualDiscountContainer').style.display = 'block'; @@ -2484,19 +2543,54 @@ function updateDiscountsUI(result) { 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 = ` + Произвольная скидка ${discountText} + `; + itemBreakdown.appendChild(customDiv); + } + // Показываем/скрываем summary if (hasDiscounts) { summaryDiv.style.display = 'block'; document.getElementById('discountsSubtotal').textContent = (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 = - '-' + (result.total_discount || 0).toFixed(2) + ' руб.'; + '-' + totalDiscount.toFixed(2) + ' руб.'; } else { 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) + ' руб.'; // Пересчитываем виджет оплаты @@ -2537,12 +2631,15 @@ function renderDiscountsDropdown(discounts) { const li = document.createElement('li'); const valueText = d.discount_type === 'percentage' ? `${d.value}%` : `${d.value} руб.`; 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'); a.href = '#'; a.className = 'dropdown-item d-flex justify-content-between'; + a.title = modeTitle; a.innerHTML = ` - ${d.name} + ${modeIcon} ${d.name} ${valueText} ${minText} `; a.onclick = (e) => { @@ -2577,6 +2674,12 @@ document.getElementById('removeManualDiscountBtn').addEventListener('click', asy await loadAvailableDiscounts(); }); +// Отменить автоматическую скидку +document.getElementById('skipAutoDiscountBtn').addEventListener('click', async () => { + skipAutoDiscount = true; + await checkAutoDiscounts(); +}); + // Применить промокод async function applyPromoCode() { const code = document.getElementById('promoCodeInput').value.trim().toUpperCase(); @@ -2657,8 +2760,9 @@ async function recalculateDiscountsWithPromo(promoCode) { if (result.success) { cartDiscounts.totalDiscount = result.total_discount || 0; - cartDiscounts.orderDiscount = result.order_discount; + cartDiscounts.orderDiscounts = result.order_discounts || []; cartDiscounts.itemDiscounts = result.item_discounts || []; + cartDiscounts.excludedBy = result.excluded_by || null; updateDiscountsUI(result); } } 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) { const paymentMethods = [ { id: 1, code: 'account_balance', name: 'С баланса счёта' }, @@ -2778,7 +2966,11 @@ async function handleCheckoutSubmit(paymentsData) { payments: paymentsData, notes: document.getElementById('orderNote').value.trim(), 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 }; // Отправляем на сервер diff --git a/myproject/pos/templates/pos/terminal.html b/myproject/pos/templates/pos/terminal.html index ec443b1..958ede3 100644 --- a/myproject/pos/templates/pos/terminal.html +++ b/myproject/pos/templates/pos/terminal.html @@ -349,12 +349,17 @@