Реализована полноценная система оплаты для POS-терминала
Добавлена интеграция оплаты в POS с поддержкой одиночной и смешанной оплаты, работой с кошельком клиента и автоматическим созданием заказов. Backend изменения: - TransactionService: добавлены методы get_available_payment_methods() и create_multiple_payments() для фильтрации способов оплаты и атомарного создания нескольких платежей - POS API: новый endpoint pos_checkout() для создания заказов со статусом "Выполнен" с обработкой платежей, освобождением блокировок и очисткой корзины - Template tags: payment_tags.py для получения способов оплаты в шаблонах Frontend изменения: - PaymentWidget: переиспользуемый ES6 класс с поддержкой single/mixed режимов, автоматической валидацией и интеграцией с кошельком клиента - terminal.html: компактное модальное окно (70vw) с оптимизированной компоновкой, удален функционал скидок, добавлен показ баланса кошелька - terminal.js: динамическая загрузка PaymentWidget, интеграция с backend API, обработка успешной оплаты и ошибок Поддерживаемые способы оплаты: наличные, карта, онлайн, баланс счёта. Смешанная оплата позволяет комбинировать несколько способов в одной транзакции. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1513,123 +1513,183 @@ function renderCheckoutModal() {
|
||||
|
||||
// Обновляем информацию о клиенте
|
||||
updateCustomerDisplay();
|
||||
|
||||
// Обновляем базовую цену и пересчитываем
|
||||
updateCheckoutPricing(total);
|
||||
}
|
||||
|
||||
// Пересчёт цен в модалке продажи
|
||||
function updateCheckoutPricing(basePrice = null) {
|
||||
// Если basePrice не передан, пересчитываем из корзины
|
||||
if (basePrice === null) {
|
||||
basePrice = 0;
|
||||
cart.forEach((item) => {
|
||||
basePrice += item.qty * item.price;
|
||||
});
|
||||
// ===== CHECKOUT: ПОДТВЕРЖДЕНИЕ ПРОДАЖИ =====
|
||||
|
||||
let paymentWidget = null;
|
||||
|
||||
// При открытии модалки checkout
|
||||
document.getElementById('checkoutModal').addEventListener('show.bs.modal', () => {
|
||||
const customer = selectedCustomer || SYSTEM_CUSTOMER;
|
||||
const walletBalance = customer.wallet_balance || 0;
|
||||
|
||||
// Показываем баланс кошелька
|
||||
const walletDiv = document.getElementById('checkoutWalletBalance');
|
||||
if (customer.id !== SYSTEM_CUSTOMER.id) {
|
||||
document.getElementById('checkoutWalletBalanceAmount').textContent = walletBalance.toFixed(2);
|
||||
walletDiv.style.display = 'block';
|
||||
} else {
|
||||
walletDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
// Базовая сумма
|
||||
document.getElementById('checkoutBasePrice').textContent = formatMoney(basePrice) + ' руб.';
|
||||
|
||||
// Скидка
|
||||
const discountType = document.getElementById('discountType').value;
|
||||
const discountValue = parseFloat(document.getElementById('discountValue').value) || 0;
|
||||
|
||||
let discountedPrice = basePrice;
|
||||
if (discountType !== 'none' && discountValue > 0) {
|
||||
if (discountType === 'percent') {
|
||||
discountedPrice = basePrice - (basePrice * discountValue / 100);
|
||||
} else if (discountType === 'amount') {
|
||||
discountedPrice = Math.max(0, basePrice - discountValue);
|
||||
|
||||
// Вычисляем итоговую сумму
|
||||
let totalAmount = 0;
|
||||
cart.forEach((item) => {
|
||||
totalAmount += item.qty * item.price;
|
||||
});
|
||||
|
||||
document.getElementById('checkoutFinalPrice').textContent = formatMoney(totalAmount) + ' руб.';
|
||||
|
||||
// Инициализируем виджет в single mode
|
||||
initPaymentWidget('single', {
|
||||
order: { total: totalAmount, amount_due: totalAmount, amount_paid: 0 },
|
||||
customer: { id: customer.id, name: customer.name, wallet_balance: walletBalance }
|
||||
});
|
||||
});
|
||||
|
||||
// Переключение режима оплаты
|
||||
document.getElementById('singlePaymentMode').addEventListener('click', function() {
|
||||
document.getElementById('singlePaymentMode').classList.add('active');
|
||||
document.getElementById('mixedPaymentMode').classList.remove('active');
|
||||
reinitPaymentWidget('single');
|
||||
});
|
||||
|
||||
document.getElementById('mixedPaymentMode').addEventListener('click', function() {
|
||||
document.getElementById('mixedPaymentMode').classList.add('active');
|
||||
document.getElementById('singlePaymentMode').classList.remove('active');
|
||||
reinitPaymentWidget('mixed');
|
||||
});
|
||||
|
||||
function reinitPaymentWidget(mode) {
|
||||
const customer = selectedCustomer || SYSTEM_CUSTOMER;
|
||||
const totalAmountText = document.getElementById('checkoutFinalPrice').textContent;
|
||||
const totalAmount = parseFloat(totalAmountText.replace(/[^\d.]/g, ''));
|
||||
|
||||
initPaymentWidget(mode, {
|
||||
order: { total: totalAmount, amount_due: totalAmount, amount_paid: 0 },
|
||||
customer: { id: customer.id, name: customer.name, wallet_balance: customer.wallet_balance || 0 }
|
||||
});
|
||||
}
|
||||
|
||||
async function initPaymentWidget(mode, data) {
|
||||
const paymentMethods = [
|
||||
{ id: 1, code: 'account_balance', name: 'С баланса счёта' },
|
||||
{ id: 2, code: 'cash', name: 'Наличными' },
|
||||
{ id: 3, code: 'card', name: 'Картой' },
|
||||
{ id: 4, code: 'online', name: 'Онлайн' }
|
||||
];
|
||||
|
||||
// Динамически загружаем PaymentWidget если еще не загружен
|
||||
if (!window.PaymentWidget) {
|
||||
try {
|
||||
const module = await import('/static/orders/js/payment_widget.js');
|
||||
window.PaymentWidget = module.PaymentWidget;
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки PaymentWidget:', error);
|
||||
alert('Ошибка загрузки модуля оплаты. Перезагрузите страницу.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('checkoutDiscountedPrice').textContent = formatMoney(discountedPrice) + ' руб.';
|
||||
|
||||
// Финальная цена (с учётом ручной суммы если задана)
|
||||
const useManualPrice = document.getElementById('useManualPrice').checked;
|
||||
const manualPrice = parseFloat(document.getElementById('manualPrice').value) || 0;
|
||||
|
||||
let finalPrice = discountedPrice;
|
||||
if (useManualPrice && manualPrice > 0) {
|
||||
finalPrice = manualPrice;
|
||||
}
|
||||
|
||||
document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalPrice);
|
||||
|
||||
paymentWidget = new window.PaymentWidget({
|
||||
containerId: 'paymentWidgetContainer',
|
||||
mode: mode,
|
||||
order: data.order,
|
||||
customer: data.customer,
|
||||
paymentMethods: paymentMethods,
|
||||
onSubmit: (paymentsData) => handleCheckoutSubmit(paymentsData)
|
||||
});
|
||||
}
|
||||
|
||||
// Обработчики для полей скидки и цены
|
||||
document.getElementById('discountType').addEventListener('change', function() {
|
||||
const discountBlock = document.getElementById('discountValueBlock');
|
||||
if (this.value === 'none') {
|
||||
discountBlock.style.display = 'none';
|
||||
document.getElementById('discountValue').value = '0';
|
||||
} else {
|
||||
discountBlock.style.display = 'block';
|
||||
}
|
||||
updateCheckoutPricing();
|
||||
});
|
||||
|
||||
document.getElementById('discountValue').addEventListener('input', function() {
|
||||
updateCheckoutPricing();
|
||||
});
|
||||
|
||||
document.getElementById('useManualPrice').addEventListener('change', function() {
|
||||
const manualPriceBlock = document.getElementById('manualPriceBlock');
|
||||
if (this.checked) {
|
||||
manualPriceBlock.style.display = 'block';
|
||||
} else {
|
||||
manualPriceBlock.style.display = 'none';
|
||||
document.getElementById('manualPrice').value = '';
|
||||
}
|
||||
updateCheckoutPricing();
|
||||
});
|
||||
|
||||
document.getElementById('manualPrice').addEventListener('input', function() {
|
||||
updateCheckoutPricing();
|
||||
});
|
||||
|
||||
// Подтверждение продажи (пока заглушка)
|
||||
// Обработчик кнопки "Подтвердить продажу"
|
||||
document.getElementById('confirmCheckoutBtn').onclick = () => {
|
||||
const payment = document.getElementById('paymentMethod').value;
|
||||
const note = document.getElementById('orderNote').value.trim();
|
||||
|
||||
const paymentText = {
|
||||
'cash': 'Наличные',
|
||||
'card': 'Карта',
|
||||
'mixed': 'Смешанная оплата'
|
||||
}[payment] || payment;
|
||||
|
||||
// Получаем данные о ценах и скидке
|
||||
const basePrice = document.getElementById('checkoutBasePrice').textContent;
|
||||
const discountType = document.getElementById('discountType').value;
|
||||
const discountValue = document.getElementById('discountValue').value;
|
||||
const finalPrice = document.getElementById('checkoutFinalPrice').textContent;
|
||||
|
||||
let discountInfo = 'нет скидки';
|
||||
if (discountType === 'percent' && discountValue > 0) {
|
||||
discountInfo = `скидка ${discountValue}%`;
|
||||
} else if (discountType === 'amount' && discountValue > 0) {
|
||||
discountInfo = `скидка ${discountValue} руб.`;
|
||||
if (paymentWidget) {
|
||||
paymentWidget.submit();
|
||||
}
|
||||
|
||||
const useManual = document.getElementById('useManualPrice').checked;
|
||||
if (useManual) {
|
||||
discountInfo += ' (установлена своя сумма)';
|
||||
}
|
||||
|
||||
alert(`Функция проведения продажи будет подключена позже.
|
||||
|
||||
Базовая сумма: ${basePrice}
|
||||
Скидка: ${discountInfo}
|
||||
Итого к оплате: ${finalPrice} руб.
|
||||
Оплата: ${paymentText}
|
||||
Комментарий: ${note || '—'}`);
|
||||
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('checkoutModal'));
|
||||
modal.hide();
|
||||
};
|
||||
|
||||
// Отправка заказа на сервер
|
||||
async function handleCheckoutSubmit(paymentsData) {
|
||||
try {
|
||||
// Блокируем кнопку
|
||||
const btn = document.getElementById('confirmCheckoutBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Обработка...';
|
||||
|
||||
// Собираем данные
|
||||
const customer = selectedCustomer || SYSTEM_CUSTOMER;
|
||||
const orderData = {
|
||||
customer_id: customer.id,
|
||||
warehouse_id: currentWarehouse.id,
|
||||
items: Array.from(cart.values()).map(item => ({
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
quantity: item.qty,
|
||||
price: item.price
|
||||
})),
|
||||
payments: paymentsData,
|
||||
notes: document.getElementById('orderNote').value.trim()
|
||||
};
|
||||
|
||||
// Отправляем на сервер
|
||||
const response = await fetch('/pos/api/checkout/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
},
|
||||
body: JSON.stringify(orderData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Успех
|
||||
alert(`Заказ #${result.order_number} успешно создан!\nСумма: ${result.total_amount.toFixed(2)} руб.`);
|
||||
|
||||
// Очищаем корзину
|
||||
cart.clear();
|
||||
updateCartUI();
|
||||
updateCartCount();
|
||||
|
||||
// Закрываем модалку
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('checkoutModal'));
|
||||
modal.hide();
|
||||
|
||||
// Перезагружаем витринные комплекты
|
||||
loadShowcaseKits();
|
||||
|
||||
} else {
|
||||
alert('Ошибка: ' + result.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка checkout:', error);
|
||||
alert('Ошибка при проведении продажи: ' + error.message);
|
||||
} finally {
|
||||
// Разблокируем кнопку
|
||||
const btn = document.getElementById('confirmCheckoutBtn');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-check2-circle"></i> Подтвердить продажу';
|
||||
}
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
document.getElementById('scheduleLater').onclick = async () => {
|
||||
alert('Функционал будет подключен позже: создание заказа на доставку/самовывоз.');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user