Реализована полноценная система оплаты для 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:
370
myproject/orders/static/orders/js/payment_widget.js
Normal file
370
myproject/orders/static/orders/js/payment_widget.js
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* PaymentWidget - переиспользуемый виджет для оплаты заказа.
|
||||
* Поддерживает одиночную и смешанную оплату.
|
||||
*
|
||||
* @example
|
||||
* const widget = new PaymentWidget({
|
||||
* containerId: 'paymentContainer',
|
||||
* mode: 'single', // или 'mixed'
|
||||
* order: { id: 1, total: 5000, amount_due: 5000 },
|
||||
* customer: { id: 1, wallet_balance: 1000 },
|
||||
* paymentMethods: [
|
||||
* { id: 1, code: 'account_balance', name: 'С баланса счёта' },
|
||||
* { id: 2, code: 'cash', name: 'Наличными' },
|
||||
* ...
|
||||
* ],
|
||||
* onSubmit: (payments) => { console.log(payments); }
|
||||
* });
|
||||
*/
|
||||
export class PaymentWidget {
|
||||
constructor(options = {}) {
|
||||
this.containerId = options.containerId;
|
||||
this.mode = options.mode || 'single'; // 'single' | 'mixed'
|
||||
this.order = options.order;
|
||||
this.customer = options.customer;
|
||||
this.paymentMethods = options.paymentMethods || [];
|
||||
this.onSubmit = options.onSubmit || (() => {});
|
||||
|
||||
// Внутреннее состояние
|
||||
this.payments = []; // [{ method_id, method_code, method_name, amount, notes }]
|
||||
this.selectedMethod = null; // Для single mode
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.render();
|
||||
this.attachEvents();
|
||||
}
|
||||
|
||||
render() {
|
||||
const container = document.getElementById(this.containerId);
|
||||
if (!container) {
|
||||
console.error(`Container #${this.containerId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<!-- Способы оплаты -->
|
||||
<div class="mb-2">
|
||||
<label class="form-label fw-semibold small mb-1">Способ оплаты</label>
|
||||
<div class="d-flex flex-wrap gap-1" id="${this.containerId}-methods">
|
||||
${this.renderPaymentMethods()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.mode === 'mixed' ? this.renderPaymentsList() : ''}
|
||||
|
||||
<!-- Сумма -->
|
||||
<div class="mb-2">
|
||||
<label class="form-label fw-semibold small mb-1">Сумма</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number"
|
||||
class="form-control form-control-sm"
|
||||
id="${this.containerId}-amount"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
value="${this.order.amount_due}"
|
||||
placeholder="0.00">
|
||||
<span class="input-group-text">руб.</span>
|
||||
</div>
|
||||
<small class="text-muted" id="${this.containerId}-hint"></small>
|
||||
</div>
|
||||
|
||||
<!-- Примечание -->
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Примечание</label>
|
||||
<input type="text"
|
||||
class="form-control form-control-sm"
|
||||
id="${this.containerId}-notes"
|
||||
placeholder="Опционально">
|
||||
</div>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div class="d-flex gap-2">
|
||||
${this.mode === 'mixed' ? `
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
id="${this.containerId}-add-btn">
|
||||
<i class="bi bi-plus-circle"></i> Добавить
|
||||
</button>
|
||||
` : ''}
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-success flex-grow-1"
|
||||
id="${this.containerId}-submit-btn">
|
||||
<i class="bi bi-check-lg"></i> Оплатить
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderPaymentMethods() {
|
||||
return this.paymentMethods.map(method => {
|
||||
const showWallet = method.code === 'account_balance' && this.customer;
|
||||
return `
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-primary payment-method-btn"
|
||||
data-method-id="${method.id}"
|
||||
data-method-code="${method.code}"
|
||||
data-method-name="${method.name}"
|
||||
style="font-size: 0.85rem;">
|
||||
${method.name}
|
||||
${showWallet ? `
|
||||
<span class="badge bg-info ms-1" style="font-size: 0.75rem;">
|
||||
${this.customer.wallet_balance.toFixed(2)}
|
||||
</span>
|
||||
` : ''}
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
renderPaymentsList() {
|
||||
return `
|
||||
<div class="mb-2">
|
||||
<label class="form-label fw-semibold small mb-1">Добавленные платежи</label>
|
||||
<div id="${this.containerId}-payments-list"
|
||||
class="border rounded p-2 bg-light"
|
||||
style="min-height: 50px; max-height: 150px; overflow-y: auto; font-size: 0.9rem;">
|
||||
<small class="text-muted">Платежи не добавлены</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachEvents() {
|
||||
// Выбор способа оплаты
|
||||
document.querySelectorAll(`#${this.containerId}-methods .payment-method-btn`).forEach(btn => {
|
||||
btn.addEventListener('click', () => this.selectPaymentMethod(btn));
|
||||
});
|
||||
|
||||
// Добавить платеж (mixed mode)
|
||||
if (this.mode === 'mixed') {
|
||||
const addBtn = document.getElementById(`${this.containerId}-add-btn`);
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', () => this.addPayment());
|
||||
}
|
||||
}
|
||||
|
||||
// Отправка
|
||||
const submitBtn = document.getElementById(`${this.containerId}-submit-btn`);
|
||||
if (submitBtn) {
|
||||
submitBtn.addEventListener('click', () => this.submit());
|
||||
}
|
||||
}
|
||||
|
||||
selectPaymentMethod(btnElement) {
|
||||
// Снимаем выделение
|
||||
document.querySelectorAll(`#${this.containerId}-methods .payment-method-btn`).forEach(b => {
|
||||
b.classList.remove('active');
|
||||
});
|
||||
|
||||
// Выделяем выбранный
|
||||
btnElement.classList.add('active');
|
||||
|
||||
this.selectedMethod = {
|
||||
id: btnElement.dataset.methodId,
|
||||
code: btnElement.dataset.methodCode,
|
||||
name: btnElement.dataset.methodName
|
||||
};
|
||||
|
||||
// Валидация для кошелька
|
||||
const amountInput = document.getElementById(`${this.containerId}-amount`);
|
||||
const hint = document.getElementById(`${this.containerId}-hint`);
|
||||
|
||||
if (this.selectedMethod.code === 'account_balance' && this.customer) {
|
||||
const maxAmount = Math.min(this.order.amount_due, this.customer.wallet_balance);
|
||||
amountInput.max = maxAmount;
|
||||
amountInput.value = maxAmount.toFixed(2);
|
||||
hint.textContent = `Макс: ${maxAmount.toFixed(2)} руб.`;
|
||||
} else {
|
||||
amountInput.removeAttribute('max');
|
||||
amountInput.value = this.order.amount_due.toFixed(2);
|
||||
hint.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
addPayment() {
|
||||
if (!this.selectedMethod) {
|
||||
alert('Выберите способ оплаты');
|
||||
return;
|
||||
}
|
||||
|
||||
const amountInput = document.getElementById(`${this.containerId}-amount`);
|
||||
const notesInput = document.getElementById(`${this.containerId}-notes`);
|
||||
|
||||
const amount = parseFloat(amountInput.value);
|
||||
|
||||
if (amount <= 0 || isNaN(amount)) {
|
||||
alert('Введите сумму больше 0');
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка не превышает ли общая сумма amount_due
|
||||
const currentTotal = this.getTotalPayments();
|
||||
if (currentTotal + amount > this.order.amount_due) {
|
||||
alert(`Общая сумма платежей (${(currentTotal + amount).toFixed(2)}) превышает сумму к оплате (${this.order.amount_due.toFixed(2)})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Добавляем в список
|
||||
this.payments.push({
|
||||
method_id: this.selectedMethod.id,
|
||||
method_code: this.selectedMethod.code,
|
||||
method_name: this.selectedMethod.name,
|
||||
amount: amount,
|
||||
notes: notesInput.value.trim()
|
||||
});
|
||||
|
||||
// Обновляем UI
|
||||
this.updatePaymentsList();
|
||||
|
||||
// Сбрасываем форму
|
||||
const remaining = this.order.amount_due - this.getTotalPayments();
|
||||
amountInput.value = remaining > 0 ? remaining.toFixed(2) : '0.00';
|
||||
notesInput.value = '';
|
||||
|
||||
// Снимаем выделение способа
|
||||
document.querySelectorAll(`#${this.containerId}-methods .payment-method-btn`).forEach(b => {
|
||||
b.classList.remove('active');
|
||||
});
|
||||
this.selectedMethod = null;
|
||||
|
||||
const hint = document.getElementById(`${this.containerId}-hint`);
|
||||
hint.textContent = remaining > 0 ? `Осталось оплатить: ${remaining.toFixed(2)} руб.` : 'Оплачено полностью';
|
||||
}
|
||||
|
||||
removePayment(index) {
|
||||
this.payments.splice(index, 1);
|
||||
this.updatePaymentsList();
|
||||
|
||||
// Обновляем подсказку
|
||||
const amountInput = document.getElementById(`${this.containerId}-amount`);
|
||||
const remaining = this.order.amount_due - this.getTotalPayments();
|
||||
amountInput.value = remaining > 0 ? remaining.toFixed(2) : '0.00';
|
||||
|
||||
const hint = document.getElementById(`${this.containerId}-hint`);
|
||||
hint.textContent = remaining > 0 ? `Осталось оплатить: ${remaining.toFixed(2)} руб.` : '';
|
||||
}
|
||||
|
||||
updatePaymentsList() {
|
||||
const listContainer = document.getElementById(`${this.containerId}-payments-list`);
|
||||
if (!listContainer) return;
|
||||
|
||||
if (this.payments.length === 0) {
|
||||
listContainer.innerHTML = '<small class="text-muted">Платежи не добавлены</small>';
|
||||
return;
|
||||
}
|
||||
|
||||
listContainer.innerHTML = this.payments.map((p, index) => `
|
||||
<div class="d-flex justify-content-between align-items-center mb-1 pb-1 ${index < this.payments.length - 1 ? 'border-bottom' : ''}">
|
||||
<div style="font-size: 0.9rem;">
|
||||
<strong>${p.method_name}</strong>
|
||||
<span class="text-success ms-2">${p.amount.toFixed(2)} руб.</span>
|
||||
${p.notes ? `<br><small class="text-muted">${p.notes}</small>` : ''}
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-danger remove-payment-btn"
|
||||
data-index="${index}"
|
||||
style="font-size: 0.75rem; padding: 0.1rem 0.3rem;">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Прикрепляем обработчики удаления
|
||||
listContainer.querySelectorAll('.remove-payment-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const index = parseInt(btn.dataset.index);
|
||||
this.removePayment(index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getTotalPayments() {
|
||||
return this.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
}
|
||||
|
||||
validate() {
|
||||
if (this.mode === 'single') {
|
||||
// Одиночная оплата
|
||||
if (!this.selectedMethod) {
|
||||
throw new Error('Выберите способ оплаты');
|
||||
}
|
||||
|
||||
const amountInput = document.getElementById(`${this.containerId}-amount`);
|
||||
const amount = parseFloat(amountInput.value);
|
||||
|
||||
if (amount <= 0 || isNaN(amount)) {
|
||||
throw new Error('Введите сумму больше 0');
|
||||
}
|
||||
|
||||
if (amount > this.order.amount_due) {
|
||||
throw new Error(`Сумма превышает остаток к оплате (${this.order.amount_due.toFixed(2)} руб.)`);
|
||||
}
|
||||
|
||||
// Проверка кошелька
|
||||
if (this.selectedMethod.code === 'account_balance' && this.customer) {
|
||||
if (amount > this.customer.wallet_balance) {
|
||||
throw new Error(`Недостаточно средств в кошельке (${this.customer.wallet_balance.toFixed(2)} руб.)`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// Смешанная оплата
|
||||
if (this.payments.length === 0) {
|
||||
throw new Error('Добавьте хотя бы один платеж');
|
||||
}
|
||||
|
||||
const total = this.getTotalPayments();
|
||||
|
||||
if (total > this.order.amount_due) {
|
||||
throw new Error(`Сумма платежей превышает остаток к оплате (${this.order.amount_due.toFixed(2)} руб.)`);
|
||||
}
|
||||
|
||||
// Проверка кошелька
|
||||
if (this.customer) {
|
||||
const walletPayments = this.payments.filter(p => p.method_code === 'account_balance');
|
||||
const walletTotal = walletPayments.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
if (walletTotal > this.customer.wallet_balance) {
|
||||
throw new Error(`Недостаточно средств в кошельке (${this.customer.wallet_balance.toFixed(2)} руб.)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
submit() {
|
||||
try {
|
||||
this.validate();
|
||||
|
||||
let paymentsData;
|
||||
|
||||
if (this.mode === 'single') {
|
||||
// Одиночная оплата
|
||||
const amountInput = document.getElementById(`${this.containerId}-amount`);
|
||||
const notesInput = document.getElementById(`${this.containerId}-notes`);
|
||||
|
||||
paymentsData = [{
|
||||
payment_method: this.selectedMethod.code,
|
||||
amount: parseFloat(amountInput.value),
|
||||
notes: notesInput.value.trim()
|
||||
}];
|
||||
} else {
|
||||
// Смешанная оплата
|
||||
paymentsData = this.payments.map(p => ({
|
||||
payment_method: p.method_code,
|
||||
amount: p.amount,
|
||||
notes: p.notes
|
||||
}));
|
||||
}
|
||||
|
||||
this.onSubmit(paymentsData);
|
||||
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user