Рефакторинг: отдельные 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:
2025-11-29 02:27:50 +03:00
parent ee002d5fed
commit 84ed3a0c7d
5 changed files with 160 additions and 342 deletions

View File

@@ -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 }}&nbsp;руб.</span>
{% else %}
<span class="text-muted">0.00&nbsp;руб.</span>
{% endif %}
</div>
<div>
<strong>Остаток к оплате:</strong>
<span class="text-primary fw-bold">{{ order.amount_due|floatformat:2 }}&nbsp;руб.</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>&nbsp;руб.
{{ order.amount_paid|default:"0.00"|floatformat:2 }}&nbsp;руб.
</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>