Реализована полноценная система оплаты для 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:
2025-12-03 15:38:35 +03:00
parent 9dab280def
commit 1cda9086d0
7 changed files with 865 additions and 193 deletions

View 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);
}
}
}