Implement warehouse/lot pricing updates and configurator performance fixes

This commit is contained in:
2026-02-07 05:20:35 +03:00
parent c1a31e5ee0
commit 7c741ff675
26 changed files with 1701 additions and 305 deletions

View File

@@ -246,6 +246,10 @@
<input id="settings-disable-price-refresh" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span>Не обновлять цены</span>
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input id="settings-only-in-stock" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span>Только наличие</span>
</label>
</div>
<div class="px-5 py-4 border-t flex justify-end gap-2">
<button type="button" onclick="closePriceSettingsModal()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Отмена</button>
@@ -334,12 +338,19 @@ let selectedPricelistIds = {
competitor: null
};
let disablePriceRefresh = false;
let onlyInStock = false;
let activePricelistsBySource = {
estimate: [],
warehouse: [],
competitor: []
};
let activePricelistsLoadedAt = 0;
let activePricelistsLoadPromise = null;
let priceLevelsRequestSeq = 0;
let priceLevelsRefreshTimer = null;
let warehouseStockLotsByPricelist = new Map();
let warehouseStockLoadSeq = 0;
let warehouseStockLoadsByPricelist = new Map();
// Autocomplete state
let autocompleteInput = null;
@@ -389,8 +400,10 @@ function formatDelta(abs, pct) {
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
}
async function refreshPriceLevels() {
if (!configUUID || cart.length === 0 || disablePriceRefresh) {
async function refreshPriceLevels(options = {}) {
const force = options.force === true;
const noCache = options.noCache === true;
if (!configUUID || cart.length === 0 || (disablePriceRefresh && !force)) {
return;
}
@@ -401,6 +414,7 @@ async function refreshPriceLevels() {
lot_name: item.lot_name,
quantity: item.quantity
})),
no_cache: noCache,
pricelist_ids: Object.fromEntries(
Object.entries(selectedPricelistIds)
.filter(([, id]) => typeof id === 'number' && id > 0)
@@ -443,6 +457,99 @@ async function refreshPriceLevels() {
}
}
function schedulePriceLevelsRefresh(options = {}) {
const delay = Number.isFinite(options.delay) ? options.delay : 120;
const rerender = options.rerender !== false;
const autosave = options.autosave === true;
const noCache = options.noCache === true;
const force = options.force === true;
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
priceLevelsRefreshTimer = setTimeout(async () => {
priceLevelsRefreshTimer = null;
await refreshPriceLevels({ noCache, force });
if (rerender) {
renderTab();
updateCartUI();
}
if (autosave) {
triggerAutoSave();
}
}, Math.max(0, delay));
}
function currentWarehousePricelistID() {
const id = selectedPricelistIds.warehouse;
if (Number.isFinite(id) && id > 0) return Number(id);
const fallback = activePricelistsBySource.warehouse?.[0]?.id;
if (Number.isFinite(fallback) && fallback > 0) return Number(fallback);
return null;
}
async function loadWarehouseInStockLots() {
const pricelistID = currentWarehousePricelistID();
if (!pricelistID) return new Set();
if (warehouseStockLotsByPricelist.has(pricelistID)) {
return warehouseStockLotsByPricelist.get(pricelistID);
}
const existingLoad = warehouseStockLoadsByPricelist.get(pricelistID);
if (existingLoad) {
return existingLoad;
}
const loadPromise = (async () => {
const seq = ++warehouseStockLoadSeq;
const result = new Set();
const resp = await fetch(`/api/pricelists/${pricelistID}/lots`);
if (!resp.ok) {
throw new Error(`warehouse lots request failed: ${resp.status}`);
}
const data = await resp.json();
const lotNames = Array.isArray(data.lot_names) ? data.lot_names : [];
lotNames.forEach(lot => {
if (typeof lot === 'string' && lot.trim() !== '') {
result.add(lot);
}
});
if (seq === warehouseStockLoadSeq) {
warehouseStockLotsByPricelist.set(pricelistID, result);
}
return result;
})();
warehouseStockLoadsByPricelist.set(pricelistID, loadPromise);
try {
return await loadPromise;
} finally {
warehouseStockLoadsByPricelist.delete(pricelistID);
}
}
async function ensureWarehouseStockFilterLoaded() {
if (!onlyInStock) return;
try {
await loadWarehouseInStockLots();
} catch (e) {
console.error('Failed to load warehouse availability filter', e);
showToast('Не удалось загрузить наличие склада', 'error');
}
}
function isComponentAllowedByStockFilter(comp) {
if (!onlyInStock) return true;
const pricelistID = currentWarehousePricelistID();
if (!pricelistID) return false;
const availableLots = warehouseStockLotsByPricelist.get(pricelistID);
// Don't block UI while stock set is being loaded.
if (!availableLots) return true;
return availableLots.has(comp.lot_name);
}
// Load categories from API and update tab configuration
async function loadCategoriesFromAPI() {
try {
@@ -486,8 +593,8 @@ document.addEventListener('DOMContentLoaded', async function() {
return;
}
// Load categories first
await loadCategoriesFromAPI();
// Load categories in background (defaults are usable immediately).
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
try {
const resp = await fetch('/api/configs/' + configUUID);
@@ -508,6 +615,7 @@ document.addEventListener('DOMContentLoaded', async function() {
document.getElementById('server-count').value = serverCount;
document.getElementById('total-server-count').textContent = serverCount;
selectedPricelistIds.estimate = config.pricelist_id || null;
onlyInStock = Boolean(config.only_in_stock);
if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({
@@ -538,14 +646,21 @@ document.addEventListener('DOMContentLoaded', async function() {
}
restoreLocalPriceSettings();
await loadActivePricelists();
await Promise.all([
loadActivePricelists(),
loadAllComponents(),
categoriesPromise,
]);
syncPriceSettingsControls();
renderPricelistSettingsSummary();
updateRefreshPricesButtonState();
await loadAllComponents();
await refreshPriceLevels();
renderTab();
updateCartUI();
ensureWarehouseStockFilterLoaded().then(() => {
renderTab();
updateCartUI();
});
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: false });
// Close autocomplete on outside click
document.addEventListener('click', function(e) {
@@ -582,25 +697,44 @@ function updateServerCount() {
triggerAutoSave();
}
async function loadActivePricelists() {
async function loadActivePricelists(force = false) {
const now = Date.now();
const isFresh = (now - activePricelistsLoadedAt) < 15000;
if (!force && isFresh) {
return;
}
if (activePricelistsLoadPromise) {
await activePricelistsLoadPromise;
return;
}
const sources = ['estimate', 'warehouse', 'competitor'];
await Promise.all(sources.map(async source => {
try {
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
const data = await resp.json();
activePricelistsBySource[source] = data.pricelists || [];
const existing = selectedPricelistIds[source];
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
return;
activePricelistsLoadPromise = (async () => {
await Promise.all(sources.map(async source => {
try {
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
const data = await resp.json();
activePricelistsBySource[source] = data.pricelists || [];
const existing = selectedPricelistIds[source];
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
return;
}
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
? Number(activePricelistsBySource[source][0].id)
: null;
} catch (e) {
activePricelistsBySource[source] = [];
selectedPricelistIds[source] = null;
}
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
? Number(activePricelistsBySource[source][0].id)
: null;
} catch (e) {
activePricelistsBySource[source] = [];
selectedPricelistIds[source] = null;
}
}));
}));
activePricelistsLoadedAt = Date.now();
})();
try {
await activePricelistsLoadPromise;
} finally {
activePricelistsLoadPromise = null;
}
}
function renderPricelistSelectOptions(selectId, source) {
@@ -627,6 +761,10 @@ function syncPriceSettingsControls() {
if (disableCheckbox) {
disableCheckbox.checked = disablePriceRefresh;
}
const inStockCheckbox = document.getElementById('settings-only-in-stock');
if (inStockCheckbox) {
inStockCheckbox.checked = onlyInStock;
}
}
function getPricelistVersionById(source, id) {
@@ -642,7 +780,8 @@ function renderPricelistSettingsSummary() {
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}`;
const stockFilterState = onlyInStock ? ' | Только наличие: вкл' : '';
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}${stockFilterState}`;
}
function updateRefreshPricesButtonState() {
@@ -693,11 +832,14 @@ function restoreLocalPriceSettings() {
}
}
async function openPriceSettingsModal() {
await loadActivePricelists();
function openPriceSettingsModal() {
syncPriceSettingsControls();
renderPricelistSettingsSummary();
document.getElementById('price-settings-modal')?.classList.remove('hidden');
loadActivePricelists().then(() => {
syncPriceSettingsControls();
renderPricelistSettingsSummary();
});
}
function closePriceSettingsModal() {
@@ -709,22 +851,31 @@ function applyPriceSettings() {
const warehouseVal = parseInt(document.getElementById('settings-pricelist-warehouse')?.value || '');
const competitorVal = parseInt(document.getElementById('settings-pricelist-competitor')?.value || '');
const disableVal = Boolean(document.getElementById('settings-disable-price-refresh')?.checked);
const inStockVal = Boolean(document.getElementById('settings-only-in-stock')?.checked);
const prevWarehouseID = currentWarehousePricelistID();
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
disablePriceRefresh = disableVal;
onlyInStock = inStockVal;
const nextWarehouseID = currentWarehousePricelistID();
if (Number.isFinite(prevWarehouseID) && prevWarehouseID > 0 && prevWarehouseID !== nextWarehouseID) {
warehouseStockLotsByPricelist.delete(prevWarehouseID);
}
if (onlyInStock) {
ensureWarehouseStockFilterLoaded().then(() => {
renderTab();
updateCartUI();
});
}
updateRefreshPricesButtonState();
renderPricelistSettingsSummary();
persistLocalPriceSettings();
closePriceSettingsModal();
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
}
function getCategoryFromLotName(lotName) {
@@ -1065,6 +1216,7 @@ function filterAutocomplete(category, search) {
autocompleteFiltered = components.filter(c => {
if (!c.current_price) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
@@ -1166,11 +1318,10 @@ function selectAutocompleteItem(index) {
});
hideAutocomplete();
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
function hideAutocomplete() {
@@ -1200,6 +1351,7 @@ function filterAutocompleteMulti(search) {
autocompleteFiltered = components.filter(c => {
if (!c.current_price) return false;
if (addedLots.has(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
@@ -1258,11 +1410,10 @@ function selectAutocompleteItemMulti(index) {
});
hideAutocomplete();
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
@@ -1299,6 +1450,7 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
autocompleteFiltered = sectionComponents.filter(c => {
if (!c.current_price) return false;
if (addedLots.has(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
@@ -1364,12 +1516,10 @@ function selectAutocompleteItemSection(index, sectionId) {
// Reset quantity to 1
if (qtyInput) qtyInput.value = '1';
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
function clearSingleSelect(category) {
@@ -1517,6 +1667,12 @@ function triggerAutoSave() {
async function saveConfig(showNotification = true) {
// RBAC disabled - no token check required
if (!configUUID) return;
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
await refreshPriceLevels({ force: true, noCache: true });
// Get custom price if set
const customPriceInput = document.getElementById('custom-price-input');
@@ -1538,7 +1694,8 @@ async function saveConfig(showNotification = true) {
custom_price: customPrice,
notes: '',
server_count: serverCountValue,
pricelist_id: selectedPricelistIds.estimate
pricelist_id: selectedPricelistIds.estimate,
only_in_stock: onlyInStock
})
});
@@ -1563,10 +1720,20 @@ async function exportCSV() {
if (cart.length === 0) return;
try {
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
await refreshPriceLevels({ force: true, noCache: true });
const exportItems = cart.map(item => ({
...item,
unit_price: getDisplayPrice(item),
}));
const resp = await fetch('/api/export/csv', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: cart, name: configName})
body: JSON.stringify({items: exportItems, name: configName})
});
const blob = await resp.blob();
@@ -1793,6 +1960,11 @@ function clearCustomPrice() {
async function exportCSVWithCustomPrice() {
if (cart.length === 0) return;
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
await refreshPriceLevels({ force: true, noCache: true });
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);