From 275bc1b78d67cd5916497c914536e224b02969a4 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Fri, 2 Jan 2026 17:46:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2=20=D0=B2=20POS=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Обновлён pos/views.py: метод pos_checkout теперь создаёт Order и связанную модель Delivery - Обновлён showcase_manager.py: метод sell_showcase_item_to_customer использует новую архитектуру - Удалён устаревший скрипт create_demo_orders.py - Исправлена ошибка 'property is_delivery of Order object has no setter' --- .../inventory/services/showcase_manager.py | 17 +- .../management/commands/create_demo_orders.py | 207 ----------- myproject/pos/static/pos/css/terminal.css | 67 ++++ myproject/pos/static/pos/js/terminal.js | 349 +++++++++++++++++- myproject/pos/templates/pos/terminal.html | 85 +++++ myproject/pos/views.py | 25 +- myproject/products/views/api_views.py | 5 +- 7 files changed, 528 insertions(+), 227 deletions(-) delete mode 100644 myproject/orders/management/commands/create_demo_orders.py diff --git a/myproject/inventory/services/showcase_manager.py b/myproject/inventory/services/showcase_manager.py index 7de836d..c2f8e5f 100644 --- a/myproject/inventory/services/showcase_manager.py +++ b/myproject/inventory/services/showcase_manager.py @@ -274,12 +274,25 @@ class ShowcaseManager: # Создаём заказ order = Order.objects.create( customer=customer, - is_delivery=False, - pickup_warehouse=warehouse, status=completed_status, is_paid=True, modified_by=user ) + + # Создаём доставку (самовывоз) + from orders.models import Delivery + from django.utils import timezone as tz + now_local = tz.localtime(tz.now()) + + Delivery.objects.create( + order=order, + delivery_type=Delivery.DELIVERY_TYPE_PICKUP, + pickup_warehouse=warehouse, + delivery_date=now_local.date(), + time_from=now_local.time(), + time_to=now_local.time(), + cost=0 + ) # Определяем цену price = custom_price if custom_price else product_kit.actual_price diff --git a/myproject/orders/management/commands/create_demo_orders.py b/myproject/orders/management/commands/create_demo_orders.py deleted file mode 100644 index 9af66e0..0000000 --- a/myproject/orders/management/commands/create_demo_orders.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -Management команда для создания демо-заказов на разные даты -ВАЖНО: Создает заказы через Django ORM, что автоматически активирует -сигналы резервирования товаров! -""" -from django.core.management.base import BaseCommand -from django.utils import timezone -from django.db import connection -from datetime import datetime, timedelta -import random -from decimal import Decimal - -from orders.models import Order, OrderItem, Address, Recipient -from customers.models import Customer -from inventory.models import Warehouse -from products.models import Product - - -class Command(BaseCommand): - help = 'Создает демо-заказы через ORM (с автоматическим резервированием товаров)' - - def add_arguments(self, parser): - parser.add_argument( - '--count', - type=int, - default=25, - help='Количество заказов для создания (по умолчанию: 25)' - ) - parser.add_argument( - '--schema', - type=str, - default='grach', - help='Схема базы данных (tenant) для создания заказов' - ) - - def handle(self, *args, **options): - count = options['count'] - schema_name = options['schema'] - - # Устанавливаем схему для работы с tenant - with connection.cursor() as cursor: - cursor.execute(f'SET search_path TO {schema_name}') - - self.stdout.write(f'[НАЧАЛО] Создание {count} демо-заказов в схеме {schema_name}...') - self.stdout.write('[INFO] Заказы создаются через ORM - резервы товаров будут созданы автоматически!') - - # Проверяем наличие необходимых данных - customers = list(Customer.objects.all()) - if not customers: - self.stdout.write(self.style.ERROR('Нет клиентов в базе! Создайте хотя бы одного клиента.')) - return - - products = list(Product.objects.all()) - if not products: - self.stdout.write(self.style.ERROR('Нет товаров в базе! Создайте хотя бы один товар.')) - return - - addresses = list(Address.objects.all()) - warehouses = list(Warehouse.objects.filter(is_pickup_point=True)) - - if not addresses and not warehouses: - self.stdout.write(self.style.ERROR('Нет ни адресов, ни складов для самовывоза! Создайте хотя бы что-то одно.')) - return - - # Статусы и их вероятности - statuses = [ - ('new', 0.15), - ('confirmed', 0.25), - ('in_assembly', 0.20), - ('in_delivery', 0.15), - ('delivered', 0.20), - ('cancelled', 0.05), - ] - - payment_statuses = [ - ('unpaid', 0.30), - ('partial', 0.20), - ('paid', 0.50), - ] - - payment_methods = [ - 'cash_to_courier', - 'card_to_courier', - 'online', - 'bank_transfer', - ] - - # Генерируем даты в диапазоне ±15 дней от сегодня - today = datetime.now().date() - - created_count = 0 - for i in range(count): - try: - # Случайная дата доставки - days_offset = random.randint(-15, 15) - delivery_date = today + timedelta(days=days_offset) - - # Выбираем клиента - customer = random.choice(customers) - - # Выбираем тип доставки - is_delivery = random.choice([True, False]) if addresses and shops else bool(addresses) - - # Создаем заказ - order = Order() - order.customer = customer - order.is_delivery = is_delivery - - # Устанавливаем адрес или магазин - if is_delivery and addresses: - # Для доставки выбираем случайный адрес (адреса теперь привязаны к заказам) - order.delivery_address = random.choice(addresses) - order.delivery_cost = Decimal(random.randint(200, 500)) - elif warehouses: - order.pickup_warehouse = random.choice(warehouses) - order.delivery_cost = Decimal(0) - - # Дата и время - order.delivery_date = delivery_date - if random.random() > 0.3: # 70% заказов с указанным временем - start_hour = random.randint(9, 18) - order.delivery_time_start = f"{start_hour:02d}:00:00" - order.delivery_time_end = f"{start_hour + 2:02d}:00:00" - - # Статус - status_choices = [s[0] for s in statuses] - status_weights = [s[1] for s in statuses] - order.status = random.choices(status_choices, weights=status_weights)[0] - - # Способ оплаты - order.payment_method = random.choice(payment_methods) - - # Дополнительная информация - if random.random() > 0.7: # 30% - подарок другому человеку - # Создаем получателя - recipient_name = f"Получатель {i+1}" - recipient_phone = f"+7{random.randint(9000000000, 9999999999)}" - recipient, created = Recipient.objects.get_or_create( - name=recipient_name, - phone=recipient_phone - ) - order.recipient = recipient - - if random.random() > 0.8: # 20% анонимных - order.is_anonymous = True - - if random.random() > 0.5: # 50% с комментариями - comments = [ - "Позвонить за час до доставки", - "Доставить точно в указанное время", - "Не звонить в дверь, только по телефону", - "Упаковать покрасивее", - "Приложить открытку", - ] - order.special_instructions = random.choice(comments) - - # Сохраняем заказ (чтобы получить ID) - order.save() - - # Добавляем товары в заказ - items_count = random.randint(1, 4) - order_products = random.sample(products, min(items_count, len(products))) - - items_total = Decimal(0) - for product in order_products: - item = OrderItem() - item.order = order - item.product = product - item.quantity = random.randint(1, 3) - item.price = product.price - item.save() - items_total += item.get_total_price() - - # Рассчитываем итоговую сумму - order.total_amount = items_total + order.delivery_cost - - # Скидка (20% заказов) - if random.random() > 0.8: - order.discount_amount = Decimal(random.randint(100, 500)) - order.total_amount -= order.discount_amount - - # Статус оплаты - payment_status_choices = [s[0] for s in payment_statuses] - payment_status_weights = [s[1] for s in payment_statuses] - order.payment_status = random.choices(payment_status_choices, weights=payment_status_weights)[0] - - if order.payment_status == 'paid': - order.amount_paid = order.total_amount - order.is_paid = True - elif order.payment_status == 'partial': - order.amount_paid = order.total_amount * Decimal(random.uniform(0.2, 0.8)) - order.is_paid = False - else: - order.amount_paid = Decimal(0) - order.is_paid = False - - order.save() - - created_count += 1 - self.stdout.write(f' [OK] Заказ #{order.order_number} на {delivery_date} (товаров: {len(order_products)})') - - except Exception as e: - self.stdout.write(self.style.ERROR(f'[ОШИБКА] Заказ {i+1}: {str(e)}')) - - self.stdout.write(self.style.SUCCESS(f'\n[ЗАВЕРШЕНО] Успешно создано {created_count} заказов!')) - self.stdout.write(f'Даты доставки: от {today - timedelta(days=15)} до {today + timedelta(days=15)}') - self.stdout.write(self.style.SUCCESS('\n[ВАЖНО] Резервы товаров созданы автоматически через Django сигналы!')) diff --git a/myproject/pos/static/pos/css/terminal.css b/myproject/pos/static/pos/css/terminal.css index e486859..bdfacd8 100644 --- a/myproject/pos/static/pos/css/terminal.css +++ b/myproject/pos/static/pos/css/terminal.css @@ -356,3 +356,70 @@ body { font-size: 0.8rem; } } + +/* Стили для модального окна выбора единицы продажи */ +.unit-selection-card { + border: 2px solid #dee2e6; + border-radius: 8px; + padding: 12px; + cursor: pointer; + transition: all 0.2s; + background: white; +} + +.unit-selection-card:hover { + border-color: #0d6efd; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + transform: translateY(-1px); +} + +.unit-selection-card.selected { + border-color: #0d6efd; + background: #e7f3ff; + box-shadow: 0 2px 12px rgba(13,110,253,0.3); +} + +.unit-selection-card .unit-name { + font-weight: 600; + font-size: 1rem; + color: #212529; + margin-bottom: 4px; +} + +.unit-selection-card .unit-price { + font-size: 1.1rem; + font-weight: 600; + color: #0d6efd; + margin-bottom: 4px; +} + +.unit-selection-card .unit-availability { + font-size: 0.85rem; + color: #6c757d; +} + +.unit-selection-card .unit-code { + font-size: 0.8rem; + color: #adb5bd; +} + +.unit-selection-card .badge { + font-size: 0.75rem; + padding: 4px 8px; +} + +/* Индикаторы наличия */ +.stock-badge-good { + background-color: #d4edda; + color: #155724; +} + +.stock-badge-low { + background-color: #fff3cd; + color: #856404; +} + +.stock-badge-none { + background-color: #f8d7da; + color: #721c24; +} diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index a8aa483..bd902a0 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -34,6 +34,12 @@ let editingKitId = null; // Временная корзина для модального окна создания/редактирования комплекта const tempCart = new Map(); +// ===== ПЕРЕМЕННЫЕ ДЛЯ МОДАЛЬНОГО ОКНА ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ ===== +let unitModalProduct = null; // Текущий товар для модального окна +let unitModalSalesUnits = []; // Список единиц продажи +let selectedSalesUnit = null; // Выбранная единица продажи +let unitModalInstance = null; // Bootstrap Modal instance + // ===== СОХРАНЕНИЕ КОРЗИНЫ В REDIS ===== let saveCartTimeout = null; @@ -390,6 +396,255 @@ async function createNewCustomer() { } } +// ===== ФУНКЦИИ ДЛЯ МОДАЛЬНОГО ОКНА ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ ===== + +/** + * Открывает модальное окно выбора единицы продажи + * @param {object} product - Объект товара с информацией о единицах продажи + */ +async function openProductUnitModal(product) { + unitModalProduct = product; + + // Устанавливаем название товара + document.getElementById('unitModalProductName').textContent = + `${product.name}${product.sku ? ' (' + product.sku + ')' : ''}`; + + // Загружаем единицы продажи + try { + const response = await fetch( + `/products/api/products/${product.id}/sales-units/?warehouse=${currentWarehouse.id}` + ); + const data = await response.json(); + + if (!data.success || !data.sales_units || data.sales_units.length === 0) { + alert('Не удалось загрузить единицы продажи'); + return; + } + + unitModalSalesUnits = data.sales_units; + + // Отрисовываем список единиц + renderUnitSelectionList(); + + // Выбираем единицу по умолчанию или первую + const defaultUnit = unitModalSalesUnits.find(u => u.is_default) || unitModalSalesUnits[0]; + if (defaultUnit) { + selectUnit(defaultUnit); + } + + // Открываем модальное окно + if (!unitModalInstance) { + unitModalInstance = new bootstrap.Modal(document.getElementById('selectProductUnitModal')); + } + unitModalInstance.show(); + + } catch (error) { + console.error('Ошибка загрузки единиц продажи:', error); + alert('Ошибка загрузки данных. Попробуйте ещё раз.'); + } +} + +/** + * Отрисовывает список единиц продажи + */ +function renderUnitSelectionList() { + const listContainer = document.getElementById('unitSelectionList'); + listContainer.innerHTML = ''; + + unitModalSalesUnits.forEach(unit => { + const card = document.createElement('div'); + card.className = 'unit-selection-card'; + card.dataset.unitId = unit.id; + card.onclick = () => selectUnit(unit); + + // Доступное количество + const availableQty = parseFloat(unit.available_quantity || 0); + let stockBadgeClass = 'stock-badge-none'; + let stockText = 'Нет на складе'; + + if (availableQty > 10) { + stockBadgeClass = 'stock-badge-good'; + stockText = `${availableQty} ${unit.unit_short_name} доступно`; + } else if (availableQty > 0) { + stockBadgeClass = 'stock-badge-low'; + stockText = `${availableQty} ${unit.unit_short_name} доступно`; + } + + // Бейдж "По умолчанию" + const defaultBadge = unit.is_default ? + 'По умолчанию' : ''; + + card.innerHTML = ` +
+
+
${unit.name}${defaultBadge}
+
${unit.unit_code} (${unit.unit_short_name})
+
+
${formatMoney(unit.actual_price)} руб
+
+
+ ${stockText} +
+ `; + + listContainer.appendChild(card); + }); +} + +/** + * Выбирает единицу продажи + * @param {object} unit - Объект единицы продажи + */ +function selectUnit(unit) { + selectedSalesUnit = unit; + + // Обновляем визуальное выделение + document.querySelectorAll('.unit-selection-card').forEach(card => { + if (card.dataset.unitId === String(unit.id)) { + card.classList.add('selected'); + } else { + card.classList.remove('selected'); + } + }); + + // Обновляем отображение выбранной единицы + document.getElementById('selectedUnitDisplay').textContent = + `${unit.name} (${unit.unit_short_name})`; + + // Устанавливаем минимальное количество и шаг + const qtyInput = document.getElementById('unitModalQuantity'); + qtyInput.value = roundQuantity(unit.min_quantity, 3); + qtyInput.min = unit.min_quantity; + qtyInput.step = unit.quantity_step; + + // Устанавливаем цену + document.getElementById('unitModalPrice').value = unit.actual_price; + + // Обновляем подсказку + const hintEl = document.getElementById('unitQtyHint'); + hintEl.textContent = `Мин. ${unit.min_quantity}, шаг ${unit.quantity_step}`; + + // Сбрасываем индикатор изменения цены + document.getElementById('priceOverrideIndicator').style.display = 'none'; + + // Пересчитываем итого + calculateUnitModalSubtotal(); + + // Валидируем количество + validateUnitQuantity(); +} + +/** + * Проверяет количество на соответствие ограничениям + * @returns {boolean} - true если валидно + */ +function validateUnitQuantity() { + if (!selectedSalesUnit) return false; + + const qtyInput = document.getElementById('unitModalQuantity'); + const qty = parseFloat(qtyInput.value); + const errorEl = document.getElementById('unitQtyError'); + const confirmBtn = document.getElementById('confirmAddUnitToCart'); + + // Проверка минимального количества + if (qty < parseFloat(selectedSalesUnit.min_quantity)) { + errorEl.textContent = `Минимальное количество: ${selectedSalesUnit.min_quantity}`; + errorEl.style.display = 'block'; + confirmBtn.disabled = true; + return false; + } + + // Проверка шага (с учётом погрешности) + const step = parseFloat(selectedSalesUnit.quantity_step); + const minQty = parseFloat(selectedSalesUnit.min_quantity); + const diff = qty - minQty; + const remainder = diff % step; + const epsilon = 0.0001; + + if (remainder > epsilon && (step - remainder) > epsilon) { + errorEl.textContent = `Количество должно быть кратно ${step}`; + errorEl.style.display = 'block'; + confirmBtn.disabled = true; + return false; + } + + // Всё ок, скрываем ошибку + errorEl.style.display = 'none'; + confirmBtn.disabled = false; + return true; +} + +/** + * Рассчитывает итоговую сумму + */ +function calculateUnitModalSubtotal() { + const qtyRaw = parseFloat(document.getElementById('unitModalQuantity').value) || 0; + const qty = roundQuantity(qtyRaw, 3); // Округляем количество + const price = parseFloat(document.getElementById('unitModalPrice').value) || 0; + // Округляем до 2 знаков после запятой для корректного отображения + const subtotal = Math.round(qty * price * 100) / 100; + + document.getElementById('unitModalSubtotal').textContent = `${formatMoney(subtotal)} руб`; + + // Проверяем изменение цены + if (selectedSalesUnit && Math.abs(price - parseFloat(selectedSalesUnit.actual_price)) > 0.01) { + document.getElementById('priceOverrideIndicator').style.display = 'block'; + } else { + document.getElementById('priceOverrideIndicator').style.display = 'none'; + } +} + +/** + * Добавляет товар с выбранной единицей в корзину + */ +function addToCartFromModal() { + if (!validateUnitQuantity()) { + return; + } + + const qtyRaw = parseFloat(document.getElementById('unitModalQuantity').value); + const qty = roundQuantity(qtyRaw, 3); // Округляем количество + const price = parseFloat(document.getElementById('unitModalPrice').value); + const priceOverridden = Math.abs(price - parseFloat(selectedSalesUnit.actual_price)) > 0.01; + + // Формируем ключ корзины: product-{id}-{sales_unit_id} + const cartKey = `product-${unitModalProduct.id}-${selectedSalesUnit.id}`; + + // Добавляем или обновляем в корзине + if (cart.has(cartKey)) { + const existing = cart.get(cartKey); + existing.qty = roundQuantity(existing.qty + qty, 3); // Округляем сумму + existing.price = price; // Обновляем цену + existing.quantity_step = parseFloat(selectedSalesUnit.quantity_step) || 1; // Обновляем шаг + existing.price_overridden = priceOverridden; + } else { + cart.set(cartKey, { + id: unitModalProduct.id, + name: unitModalProduct.name, + price: price, + qty: qty, + type: 'product', + sales_unit_id: selectedSalesUnit.id, + unit_name: selectedSalesUnit.name, + unit_short_name: selectedSalesUnit.unit_short_name, + quantity_step: parseFloat(selectedSalesUnit.quantity_step) || 1, // Сохраняем шаг количества + price_overridden: priceOverridden + }); + } + + // Обновляем корзину + renderCart(); + saveCartToRedis(); + + // Перерисовываем товары для обновления визуального остатка + if (!isShowcaseView) { + renderProducts(); + } + + // Закрываем модальное окно + unitModalInstance.hide(); +} + function renderCategories() { const grid = document.getElementById('categoryGrid'); grid.innerHTML = ''; @@ -755,6 +1010,13 @@ function setupInfiniteScroll() { } async function addToCart(item) { + // ПРОВЕРКА НА НАЛИЧИЕ НЕСКОЛЬКИХ ЕДИНИЦ ПРОДАЖИ + if (item.type === 'product' && item.sales_units_count > 1) { + // Открываем модальное окно выбора единицы + await openProductUnitModal(item); + return; + } + const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1" // СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock) @@ -819,7 +1081,8 @@ async function addToCart(item) { if (!cart.has(cartKey)) { cart.set(cartKey, { id: item.id, name: item.name, price: Number(item.price), qty: 1, type: item.type }); } else { - cart.get(cartKey).qty += 1; + const cartItem = cart.get(cartKey); + cartItem.qty = roundQuantity(cartItem.qty + 1, 3); } } @@ -850,10 +1113,13 @@ async function updateCartItemQty(cartKey, newQty) { const item = cart.get(cartKey); if (!item) return; - if (newQty <= 0) { + // Округляем новое количество + const roundedQty = roundQuantity(newQty, 3); + + if (roundedQty <= 0) { await removeFromCart(cartKey); } else { - item.qty = newQty; + item.qty = roundedQty; renderCart(); saveCartToRedis(); @@ -898,10 +1164,16 @@ function renderCart() { if (item.type === 'kit' || item.type === 'showcase_kit') { typeIcon = ' '; } + + // Единица продажи (если есть) + let unitInfo = ''; + if (item.sales_unit_id && item.unit_name) { + unitInfo = ` ${item.unit_name}`; + } namePrice.innerHTML = ` -
${typeIcon}${item.name}
-
${formatMoney(item.price)} / шт
+
${typeIcon}${item.name}${unitInfo}
+
${formatMoney(item.price)} / ${item.unit_short_name || 'шт'}
`; // Знак умножения @@ -932,7 +1204,7 @@ function renderCart() { qtyInput.style.width = '60px'; qtyInput.style.textAlign = 'center'; qtyInput.style.padding = '0.375rem 0.25rem'; - qtyInput.value = item.qty; + qtyInput.value = roundQuantity(item.qty, 3); qtyInput.min = 1; qtyInput.readOnly = true; // Только чтение - изменяем только через +/- qtyInput.style.backgroundColor = '#fff3cd'; // Желтый фон как у витринных @@ -960,7 +1232,8 @@ function renderCart() { minusBtn.onclick = async (e) => { e.preventDefault(); const currentQty = cart.get(cartKey).qty; - await updateCartItemQty(cartKey, currentQty - 1); + const step = item.quantity_step || 1; // Используем шаг единицы или 1 по умолчанию + await updateCartItemQty(cartKey, roundQuantity(currentQty - step, 3)); }; // Поле ввода количества @@ -970,12 +1243,18 @@ function renderCart() { qtyInput.style.width = '60px'; qtyInput.style.textAlign = 'center'; qtyInput.style.padding = '0.375rem 0.25rem'; - qtyInput.value = item.qty; + qtyInput.value = roundQuantity(item.qty, 3); qtyInput.min = 1; + qtyInput.step = item.quantity_step || 0.001; // Устанавливаем шаг единицы продажи qtyInput.onchange = async (e) => { - const newQty = parseInt(e.target.value) || 1; + const newQty = parseFloat(e.target.value) || 1; await updateCartItemQty(cartKey, newQty); }; + // Округление при потере фокуса + qtyInput.onblur = (e) => { + const rawValue = parseFloat(e.target.value) || 1; + e.target.value = roundQuantity(rawValue, 3); + }; // Кнопка плюс const plusBtn = document.createElement('button'); @@ -984,7 +1263,8 @@ function renderCart() { plusBtn.onclick = async (e) => { e.preventDefault(); const currentQty = cart.get(cartKey).qty; - await updateCartItemQty(cartKey, currentQty + 1); + const step = item.quantity_step || 1; // Используем шаг единицы или 1 по умолчанию + await updateCartItemQty(cartKey, roundQuantity(currentQty + step, 3)); }; // Собираем контейнер @@ -2103,6 +2383,10 @@ async function handleCheckoutSubmit(paymentsData) { if (item.type === 'showcase_kit' && item.showcase_item_ids) { itemData.showcase_item_ids = item.showcase_item_ids; } + // Для товаров с единицами продажи + if (item.sales_unit_id) { + itemData.sales_unit_id = item.sales_unit_id; + } return itemData; }), payments: paymentsData, @@ -2222,6 +2506,51 @@ document.addEventListener('DOMContentLoaded', () => { }); renderCart(); // Отрисовываем восстановленную корзину } + + // ===== ОБРАБОТЧИКИ ДЛЯ МОДАЛКИ ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ ===== + + // Кнопки изменения количества + document.getElementById('unitQtyDecrement').addEventListener('click', () => { + const input = document.getElementById('unitModalQuantity'); + const step = parseFloat(input.step) || 1; + const newValue = Math.max(parseFloat(input.min), parseFloat(input.value) - step); + input.value = roundQuantity(newValue, 3); + calculateUnitModalSubtotal(); + validateUnitQuantity(); + }); + + document.getElementById('unitQtyIncrement').addEventListener('click', () => { + const input = document.getElementById('unitModalQuantity'); + const step = parseFloat(input.step) || 1; + const newValue = parseFloat(input.value) + step; + input.value = roundQuantity(newValue, 3); + calculateUnitModalSubtotal(); + validateUnitQuantity(); + }); + + // Изменение количества вручную + document.getElementById('unitModalQuantity').addEventListener('input', () => { + calculateUnitModalSubtotal(); + validateUnitQuantity(); + }); + + // Округление количества при потере фокуса + document.getElementById('unitModalQuantity').addEventListener('blur', (e) => { + const rawValue = parseFloat(e.target.value) || 0; + e.target.value = roundQuantity(rawValue, 3); + calculateUnitModalSubtotal(); + validateUnitQuantity(); + }); + + // Изменение цены + document.getElementById('unitModalPrice').addEventListener('input', () => { + calculateUnitModalSubtotal(); + }); + + // Кнопка подтверждения добавления в корзину + document.getElementById('confirmAddUnitToCart').addEventListener('click', () => { + addToCartFromModal(); + }); }); // Смена склада diff --git a/myproject/pos/templates/pos/terminal.html b/myproject/pos/templates/pos/terminal.html index 4fffa00..093fd40 100644 --- a/myproject/pos/templates/pos/terminal.html +++ b/myproject/pos/templates/pos/terminal.html @@ -482,6 +482,91 @@ + + + {% endblock %} {% block extra_js %} diff --git a/myproject/pos/views.py b/myproject/pos/views.py index 230ac46..199ca12 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -792,6 +792,10 @@ def get_items_api(request): reserved = p.reserved_qty free_qty = available - reserved + # Подсчитываем активные единицы продажи + sales_units_count = p.sales_units.filter(is_active=True).count() + has_sales_units = sales_units_count > 0 + products.append({ 'id': p.id, 'name': p.name, @@ -804,7 +808,9 @@ def get_items_api(request): 'available_qty': str(available), 'reserved_qty': str(reserved), 'free_qty': str(free_qty), # Передаём как строку для сохранения точности - 'free_qty_sort': float(free_qty) # Для сортировки отдельное поле + 'free_qty_sort': float(free_qty), # Для сортировки отдельное поле + 'sales_units_count': sales_units_count, + 'has_sales_units': has_sales_units }) # Prefetch для первого фото комплектов @@ -1434,21 +1440,28 @@ def pos_checkout(request): with db_transaction.atomic(): # 1. Создаём заказ с текущей датой и временем в локальном часовом поясе (Europe/Minsk) from django.utils import timezone as tz + from orders.models import Delivery now_utc = tz.now() # Текущее время в UTC now_local = tz.localtime(now_utc) # Конвертируем в локальный часовой пояс (Europe/Minsk) current_time = now_local.time() # Извлекаем время в минском часовом поясе order = Order.objects.create( customer=customer, - is_delivery=False, # POS - всегда самовывоз - pickup_warehouse=warehouse, status=completed_status, # Сразу "Выполнен" - delivery_date=now_local.date(), # Текущая дата в минском часовом поясе - delivery_time_start=current_time, # Текущее время (Минск) - delivery_time_end=current_time, # То же время (точное время) special_instructions=order_notes, modified_by=request.user ) + + # Создаём связанную доставку (самовывоз для POS) + Delivery.objects.create( + order=order, + delivery_type=Delivery.DELIVERY_TYPE_PICKUP, # POS - всегда самовывоз + pickup_warehouse=warehouse, + delivery_date=now_local.date(), # Текущая дата в минском часовом поясе + time_from=current_time, # Текущее время (Минск) + time_to=current_time, # То же время (точное время) + cost=0 # Самовывоз бесплатный + ) # 2. Добавляем товары from inventory.models import ShowcaseItem diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index fa1cb89..80adc98 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -5,9 +5,10 @@ from django.http import JsonResponse from django.db import models from django.core.cache import cache from django.core.exceptions import ValidationError +from django.contrib.auth.decorators import login_required import logging -from ..models import Product, ProductVariantGroup, ProductKit, ProductCategory, ProductPhoto +from ..models import Product, ProductVariantGroup, ProductKit, ProductCategory, ProductPhoto, ProductSalesUnit logger = logging.getLogger(__name__) @@ -1360,7 +1361,7 @@ def get_payment_methods(request): 'error': f'Ошибка при загрузке способов оплаты: {str(e)}' }, status=500) - +@login_required def get_product_sales_units_api(request, product_id): """ API для получения единиц продажи товара с остатками.