Рефакторинг: отдельные endpoints для управления платежами (Django best practices)
ПРОБЛЕМА: Использование PaymentFormSet для платежей было НЕПРАВИЛЬНЫМ подходом: 1. Платежи = финансовые транзакции (не должны редактироваться inline) 2. Формы валидировали существующие платежи как новые 3. Сложная логика с formset management forms 4. Конфликты валидации кошелька РЕШЕНИЕ (Django Best Practices): Разделили управление платежами на отдельные операции: АРХИТЕКТУРА: ` POST /orders/111/payments/add/ # Добавить платеж POST /orders/111/payments/123/delete/ # Удалить платеж ` ПРЕИМУЩЕСТВА: ✅ Чистая архитектура (separation of concerns) ✅ Платежи = неизменяемые транзакции ✅ Простая валидация (только для новых) ✅ Легко тестировать ✅ API-ready структура ИЗМЕНЕНИЯ: 1. orders/views.py: - Убран PaymentFormSet из order_create и order_update - Добавлен payment_add(request, order_number) - Добавлен payment_delete(request, order_number, payment_id) - Используется простой PaymentForm вместо formset - Payment.save() автоматически обрабатывает: * Списание из кошелька * Обработку переплаты * Обновление amount_paid 2. orders/urls.py: - Добавлены URL patterns для payment-add и payment-delete - Структура: /orders/<number>/payments/add|<id>/delete/ 3. orders/templates/orders/order_form.html: - Убран PaymentFormSet и все его скрипты (~265 строк) - Простая HTML форма для добавления платежа - Существующие платежи: read-only список с кнопками удаления - Каждое удаление = отдельный POST запрос - Для создания: показываем предупреждение вместо формы 4. orders/templatetags/orders_tags.py (NEW): - Template tag get_payment_methods - Загружает активные способы оплаты - Использование: {% get_payment_methods as payment_methods %} РЕЗУЛЬТАТ: - Код: -191 строка - Логика: простая и понятная - Архитектура: правильная (как в учебнике) - Платежи: только add/delete (без edit) - Валидация: работает корректно - UX: чище и понятнее
This commit is contained in:
@@ -571,44 +571,32 @@
|
||||
|
||||
<!-- Оплата (смешанная оплата) -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-credit-card"></i> Оплата</h5>
|
||||
<button type="button" class="btn btn-sm btn-success" id="add-payment-btn">
|
||||
<i class="bi bi-plus-circle"></i> Добавить платеж
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Блок кошелька клиента -->
|
||||
{% if order.customer %}
|
||||
<div class="alert alert-info d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong><i class="bi bi-wallet2"></i> Кошелёк клиента:</strong>
|
||||
{% if order.customer.wallet_balance > 0 %}
|
||||
<span class="text-success fw-bold">{{ order.customer.wallet_balance|floatformat:2 }} руб.</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0.00 руб.</span>
|
||||
{% endif %}
|
||||
<span class="ms-3">Остаток к оплате: <strong>{{ order.amount_due|floatformat:2 }} руб.</strong></span>
|
||||
</div>
|
||||
{% if order.customer.wallet_balance > 0 and order.amount_due > 0 %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="apply-wallet-max-btn">
|
||||
Учесть максимум
|
||||
</button>
|
||||
<div class="input-group" style="max-width: 280px;">
|
||||
<input type="number" step="0.01" min="0" class="form-control form-control-sm" id="apply-wallet-amount-input" placeholder="Сумма из кошелька">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="apply-wallet-amount-btn">Учесть сумму</button>
|
||||
<div class="alert alert-info mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong><i class="bi bi-wallet2"></i> Кошелёк клиента:</strong>
|
||||
{% if order.customer.wallet_balance > 0 %}
|
||||
<span class="text-success fw-bold">{{ order.customer.wallet_balance|floatformat:2 }} руб.</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0.00 руб.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Остаток к оплате:</strong>
|
||||
<span class="text-primary fw-bold">{{ order.amount_due|floatformat:2 }} руб.</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Скрытые поля для formset management -->
|
||||
{{ payment_formset.management_form }}
|
||||
|
||||
<!-- Уже сохраненные платежи (информационно) -->
|
||||
<!-- Уже сохраненные платежи -->
|
||||
{% if order.pk and order.payments.exists %}
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted mb-3"><i class="bi bi-check-circle"></i> Проведенные платежи</h6>
|
||||
@@ -628,13 +616,14 @@
|
||||
<span class="text-muted">{{ payment.notes|default:"—" }}</span>
|
||||
</div>
|
||||
<div class="col-md-1 text-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm delete-existing-payment-btn"
|
||||
data-payment-id="{{ payment.id }}"
|
||||
data-payment-name="{{ payment.payment_method.name }}"
|
||||
data-payment-amount="{{ payment.amount|floatformat:2 }}"
|
||||
title="Удалить платеж">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<form method="post" action="{% url 'orders:payment-delete' order.order_number payment.id %}" style="display: inline;">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm"
|
||||
onclick="return confirm('Удалить платеж {{ payment.payment_method.name }} на сумму {{ payment.amount|floatformat:2 }} руб.?');"
|
||||
title="Удалить платеж">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -642,251 +631,64 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Контейнер для НОВЫХ платежей -->
|
||||
<div id="payments-container">
|
||||
<!-- Здесь будут добавляться новые платежи -->
|
||||
<!-- Форма добавления нового платежа -->
|
||||
{% if order.pk %}
|
||||
<div class="border rounded p-3 bg-white">
|
||||
<h6 class="mb-3"><i class="bi bi-plus-circle"></i> Добавить новый платеж</h6>
|
||||
<form method="post" action="{% url 'orders:payment-add' order.order_number %}" id="payment-add-form">
|
||||
{% csrf_token %}
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Способ оплаты</label>
|
||||
<select name="payment_method" class="form-select" required>
|
||||
<option value="">---------</option>
|
||||
{% load orders_tags %}
|
||||
{% get_payment_methods as payment_methods %}
|
||||
{% for pm in payment_methods %}
|
||||
<option value="{{ pm.id }}">{{ pm.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Сумма</label>
|
||||
<input type="number" name="amount" step="0.01" min="0.01" class="form-control" placeholder="0.00" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Примечания</label>
|
||||
<input type="text" name="notes" class="form-control" placeholder="Опционально">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-plus-lg"></i> Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning mb-0">
|
||||
<i class="bi bi-info-circle"></i> Сначала создайте заказ, затем вы сможете добавлять платежи.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Итоговая сумма платежей -->
|
||||
<div id="payments-total-section" class="border-top pt-3 mt-3 mb-3">
|
||||
{% if order.pk %}
|
||||
<div class="border-top pt-3 mt-3">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<p class="mb-0 text-muted"><i class="bi bi-cash-stack"></i> Всего внесено:</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<h5 class="mb-0 text-success">
|
||||
<span id="payments-total-value">{{ order.amount_paid|default:"0.00"|floatformat:2 }}</span> руб.
|
||||
{{ order.amount_paid|default:"0.00"|floatformat:2 }} руб.
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Скрытый шаблон для новых платежей -->
|
||||
<template id="empty-payment-form-template">
|
||||
<div class="payment-form border rounded p-3 mb-3" data-form-index="__prefix__">
|
||||
<input type="hidden" name="payments-__prefix__-id" id="id_payments-__prefix__-id" form="order-form">
|
||||
<input type="checkbox" name="payments-__prefix__-DELETE" id="id_payments-__prefix__-DELETE" form="order-form" style="display: none;">
|
||||
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Способ оплаты</label>
|
||||
<select name="payments-__prefix__-payment_method" class="form-select" id="id_payments-__prefix__-payment_method" form="order-form">
|
||||
<option value="">---------</option>
|
||||
{% for pm in payment_formset.forms.0.fields.payment_method.queryset %}
|
||||
<option value="{{ pm.id }}">{{ pm.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Сумма</label>
|
||||
<input type="number" name="payments-__prefix__-amount" step="0.01" min="0" class="form-control" placeholder="Сумма платежа" id="id_payments-__prefix__-amount" form="order-form">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Примечания</label>
|
||||
<textarea name="payments-__prefix__-notes" class="form-control" rows="1" placeholder="Примечания к платежу (опционально)" id="id_payments-__prefix__-notes" form="order-form"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="button" class="btn btn-danger btn-sm w-100 remove-payment-btn">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const walletBalance = parseFloat("{{ order.customer.wallet_balance|default:'0' }}".replace(',', '.')) || 0;
|
||||
const amountDue = parseFloat("{{ order.amount_due|default:'0' }}".replace(',', '.')) || 0;
|
||||
|
||||
function addPaymentRow() {
|
||||
const totalFormsInput = document.querySelector('input[name="payments-TOTAL_FORMS"]');
|
||||
const idx = parseInt(totalFormsInput.value, 10);
|
||||
const tpl = document.getElementById('empty-payment-form-template').innerHTML.replace(/__prefix__/g, idx);
|
||||
const container = document.getElementById('payments-container');
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = tpl.trim();
|
||||
container.appendChild(wrapper.firstElementChild);
|
||||
totalFormsInput.value = String(idx + 1);
|
||||
return container.querySelector(`.payment-form[data-form-index="${idx}"]`);
|
||||
}
|
||||
|
||||
function selectAccountBalance(selectEl) {
|
||||
if (!selectEl) return;
|
||||
const options = Array.from(selectEl.options);
|
||||
const target = options.find(o => o.textContent.trim() === 'С баланса счёта');
|
||||
if (target) {
|
||||
selectEl.value = target.value;
|
||||
selectEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function applyWallet(amount) {
|
||||
if (!amount || amount <= 0) {
|
||||
alert('Введите сумму больше 0.');
|
||||
return;
|
||||
}
|
||||
if (amount > walletBalance) {
|
||||
alert(`Недостаточно средств в кошельке. Доступно ${walletBalance.toFixed(2)} руб.`);
|
||||
return;
|
||||
}
|
||||
if (amount > amountDue) {
|
||||
alert(`Сумма превышает остаток к оплате (${amountDue.toFixed(2)} руб.).`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Всегда добавляем новую строку платежа
|
||||
const formEl = addPaymentRow();
|
||||
const sel = formEl.querySelector('select[id^="id_payments-"][id$="-payment_method"]');
|
||||
const amt = formEl.querySelector('input[id^="id_payments-"][id$="-amount"]');
|
||||
const notes = formEl.querySelector('textarea[id^="id_payments-"][id$="-notes"]');
|
||||
|
||||
// Загружаем список способов оплаты
|
||||
if (sel) {
|
||||
fetch('/products/api/payment-methods/')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
sel.innerHTML = '<option value="">---------</option>';
|
||||
data.forEach(method => {
|
||||
const option = document.createElement('option');
|
||||
option.value = method.id;
|
||||
option.textContent = method.name;
|
||||
sel.appendChild(option);
|
||||
});
|
||||
|
||||
// После загрузки устанавливаем "С баланса счёта"
|
||||
for (let i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].textContent.trim() === 'С баланса счёта') {
|
||||
sel.value = sel.options[i].value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading payment methods:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Проставляем сумму
|
||||
if (amt) {
|
||||
const maxUsable = Math.min(walletBalance, amountDue);
|
||||
const finalAmount = Math.min(amount, maxUsable);
|
||||
amt.value = finalAmount.toFixed(2);
|
||||
amt.setAttribute('max', maxUsable.toFixed(2));
|
||||
}
|
||||
|
||||
// Небольшая подсказка в примечания
|
||||
if (notes && !notes.value) {
|
||||
notes.value = 'Оплата из кошелька';
|
||||
}
|
||||
|
||||
// Добавляем обработчик удаления
|
||||
const removeBtn = formEl.querySelector('.remove-payment-btn');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', function() {
|
||||
if (!confirm('Вы действительно хотите удалить этот платеж?')) {
|
||||
return;
|
||||
}
|
||||
const deleteCheckbox = formEl.querySelector('input[name$="-DELETE"]');
|
||||
const idField = formEl.querySelector('input[name$="-id"]');
|
||||
if (idField && idField.value) {
|
||||
deleteCheckbox.checked = true;
|
||||
formEl.classList.add('deleted');
|
||||
formEl.style.display = 'none';
|
||||
} else {
|
||||
formEl.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчики кнопок применения кошелька
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const applyMaxBtn = document.getElementById('apply-wallet-max-btn');
|
||||
const applyAmountBtn = document.getElementById('apply-wallet-amount-btn');
|
||||
const amountInput = document.getElementById('apply-wallet-amount-input');
|
||||
|
||||
if (applyMaxBtn) {
|
||||
applyMaxBtn.addEventListener('click', function() {
|
||||
const maxUsable = Math.min(walletBalance, amountDue);
|
||||
applyWallet(maxUsable);
|
||||
});
|
||||
}
|
||||
if (applyAmountBtn && amountInput) {
|
||||
applyAmountBtn.addEventListener('click', function() {
|
||||
const val = parseFloat((amountInput.value || '0').replace(',', '.')) || 0;
|
||||
applyWallet(val);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Автозаполнение при выборе "С баланса счёта"
|
||||
document.getElementById('payments-container').addEventListener('change', function(e) {
|
||||
const sel = e.target;
|
||||
if (sel.tagName === 'SELECT') {
|
||||
const label = sel.options[sel.selectedIndex]?.textContent?.trim() || '';
|
||||
if (label === 'С баланса счёта') {
|
||||
const wrap = sel.closest('.payment-form');
|
||||
const amt = wrap.querySelector('input[id^="id_payments-"][id$="-amount"]');
|
||||
if (amt) {
|
||||
const maxUsable = Math.min(walletBalance, amountDue);
|
||||
amt.value = maxUsable.toFixed(2);
|
||||
amt.setAttribute('max', maxUsable.toFixed(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Обработчик удаления существующих платежей
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteButtons = document.querySelectorAll('.delete-existing-payment-btn');
|
||||
|
||||
deleteButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const paymentId = this.dataset.paymentId;
|
||||
const paymentName = this.dataset.paymentName;
|
||||
const paymentAmount = this.dataset.paymentAmount;
|
||||
|
||||
if (!confirm(`Удалить платеж "${paymentName}" на сумму ${paymentAmount} руб.?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем скрытую форму для отправки
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = window.location.href;
|
||||
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrfmiddlewaretoken';
|
||||
csrfInput.value = csrfToken;
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
const paymentIdInput = document.createElement('input');
|
||||
paymentIdInput.type = 'hidden';
|
||||
paymentIdInput.name = 'delete_payment_id';
|
||||
paymentIdInput.value = paymentId;
|
||||
form.appendChild(paymentIdInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-three-dots"></i> Дополнительно</h5>
|
||||
|
||||
Reference in New Issue
Block a user