Реализована возможность редактирования состава витринного комплекта с поддержкой отрицательных резервов

- Добавлены методы reserve_product_to_showcase и release_showcase_reservation в ShowcaseManager
- Методы работают с резервами для всех активных экземпляров витринного комплекта
- НЕ блокируют создание резерва при нехватке товара, возвращают информацию о дефиците (overdraft)
- Обновлён API endpoint update_product_kit для корректировки резервов при изменении состава
- Добавлено визуальное предупреждение на фронте о нехватке товара на складе
- В модалке редактирования комплекта добавлены контролы для изменения количества товаров (+/-, поле ввода, удаление)
- Автоматический пересчёт цен при изменении состава
- Очистка корзины POS после успешного создания витринного комплекта
This commit is contained in:
2025-12-14 13:49:13 +03:00
parent 835d6020e2
commit aff25d0317
3 changed files with 292 additions and 21 deletions

View File

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

View File

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

View File

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