feat(pos): добавлен полноценный интерфейс скидок в модальное окно продажи

- Добавлен 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 01:59:43 +03:00
parent 6313b8f6e7
commit 42d8c34e8c
5 changed files with 389 additions and 30 deletions

View File

@@ -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 = `
<span>${result.order_discount.discount_name}</span>
<span class="text-success">-${result.order_discount.discount_amount.toFixed(2)} руб.</span>
`;
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 = '<li><span class="dropdown-item-text small text-muted">Нет доступных скидок</span></li>';
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 = `
<span>${d.name}</span>
<span class="text-success">${valueText} ${minText}</span>
`;
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
};
// Отправляем на сервер