Implement warehouse/lot pricing updates and configurator performance fixes
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user