Добавлена функциональность редактирования заказов с обновлением резервов товаров
This commit is contained in:
@@ -143,6 +143,7 @@ class OrderForm(forms.ModelForm):
|
||||
'class': 'form-select select2',
|
||||
'data-placeholder': 'Выберите адрес доставки'
|
||||
})
|
||||
# Адрес доставки не обязателен при редактировании (создаётся из отдельных полей)
|
||||
self.fields['delivery_address'].required = False
|
||||
|
||||
self.fields['pickup_warehouse'].widget.attrs.update({
|
||||
@@ -221,9 +222,16 @@ class OrderForm(forms.ModelForm):
|
||||
class OrderItemForm(forms.ModelForm):
|
||||
"""Форма для позиции заказа"""
|
||||
|
||||
# Элегантно переопределяем поле формы, чтобы парсить '277,00' как Decimal
|
||||
price = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = OrderItem
|
||||
fields = ['product', 'product_kit', 'quantity', 'price', 'is_custom_price']
|
||||
# ВАЖНО: НЕ включаем 'id' в fields - это предотвращает ошибку валидации
|
||||
widgets = {
|
||||
'quantity': forms.NumberInput(attrs={'min': 1, 'value': 1}),
|
||||
# Скрываем поля product и product_kit - они будут заполняться через JS
|
||||
@@ -254,6 +262,17 @@ class OrderItemForm(forms.ModelForm):
|
||||
# Поле is_custom_price устанавливается через JS
|
||||
self.fields['is_custom_price'].required = False
|
||||
|
||||
def clean_price(self):
|
||||
"""Парсим цену с запятой или точкой"""
|
||||
value = self.cleaned_data.get('price')
|
||||
if value in (None, ''):
|
||||
return None
|
||||
value_str = str(value).strip().replace(',', '.')
|
||||
try:
|
||||
return Decimal(value_str)
|
||||
except Exception:
|
||||
raise forms.ValidationError('Введите число.')
|
||||
|
||||
def clean(self):
|
||||
"""Валидация: должен быть выбран либо товар, либо комплект (не оба, не ни один)"""
|
||||
cleaned_data = super().clean()
|
||||
|
||||
@@ -263,23 +263,18 @@ class Order(models.Model):
|
||||
"""Валидация модели"""
|
||||
super().clean()
|
||||
|
||||
# Проверка: для доставки обязателен адрес
|
||||
if self.is_delivery and not self.delivery_address:
|
||||
raise ValidationError({
|
||||
'delivery_address': 'Для доставки необходимо указать адрес доставки'
|
||||
})
|
||||
|
||||
# Проверка: для самовывоза обязателен склад
|
||||
if not self.is_delivery and not self.pickup_warehouse:
|
||||
raise ValidationError({
|
||||
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
|
||||
})
|
||||
|
||||
# Проверка: время окончания должно быть позже времени начала
|
||||
# Проверка: время окончания должно быть позже или равно времени начала
|
||||
# Равные времена означают точное время доставки (например, "к 13:00")
|
||||
if self.delivery_time_start and self.delivery_time_end:
|
||||
if self.delivery_time_end <= self.delivery_time_start:
|
||||
if self.delivery_time_end < self.delivery_time_start:
|
||||
raise ValidationError({
|
||||
'delivery_time_end': 'Время окончания должно быть позже времени начала'
|
||||
'delivery_time_end': 'Время окончания не может быть раньше времени начала'
|
||||
})
|
||||
|
||||
def get_delivery_cost(self):
|
||||
@@ -384,5 +379,8 @@ class Order(models.Model):
|
||||
def delivery_time_window(self):
|
||||
"""Временное окно доставки"""
|
||||
if self.delivery_time_start and self.delivery_time_end:
|
||||
# Если времена равны - это точное время доставки
|
||||
if self.delivery_time_start == self.delivery_time_end:
|
||||
return f"к {self.delivery_time_start.strftime('%H:%M')}"
|
||||
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
|
||||
return "Время не указано"
|
||||
|
||||
@@ -843,6 +843,90 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Глобально определяем initOrderItemSelect2, чтобы она была доступна при вызове ниже
|
||||
window.initOrderItemSelect2 = function(element) {
|
||||
console.log('[initOrderItemSelect2] Вызвана для элемента:', element);
|
||||
|
||||
// Проверяем доступность jQuery
|
||||
if (typeof $ === 'undefined') {
|
||||
console.error('[initOrderItemSelect2] jQuery не загружен!');
|
||||
return;
|
||||
}
|
||||
|
||||
const $element = $(element);
|
||||
const formIndex = element.dataset.formIndex;
|
||||
console.log('[initOrderItemSelect2] formIndex:', formIndex);
|
||||
|
||||
// Проверяем, что функция initProductSelect2 доступна
|
||||
if (typeof window.initProductSelect2 !== 'function') {
|
||||
console.error('[initOrderItemSelect2] window.initProductSelect2 не определена. Убедитесь, что select2-product-search.js загружен.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[initOrderItemSelect2] Инициализация Select2 через initProductSelect2...');
|
||||
// Инициализируем Select2 с AJAX поиском
|
||||
window.initProductSelect2(
|
||||
element,
|
||||
'all', // Искать и товары, и комплекты
|
||||
'{% url "products:api-search-products-variants" %}'
|
||||
);
|
||||
|
||||
// Обработка выбора элемента
|
||||
$element.on('select2:select', function(e) {
|
||||
// Проверяем наличие params (может не быть при программном вызове)
|
||||
if (!e.params || !e.params.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = e.params.data;
|
||||
const idParts = data.id.split('_');
|
||||
const type = idParts[0]; // 'product' или 'kit'
|
||||
const id = idParts[1];
|
||||
|
||||
// Найти скрытые поля product и product_kit
|
||||
const form = element.closest('.order-item-form');
|
||||
const productField = form.querySelector('[name$="-product"]');
|
||||
const kitField = form.querySelector('[name$="-product_kit"]');
|
||||
const priceField = form.querySelector('[name$="-price"]');
|
||||
const isCustomPriceField = form.querySelector('[name$="-is_custom_price"]');
|
||||
|
||||
const originalPrice = data.actual_price || data.price || '';
|
||||
|
||||
// Установить значение в правильное поле
|
||||
if (type === 'product') {
|
||||
productField.value = id;
|
||||
kitField.value = '';
|
||||
priceField.value = originalPrice;
|
||||
} else if (type === 'kit') {
|
||||
kitField.value = id;
|
||||
productField.value = '';
|
||||
priceField.value = originalPrice;
|
||||
}
|
||||
|
||||
// Сохраняем оригинальную цену в data-атрибуте
|
||||
priceField.dataset.originalPrice = originalPrice;
|
||||
|
||||
// Сбрасываем флаг кастомной цены
|
||||
isCustomPriceField.value = 'false';
|
||||
|
||||
// Скрываем индикатор
|
||||
const badge = form.querySelector('.custom-price-badge');
|
||||
const priceInfo = form.querySelector('.original-price-info');
|
||||
if (badge) badge.style.display = 'none';
|
||||
if (priceInfo) priceInfo.style.display = 'none';
|
||||
});
|
||||
|
||||
// Очистка при удалении выбора
|
||||
$element.on('select2:clear', function() {
|
||||
const form = element.closest('.order-item-form');
|
||||
form.querySelector('[name$="-product"]').value = '';
|
||||
form.querySelector('[name$="-product_kit"]').value = '';
|
||||
form.querySelector('[name$="-price"]').value = '';
|
||||
});
|
||||
|
||||
console.log('[initOrderItemSelect2] Инициализация завершена успешно');
|
||||
};
|
||||
|
||||
// Ждем пока jQuery загрузится
|
||||
function initCustomerSelect2() {
|
||||
if (typeof $ === 'undefined') {
|
||||
@@ -1158,79 +1242,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
customerIsRecipientCheckbox.addEventListener('change', toggleRecipientFields);
|
||||
toggleRecipientFields(); // Инициализация при загрузке
|
||||
|
||||
// Инициализация Select2 для поиска товаров/комплектов
|
||||
// ВНИМАНИЕ: Эта функция будет вызвана ПОСЛЕ загрузки select2-product-search.js
|
||||
window.initOrderItemSelect2 = function(element) {
|
||||
const $element = $(element);
|
||||
const formIndex = element.dataset.formIndex;
|
||||
|
||||
// Проверяем, что функция initProductSelect2 доступна
|
||||
if (typeof window.initProductSelect2 !== 'function') {
|
||||
console.error('window.initProductSelect2 is not defined. Make sure select2-product-search.js is loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Инициализируем Select2 с AJAX поиском
|
||||
window.initProductSelect2(
|
||||
element,
|
||||
'all', // Искать и товары, и комплекты
|
||||
'{% url "products:api-search-products-variants" %}'
|
||||
);
|
||||
|
||||
// Обработка выбора элемента
|
||||
$element.on('select2:select', function(e) {
|
||||
// Проверяем наличие params (может не быть при программном вызове)
|
||||
if (!e.params || !e.params.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = e.params.data;
|
||||
const idParts = data.id.split('_');
|
||||
const type = idParts[0]; // 'product' или 'kit'
|
||||
const id = idParts[1];
|
||||
|
||||
// Найти скрытые поля product и product_kit
|
||||
const form = element.closest('.order-item-form');
|
||||
const productField = form.querySelector('[name$="-product"]');
|
||||
const kitField = form.querySelector('[name$="-product_kit"]');
|
||||
const priceField = form.querySelector('[name$="-price"]');
|
||||
const isCustomPriceField = form.querySelector('[name$="-is_custom_price"]');
|
||||
|
||||
const originalPrice = data.actual_price || data.price || '';
|
||||
|
||||
// Установить значение в правильное поле
|
||||
if (type === 'product') {
|
||||
productField.value = id;
|
||||
kitField.value = '';
|
||||
priceField.value = originalPrice;
|
||||
} else if (type === 'kit') {
|
||||
kitField.value = id;
|
||||
productField.value = '';
|
||||
priceField.value = originalPrice;
|
||||
}
|
||||
|
||||
// Сохраняем оригинальную цену в data-атрибуте
|
||||
priceField.dataset.originalPrice = originalPrice;
|
||||
|
||||
// Сбрасываем флаг кастомной цены
|
||||
isCustomPriceField.value = 'false';
|
||||
|
||||
// Скрываем индикатор
|
||||
const badge = form.querySelector('.custom-price-badge');
|
||||
const priceInfo = form.querySelector('.original-price-info');
|
||||
if (badge) badge.style.display = 'none';
|
||||
if (priceInfo) priceInfo.style.display = 'none';
|
||||
});
|
||||
|
||||
// Очистка при удалении выбора
|
||||
$element.on('select2:clear', function() {
|
||||
const form = element.closest('.order-item-form');
|
||||
form.querySelector('[name$="-product"]').value = '';
|
||||
form.querySelector('[name$="-product_kit"]').value = '';
|
||||
form.querySelector('[name$="-price"]').value = '';
|
||||
});
|
||||
};
|
||||
|
||||
// === РАСЧЁТ ИТОГОВОЙ СУММЫ ТОВАРОВ ===
|
||||
function calculateOrderItemsTotal() {
|
||||
// Собираем все видимые (не удалённые) формы товаров
|
||||
@@ -1431,26 +1442,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Инициализируем итоговую сумму при загрузке страницы
|
||||
updateOrderItemsTotal();
|
||||
|
||||
// Валидация перед отправкой
|
||||
// Валидация перед отправкой (убрана обязательность товаров — можно сохранить пустой заказ)
|
||||
document.getElementById('order-form').addEventListener('submit', function(e) {
|
||||
const visibleForms = Array.from(container.querySelectorAll('.order-item-form'))
|
||||
.filter(f => !f.classList.contains('deleted'));
|
||||
|
||||
// Проверяем, что есть хотя бы одна позиция с товаром
|
||||
let hasItems = false;
|
||||
visibleForms.forEach(form => {
|
||||
const productField = form.querySelector('[name$="-product"]');
|
||||
const kitField = form.querySelector('[name$="-product_kit"]');
|
||||
if (productField.value || kitField.value) {
|
||||
hasItems = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasItems && visibleForms.length > 0) {
|
||||
e.preventDefault();
|
||||
alert('Добавьте хотя бы один товар или комплект в заказ');
|
||||
return false;
|
||||
}
|
||||
// Валидация отключена — заказ можно сохранить без товаров
|
||||
// Товары можно добавить позже
|
||||
});
|
||||
|
||||
// === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
|
||||
@@ -2147,13 +2142,42 @@ if (!document.getElementById('notification-styles')) {
|
||||
|
||||
<script>
|
||||
// Инициализируем все существующие Select2 для товаров после загрузки модуля
|
||||
(function() {
|
||||
if (typeof window.initOrderItemSelect2 === 'function') {
|
||||
document.querySelectorAll('.select2-order-item').forEach(window.initOrderItemSelect2);
|
||||
console.log('[Order Items] Select2 initialized for existing items');
|
||||
} else {
|
||||
console.error('[Order Items] window.initOrderItemSelect2 is not defined');
|
||||
(function initExistingOrderItems() {
|
||||
console.log('[Order Items] Начало инициализации существующих элементов');
|
||||
console.log('[Order Items] jQuery доступен?', typeof $ !== 'undefined');
|
||||
console.log('[Order Items] initOrderItemSelect2 доступен?', typeof window.initOrderItemSelect2 === 'function');
|
||||
console.log('[Order Items] initProductSelect2 доступен?', typeof window.initProductSelect2 === 'function');
|
||||
|
||||
// Проверяем все зависимости
|
||||
if (typeof $ === 'undefined') {
|
||||
console.log('[Order Items] Ожидание загрузки jQuery...');
|
||||
setTimeout(initExistingOrderItems, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.initOrderItemSelect2 !== 'function') {
|
||||
console.log('[Order Items] Ожидание инициализации initOrderItemSelect2...');
|
||||
setTimeout(initExistingOrderItems, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.initProductSelect2 !== 'function') {
|
||||
console.log('[Order Items] Ожидание загрузки initProductSelect2 из select2-product-search.js...');
|
||||
setTimeout(initExistingOrderItems, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Все зависимости готовы
|
||||
console.log('[Order Items] Все зависимости готовы, запуск инициализации...');
|
||||
const items = document.querySelectorAll('.select2-order-item');
|
||||
console.log('[Order Items] Найдено элементов для инициализации:', items.length);
|
||||
|
||||
items.forEach((item, index) => {
|
||||
console.log(`[Order Items] Инициализация элемента ${index + 1}/${items.length}`);
|
||||
window.initOrderItemSelect2(item);
|
||||
});
|
||||
|
||||
console.log('[Order Items] Инициализация всех существующих элементов завершена');
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -130,13 +130,23 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="js-status-container" data-order-id="{{ order.pk }}">
|
||||
<span class="badge badge-lg js-status-badge" style="{% if order.status %}background-color: {{ order.status.color }}; color: #fff;{% else %}background-color: #6c757d; color: #fff;{% endif %} cursor: pointer; font-size: 0.9rem; padding: 0.5rem 0.75rem;" title="Кликните для изменения">
|
||||
{% if order.status %}
|
||||
<span class="badge" style="background-color: {{ order.status.color }}; color: #fff;">
|
||||
{{ order.status.label|default:order.status.name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Не установлен</span>
|
||||
Не установлен
|
||||
{% endif %}
|
||||
</span>
|
||||
<select class="form-select form-select-sm js-status-select" style="display: none;">
|
||||
<option value="" {% if not order.status_id %}selected{% endif %}>Не установлен</option>
|
||||
{% for s in status_choices %}
|
||||
<option value="{{ s.pk }}" data-color="{{ s.color }}" data-label="{{ s.label|default:s.name }}" {% if order.status_id == s.pk %}selected{% endif %}>
|
||||
{{ s.label|default:s.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td><strong>{{ order.total_amount }} руб.</strong></td>
|
||||
<td>
|
||||
@@ -225,4 +235,79 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'orders/js/date_filter.js' %}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const csrfToken = '{{ csrf_token }}';
|
||||
|
||||
async function updateStatus(orderId, statusId) {
|
||||
const body = new URLSearchParams({ status_id: statusId }).toString();
|
||||
const resp = await fetch(`/orders/api/${orderId}/set-status/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.js-status-container').forEach(function(container) {
|
||||
const badge = container.querySelector('.js-status-badge');
|
||||
const select = container.querySelector('.js-status-select');
|
||||
const orderId = container.dataset.orderId;
|
||||
|
||||
// Click on badge: show select
|
||||
badge.addEventListener('click', function() {
|
||||
badge.style.display = 'none';
|
||||
select.style.display = 'inline-block';
|
||||
select.focus();
|
||||
// Open dropdown programmatically
|
||||
if (select.showPicker) {
|
||||
select.showPicker();
|
||||
} else {
|
||||
// Fallback for browsers without showPicker
|
||||
select.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Change status
|
||||
select.addEventListener('change', async function() {
|
||||
const statusId = select.value || '';
|
||||
const selectedOption = select.options[select.selectedIndex];
|
||||
select.disabled = true;
|
||||
|
||||
try {
|
||||
const result = await updateStatus(orderId, statusId);
|
||||
if (result.success) {
|
||||
// Update badge
|
||||
const newColor = selectedOption.dataset.color || '#6c757d';
|
||||
const newLabel = selectedOption.dataset.label || 'Не установлен';
|
||||
badge.style.backgroundColor = newColor;
|
||||
badge.style.color = '#fff';
|
||||
badge.textContent = newLabel;
|
||||
|
||||
// Show badge, hide select
|
||||
select.style.display = 'none';
|
||||
badge.style.display = 'inline-block';
|
||||
} else {
|
||||
alert(result.error || 'Не удалось обновить статус');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка сервера при обновлении статуса');
|
||||
} finally {
|
||||
select.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside or blur: hide select, show badge
|
||||
select.addEventListener('blur', function() {
|
||||
setTimeout(function() {
|
||||
select.style.display = 'none';
|
||||
badge.style.display = 'inline-block';
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -19,6 +19,9 @@ urlpatterns = [
|
||||
# Wallet payment
|
||||
path('<int:pk>/apply-wallet/', views.apply_wallet_payment, name='apply-wallet'),
|
||||
|
||||
# AJAX status update
|
||||
path('api/<int:pk>/set-status/', views.set_order_status, name='api-set-order-status'),
|
||||
|
||||
# Order Status Management URLs
|
||||
path('statuses/', views.order_status_list, name='status_list'),
|
||||
path('statuses/create/', views.order_status_create, name='status_create'),
|
||||
|
||||
@@ -202,6 +202,20 @@ def order_update(request, pk):
|
||||
messages.success(request, f'Заказ #{order.order_number} успешно обновлен!')
|
||||
return redirect('orders:order-detail', pk=order.pk)
|
||||
else:
|
||||
# Логируем ошибки для отладки
|
||||
print("\n=== ОШИБКИ ВАЛИДАЦИИ ФОРМЫ ===")
|
||||
if not form.is_valid():
|
||||
print(f"OrderForm errors: {form.errors}")
|
||||
if not formset.is_valid():
|
||||
print(f"OrderItemFormSet errors: {formset.errors}")
|
||||
print(f"OrderItemFormSet non_form_errors: {formset.non_form_errors()}")
|
||||
for i, item_form in enumerate(formset):
|
||||
if item_form.errors:
|
||||
print(f" Item form {i} errors: {item_form.errors}")
|
||||
if not payment_formset.is_valid():
|
||||
print(f"PaymentFormSet errors: {payment_formset.errors}")
|
||||
print(f"PaymentFormSet non_form_errors: {payment_formset.non_form_errors()}")
|
||||
print("=== КОНЕЦ ОШИБОК ===\n")
|
||||
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
|
||||
else:
|
||||
form = OrderForm(instance=order)
|
||||
@@ -662,3 +676,43 @@ def apply_wallet_payment(request, pk):
|
||||
messages.error(request, f'Ошибка при оплате из кошелька: {str(e)}')
|
||||
|
||||
return redirect('orders:order-detail', pk=pk)
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@login_required
|
||||
def set_order_status(request, pk):
|
||||
"""
|
||||
Update order status via AJAX.
|
||||
Accepts POST with 'status_id' (can be empty to clear).
|
||||
Returns JSON with the resulting status info.
|
||||
"""
|
||||
try:
|
||||
order = get_object_or_404(Order, pk=pk)
|
||||
status_id = request.POST.get('status_id', '').strip()
|
||||
|
||||
# Allow clearing status if empty
|
||||
if status_id == '':
|
||||
order.status = None
|
||||
order.modified_by = request.user
|
||||
order.save(update_fields=['status', 'modified_by', 'updated_at'])
|
||||
return JsonResponse({'success': True, 'status': None})
|
||||
|
||||
try:
|
||||
status = OrderStatus.objects.get(pk=status_id)
|
||||
except OrderStatus.DoesNotExist:
|
||||
return JsonResponse({'success': False, 'error': 'Status not found'}, status=404)
|
||||
|
||||
order.status = status
|
||||
order.modified_by = request.user
|
||||
order.save(update_fields=['status', 'modified_by', 'updated_at'])
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': {
|
||||
'id': status.pk,
|
||||
'name': status.label or status.name,
|
||||
'color': status.color
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'error': f'Server error: {str(e)}'}, status=500)
|
||||
|
||||
Reference in New Issue
Block a user