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

@@ -46,9 +46,11 @@
<th>Баланс кошелька:</th> <th>Баланс кошелька:</th>
<td> <td>
{% if customer.wallet_balance > 0 %} {% if customer.wallet_balance > 0 %}
<span class="text-success fw-bold">{{ customer.wallet_balance|floatformat:2 }} руб.</span> <span class="badge bg-success" style="font-size: 1.1em;">{{ customer.wallet_balance|floatformat:2 }} руб.</span>
{% elif customer.wallet_balance == 0 %}
<span class="badge bg-secondary">{{ customer.wallet_balance|floatformat:2 }} руб.</span>
{% else %} {% else %}
{{ customer.wallet_balance|floatformat:2 }} руб. <span class="badge bg-danger">{{ customer.wallet_balance|floatformat:2 }} руб.</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -79,6 +81,211 @@
</div> </div>
</div> </div>
</div> </div>
<!-- История транзакций кошелька -->
<div class="col-md-12">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5>История кошелька (последние 20)</h5>
<span class="badge bg-primary">{{ wallet_transactions|length }}</span>
</div>
<div class="card-body">
{% if wallet_transactions %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Дата</th>
<th>Тип</th>
<th>Сумма</th>
<th>Описание</th>
<th>Заказ</th>
<th>Создал</th>
</tr>
</thead>
<tbody>
{% for transaction in wallet_transactions %}
<tr>
<td><small>{{ transaction.created_at|date:"d.m.Y H:i" }}</small></td>
<td>
{% if transaction.transaction_type == 'deposit' %}
<span class="badge bg-success">Пополнение</span>
{% elif transaction.transaction_type == 'spend' %}
<span class="badge bg-danger">Списание</span>
{% else %}
<span class="badge bg-warning">Корректировка</span>
{% endif %}
</td>
<td>
{% if transaction.transaction_type == 'deposit' or transaction.transaction_type == 'adjustment' and transaction.amount > 0 %}
<span class="text-success fw-bold">+{{ transaction.amount|floatformat:2 }} руб.</span>
{% else %}
<span class="text-danger fw-bold">-{{ transaction.amount|floatformat:2 }} руб.</span>
{% endif %}
</td>
<td>{{ transaction.description|default:"-" }}</td>
<td>
{% if transaction.order %}
<a href="{% url 'orders:order-detail' transaction.order.pk %}" class="btn btn-sm btn-outline-primary">
#{{ transaction.order.order_number }}
</a>
{% else %}
-
{% endif %}
</td>
<td><small>{{ transaction.created_by.username|default:"-" }}</small></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">История транзакций пуста.</p>
{% endif %}
</div>
</div>
</div>
<!-- История заказов -->
<div class="col-md-12">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5>История заказов</h5>
<div>
<span class="badge bg-primary">{{ orders_page.paginator.count }}</span>
<a href="{% url 'orders:order-create' %}?customer={{ customer.pk }}" class="btn btn-sm btn-success ms-2">
<i class="bi bi-plus-circle"></i> Новый заказ
</a>
</div>
</div>
<div class="card-body">
{% if orders_page %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th></th>
<th>Дата создания</th>
<th>Дата доставки</th>
<th>Статус</th>
<th>Оплата</th>
<th>Сумма</th>
<th>Оплачено</th>
<th>Остаток</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for order in orders_page %}
<tr>
<td><strong>#{{ order.order_number }}</strong></td>
<td><small>{{ order.created_at|date:"d.m.Y H:i" }}</small></td>
<td>
{% if order.delivery_date %}
<strong>{{ order.delivery_date|date:"d.m.Y" }}</strong>
{% if order.delivery_time %}
<br><small class="text-muted">{{ order.delivery_time }}</small>
{% endif %}
{% if order.is_delivery %}
<br><span class="badge bg-info">Доставка</span>
{% else %}
<br><span class="badge bg-secondary">Самовывоз</span>
{% endif %}
{% else %}
<span class="text-muted">Не указана</span>
{% endif %}
</td>
<td>
{% if order.status == 'draft' %}
<span class="badge bg-secondary">Черновик</span>
{% elif order.status == 'pending' %}
<span class="badge bg-warning">Ожидает</span>
{% elif order.status == 'in_production' %}
<span class="badge bg-info">В производстве</span>
{% elif order.status == 'ready' %}
<span class="badge bg-primary">Готов</span>
{% elif order.status == 'delivered' %}
<span class="badge bg-success">Доставлен</span>
{% elif order.status == 'cancelled' %}
<span class="badge bg-danger">Отменён</span>
{% else %}
<span class="badge bg-secondary">{{ order.get_status_display }}</span>
{% endif %}
</td>
<td>
{% if order.payment_status == 'paid' %}
<span class="badge bg-success">Оплачено</span>
{% elif order.payment_status == 'partial' %}
<span class="badge bg-warning">Частично</span>
{% else %}
<span class="badge bg-danger">Не оплачено</span>
{% endif %}
</td>
<td><strong>{{ order.total_amount|floatformat:2 }} руб.</strong></td>
<td>
{% if order.amount_paid > 0 %}
<span class="text-success">{{ order.amount_paid|floatformat:2 }} руб.</span>
{% else %}
<span class="text-muted">0.00 руб.</span>
{% endif %}
</td>
<td>
{% if order.amount_due > 0 %}
<span class="text-danger fw-bold">{{ order.amount_due|floatformat:2 }} руб.</span>
{% else %}
<span class="text-success">0.00 руб.</span>
{% endif %}
</td>
<td>
<a href="{% url 'orders:order-detail' order.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
<a href="{% url 'orders:order-update' order.pk %}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if orders_page.has_other_pages %}
<nav aria-label="Навигация по заказам">
<ul class="pagination justify-content-center mt-3">
{% if orders_page.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ orders_page.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">
Страница {{ orders_page.number }} из {{ orders_page.paginator.num_pages }}
</span>
</li>
{% if orders_page.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ orders_page.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ orders_page.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<p class="text-muted mb-0">У клиента пока нет заказов.</p>
{% endif %}
</div>
</div>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -89,10 +89,24 @@ def customer_detail(request, pk):
active_orders = customer.orders.exclude(payment_status='paid') active_orders = customer.orders.exclude(payment_status='paid')
total_debt = sum(order.amount_due for order in active_orders) total_debt = sum(order.amount_due for order in active_orders)
# История транзакций кошелька (последние 20)
from .models import WalletTransaction
wallet_transactions = WalletTransaction.objects.filter(
customer=customer
).select_related('order', 'created_by').order_by('-created_at')[:20]
# История заказов с пагинацией
orders_list = customer.orders.all().order_by('-created_at')
paginator = Paginator(orders_list, 10) # 10 заказов на страницу
page_number = request.GET.get('page')
orders_page = paginator.get_page(page_number)
context = { context = {
'customer': customer, 'customer': customer,
'total_debt': total_debt, 'total_debt': total_debt,
'active_orders_count': active_orders.count(), 'active_orders_count': active_orders.count(),
'wallet_transactions': wallet_transactions,
'orders_page': orders_page,
} }
return render(request, 'customers/customer_detail.html', context) return render(request, 'customers/customer_detail.html', context)

View File

@@ -5,6 +5,7 @@ from .models import Order, OrderItem, Payment, Address, OrderStatus
from customers.models import Customer from customers.models import Customer
from inventory.models import Warehouse from inventory.models import Warehouse
from products.models import Product, ProductKit from products.models import Product, ProductKit
from decimal import Decimal
class OrderForm(forms.ModelForm): class OrderForm(forms.ModelForm):
@@ -481,6 +482,44 @@ class PaymentForm(forms.ModelForm):
# Делаем notes опциональным # Делаем notes опциональным
self.fields['notes'].required = False 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 для множественных платежей # Formset для множественных платежей
PaymentFormSet = inlineformset_factory( PaymentFormSet = inlineformset_factory(

View File

@@ -1,5 +1,8 @@
from django.db import models from django.db import models
from accounts.models import CustomUser from accounts.models import CustomUser
from decimal import Decimal
from django.db import transaction
from django.core.exceptions import ValidationError
class PaymentMethod(models.Model): class PaymentMethod(models.Model):
@@ -137,16 +140,41 @@ class Payment(models.Model):
return f"Платеж {self.amount} руб. по заказу #{self.order.order_number}" return f"Платеж {self.amount} руб. по заказу #{self.order.order_number}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""При сохранении платежа обновляем сумму оплаты в заказе""" """При сохранении платежа обновляем сумму оплаты в заказе и обрабатываем кошелёк/переплаты"""
super().save(*args, **kwargs) is_new = self.pk is None
# Пересчитываем общую сумму оплаты в заказе with transaction.atomic():
self.order.amount_paid = sum(p.amount for p in self.order.payments.all()) super().save(*args, **kwargs)
self.order.update_payment_status()
# Нормализация переплаты: лишнее в кошелёк, amount_paid = total_amount # Пересчитываем общую сумму оплаты в заказе
try: self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
from customers.services.wallet_service import WalletService self.order.update_payment_status()
WalletService.add_overpayment(self.order, self.created_by)
except Exception: # Списание из кошелька при новом платеже методом 'account_balance'
# Если обработка переплаты не удалась, продолжаем без ошибок if is_new and self.payment_method.code == 'account_balance':
pass 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"> <label for="{{ form.customer.id_for_label }}" class="form-label">
Клиент <span class="text-danger">*</span> Клиент <span class="text-danger">*</span>
</label> </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 %} {% if form.customer.errors %}
<div class="text-danger">{{ form.customer.errors }}</div> <div class="text-danger">{{ form.customer.errors }}</div>
{% endif %} {% endif %}
@@ -565,11 +573,43 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Оплата</h5> <h5 class="mb-0">Оплата</h5>
<button type="button" class="btn btn-sm btn-success" id="add-payment-btn"> <div>
<i class="bi bi-plus-circle"></i> Добавить платеж <span class="badge bg-{% if order.payment_status == 'paid' %}success{% elif order.payment_status == 'partial' %}warning{% else %}danger{% endif %} me-2">
</button> {{ 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>
<div class="card-body"> <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 --> <!-- Скрытые поля для formset management -->
{{ payment_formset.management_form }} {{ payment_formset.management_form }}
@@ -639,6 +679,9 @@
<label class="form-label">Способ оплаты</label> <label class="form-label">Способ оплаты</label>
<select name="payments-__prefix__-payment_method" class="form-select" id="id_payments-__prefix__-payment_method"> <select name="payments-__prefix__-payment_method" class="form-select" id="id_payments-__prefix__-payment_method">
<option value="">---------</option> <option value="">---------</option>
{% for pm in payment_formset.forms.0.fields.payment_method.queryset %}
<option value="{{ pm.id }}">{{ pm.name }}</option>
{% endfor %}
</select> </select>
</div> </div>
</div> </div>
@@ -675,6 +718,98 @@
</div> </div>
</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 mb-3">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Дополнительно</h5> <h5 class="mb-0">Дополнительно</h5>
@@ -781,6 +916,14 @@ function initCustomerSelect2() {
console.log('Значение восстановлено:', $customerSelect.val()); 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) { $customerSelect.on('select2:open', function(e) {
console.log('7. Dropdown открыт'); console.log('7. Dropdown открыт');

View File

@@ -108,7 +108,19 @@ def order_create(request):
else: else:
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.') messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
else: 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() formset = OrderItemFormSet()
payment_formset = PaymentFormSet() payment_formset = PaymentFormSet()
@@ -116,6 +128,7 @@ def order_create(request):
'form': form, 'form': form,
'formset': formset, 'formset': formset,
'payment_formset': payment_formset, 'payment_formset': payment_formset,
'preselected_customer': preselected_customer,
'title': 'Создание заказа', 'title': 'Создание заказа',
'button_text': 'Создать заказ', 'button_text': 'Создать заказ',
} }