feat: lista spesa con tab Da comprare/In previsione, tag, frequenza, tap-to-scan

- Counter nei tab aggiornati dinamicamente
- Auto-aggiunta prodotti CRITICI a Bring! al caricamento (1x per sessione)
- Badge urgenza e frequenza sugli item in lista (cross-ref smart shopping)
- Tag locali per item (Urgente/Priorità/Verificare) con menu dropdown
- Ordinamento automatico per frequenza utilizzo (item più usati in cima)
- Tap su un item → scanner barcode, con banner 'Trovato! Rimuovi dalla lista'
- Fix pctLeft: usa max(1, qty) come fallback refQty per evitare falsi alert
- Fix daysLeft capped a 365gg per pulire stringhe di previsione
- Back button on action page → torna a shopping se aperto da lista
This commit is contained in:
dadaloop82
2026-03-29 14:12:37 +00:00
parent 5b11ab6493
commit a38a5d670f
6 changed files with 1571 additions and 51 deletions
+237
View File
@@ -147,4 +147,241 @@ function migrateDB(PDO $db): void {
if (!in_array('vacuum_sealed', $invColNames)) {
$db->exec("ALTER TABLE inventory ADD COLUMN vacuum_sealed INTEGER DEFAULT 0");
}
// Add opened_at column to inventory if missing
if (!in_array('opened_at', $invColNames)) {
$db->exec("ALTER TABLE inventory ADD COLUMN opened_at DATETIME DEFAULT NULL");
// Backfill: detect already-opened items and set opened_at + recalculate expiry
backfillOpenedItems($db);
}
// Migration v2: recalculate sealed fridge item expiry (fridge extends shelf life)
$migrated = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fridge_expiry_v1'")->fetchColumn();
if (!$migrated) {
recalcSealedFridgeExpiry($db);
$db->exec("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('migration_fridge_expiry_v1', '1')");
}
}
/**
* Backfill opened_at for existing inventory items that appear to be opened.
* An item is considered opened if:
* - conf unit with fractional quantity
* - weight/volume unit (g,kg,ml,l) with quantity < default_quantity
* Uses updated_at as the approximate opened_at date.
* Recalculates expiry_date based on opened shelf life from opened_at.
*/
function backfillOpenedItems(PDO $db): void {
$stmt = $db->query("
SELECT i.id, i.quantity, i.location, i.updated_at, i.expiry_date, i.vacuum_sealed,
p.name, p.category, p.unit, p.default_quantity
FROM inventory i
JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0
");
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
$isOpened = false;
$unit = $row['unit'] ?: 'pz';
$qty = (float)$row['quantity'];
$defQty = (float)($row['default_quantity'] ?: 0);
if ($unit === 'conf') {
$frac = $qty - floor($qty + 0.001);
if ($frac > 0.001) $isOpened = true;
} elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0 && $qty < $defQty - 0.001) {
$isOpened = true;
}
if (!$isOpened) continue;
$openedAt = $row['updated_at'];
$openedDays = estimateOpenedExpiryDaysPHP($row['name'], $row['category'], $row['location']);
if ($row['vacuum_sealed']) $openedDays = (int)round($openedDays * 1.5);
// Calculate new expiry from opened_at
$newExpiry = date('Y-m-d', strtotime($openedAt . " +{$openedDays} days"));
$upd = $db->prepare("UPDATE inventory SET opened_at = ?, expiry_date = ? WHERE id = ?");
$upd->execute([$openedAt, $newExpiry, $row['id']]);
}
}
/**
* Estimate shelf life in days for an opened product.
* Much shorter than sealed shelf life.
*/
function estimateOpenedExpiryDaysPHP(string $name, string $category, string $location): int {
$n = mb_strtolower($name);
$cat = mb_strtolower($category);
$loc = mb_strtolower($location);
// Freezer: opened items still last a long time
if ($loc === 'freezer') return 90;
// Dispensa: opened dry goods
if ($loc === 'dispensa') return 30;
// Specific product overrides (fridge)
if (preg_match('/latte\s+(fresco|intero|parzial|scremato)/', $n)) return 3;
if (preg_match('/latte\s+uht|latte\s+a\s+lunga/', $n)) return 5;
if (preg_match('/latte/', $n)) return 4;
if (preg_match('/yogurt/', $n)) return 3;
if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 2;
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
if (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) return 21;
if (preg_match('/formaggio/', $n)) return 10;
if (preg_match('/burro/', $n)) return 21;
if (preg_match('/panna/', $n)) return 3;
if (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) return 3;
if (preg_match('/prosciutto\s+crudo|salame|bresaola|speck|pancetta|nduja/', $n)) return 7;
if (preg_match('/pollo|tacchino|maiale|manzo|vitello/', $n)) return 2;
if (preg_match('/salmone|tonno\s+fresco|pesce/', $n)) return 2;
if (preg_match('/passata|pelati|polpa|sugo/', $n)) return 5;
if (preg_match('/marmellata|confettura/', $n)) return 30;
if (preg_match('/miele/', $n)) return 180;
if (preg_match('/nutella/', $n)) return 60;
if (preg_match('/succo|spremuta/', $n)) return 4;
if (preg_match('/olio|aceto/', $n)) return 90;
if (preg_match('/vino|birra/', $n)) return 5;
if (preg_match('/limone|limmi/', $n)) return 21;
if (preg_match('/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/', $n)) return 3;
if (preg_match('/insalata|rucola|spinaci/', $n)) return 3;
// Category fallbacks
if (preg_match('/dairy|latticin|lait|dairies/', $cat)) return 5;
if (preg_match('/meat|carne|meats/', $cat)) return 3;
if (preg_match('/fish|pesce/', $cat)) return 2;
if (preg_match('/fruit|frutta/', $cat)) return 5;
if (preg_match('/verdur|vegetable|plant-based/', $cat)) return 5;
if (preg_match('/conserve/', $cat)) return 5;
if (preg_match('/condimenti|sauce/', $cat)) return 21;
if (preg_match('/bevand|beverage/', $cat)) return 4;
return 5; // safe default for fridge
}
/**
* Estimate sealed shelf life in days, with fridge/freezer extensions.
* Mirrors the JS estimateExpiryDays() function.
*/
function estimateSealedExpiryDaysPHP(string $name, string $category, string $location): int {
$n = mb_strtolower($name);
$cat = mb_strtolower($category);
$loc = mb_strtolower($location);
$days = null;
// Specific product overrides
if (preg_match('/latte\s+(fresco|intero|parzial|scremato)/', $n)) $days = 7;
elseif (preg_match('/latte\s+uht|latte\s+a\s+lunga/', $n)) $days = 90;
elseif (preg_match('/yogurt/', $n)) $days = 21;
elseif (preg_match('/mozzarella|burrata|stracciatella/', $n)) $days = 5;
elseif (preg_match('/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) $days = 10;
elseif (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) $days = 60;
elseif (preg_match('/burro/', $n)) $days = 60;
elseif (preg_match('/panna/', $n)) $days = 14;
elseif (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) $days = 7;
elseif (preg_match('/prosciutto\s+crudo|salame|bresaola|speck/', $n)) $days = 30;
elseif (preg_match('/nduja/', $n)) $days = 90;
elseif (preg_match('/uova/', $n)) $days = 28;
elseif (preg_match('/pane\s+fresco|pane\s+in\s+cassetta/', $n)) $days = 5;
elseif (preg_match('/pane\s+confezionato|pan\s+carr|pancarrè/', $n)) $days = 14;
elseif (preg_match('/insalata|rucola|spinaci\s+freschi/', $n)) $days = 5;
elseif (preg_match('/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/', $n)) $days = 3;
elseif (preg_match('/salmone|tonno\s+fresco|pesce/', $n) && !preg_match('/tonno\s+in\s+scatola|tonno\s+rio/', $n)) $days = 2;
elseif (preg_match('/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/', $n)) $days = 1095;
elseif (preg_match('/surgelat|frozen|findus|4\s*salti/', $n)) $days = 180;
elseif (preg_match('/gelato/', $n)) $days = 365;
elseif (preg_match('/succo|spremuta/', $n)) $days = 7;
elseif (preg_match('/birra|vino/', $n)) $days = 365;
elseif (preg_match('/acqua/', $n)) $days = 365;
elseif (preg_match('/mela|mele\b/', $n)) $days = 7;
elseif (preg_match('/arancia|arance|mandarini|agrumi/', $n)) $days = 7;
elseif (preg_match('/banana|banane/', $n)) $days = 5;
elseif (preg_match('/pera|pere\b|fragola|fragole|uva|kiwi/', $n)) $days = 5;
elseif (preg_match('/carota|carote|zucchina|zucchine|peperoni|melanzane/', $n)) $days = 7;
elseif (preg_match('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5;
elseif (preg_match('/cipolla|cipolle/', $n)) $days = 10;
elseif (preg_match('/patata|patate/', $n)) $days = 14;
elseif (preg_match('/biscott|cracker|grissini|fette\s+biscott/', $n)) $days = 180;
elseif (preg_match('/nutella|marmellata|miele/', $n)) $days = 365;
elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730;
elseif (preg_match('/olio|aceto/', $n)) $days = 548;
if ($days === null) {
// Category fallbacks
$catMap = [
'latticini' => 7, 'carne' => 4, 'pesce' => 3, 'frutta' => 7, 'verdura' => 7,
'pasta' => 730, 'pane' => 4, 'surgelati' => 180, 'bevande' => 365, 'condimenti' => 365,
'snack' => 180, 'conserve' => 730, 'cereali' => 365, 'igiene' => 1095, 'pulizia' => 1095,
];
$days = 180;
foreach ($catMap as $key => $d) {
if (strpos($cat, $key) !== false) { $days = $d; break; }
}
}
// Fridge extends shelf life for produce and short-lived items
if ($loc === 'frigo') {
if (preg_match('/mela|mele/', $n)) $days = max($days, 28);
elseif (preg_match('/arancia|arance|agrumi|mandarini|limone|limoni/', $n)) $days = max($days, 21);
elseif (preg_match('/carota|carote/', $n)) $days = max($days, 21);
elseif (preg_match('/cipolla/', $n)) $days = max($days, 14);
elseif (preg_match('/patata|patate/', $n)) $days = max($days, 21);
elseif (preg_match('/pera|pere/', $n)) $days = max($days, 21);
elseif (preg_match('/kiwi/', $n)) $days = max($days, 28);
elseif (preg_match('/uva/', $n)) $days = max($days, 14);
elseif (preg_match('/fragola|fragole/', $n)) $days = max($days, 7);
elseif (preg_match('/peperoni/', $n)) $days = max($days, 14);
elseif (preg_match('/zucchina|zucchine/', $n)) $days = max($days, 14);
elseif (preg_match('/melanzane/', $n)) $days = max($days, 14);
elseif (preg_match('/broccoli|cavolfiore|cavolo/', $n)) $days = max($days, 10);
elseif ($days <= 7 && preg_match('/frutta|fruit|verdur|vegetable|plant-based/', $cat)) {
$days = (int)round($days * 2);
}
}
// Freezer extends shelf life significantly
if ($loc === 'freezer' && $days < 180) {
if ($days <= 4) $days = 120;
elseif ($days <= 14) $days = 75;
elseif ($days <= 30) $days = 120;
else $days = max($days, 180);
}
return $days;
}
/**
* Recalculate expiry for sealed (non-opened) fridge items with new fridge-aware logic.
*/
function recalcSealedFridgeExpiry(PDO $db): void {
$stmt = $db->query("
SELECT i.id, i.added_at, i.vacuum_sealed, i.opened_at, i.expiry_date,
p.name, p.category
FROM inventory i
JOIN products p ON i.product_id = p.id
WHERE i.location = 'frigo' AND i.opened_at IS NULL AND i.quantity > 0
");
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
$days = estimateSealedExpiryDaysPHP($row['name'], $row['category'], 'frigo');
if ($row['vacuum_sealed']) $days = getVacuumExpiryDaysPHP($days);
$newExpiry = date('Y-m-d', strtotime($row['added_at'] . " +{$days} days"));
// Only extend expiry, never shorten it
if ($row['expiry_date'] && $newExpiry <= $row['expiry_date']) continue;
$upd = $db->prepare("UPDATE inventory SET expiry_date = ? WHERE id = ?");
$upd->execute([$newExpiry, $row['id']]);
}
}
function getVacuumExpiryDaysPHP(int $baseDays): int {
if ($baseDays <= 7) return (int)round($baseDays * 3);
if ($baseDays <= 14) return (int)round($baseDays * 3);
if ($baseDays <= 30) return (int)round($baseDays * 2.5);
if ($baseDays <= 90) return (int)round($baseDays * 2.5);
return (int)round($baseDays * 1.5);
}
+257 -7
View File
@@ -115,6 +115,9 @@ try {
case 'bring_suggest':
bringSuggestItems($db);
break;
case 'smart_shopping':
smartShopping($db);
break;
case 'save_settings':
saveSettings();
@@ -468,7 +471,7 @@ function listInventory(PDO $db): void {
$location = $_GET['location'] ?? '';
$query = "
SELECT i.*, p.name, p.brand, p.category, p.image_url, p.unit, p.barcode, p.default_quantity, p.package_unit,
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, i.opened_at
FROM inventory i
JOIN products p ON i.product_id = p.id
";
@@ -624,7 +627,7 @@ function useFromInventory(PDO $db): void {
return;
}
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0 ORDER BY (quantity != CAST(CAST(quantity AS INTEGER) AS REAL)) DESC, quantity ASC");
$stmt = $db->prepare("SELECT id, quantity, opened_at, vacuum_sealed FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0 ORDER BY (quantity != CAST(CAST(quantity AS INTEGER) AS REAL)) DESC, quantity ASC");
$stmt->execute([$productId, $location]);
$existing = $stmt->fetch();
@@ -640,7 +643,7 @@ function useFromInventory(PDO $db): void {
// Auto-split conf products: separate whole confs from opened (fractional) part
$openedId = null;
$stmt2 = $db->prepare("SELECT unit, default_quantity, package_unit FROM products WHERE id = ?");
$stmt2 = $db->prepare("SELECT name, category, unit, default_quantity, package_unit FROM products WHERE id = ?");
$stmt2->execute([$productId]);
$prodInfo = $stmt2->fetch();
@@ -662,8 +665,13 @@ function useFromInventory(PDO $db): void {
$newFraction = round($fraction - $quantity, 6);
if ($newFraction > 0.001) {
$stmt3 = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)");
$stmt3->execute([$productId, $location, $newFraction, $origRow['expiry_date'], $origRow['vacuum_sealed'] ?? 0]);
// Opened item: calculate shorter shelf life from now
$vacuum = (int)($origRow['vacuum_sealed'] ?? 0);
$openedDays = estimateOpenedExpiryDaysPHP($prodInfo['name'] ?? '', $prodInfo['category'] ?? '', $location);
if ($vacuum) $openedDays = (int)round($openedDays * 1.5);
$openedExpiry = date('Y-m-d', strtotime("+{$openedDays} days"));
$stmt3 = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)");
$stmt3->execute([$productId, $location, $newFraction, $openedExpiry, $vacuum]);
$openedId = (int)$db->lastInsertId();
}
@@ -684,8 +692,33 @@ function useFromInventory(PDO $db): void {
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?");
$stmt->execute([$existing['id']]);
} else {
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
$stmt->execute([$newQty, $existing['id']]);
// Check if item is now opened (first use reduces quantity)
$wasOpened = !empty($existing['opened_at']);
$isNowOpened = false;
$unit = $prodInfo['unit'] ?? 'pz';
$defQty = (float)($prodInfo['default_quantity'] ?? 0);
if ($unit === 'conf') {
$w = floor($newQty + 0.001);
$f = round($newQty - $w, 6);
if ($f > 0.001) $isNowOpened = true;
} elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0 && $newQty < $defQty - 0.001) {
$isNowOpened = true;
}
if ($isNowOpened && !$wasOpened) {
// First time opened: recalculate expiry with shorter shelf life
$pName = $prodInfo['name'] ?? '';
$pCat = $prodInfo['category'] ?? '';
$vacuum = (int)($existing['vacuum_sealed'] ?? 0);
$openedDays = estimateOpenedExpiryDaysPHP($pName, $pCat, $location);
if ($vacuum) $openedDays = (int)round($openedDays * 1.5);
$openedExpiry = date('Y-m-d', strtotime("+{$openedDays} days"));
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
$stmt->execute([$newQty, $openedExpiry, $existing['id']]);
} else {
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
$stmt->execute([$newQty, $existing['id']]);
}
}
// Log transaction
@@ -2160,6 +2193,223 @@ function bringCleanSpecs(): void {
echo json_encode(['success' => true, 'cleaned' => $cleaned]);
}
/**
* Smart Shopping List: analyzes usage frequency, stock levels, expiry to produce
* intelligent urgency-ranked shopping recommendations.
*/
function smartShopping(PDO $db): void {
$now = time();
$today = date('Y-m-d');
// 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
FROM products p
ORDER BY p.name
")->fetchAll();
// 2. Get all inventory grouped by product
$invStmt = $db->query("
SELECT i.product_id, SUM(i.quantity) as total_qty,
MIN(i.expiry_date) as nearest_expiry,
GROUP_CONCAT(DISTINCT i.location) as locations,
MAX(i.opened_at) as opened_at
FROM inventory i
WHERE i.quantity > 0
GROUP BY i.product_id
");
$inventory = [];
foreach ($invStmt->fetchAll() as $inv) {
$inventory[$inv['product_id']] = $inv;
}
// 3. Get transaction stats per product
$txStmt = $db->query("
SELECT product_id,
COUNT(CASE WHEN type IN ('out','waste') THEN 1 END) as use_count,
SUM(CASE WHEN type IN ('out','waste') THEN quantity ELSE 0 END) as total_used,
COUNT(CASE WHEN type = 'in' THEN 1 END) as buy_count,
SUM(CASE WHEN type = 'in' THEN quantity ELSE 0 END) as total_bought,
MIN(CASE WHEN type = 'in' THEN created_at END) as first_in,
MAX(CASE WHEN type = 'in' THEN created_at END) as last_in,
MAX(CASE WHEN type IN ('out','waste') THEN created_at END) as last_out
FROM transactions
GROUP BY product_id
");
$txData = [];
foreach ($txStmt->fetchAll() as $tx) {
$txData[$tx['product_id']] = $tx;
}
// 4. Fetch current Bring! list to know what's already there
$bringItems = [];
try {
$auth = bringAuth();
if ($auth) {
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}");
if ($listData && isset($listData['purchase'])) {
foreach ($listData['purchase'] as $bi) {
$bringItems[mb_strtolower(bringToItalian($bi['name'] ?? ''))] = true;
$bringItems[mb_strtolower($bi['name'] ?? '')] = true;
}
}
}
} catch (Exception $e) { /* ignore */ }
// 5. Analyze each product
$items = [];
foreach ($products as $p) {
$pid = $p['id'];
$inv = $inventory[$pid] ?? null;
$tx = $txData[$pid] ?? null;
// Skip products never bought/used and not in inventory
if (!$tx && !$inv) continue;
$qty = $inv ? (float)$inv['total_qty'] : 0;
$unit = $p['unit'] ?: 'pz';
$defQty = (float)($p['default_quantity'] ?: 0);
$isOpened = $inv && !empty($inv['opened_at']);
// --- Usage frequency ---
$useCount = $tx ? (int)$tx['use_count'] : 0;
$buyCount = $tx ? (int)$tx['buy_count'] : 0;
$totalUsed = $tx ? (float)$tx['total_used'] : 0;
$totalBought = $tx ? (float)$tx['total_bought'] : 0;
// Days since first purchase
$firstIn = $tx && $tx['first_in'] ? strtotime($tx['first_in']) : null;
$lastIn = $tx && $tx['last_in'] ? strtotime($tx['last_in']) : null;
$lastOut = $tx && $tx['last_out'] ? strtotime($tx['last_out']) : null;
$daysSinceFirst = $firstIn ? max(1, ($now - $firstIn) / 86400) : 999;
// Average daily consumption rate
$dailyRate = $daysSinceFirst < 999 && $totalUsed > 0 ? $totalUsed / $daysSinceFirst : 0;
// Days of stock remaining
$daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0);
// --- Expiry check ---
$expiryDate = $inv ? $inv['nearest_expiry'] : null;
$daysToExpiry = $expiryDate ? (strtotime($expiryDate) - $now) / 86400 : 999;
$isExpired = $daysToExpiry < 0;
$isExpiringSoon = !$isExpired && $daysToExpiry <= 3;
// --- Stock level assessment ---
// percentage_left: how much is left vs typical purchase size
// Use average of totalBought/buyCount if available, else default_quantity, else best-guess from defQty or 1
$refQty = $totalBought > 0 && $buyCount > 0
? $totalBought / $buyCount
: ($defQty > 0 ? $defQty : max(1, $qty)); // avoid inflating pctLeft for products with no history
$pctLeft = $refQty > 0 ? min(200, ($qty / $refQty) * 100) : ($qty > 0 ? 100 : 0);
// Cap daysLeft at a reasonable ceiling to avoid 999-day noise in reason strings
$daysLeft = min($daysLeft, 365);
// --- Determine urgency ---
$urgency = 'none'; // none, low, medium, high, critical
$reasons = [];
$score = 0;
// Out of stock
if ($qty <= 0) {
if ($useCount >= 3 || $buyCount >= 2) {
$urgency = 'critical';
$reasons[] = 'Esaurito';
$score += 100;
if ($useCount >= 5) { $score += 20; $reasons[] = "Uso frequente ({$useCount}x)"; }
} else {
// Rarely used — not urgent even if finished
continue;
}
}
// Almost finished
if ($qty > 0 && $pctLeft <= 15) {
$urgency = 'high';
$reasons[] = 'Quasi finito (' . round($pctLeft) . '%)';
$score += 80;
} elseif ($qty > 0 && $pctLeft <= 30) {
if ($dailyRate > 0 && $daysLeft <= 5) {
$urgency = 'high';
$reasons[] = 'Finisce tra ~' . round($daysLeft) . 'gg';
$score += 75;
} elseif ($dailyRate > 0 && $daysLeft <= 10) {
$urgency = 'medium';
$reasons[] = 'Finisce tra ~' . round($daysLeft) . 'gg';
$score += 50;
} else {
$urgency = 'low';
$reasons[] = 'Scorta bassa (' . round($pctLeft) . '%)';
$score += 30;
}
}
// Expiring soon or expired (needs replacement)
if ($isExpired && $qty > 0) {
$urgency = 'critical';
$reasons[] = 'Scaduto!';
$score += 90;
} elseif ($isExpiringSoon && $qty > 0) {
if ($urgency === 'none') $urgency = 'medium';
$reasons[] = 'Scade tra ' . max(0, round($daysToExpiry)) . 'gg';
$score += 40;
}
// Frequently used but stock getting low (predictive)
if ($urgency === 'none' && $dailyRate > 0 && $daysLeft <= 14 && $useCount >= 3) {
$urgency = 'low';
$reasons[] = 'Previsto esaurimento tra ~' . round($daysLeft) . 'gg';
$score += 25;
}
// Opened item with fast consumption
if ($isOpened && $urgency === 'none' && $dailyRate > 0 && $daysLeft <= 7) {
$urgency = 'low';
$reasons[] = 'Aperto, finisce presto';
$score += 20;
}
if ($urgency === 'none') continue;
// Boost score for very frequent items
if ($useCount >= 8) $score += 15;
elseif ($useCount >= 5) $score += 10;
// Is already on Bring?
$onBring = isset($bringItems[mb_strtolower($p['name'])]);
$items[] = [
'product_id' => $pid,
'name' => $p['name'],
'brand' => $p['brand'] ?: '',
'category' => $p['category'] ?: '',
'unit' => $unit,
'current_qty' => round($qty, 1),
'default_qty' => $defQty,
'package_unit' => $p['package_unit'] ?: '',
'pct_left' => round($pctLeft),
'use_count' => $useCount,
'buy_count' => $buyCount,
'daily_rate' => round($dailyRate, 2),
'days_left' => round($daysLeft),
'expiry_date' => $expiryDate,
'days_to_expiry' => round($daysToExpiry),
'is_opened' => $isOpened,
'urgency' => $urgency,
'reasons' => $reasons,
'score' => $score,
'on_bring' => $onBring,
'locations' => $inv ? $inv['locations'] : '',
];
}
// Sort by score descending (most urgent first)
usort($items, fn($a, $b) => $b['score'] - $a['score']);
echo json_encode(['success' => true, 'items' => $items], JSON_UNESCAPED_UNICODE);
}
function bringSuggestItems(PDO $db): void {
$env = loadEnvVars();
$apiKey = $env['GEMINI_API_KEY'] ?? '';