From 42d8c34e8c152744aefad6ed7bdb9670eb41344a Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sun, 11 Jan 2026 01:59:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(pos):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=20=D1=81=D0=BA=D0=B8=D0=B4=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B2=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5?= =?UTF-8?q?=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=D0=B6=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен API endpoint /pos/api/discounts/available/ для получения списка доступных скидок - Добавлен метод DiscountApplier.apply_manual_discount() для применения ручных скидок - Обновлен POS checkout для обработки manual_discount_id - Расширена секция скидок в модальном окне: * Отображение автоматических скидок (read-only) * Dropdown для выбора скидки вручную * Подробная детализация: подитог, общая скидка, скидки на позиции * Поле промокода с иконкой - Увеличен размер модального окна и изменено соотношение колонок (5/7) - Убрана вертикальная прокрутка из модального окна Co-Authored-By: Claude Opus 4.5 --- myproject/discounts/services/applier.py | 63 ++++++++ myproject/pos/static/pos/js/terminal.js | 183 ++++++++++++++++++++-- myproject/pos/templates/pos/terminal.html | 70 +++++++-- myproject/pos/urls.py | 2 + myproject/pos/views.py | 101 +++++++++++- 5 files changed, 389 insertions(+), 30 deletions(-) diff --git a/myproject/discounts/services/applier.py b/myproject/discounts/services/applier.py index b6bbefe..5bd0574 100644 --- a/myproject/discounts/services/applier.py +++ b/myproject/discounts/services/applier.py @@ -214,6 +214,69 @@ class DiscountApplier: # Пересчитываем order.calculate_total() + @staticmethod + @transaction.atomic + def apply_manual_discount(order, discount, user=None): + """ + Применить скидку вручную к заказу. + + Args: + order: Order + discount: Discount + user: CustomUser (применивший скидку) + + Returns: + dict: { + 'success': bool, + 'discount_amount': Decimal, + 'error': str + } + """ + from discounts.models import DiscountApplication + + # Проверяем scope скидки + if discount.scope != 'order': + return {'success': False, 'error': 'Эта скидка не применяется к заказу'} + + # Проверяем мин. сумму + if discount.min_order_amount and order.subtotal < discount.min_order_amount: + return {'success': False, 'error': f'Мин. сумма заказа: {discount.min_order_amount} руб.'} + + # Удаляем предыдущую скидку на заказ + DiscountApplier._remove_order_discount_only(order) + + # Рассчитываем сумму + discount_amount = discount.calculate_discount_amount(Decimal(order.subtotal)) + + # Применяем к заказу + order.applied_discount = discount + order.discount_amount = discount_amount + order.save(update_fields=['applied_discount', 'discount_amount']) + + # Пересчитываем total_amount + order.calculate_total() + + # Создаем запись о применении + 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 + ) + + # Увеличиваем счетчик использований скидки + discount.current_usage_count += 1 + discount.save(update_fields=['current_usage_count']) + + return { + 'success': True, + 'discount_amount': discount_amount + } + @staticmethod def _remove_order_discount_only(order): """ diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index 23cfdac..ffc47c3 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -2294,6 +2294,8 @@ let paymentWidget = null; // Переменные состояния скидок let appliedPromoCode = null; // примененный промокод +let appliedManualDiscount = null; // выбранная вручную скидка +let availableDiscounts = []; // список доступных скидок let cartDiscounts = { orderDiscount: null, // скидка на заказ itemDiscounts: [], // скидки на позиции @@ -2326,6 +2328,9 @@ document.getElementById('checkoutModal').addEventListener('show.bs.modal', async // Проверяем автоматические скидки await checkAutoDiscounts(); + // Загружаем доступные скидки для ручного выбора + await loadAvailableDiscounts(); + // Применяем скидки к итоговой сумме const finalTotal = Math.max(0, totalAmount - cartDiscounts.totalDiscount); document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.'; @@ -2366,6 +2371,8 @@ function reinitPaymentWidget(mode) { // Сброс скидок function resetDiscounts() { appliedPromoCode = null; + appliedManualDiscount = null; + availableDiscounts = []; cartDiscounts = { orderDiscount: null, itemDiscounts: [], @@ -2379,8 +2386,13 @@ function resetDiscounts() { 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 = ''; + + // Новые элементы UI + document.getElementById('autoDiscountsContainer').style.display = 'none'; + document.getElementById('autoDiscountsList').innerHTML = ''; + document.getElementById('manualDiscountContainer').style.display = 'none'; + document.getElementById('discountsSummary').style.display = 'none'; + document.getElementById('itemDiscountsBreakdown').innerHTML = ''; } // Проверить автоматические скидки @@ -2403,29 +2415,168 @@ async function checkAutoDiscounts() { }, body: JSON.stringify({ 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 }) }); const result = await response.json(); if (result.success) { - // Сохраняем скидки cartDiscounts.totalDiscount = result.total_discount || 0; cartDiscounts.orderDiscount = result.order_discount; + cartDiscounts.itemDiscounts = result.item_discounts || []; - // Показываем автоматические скидки - 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)} руб.`; - } + updateDiscountsUI(result); } } catch (error) { console.error('Ошибка при проверке автоматических скидок:', error); } } +// Обновить UI скидок +function updateDiscountsUI(result) { + const autoContainer = document.getElementById('autoDiscountsContainer'); + const autoList = document.getElementById('autoDiscountsList'); + const summaryDiv = document.getElementById('discountsSummary'); + const itemBreakdown = document.getElementById('itemDiscountsBreakdown'); + + // Очистка + autoList.innerHTML = ''; + itemBreakdown.innerHTML = ''; + + let hasDiscounts = false; + + // 1. Скидка на заказ (автоматическая) + if (result.order_discount && result.order_discount.discount_id) { + 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); + } else { + autoContainer.style.display = 'none'; + } + + // 2. Скидки на позиции + if (result.item_discounts && result.item_discounts.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); + }); + } + + // 3. Ручная скидка + if (appliedManualDiscount) { + hasDiscounts = true; + document.getElementById('manualDiscountContainer').style.display = 'block'; + document.getElementById('manualDiscountName').textContent = appliedManualDiscount.name; + document.getElementById('manualDiscountAmount').textContent = + `-${appliedManualDiscount.amount.toFixed(2)} руб.`; + } else { + document.getElementById('manualDiscountContainer').style.display = 'none'; + } + + // Показываем/скрываем summary + if (hasDiscounts) { + summaryDiv.style.display = 'block'; + document.getElementById('discountsSubtotal').textContent = + (result.cart_subtotal || 0).toFixed(2) + ' руб.'; + document.getElementById('discountsTotalDiscount').textContent = + '-' + (result.total_discount || 0).toFixed(2) + ' руб.'; + } else { + summaryDiv.style.display = 'none'; + } + + // Обновляем итоговую цену + const finalTotal = Math.max(0, (result.cart_subtotal || 0) - (result.total_discount || 0)); + document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.'; + + // Пересчитываем виджет оплаты + reinitPaymentWidget(document.getElementById('singlePaymentMode').classList.contains('active') ? 'single' : 'mixed'); +} + +// Загрузить доступные скидки +async function loadAvailableDiscounts() { + try { + let cartTotal = 0; + cart.forEach((item) => { + cartTotal += item.qty * item.price; + }); + + const response = await fetch(`/pos/api/discounts/available/?cart_total=${cartTotal}`); + const result = await response.json(); + + if (result.success) { + availableDiscounts = result.order_discounts; + renderDiscountsDropdown(result.order_discounts); + } + } catch (error) { + console.error('Ошибка загрузки скидок:', error); + } +} + +// Отобразить список скидок в dropdown +function renderDiscountsDropdown(discounts) { + const list = document.getElementById('discountsDropdownList'); + list.innerHTML = ''; + + if (discounts.length === 0) { + list.innerHTML = '
  • Нет доступных скидок
  • '; + return; + } + + discounts.forEach(d => { + 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 a = document.createElement('a'); + a.href = '#'; + a.className = 'dropdown-item d-flex justify-content-between'; + a.innerHTML = ` + ${d.name} + ${valueText} ${minText} + `; + a.onclick = (e) => { + e.preventDefault(); + applyManualDiscount(d); + }; + li.appendChild(a); + list.appendChild(li); + }); +} + +// Применить скидку вручную +async function applyManualDiscount(discount) { + // Рассчитываем сумму скидки на клиенте (для отображения до ответа сервера) + let cartTotal = 0; + cart.forEach((item) => { + cartTotal += item.qty * item.price; + }); + let discountAmount = discount.discount_type === 'percentage' + ? cartTotal * (discount.value / 100) + : discount.value; + + appliedManualDiscount = { ...discount, amount: discountAmount }; + await checkAutoDiscounts(); + await loadAvailableDiscounts(); +} + +// Удалить ручную скидку +document.getElementById('removeManualDiscountBtn').addEventListener('click', async () => { + appliedManualDiscount = null; + await checkAutoDiscounts(); + await loadAvailableDiscounts(); +}); + // Применить промокод async function applyPromoCode() { const code = document.getElementById('promoCodeInput').value.trim().toUpperCase(); @@ -2455,7 +2606,7 @@ async function applyPromoCode() { if (result.success) { appliedPromoCode = result.promo_code; - // Пересчитываем скидки с промокодом + // Пересчитываем скидки с промокодом (updateDiscountsUI обновит UI) await recalculateDiscountsWithPromo(code); // Показываем успех @@ -2464,9 +2615,6 @@ async function applyPromoCode() { 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 || 'Неверный промокод'; @@ -2511,6 +2659,7 @@ async function recalculateDiscountsWithPromo(promoCode) { cartDiscounts.totalDiscount = result.total_discount || 0; cartDiscounts.orderDiscount = result.order_discount; cartDiscounts.itemDiscounts = result.item_discounts || []; + updateDiscountsUI(result); } } catch (error) { console.error('Ошибка при пересчёте скидок:', error); @@ -2539,12 +2688,11 @@ function updateCheckoutTotalWithDiscounts() { function removePromoCode() { appliedPromoCode = null; - // Пересчитываем без промокода + // Пересчитываем без промокода (updateDiscountsUI обновит UI) recalculateDiscountsWithPromo(null).then(() => { document.getElementById('promoCodeInput').value = ''; document.getElementById('removePromoBtn').style.display = 'none'; document.getElementById('promoCodeSuccess').style.display = 'none'; - updateCheckoutTotalWithDiscounts(); }); } @@ -2629,7 +2777,8 @@ async function handleCheckoutSubmit(paymentsData) { }), payments: paymentsData, notes: document.getElementById('orderNote').value.trim(), - promo_code: appliedPromoCode || null + promo_code: appliedPromoCode?.code || null, + manual_discount_id: appliedManualDiscount?.id || null }; // Отправляем на сервер diff --git a/myproject/pos/templates/pos/terminal.html b/myproject/pos/templates/pos/terminal.html index 909f6e5..ec443b1 100644 --- a/myproject/pos/templates/pos/terminal.html +++ b/myproject/pos/templates/pos/terminal.html @@ -282,18 +282,18 @@