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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user