feat: кнопка "Обновить цены" использует последний скачанный прайслист без синхронизации и показывает diff

- убрать вызовы /api/sync/components и /api/sync/pricelists из обеих кнопок
- брать самый свежий прайслист из уже скачанных (active_only)
- проверять галочку disable_price_refresh (пропускать конфиг если включена)
- показывать модальное окно diff: компонент / цена за шт. / сумма (было → стало) + итог конфиги
- общие утилиты (fetchLatestEstimatePricelistId, showPriceDiffModal) вынесены в base.html
- обе кнопки вызывают refreshPrices() без дублирования кода

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 04:26:25 +03:00
parent 56782fa718
commit 09d694234d
3 changed files with 226 additions and 106 deletions

View File

@@ -79,6 +79,24 @@
</div> </div>
</div> </div>
<!-- Price Diff Modal -->
<div id="price-diff-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col">
<div class="p-5 border-b border-gray-200 flex-shrink-0 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">Изменение цен</h3>
<button onclick="closePriceDiffModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="price-diff-modal-body" class="overflow-y-auto flex-1 p-5 space-y-5"></div>
<div class="p-4 border-t border-gray-200 flex-shrink-0 flex justify-end">
<button onclick="closePriceDiffModal()" class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 text-sm">Закрыть</button>
</div>
</div>
</div>
<!-- Sync Info Modal --> <!-- Sync Info Modal -->
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4"> <div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col"> <div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
@@ -517,6 +535,121 @@
// } // }
// } // }
// ==================== SHARED PRICE REFRESH UTILITIES ====================
async function fetchLatestEstimatePricelistId() {
try {
const resp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1');
if (!resp.ok) return null;
const data = await resp.json();
const list = data.pricelists || data.items || data;
if (Array.isArray(list) && list.length > 0) return Number(list[0].id);
} catch(_) {}
return null;
}
function _fmtMoneyDiff(value) {
if (!Number.isFinite(Number(value))) return 'N/A';
return '$ ' + Math.round(Number(value)).toLocaleString('ru-RU');
}
function _fmtArrow(prev, next) {
const diff = next - prev;
if (Math.abs(diff) < 0.5) return '';
const pct = prev > 0 ? Math.round((diff / prev) * 100) : 0;
const sign = diff > 0 ? '+' : '';
const color = diff > 0 ? 'text-red-600' : 'text-green-600';
return ` <span class="${color} text-xs font-medium">(${sign}${pct}%)</span>`;
}
function _buildDiffRow(lot, qty, prev, next) {
const prevLine = prev * qty;
const nextLine = next * qty;
const delta = next - prev;
const arrowColor = delta > 0 ? 'text-red-600' : 'text-green-600';
return `<tr class="border-b border-gray-100 last:border-0">
<td class="py-1.5 pr-3 text-sm text-gray-700 font-mono">${lot}</td>
<td class="py-1.5 px-2 text-sm text-right text-gray-500">${qty}</td>
<td class="py-1.5 px-2 text-sm text-right whitespace-nowrap">
<span class="text-gray-400 line-through text-xs">${_fmtMoneyDiff(prev)}</span>
<span class="${arrowColor} font-medium ml-1">${_fmtMoneyDiff(next)}</span>
</td>
<td class="py-1.5 pl-2 text-sm text-right whitespace-nowrap">
<span class="text-gray-400 line-through text-xs">${_fmtMoneyDiff(prevLine)}</span>
<span class="${arrowColor} font-medium ml-1">${_fmtMoneyDiff(nextLine)}</span>
</td>
</tr>`;
}
function showPriceDiffModal(results) {
const body = document.getElementById('price-diff-modal-body');
if (!body) return;
const sections = results.filter(r => !r.skipped);
if (sections.length === 0) {
body.innerHTML = '<p class="text-gray-500 text-sm text-center py-4">Обновление цен отключено для всех конфигураций</p>';
document.getElementById('price-diff-modal').classList.remove('hidden');
return;
}
let html = '';
let anyChanges = false;
for (const r of sections) {
if (r.error) {
html += `<div class="text-sm text-red-600 bg-red-50 rounded px-3 py-2">${r.configName ? `<span class="font-medium">${r.configName}:</span> ` : ''}Ошибка обновления цен</div>`;
continue;
}
const diffs = (r.itemDiffs || []).filter(d => Math.abs(d.prevPrice - d.newPrice) > 0.01);
const totalDelta = (r.newTotal || 0) - (r.prevTotal || 0);
if (results.length > 1) {
html += `<div class="text-sm font-semibold text-gray-800 mb-1">${r.configName || '—'}</div>`;
}
if (diffs.length === 0) {
html += `<div class="text-sm text-gray-500 bg-gray-50 rounded px-3 py-2 mb-2">Изменений нет</div>`;
} else {
anyChanges = true;
html += `<div class="overflow-x-auto mb-2">
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase">
<th class="pb-1 pr-3 font-medium">Компонент</th>
<th class="pb-1 px-2 text-right font-medium">Кол.</th>
<th class="pb-1 px-2 text-right font-medium">Цена / шт.</th>
<th class="pb-1 pl-2 text-right font-medium">Сумма</th>
</tr>
</thead>
<tbody>${diffs.map(d => _buildDiffRow(d.lot_name, d.quantity, d.prevPrice, d.newPrice)).join('')}</tbody>
</table>
</div>`;
}
const totalColor = totalDelta > 0 ? 'text-red-600' : totalDelta < 0 ? 'text-green-600' : 'text-gray-600';
const totalArrow = _fmtArrow(r.prevTotal || 0, r.newTotal || 0);
html += `<div class="flex justify-between items-center text-sm bg-gray-50 rounded px-3 py-2 mb-1">
<span class="text-gray-600 font-medium">Итог конфигурации</span>
<span>
<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>
<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
</span>
</div>`;
}
if (!anyChanges && sections.every(r => !r.error)) {
html = '<div class="text-sm text-gray-500 text-center py-6">Цены актуальны — изменений нет</div>' + html;
}
body.innerHTML = html;
document.getElementById('price-diff-modal').classList.remove('hidden');
}
function closePriceDiffModal() {
document.getElementById('price-diff-modal')?.classList.add('hidden');
}
// Call functions immediately to ensure they run even before DOMContentLoaded // Call functions immediately to ensure they run even before DOMContentLoaded
// This ensures username and admin link are visible ASAP // This ensures username and admin link are visible ASAP
loadDBUser(); loadDBUser();

