Fix: Implement proper deletion of order items with confirmation dialog
Fixes deletion functionality for order items across frontend and backend: - Remove restriction preventing deletion of last item - Add confirmation dialog before deletion - Properly track and send deleted item IDs to backend via autosave - Update backend to handle item deletion by ID instead of index - Fix visual feedback: deleted items are hidden immediately - Auto-recalculate total sum after deletion Technical changes: - order_form.html: Add confirmation dialog, trigger autosave on delete - autosave.js: Collect deleted item IDs, send to backend - draft_service.py: Process deleted_item_ids, update items by ID 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -208,6 +208,13 @@ class DraftOrderService:
|
|||||||
|
|
||||||
setattr(order, field, value)
|
setattr(order, field, value)
|
||||||
|
|
||||||
|
# Обрабатываем удаление позиций заказа
|
||||||
|
if 'deleted_item_ids' in data:
|
||||||
|
deleted_ids = data['deleted_item_ids']
|
||||||
|
if deleted_ids:
|
||||||
|
from ..models import OrderItem
|
||||||
|
OrderItem.objects.filter(id__in=deleted_ids, order=order).delete()
|
||||||
|
|
||||||
# Обрабатываем позиции заказа (items)
|
# Обрабатываем позиции заказа (items)
|
||||||
if 'items' in data:
|
if 'items' in data:
|
||||||
# Импортируем модели
|
# Импортируем модели
|
||||||
@@ -216,18 +223,9 @@ class DraftOrderService:
|
|||||||
|
|
||||||
items_data = data['items']
|
items_data = data['items']
|
||||||
|
|
||||||
# Получаем существующие позиции
|
# Обрабатываем каждую позицию
|
||||||
existing_items = list(order.items.all())
|
for item_data in items_data:
|
||||||
|
item_id = item_data.get('id') # ID существующей позиции (если есть)
|
||||||
# Удаляем все существующие позиции, которых нет в новых данных
|
|
||||||
items_to_keep_count = len(items_data)
|
|
||||||
for i, existing_item in enumerate(existing_items):
|
|
||||||
if i >= items_to_keep_count:
|
|
||||||
# Удаляем лишние позиции
|
|
||||||
existing_item.delete()
|
|
||||||
|
|
||||||
# Обновляем или создаём позиции
|
|
||||||
for index, item_data in enumerate(items_data):
|
|
||||||
product_id = item_data.get('product_id')
|
product_id = item_data.get('product_id')
|
||||||
product_kit_id = item_data.get('product_kit_id')
|
product_kit_id = item_data.get('product_kit_id')
|
||||||
quantity = item_data.get('quantity', 1)
|
quantity = item_data.get('quantity', 1)
|
||||||
@@ -275,17 +273,28 @@ class DraftOrderService:
|
|||||||
is_custom_price = False
|
is_custom_price = False
|
||||||
|
|
||||||
# Обновляем существующую позицию или создаём новую
|
# Обновляем существующую позицию или создаём новую
|
||||||
if index < len(existing_items):
|
if item_id:
|
||||||
# Обновляем существующую
|
# Обновляем существующую позицию
|
||||||
item = existing_items[index]
|
try:
|
||||||
item.product = product
|
item = OrderItem.objects.get(id=item_id, order=order)
|
||||||
item.product_kit = product_kit
|
item.product = product
|
||||||
item.quantity = quantity
|
item.product_kit = product_kit
|
||||||
item.price = price
|
item.quantity = quantity
|
||||||
item.is_custom_price = is_custom_price
|
item.price = price
|
||||||
item.save()
|
item.is_custom_price = is_custom_price
|
||||||
|
item.save()
|
||||||
|
except OrderItem.DoesNotExist:
|
||||||
|
# Если позиция не найдена, создаём новую
|
||||||
|
OrderItem.objects.create(
|
||||||
|
order=order,
|
||||||
|
product=product,
|
||||||
|
product_kit=product_kit,
|
||||||
|
quantity=quantity,
|
||||||
|
price=price,
|
||||||
|
is_custom_price=is_custom_price
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Создаём новую
|
# Создаём новую позицию
|
||||||
OrderItem.objects.create(
|
OrderItem.objects.create(
|
||||||
order=order,
|
order=order,
|
||||||
product=product,
|
product=product,
|
||||||
|
|||||||
@@ -213,7 +213,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = form.querySelectorAll('select, input[type="number"], input[type="checkbox"]');
|
const fields = form.querySelectorAll('select, input[type="number"], input[type="text"], input[type="checkbox"]');
|
||||||
|
|
||||||
fields.forEach(field => {
|
fields.forEach(field => {
|
||||||
if (field.tagName === 'SELECT' || field.type === 'checkbox') {
|
if (field.tagName === 'SELECT' || field.type === 'checkbox') {
|
||||||
@@ -421,7 +421,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Собираем позиции заказа
|
// Собираем позиции заказа
|
||||||
data.items = collectOrderItems();
|
const orderItemsData = collectOrderItems();
|
||||||
|
data.items = orderItemsData.items;
|
||||||
|
data.deleted_item_ids = orderItemsData.deletedItemIds;
|
||||||
|
|
||||||
// Флаг для пересчета итоговой суммы
|
// Флаг для пересчета итоговой суммы
|
||||||
data.recalculate = true;
|
data.recalculate = true;
|
||||||
@@ -434,13 +436,20 @@
|
|||||||
*/
|
*/
|
||||||
function collectOrderItems() {
|
function collectOrderItems() {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
const deletedItemIds = [];
|
||||||
const itemForms = document.querySelectorAll('.order-item-form');
|
const itemForms = document.querySelectorAll('.order-item-form');
|
||||||
|
|
||||||
itemForms.forEach(form => {
|
itemForms.forEach(form => {
|
||||||
// Пропускаем удаленные формы
|
// Проверяем, помечена ли форма на удаление
|
||||||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||||
|
const idField = form.querySelector('input[name$="-id"]');
|
||||||
|
|
||||||
if (deleteCheckbox && deleteCheckbox.checked) {
|
if (deleteCheckbox && deleteCheckbox.checked) {
|
||||||
return;
|
// Если форма помечена на удаление и имеет ID, добавляем в список удалённых
|
||||||
|
if (idField && idField.value) {
|
||||||
|
deletedItemIds.push(parseInt(idField.value));
|
||||||
|
}
|
||||||
|
return; // Не добавляем в items
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем выбранный товар/комплект
|
// Получаем выбранный товар/комплект
|
||||||
@@ -459,9 +468,14 @@
|
|||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
quantity: quantityInput.value || '1',
|
quantity: quantityInput.value || '1',
|
||||||
price: priceInput.value || '0'
|
price: (priceInput.value || '0').replace(',', '.')
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Если есть ID (существующий товар), добавляем его
|
||||||
|
if (idField && idField.value) {
|
||||||
|
item.id = parseInt(idField.value);
|
||||||
|
}
|
||||||
|
|
||||||
// Определяем тип: товар или комплект
|
// Определяем тип: товар или комплект
|
||||||
if (itemValue.startsWith('product_')) {
|
if (itemValue.startsWith('product_')) {
|
||||||
item.product_id = parseInt(itemValue.replace('product_', ''));
|
item.product_id = parseInt(itemValue.replace('product_', ''));
|
||||||
@@ -472,7 +486,7 @@
|
|||||||
items.push(item);
|
items.push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return { items, deletedItemIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -544,4 +558,9 @@
|
|||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Экспортируем функцию scheduleAutosave в глобальную область
|
||||||
|
window.orderAutosave = {
|
||||||
|
scheduleAutosave: scheduleAutosave
|
||||||
|
};
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load l10n %}
|
||||||
|
|
||||||
{% block title %}{{ title }}{% endblock %}
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
@@ -227,11 +228,11 @@
|
|||||||
<label class="form-label">Цена</label>
|
<label class="form-label">Цена</label>
|
||||||
<div class="position-relative">
|
<div class="position-relative">
|
||||||
{% if item_form.instance.product %}
|
{% if item_form.instance.product %}
|
||||||
<input type="number" name="{{ item_form.price.name }}" step="0.01" min="0" class="form-control" id="{{ item_form.price.id_for_label }}" value="{% if item_form.instance.price %}{{ item_form.instance.price }}{% else %}{{ item_form.instance.product.actual_price }}{% endif %}" data-original-price="{{ item_form.instance.product.actual_price }}">
|
<input type="text" name="{{ item_form.prefix }}-price" step="0.01" min="0" class="form-control" id="{{ item_form.price.id_for_label }}" value="{% if item_form.instance.price and item_form.instance.price != 0 %}{{ item_form.instance.price }}{% else %}{{ item_form.instance.product.actual_price }}{% endif %}" data-original-price="{{ item_form.instance.product.actual_price }}">
|
||||||
{% elif item_form.instance.product_kit %}
|
{% elif item_form.instance.product_kit %}
|
||||||
<input type="number" name="{{ item_form.price.name }}" step="0.01" min="0" class="form-control" id="{{ item_form.price.id_for_label }}" value="{% if item_form.instance.price %}{{ item_form.instance.price }}{% else %}{{ item_form.instance.product_kit.actual_price }}{% endif %}" data-original-price="{{ item_form.instance.product_kit.actual_price }}">
|
<input type="text" name="{{ item_form.prefix }}-price" step="0.01" min="0" class="form-control" id="{{ item_form.price.id_for_label }}" value="{% if item_form.instance.price and item_form.instance.price != 0 %}{{ item_form.instance.price }}{% else %}{{ item_form.instance.product_kit.actual_price }}{% endif %}" data-original-price="{{ item_form.instance.product_kit.actual_price }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ item_form.price }}
|
<input type="text" name="{{ item_form.prefix }}-price" step="0.01" min="0" class="form-control" id="{{ item_form.price.id_for_label }}" value="{{ item_form.instance.price|default:'' }}" data-original-price="0">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
|
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
|
||||||
Изменена
|
Изменена
|
||||||
@@ -302,7 +303,7 @@
|
|||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Цена</label>
|
<label class="form-label">Цена</label>
|
||||||
<div class="position-relative">
|
<div class="position-relative">
|
||||||
<input type="number" name="items-__prefix__-price" step="0.01" min="0" class="form-control" id="id_items-__prefix__-price">
|
<input type="text" name="items-__prefix__-price" step="0.01" min="0" class="form-control" id="id_items-__prefix__-price">
|
||||||
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
|
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
|
||||||
Изменена
|
Изменена
|
||||||
</span>
|
</span>
|
||||||
@@ -1001,18 +1002,34 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
|
console.log('[TOTAL] Calculating total for', visibleForms.length, 'forms');
|
||||||
|
|
||||||
// Для каждого товара: количество × цена
|
// Для каждого товара: количество × цена
|
||||||
visibleForms.forEach(form => {
|
visibleForms.forEach((form, index) => {
|
||||||
const quantityField = form.querySelector('[name$="-quantity"]');
|
const quantityField = form.querySelector('[name$="-quantity"]');
|
||||||
const priceField = form.querySelector('[name$="-price"]');
|
const priceField = form.querySelector('[name$="-price"]');
|
||||||
|
|
||||||
|
console.log(`[TOTAL] Form ${index}:`, form);
|
||||||
|
console.log(`[TOTAL] Form ${index}: quantityField=${quantityField}, priceField=${priceField}`);
|
||||||
|
const allInputs = form.querySelectorAll('input');
|
||||||
|
console.log(`[TOTAL] Form ${index}: All inputs:`, allInputs);
|
||||||
|
allInputs.forEach((input, i) => {
|
||||||
|
console.log(` Input ${i}: name="${input.name}", id="${input.id}", type="${input.type}"`);
|
||||||
|
});
|
||||||
|
|
||||||
if (quantityField && priceField) {
|
if (quantityField && priceField) {
|
||||||
const quantity = parseFloat(quantityField.value) || 0;
|
const quantity = parseFloat(quantityField.value) || 0;
|
||||||
const price = parseFloat(priceField.value) || 0;
|
// Заменяем запятую на точку для корректного парсинга
|
||||||
|
const priceValue = priceField.value.replace(',', '.');
|
||||||
|
const price = parseFloat(priceValue) || 0;
|
||||||
|
console.log(`[TOTAL] Form ${index}: quantity=${quantityField.value} (parsed: ${quantity}), price="${priceField.value}" (parsed: ${price}), subtotal=${quantity * price}`);
|
||||||
total += quantity * price;
|
total += quantity * price;
|
||||||
|
} else {
|
||||||
|
console.log(`[TOTAL] Form ${index}: SKIPPED - missing fields!`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[TOTAL] Final total:', total);
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1129,25 +1146,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// Функция для удаления формы
|
// Функция для удаления формы
|
||||||
function removeForm(form) {
|
function removeForm(form) {
|
||||||
|
// Показываем диалог подтверждения
|
||||||
|
if (!confirm('Вы действительно хотите удалить этот товар из заказа?')) {
|
||||||
|
return; // Пользователь отменил удаление
|
||||||
|
}
|
||||||
|
|
||||||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||||
const idField = form.querySelector('input[name$="-id"]');
|
const idField = form.querySelector('input[name$="-id"]');
|
||||||
|
|
||||||
// Проверяем, не последняя ли это форма
|
|
||||||
const visibleForms = Array.from(container.querySelectorAll('.order-item-form'))
|
|
||||||
.filter(f => !f.classList.contains('deleted'));
|
|
||||||
|
|
||||||
if (visibleForms.length <= 1) {
|
|
||||||
alert('Нельзя удалить единственную позицию. Добавьте новую позицию перед удалением этой.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если форма уже сохранена (есть ID), помечаем на удаление
|
// Если форма уже сохранена (есть ID), помечаем на удаление
|
||||||
if (idField && idField.value) {
|
if (idField && idField.value) {
|
||||||
deleteCheckbox.checked = true;
|
deleteCheckbox.checked = true;
|
||||||
form.classList.add('deleted');
|
form.classList.add('deleted');
|
||||||
console.log('Form marked for deletion');
|
form.style.display = 'none'; // Скрываем форму визуально
|
||||||
|
console.log('Form marked for deletion, id:', idField.value);
|
||||||
// Обновляем итоговую сумму после удаления
|
// Обновляем итоговую сумму после удаления
|
||||||
updateOrderItemsTotal();
|
updateOrderItemsTotal();
|
||||||
|
// Триггерим автосохранение для отправки изменений
|
||||||
|
if (typeof window.orderAutosave !== 'undefined' && window.orderAutosave.scheduleAutosave) {
|
||||||
|
window.orderAutosave.scheduleAutosave();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Если форма новая, просто удаляем из DOM
|
// Если форма новая, просто удаляем из DOM
|
||||||
form.remove();
|
form.remove();
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def search_products_and_variants(request):
|
|||||||
numeric_id = int(item_id)
|
numeric_id = int(item_id)
|
||||||
|
|
||||||
if item_type == 'product':
|
if item_type == 'product':
|
||||||
product = Product.objects.get(id=numeric_id, is_active=True)
|
product = Product.objects.get(id=numeric_id, status='active')
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'results': [{
|
'results': [{
|
||||||
'id': f'product_{product.id}',
|
'id': f'product_{product.id}',
|
||||||
@@ -89,7 +89,7 @@ def search_products_and_variants(request):
|
|||||||
elif item_type == 'kit':
|
elif item_type == 'kit':
|
||||||
# Для комплектов: временные комплекты можно получать по ID (для заказов)
|
# Для комплектов: временные комплекты можно получать по ID (для заказов)
|
||||||
# но не показываем их в общем поиске
|
# но не показываем их в общем поиске
|
||||||
kit = ProductKit.objects.get(id=numeric_id, is_active=True)
|
kit = ProductKit.objects.get(id=numeric_id, status='active')
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'results': [{
|
'results': [{
|
||||||
'id': f'kit_{kit.id}',
|
'id': f'kit_{kit.id}',
|
||||||
@@ -170,7 +170,7 @@ def search_products_and_variants(request):
|
|||||||
|
|
||||||
if search_type in ['all', 'kit']:
|
if search_type in ['all', 'kit']:
|
||||||
# Показываем последние добавленные активные комплекты (только постоянные)
|
# Показываем последние добавленные активные комплекты (только постоянные)
|
||||||
kits = ProductKit.objects.filter(is_active=True, is_temporary=False)\
|
kits = ProductKit.objects.filter(status='active', is_temporary=False)\
|
||||||
.order_by('-created_at')[:page_size]\
|
.order_by('-created_at')[:page_size]\
|
||||||
.values('id', 'name', 'sku', 'price', 'sale_price')
|
.values('id', 'name', 'sku', 'price', 'sale_price')
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ def search_products_and_variants(request):
|
|||||||
models.Q(name_lower__contains=query_lower) |
|
models.Q(name_lower__contains=query_lower) |
|
||||||
models.Q(sku_lower__contains=query_lower) |
|
models.Q(sku_lower__contains=query_lower) |
|
||||||
models.Q(description_lower__contains=query_lower),
|
models.Q(description_lower__contains=query_lower),
|
||||||
is_active=True
|
status='active'
|
||||||
).annotate(
|
).annotate(
|
||||||
relevance=Case(
|
relevance=Case(
|
||||||
When(name_lower=query_lower, then=3),
|
When(name_lower=query_lower, then=3),
|
||||||
@@ -259,7 +259,7 @@ def search_products_and_variants(request):
|
|||||||
models.Q(name__icontains=query_normalized) |
|
models.Q(name__icontains=query_normalized) |
|
||||||
models.Q(sku__icontains=query_normalized) |
|
models.Q(sku__icontains=query_normalized) |
|
||||||
models.Q(description__icontains=query_normalized),
|
models.Q(description__icontains=query_normalized),
|
||||||
is_active=True
|
status='active'
|
||||||
).annotate(
|
).annotate(
|
||||||
relevance=Case(
|
relevance=Case(
|
||||||
When(name__iexact=query_normalized, then=3),
|
When(name__iexact=query_normalized, then=3),
|
||||||
@@ -310,7 +310,7 @@ def search_products_and_variants(request):
|
|||||||
models.Q(name_lower__contains=query_lower) |
|
models.Q(name_lower__contains=query_lower) |
|
||||||
models.Q(sku_lower__contains=query_lower) |
|
models.Q(sku_lower__contains=query_lower) |
|
||||||
models.Q(description_lower__contains=query_lower),
|
models.Q(description_lower__contains=query_lower),
|
||||||
is_active=True,
|
status='active',
|
||||||
is_temporary=False
|
is_temporary=False
|
||||||
).annotate(
|
).annotate(
|
||||||
relevance=Case(
|
relevance=Case(
|
||||||
@@ -325,7 +325,7 @@ def search_products_and_variants(request):
|
|||||||
models.Q(name__icontains=query_normalized) |
|
models.Q(name__icontains=query_normalized) |
|
||||||
models.Q(sku__icontains=query_normalized) |
|
models.Q(sku__icontains=query_normalized) |
|
||||||
models.Q(description__icontains=query_normalized),
|
models.Q(description__icontains=query_normalized),
|
||||||
is_active=True,
|
status='active',
|
||||||
is_temporary=False
|
is_temporary=False
|
||||||
).annotate(
|
).annotate(
|
||||||
relevance=Case(
|
relevance=Case(
|
||||||
@@ -498,7 +498,7 @@ def validate_kit_cost(request):
|
|||||||
elif variant_group_id:
|
elif variant_group_id:
|
||||||
try:
|
try:
|
||||||
variant_group = ProductVariantGroup.objects.get(id=variant_group_id)
|
variant_group = ProductVariantGroup.objects.get(id=variant_group_id)
|
||||||
product = variant_group.products.filter(is_active=True).first()
|
product = variant_group.products.filter(status='active').first()
|
||||||
if variant_group:
|
if variant_group:
|
||||||
product_name = f"[Варианты] {variant_group.name}"
|
product_name = f"[Варианты] {variant_group.name}"
|
||||||
except ProductVariantGroup.DoesNotExist:
|
except ProductVariantGroup.DoesNotExist:
|
||||||
|
|||||||
Reference in New Issue
Block a user