5 Commits
v1.6.0 ... main

Author SHA1 Message Date
11fd314a65 release: v1.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:03:17 +03:00
e59a43c279 feat: унифицировать autocomplete для LOT на всех вкладках
- Все вкладки (storage, pci, power, accessories, sw, other) теперь
  используют редактируемый autocomplete-input для существующих позиций,
  как на вкладке base; выбор заменяет позицию с сохранением количества
- LOT-поле в BOM-таблицах переведено на общий autocomplete dropdown
  вместо datalist
- Кнопка ✕ в BOM снимает сопоставление вместо удаления строки
- Кнопка «Пересчитать эстимейт» переименована в «Перенести в эстимейт»

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:00:45 +03:00
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
4 changed files with 361 additions and 82 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

@@ -0,0 +1,13 @@
# QuoteForge v1.7
Дата релиза: 2026-04-23
Тег: `v1.7`
Предыдущий релиз: `v1.6.2`
## Ключевые изменения
- все вкладки estimate (storage, pci, power, accessories, sw, other) теперь используют редактируемый autocomplete-input для существующих позиций — поведение идентично вкладке base;
- LOT-поля в BOM-таблицах переведены на общий autocomplete dropdown вместо datalist;
- кнопка ✕ в BOM снимает сопоставление BOM→LOT вместо удаления строки;
- кнопка «Пересчитать эстимейт» переименована в «Перенести в эстимейт».

View File

