fix: centralize price totals server-side; batch API call; 5-min total cache

This commit is contained in:
dadaloop82
2026-05-07 18:55:37 +00:00
parent a01ca583ea
commit 6c342a412b
3 changed files with 179 additions and 167 deletions
+87 -24
View File
@@ -6607,25 +6607,79 @@ function getShoppingPrice(PDO $db): void {
/**
* GET /api/?action=get_all_shopping_prices
* POST body: { items: [{name, quantity, unit, default_quantity, package_unit}], country, currency, lang, force_refresh }
* POST body: { items: [{name}], country, currency, lang, force_refresh }
* qty/unit are resolved SERVER-SIDE from smart_shopping_cache not trusted from client.
*
* Returns: { success, prices: { name priceEntry }, total, total_label }
* Returns: { success, prices: { name priceEntry }, total, total_label, from_total_cache }
*/
function getAllShoppingPrices(PDO $db): void {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$items = $input['items'] ?? [];
$clientItems = $input['items'] ?? [];
$country = trim($input['country'] ?? env('PRICE_COUNTRY', 'Italia'));
$currency = trim($input['currency'] ?? env('PRICE_CURRENCY', 'EUR'));
$lang = trim($input['lang'] ?? 'it');
$forceRefresh = !empty($input['force_refresh']);
$updateMonths = (int)env('PRICE_UPDATE_MONTHS', '3');
if (empty($items)) {
if (empty($clientItems)) {
echo json_encode(['success' => true, 'prices' => [], 'total' => 0, 'total_label' => _formatPrice(0, $currency)]);
return;
}
$cache = _loadPriceCache();
// ── Resolve qty/unit from server-side smart cache (source of truth) ──────
$smartItems = [];
$smartCacheFile = __DIR__ . '/../data/smart_shopping_cache.json';
if (file_exists($smartCacheFile)) {
$raw = file_get_contents($smartCacheFile);
if ($raw) {
$sc = json_decode($raw, true);
if ($sc && isset($sc['items'])) $smartItems = $sc['items'];
}
}
// Build lookup: lowercase name/shopping_name → smart item
$smartByName = [];
foreach ($smartItems as $si) {
$smartByName[mb_strtolower($si['name'] ?? '')] = $si;
if (!empty($si['shopping_name'])) {
$smartByName[mb_strtolower($si['shopping_name'])] = $si;
}
}
// Build canonical items array using server-side qty/unit
$items = [];
foreach ($clientItems as $ci) {
$name = trim($ci['name'] ?? '');
if ($name === '') continue;
$si = $smartByName[mb_strtolower($name)] ?? null;
$items[] = [
'name' => $name,
'quantity' => (float)(($si['suggested_qty'] ?? $si['buy_qty'] ?? null) ?? ($ci['quantity'] ?? 1)),
'unit' => trim(($si['suggested_unit'] ?? $si['unit'] ?? null) ?? ($ci['unit'] ?? 'conf')),
'default_quantity' => (float)(($si['default_qty'] ?? null) ?? ($ci['default_quantity'] ?? 0)),
'package_unit' => trim(($si['package_unit'] ?? null) ?? ($ci['package_unit'] ?? '')),
];
}
// ── 5-minute server-side total cache ──────────────────────────────────────
// Key = hash of item names + resolved qty/unit + country (not force_refresh)
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
$totalCacheKey = md5(json_encode(array_map(
fn($i) => [$i['name'], $i['quantity'], $i['unit']],
$items
)) . $country . $currency);
if (!$forceRefresh && file_exists($totalCachePath)) {
$tc = json_decode(file_get_contents($totalCachePath), true) ?? [];
if (isset($tc[$totalCacheKey]) && (time() - ($tc[$totalCacheKey]['ts'] ?? 0)) < 300) {
$cached = $tc[$totalCacheKey]['result'];
$cached['from_total_cache'] = true;
echo json_encode($cached, JSON_UNESCAPED_UNICODE);
return;
}
}
// ── Price computation ─────────────────────────────────────────────────────
$priceCache = _loadPriceCache();
$now = time();
$maxAge = $updateMonths * 30 * 86400;
$prices = [];
@@ -6634,18 +6688,17 @@ function getAllShoppingPrices(PDO $db): void {
// First pass: serve from cache
foreach ($items as $item) {
$name = trim($item['name'] ?? '');
$qty = (float)($item['quantity'] ?? 1);
$unit = trim($item['unit'] ?? 'pz');
$defQty = (float)($item['default_quantity'] ?? 0);
$pkgUnit = trim($item['package_unit'] ?? '');
if (empty($name)) continue;
$name = $item['name'];
$qty = $item['quantity'];
$unit = $item['unit'];
$defQty = $item['default_quantity'];
$pkgUnit = $item['package_unit'];
$key = _priceKey($name, $country);
if (!$forceRefresh && isset($cache[$key])) {
$age = $now - ($cache[$key]['cached_at'] ?? 0);
if (!$forceRefresh && isset($priceCache[$key])) {
$age = $now - ($priceCache[$key]['cached_at'] ?? 0);
if ($age < $maxAge) {
$entry = $cache[$key];
$entry = $priceCache[$key];
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $qty, $unit, $defQty, $pkgUnit);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
@@ -6661,11 +6714,11 @@ function getAllShoppingPrices(PDO $db): void {
// Second pass: fetch missing from AI (sequential to avoid rate limits)
foreach ($missing as $item) {
$name = trim($item['name'] ?? '');
$qty = (float)($item['quantity'] ?? 1);
$unit = trim($item['unit'] ?? 'pz');
$defQty = (float)($item['default_quantity'] ?? 0);
$pkgUnit = trim($item['package_unit'] ?? '');
$name = $item['name'];
$qty = $item['quantity'];
$unit = $item['unit'];
$defQty = $item['default_quantity'];
$pkgUnit = $item['package_unit'];
$key = _priceKey($name, $country);
$priceData = _fetchPriceFromAI($name, $country, $currency, $lang);
@@ -6673,13 +6726,13 @@ function getAllShoppingPrices(PDO $db): void {
$entry = [
'name' => $name,
'price_per_unit' => (float)$priceData['price_per_unit'],
'unit_label' => $priceData['unit_label'] ?? 'kg',
'unit_label' => $priceData['unit_label'] ?? 'pz',
'currency' => $currency,
'source_note' => $priceData['source_note'] ?? '',
'country' => $country,
'cached_at' => $now,
];
$cache[$key] = $entry;
$priceCache[$key] = $entry;
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'], $qty, $unit, $defQty, $pkgUnit);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
@@ -6692,15 +6745,25 @@ function getAllShoppingPrices(PDO $db): void {
}
}
_savePriceCache($cache);
_savePriceCache($priceCache);
$total = round($total, 2);
echo json_encode([
$result = [
'success' => true,
'prices' => $prices,
'total' => $total,
'total_label' => _formatPrice($total, $currency),
]);
'from_total_cache' => false,
];
// Persist to total cache
$tc = file_exists($totalCachePath) ? (json_decode(file_get_contents($totalCachePath), true) ?? []) : [];
// Keep cache small: max 10 keys (different list configurations)
if (count($tc) >= 10) $tc = array_slice($tc, -9, null, true);
$tc[$totalCacheKey] = ['ts' => $now, 'result' => $result];
file_put_contents($totalCachePath, json_encode($tc, JSON_UNESCAPED_UNICODE));
echo json_encode($result, JSON_UNESCAPED_UNICODE);
}
/**
+52 -103
View File
@@ -8874,7 +8874,7 @@ async function fetchAllPrices(forceRefresh = false) {
if (fetchBtn) fetchBtn.disabled = true;
if (refreshBtn) { refreshBtn.disabled = true; refreshBtn.textContent = '⏳'; }
if (_pricesFetching) return; // already running — buttons will be re-enabled by active fetch
if (_pricesFetching) return;
if (!shoppingItems.length) {
if (fetchBtn) fetchBtn.disabled = false;
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
@@ -8897,112 +8897,73 @@ async function fetchAllPrices(forceRefresh = false) {
if (priceBar) priceBar.style.display = 'block';
const sym = _currencySymbol(s.price_currency || 'EUR');
if (forceRefresh) {
// Full refresh: clear in-memory + sessionStorage cache, reset all badges to loading
_cachedPrices = {};
try { sessionStorage.removeItem('_pricecache'); } catch { /* ignore */ }
try { sessionStorage.removeItem('_pricecache'); sessionStorage.removeItem('_pricetotal'); } catch { /* ignore */ }
shoppingItems.forEach((_, idx) => {
const badge = document.getElementById(`price-badge-${idx}`);
if (badge) badge.innerHTML = `<span class="price-col-loading">…</span>`;
});
if (totalEl) totalEl.textContent = t('shopping.price_loading');
if (loadingBar) loadingBar.style.display = 'block';
if (loadingInner) { loadingInner.style.transition = 'none'; loadingInner.style.width = '0%'; }
if (loadingInner) { loadingInner.style.transition = 'none'; loadingInner.style.width = '5%'; }
} else {
// Incremental: apply cached prices instantly, mark uncached as loading
const { total: cachedTotal, count: cachedCount } = _applyPriceBadgesFromCache();
shoppingItems.forEach((item, idx) => {
if (!_cachedPrices[item.name]) {
const badge = document.getElementById(`price-badge-${idx}`);
if (badge) badge.innerHTML = `<span class="price-col-loading">…</span>`;
}
});
const uncachedCount = shoppingItems.filter(i => !_cachedPrices[i.name]).length;
if (uncachedCount === 0) {
// All already cached — just show total and done
if (totalEl && cachedCount > 0) totalEl.textContent = `ca. ${_currencySymbol(s.price_currency || 'EUR')}${cachedTotal.toFixed(2)}`;
_pricesFetching = false;
if (fetchBtn) fetchBtn.disabled = false;
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
return;
}
if (totalEl && cachedCount > 0) totalEl.textContent = `ca. ${_currencySymbol(s.price_currency || 'EUR')}${cachedTotal.toFixed(2)}`;
// Show cached prices instantly while the server call is in flight
_applyPriceBadgesFromCache();
if (loadingBar) loadingBar.style.display = 'block';
if (loadingInner) { loadingInner.style.transition = 'none'; loadingInner.style.width = '0%'; }
if (loadingInner) { loadingInner.style.transition = 'none'; loadingInner.style.width = '5%'; }
}
const lang = s.language || 'it';
const country = s.price_country || 'Italia';
const currency = s.price_currency || 'EUR';
const sym = _currencySymbol(currency);
const items = _buildPricePayload();
const total = items.length;
// Running totals: only count items in the CURRENT shopping list with matching qty
let runningTotal = shoppingItems.reduce((sum, item) => {
const e = _cachedPrices[item.name];
const pi = items.find(x => x.name === item.name);
if (!e || !pi || e._qty !== pi.quantity || e._unit !== pi.unit) return sum;
return sum + (e?.estimated_total || 0);
}, 0);
let pricesFound = shoppingItems.filter(i => {
const e = _cachedPrices[i.name];
const pi = items.find(x => x.name === i.name);
return e && pi && e._qty === pi.quantity && e._unit === pi.unit && e.estimated_total != null;
}).length;
let processed = 0;
// Send only item names — server resolves qty/unit from smart_shopping_cache
const itemsPayload = shoppingItems.map(i => ({ name: i.name }));
let serverTotal = null;
try {
for (let i = 0; i < items.length; i++) {
if (!_pricesFetching) break; // guard: list was reloaded mid-fetch
const item = items[i];
const badge = document.getElementById(`price-badge-${i}`);
// Skip if already cached with same qty/unit (and not forceRefresh)
const cached = _cachedPrices[item.name];
if (!forceRefresh && cached && cached._qty === item.quantity && cached._unit === item.unit) {
processed++;
const progress = Math.round((processed / total) * 100);
if (loadingInner) { loadingInner.style.transition = 'width 0.3s ease'; loadingInner.style.width = `${progress}%`; }
continue;
}
try {
const data = await api('get_shopping_price', {}, 'POST', {
...item, country, currency, lang, force_refresh: forceRefresh,
const data = await api('get_all_shopping_prices', {}, 'POST', {
items: itemsPayload,
country, currency, lang,
force_refresh: forceRefresh,
});
if (data && data.success) {
_cachedPrices[item.name] = { ...data, _qty: item.quantity, _unit: item.unit };
if (badge) badge.innerHTML = _buildPriceBadgeHTML(data, sym);
if (data.estimated_total != null) {
runningTotal += data.estimated_total;
pricesFound++;
const prices = data.prices || {};
// Apply each item's result to badges and update in-memory cache
shoppingItems.forEach((item, idx) => {
const entry = prices[item.name];
const badge = document.getElementById(`price-badge-${idx}`);
if (entry && !entry.error && entry.estimated_total != null) {
// Store in client cache keyed by whatever qty the server used
_cachedPrices[item.name] = {
...entry,
_qty: entry._qty ?? entry.quantity ?? 1,
_unit: entry._unit ?? entry.unit ?? 'conf',
};
if (badge) badge.innerHTML = _buildPriceBadgeHTML(entry, sym);
} else if (badge) {
badge.innerHTML = `<span class="price-col-error"></span>`;
}
});
// Server is the source of truth for the total
serverTotal = data.total ?? null;
if (serverTotal != null && totalEl) {
totalEl.textContent = `ca. ${sym}${Number(serverTotal).toFixed(2)}`;
}
// Update dashboard stat card in real-time as each price arrives
_updateDashboardPriceTotal();
} else {
if (badge) badge.innerHTML = `<span class="price-col-error"></span>`;
}
} catch (_err) {
if (badge) badge.innerHTML = `<span class="price-col-error"></span>`;
}
processed++;
const progress = Math.round((processed / total) * 100);
if (loadingInner) { loadingInner.style.transition = 'width 0.3s ease'; loadingInner.style.width = `${progress}%`; }
if (totalEl) {
totalEl.textContent = pricesFound > 0
? `ca. ${sym}${runningTotal.toFixed(2)}`
: t('shopping.price_loading');
}
}
// On network error fall back to whatever we have in cache
const { total: ct, count: cc } = _applyPriceBadgesFromCache();
if (cc > 0 && totalEl) totalEl.textContent = `ca. ${sym}${ct.toFixed(2)}`;
} finally {
_pricesFetching = false;
// Persist to sessionStorage so prices survive page navigation
try { sessionStorage.setItem('_pricecache', JSON.stringify(_cachedPrices)); } catch { /* quota */ }
if (loadingBar) loadingBar.style.display = 'none';
if (totalEl) totalEl.textContent = pricesFound > 0 ? `ca. ${sym}${runningTotal.toFixed(2)}` : '';
if (loadingBar) { if (loadingInner) loadingInner.style.width = '100%'; setTimeout(() => { loadingBar.style.display = 'none'; }, 300); }
if (fetchBtn) fetchBtn.disabled = false;
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
_updateDashboardPriceTotal();
@@ -9239,17 +9200,12 @@ function _updateDashboardPriceTotal() {
const s = getSettings();
if (!s.price_enabled) { el.style.display = 'none'; return; }
// If shoppingItems are loaded, compute fresh total and persist it
if (shoppingItems.length > 0) {
// Compute total from cached prices (server is source of truth — entries set by batch call)
const sym = _currencySymbol(s.price_currency || 'EUR');
const items = _buildPricePayload();
let total = 0, count = 0;
for (const item of items) {
for (const item of shoppingItems) {
const e = _cachedPrices[item.name];
if (e && e._qty === item.quantity && e._unit === item.unit && e.estimated_total != null) {
total += e.estimated_total;
count++;
}
if (e && e.estimated_total != null) { total += e.estimated_total; count++; }
}
if (count > 0) {
const text = `ca. ${sym}${total.toFixed(2)}`;
@@ -9258,16 +9214,11 @@ function _updateDashboardPriceTotal() {
try { sessionStorage.setItem('_pricetotal', text); } catch { /* quota */ }
return;
}
}
// Fallback: restore last known total saved in sessionStorage (dashboard before visiting shopping tab)
// Fallback: restore last known total from sessionStorage
const saved = sessionStorage.getItem('_pricetotal');
if (saved) {
el.textContent = saved;
el.style.display = '';
} else {
el.style.display = 'none';
}
if (saved) { el.textContent = saved; el.style.display = ''; }
else el.style.display = 'none';
}
/**
@@ -9995,11 +9946,10 @@ async function renderShoppingItems() {
document.getElementById('btn-fetch-prices').style.display = 'inline-flex';
// Allow a new fetch (re-render may have happened while old fetch was running)
_pricesFetching = false;
// Check if ALL items are already cached with matching qty/unit
const _pItems = _buildPricePayload();
const _allCached = _pItems.length > 0 && _pItems.every(item => {
// Check if ALL items are already cached (server already returned prices for them)
const _allCached = shoppingItems.length > 0 && shoppingItems.every(item => {
const e = _cachedPrices[item.name];
return e && e._qty === item.quantity && e._unit === item.unit && e.estimated_total != null;
return e && e.estimated_total != null;
});
if (_allCached) {
// Prices are fully fresh — apply instantly, no loading state
@@ -10018,9 +9968,8 @@ async function renderShoppingItems() {
// once we have real qty/unit data. Just apply whatever is cached silently.
_applyPriceBadgesFromCache();
} else {
// Immediately apply any prices already fetched this session — no flicker for cached items
// Apply any cached prices instantly while batch fetch runs
_applyPriceBadgesFromCache();
// Fetch only items not yet priced (or stale)
fetchAllPrices(false);
}
} else {
+1 -1
View File
@@ -1461,6 +1461,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260507g"></script>
<script src="assets/js/app.js?v=20260507h"></script>
</body>
</html>