View File

@@ -2817,7 +2817,6 @@ async function exportCSVWithCustomPrice() {
} }
async function refreshPrices() { async function refreshPrices() {
// RBAC disabled - no token check required
if (!configUUID) return; if (!configUUID) return;
if (disablePriceRefresh) { if (disablePriceRefresh) {
showToast('Обновление цен отключено в настройках', 'error'); showToast('Обновление цен отключено в настройках', 'error');
@@ -2834,30 +2833,7 @@ async function refreshPrices() {
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed'; refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
} }
let serverSyncSkipped = false; await loadActivePricelists(true);
try {
const statusResp = await fetch('/api/sync/status');
const statusData = statusResp.ok ? await statusResp.json() : null;
if (statusData && statusData.is_online) {
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');
} else {
serverSyncSkipped = true;
}
} catch(syncErr) {
if (syncErr.message === 'component sync failed' || syncErr.message === 'pricelist sync failed') {
throw syncErr;
}
serverSyncSkipped = true;
}
await Promise.all([
loadActivePricelists(true),
loadAllComponents()
]);
['estimate', 'warehouse', 'competitor'].forEach(source => { ['estimate', 'warehouse', 'competitor'].forEach(source => {
const latest = activePricelistsBySource[source]?.[0]; const latest = activePricelistsBySource[source]?.[0];
@@ -2871,22 +2847,44 @@ async function refreshPrices() {
renderPricelistSettingsSummary(); renderPricelistSettingsSummary();
persistLocalPriceSettings(); persistLocalPriceSettings();
// Snapshot prices before refresh for diff
const beforePricesMap = {};
let beforeTotal = 0;
for (const item of cart) {
const p = getDisplayPrice(item);
beforePricesMap[item.lot_name] = { price: p, qty: item.quantity };
beforeTotal += p * item.quantity;
}
beforeTotal *= serverCount;
await saveConfig(false); await saveConfig(false);
await refreshPriceLevels({ force: true, noCache: true }); await refreshPriceLevels({ force: true, noCache: true });
renderTab(); renderTab();
updateCartUI(); updateCartUI();
// Compute diff after refresh
const itemDiffs = [];
let afterTotal = 0;
for (const item of cart) {
const newPrice = getDisplayPrice(item);
afterTotal += newPrice * item.quantity;
const before = beforePricesMap[item.lot_name];
if (before && Math.abs(before.price - newPrice) > 0.01) {
itemDiffs.push({ lot_name: item.lot_name, quantity: item.quantity, prevPrice: before.price, newPrice });
}
}
afterTotal *= serverCount;
if (configUUID) { if (configUUID) {
const configResp = await fetch('/api/configs/' + configUUID); const configResp = await fetch('/api/configs/' + configUUID);
if (configResp.ok) { if (configResp.ok) {
const config = await configResp.json(); const config = await configResp.json();
if (config.price_updated_at) { if (config.price_updated_at) updatePriceUpdateDate(config.price_updated_at);
updatePriceUpdateDate(config.price_updated_at);
}
} }
} }
showToast(serverSyncSkipped ? 'Цены обновлены (без связи с сервером)' : 'Цены обновлены', 'success'); showToast('Цены обновлены', 'success');
showPriceDiffModal([{ configName: configName || 'Конфигурация', prevTotal: beforeTotal, newTotal: afterTotal, serverCount, itemDiffs }]);
} catch(e) { } catch(e) {
showToast('Ошибка обновления цен', 'error'); showToast('Ошибка обновления цен', 'error');
} finally { } finally {

View File

@@ -40,7 +40,7 @@
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"> <button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Конфигурация + Конфигурация
</button> </button>
<button id="refresh-all-prices-btn" onclick="refreshAllPrices()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"> <button id="refresh-all-prices-btn" onclick="refreshPrices()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
Обновить цены Обновить цены
</button> </button>
<button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium"> <button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
@@ -1572,10 +1572,10 @@ async function exportProject() {
} }
} }
async function refreshAllPrices() { async function refreshPrices() {
const configs = (allConfigs || []).filter(c => c.is_active !== false); const configs = (allConfigs || []).filter(c => c.is_active !== false);
if (!configs.length) { if (!configs.length) {
if (typeof showToast === 'function') showToast('Нет активных конфигураций', 'error'); showToast('Нет активных конфигураций', 'error');
return; return;
} }
@@ -1586,87 +1586,76 @@ async function refreshAllPrices() {
btn.className = 'py-2 bg-gray-300 text-gray-500 rounded-lg cursor-not-allowed font-medium'; btn.className = 'py-2 bg-gray-300 text-gray-500 rounded-lg cursor-not-allowed font-medium';
} }
let serverSyncSkipped = false;
try { try {
const statusResp = await fetch('/api/sync/status'); const latestEstimatePricelistId = await fetchLatestEstimatePricelistId();
const statusData = statusResp.ok ? await statusResp.json() : null;
if (statusData && statusData.is_online) {
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');
} else {
serverSyncSkipped = true;
}
} catch (syncErr) {
if (syncErr.message === 'component sync failed' || syncErr.message === 'pricelist sync failed') {
if (btn) {
btn.disabled = false;
btn.textContent = 'Обновить цены';
btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium';
}
if (typeof showToast === 'function') showToast('Ошибка синхронизации прайс-листов', 'error');
return;
}
serverSyncSkipped = true;
}
// Resolve latest estimate pricelist ID to pass explicitly, so each config const diffResults = [];
// is updated to the newest pricelist rather than the one stored in the config. for (const cfg of configs) {
let latestEstimatePricelistId = null; if (cfg.disable_price_refresh) {
try { diffResults.push({ configName: cfg.name, skipped: true });
const plResp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1'); continue;
if (plResp.ok) {
const plData = await plResp.json();
const list = plData.pricelists || plData.items || plData;
if (Array.isArray(list) && list.length > 0 && list[0].id) {
latestEstimatePricelistId = Number(list[0].id);
} }
}
} catch (_) {}
let failed = 0; const prevTotal = cfg.total_price || 0;
let newTotalSum = 0; const prevItemsMap = {};
for (const cfg of configs) { if (cfg.items) {
try { for (const item of cfg.items) prevItemsMap[item.lot_name] = item.unit_price;
const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined; }
const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
method: 'POST', try {
headers: body ? { 'Content-Type': 'application/json' } : {}, const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined;
body, const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
}); method: 'POST',
if (!resp.ok) { failed++; continue; } headers: body ? { 'Content-Type': 'application/json' } : {},
const updated = await resp.json(); body,
if (updated && updated.total_price != null) { });
cfg.total_price = updated.total_price; if (!resp.ok) {
const totalCell = document.querySelector('[data-total-uuid="' + cfg.uuid + '"]'); diffResults.push({ configName: cfg.name, error: true });
if (totalCell) totalCell.textContent = formatMoneyNoDecimals(updated.total_price); continue;
const serverCount = cfg.server_count || 1;
const unitPrice = serverCount > 0 ? (updated.total_price / serverCount) : 0;
const row = totalCell && totalCell.closest('tr');
if (row) {
const cells = row.querySelectorAll('td');
if (cells[4]) cells[4].textContent = formatMoneyNoDecimals(unitPrice);
} }
const updated = await resp.json();
cfg.total_price = updated.total_price ?? cfg.total_price;
if (updated.items) cfg.items = updated.items;
const totalCell = document.querySelector('[data-total-uuid="' + cfg.uuid + '"]');
if (totalCell && updated.total_price != null) {
totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
const sc = cfg.server_count || 1;
const row = totalCell.closest('tr');
if (row) {
const cells = row.querySelectorAll('td');
if (cells[4]) cells[4].textContent = formatMoneyNoDecimals(sc > 0 ? updated.total_price / sc : 0);
}
}
const itemDiffs = [];
if (updated.items) {
for (const item of updated.items) {
const prevPrice = prevItemsMap[item.lot_name];
if (prevPrice !== undefined && Math.abs(prevPrice - item.unit_price) > 0.01) {
itemDiffs.push({ lot_name: item.lot_name, quantity: item.quantity, prevPrice, newPrice: item.unit_price });
}
}
}
diffResults.push({ configName: cfg.name, prevTotal, newTotal: updated.total_price || 0, serverCount: cfg.server_count || 1, itemDiffs });
} catch(_) {
diffResults.push({ configName: cfg.name, error: true });
} }
newTotalSum += cfg.total_price || 0; }
} catch { failed++; }
}
const footerTotal = document.querySelector('[data-footer-total="1"]'); updateFooterTotal();
if (footerTotal) footerTotal.textContent = formatMoneyNoDecimals(newTotalSum); showToast('Цены обновлены', 'success');
showPriceDiffModal(diffResults);
if (btn) { } catch(e) {
btn.disabled = false; showToast('Ошибка обновления цен', 'error');
btn.textContent = 'Обновить цены'; } finally {
btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium'; if (btn) {
} btn.disabled = false;
btn.textContent = 'Обновить цены';
if (failed > 0) { btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium';
if (typeof showToast === 'function') showToast('Часть конфигураций не обновилась (' + failed + ')', 'error'); }
} else {
const msg = serverSyncSkipped ? 'Цены обновлены (без связи с сервером)' : 'Цены обновлены';
if (typeof showToast === 'function') showToast(msg, 'success');
} }
} }