@@ -194,7 +194,7 @@
Сохранить BOM Сохранить BOM
</button> </button>
<button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700"> <button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700">
Пересчитать эстимейт Перенести в эстимейт
</button> </button>
</div> </div>
</div> </div>
@@ -542,7 +542,8 @@ let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks o
// Autocomplete state // Autocomplete state
let autocompleteInput = null; let autocompleteInput = null;
let autocompleteCategory = null; let autocompleteCategory = null;
let autocompleteMode = null; // 'single', 'multi', 'section' let autocompleteMode = null; // 'single', 'multi', 'section', 'edit-item'
let autocompleteEditCategories = null;
let autocompleteIndex = -1; let autocompleteIndex = -1;
let autocompleteFiltered = []; let autocompleteFiltered = [];
@@ -837,6 +838,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 +1160,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 +1178,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 +1187,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 +1270,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">
@@ -1363,7 +1390,16 @@ function renderMultiSelectTab(components) {
html += ` html += `
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td> <td class="px-3 py-2 min-w-48">
<div class="autocomplete-wrapper relative">
<input type="text"
value="${escapeHtml(item.lot_name)}"
class="w-full px-2 py-1 border rounded text-sm font-mono"
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
oninput="filterAutocompleteEditItem(this.value)"
onkeydown="handleAutocompleteKeyEditItem(event)">
</div>
</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td> <td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td> <td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
@@ -1457,6 +1493,10 @@ function renderMultiSelectTabWithSections(sections) {
<tbody class="divide-y"> <tbody class="divide-y">
`; `;
// Add empty row for new item in this section
const sectionId = section.categories.join('-');
const categoriesStr = section.categories.join(',');
// Render existing cart items for this section // Render existing cart items for this section
sectionItems.forEach((item) => { sectionItems.forEach((item) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name); const comp = allComponents.find(c => c.lot_name === item.lot_name);
@@ -1464,7 +1504,17 @@ function renderMultiSelectTabWithSections(sections) {
html += ` html += `
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td> <td class="px-3 py-2 min-w-48">
<div class="autocomplete-wrapper relative">
<input type="text"
value="${escapeHtml(item.lot_name)}"
data-categories="${categoriesStr}"
class="w-full px-2 py-1 border rounded text-sm font-mono"
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
oninput="filterAutocompleteEditItem(this.value)"
onkeydown="handleAutocompleteKeyEditItem(event)">
</div>
</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td> <td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td> <td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
@@ -1485,8 +1535,6 @@ function renderMultiSelectTabWithSections(sections) {
}); });
// Add empty row for new item in this section // Add empty row for new item in this section
const sectionId = section.categories.join('-');
const categoriesStr = section.categories.join(',');
html += ` html += `
<tr class="hover:bg-gray-50 bg-gray-50"> <tr class="hover:bg-gray-50 bg-gray-50">
<td class="px-3 py-2" colspan="2"> <td class="px-3 py-2" colspan="2">
@@ -1614,6 +1662,10 @@ function renderAutocomplete() {
onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`; onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`;
} else if (autocompleteMode === 'multi') { } else if (autocompleteMode === 'multi') {
onmousedown = `selectAutocompleteItemMulti(${idx})`; onmousedown = `selectAutocompleteItemMulti(${idx})`;
} else if (autocompleteMode === 'bom') {
onmousedown = `selectAutocompleteItemBOM(${idx}, ${autocompleteCategory})`;
} else if (autocompleteMode === 'edit-item') {
onmousedown = `selectAutocompleteEditItem(${idx})`;
} else { } else {
// single mode // single mode
onmousedown = `selectAutocompleteItem(${idx})`; onmousedown = `selectAutocompleteItem(${idx})`;
@@ -1895,6 +1947,138 @@ function selectAutocompleteItemSection(index, sectionId) {
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false }); schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
} }
// Autocomplete for editing an existing cart item's LOT (multi/section tabs)
async function showAutocompleteEditItem(lotName, input) {
autocompleteInput = input;
autocompleteCategory = lotName;
autocompleteMode = 'edit-item';
autocompleteIndex = -1;
autocompleteEditCategories = input.dataset.categories
? input.dataset.categories.split(',').map(c => c.trim().toUpperCase())
: null;
const components = getComponentsForTab(currentTab);
await ensurePricesLoaded(components);
filterAutocompleteEditItem(input.value);
}
function filterAutocompleteEditItem(search) {
const searchLower = (search || '').toLowerCase();
const components = autocompleteEditCategories
? allComponents.filter(c => autocompleteEditCategories.includes(getComponentCategory(c)))
: getComponentsForTab(currentTab);
autocompleteFiltered = components.filter(c => {
if (!hasComponentPrice(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
return (c.lot_name + ' ' + (c.description || '')).toLowerCase().includes(searchLower);
}).sort((a, b) => {
const d = (b.popularity_score || 0) - (a.popularity_score || 0);
return d !== 0 ? d : a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeyEditItem(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteEditItem(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteEditItem(index) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const lotName = autocompleteCategory;
const oldItem = cart.find(i => i.lot_name === lotName);
const qty = oldItem?.quantity || 1;
cart = cart.filter(i => i.lot_name !== lotName);
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
// Autocomplete for BOM LOT mapping
function showAutocompleteBOM(rowIdx, input) {
autocompleteInput = input;
autocompleteCategory = rowIdx;
autocompleteMode = 'bom';
autocompleteIndex = -1;
filterAutocompleteBOM(rowIdx, input.value);
}
function filterAutocompleteBOM(rowIdx, search) {
const searchLower = (search || '').toLowerCase();
autocompleteFiltered = (window._bomAllComponents || allComponents).filter(c => {
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
}).sort((a, b) => {
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeyBOM(event, rowIdx) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemBOM(autocompleteIndex, rowIdx);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItemBOM(index, rowIdx) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
if (!row) return;
row.manual_lot = comp.lot_name;
hideAutocomplete();
resolveBOM();
}
function clearSingleSelect(category) { function clearSingleSelect(category) {
cart = cart.filter(item => cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase() (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
@@ -2096,6 +2280,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 +2345,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 +2824,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 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;
} }
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(refreshPayload)
}); });
if (!resp.ok) {
showToast('Ошибка обновления цен', 'error');
return;
}
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(); syncPriceSettingsControls();
renderPricelistSettingsSummary(); renderPricelistSettingsSummary();
if (selectedPricelistIds.estimate) {
persistLocalPriceSettings(); persistLocalPriceSettings();
}
}
// Re-render UI await saveConfig(false);
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();
}
} }
} }
@@ -2889,6 +3126,18 @@ function deleteBOMRawRow(rowIdx) {
rebuildBOMRowsFromRaw(); rebuildBOMRowsFromRaw();
} }
function clearBOMLotMapping(rowIdx) {
const row = bomRows.find(r => r.source_row_index === rowIdx);
if (!row) return;
row.manual_lot = '';
row.resolved_lot = '';
row.resolution_source = 'unresolved';
row.lot_allocations = [];
row.bundle_enabled = false;
renderBOMTable();
debouncedResolveBOM();
}
function _bomRawLotCell(rowIdx) { function _bomRawLotCell(rowIdx) {
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return '—'; if (!bomImportRaw || bomImportRaw.mode !== 'raw') return '—';
if (bomImportRaw.ignoredRows?.[rowIdx]) return '<span class="text-gray-400">—</span>'; if (bomImportRaw.ignoredRows?.[rowIdx]) return '<span class="text-gray-400">—</span>';
@@ -2910,13 +3159,12 @@ function _bomRawLotCell(rowIdx) {
if (isUnresolved) { if (isUnresolved) {
const val = map.manual_lot || ''; const val = map.manual_lot || '';
const invalid = val && !_bomLotValid(val); return `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
return `<input type="text" placeholder="LOT..." value="${escapeHtml(val)}" value="${escapeHtml(val)}"
class="w-full min-w-28 px-2 py-1 border rounded text-xs ${invalid ? 'border-red-400 bg-red-50' : ''}" class="w-full min-w-28 px-2 py-1 border rounded text-xs font-mono"
list="lot-autocomplete-list" onfocus="showAutocompleteBOM(${rowIdx}, this)"
oninput="setBOMManualLotDraft(${rowIdx}, this.value, this)" oninput="filterAutocompleteBOM(${rowIdx}, this.value); setBOMManualLotDraft(${rowIdx}, this.value, this)"
onchange="commitBOMManualLot(${rowIdx}, this)" onkeydown="handleAutocompleteKeyBOM(event, ${rowIdx})"></div>
onblur="commitBOMManualLot(${rowIdx}, this)">
${renderBOMLotAllocationsEditor(rowIdx)}`; ${renderBOMLotAllocationsEditor(rowIdx)}`;
} }
let suffix = ''; let suffix = '';
@@ -3300,12 +3548,12 @@ function _renderBOMParsedTable() {
let lotCell = ''; let lotCell = '';
if (isUnresolved) { if (isUnresolved) {
lotCell = `<input type="text" placeholder="Введите LOT..." value="${escapeHtml(row.manual_lot || '')}" lotCell = `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
class="w-full px-2 py-1 border rounded text-sm focus:ring-1 focus:ring-blue-400" value="${escapeHtml(row.manual_lot || '')}"
oninput="bomRows[${idx}].manual_lot = this.value; this.classList.toggle('border-red-400', this.value && !_bomLotValid(this.value));" class="w-full px-2 py-1 border rounded text-sm font-mono focus:ring-1 focus:ring-blue-400"
onchange="if(_bomLotValid(this.value)){bomRows[${idx}].manual_lot=this.value;resolveBOM(); this.classList.remove('border-red-400');}else{this.value=bomRows[${idx}].manual_lot||'';}" onfocus="showAutocompleteBOM(${row.source_row_index}, this)"
onblur="if(this.value && !_bomLotValid(this.value)){this.value=bomRows[${idx}].manual_lot||'';}" oninput="filterAutocompleteBOM(${row.source_row_index}, this.value); bomRows.find(r=>r.source_row_index===${row.source_row_index}).manual_lot=this.value;"
list="lot-autocomplete-list">${renderBOMLotAllocationsEditor(idx)}`; onkeydown="handleAutocompleteKeyBOM(event, ${row.source_row_index})"></div>${renderBOMLotAllocationsEditor(idx)}`;
} else { } else {
let suffix = ''; let suffix = '';
if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`; if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`;
@@ -3395,7 +3643,7 @@ function _renderBOMRawTable() {
<td class="w-12 px-1 py-1 border-b text-center align-top whitespace-nowrap"> <td class="w-12 px-1 py-1 border-b text-center align-top whitespace-nowrap">
<button type="button" title="Добавить LOT в bundle" onclick="addBOMAllocation(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-blue-600">+</button> <button type="button" title="Добавить LOT в bundle" onclick="addBOMAllocation(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-blue-600">+</button>
<button type="button" title="${ignored ? 'Не игнорировать' : 'Игнорировать'}" onclick="toggleBOMRawRowIgnored(${rowIdx})" class="inline-block text-xs px-1 ${ignored ? 'text-blue-600' : 'text-gray-400 hover:text-gray-700'}">${ignored ? '◉' : '○'}</button> <button type="button" title="${ignored ? 'Не игнорировать' : 'Игнорировать'}" onclick="toggleBOMRawRowIgnored(${rowIdx})" class="inline-block text-xs px-1 ${ignored ? 'text-blue-600' : 'text-gray-400 hover:text-gray-700'}">${ignored ? '◉' : '○'}</button>
<button type="button" title="Удалить строку" onclick="deleteBOMRawRow(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button> <button type="button" title="Снять сопоставление" onclick="clearBOMLotMapping(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button>
</td>`; </td>`;
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
@@ -4019,14 +4267,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() {