Fix smart shopping: skip depleted products with equivalent in-stock substitutes

PHP smartShopping():
- Add nameTokens() helper (mirrors JS _nameTokens)
- Build stockByFirstToken map before product loop
- Skip depleted (qty=0) products whose first token has stock elsewhere
  → 'Aglio rosso' depleted but 'Aglio' qty=3 → skip
  → 'Latte Parzialmente Scremato' depleted but 'Latte di Montagna' 4.8 conf → skip
  → 'Muesli Frutta Secca' depleted but 'Muesli multifrutta' 930g → skip
- Result: 13→9 items, no more false critical flagging for covered products

JS cleanupObsoleteBringItems():
- Rewrite with stockByFirstToken approach (aggregate by first token, not product_id)
- urgentMatch logic: if smart item is completely depleted (qty=0) but equivalent
  stock exists via first token → still remove from Bring (need is covered)
- Only keep Bring item if: smart flags it with current_qty>0 (genuinely running low)

Also: removed Milch/Knoblauch/Fruechte/Passata from Bring directly (immediate fix)
This commit is contained in:
dadaloop82
2026-04-07 15:20:33 +00:00
parent 0bca79b8a2
commit dcc7e9de42
2 changed files with 59 additions and 38 deletions
+28
View File
@@ -2568,6 +2568,14 @@ function smartShopping(PDO $db): void {
$now = time();
$today = date('Y-m-d');
// Helper: extract significant tokens from a product name (mirrors JS _nameTokens)
$nameTokens = function(string $name): array {
$stop = ['di','del','della','dei','degli','delle','da','in','con','per','su',
'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo'];
$tokens = preg_split('/\s+/', strtolower(preg_replace('/[^\p{L}\s]/u', ' ', $name)));
return array_values(array_filter($tokens, fn($t) => strlen($t) > 2 && !in_array($t, $stop)));
};
// 1. Get all products with their inventory and transaction history
$products = $db->query("
SELECT p.id, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit
@@ -2623,6 +2631,20 @@ 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 = [];
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;
}
}
// 5. Analyze each product
$items = [];
foreach ($products as $p) {
@@ -2699,6 +2721,12 @@ 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")
$pToks = $nameTokens($p['name']);
$pFirst = $pToks[0] ?? null;
if ($pFirst && ($stockByFirstToken[$pFirst] ?? 0) > 0) continue;
if ($isFrequent && $isRecent && $buyCount >= 2) {
// Frequently used, recently active, AND bought multiple times → critical
$urgency = 'critical';
+31 -38
View File
@@ -5067,18 +5067,11 @@ async function autoAddCriticalItems() {
* Items not matching any DB product are left untouched (likely manually added by user).
*/
async function cleanupObsoleteBringItems() {
// Run at most once every 30 minutes (not once per session — user may restock mid-session)
// Run at most once every 30 minutes
const lastCleanup = parseInt(localStorage.getItem('_bringCleanupTs') || '0');
if (Date.now() - lastCleanup < 30 * 60 * 1000) return;
localStorage.setItem('_bringCleanupTs', String(Date.now()));
if (!shoppingItems.length) return;
// Build set of smart-flagged names (these should stay if still urgently needed)
const smartUrgent = new Set(
smartShoppingItems
.filter(i => i.urgency === 'critical' || i.urgency === 'high')
.map(i => i.name.toLowerCase())
);
if (!shoppingItems.length || !smartShoppingItems.length) return;
// Load live inventory (has actual quantities unlike products_list)
let invItems = [];
@@ -5087,44 +5080,44 @@ async function cleanupObsoleteBringItems() {
invItems = res.inventory || [];
} catch (e) { return; }
// Build map: lowercase product name → total inventory qty
const invQtyByName = {};
const invQtyById = {};
// Build: first-significant-token → total in-stock qty across all products with that token
const stockByFirstToken = new Map();
for (const inv of invItems) {
const key = (inv.name || '').toLowerCase();
invQtyByName[key] = (invQtyByName[key] || 0) + parseFloat(inv.quantity || 0);
invQtyById[inv.product_id] = (invQtyById[inv.product_id] || 0) + parseFloat(inv.quantity || 0);
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);
}
// Build: first-significant-token → smart item (critical/high only)
// This tells us which tokens still urgently need buying
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);
}
const toRemove = [];
for (const item of shoppingItems) {
const nameLower = item.name.toLowerCase();
const itemFirst = _nameTokens(item.name)[0];
const stockQty = (itemFirst && stockByFirstToken.get(itemFirst)) || 0;
// Keep if still urgent according to smart shopping
if (smartUrgent.has(nameLower)) continue;
const urgentMatch = smartShoppingItems.find(si =>
(si.urgency === 'critical' || si.urgency === 'high') &&
_nameTokens(si.name)[0] === _nameTokens(item.name)[0]
);
if (urgentMatch) continue;
// No stock of anything similar → nothing to remove
if (stockQty <= 0) continue;
// Check if we have stock in inventory (exact name match or first-token match)
let hasStock = false;
const exactQty = invQtyByName[nameLower] || 0;
if (exactQty > 0) {
hasStock = true;
} else {
// Fuzzy: find inventory item with matching first token
const itemFirst = _nameTokens(item.name)[0];
if (itemFirst) {
const match = invItems.find(inv => _nameTokens(inv.name || '')[0] === itemFirst);
if (match && invQtyById[match.product_id] > 0) hasStock = true;
}
// Check if smart shopping flags something with this token as urgently needed
const urgSi = itemFirst && urgentSmartByToken.get(itemFirst);
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
}
// Only remove Bring items for products we actually track (have inventory entry)
if (!hasStock) continue;
toRemove.push(item);
}