@@ -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 `
(${sign}${pct}%) `;
+ }
+
+ 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 `
+ ${lot}
+ ${qty}
+
+ ${_fmtMoneyDiff(prev)}
+ ${_fmtMoneyDiff(next)}
+
+
+ ${_fmtMoneyDiff(prevLine)}
+ ${_fmtMoneyDiff(nextLine)}
+
+ `;
+ }
+
+ 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 = '
Обновление цен отключено для всех конфигураций
';
+ document.getElementById('price-diff-modal').classList.remove('hidden');
+ return;
+ }
+
+ let html = '';
+ let anyChanges = false;
+
+ for (const r of sections) {
+ if (r.error) {
+ html += `
${r.configName ? `${r.configName}: ` : ''}Ошибка обновления цен
`;
+ 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 += `
${r.configName || '—'}
`;
+ }
+
+ if (diffs.length === 0) {
+ html += `
Изменений нет
`;
+ } else {
+ anyChanges = true;
+ html += `
+
+
+
+ Компонент
+ Кол.
+ Цена / шт.
+ Сумма
+
+
+ ${diffs.map(d => _buildDiffRow(d.lot_name, d.quantity, d.prevPrice, d.newPrice)).join('')}
+
+
`;
+ }
+
+ 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 += `
+ Итог конфигурации
+
+ ${_fmtMoneyDiff(r.prevTotal || 0)}
+ ${_fmtMoneyDiff(r.newTotal || 0)} ${totalArrow}
+
+
`;
+ }
+
+ if (!anyChanges && sections.every(r => !r.error)) {
+ html = '
Цены актуальны — изменений нет
' + 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
// This ensures username and admin link are visible ASAP
loadDBUser();
diff --git a/web/templates/index.html b/web/templates/index.html
index bb5a647..6f03bbf 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -2817,7 +2817,6 @@ async function exportCSVWithCustomPrice() {
}
async function refreshPrices() {
- // RBAC disabled - no token check required
if (!configUUID) return;
if (disablePriceRefresh) {
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';
}
- let serverSyncSkipped = false;
- 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()
- ]);
+ await loadActivePricelists(true);
['estimate', 'warehouse', 'competitor'].forEach(source => {
const latest = activePricelistsBySource[source]?.[0];
@@ -2871,22 +2847,44 @@ async function refreshPrices() {
renderPricelistSettingsSummary();
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 refreshPriceLevels({ force: true, noCache: true });
renderTab();
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) {
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);
- }
+ if (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) {
showToast('Ошибка обновления цен', 'error');
} finally {
diff --git a/web/templates/project_detail.html b/web/templates/project_detail.html
index 6cdb86c..6898383 100644
--- a/web/templates/project_detail.html
+++ b/web/templates/project_detail.html
@@ -40,7 +40,7 @@
+ Конфигурация
-
+
Обновить цены
@@ -1572,10 +1572,10 @@ async function exportProject() {
}
}
-async function refreshAllPrices() {
+async function refreshPrices() {
const configs = (allConfigs || []).filter(c => c.is_active !== false);
if (!configs.length) {
- if (typeof showToast === 'function') showToast('Нет активных конфигураций', 'error');
+ showToast('Нет активных конфигураций', 'error');
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';
}
- let serverSyncSkipped = false;
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') {
- 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;
- }
+ const latestEstimatePricelistId = await fetchLatestEstimatePricelistId();
- // Resolve latest estimate pricelist ID to pass explicitly, so each config
- // is updated to the newest pricelist rather than the one stored in the config.
- let latestEstimatePricelistId = null;
- try {
- const plResp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1');
- 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);
+ const diffResults = [];
+ for (const cfg of configs) {
+ if (cfg.disable_price_refresh) {
+ diffResults.push({ configName: cfg.name, skipped: true });
+ continue;
}
- }
- } catch (_) {}
- let failed = 0;
- let newTotalSum = 0;
- for (const cfg of configs) {
- try {
- const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined;
- const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
- method: 'POST',
- headers: body ? { 'Content-Type': 'application/json' } : {},
- body,
- });
- if (!resp.ok) { failed++; continue; }
- const updated = await resp.json();
- if (updated && updated.total_price != null) {
- cfg.total_price = updated.total_price;
- const totalCell = document.querySelector('[data-total-uuid="' + cfg.uuid + '"]');
- if (totalCell) totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
- 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 prevTotal = cfg.total_price || 0;
+ const prevItemsMap = {};
+ if (cfg.items) {
+ for (const item of cfg.items) prevItemsMap[item.lot_name] = item.unit_price;
+ }
+
+ try {
+ const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined;
+ const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
+ method: 'POST',
+ headers: body ? { 'Content-Type': 'application/json' } : {},
+ body,
+ });
+ if (!resp.ok) {
+ diffResults.push({ configName: cfg.name, error: true });
+ continue;
}
+ 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"]');
- if (footerTotal) footerTotal.textContent = formatMoneyNoDecimals(newTotalSum);
-
- 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 (failed > 0) {
- if (typeof showToast === 'function') showToast('Часть конфигураций не обновилась (' + failed + ')', 'error');
- } else {
- const msg = serverSyncSkipped ? 'Цены обновлены (без связи с сервером)' : 'Цены обновлены';
- if (typeof showToast === 'function') showToast(msg, 'success');
+ updateFooterTotal();
+ showToast('Цены обновлены', 'success');
+ showPriceDiffModal(diffResults);
+ } catch(e) {
+ showToast('Ошибка обновления цен', 'error');
+ } finally {
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = 'Обновить цены';
+ btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium';
+ }
}
}