feat: упростить создание заказов и рефакторинг единиц измерения

- Добавить inline-редактирование цен в списке товаров
- Оптимизировать карточки товаров в POS-терминале
- Рефакторинг моделей единиц измерения
- Миграция unit -> base_unit в SalesUnit
- Улучшить UI форм создания/редактирования товаров

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-17 03:34:43 +03:00
parent 928b340486
commit 2f1f0621e6
24 changed files with 1079 additions and 227 deletions

View File

@@ -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 ?
'<span class="badge bg-primary ms-2">По умолчанию</span>' : '';
card.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="unit-name">${unit.name}${defaultBadge}</div>
<div class="unit-code text-muted">${unit.unit_code} (${unit.unit_short_name})</div>
</div>
<div class="unit-price">${formatMoney(unit.actual_price)} руб</div>
</div>
@@ -486,7 +487,7 @@ function renderUnitSelectionList() {
<span class="badge ${stockBadgeClass}">${stockText}</span>
</div>
`;
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 = `
<div class="fw-semibold small">${typeIcon}${item.name}${unitInfo}</div>
<div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / ${item.unit_short_name || 'шт'}</div>
<div class="fw-semibold small">${typeIcon}${item.name}</div>
<div class="price-unit-row">
<span class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)}</span>${unitInfo}
</div>
`;
// Знак умножения