3 Commits
v1.6.0 ... main

Author SHA1 Message Date
Mikhail Chusavitin
83a3202bdf Restore RAID section for server storage tab 2026-04-16 09:28:14 +03:00
Mikhail Chusavitin
4bc7979a70 Remove obsolete storage components guide docx 2026-04-15 18:58:10 +03:00
Mikhail Chusavitin
1137c6d4db Persist pricing state and refresh storage sync 2026-04-15 18:56:40 +03:00
3 changed files with 159 additions and 62 deletions

Binary file not shown.

View File

@@ -28,8 +28,9 @@ type ComponentSyncResult struct {
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) { func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
startTime := time.Now() startTime := time.Now()
// Query to join lot with qt_lot_metadata (metadata only, no pricing) // Build the component catalog from every runtime source of LOT names.
// Use LEFT JOIN to include lots without metadata // 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 { type componentRow struct {
LotName string LotName string
LotDescription string LotDescription string
@@ -40,15 +41,29 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
var rows []componentRow var rows []componentRow
err := mariaDB.Raw(` err := mariaDB.Raw(`
SELECT SELECT
l.lot_name, src.lot_name,
l.lot_description, COALESCE(MAX(NULLIF(TRIM(l.lot_description), '')), '') AS lot_description,
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category, COALESCE(
m.model MAX(NULLIF(TRIM(c.code), '')),
FROM lot l MAX(NULLIF(TRIM(l.lot_category), '')),
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name 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 LEFT JOIN qt_categories c ON m.category_id = c.id
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL GROUP BY src.lot_name
ORDER BY l.lot_name ORDER BY src.lot_name
`).Scan(&rows).Error `).Scan(&rows).Error
if err != nil { if err != nil {
return nil, fmt.Errorf("querying components from MariaDB: %w", err) return nil, fmt.Errorf("querying components from MariaDB: %w", err)

View File

@@ -837,6 +837,7 @@ document.addEventListener('DOMContentLoaded', async function() {
serverModelForQuote = config.server_model || ''; serverModelForQuote = config.server_model || '';
supportCode = config.support_code || ''; supportCode = config.support_code || '';
currentArticle = config.article || ''; currentArticle = config.article || '';
restorePricingStateFromNotes(config.notes || '');
// Restore custom price if saved // Restore custom price if saved
if (config.custom_price) { if (config.custom_price) {
@@ -1158,9 +1159,17 @@ const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC']; const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
// Server-only categories — hidden for storage configs // Server-only categories — hidden for storage configs
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM']; 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() { function applyConfigTypeToTabs() {
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC']; const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
const storageSections = [
{ title: 'RAID Контроллеры', categories: ['RAID'] },
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
];
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC']; const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
const pciSections = [ const pciSections = [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] }, { title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
@@ -1168,6 +1177,7 @@ function applyConfigTypeToTabs() {
{ title: 'HBA', categories: ['HBA'] }, { title: 'HBA', categories: ['HBA'] },
{ title: 'HIC', categories: ['HIC'] } { title: 'HIC', categories: ['HIC'] }
]; ];
const powerCategories = ['PS', 'PSU'];
TAB_CONFIG.base.categories = baseCategories.filter(c => { TAB_CONFIG.base.categories = baseCategories.filter(c => {
if (configType === 'storage') { if (configType === 'storage') {
@@ -1176,11 +1186,27 @@ function applyConfigTypeToTabs() {
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c); 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.filter(section => {
if (configType === 'storage') {
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
}
return true;
});
TAB_CONFIG.pci.categories = pciCategories.filter(c => { 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 => { 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 // Rebuild assigned categories index
@@ -1243,7 +1269,7 @@ function renderSingleSelectTab(categories) {
if (currentTab === 'base') { if (currentTab === 'base') {
html += ` html += `
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start"> <div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label> <label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель системы для партномера:</label>
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label> <label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
</div> </div>
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start"> <div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
@@ -2096,6 +2122,58 @@ function getCurrentArticle() {
return currentArticle || ''; 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() { function getAutosaveStorageKey() {
return `qf_config_autosave_${configUUID || 'default'}`; return `qf_config_autosave_${configUUID || 'default'}`;
} }
@@ -2109,7 +2187,7 @@ function buildSavePayload() {
name: configName, name: configName,
items: cart, items: cart,
custom_price: customPrice, custom_price: customPrice,
notes: '', notes: serializeConfigNotes(),
server_count: serverCount, server_count: serverCount,
server_model: serverModelForQuote, server_model: serverModelForQuote,
support_code: supportCode, support_code: supportCode,
@@ -2588,66 +2666,67 @@ async function refreshPrices() {
return; return;
} }
const refreshBtn = document.getElementById('refresh-prices-btn');
const previousLabel = refreshBtn ? refreshBtn.textContent : '';
try { try {
const refreshPayload = {}; if (refreshBtn) {
if (selectedPricelistIds.estimate) { refreshBtn.disabled = true;
refreshPayload.pricelist_id = selectedPricelistIds.estimate; 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', const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' });
headers: { 'Content-Type': 'application/json' }, if (!componentSyncResp.ok) {
body: JSON.stringify(refreshPayload) 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) { syncPriceSettingsControls();
showToast('Ошибка обновления цен', 'error'); renderPricelistSettingsSummary();
return; persistLocalPriceSettings();
}
const config = await resp.json(); await saveConfig(false);
// 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 refreshPriceLevels({ force: true, noCache: true }); await refreshPriceLevels({ force: true, noCache: true });
renderTab(); renderTab();
updateCartUI(); 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'); showToast('Цены обновлены', 'success');
} catch(e) { } catch(e) {
showToast('Ошибка обновления цен', 'error'); showToast('Ошибка обновления цен', 'error');
} finally {
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.textContent = previousLabel || 'Обновить цены';
updateRefreshPricesButtonState();
}
} }
} }
@@ -4019,14 +4098,17 @@ function applyCustomPrice(table) {
function onBuyCustomPriceInput() { function onBuyCustomPriceInput() {
applyCustomPrice('buy'); applyCustomPrice('buy');
triggerAutoSave();
} }
function onSaleCustomPriceInput() { function onSaleCustomPriceInput() {
applyCustomPrice('sale'); applyCustomPrice('sale');
triggerAutoSave();
} }
function onSaleMarkupInput() { function onSaleMarkupInput() {
renderPricingTab(); renderPricingTab();
triggerAutoSave();
} }
function setPricingCustomPriceFromVendor() { function setPricingCustomPriceFromVendor() {