From 6978f4e59f6d91d17a60df9e795ed18c48a87557 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sun, 11 Jan 2026 00:30:55 +0300 Subject: [PATCH] =?UTF-8?q?feat(pos):=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=D0=B8=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=20=D1=81=D0=BA=D0=B8=D0=B4=D0=BE=D0=BA=20=D0=B2=20?= =?UTF-8?q?POS=20=D1=82=D0=B5=D1=80=D0=BC=D0=B8=D0=BD=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API endpoints: - POST /api/discounts/validate-promo/: валидация промокода - POST /api/discounts/calculate/: расчёт скидок для корзины Обновлён pos_checkout: - добавлен параметр promo_code в payload - автоматическое применение скидок к заказу UI (terminal.html): - секция скидок в модальном окне оплаты - поле ввода промокода - отображение автоматических скидок - кнопки применения/удаления промокода JavaScript (terminal.js): - переменные состояния скидок - функции applyPromoCode, removePromoCode - checkAutoDiscounts: проверка автоматических скидок - updateCheckoutTotalWithDiscounts: пересчёт итога - обработчики кнопок промокода Co-Authored-By: Claude Opus 4.5 --- myproject/pos/static/pos/js/terminal.js | 224 +++++++++++++++++++++- myproject/pos/templates/pos/terminal.html | 23 +++ myproject/pos/urls.py | 7 + myproject/pos/views.py | 167 +++++++++++++++- 4 files changed, 415 insertions(+), 6 deletions(-) diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index 8aa5ed0..87ced6c 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -2292,11 +2292,22 @@ function renderCheckoutModal() { let paymentWidget = null; +// Переменные состояния скидок +let appliedPromoCode = null; // примененный промокод +let cartDiscounts = { + orderDiscount: null, // скидка на заказ + itemDiscounts: [], // скидки на позиции + totalDiscount: 0 // общая сумма скидки +}; + // При открытии модалки checkout -document.getElementById('checkoutModal').addEventListener('show.bs.modal', () => { +document.getElementById('checkoutModal').addEventListener('show.bs.modal', async () => { const customer = selectedCustomer || SYSTEM_CUSTOMER; const walletBalance = customer.wallet_balance || 0; + // Сбрасываем скидки + resetDiscounts(); + // Показываем баланс кошелька const walletDiv = document.getElementById('checkoutWalletBalance'); if (customer.id !== SYSTEM_CUSTOMER.id) { @@ -2312,11 +2323,16 @@ document.getElementById('checkoutModal').addEventListener('show.bs.modal', () => totalAmount += item.qty * item.price; }); - document.getElementById('checkoutFinalPrice').textContent = formatMoney(totalAmount) + ' руб.'; + // Проверяем автоматические скидки + await checkAutoDiscounts(); + + // Применяем скидки к итоговой сумме + const finalTotal = Math.max(0, totalAmount - cartDiscounts.totalDiscount); + document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.'; // Инициализируем виджет в single mode initPaymentWidget('single', { - order: { total: totalAmount, amount_due: totalAmount, amount_paid: 0 }, + order: { total: finalTotal, amount_due: finalTotal, amount_paid: 0 }, customer: { id: customer.id, name: customer.name, wallet_balance: walletBalance } }); }); @@ -2345,6 +2361,205 @@ function reinitPaymentWidget(mode) { }); } +// ===== ФУНКЦИИ ДЛЯ РАБОТЫ СО СКИДКАМИ ===== + +// Сброс скидок +function resetDiscounts() { + appliedPromoCode = null; + cartDiscounts = { + orderDiscount: null, + itemDiscounts: [], + totalDiscount: 0 + }; + + // Сбрасываем UI + document.getElementById('promoCodeInput').value = ''; + document.getElementById('promoCodeError').style.display = 'none'; + document.getElementById('promoCodeError').textContent = ''; + document.getElementById('promoCodeSuccess').style.display = 'none'; + document.getElementById('promoCodeSuccess').textContent = ''; + document.getElementById('removePromoBtn').style.display = 'none'; + document.getElementById('autoDiscounts').style.display = 'none'; + document.getElementById('autoDiscountsText').textContent = ''; +} + +// Проверить автоматические скидки +async function checkAutoDiscounts() { + try { + const items = Array.from(cart.values()).map(item => ({ + type: item.type, + id: item.id, + quantity: item.qty, + price: item.price + })); + + const customer = selectedCustomer || SYSTEM_CUSTOMER; + + const response = await fetch('/pos/api/discounts/calculate/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + items: items, + customer_id: customer.id !== SYSTEM_CUSTOMER.id ? customer.id : null + }) + }); + + const result = await response.json(); + + if (result.success) { + // Сохраняем скидки + cartDiscounts.totalDiscount = result.total_discount || 0; + cartDiscounts.orderDiscount = result.order_discount; + + // Показываем автоматические скидки + if (result.order_discount && result.order_discount.discount_id) { + document.getElementById('autoDiscounts').style.display = 'block'; + document.getElementById('autoDiscountsText').textContent = + `${result.order_discount.discount_name}: -${result.order_discount.discount_amount.toFixed(2)} руб.`; + } + } + } catch (error) { + console.error('Ошибка при проверке автоматических скидок:', error); + } +} + +// Применить промокод +async function applyPromoCode() { + const code = document.getElementById('promoCodeInput').value.trim().toUpperCase(); + if (!code) return; + + // Вычисляем сумму корзины + let cartTotal = 0; + cart.forEach((item) => { + cartTotal += item.qty * item.price; + }); + + try { + const response = await fetch('/pos/api/discounts/validate-promo/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + promo_code: code, + cart_total: cartTotal + }) + }); + + const result = await response.json(); + + if (result.success) { + appliedPromoCode = result.promo_code; + + // Пересчитываем скидки с промокодом + await recalculateDiscountsWithPromo(code); + + // Показываем успех + document.getElementById('promoCodeSuccess').textContent = + `Скидка: ${result.promo_code.discount_name} (${result.promo_code.discount_type === 'percentage' ? result.promo_code.discount_value + '%' : result.promo_code.discount_value + ' руб.'})`; + document.getElementById('promoCodeSuccess').style.display = 'block'; + document.getElementById('promoCodeError').style.display = 'none'; + document.getElementById('removePromoBtn').style.display = 'inline-block'; + + // Обновляем итоговую сумму + updateCheckoutTotalWithDiscounts(); + } else { + // Показываем ошибку + document.getElementById('promoCodeError').textContent = result.error || 'Неверный промокод'; + document.getElementById('promoCodeError').style.display = 'block'; + document.getElementById('promoCodeSuccess').style.display = 'none'; + } + } catch (error) { + console.error('Ошибка при применении промокода:', error); + document.getElementById('promoCodeError').textContent = 'Ошибка при проверке промокода'; + document.getElementById('promoCodeError').style.display = 'block'; + } +} + +// Пересчитать скидки с промокодом +async function recalculateDiscountsWithPromo(promoCode) { + try { + const items = Array.from(cart.values()).map(item => ({ + type: item.type, + id: item.id, + quantity: item.qty, + price: item.price + })); + + const customer = selectedCustomer || SYSTEM_CUSTOMER; + + const response = await fetch('/pos/api/discounts/calculate/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + items: items, + promo_code: promoCode, + customer_id: customer.id !== SYSTEM_CUSTOMER.id ? customer.id : null + }) + }); + + const result = await response.json(); + + if (result.success) { + cartDiscounts.totalDiscount = result.total_discount || 0; + cartDiscounts.orderDiscount = result.order_discount; + cartDiscounts.itemDiscounts = result.item_discounts || []; + } + } catch (error) { + console.error('Ошибка при пересчёте скидок:', error); + } +} + +// Обновить итоговую сумму с учётом скидок +function updateCheckoutTotalWithDiscounts() { + let cartTotal = 0; + cart.forEach((item) => { + cartTotal += item.qty * item.price; + }); + + const finalTotal = Math.max(0, cartTotal - cartDiscounts.totalDiscount); + document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.'; + + // Пересоздаём платёжный виджет с новой суммой + const customer = selectedCustomer || SYSTEM_CUSTOMER; + initPaymentWidget('single', { + order: { total: finalTotal, amount_due: finalTotal, amount_paid: 0 }, + customer: { id: customer.id, name: customer.name, wallet_balance: customer.wallet_balance || 0 } + }); +} + +// Удалить промокод +function removePromoCode() { + appliedPromoCode = null; + + // Пересчитываем без промокода + recalculateDiscountsWithPromo(null).then(() => { + document.getElementById('promoCodeInput').value = ''; + document.getElementById('removePromoBtn').style.display = 'none'; + document.getElementById('promoCodeSuccess').style.display = 'none'; + updateCheckoutTotalWithDiscounts(); + }); +} + +// Обработчики кнопок промокода +document.getElementById('applyPromoBtn').addEventListener('click', applyPromoCode); + +document.getElementById('removePromoBtn').addEventListener('click', removePromoCode); + +document.getElementById('promoCodeInput').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + applyPromoCode(); + } +}); + async function initPaymentWidget(mode, data) { const paymentMethods = [ { id: 1, code: 'account_balance', name: 'С баланса счёта' }, @@ -2413,7 +2628,8 @@ async function handleCheckoutSubmit(paymentsData) { return itemData; }), payments: paymentsData, - notes: document.getElementById('orderNote').value.trim() + notes: document.getElementById('orderNote').value.trim(), + promo_code: appliedPromoCode || null }; // Отправляем на сервер diff --git a/myproject/pos/templates/pos/terminal.html b/myproject/pos/templates/pos/terminal.html index cb70d7a..909f6e5 100644 --- a/myproject/pos/templates/pos/terminal.html +++ b/myproject/pos/templates/pos/terminal.html @@ -345,6 +345,29 @@
0.00 руб.
+ +
+ Скидки: + + + +
+ + + +
+ + +
+
diff --git a/myproject/pos/urls.py b/myproject/pos/urls.py index 4056778..b73b99a 100644 --- a/myproject/pos/urls.py +++ b/myproject/pos/urls.py @@ -37,4 +37,11 @@ urlpatterns = [ path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'), # Создать заказ и провести оплату в POS [POST] path('api/checkout/', views.pos_checkout, name='pos-checkout'), + # ============================================ + # DISCOUNT API + # ============================================ + # Валидировать промокод [POST] + path('api/discounts/validate-promo/', views.validate_promo_code, name='validate-promo'), + # Рассчитать скидки для корзины [POST] + path('api/discounts/calculate/', views.calculate_cart_discounts, name='calculate-discounts'), ] \ No newline at end of file diff --git a/myproject/pos/views.py b/myproject/pos/views.py index 183c433..a8c4e7f 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -1400,7 +1400,8 @@ def pos_checkout(request): {"payment_method": "cash"|"card"|"online"|"account_balance", "amount": float, "notes": str}, ... ], - "notes": str (optional) + "notes": str (optional), + "promo_code": str (optional) - Промокод для скидки } """ from orders.models import Order, OrderItem, OrderStatus @@ -1421,6 +1422,7 @@ def pos_checkout(request): items_data = body.get('items', []) payments_data = body.get('payments', []) order_notes = body.get('notes', '') + promo_code = body.get('promo_code') # Промокод для скидки if not customer_id: return JsonResponse({'success': False, 'error': 'Не указан клиент'}, status=400) @@ -1551,7 +1553,22 @@ def pos_checkout(request): order.refresh_from_db() order.calculate_total() - # 4. Проводим платежи + # 4. Применяем скидки + if promo_code: + from discounts.services.applier import DiscountApplier + apply_result = DiscountApplier.apply_promo_code( + order=order, + promo_code=promo_code, + user=request.user + ) + if not apply_result['success']: + raise ValidationError(apply_result['error']) + else: + # Применяем автоматические скидки + from discounts.services.applier import DiscountApplier + DiscountApplier.apply_auto_discounts(order, user=request.user) + + # 5. Проводим платежи payments_list = [] for payment_data in payments_data: payments_list.append({ @@ -1652,3 +1669,149 @@ def create_order_draft(request): except Exception as e: logger.error(f'Ошибка при создании черновика заказа: {str(e)}', exc_info=True) return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500) + + +# ============================================ +# DISCOUNT API +# ============================================ + +@login_required +@require_http_methods(["POST"]) +def validate_promo_code(request): + """ + Валидировать промокод. + + Payload JSON: + { + 'promo_code': str, + 'cart_total': decimal (optional) + } + + Returns JSON: + { + 'success': true/false, + 'promo_code': {...}, + 'error': str + } + """ + from discounts.services.validator import DiscountValidator + from customers.models import Customer + + try: + data = json.loads(request.body) + promo_code = data.get('promo_code', '') + cart_total = data.get('cart_total', '0') + + # Получаем текущего клиента из Redis + from django.core.cache import cache + cart_key = f'pos:customer:{request.user.id}' + customer_id = cache.get(cart_key) + + customer = None + if customer_id: + try: + customer = Customer.objects.get(id=customer_id) + except Customer.DoesNotExist: + pass + + is_valid, promo, error = DiscountValidator.validate_promo_code( + promo_code, customer, Decimal(str(cart_total)) if cart_total else None + ) + + if is_valid: + return JsonResponse({ + 'success': True, + 'promo_code': { + 'code': promo.code, + 'discount_id': promo.discount.id, + 'discount_name': promo.discount.name, + 'discount_type': promo.discount.discount_type, + 'discount_value': float(promo.discount.value), + 'scope': promo.discount.scope, + } + }) + else: + return JsonResponse({'success': False, 'error': error}) + + except json.JSONDecodeError: + return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400) + except Exception as e: + logger.error(f'Ошибка при валидации промокода: {str(e)}', exc_info=True) + return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500) + + +@login_required +@require_http_methods(["POST"]) +def calculate_cart_discounts(request): + """ + Рассчитать скидки для корзины POS. + + Payload JSON: + { + 'items': [...], + 'promo_code': str (optional), + 'customer_id': int (optional) + } + + Returns JSON: + { + 'success': true, + 'cart_subtotal': float, + 'order_discount': {...}, + 'item_discounts': [...], + 'total_discount': float, + 'final_total': float + } + """ + from discounts.services.calculator import DiscountCalculator + from customers.models import Customer + + try: + data = json.loads(request.body) + items_data = data.get('items', []) + promo_code = data.get('promo_code') + customer_id = data.get('customer_id') + + customer = None + if customer_id: + try: + customer = Customer.objects.get(id=customer_id) + except Customer.DoesNotExist: + pass + + result = DiscountCalculator.calculate_cart_discounts( + items_data, promo_code, customer + ) + + cart_subtotal = Decimal('0') + for item in items_data: + cart_subtotal += Decimal(str(item['price'])) * Decimal(str(item['quantity'])) + + response_data = { + 'success': True, + 'cart_subtotal': float(cart_subtotal), + 'order_discount': { + 'discount_id': result['order_discount']['discount'].id if result['order_discount'].get('discount') else None, + 'discount_name': result['order_discount']['discount'].name if result['order_discount'].get('discount') else None, + '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']), + 'final_total': float(result['final_total']), + } + + return JsonResponse(response_data) + + except json.JSONDecodeError: + return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400) + except Exception as e: + logger.error(f'Ошибка при расчете скидок: {str(e)}', exc_info=True) + return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)