From dccda8ebc9396b0859504f8a5252140b1b836220 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Tue, 7 Apr 2026 15:26:35 +0000 Subject: [PATCH] Fix: any-token product family grouping + auto timer reset on cache change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: autoAddCriticalItems used stale in-memory cache (old critical items) and re-added items to Bring right after manual removal, because on_bring was now false but urgency was still 'critical' in the old cache. PHP smartShopping(): - Rename stockByFirstToken → stockByAnyToken (indexes ALL significant tokens) - 'Passata di pomodoro' depleted + 'Polpa di pomodoro' in stock → share token 'pomodoro' → passata no longer flagged as critical (COVERS: passata/polpa/pelato and any future tomato product variant) - Same logic: 'aglio'/'aglio rosso', 'latte'/'latte di montagna', etc. JS loadSmartShopping(): - When critical item set changes (items added OR removed), immediately reset _autoAddedCriticalTs and _bringCleanupTs so next shopping load uses fresh data instead of debounced old data JS cleanupObsoleteBringItems(): - Use any-token matching (like PHP) for both stockByAnyToken and urgentSmartByToken → 'Passata di pomodoro' in Bring, 'polpa' in stock → share 'pomodoro' → removed --- api/index.php | 30 ++++++++++++++++----------- assets/js/app.js | 53 ++++++++++++++++++++++++++++++------------------ 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/api/index.php b/api/index.php index c9156a1..6a50f7e 100644 --- a/api/index.php +++ b/api/index.php @@ -2631,17 +2631,18 @@ function smartShopping(PDO $db): void { } } catch (Exception $e) { /* ignore */ } - // 4b. Build stockByFirstToken: first-significant-token → total in-stock qty. - // Used to skip depleted products that have an equivalent product in stock - // (e.g. "Aglio rosso" depleted but "Aglio" has 3 pz → don't re-add Aglio rosso to list) - $stockByFirstToken = []; + // 4b. Build stockByAnyToken: every significant token of in-stock products → total qty. + // Used to skip depleted products covered by any equivalent in-stock product. + // Any-token (not just first) groups product families: + // 'Passata di pomodoro' + 'Polpa di pomodoro' + 'Pelato Cirio' all share 'pomodoro' + // 'Aglio rosso' + 'Aglio' share 'aglio' + // 'Latte di Montagna' + 'Latte Parzialmente Scremato' share 'latte' + $stockByAnyToken = []; foreach ($products as $pStock) { $qty = isset($inventory[$pStock['id']]) ? (float)$inventory[$pStock['id']]['total_qty'] : 0; if ($qty <= 0) continue; - $toks = $nameTokens($pStock['name']); - if (!empty($toks)) { - $tok = $toks[0]; - $stockByFirstToken[$tok] = ($stockByFirstToken[$tok] ?? 0) + $qty; + foreach ($nameTokens($pStock['name']) as $tok) { + $stockByAnyToken[$tok] = ($stockByAnyToken[$tok] ?? 0) + $qty; } } @@ -2721,11 +2722,16 @@ function smartShopping(PDO $db): void { // Out of stock if ($qty <= 0) { - // If another product with the same first-token has stock, this depletion is covered - // (e.g. "Aglio rosso" depleted but "Aglio" has 3 pz → skip "Aglio rosso") + // If ANY significant token of this depleted product also appears in an in-stock product, + // the user's need is already covered — skip flagging it. + // Examples: 'Passata di pomodoro' depleted, 'Polpa di pomodoro' in stock → share 'pomodoro' → skip + // 'Aglio rosso' depleted, 'Aglio' in stock → share 'aglio' → skip $pToks = $nameTokens($p['name']); - $pFirst = $pToks[0] ?? null; - if ($pFirst && ($stockByFirstToken[$pFirst] ?? 0) > 0) continue; + $coveredByEquivalent = false; + foreach ($pToks as $tok) { + if (($stockByAnyToken[$tok] ?? 0) > 0) { $coveredByEquivalent = true; break; } + } + if ($coveredByEquivalent) continue; if ($isFrequent && $isRecent && $buyCount >= 2) { // Frequently used, recently active, AND bought multiple times → critical diff --git a/assets/js/app.js b/assets/js/app.js index 09f5ef9..6b0c5f8 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -5080,42 +5080,43 @@ async function cleanupObsoleteBringItems() { invItems = res.inventory || []; } catch (e) { return; } - // Build: first-significant-token → total in-stock qty across all products with that token - const stockByFirstToken = new Map(); + // Build: every significant token of in-stock products → total qty + // Any-token matching groups product families: + // 'Passata di pomodoro' + 'Polpa di pomodoro' share 'pomodoro' → same need + const stockByAnyToken = new Map(); for (const inv of invItems) { const qty = parseFloat(inv.quantity || 0); if (qty <= 0) continue; - const tok = _nameTokens(inv.name || '')[0]; - if (tok) stockByFirstToken.set(tok, (stockByFirstToken.get(tok) || 0) + qty); + for (const tok of _nameTokens(inv.name || '')) { + stockByAnyToken.set(tok, (stockByAnyToken.get(tok) || 0) + qty); + } } - // Build: first-significant-token → smart item (critical/high only) - // This tells us which tokens still urgently need buying + // Build: any matching token → smart item (critical/high only) const urgentSmartByToken = new Map(); for (const si of smartShoppingItems) { if (si.urgency !== 'critical' && si.urgency !== 'high') continue; - const tok = _nameTokens(si.name)[0]; - if (tok) urgentSmartByToken.set(tok, si); + for (const tok of _nameTokens(si.name)) { + if (!urgentSmartByToken.has(tok)) urgentSmartByToken.set(tok, si); + } } const toRemove = []; for (const item of shoppingItems) { - const itemFirst = _nameTokens(item.name)[0]; - const stockQty = (itemFirst && stockByFirstToken.get(itemFirst)) || 0; + // Check if any significant token of this Bring item has stock in inventory + const itemTokens = _nameTokens(item.name); + const stockQty = itemTokens.reduce((sum, tok) => sum + (stockByAnyToken.get(tok) || 0), 0); - // No stock of anything similar → nothing to remove + // No inventory stock for any related product → nothing to remove if (stockQty <= 0) continue; - // Check if smart shopping flags something with this token as urgently needed - const urgSi = itemFirst && urgentSmartByToken.get(itemFirst); + // Check if smart shopping flags something with a matching token as urgently needed + const urgSi = itemTokens.map(tok => urgentSmartByToken.get(tok)).find(Boolean); if (urgSi) { - // Smart shopping says this type is urgent. - // BUT: if the flagged product is COMPLETELY depleted (qty=0) yet we have - // a different in-stock product with the same root token (e.g. "Aglio rosso" - // depleted but "Aglio" has 3 pz), the user's need is already covered → remove. - // If the flagged product still has some qty but is running low → keep (genuine need). - if (urgSi.current_qty > 0) continue; // genuinely running low → keep in list - // depleted variant but equivalent in stock → fall through to remove + // Smart says something with this root token is urgent. + // If the flagged product still has qty > 0, it's genuinely running low → keep. + // If depleted (qty=0) but we have equivalent stock via another token → remove. + if (urgSi.current_qty > 0) continue; } toRemove.push(item); @@ -5307,8 +5308,20 @@ async function loadSmartShopping() { try { const data = await api('smart_shopping'); if (data.success && data.items && data.items.length > 0) { + const prevCriticalNames = new Set( + smartShoppingItems.filter(i => i.urgency === 'critical').map(i => i.name) + ); smartShoppingItems = data.items; _smartShoppingLastFetch = Date.now(); + // If the set of critical items changed, reset autoAdd/cleanup timers so + // they run with fresh data on next shopping page load + const newCriticalNames = new Set(data.items.filter(i => i.urgency === 'critical').map(i => i.name)); + const criticalChanged = [...prevCriticalNames].some(n => !newCriticalNames.has(n)) || + [...newCriticalNames].some(n => !prevCriticalNames.has(n)); + if (criticalChanged) { + localStorage.removeItem('_autoAddedCriticalTs'); + localStorage.removeItem('_bringCleanupTs'); + } renderSmartShopping(); _renderSmartLastUpdate(); _updateSmartUrgencyBadge();