From 2f1f0621e68430aa22965e96f300521aa052635b Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sat, 17 Jan 2026 03:34:43 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=83=D0=BF=D1=80=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=B8=D1=86=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавить inline-редактирование цен в списке товаров - Оптимизировать карточки товаров в POS-терминале - Рефакторинг моделей единиц измерения - Миграция unit -> base_unit в SalesUnit - Улучшить UI форм создания/редактирования товаров Co-Authored-By: Claude Opus 4.5 --- myproject/pos/static/pos/js/terminal.js | 237 +++++++++++++----- myproject/pos/templates/pos/terminal.html | 16 +- myproject/pos/views.py | 41 ++- myproject/products/admin.py | 15 +- myproject/products/forms.py | 160 +++++++++++- .../0002_migrate_unit_to_base_unit.py | 101 ++++++++ .../0003_remove_unit_from_sales_unit.py | 18 ++ myproject/products/models/products.py | 28 +-- myproject/products/models/units.py | 7 +- myproject/products/services/import_export.py | 22 +- .../static/products/js/sales-units.js | 2 +- .../templates/products/product_detail.html | 4 - .../templates/products/product_form.html | 203 +++++++++++++++ .../products/uom/sales_unit_delete.html | 3 - .../products/uom/sales_unit_form.html | 38 +-- .../products/uom/sales_unit_list.html | 28 +-- .../templates/products/uom/unit_delete.html | 67 +++++ .../templates/products/uom/unit_form.html | 110 ++++++++ .../templates/products/uom/unit_list.html | 12 +- myproject/products/urls.py | 7 +- myproject/products/views/__init__.py | 6 + myproject/products/views/api_views.py | 16 +- myproject/products/views/product_views.py | 64 ++++- myproject/products/views/uom_views.py | 101 ++++++-- 24 files changed, 1079 insertions(+), 227 deletions(-) create mode 100644 myproject/products/migrations/0002_migrate_unit_to_base_unit.py create mode 100644 myproject/products/migrations/0003_remove_unit_from_sales_unit.py create mode 100644 myproject/products/templates/products/uom/unit_delete.html create mode 100644 myproject/products/templates/products/uom/unit_form.html diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index d519030..f46771f 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -108,21 +108,23 @@ function formatMoney(v) { * - Баланс кошелька в модальном окне продажи (если оно открыто) */ function updateCustomerDisplay() { + // Определяем, системный ли это клиент + const isSystemCustomer = Number(selectedCustomer.id) === Number(SYSTEM_CUSTOMER.id); + const displayName = isSystemCustomer ? 'Выбрать клиента' : selectedCustomer.name; + // Обновляем текст кнопки в корзине const btnText = document.getElementById('customerSelectBtnText'); if (btnText) { - btnText.textContent = selectedCustomer.name; + btnText.textContent = displayName; } // Обновляем текст кнопки в модалке продажи const checkoutBtnText = document.getElementById('checkoutCustomerSelectBtnText'); if (checkoutBtnText) { - checkoutBtnText.textContent = selectedCustomer.name; + checkoutBtnText.textContent = displayName; } // Обновляем видимость кнопок сброса (в корзине и в модалке продажи) - // Приводим к числу для надёжного сравнения (JSON может вернуть разные типы) - const isSystemCustomer = Number(selectedCustomer.id) === Number(SYSTEM_CUSTOMER.id); [document.getElementById('resetCustomerBtn'), document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => { @@ -450,35 +452,34 @@ async function openProductUnitModal(product) { 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} доступно`; + stockText = `${availableQty} шт доступно`; } else if (availableQty > 0) { stockBadgeClass = 'stock-badge-low'; - stockText = `${availableQty} ${unit.unit_short_name} доступно`; + stockText = `${availableQty} шт доступно`; } - + // Бейдж "По умолчанию" - const defaultBadge = unit.is_default ? + const defaultBadge = unit.is_default ? 'По умолчанию' : ''; - + card.innerHTML = `
${unit.name}${defaultBadge}
-
${unit.unit_code} (${unit.unit_short_name})
${formatMoney(unit.actual_price)} руб
@@ -486,7 +487,7 @@ function renderUnitSelectionList() { ${stockText} `; - + listContainer.appendChild(card); }); } @@ -508,9 +509,9 @@ function selectUnit(unit) { }); // Обновляем отображение выбранной единицы - document.getElementById('selectedUnitDisplay').textContent = - `${unit.name} (${unit.unit_short_name})`; - + document.getElementById('selectedUnitDisplay').textContent = + unit.name; + // Устанавливаем минимальное количество и шаг const qtyInput = document.getElementById('unitModalQuantity'); qtyInput.value = roundQuantity(unit.min_quantity, 3); @@ -626,7 +627,6 @@ function addToCartFromModal() { 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 }); @@ -645,6 +645,54 @@ function addToCartFromModal() { unitModalInstance.hide(); } +/** + * Добавляет товар с единицей продажи напрямую в корзину (без модального окна) + * Используется для быстрого добавления когда у товара только одна единица продажи + * @param {object} product - Товар из ITEMS + * @param {object} salesUnit - Единица продажи (default_sales_unit) + * @param {number} qty - Количество для добавления + */ +async function addProductWithUnitToCart(product, salesUnit, qty = 1) { + const cartKey = `product-${product.id}-${salesUnit.id}`; + + if (cart.has(cartKey)) { + const existing = cart.get(cartKey); + existing.qty = roundQuantity(existing.qty + qty, 3); + } else { + cart.set(cartKey, { + id: product.id, + name: product.name, + price: Number(salesUnit.price), + qty: qty, + type: 'product', + sales_unit_id: salesUnit.id, + unit_name: salesUnit.name, + quantity_step: parseFloat(salesUnit.quantity_step) || 1 + }); + } + + renderCart(); + saveCartToRedis(); + + // Перерисовываем товары для обновления визуального остатка + if (!isShowcaseView) { + renderProducts(); + } + + // Фокус на поле количества + setTimeout(() => { + const qtyInputs = document.querySelectorAll('.qty-input'); + const itemIndex = Array.from(cart.keys()).indexOf(cartKey); + + if (itemIndex !== -1 && qtyInputs[itemIndex]) { + if (!isMobileDevice()) { + qtyInputs[itemIndex].focus(); + qtyInputs[itemIndex].select(); + } + } + }, 50); +} + function renderCategories() { const grid = document.getElementById('categoryGrid'); grid.innerHTML = ''; @@ -699,7 +747,7 @@ function renderCategories() { // Категории CATEGORIES.forEach(cat => { const col = document.createElement('div'); - col.className = 'col-6 col-sm-4 col-md-3 col-lg-2'; + col.className = 'col-6 col-sm-4 col-md-3 col-lg-custom-5'; const card = document.createElement('div'); card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : ''); @@ -752,7 +800,7 @@ function renderProducts() { filtered.forEach(item => { const col = document.createElement('div'); - col.className = 'col-6 col-sm-4 col-md-3 col-lg-2'; + col.className = 'col-6 col-sm-4 col-md-3 col-lg-custom-5'; const card = document.createElement('div'); card.className = 'card product-card'; @@ -852,54 +900,102 @@ function renderProducts() { stock.style.color = '#856404'; stock.style.fontWeight = 'bold'; } else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) { - // Для обычных товаров показываем остатки: FREE(-RESERVED-IN_CART) - // FREE = доступно для продажи (available - reserved - в корзине) + // Для обычных товаров показываем остатки + // Если у товара есть единицы продажи - отображаем в единицах продажи const available = parseFloat(item.available_qty) || 0; const reserved = parseFloat(item.reserved_qty) || 0; - // Вычитаем количество из корзины для визуализации - const cartKey = `product-${item.id}`; - const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0; + // Используем единицу продажи если есть + if (item.default_sales_unit) { + const unit = item.default_sales_unit; + const conversionFactor = parseFloat(unit.conversion_factor) || 1; - const free = available - reserved - inCart; - const freeRounded = roundQuantity(free, 3); // Округляем для отображения + // Вычисляем количество в корзине в единицах продажи + const cartKey = `product-${item.id}-${unit.id}`; + const inCartBaseQty = cart.has(cartKey) ? cart.get(cartKey).qty : 0; - // Создаём элементы для стилизации разных размеров - const freeSpan = document.createElement('span'); - freeSpan.textContent = freeRounded; // Используем округлённое значение - freeSpan.style.fontSize = '1.1em'; - freeSpan.style.fontWeight = 'bold'; - freeSpan.style.fontStyle = 'normal'; + // Свободное количество в единицах продажи + const availableInUnit = parseFloat(item.available_qty_in_unit) || 0; + const reservedInUnit = reserved * conversionFactor; + const freeInUnit = availableInUnit - reservedInUnit - inCartBaseQty; + const freeRounded = roundQuantity(freeInUnit, 1); // Округляем для отображения - // Отображаем резерв и корзину если они есть - const suffixParts = []; - if (reserved > 0) { - suffixParts.push(`−${roundQuantity(reserved, 3)}`); - } - if (inCart > 0) { - suffixParts.push(`−${roundQuantity(inCart, 3)}🛒`); - } + // Создаём элементы для стилизации + const freeSpan = document.createElement('span'); + freeSpan.style.fontSize = '1.1em'; + freeSpan.style.fontWeight = 'bold'; - if (suffixParts.length > 0) { - const suffixSpan = document.createElement('span'); - suffixSpan.textContent = `(${suffixParts.join(' ')})`; - suffixSpan.style.fontSize = '0.85em'; - suffixSpan.style.marginLeft = '3px'; - suffixSpan.style.fontStyle = 'normal'; + const qtyText = document.createElement('span'); + qtyText.textContent = freeRounded; + freeSpan.appendChild(qtyText); - stock.appendChild(freeSpan); - stock.appendChild(suffixSpan); + const unitBadge = document.createElement('span'); + unitBadge.className = 'badge bg-secondary ms-1'; + unitBadge.style.fontSize = '0.7rem'; + unitBadge.textContent = unit.name; + freeSpan.appendChild(unitBadge); + + // Отображаем корзину если есть + if (inCartBaseQty > 0) { + const suffixSpan = document.createElement('span'); + suffixSpan.textContent = ` (−${roundQuantity(inCartBaseQty, 1)}🛒)`; + suffixSpan.style.fontSize = '0.85em'; + suffixSpan.style.marginLeft = '3px'; + + stock.appendChild(freeSpan); + stock.appendChild(suffixSpan); + } else { + stock.appendChild(freeSpan); + } + + // Цветовая индикация + if (freeInUnit <= 0) { + stock.style.color = '#dc3545'; // Красный + } else if (freeInUnit < 5) { + stock.style.color = '#ffc107'; // Жёлтый + } else { + stock.style.color = '#28a745'; // Зелёный + } } else { - stock.appendChild(freeSpan); - } + // Отображение в базовых единицах (старая логика) + const cartKey = `product-${item.id}`; + const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0; - // Цветовая индикация: красный если свободных остатков нет или отрицательные - if (free <= 0) { - stock.style.color = '#dc3545'; // Красный - } else if (free < 5) { - stock.style.color = '#ffc107'; // Жёлтый (мало остатков) - } else { - stock.style.color = '#28a745'; // Зелёный (достаточно) + const free = available - reserved - inCart; + const freeRounded = roundQuantity(free, 3); + + const freeSpan = document.createElement('span'); + freeSpan.textContent = freeRounded; + freeSpan.style.fontSize = '1.1em'; + freeSpan.style.fontWeight = 'bold'; + + const suffixParts = []; + if (reserved > 0) { + suffixParts.push(`−${roundQuantity(reserved, 3)}`); + } + if (inCart > 0) { + suffixParts.push(`−${roundQuantity(inCart, 3)}🛒`); + } + + if (suffixParts.length > 0) { + const suffixSpan = document.createElement('span'); + suffixSpan.textContent = `(${suffixParts.join(' ')})`; + suffixSpan.style.fontSize = '0.85em'; + suffixSpan.style.marginLeft = '3px'; + + stock.appendChild(freeSpan); + stock.appendChild(suffixSpan); + } else { + stock.appendChild(freeSpan); + } + + if (free <= 0) { + stock.style.color = '#dc3545'; + } else if (free < 5) { + stock.style.color = '#ffc107'; + } else { + stock.style.color = '#28a745'; + } } } else { // Комплекты: показываем доступное количество @@ -929,7 +1025,9 @@ function renderProducts() { const priceSpan = document.createElement('span'); priceSpan.className = 'product-price'; - priceSpan.textContent = `${formatMoney(item.price)}`; + // Используем цену из единицы продажи если есть, иначе базовую цену + const itemPrice = item.default_sales_unit ? item.price_in_unit : item.price; + priceSpan.textContent = `${formatMoney(itemPrice)}`; sku.appendChild(skuText); sku.appendChild(priceSpan); @@ -1022,9 +1120,18 @@ function setupInfiniteScroll() { } async function addToCart(item) { - // ПРОВЕРКА НА НАЛИЧИЕ НЕСКОЛЬКИХ ЕДИНИЦ ПРОДАЖИ - if (item.type === 'product' && item.sales_units_count > 1) { - // Открываем модальное окно выбора единицы + // ПРОВЕРКА НА НАЛИЧИЕ ЕДИНИЦ ПРОДАЖИ + // Если у товара одна единица продажи - добавляем сразу + // Если несколько - показываем модальное окно выбора + console.log('addToCart:', item.name, 'has_sales_units:', item.has_sales_units, 'sales_units_count:', item.sales_units_count); + + if (item.type === 'product' && item.has_sales_units) { + // Если одна единица продажи - добавляем сразу + if (item.sales_units_count === 1 && item.default_sales_unit) { + await addProductWithUnitToCart(item, item.default_sales_unit, 1); + return; + } + // Иначе открываем модальное окно выбора единицы await openProductUnitModal(item); return; } @@ -1187,8 +1294,10 @@ function renderCart() { } namePrice.innerHTML = ` -
${typeIcon}${item.name}${unitInfo}
-
${formatMoney(item.price)} / ${item.unit_short_name || 'шт'}
+
${typeIcon}${item.name}
+
+ ${formatMoney(item.price)}${unitInfo} +
`; // Знак умножения diff --git a/myproject/pos/templates/pos/terminal.html b/myproject/pos/templates/pos/terminal.html index 137b42d..2bcc338 100644 --- a/myproject/pos/templates/pos/terminal.html +++ b/myproject/pos/templates/pos/terminal.html @@ -16,7 +16,7 @@
-
+
@@ -39,7 +39,7 @@
-
+
@@ -67,11 +67,8 @@
Корзина
+
+

+ Настройте, в каких единицах продается товар (ветка, кг, штука). + Коэффициент указывает, сколько единиц продажи получается из 1 базовой единицы. +

+ + {{ sales_unit_formset.management_form }} + + + + +
+ {% for form in sales_unit_formset %} +
+
+ {% if form.instance.pk %} + + {% endif %} + +
+ + {{ form.unit }} +
+
+ + {{ form.name }} +
+
+ + {{ form.conversion_factor }} +
+
+ + {{ form.price }} +
+
+ + {{ form.sale_price }} +
+
+ + {{ form.min_quantity }} +
+
+ + {{ form.quantity_step }} +
+
+ + {{ form.position }} +
+
+ + {{ form.is_default }} +
+
+
+
+ {{ form.is_active }} +
+ {% if form.instance.pk %} +
+ {{ form.DELETE }} +
+ {% endif %} +
+
+
+ {% if form.errors %} +
+ {% for field, errors in form.errors.items %} + {{ field }}: {{ errors|join:", " }} + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+ + +
+
+ {% endif %} + +
+ {% if object %}
@@ -686,6 +837,58 @@ document.addEventListener('DOMContentLoaded', function() { }); }, 250); }); + + // === Динамическое добавление единиц продажи === + const addButton = document.getElementById('add-sales-unit'); + const container = document.getElementById('sales-units-container'); + const totalFormsInput = document.querySelector('[name="sales_units-TOTAL_FORMS"]'); + + if (addButton && container && totalFormsInput) { + addButton.addEventListener('click', function() { + const formCount = parseInt(totalFormsInput.value); + const template = document.getElementById('empty-sales-unit-template'); + + if (template) { + // Клонируем содержимое шаблона + const newRow = template.content.cloneNode(true); + const rowDiv = newRow.querySelector('.sales-unit-row'); + + // Обновляем имена и id полей + rowDiv.querySelectorAll('input, select').forEach(input => { + const name = input.getAttribute('name'); + const id = input.getAttribute('id'); + + if (name) { + // Заменяем __prefix__ на текущий индекс + input.setAttribute('name', name.replace('__prefix__', formCount)); + } + if (id) { + input.setAttribute('id', id.replace('__prefix__', formCount)); + } + + // Устанавливаем значения по умолчанию + if (input.type === 'checkbox') { + if (input.name.includes('is_active')) { + input.checked = true; + } else { + input.checked = false; + } + } else if (input.type !== 'hidden') { + if (input.name.includes('min_quantity') || input.name.includes('quantity_step')) { + input.value = '1'; + } else if (input.name.includes('position')) { + input.value = formCount; + } else if (input.name.includes('conversion_factor')) { + input.value = '1'; + } + } + }); + + container.appendChild(rowDiv); + totalFormsInput.value = formCount + 1; + } + }); + } }); {% endblock %} diff --git a/myproject/products/templates/products/uom/sales_unit_delete.html b/myproject/products/templates/products/uom/sales_unit_delete.html index 11c5e8b..6a3979a 100644 --- a/myproject/products/templates/products/uom/sales_unit_delete.html +++ b/myproject/products/templates/products/uom/sales_unit_delete.html @@ -22,9 +22,6 @@
Название:
{{ sales_unit.name }}
-
Единица:
-
{{ sales_unit.unit.name }} ({{ sales_unit.unit.short_name }})
-
Коэффициент:
{{ sales_unit.conversion_factor }}
diff --git a/myproject/products/templates/products/uom/sales_unit_form.html b/myproject/products/templates/products/uom/sales_unit_form.html index 035a430..8e3a53b 100644 --- a/myproject/products/templates/products/uom/sales_unit_form.html +++ b/myproject/products/templates/products/uom/sales_unit_form.html @@ -20,32 +20,18 @@
{% endif %} - -
-
- - {{ form.product }} - {% if form.product.help_text %} - {{ form.product.help_text }} - {% endif %} - {% if form.product.errors %} -
{{ form.product.errors }}
- {% endif %} -
-
- - {{ form.unit }} - {% if form.unit.help_text %} - {{ form.unit.help_text }} - {% endif %} - {% if form.unit.errors %} -
{{ form.unit.errors }}
- {% endif %} -
+ +
+ + {{ form.product }} + {% if form.product.help_text %} + {{ form.product.help_text }} + {% endif %} + {% if form.product.errors %} +
{{ form.product.errors }}
+ {% endif %}
diff --git a/myproject/products/templates/products/uom/sales_unit_list.html b/myproject/products/templates/products/uom/sales_unit_list.html index 690fcf9..d2d4ac6 100644 --- a/myproject/products/templates/products/uom/sales_unit_list.html +++ b/myproject/products/templates/products/uom/sales_unit_list.html @@ -17,19 +17,9 @@
-
+
-
- -