From 156f64625247e34f3c19b863f2ca23bd3d572ae7 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sun, 16 Nov 2025 21:24:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D1=83=20?= =?UTF-8?q?=D0=B8=D0=B7=20POS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен API endpoint для создания временного комплекта из корзины - Реализован endpoint получения списка активных витрин - Создано модальное окно для настройки комплекта и выбора витрины - JavaScript логика: валидация корзины, отправка данных, очистка после успеха - Автоматическая генерация названия комплекта с датой и временем - Агрегация дубликатов товаров в корзине перед созданием - Резервирование компонентов на витрину через ShowcaseManager - Расчёт и отображение итоговой цены комплекта --- myproject/pos/static/pos/js/terminal.js | 193 +++++++++++++++++++++- myproject/pos/templates/pos/terminal.html | 49 ++++++ myproject/pos/urls.py | 2 + myproject/pos/views.py | 162 +++++++++++++++++- 4 files changed, 403 insertions(+), 3 deletions(-) diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index 2842a66..cd38774 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -285,9 +285,200 @@ document.getElementById('clearCart').onclick = clearCart; // Кнопка "На витрину" - функционал будет добавлен позже document.getElementById('addToShowcaseBtn').onclick = () => { - alert('Функционал "На витрину" будет реализован позже'); + openCreateTempKitModal(); }; +// Функция открытия модального окна для создания временного комплекта +async function openCreateTempKitModal() { + // Проверяем что корзина не пуста + if (cart.size === 0) { + alert('Корзина пуста. Добавьте товары перед созданием комплекта.'); + return; + } + + // Проверяем что в корзине только товары (не комплекты) + let hasKits = false; + for (const [cartKey, item] of cart) { + if (item.type === 'kit') { + hasKits = true; + break; + } + } + + if (hasKits) { + alert('В корзине есть комплекты. Для витрины добавляйте только отдельные товары.'); + return; + } + + // Генерируем название по умолчанию + const now = new Date(); + const defaultName = `Витрина — ${now.toLocaleDateString('ru-RU')} ${now.toLocaleTimeString('ru-RU', {hour: '2-digit', minute: '2-digit'})}`; + document.getElementById('tempKitName').value = defaultName; + + // Загружаем список витрин + await loadShowcases(); + + // Заполняем список товаров из корзины + renderTempKitItems(); + + // Открываем модальное окно + const modal = new bootstrap.Modal(document.getElementById('createTempKitModal')); + modal.show(); +} + +// Загрузка списка витрин +async function loadShowcases() { + try { + const response = await fetch('/pos/api/get-showcases/'); + const data = await response.json(); + + const select = document.getElementById('showcaseSelect'); + select.innerHTML = ''; + + if (data.success && data.showcases.length > 0) { + data.showcases.forEach(showcase => { + const option = document.createElement('option'); + option.value = showcase.id; + option.textContent = `${showcase.name} (${showcase.warehouse_name})`; + select.appendChild(option); + }); + } else { + select.innerHTML = ''; + } + } catch (error) { + console.error('Error loading showcases:', error); + alert('Ошибка загрузки витрин'); + } +} + +// Отображение товаров из корзины в модальном окне +function renderTempKitItems() { + const container = document.getElementById('tempKitItemsList'); + container.innerHTML = ''; + + let estimatedTotal = 0; + + cart.forEach((item, cartKey) => { + // Только товары (не комплекты) + if (item.type !== 'product') return; + + const itemDiv = document.createElement('div'); + itemDiv.className = 'd-flex justify-content-between align-items-center mb-2 pb-2 border-bottom'; + itemDiv.innerHTML = ` +
+ ${item.name} +
+ ${item.qty} шт × ${formatMoney(item.price)} руб. +
+
+ ${formatMoney(item.qty * item.price)} руб. +
+ `; + container.appendChild(itemDiv); + + estimatedTotal += item.qty * item.price; + }); + + document.getElementById('tempKitEstimatedPrice').textContent = formatMoney(estimatedTotal); +} + +// Подтверждение создания временного комплекта +document.getElementById('confirmCreateTempKit').onclick = async () => { + const kitName = document.getElementById('tempKitName').value.trim(); + const showcaseId = document.getElementById('showcaseSelect').value; + + // Валидация + if (!kitName) { + alert('Введите название комплекта'); + return; + } + + if (!showcaseId) { + alert('Выберите витрину'); + return; + } + + // Собираем товары из корзины + const items = []; + cart.forEach((item, cartKey) => { + if (item.type === 'product') { + items.push({ + product_id: item.id, + quantity: item.qty + }); + } + }); + + if (items.length === 0) { + alert('Нет товаров для создания комплекта'); + return; + } + + // Отправляем запрос на сервер + const confirmBtn = document.getElementById('confirmCreateTempKit'); + confirmBtn.disabled = true; + confirmBtn.innerHTML = 'Создание...'; + + try { + const response = await fetch('/pos/api/create-temp-kit/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + kit_name: kitName, + showcase_id: parseInt(showcaseId), + items: items, + price_adjustment_type: 'none', + price_adjustment_value: 0 + }) + }); + + const data = await response.json(); + + if (data.success) { + // Успех! + alert(`✅ ${data.message} + +Комплект: ${data.kit_name} +Цена: ${data.kit_price} руб. +Зарезервировано компонентов: ${data.reservations_count}`); + + // Очищаем корзину + clearCart(); + + // Закрываем модальное окно + const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal')); + modal.hide(); + } else { + alert(`Ошибка: ${data.error}`); + } + } catch (error) { + console.error('Error creating temp kit:', error); + alert('Ошибка при создании комплекта'); + } finally { + confirmBtn.disabled = false; + confirmBtn.innerHTML = ' Создать и зарезервировать'; + } +}; + +// Вспомогательная функция для получения CSRF токена +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} + // Заглушки для функционала (будет реализовано позже) document.getElementById('checkoutNow').onclick = async () => { alert('Функционал будет подключен позже: создание заказа и списание со склада.'); diff --git a/myproject/pos/templates/pos/terminal.html b/myproject/pos/templates/pos/terminal.html index 9fc8b32..5a853fd 100644 --- a/myproject/pos/templates/pos/terminal.html +++ b/myproject/pos/templates/pos/terminal.html @@ -104,6 +104,55 @@ + + + {% endblock %} {% block extra_js %} diff --git a/myproject/pos/urls.py b/myproject/pos/urls.py index 20e098a..78b97b4 100644 --- a/myproject/pos/urls.py +++ b/myproject/pos/urls.py @@ -7,4 +7,6 @@ app_name = 'pos' urlpatterns = [ path('', views.pos_terminal, name='terminal'), path('api/showcase-items/', views.showcase_items_api, name='showcase-items-api'), + path('api/get-showcases/', views.get_showcases_api, name='get-showcases-api'), + path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'), ] diff --git a/myproject/pos/views.py b/myproject/pos/views.py index 2834d49..973d91b 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -3,10 +3,15 @@ from django.shortcuts import render from django.contrib.auth.decorators import login_required from django.http import JsonResponse from django.views.decorators.http import require_http_methods -from products.models import Product, ProductCategory, ProductKit -from inventory.models import Showcase, Reservation +from django.db import transaction +from django.utils import timezone +from decimal import Decimal import json +from products.models import Product, ProductCategory, ProductKit, KitItem +from inventory.models import Showcase, Reservation, Warehouse +from inventory.services import ShowcaseManager + @login_required def pos_terminal(request): @@ -97,3 +102,156 @@ def showcase_items_api(request): 'success': True, 'showcases': showcases_list }) + + +@login_required +@require_http_methods(["GET"]) +def get_showcases_api(request): + """ + API endpoint для получения списка активных витрин. + Используется для выбора витрины при создании временного комплекта. + """ + showcases = Showcase.objects.filter(is_active=True).select_related('warehouse') + + showcases_data = [{ + 'id': s.id, + 'name': s.name, + 'warehouse_name': s.warehouse.name, + 'warehouse_id': s.warehouse.id + } for s in showcases] + + return JsonResponse({ + 'success': True, + 'showcases': showcases_data + }) + + +@login_required +@require_http_methods(["POST"]) +def create_temp_kit_to_showcase(request): + """ + API endpoint для создания временного комплекта из корзины POS + и резервирования его на витрину. + + Ожидаемый payload: + { + "kit_name": "Название комплекта", + "showcase_id": 1, + "items": [ + {"product_id": 1, "quantity": 2.0}, + {"product_id": 3, "quantity": 1.0} + ], + "price_adjustment_type": "none", // optional + "price_adjustment_value": 0 // optional + } + """ + try: + data = json.loads(request.body) + + kit_name = data.get('kit_name', '').strip() + showcase_id = data.get('showcase_id') + items = data.get('items', []) + price_adjustment_type = data.get('price_adjustment_type', 'none') + price_adjustment_value = Decimal(str(data.get('price_adjustment_value', 0))) + + # Валидация + if not kit_name: + return JsonResponse({ + 'success': False, + 'error': 'Необходимо указать название комплекта' + }, status=400) + + if not showcase_id: + return JsonResponse({ + 'success': False, + 'error': 'Необходимо выбрать витрину' + }, status=400) + + if not items or len(items) == 0: + return JsonResponse({ + 'success': False, + 'error': 'Корзина пуста. Добавьте товары для создания комплекта' + }, status=400) + + # Проверяем что витрина существует и активна + try: + showcase = Showcase.objects.select_related('warehouse').get(id=showcase_id, is_active=True) + except Showcase.DoesNotExist: + return JsonResponse({ + 'success': False, + 'error': 'Витрина не найдена или неактивна' + }, status=404) + + # Проверяем что все товары из корзины - это Product (не Kit) + product_ids = [item['product_id'] for item in items] + products = Product.objects.in_bulk(product_ids) + + if len(products) != len(product_ids): + return JsonResponse({ + 'success': False, + 'error': 'Некоторые товары не найдены' + }, status=400) + + # Агрегируем дубликаты (если один товар добавлен несколько раз) + aggregated_items = {} + for item in items: + product_id = item['product_id'] + quantity = Decimal(str(item['quantity'])) + + if product_id in aggregated_items: + aggregated_items[product_id] += quantity + else: + aggregated_items[product_id] = quantity + + # Создаём временный комплект и резервируем на витрину + with transaction.atomic(): + # 1. Создаём ProductKit (is_temporary=True) + kit = ProductKit.objects.create( + name=kit_name, + is_temporary=True, + status='active', + price_adjustment_type=price_adjustment_type, + price_adjustment_value=price_adjustment_value + ) + + # 2. Создаём KitItem для каждого товара из корзины + for product_id, quantity in aggregated_items.items(): + KitItem.objects.create( + kit=kit, + product=products[product_id], + quantity=quantity + ) + + # 3. Пересчитываем цену комплекта + kit.recalculate_base_price() + + # 4. Резервируем комплект на витрину + result = ShowcaseManager.reserve_kit_to_showcase( + product_kit=kit, + showcase=showcase, + quantity=1 + ) + + if not result['success']: + # Откатываем транзакцию через raise + raise Exception(result['message']) + + return JsonResponse({ + 'success': True, + 'message': f'Временный комплект "{kit_name}" создан и зарезервирован на витрине "{showcase.name}"', + 'kit_id': kit.id, + 'kit_name': kit.name, + 'kit_price': str(kit.actual_price), + 'reservations_count': len(result['reservations']) + }) + + except json.JSONDecodeError: + return JsonResponse({ + 'success': False, + 'error': 'Неверный формат данных' + }, status=400) + except Exception as e: + return JsonResponse({ + 'success': False, + 'error': f'Ошибка при создании комплекта: {str(e)}' + }, status=500)