Добавлена функциональность редактирования заказов с обновлением резервов товаров

This commit is contained in:
2025-11-27 21:13:42 +03:00
parent da5d4001b5
commit 82ed5a409e
6 changed files with 296 additions and 113 deletions

View File

@@ -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()

View File

@@ -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 "Время не указано"

View File

@@ -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>

View File

@@ -130,13 +130,23 @@
{% endif %}
</td>
<td>
{% if order.status %}
<span class="badge" style="background-color: {{ order.status.color }}; color: #fff;">
{{ order.status.label|default:order.status.name }}
<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 %}
{{ order.status.label|default:order.status.name }}
{% else %}
Не установлен
{% endif %}
</span>
{% else %}
<span class="badge bg-secondary">Не установлен</span>
{% endif %}
<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 %}

View File

@@ -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'),

View File

@@ -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)