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:
@@ -214,6 +214,69 @@ class DiscountApplier:
|
|||||||
# Пересчитываем
|
# Пересчитываем
|
||||||
order.calculate_total()
|
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
|
@staticmethod
|
||||||
def _remove_order_discount_only(order):
|
def _remove_order_discount_only(order):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2294,6 +2294,8 @@ let paymentWidget = null;
|
|||||||
|
|
||||||
// Переменные состояния скидок
|
// Переменные состояния скидок
|
||||||
let appliedPromoCode = null; // примененный промокод
|
let appliedPromoCode = null; // примененный промокод
|
||||||
|
let appliedManualDiscount = null; // выбранная вручную скидка
|
||||||
|
let availableDiscounts = []; // список доступных скидок
|
||||||
let cartDiscounts = {
|
let cartDiscounts = {
|
||||||
orderDiscount: null, // скидка на заказ
|
orderDiscount: null, // скидка на заказ
|
||||||
itemDiscounts: [], // скидки на позиции
|
itemDiscounts: [], // скидки на позиции
|
||||||
@@ -2326,6 +2328,9 @@ document.getElementById('checkoutModal').addEventListener('show.bs.modal', async
|
|||||||
// Проверяем автоматические скидки
|
// Проверяем автоматические скидки
|
||||||
await checkAutoDiscounts();
|
await checkAutoDiscounts();
|
||||||
|
|
||||||
|
// Загружаем доступные скидки для ручного выбора
|
||||||
|
await loadAvailableDiscounts();
|
||||||
|
|
||||||
// Применяем скидки к итоговой сумме
|
// Применяем скидки к итоговой сумме
|
||||||
const finalTotal = Math.max(0, totalAmount - cartDiscounts.totalDiscount);
|
const finalTotal = Math.max(0, totalAmount - cartDiscounts.totalDiscount);
|
||||||
document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.';
|
document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.';
|
||||||
@@ -2366,6 +2371,8 @@ function reinitPaymentWidget(mode) {
|
|||||||
// Сброс скидок
|
// Сброс скидок
|
||||||
function resetDiscounts() {
|
function resetDiscounts() {
|
||||||
appliedPromoCode = null;
|
appliedPromoCode = null;
|
||||||
|
appliedManualDiscount = null;
|
||||||
|
availableDiscounts = [];
|
||||||
cartDiscounts = {
|
cartDiscounts = {
|
||||||
orderDiscount: null,
|
orderDiscount: null,
|
||||||
itemDiscounts: [],
|
itemDiscounts: [],
|
||||||
@@ -2379,8 +2386,13 @@ function resetDiscounts() {
|
|||||||
document.getElementById('promoCodeSuccess').style.display = 'none';
|
document.getElementById('promoCodeSuccess').style.display = 'none';
|
||||||
document.getElementById('promoCodeSuccess').textContent = '';
|
document.getElementById('promoCodeSuccess').textContent = '';
|
||||||
document.getElementById('removePromoBtn').style.display = 'none';
|
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({
|
body: JSON.stringify({
|
||||||
items: items,
|
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();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Сохраняем скидки
|
|
||||||
cartDiscounts.totalDiscount = result.total_discount || 0;
|
cartDiscounts.totalDiscount = result.total_discount || 0;
|
||||||
cartDiscounts.orderDiscount = result.order_discount;
|
cartDiscounts.orderDiscount = result.order_discount;
|
||||||
|
cartDiscounts.itemDiscounts = result.item_discounts || [];
|
||||||
|
|
||||||
// Показываем автоматические скидки
|
updateDiscountsUI(result);
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Ошибка при проверке автоматических скидок:', 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() {
|
async function applyPromoCode() {
|
||||||
const code = document.getElementById('promoCodeInput').value.trim().toUpperCase();
|
const code = document.getElementById('promoCodeInput').value.trim().toUpperCase();
|
||||||
@@ -2455,7 +2606,7 @@ async function applyPromoCode() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
appliedPromoCode = result.promo_code;
|
appliedPromoCode = result.promo_code;
|
||||||
|
|
||||||
// Пересчитываем скидки с промокодом
|
// Пересчитываем скидки с промокодом (updateDiscountsUI обновит UI)
|
||||||
await recalculateDiscountsWithPromo(code);
|
await recalculateDiscountsWithPromo(code);
|
||||||
|
|
||||||
// Показываем успех
|
// Показываем успех
|
||||||
@@ -2464,9 +2615,6 @@ async function applyPromoCode() {
|
|||||||
document.getElementById('promoCodeSuccess').style.display = 'block';
|
document.getElementById('promoCodeSuccess').style.display = 'block';
|
||||||
document.getElementById('promoCodeError').style.display = 'none';
|
document.getElementById('promoCodeError').style.display = 'none';
|
||||||
document.getElementById('removePromoBtn').style.display = 'inline-block';
|
document.getElementById('removePromoBtn').style.display = 'inline-block';
|
||||||
|
|
||||||
// Обновляем итоговую сумму
|
|
||||||
updateCheckoutTotalWithDiscounts();
|
|
||||||
} else {
|
} else {
|
||||||
// Показываем ошибку
|
// Показываем ошибку
|
||||||
document.getElementById('promoCodeError').textContent = result.error || 'Неверный промокод';
|
document.getElementById('promoCodeError').textContent = result.error || 'Неверный промокод';
|
||||||
@@ -2511,6 +2659,7 @@ async function recalculateDiscountsWithPromo(promoCode) {
|
|||||||
cartDiscounts.totalDiscount = result.total_discount || 0;
|
cartDiscounts.totalDiscount = result.total_discount || 0;
|
||||||
cartDiscounts.orderDiscount = result.order_discount;
|
cartDiscounts.orderDiscount = result.order_discount;
|
||||||
cartDiscounts.itemDiscounts = result.item_discounts || [];
|
cartDiscounts.itemDiscounts = result.item_discounts || [];
|
||||||
|
updateDiscountsUI(result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при пересчёте скидок:', error);
|
console.error('Ошибка при пересчёте скидок:', error);
|
||||||
@@ -2539,12 +2688,11 @@ function updateCheckoutTotalWithDiscounts() {
|
|||||||
function removePromoCode() {
|
function removePromoCode() {
|
||||||
appliedPromoCode = null;
|
appliedPromoCode = null;
|
||||||
|
|
||||||
// Пересчитываем без промокода
|
// Пересчитываем без промокода (updateDiscountsUI обновит UI)
|
||||||
recalculateDiscountsWithPromo(null).then(() => {
|
recalculateDiscountsWithPromo(null).then(() => {
|
||||||
document.getElementById('promoCodeInput').value = '';
|
document.getElementById('promoCodeInput').value = '';
|
||||||
document.getElementById('removePromoBtn').style.display = 'none';
|
document.getElementById('removePromoBtn').style.display = 'none';
|
||||||
document.getElementById('promoCodeSuccess').style.display = 'none';
|
document.getElementById('promoCodeSuccess').style.display = 'none';
|
||||||
updateCheckoutTotalWithDiscounts();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2629,7 +2777,8 @@ async function handleCheckoutSubmit(paymentsData) {
|
|||||||
}),
|
}),
|
||||||
payments: paymentsData,
|
payments: paymentsData,
|
||||||
notes: document.getElementById('orderNote').value.trim(),
|
notes: document.getElementById('orderNote').value.trim(),
|
||||||
promo_code: appliedPromoCode || null
|
promo_code: appliedPromoCode?.code || null,
|
||||||
|
manual_discount_id: appliedManualDiscount?.id || null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Отправляем на сервер
|
// Отправляем на сервер
|
||||||
|
|||||||
@@ -282,18 +282,18 @@
|
|||||||
|
|
||||||
<!-- Модалка: Продажа -->
|
<!-- Модалка: Продажа -->
|
||||||
<div class="modal fade" id="checkoutModal" tabindex="-1" aria-labelledby="checkoutModalLabel" aria-hidden="true">
|
<div class="modal fade" id="checkoutModal" tabindex="-1" aria-labelledby="checkoutModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered modal-lg" style="max-width: 70vw;">
|
<div class="modal-dialog modal-dialog-centered modal-xl" style="max-width: 85vw;">
|
||||||
<div class="modal-content" style="max-height: 90vh; overflow: hidden;">
|
<div class="modal-content">
|
||||||
<div class="modal-header py-2">
|
<div class="modal-header py-2">
|
||||||
<h5 class="modal-title" id="checkoutModalLabel">
|
<h5 class="modal-title" id="checkoutModalLabel">
|
||||||
<i class="bi bi-cash-stack"></i> Продажа
|
<i class="bi bi-cash-stack"></i> Продажа
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body py-2" style="overflow-y: auto;">
|
<div class="modal-body py-2">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<!-- Левая колонка: состав заказа -->
|
<!-- Левая колонка: состав заказа -->
|
||||||
<div class="col-md-7">
|
<div class="col-md-5">
|
||||||
<!-- Информация о клиенте и баланс в одной строке -->
|
<!-- Информация о клиенте и баланс в одной строке -->
|
||||||
<div class="row g-2 mb-2">
|
<div class="row g-2 mb-2">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
@@ -320,7 +320,7 @@
|
|||||||
<!-- Состав заказа -->
|
<!-- Состав заказа -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<strong class="small">Состав заказа</strong>
|
<strong class="small">Состав заказа</strong>
|
||||||
<div class="border rounded p-2 mt-1" id="checkoutItems" style="max-height: 180px; overflow-y: auto; background: #f8f9fa; font-size: 0.9rem;">
|
<div class="border rounded p-2 mt-1" id="checkoutItems" style="background: #f8f9fa; font-size: 0.9rem;">
|
||||||
<!-- Заполняется из JS -->
|
<!-- Заполняется из JS -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,7 +333,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Правая колонка: оплата -->
|
<!-- Правая колонка: оплата -->
|
||||||
<div class="col-md-5">
|
<div class="col-md-7">
|
||||||
<div class="card mb-0">
|
<div class="card mb-0">
|
||||||
<div class="card-header bg-light py-2">
|
<div class="card-header bg-light py-2">
|
||||||
<strong class="small">Оплата</strong>
|
<strong class="small">Оплата</strong>
|
||||||
@@ -347,14 +347,45 @@
|
|||||||
|
|
||||||
<!-- Скидки -->
|
<!-- Скидки -->
|
||||||
<div class="mb-2 pb-2 border-bottom">
|
<div class="mb-2 pb-2 border-bottom">
|
||||||
<small class="text-muted">Скидки:</small>
|
<small class="text-muted d-block mb-1">Скидки</small>
|
||||||
<!-- Автоматические скидки -->
|
|
||||||
<div id="autoDiscounts" class="small text-success mb-1" style="display: none;">
|
<!-- Автоматические скидки (read-only) -->
|
||||||
<i class="bi bi-tag-fill"></i>
|
<div id="autoDiscountsContainer" class="mb-2" style="display: none;">
|
||||||
<span id="autoDiscountsText"></span>
|
<div class="alert alert-success py-1 px-2 mb-1" style="font-size: 0.85rem;">
|
||||||
|
<i class="bi bi-magic me-1"></i>
|
||||||
|
<strong>Автоматически:</strong>
|
||||||
|
<div id="autoDiscountsList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Выбранная скидка вручную -->
|
||||||
|
<div id="manualDiscountContainer" class="mb-2" style="display: none;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="badge bg-info text-dark">
|
||||||
|
<i class="bi bi-tag me-1"></i>
|
||||||
|
<span id="manualDiscountName"></span>
|
||||||
|
<span id="manualDiscountAmount" class="ms-1"></span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-link text-danger p-0" id="removeManualDiscountBtn">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dropdown для выбора скидки -->
|
||||||
|
<div class="dropdown mb-2">
|
||||||
|
<button class="btn btn-sm btn-outline-primary w-100 dropdown-toggle" type="button"
|
||||||
|
id="selectDiscountBtn" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Добавить скидку
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu w-100" id="discountsDropdownList">
|
||||||
|
<li><span class="dropdown-item-text small text-muted">Загрузка...</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Промокод -->
|
<!-- Промокод -->
|
||||||
<div class="input-group input-group-sm mt-1">
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text"><i class="bi bi-ticket-perforated"></i></span>
|
||||||
<input type="text" class="form-control" id="promoCodeInput"
|
<input type="text" class="form-control" id="promoCodeInput"
|
||||||
placeholder="Промокод" style="text-transform: uppercase;">
|
placeholder="Промокод" style="text-transform: uppercase;">
|
||||||
<button class="btn btn-outline-secondary" type="button" id="applyPromoBtn">
|
<button class="btn btn-outline-secondary" type="button" id="applyPromoBtn">
|
||||||
@@ -366,6 +397,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="promoCodeError" class="text-danger small mt-1" style="display: none;"></div>
|
<div id="promoCodeError" class="text-danger small mt-1" style="display: none;"></div>
|
||||||
<div id="promoCodeSuccess" class="text-success small mt-1" style="display: none;"></div>
|
<div id="promoCodeSuccess" class="text-success small mt-1" style="display: none;"></div>
|
||||||
|
|
||||||
|
<!-- Итоговая информация по скидкам -->
|
||||||
|
<div id="discountsSummary" class="mt-2 pt-2 border-top" style="display: none;">
|
||||||
|
<div class="d-flex justify-content-between small text-muted">
|
||||||
|
<span>Подитог:</span>
|
||||||
|
<span id="discountsSubtotal">0.00 руб.</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between small text-success">
|
||||||
|
<span>Общая скидка:</span>
|
||||||
|
<span id="discountsTotalDiscount">-0.00 руб.</span>
|
||||||
|
</div>
|
||||||
|
<div id="itemDiscountsBreakdown" class="mt-1 small text-muted">
|
||||||
|
<!-- Скидки на позиции -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Переключатель режима -->
|
<!-- Переключатель режима -->
|
||||||
|
|||||||
@@ -44,4 +44,6 @@ urlpatterns = [
|
|||||||
path('api/discounts/validate-promo/', views.validate_promo_code, name='validate-promo'),
|
path('api/discounts/validate-promo/', views.validate_promo_code, name='validate-promo'),
|
||||||
# Рассчитать скидки для корзины [POST]
|
# Рассчитать скидки для корзины [POST]
|
||||||
path('api/discounts/calculate/', views.calculate_cart_discounts, name='calculate-discounts'),
|
path('api/discounts/calculate/', views.calculate_cart_discounts, name='calculate-discounts'),
|
||||||
|
# Получить список доступных скидок [GET]
|
||||||
|
path('api/discounts/available/', views.get_available_discounts, name='available-discounts'),
|
||||||
]
|
]
|
||||||
@@ -1402,6 +1402,7 @@ def pos_checkout(request):
|
|||||||
],
|
],
|
||||||
"notes": str (optional),
|
"notes": str (optional),
|
||||||
"promo_code": str (optional) - Промокод для скидки
|
"promo_code": str (optional) - Промокод для скидки
|
||||||
|
"manual_discount_id": int (optional) - ID выбранной вручную скидки
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
from orders.models import Order, OrderItem, OrderStatus
|
from orders.models import Order, OrderItem, OrderStatus
|
||||||
@@ -1423,6 +1424,7 @@ def pos_checkout(request):
|
|||||||
payments_data = body.get('payments', [])
|
payments_data = body.get('payments', [])
|
||||||
order_notes = body.get('notes', '')
|
order_notes = body.get('notes', '')
|
||||||
promo_code = body.get('promo_code') # Промокод для скидки
|
promo_code = body.get('promo_code') # Промокод для скидки
|
||||||
|
manual_discount_id = body.get('manual_discount_id') # ID выбранной вручную скидки
|
||||||
|
|
||||||
if not customer_id:
|
if not customer_id:
|
||||||
return JsonResponse({'success': False, 'error': 'Не указан клиент'}, status=400)
|
return JsonResponse({'success': False, 'error': 'Не указан клиент'}, status=400)
|
||||||
@@ -1554,7 +1556,22 @@ def pos_checkout(request):
|
|||||||
order.calculate_total()
|
order.calculate_total()
|
||||||
|
|
||||||
# 4. Применяем скидки
|
# 4. Применяем скидки
|
||||||
if promo_code:
|
if manual_discount_id:
|
||||||
|
from discounts.services.applier import DiscountApplier
|
||||||
|
from discounts.models import Discount
|
||||||
|
|
||||||
|
try:
|
||||||
|
discount = Discount.objects.get(id=manual_discount_id, is_active=True)
|
||||||
|
apply_result = DiscountApplier.apply_manual_discount(
|
||||||
|
order=order,
|
||||||
|
discount=discount,
|
||||||
|
user=request.user
|
||||||
|
)
|
||||||
|
if not apply_result['success']:
|
||||||
|
raise ValidationError(apply_result['error'])
|
||||||
|
except Discount.DoesNotExist:
|
||||||
|
pass
|
||||||
|
elif promo_code:
|
||||||
from discounts.services.applier import DiscountApplier
|
from discounts.services.applier import DiscountApplier
|
||||||
apply_result = DiscountApplier.apply_promo_code(
|
apply_result = DiscountApplier.apply_promo_code(
|
||||||
order=order,
|
order=order,
|
||||||
@@ -1815,3 +1832,85 @@ def calculate_cart_discounts(request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Ошибка при расчете скидок: {str(e)}', exc_info=True)
|
logger.error(f'Ошибка при расчете скидок: {str(e)}', exc_info=True)
|
||||||
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def get_available_discounts(request):
|
||||||
|
"""
|
||||||
|
Возвращает список доступных скидок для ручного применения в POS.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- cart_total: сумма корзины (для фильтрации по min_order_amount)
|
||||||
|
|
||||||
|
Returns JSON:
|
||||||
|
{
|
||||||
|
'success': true,
|
||||||
|
'order_discounts': [
|
||||||
|
{
|
||||||
|
'id': 1,
|
||||||
|
'name': 'Скидка 5%',
|
||||||
|
'discount_type': 'percentage',
|
||||||
|
'value': 5,
|
||||||
|
'min_order_amount': 50
|
||||||
|
},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
'auto_order_discount': {...} # если есть автоскидка на заказ
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from discounts.services.calculator import DiscountCalculator
|
||||||
|
|
||||||
|
try:
|
||||||
|
cart_total = request.GET.get('cart_total', '0')
|
||||||
|
cart_total = Decimal(str(cart_total))
|
||||||
|
|
||||||
|
# Получаем НЕ автоматические скидки на заказ для ручного применения
|
||||||
|
order_discounts = DiscountCalculator.get_available_discounts(
|
||||||
|
scope='order',
|
||||||
|
auto_only=False
|
||||||
|
).filter(is_auto=False)
|
||||||
|
|
||||||
|
# Фильтруем по мин. сумме заказа
|
||||||
|
result_discounts = []
|
||||||
|
for d in order_discounts:
|
||||||
|
if d.min_order_amount and cart_total < d.min_order_amount:
|
||||||
|
continue
|
||||||
|
result_discounts.append({
|
||||||
|
'id': d.id,
|
||||||
|
'name': d.name,
|
||||||
|
'discount_type': d.discount_type,
|
||||||
|
'value': float(d.value),
|
||||||
|
'min_order_amount': float(d.min_order_amount) if d.min_order_amount else None
|
||||||
|
})
|
||||||
|
|
||||||
|
# Получаем автоматическую скидку (только одну для отображения)
|
||||||
|
auto_discounts = DiscountCalculator.get_available_discounts(
|
||||||
|
scope='order',
|
||||||
|
auto_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_discount_data = None
|
||||||
|
for d in auto_discounts:
|
||||||
|
if d.min_order_amount and cart_total < d.min_order_amount:
|
||||||
|
continue
|
||||||
|
# Рассчитываем сумму
|
||||||
|
discount_amount = d.calculate_discount_amount(cart_total)
|
||||||
|
auto_discount_data = {
|
||||||
|
'id': d.id,
|
||||||
|
'name': d.name,
|
||||||
|
'discount_type': d.discount_type,
|
||||||
|
'value': float(d.value),
|
||||||
|
'discount_amount': float(discount_amount)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'order_discounts': result_discounts,
|
||||||
|
'auto_order_discount': auto_discount_data
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Ошибка при получении скидок: {str(e)}', exc_info=True)
|
||||||
|
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||||||
|
|||||||
Reference in New Issue
Block a user