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:
2026-01-11 12:56:38 +03:00
parent 293f3b58cb
commit f57e639dbe
9 changed files with 715 additions and 223 deletions

View File

@@ -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': '<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 скидок
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 = `
<span>${result.order_discount.discount_name}</span>
<span class="text-success">-${result.order_discount.discount_amount.toFixed(2)} руб.</span>
`;
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 = `
<span>${modeIcon} ${disc.discount_name}</span>
<span class="text-success">-${disc.discount_amount.toFixed(2)} руб.</span>
`;
autoList.appendChild(div);
});
// Показываем информацию о комбинировании
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);
}
// Показываем кнопку отмены (только если еще не пропущена)
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}: <span class="text-success">-${item.total_discount.toFixed(2)} руб.</span>`;
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 = `
<span class="badge bg-primary">Произвольная скидка ${discountText}</span>
`;
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 = `
<span>${d.name}</span>
<span>${modeIcon} ${d.name}</span>
<span class="text-success">${valueText} ${minText}</span>
`;
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
};
// Отправляем на сервер