Реализована возможность редактирования состава витринного комплекта с поддержкой отрицательных резервов
- Добавлены методы reserve_product_to_showcase и release_showcase_reservation в ShowcaseManager - Методы работают с резервами для всех активных экземпляров витринного комплекта - НЕ блокируют создание резерва при нехватке товара, возвращают информацию о дефиците (overdraft) - Обновлён API endpoint update_product_kit для корректировки резервов при изменении состава - Добавлено визуальное предупреждение на фронте о нехватке товара на складе - В модалке редактирования комплекта добавлены контролы для изменения количества товаров (+/-, поле ввода, удаление) - Автоматический пересчёт цен при изменении состава - Очистка корзины POS после успешного создания витринного комплекта
This commit is contained in:
@@ -324,6 +324,179 @@ class ShowcaseManager:
|
|||||||
'message': f'Ошибка продажи: {str(e)}'
|
'message': f'Ошибка продажи: {str(e)}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reserve_product_to_showcase(product, showcase, product_kit, quantity_per_item):
|
||||||
|
"""
|
||||||
|
Дозарезервировать товар для всех АКТИВНЫХ экземпляров витринного комплекта.
|
||||||
|
НЕ блокирует при нехватке товара - допускает резерв "в минус".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
product: Product – товар-компонент
|
||||||
|
showcase: Showcase – витрина
|
||||||
|
product_kit: ProductKit – шаблон витринного комплекта
|
||||||
|
quantity_per_item: Decimal или int – насколько увеличить количество
|
||||||
|
этого товара в ОДНОМ экземпляре букета
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'overdraft': Decimal, # сколько не хватает (может быть 0)
|
||||||
|
'message': str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
if not showcase or not product_kit:
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'overdraft': Decimal('0'),
|
||||||
|
'message': 'Нет витрины или комплекта – резервы не изменены'
|
||||||
|
}
|
||||||
|
|
||||||
|
quantity_per_item = Decimal(str(quantity_per_item))
|
||||||
|
if quantity_per_item <= 0:
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'overdraft': Decimal('0'),
|
||||||
|
'message': 'Изменение количества не требует дополнительного резерва'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Берём только актуальные экземпляры на витрине
|
||||||
|
active_items = ShowcaseItem.objects.filter(
|
||||||
|
showcase=showcase,
|
||||||
|
product_kit=product_kit,
|
||||||
|
status__in=['available', 'in_cart'],
|
||||||
|
)
|
||||||
|
|
||||||
|
item_count = active_items.count()
|
||||||
|
if item_count == 0:
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'overdraft': Decimal('0'),
|
||||||
|
'message': 'Нет активных экземпляров на витрине – резервы не изменены'
|
||||||
|
}
|
||||||
|
|
||||||
|
warehouse = showcase.warehouse
|
||||||
|
total_needed = quantity_per_item * item_count
|
||||||
|
|
||||||
|
# Проверяем, хватает ли свободного остатка (НЕ блокируем, только считаем дефицит)
|
||||||
|
try:
|
||||||
|
stock = Stock.objects.get(product=product, warehouse=warehouse)
|
||||||
|
free_qty = stock.quantity_free
|
||||||
|
except Stock.DoesNotExist:
|
||||||
|
free_qty = Decimal('0')
|
||||||
|
|
||||||
|
overdraft = max(Decimal('0'), total_needed - free_qty)
|
||||||
|
|
||||||
|
# Создаём/увеличиваем резервы по каждому экземпляру (даже если не хватает!)
|
||||||
|
with transaction.atomic():
|
||||||
|
for showcase_item in active_items:
|
||||||
|
reservation, created = Reservation.objects.get_or_create(
|
||||||
|
product=product,
|
||||||
|
warehouse=warehouse,
|
||||||
|
showcase=showcase,
|
||||||
|
product_kit=product_kit,
|
||||||
|
showcase_item=showcase_item,
|
||||||
|
status='reserved',
|
||||||
|
defaults={'quantity': quantity_per_item},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
reservation.quantity = (reservation.quantity or Decimal('0')) + quantity_per_item
|
||||||
|
reservation.save(update_fields=['quantity'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'overdraft': overdraft,
|
||||||
|
'message': (
|
||||||
|
f'Зарезервировано {total_needed} ед. товара "{product.name}"'
|
||||||
|
+ (f' (не хватает {overdraft})' if overdraft > 0 else '')
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def release_showcase_reservation(product, showcase, product_kit, quantity_per_item):
|
||||||
|
"""
|
||||||
|
Освободить часть резерва товара для всех АКТИВНЫХ экземпляров витринного комплекта.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
product: Product – товар-компонент
|
||||||
|
showcase: Showcase – витрина
|
||||||
|
product_kit: ProductKit – шаблон витринного комплекта
|
||||||
|
quantity_per_item: Decimal или int – насколько уменьшить количество
|
||||||
|
этого товара в ОДНОМ экземпляре букета
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'released': Decimal,
|
||||||
|
'message': str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
if not showcase or not product_kit:
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'released': Decimal('0'),
|
||||||
|
'message': 'Нет витрины или комплекта – резервы не изменены',
|
||||||
|
}
|
||||||
|
|
||||||
|
quantity_per_item = Decimal(str(quantity_per_item))
|
||||||
|
if quantity_per_item <= 0:
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'released': Decimal('0'),
|
||||||
|
'message': 'Изменение количества не требует освобождения резерва',
|
||||||
|
}
|
||||||
|
|
||||||
|
active_items = ShowcaseItem.objects.filter(
|
||||||
|
showcase=showcase,
|
||||||
|
product_kit=product_kit,
|
||||||
|
status__in=['available', 'in_cart'],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not active_items.exists():
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'released': Decimal('0'),
|
||||||
|
'message': 'Нет активных экземпляров на витрине – резервы не изменены',
|
||||||
|
}
|
||||||
|
|
||||||
|
released_total = Decimal('0')
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
reservations = Reservation.objects.filter(
|
||||||
|
showcase_item__in=active_items,
|
||||||
|
product=product,
|
||||||
|
product_kit=product_kit,
|
||||||
|
showcase=showcase,
|
||||||
|
warehouse=showcase.warehouse,
|
||||||
|
status='reserved',
|
||||||
|
)
|
||||||
|
|
||||||
|
for res in reservations:
|
||||||
|
old_qty = res.quantity or Decimal('0')
|
||||||
|
new_qty = old_qty - quantity_per_item
|
||||||
|
|
||||||
|
if new_qty > 0:
|
||||||
|
res.quantity = new_qty
|
||||||
|
res.save(update_fields=['quantity'])
|
||||||
|
released_amount = quantity_per_item
|
||||||
|
else:
|
||||||
|
# Полностью освобождаем резерв
|
||||||
|
released_amount = old_qty
|
||||||
|
res.status = 'released'
|
||||||
|
res.released_at = timezone.now()
|
||||||
|
res.save(update_fields=['status', 'released_at'])
|
||||||
|
|
||||||
|
released_total += released_amount
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'released': released_total,
|
||||||
|
'message': f'Освобождено {released_total} ед. товара "{product.name}"',
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def dismantle_showcase_item(showcase_item):
|
def dismantle_showcase_item(showcase_item):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1440,22 +1440,92 @@ function renderTempKitItems() {
|
|||||||
if (item.type !== 'product') return;
|
if (item.type !== 'product') return;
|
||||||
|
|
||||||
const itemDiv = document.createElement('div');
|
const itemDiv = document.createElement('div');
|
||||||
itemDiv.className = 'd-flex justify-content-between align-items-center mb-1 pb-1 border-bottom';
|
itemDiv.className = 'd-flex justify-content-between align-items-center mb-2 pb-2 border-bottom';
|
||||||
itemDiv.innerHTML = `
|
|
||||||
<div>
|
// Левая часть: название и цена
|
||||||
<strong class="small">${item.name}</strong>
|
const leftDiv = document.createElement('div');
|
||||||
<br>
|
leftDiv.className = 'flex-grow-1';
|
||||||
<small class="text-muted">${item.qty} шт × ${formatMoney(item.price)} руб.</small>
|
leftDiv.innerHTML = `
|
||||||
</div>
|
<strong class="small">${item.name}</strong>
|
||||||
<div class="text-end">
|
<br>
|
||||||
<strong class="small">${formatMoney(item.qty * item.price)} руб.</strong>
|
<small class="text-muted">${formatMoney(item.price)} руб. / шт.</small>
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Правая часть: контролы количества и удаление
|
||||||
|
const rightDiv = document.createElement('div');
|
||||||
|
rightDiv.className = 'd-flex align-items-center gap-2';
|
||||||
|
|
||||||
|
// Кнопка минус
|
||||||
|
const minusBtn = document.createElement('button');
|
||||||
|
minusBtn.className = 'btn btn-sm btn-outline-secondary';
|
||||||
|
minusBtn.innerHTML = '<i class="bi bi-dash"></i>';
|
||||||
|
minusBtn.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (item.qty > 1) {
|
||||||
|
item.qty--;
|
||||||
|
} else {
|
||||||
|
tempCart.delete(cartKey);
|
||||||
|
}
|
||||||
|
renderTempKitItems();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Поле количества
|
||||||
|
const qtyInput = document.createElement('input');
|
||||||
|
qtyInput.type = 'number';
|
||||||
|
qtyInput.className = 'form-control form-control-sm text-center';
|
||||||
|
qtyInput.style.width = '60px';
|
||||||
|
qtyInput.value = item.qty;
|
||||||
|
qtyInput.min = 1;
|
||||||
|
qtyInput.onchange = (e) => {
|
||||||
|
const newQty = parseInt(e.target.value) || 1;
|
||||||
|
item.qty = Math.max(1, newQty);
|
||||||
|
renderTempKitItems();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Кнопка плюс
|
||||||
|
const plusBtn = document.createElement('button');
|
||||||
|
plusBtn.className = 'btn btn-sm btn-outline-secondary';
|
||||||
|
plusBtn.innerHTML = '<i class="bi bi-plus"></i>';
|
||||||
|
plusBtn.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
item.qty++;
|
||||||
|
renderTempKitItems();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сумма за товар
|
||||||
|
const totalDiv = document.createElement('div');
|
||||||
|
totalDiv.className = 'text-end ms-2';
|
||||||
|
totalDiv.style.minWidth = '80px';
|
||||||
|
totalDiv.innerHTML = `<strong class="small">${formatMoney(item.qty * item.price)} руб.</strong>`;
|
||||||
|
|
||||||
|
// Кнопка удаления
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.className = 'btn btn-sm btn-outline-danger';
|
||||||
|
deleteBtn.innerHTML = '<i class="bi bi-trash"></i>';
|
||||||
|
deleteBtn.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
tempCart.delete(cartKey);
|
||||||
|
renderTempKitItems();
|
||||||
|
};
|
||||||
|
|
||||||
|
rightDiv.appendChild(minusBtn);
|
||||||
|
rightDiv.appendChild(qtyInput);
|
||||||
|
rightDiv.appendChild(plusBtn);
|
||||||
|
rightDiv.appendChild(totalDiv);
|
||||||
|
rightDiv.appendChild(deleteBtn);
|
||||||
|
|
||||||
|
itemDiv.appendChild(leftDiv);
|
||||||
|
itemDiv.appendChild(rightDiv);
|
||||||
container.appendChild(itemDiv);
|
container.appendChild(itemDiv);
|
||||||
|
|
||||||
estimatedTotal += item.qty * item.price;
|
estimatedTotal += item.qty * item.price;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Если корзина пуста
|
||||||
|
if (tempCart.size === 0) {
|
||||||
|
container.innerHTML = '<p class="text-muted text-center py-3"><i class="bi bi-inbox"></i> Нет товаров</p>';
|
||||||
|
}
|
||||||
|
|
||||||
// Обновляем все расчеты цен
|
// Обновляем все расчеты цен
|
||||||
updatePriceCalculations(estimatedTotal);
|
updatePriceCalculations(estimatedTotal);
|
||||||
}
|
}
|
||||||
@@ -1657,7 +1727,8 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
|||||||
// Успех!
|
// Успех!
|
||||||
const createdCount = data.available_count || 1;
|
const createdCount = data.available_count || 1;
|
||||||
const qtyInfo = createdCount > 1 ? `\nСоздано экземпляров: ${createdCount}` : '';
|
const qtyInfo = createdCount > 1 ? `\nСоздано экземпляров: ${createdCount}` : '';
|
||||||
const successMessage = isEditMode
|
|
||||||
|
let successMessage = isEditMode
|
||||||
? `✅ ${data.message}\n\nКомплект: ${data.kit_name}\nЦена: ${data.kit_price} руб.`
|
? `✅ ${data.message}\n\nКомплект: ${data.kit_name}\nЦена: ${data.kit_price} руб.`
|
||||||
: `✅ ${data.message}
|
: `✅ ${data.message}
|
||||||
|
|
||||||
@@ -1665,6 +1736,15 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
|||||||
Цена: ${data.kit_price} руб.${qtyInfo}
|
Цена: ${data.kit_price} руб.${qtyInfo}
|
||||||
Зарезервировано компонентов: ${data.reservations_count}`;
|
Зарезервировано компонентов: ${data.reservations_count}`;
|
||||||
|
|
||||||
|
// Если есть предупреждение о нехватке товара - добавляем его
|
||||||
|
if (data.stock_warning && data.stock_warnings && data.stock_warnings.length > 0) {
|
||||||
|
successMessage += '\n\n⚠️ ВНИМАНИЕ: Нехватка товара на складе!\n';
|
||||||
|
data.stock_warnings.forEach(warning => {
|
||||||
|
successMessage += `\n• ${warning.product_name}: не хватает ${warning.overdraft} ед.`;
|
||||||
|
});
|
||||||
|
successMessage += '\n\nПроверьте остатки и пополните склад.';
|
||||||
|
}
|
||||||
|
|
||||||
alert(successMessage);
|
alert(successMessage);
|
||||||
|
|
||||||
// Очищаем tempCart (изолированное состояние модалки)
|
// Очищаем tempCart (изолированное состояние модалки)
|
||||||
@@ -1682,6 +1762,9 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
|||||||
document.getElementById('salePriceBlock').style.display = 'none';
|
document.getElementById('salePriceBlock').style.display = 'none';
|
||||||
document.getElementById('showcaseKitQuantity').value = '1'; // Сброс количества
|
document.getElementById('showcaseKitQuantity').value = '1'; // Сброс количества
|
||||||
|
|
||||||
|
// Запоминаем, был ли режим редактирования до сброса
|
||||||
|
const wasEditMode = isEditMode;
|
||||||
|
|
||||||
// Сбрасываем режим редактирования
|
// Сбрасываем режим редактирования
|
||||||
isEditMode = false;
|
isEditMode = false;
|
||||||
editingKitId = null;
|
editingKitId = null;
|
||||||
@@ -1690,6 +1773,12 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
|||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal'));
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal'));
|
||||||
modal.hide();
|
modal.hide();
|
||||||
|
|
||||||
|
// Если это было СОЗДАНИЕ витринного комплекта из корзины,
|
||||||
|
// очищаем основную корзину POS
|
||||||
|
if (!wasEditMode) {
|
||||||
|
await clearCart();
|
||||||
|
}
|
||||||
|
|
||||||
// Обновляем витринные комплекты и переключаемся на вид витрины
|
// Обновляем витринные комплекты и переключаемся на вид витрины
|
||||||
isShowcaseView = true;
|
isShowcaseView = true;
|
||||||
currentCategoryId = null;
|
currentCategoryId = null;
|
||||||
|
|||||||
@@ -1188,15 +1188,16 @@ def update_product_kit(request, kit_id):
|
|||||||
|
|
||||||
# Получаем витрину для резервов
|
# Получаем витрину для резервов
|
||||||
showcase_reservation = Reservation.objects.filter(
|
showcase_reservation = Reservation.objects.filter(
|
||||||
product__in=old_items.keys(),
|
product_kit=kit,
|
||||||
showcase__isnull=False,
|
showcase__isnull=False,
|
||||||
status='reserved'
|
status='reserved'
|
||||||
).select_related('showcase').first()
|
).select_related('showcase').first()
|
||||||
|
|
||||||
showcase = showcase_reservation.showcase if showcase_reservation else None
|
showcase = showcase_reservation.showcase if showcase_reservation else kit.showcase
|
||||||
|
|
||||||
# Вычисляем разницу в составе
|
# Вычисляем разницу в составе и собираем информацию о дефиците
|
||||||
all_product_ids = set(old_items.keys()) | set(aggregated_items.keys())
|
all_product_ids = set(old_items.keys()) | set(aggregated_items.keys())
|
||||||
|
stock_warnings = [] # Список товаров с нехваткой остатков
|
||||||
|
|
||||||
for product_id in all_product_ids:
|
for product_id in all_product_ids:
|
||||||
old_qty = old_items.get(product_id, Decimal('0'))
|
old_qty = old_items.get(product_id, Decimal('0'))
|
||||||
@@ -1204,21 +1205,27 @@ def update_product_kit(request, kit_id):
|
|||||||
diff = new_qty - old_qty
|
diff = new_qty - old_qty
|
||||||
|
|
||||||
if diff > 0 and showcase:
|
if diff > 0 and showcase:
|
||||||
# Нужно дозарезервировать
|
# Нужно дозарезервировать (на каждый экземпляр)
|
||||||
result = ShowcaseManager.reserve_product_to_showcase(
|
result = ShowcaseManager.reserve_product_to_showcase(
|
||||||
product=products[product_id],
|
product=products[product_id],
|
||||||
showcase=showcase,
|
showcase=showcase,
|
||||||
quantity=diff
|
product_kit=kit,
|
||||||
|
quantity_per_item=diff,
|
||||||
)
|
)
|
||||||
if not result['success']:
|
# Собираем информацию о дефиците
|
||||||
raise Exception(f"Недостаточно запасов: {result['message']}")
|
if result.get('overdraft', Decimal('0')) > 0:
|
||||||
|
stock_warnings.append({
|
||||||
|
'product_name': products[product_id].name,
|
||||||
|
'overdraft': str(result['overdraft'])
|
||||||
|
})
|
||||||
|
|
||||||
elif diff < 0 and showcase:
|
elif diff < 0 and showcase:
|
||||||
# Нужно освободить резерв
|
# Нужно освободить резерв (на каждый экземпляр)
|
||||||
ShowcaseManager.release_showcase_reservation(
|
ShowcaseManager.release_showcase_reservation(
|
||||||
product=products[product_id],
|
product=products[product_id],
|
||||||
showcase=showcase,
|
showcase=showcase,
|
||||||
quantity=abs(diff)
|
product_kit=kit,
|
||||||
|
quantity_per_item=abs(diff),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Обновляем комплект
|
# Обновляем комплект
|
||||||
@@ -1254,7 +1261,9 @@ def update_product_kit(request, kit_id):
|
|||||||
'message': f'Комплект "{kit.name}" обновлён',
|
'message': f'Комплект "{kit.name}" обновлён',
|
||||||
'kit_id': kit.id,
|
'kit_id': kit.id,
|
||||||
'kit_name': kit.name,
|
'kit_name': kit.name,
|
||||||
'kit_price': str(kit.actual_price)
|
'kit_price': str(kit.actual_price),
|
||||||
|
'stock_warning': len(stock_warnings) > 0,
|
||||||
|
'stock_warnings': stock_warnings
|
||||||
})
|
})
|
||||||
|
|
||||||
except ProductKit.DoesNotExist:
|
except ProductKit.DoesNotExist:
|
||||||
|
|||||||
Reference in New Issue
Block a user