diff --git a/myproject/inventory/services/showcase_manager.py b/myproject/inventory/services/showcase_manager.py index 6e9bb17..80ad446 100644 --- a/myproject/inventory/services/showcase_manager.py +++ b/myproject/inventory/services/showcase_manager.py @@ -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): """ diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index a8ebf30..5e58297 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -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 = ` -
- ${item.name} -
- ${item.qty} шт × ${formatMoney(item.price)} руб. -
-
- ${formatMoney(item.qty * item.price)} руб. -
+ 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 = ` + ${item.name} +
+ ${formatMoney(item.price)} руб. / шт. `; + + // Правая часть: контролы количества и удаление + 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 = ''; + 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 = ''; + 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 = `${formatMoney(item.qty * item.price)} руб.`; + + // Кнопка удаления + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'btn btn-sm btn-outline-danger'; + deleteBtn.innerHTML = ''; + 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 = '

Нет товаров

'; + } + // Обновляем все расчеты цен 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; diff --git a/myproject/pos/views.py b/myproject/pos/views.py index 1f4f622..7b070db 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -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: