Реализована возможность редактирования состава витринного комплекта с поддержкой отрицательных резервов
- Добавлены методы 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)}'
|
||||
}
|
||||
|
||||
@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
|
||||
def dismantle_showcase_item(showcase_item):
|
||||
"""
|
||||
|
||||
@@ -1440,22 +1440,92 @@ function renderTempKitItems() {
|
||||
if (item.type !== 'product') return;
|
||||
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'd-flex justify-content-between align-items-center mb-1 pb-1 border-bottom';
|
||||
itemDiv.innerHTML = `
|
||||
<div>
|
||||
<strong class="small">${item.name}</strong>
|
||||
<br>
|
||||
<small class="text-muted">${item.qty} шт × ${formatMoney(item.price)} руб.</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<strong class="small">${formatMoney(item.qty * item.price)} руб.</strong>
|
||||
</div>
|
||||
itemDiv.className = 'd-flex justify-content-between align-items-center mb-2 pb-2 border-bottom';
|
||||
|
||||
// Левая часть: название и цена
|
||||
const leftDiv = document.createElement('div');
|
||||
leftDiv.className = 'flex-grow-1';
|
||||
leftDiv.innerHTML = `
|
||||
<strong class="small">${item.name}</strong>
|
||||
<br>
|
||||
<small class="text-muted">${formatMoney(item.price)} руб. / шт.</small>
|
||||
`;
|
||||
|
||||
// Правая часть: контролы количества и удаление
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1657,7 +1727,8 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
// Успех!
|
||||
const createdCount = data.available_count || 1;
|
||||
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}
|
||||
|
||||
@@ -1665,6 +1736,15 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
Цена: ${data.kit_price} руб.${qtyInfo}
|
||||
Зарезервировано компонентов: ${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);
|
||||
|
||||
// Очищаем tempCart (изолированное состояние модалки)
|
||||
@@ -1682,6 +1762,9 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
document.getElementById('salePriceBlock').style.display = 'none';
|
||||
document.getElementById('showcaseKitQuantity').value = '1'; // Сброс количества
|
||||
|
||||
// Запоминаем, был ли режим редактирования до сброса
|
||||
const wasEditMode = isEditMode;
|
||||
|
||||
// Сбрасываем режим редактирования
|
||||
isEditMode = false;
|
||||
editingKitId = null;
|
||||
@@ -1690,6 +1773,12 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal'));
|
||||
modal.hide();
|
||||
|
||||
// Если это было СОЗДАНИЕ витринного комплекта из корзины,
|
||||
// очищаем основную корзину POS
|
||||
if (!wasEditMode) {
|
||||
await clearCart();
|
||||
}
|
||||
|
||||
// Обновляем витринные комплекты и переключаемся на вид витрины
|
||||
isShowcaseView = true;
|
||||
currentCategoryId = null;
|
||||
|
||||
@@ -1188,15 +1188,16 @@ def update_product_kit(request, kit_id):
|
||||
|
||||
# Получаем витрину для резервов
|
||||
showcase_reservation = Reservation.objects.filter(
|
||||
product__in=old_items.keys(),
|
||||
product_kit=kit,
|
||||
showcase__isnull=False,
|
||||
status='reserved'
|
||||
).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())
|
||||
stock_warnings = [] # Список товаров с нехваткой остатков
|
||||
|
||||
for product_id in all_product_ids:
|
||||
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
|
||||
|
||||
if diff > 0 and showcase:
|
||||
# Нужно дозарезервировать
|
||||
# Нужно дозарезервировать (на каждый экземпляр)
|
||||
result = ShowcaseManager.reserve_product_to_showcase(
|
||||
product=products[product_id],
|
||||
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:
|
||||
# Нужно освободить резерв
|
||||
# Нужно освободить резерв (на каждый экземпляр)
|
||||
ShowcaseManager.release_showcase_reservation(
|
||||
product=products[product_id],
|
||||
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}" обновлён',
|
||||
'kit_id': kit.id,
|
||||
'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:
|
||||
|
||||
Reference in New Issue
Block a user