Files
octopus/myproject/orders/static/orders/js/payment_widget.js
Andrey Smakotin 12282a8ce4 FIX: Исправлена автоподстановка суммы при смешанной оплате + убраны версии JS
1. Убран параметр версии ?v=4 из подключения terminal.js (для разработки не нужен)

2. Исправлена логика автоподстановки суммы в PaymentWidget:
   - Теперь при выборе способа оплаты подставляется ОСТАТОЧНАЯ сумма
   - Остаток = amount_due - уже добавленные платежи
   - Добавлена подсказка "Осталось оплатить: X руб."

Пример:
- Заказ на 30 руб.
- Добавили платеж 10 руб. наличными
- Выбираем картой → автоматически подставится 20 руб. (а не 30!)

Это предотвращает ошибки и переплаты при смешанной оплате.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 21:31:45 +03:00

366 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
<!-- Кнопка добавить (только для mixed mode) -->
${this.mode === 'mixed' ? `
<div class="d-flex gap-2">
<button type="button"
class="btn btn-sm btn-outline-primary"
id="${this.containerId}-add-btn">
<i class="bi bi-plus-circle"></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());
}
}
// Отправка
// Кнопка "Оплатить" удалена - используется только основная кнопка "Подтвердить продажу" в модалке
}
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`);
// Вычисляем остаток с учетом уже добавленных платежей
const remaining = this.order.amount_due - this.getTotalPayments();
if (this.selectedMethod.code === 'account_balance' && this.customer) {
const maxAmount = Math.min(remaining, this.customer.wallet_balance);
amountInput.max = maxAmount;
amountInput.value = maxAmount.toFixed(2);
hint.textContent = `Макс: ${maxAmount.toFixed(2)} руб. | Осталось: ${remaining.toFixed(2)} руб.`;
} else {
amountInput.removeAttribute('max');
amountInput.value = remaining.toFixed(2);
hint.textContent = `Осталось оплатить: ${remaining.toFixed(2)} руб.`;
}
}
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);
}
}
}