From 1137c6d4db595030362d859d6a0cccc0eac0efd8 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Wed, 15 Apr 2026 18:56:40 +0300 Subject: [PATCH] Persist pricing state and refresh storage sync --- internal/localdb/components.go | 35 +++++-- web/templates/index.html | 181 +++++++++++++++++++++++---------- 2 files changed, 153 insertions(+), 63 deletions(-) diff --git a/internal/localdb/components.go b/internal/localdb/components.go index fbd0077..41363d5 100644 --- a/internal/localdb/components.go +++ b/internal/localdb/components.go @@ -28,8 +28,9 @@ type ComponentSyncResult struct { func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) { startTime := time.Now() - // Query to join lot with qt_lot_metadata (metadata only, no pricing) - // Use LEFT JOIN to include lots without metadata + // Build the component catalog from every runtime source of LOT names. + // Storage lots may exist in qt_lot_metadata / qt_pricelist_items before they appear in lot, + // so the sync cannot start from lot alone. type componentRow struct { LotName string LotDescription string @@ -40,15 +41,29 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) var rows []componentRow err := mariaDB.Raw(` SELECT - l.lot_name, - l.lot_description, - COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category, - m.model - FROM lot l - LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name + src.lot_name, + COALESCE(MAX(NULLIF(TRIM(l.lot_description), '')), '') AS lot_description, + COALESCE( + MAX(NULLIF(TRIM(c.code), '')), + MAX(NULLIF(TRIM(l.lot_category), '')), + SUBSTRING_INDEX(src.lot_name, '_', 1) + ) AS category, + MAX(NULLIF(TRIM(m.model), '')) AS model + FROM ( + SELECT lot_name FROM lot + UNION + SELECT lot_name FROM qt_lot_metadata + WHERE is_hidden = FALSE OR is_hidden IS NULL + UNION + SELECT lot_name FROM qt_pricelist_items + ) src + LEFT JOIN lot l ON l.lot_name = src.lot_name + LEFT JOIN qt_lot_metadata m + ON m.lot_name = src.lot_name + AND (m.is_hidden = FALSE OR m.is_hidden IS NULL) LEFT JOIN qt_categories c ON m.category_id = c.id - WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL - ORDER BY l.lot_name + GROUP BY src.lot_name + ORDER BY src.lot_name `).Scan(&rows).Error if err != nil { return nil, fmt.Errorf("querying components from MariaDB: %w", err) diff --git a/web/templates/index.html b/web/templates/index.html index 95dd6dc..882fd17 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -378,7 +378,6 @@ let TAB_CONFIG = { singleSelect: false, label: 'Storage', sections: [ - { title: 'RAID Контроллеры', categories: ['RAID'] }, { title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] } ] }, @@ -837,6 +836,7 @@ document.addEventListener('DOMContentLoaded', async function() { serverModelForQuote = config.server_model || ''; supportCode = config.support_code || ''; currentArticle = config.article || ''; + restorePricingStateFromNotes(config.notes || ''); // Restore custom price if saved if (config.custom_price) { @@ -1158,9 +1158,16 @@ const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']); const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC']; // Server-only categories — hidden for storage configs const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM']; +const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID']; +const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU']; +const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU']; function applyConfigTypeToTabs() { const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC']; + const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL']; + const storageSections = [ + { title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] } + ]; const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC']; const pciSections = [ { title: 'GPU / DPU', categories: ['GPU', 'DPU'] }, @@ -1168,6 +1175,7 @@ function applyConfigTypeToTabs() { { title: 'HBA', categories: ['HBA'] }, { title: 'HIC', categories: ['HIC'] } ]; + const powerCategories = ['PS', 'PSU']; TAB_CONFIG.base.categories = baseCategories.filter(c => { if (configType === 'storage') { @@ -1176,11 +1184,22 @@ function applyConfigTypeToTabs() { return !STORAGE_ONLY_BASE_CATEGORIES.includes(c); }); + TAB_CONFIG.storage.categories = storageCategories.filter(c => { + return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true; + }); + TAB_CONFIG.storage.sections = storageSections; + TAB_CONFIG.pci.categories = pciCategories.filter(c => { - return configType === 'storage' ? true : c !== 'HIC'; + return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC'; }); TAB_CONFIG.pci.sections = pciSections.filter(section => { - return configType === 'storage' ? true : section.title !== 'HIC'; + if (configType === 'storage') { + return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat)); + } + return section.title !== 'HIC'; + }); + TAB_CONFIG.power.categories = powerCategories.filter(c => { + return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true; }); // Rebuild assigned categories index @@ -1243,7 +1262,7 @@ function renderSingleSelectTab(categories) { if (currentTab === 'base') { html += `
- +
@@ -2096,6 +2115,58 @@ function getCurrentArticle() { return currentArticle || ''; } +function buildPricingState() { + const buyCustom = parseDecimalInput(document.getElementById('pricing-custom-price-buy')?.value || ''); + const saleUplift = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || ''); + const saleCustom = parseDecimalInput(document.getElementById('pricing-custom-price-sale')?.value || ''); + + return { + buy_custom_price: buyCustom > 0 ? buyCustom : null, + sale_uplift: saleUplift > 0 ? saleUplift : null, + sale_custom_price: saleCustom > 0 ? saleCustom : null, + }; +} + +function serializeConfigNotes() { + return JSON.stringify({ + pricing_ui: buildPricingState() + }); +} + +function restorePricingStateFromNotes(notesRaw) { + if (!notesRaw) return; + let parsed; + try { + parsed = JSON.parse(notesRaw); + } catch (_) { + return; + } + + const pricing = parsed?.pricing_ui; + if (!pricing || typeof pricing !== 'object') return; + + const buyInput = document.getElementById('pricing-custom-price-buy'); + if (buyInput) { + buyInput.value = typeof pricing.buy_custom_price === 'number' && pricing.buy_custom_price > 0 + ? pricing.buy_custom_price.toFixed(2) + : ''; + } + + const upliftInput = document.getElementById('pricing-uplift-sale'); + if (upliftInput) { + upliftInput.value = typeof pricing.sale_uplift === 'number' && pricing.sale_uplift > 0 + ? formatUpliftInput(pricing.sale_uplift) + : ''; + } + + const saleInput = document.getElementById('pricing-custom-price-sale'); + if (saleInput) { + saleInput.value = typeof pricing.sale_custom_price === 'number' && pricing.sale_custom_price > 0 + ? pricing.sale_custom_price.toFixed(2) + : ''; + } +} + function getAutosaveStorageKey() { return `qf_config_autosave_${configUUID || 'default'}`; } @@ -2109,7 +2180,7 @@ function buildSavePayload() { name: configName, items: cart, custom_price: customPrice, - notes: '', + notes: serializeConfigNotes(), server_count: serverCount, server_model: serverModelForQuote, support_code: supportCode, @@ -2588,66 +2659,67 @@ async function refreshPrices() { return; } + const refreshBtn = document.getElementById('refresh-prices-btn'); + const previousLabel = refreshBtn ? refreshBtn.textContent : ''; + try { - const refreshPayload = {}; - if (selectedPricelistIds.estimate) { - refreshPayload.pricelist_id = selectedPricelistIds.estimate; + if (refreshBtn) { + refreshBtn.disabled = true; + refreshBtn.textContent = 'Обновление...'; + refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed'; } - const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(refreshPayload) + + const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' }); + if (!componentSyncResp.ok) { + throw new Error('component sync failed'); + } + + const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' }); + if (!pricelistSyncResp.ok) { + throw new Error('pricelist sync failed'); + } + + await Promise.all([ + loadActivePricelists(true), + loadAllComponents() + ]); + + ['estimate', 'warehouse', 'competitor'].forEach(source => { + const latest = activePricelistsBySource[source]?.[0]; + if (latest && latest.id) { + selectedPricelistIds[source] = Number(latest.id); + resolvedAutoPricelistIds[source] = null; + } }); - if (!resp.ok) { - showToast('Ошибка обновления цен', 'error'); - return; - } + syncPriceSettingsControls(); + renderPricelistSettingsSummary(); + persistLocalPriceSettings(); - const config = await resp.json(); - - // Update cart with new prices - if (config.items && config.items.length > 0) { - cart = config.items.map(item => ({ - lot_name: item.lot_name, - quantity: item.quantity, - unit_price: item.unit_price, - estimate_price: item.unit_price, - warehouse_price: null, - competitor_price: null, - description: item.description || '', - category: item.category || getCategoryFromLotName(item.lot_name) - })); - } - - // Update price update date - if (config.price_updated_at) { - updatePriceUpdateDate(config.price_updated_at); - } - if (config.pricelist_id) { - if (selectedPricelistIds.estimate) { - selectedPricelistIds.estimate = config.pricelist_id; - } else { - resolvedAutoPricelistIds.estimate = Number(config.pricelist_id); - } - if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) { - await loadActivePricelists(); - } - syncPriceSettingsControls(); - renderPricelistSettingsSummary(); - if (selectedPricelistIds.estimate) { - persistLocalPriceSettings(); - } - } - - // Re-render UI + await saveConfig(false); await refreshPriceLevels({ force: true, noCache: true }); renderTab(); updateCartUI(); + if (configUUID) { + const configResp = await fetch('/api/configs/' + configUUID); + if (configResp.ok) { + const config = await configResp.json(); + if (config.price_updated_at) { + updatePriceUpdateDate(config.price_updated_at); + } + } + } + showToast('Цены обновлены', 'success'); } catch(e) { showToast('Ошибка обновления цен', 'error'); + } finally { + if (refreshBtn) { + refreshBtn.disabled = false; + refreshBtn.textContent = previousLabel || 'Обновить цены'; + updateRefreshPricesButtonState(); + } } } @@ -4019,14 +4091,17 @@ function applyCustomPrice(table) { function onBuyCustomPriceInput() { applyCustomPrice('buy'); + triggerAutoSave(); } function onSaleCustomPriceInput() { applyCustomPrice('sale'); + triggerAutoSave(); } function onSaleMarkupInput() { renderPricingTab(); + triggerAutoSave(); } function setPricingCustomPriceFromVendor() {