feat: Add customer prefill from URL parameter in order creation

- Modified order_create view to read customer from GET parameter
- Pass preselected_customer to template context
- Template renders select with preselected option for Select2
- Fixed draft creation timing with callback after Select2 initialization
- Auto-create draft when customer is preselected from URL
- Graceful handling if customer not found or invalid ID
This commit is contained in:
2025-11-27 00:17:02 +03:00
parent 5ead7fdd2e
commit c62cdb0298
6 changed files with 463 additions and 19 deletions

View File

@@ -5,6 +5,7 @@ from .models import Order, OrderItem, Payment, Address, OrderStatus
from customers.models import Customer
from inventory.models import Warehouse
from products.models import Product, ProductKit
from decimal import Decimal
class OrderForm(forms.ModelForm):
@@ -481,6 +482,44 @@ class PaymentForm(forms.ModelForm):
# Делаем notes опциональным
self.fields['notes'].required = False
def clean(self):
"""Валидация платежа, особенно для оплаты из кошелька"""
cleaned = super().clean()
method = cleaned.get('payment_method')
amount = cleaned.get('amount')
order = getattr(self.instance, 'order', None)
# Пустые формы допустимы при удалении
if not method and not amount:
return cleaned
# Базовые проверки
if amount is None or amount <= 0:
self.add_error('amount', 'Введите сумму больше 0.')
if not order:
raise forms.ValidationError('Заказ не найден для платежа.')
# Проверка для оплаты из кошелька
if method and getattr(method, 'code', None) == 'account_balance':
wallet_balance = order.customer.wallet_balance if order.customer else Decimal('0')
amount_due = max(order.total_amount - order.amount_paid, Decimal('0'))
if wallet_balance <= 0:
self.add_error('payment_method', 'Недостаточно средств в кошельке клиента (баланс 0).')
if amount and amount > wallet_balance:
self.add_error('amount', f'Недостаточно средств в кошельке. Доступно {wallet_balance} руб.')
if amount and amount > amount_due:
self.add_error('amount', f'Сумма превышает остаток к оплате ({amount_due} руб.)')
if self.errors:
# Общее сообщение для блока формы
raise forms.ValidationError('Проверьте поля оплаты из кошелька.')
return cleaned
# Formset для множественных платежей
PaymentFormSet = inlineformset_factory(

View File

@@ -1,5 +1,8 @@
from django.db import models
from accounts.models import CustomUser
from decimal import Decimal
from django.db import transaction
from django.core.exceptions import ValidationError
class PaymentMethod(models.Model):
@@ -137,16 +140,41 @@ class Payment(models.Model):
return f"Платеж {self.amount} руб. по заказу #{self.order.order_number}"
def save(self, *args, **kwargs):
"""При сохранении платежа обновляем сумму оплаты в заказе"""
super().save(*args, **kwargs)
# Пересчитываем общую сумму оплаты в заказе
self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
self.order.update_payment_status()
"""При сохранении платежа обновляем сумму оплаты в заказе и обрабатываем кошелёк/переплаты"""
is_new = self.pk is None
with transaction.atomic():
super().save(*args, **kwargs)
# Нормализация переплаты: лишнее в кошелёк, amount_paid = total_amount
try:
from customers.services.wallet_service import WalletService
WalletService.add_overpayment(self.order, self.created_by)
except Exception:
# Если обработка переплаты не удалась, продолжаем без ошибок
pass
# Пересчитываем общую сумму оплаты в заказе
self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
self.order.update_payment_status()
# Списание из кошелька при новом платеже методом 'account_balance'
if is_new and self.payment_method.code == 'account_balance':
from customers.models import Customer, WalletTransaction
# Блокируем запись клиента
customer = Customer.objects.select_for_update().get(pk=self.order.customer_id)
if customer.wallet_balance < self.amount:
raise ValidationError(f'Недостаточно средств в кошельке (доступно {customer.wallet_balance} руб.)')
# Списываем и округляем до 2 знаков
customer.wallet_balance = (customer.wallet_balance - self.amount).quantize(Decimal('0.01'))
customer.save(update_fields=['wallet_balance'])
# Пишем историю
WalletTransaction.objects.create(
customer=customer,
amount=self.amount,
transaction_type='spend',
order=self.order,
description=f'Оплата из кошелька по заказу #{self.order.order_number}',
created_by=self.created_by
)
# Нормализация переплаты: лишнее в кошелёк, amount_paid = total_amount
try:
from customers.services.wallet_service import WalletService
WalletService.add_overpayment(self.order, self.created_by)
except Exception:
# Продолжаем, даже если нормализация переплаты не удалась
pass

View File

@@ -126,7 +126,15 @@
<label for="{{ form.customer.id_for_label }}" class="form-label">
Клиент <span class="text-danger">*</span>
</label>
{{ form.customer }}
{% if preselected_customer %}
<select name="customer" class="form-select" id="id_customer">
<option value="{{ preselected_customer.pk }}" selected data-name="{{ preselected_customer.name }}" data-phone="{{ preselected_customer.phone|default:'' }}" data-email="{{ preselected_customer.email|default:'' }}">
{{ preselected_customer.name }}{% if preselected_customer.phone %} ({{ preselected_customer.phone }}){% endif %}
</option>
</select>
{% else %}
{{ form.customer }}
{% endif %}
{% if form.customer.errors %}
<div class="text-danger">{{ form.customer.errors }}</div>
{% endif %}
@@ -565,11 +573,43 @@
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Оплата</h5>
<button type="button" class="btn btn-sm btn-success" id="add-payment-btn">
<i class="bi bi-plus-circle"></i> Добавить платеж
</button>
<div>
<span class="badge bg-{% if order.payment_status == 'paid' %}success{% elif order.payment_status == 'partial' %}warning{% else %}danger{% endif %} me-2">
{{ order.get_payment_status_display }}
</span>
<button type="button" class="btn btn-sm btn-success" id="add-payment-btn">
<i class="bi bi-plus-circle"></i> Добавить платеж
</button>
</div>
</div>
<div class="card-body">
<!-- Блок кошелька клиента -->
{% if order.customer %}
<div class="alert alert-info d-flex justify-content-between align-items-center">
<div>
<strong>Кошелёк клиента:</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>
</div>
{% endif %}
</div>
{% endif %}
<!-- Скрытые поля для formset management -->
{{ payment_formset.management_form }}
@@ -639,6 +679,9 @@
<label class="form-label">Способ оплаты</label>
<select name="payments-__prefix__-payment_method" class="form-select" id="id_payments-__prefix__-payment_method">
<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>
@@ -675,6 +718,98 @@
</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;
}
// Найти существующую форму платежа без метода, иначе добавить новую
let formEl = document.querySelector('#payments-container .payment-form:last-child');
if (!formEl) {
formEl = addPaymentRow();
}
const sel = formEl.querySelector('select[id^="id_payments-"][id$="-payment_method"]');
const amt = formEl.querySelector('input[id^="id_payments-"][id$="-amount"]');
selectAccountBalance(sel);
if (amt) {
amt.value = amount.toFixed(2);
amt.setAttribute('max', Math.min(walletBalance, amountDue).toFixed(2));
}
}
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>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Дополнительно</h5>
@@ -781,6 +916,14 @@ function initCustomerSelect2() {
console.log('Значение восстановлено:', $customerSelect.val());
}
// Уведомляем draft-creator.js что Select2 готов и есть предзаполненное значение
if (currentValue && window.DraftCreator) {
console.log('7. Уведомляем DraftCreator о предзаполненном клиенте');
setTimeout(function() {
window.DraftCreator.triggerDraftCreation();
}, 100);
}
// Слушаем события
$customerSelect.on('select2:open', function(e) {
console.log('7. Dropdown открыт');

View File

@@ -108,7 +108,19 @@ def order_create(request):
else:
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
else:
form = OrderForm()
# Предзаполнение клиента из GET параметра
initial_data = {}
preselected_customer = None
customer_id = request.GET.get('customer')
if customer_id:
try:
from customers.models import Customer
preselected_customer = Customer.objects.get(pk=customer_id)
initial_data['customer'] = preselected_customer.pk
except (Customer.DoesNotExist, ValueError):
pass
form = OrderForm(initial=initial_data)
formset = OrderItemFormSet()
payment_formset = PaymentFormSet()
@@ -116,6 +128,7 @@ def order_create(request):
'form': form,
'formset': formset,
'payment_formset': payment_formset,
'preselected_customer': preselected_customer,
'title': 'Создание заказа',
'button_text': 'Создать заказ',
}