* @license MIT */ // ── Core bootstrap (env, security, database, logger) ───────────────────────── require_once __DIR__ . '/bootstrap.php'; /** True if $location is a known location key (builtin or custom). */ function isValidLocation(PDO $db, string $location): bool { static $cache = null; if ($cache === null) { $rows = $db->query("SELECT key FROM locations")->fetchAll(PDO::FETCH_COLUMN); $cache = array_flip($rows); } return isset($cache[$location]); } function isValidSubcategory(PDO $db, string $category, string $subcategory): bool { static $cache = []; if (!isset($cache[$category])) { $stmt = $db->prepare("SELECT key FROM subcategories WHERE category = ?"); $stmt->execute([$category]); $cache[$category] = array_flip($stmt->fetchAll(PDO::FETCH_COLUMN)); } return isset($cache[$category][$subcategory]); } function getRequiredSubcategoryCategories(PDO $db): array { static $cache = null; if ($cache === null) { $stmt = $db->prepare("SELECT value FROM app_settings WHERE key = 'subcategory_required_categories'"); $stmt->execute(); $row = $stmt->fetch(); $decoded = $row ? json_decode($row['value'], true) : null; $cache = is_array($decoded) ? $decoded : ['bevande']; } return $cache; } const RECIPE_PANTRY_MIN_MATCH_SCORE = 80; const RECENTLY_EXHAUSTED_DAYS = 30; /** How long to suppress auto-re-add after user bought an item (ms, synced with client blocklist). */ const BRING_PURCHASED_BLOCK_MS = 72 * 60 * 60 * 1000; // ── Global PHP error/exception reporters ───────────────────────────────────── // These are registered immediately so any crash anywhere in this file is caught. // The handler function _phpErrorReport() is defined later; PHP resolves function // names at call time so forward-referencing is safe. if (!defined('CRON_MODE')) { set_exception_handler(function (Throwable $e): void { _phpErrorReport( $e->getMessage(), $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e) ); }); register_shutdown_function(function (): void { $err = error_get_last(); if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) { _phpErrorReport($err['message'], $err['file'], $err['line'], '', 'PHP Fatal'); } }); } // When included by the cron script, skip HTTP headers and routing entirely if (!defined('CRON_MODE')) { header('Content-Type: application/json; charset=utf-8'); evershelfSendCorsHeaders(); if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { http_response_code(200); exit; } // ── Ping / heartbeat — early response, no DB or rate-limit required ─────────── if (($_GET['action'] ?? '') === 'ping') { echo json_encode(['ok' => true, 'ts' => time()]); exit; } // ── Kiosk OTA metadata (LAN self-host; no DB required) ─────────────────────── if (($_GET['action'] ?? '') === 'kiosk_update') { getKioskUpdate(); exit; } // ── App bootstrap — same-origin browsers receive API token automatically ─────── if (($_GET['action'] ?? '') === 'app_bootstrap') { $required = evershelfApiTokenRequired(); $out = ['api_token_required' => $required]; if ($required && evershelfIsSameOriginBrowser()) { $out['api_token'] = evershelfEffectiveApiToken(); } echo json_encode($out); exit; } // ── HA discovery (no token) — lets HACS config flow find the server ─────────── if (($_GET['action'] ?? '') === 'ha_info' && evershelfApiTokenRequired() && !evershelfApiTokenValid()) { header('Content-Type: application/json; charset=utf-8'); $uniqueId = 'evershelf_' . substr(md5(__DIR__ . php_uname('n')), 0, 12); echo json_encode([ 'name' => 'EverShelf', 'instance' => env('INSTANCE_NAME', php_uname('n')), 'version' => _appVersion(), 'unique_id' => $uniqueId, 'has_token' => true, 'api_token_required' => true, 'api_version' => 1, 'items_count' => null, ], JSON_UNESCAPED_UNICODE); exit; } // ── Google Drive OAuth callback — returns HTML, not JSON ────────────────────── if (($_GET['action'] ?? '') === 'gdrive_oauth_callback') { _gdriveHandleOAuthCallback(); exit; } // ── Log viewer — returns last N log lines (requires SETTINGS_TOKEN if set) ──── if (($_GET['action'] ?? '') === 'get_logs') { require_once __DIR__ . '/logger.php'; $token = evershelfEffectiveApiToken(); $reqTok = evershelfGetProvidedApiTokenFromHeaders() ?: (string)($_GET['token'] ?? ''); if ($token !== '' && ($reqTok === '' || !hash_equals($token, $reqTok))) { EverLog::warn('get_logs: unauthorized (403)'); http_response_code(403); echo json_encode(['error' => 'Unauthorized']); exit; } $lines = min(2000, max(10, (int)($_GET['lines'] ?? 200))); $filter = strtoupper($_GET['level'] ?? ''); $raw = EverLog::tail($lines); if ($filter && in_array($filter, ['DEBUG','INFO','WARN','ERROR'], true)) { $raw = array_values(array_filter($raw, fn($l) => str_contains($l, "[{$filter}"))); } echo json_encode([ 'lines' => $raw, 'total' => count($raw), 'current_file' => basename(EverLog::currentFile()), 'level' => EverLog::levelName(), 'files' => EverLog::listFiles(), ], JSON_UNESCAPED_UNICODE); exit; } // ── Gemini token usage + cost estimate ──────────────────────────────────────── if (($_GET['action'] ?? '') === 'gemini_usage') { header('Content-Type: application/json; charset=utf-8'); // ── Cost helper ─────────────────────────────────────────────────────────── $calcCost = function(int $tokIn, int $tokOut, string $modelHint = '2.5'): float { $inRate = str_contains($modelHint, '2.5') ? GEMINI_COST_25F_IN : GEMINI_COST_20F_IN; $outRate = str_contains($modelHint, '2.5') ? GEMINI_COST_25F_OUT : GEMINI_COST_20F_OUT; return round(($tokIn / 1_000_000) * $inRate + ($tokOut / 1_000_000) * $outRate, 6); }; // ── Tracked usage (ai_usage.json) ──────────────────────────────────────── $aiData = file_exists(AI_USAGE_PATH) ? (json_decode(file_get_contents(AI_USAGE_PATH), true) ?: []) : []; $month = date('Y-m'); $year = date('Y'); $cur = $aiData[$month] ?? ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_action' => [], 'by_model' => []]; // Yearly totals (sum all tracked months of current year) $yearBucket = ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_model' => []]; foreach ($aiData as $k => $v) { if (!str_starts_with($k, $year)) continue; $yearBucket['input_tokens'] += (int)($v['input_tokens'] ?? 0); $yearBucket['output_tokens'] += (int)($v['output_tokens'] ?? 0); $yearBucket['calls'] += (int)($v['calls'] ?? 0); foreach (($v['by_model'] ?? []) as $mdl => $mu) { if (!isset($yearBucket['by_model'][$mdl])) $yearBucket['by_model'][$mdl] = ['in' => 0, 'out' => 0, 'calls' => 0]; $yearBucket['by_model'][$mdl]['in'] += $mu['in'] ?? 0; $yearBucket['by_model'][$mdl]['out'] += $mu['out'] ?? 0; $yearBucket['by_model'][$mdl]['calls'] += $mu['calls'] ?? 0; } } // ── Cache item counts (for caches card) ────────────────────────────────── $priceCache = file_exists(PRICE_CACHE_PATH) ? (json_decode(file_get_contents(PRICE_CACHE_PATH), true) ?: []) : []; $shelfCache = file_exists(SHELF_CACHE_PATH) ? (json_decode(file_get_contents(SHELF_CACHE_PATH), true) ?: []) : []; $catCache = file_exists(CATEGORY_CACHE_PATH) ? (json_decode(file_get_contents(CATEGORY_CACHE_PATH), true) ?: []) : []; $nameCache = file_exists(SHOPPING_NAME_CACHE_PATH) ? (json_decode(file_get_contents(SHOPPING_NAME_CACHE_PATH), true) ?: []) : []; // ── DB stats ────────────────────────────────────────────────────────────── $dbStats = []; try { $db = getDB(); $row = $db->query("SELECT (SELECT COUNT(*) FROM products) as products_total, (SELECT COUNT(*) FROM inventory WHERE quantity > 0) as inventory_active, (SELECT COUNT(*) FROM transactions WHERE undone=0 AND created_at >= date('now','start of month')) as tx_month, (SELECT COUNT(*) FROM transactions WHERE undone=0 AND created_at >= date('now','start of year')) as tx_year, (SELECT COUNT(*) FROM transactions WHERE type='in' AND undone=0 AND created_at >= date('now','start of month')) as restock_month, (SELECT COUNT(*) FROM transactions WHERE type IN ('out','waste') AND undone=0 AND created_at >= date('now','start of month')) as use_month, (SELECT COUNT(*) FROM products WHERE created_at >= date('now','start of month')) as products_month, (SELECT COUNT(CASE WHEN expiry_date < date('now') AND quantity > 0 THEN 1 END) FROM inventory) as expired, (SELECT COUNT(CASE WHEN expiry_date BETWEEN date('now') AND date('now','+7 days') AND quantity > 0 THEN 1 END) FROM inventory) as expiring_soon, (SELECT COUNT(CASE WHEN quantity = 0 THEN 1 END) FROM inventory) as finished ")->fetch(PDO::FETCH_ASSOC); $dbStats = $row ?: []; } catch (Throwable $e) { /* ignore */ } // ── Log info ────────────────────────────────────────────────────────────── $logFilesInfo = EverLog::listFiles(); $logBytes = 0; foreach ($logFilesInfo as $lf) { $logBytes += (int)(($lf['size_kb'] ?? 0) * 1024); } // ── Backup info ─────────────────────────────────────────────────────────── $backupDir = dirname(__DIR__) . '/data/backups'; $backupFiles = is_dir($backupDir) ? (glob($backupDir . '/*.db') ?: []) : []; rsort($backupFiles); $lastBackupTs = $backupFiles ? (int)filemtime($backupFiles[0]) : 0; $lastBackupBytes = $backupFiles ? (int)filesize($backupFiles[0]) : 0; // ── Bring! token expiry ─────────────────────────────────────────────────── $bringToken = file_exists(BRING_TOKEN_PATH) ? (json_decode(file_get_contents(BRING_TOKEN_PATH), true) ?: []) : []; $bringExpiresTs = (int)($bringToken['expires'] ?? 0); echo json_encode([ 'month' => $month, 'year' => $year, // Current month (from ai_usage.json) 'month_stats' => [ 'calls' => (int)$cur['calls'], 'input_tokens' => (int)$cur['input_tokens'], 'output_tokens'=> (int)$cur['output_tokens'], 'cost_usd' => $calcCost((int)$cur['input_tokens'], (int)$cur['output_tokens']), 'by_action' => $cur['by_action'] ?? [], 'by_model' => $cur['by_model'] ?? [], ], // Current year (from ai_usage.json — all months summed) 'year_stats' => [ 'calls' => (int)$yearBucket['calls'], 'input_tokens' => (int)$yearBucket['input_tokens'], 'output_tokens'=> (int)$yearBucket['output_tokens'], 'cost_usd' => $calcCost((int)$yearBucket['input_tokens'], (int)$yearBucket['output_tokens']), ], // DB activity 'db' => array_merge( array_map('intval', $dbStats), ['bytes' => file_exists(DB_PATH) ? (int)filesize(DB_PATH) : 0] ), // Cache item counts 'caches' => [ 'price' => count($priceCache), 'shelf' => count($shelfCache), 'category' => count($catCache), 'names' => count($nameCache), 'foodfacts'=> count(file_exists(FOODFACTS_CACHE_PATH) ? (json_decode(file_get_contents(FOODFACTS_CACHE_PATH), true) ?: []) : []), ], // Current Gemini pricing (from .env / defaults) 'pricing' => [ '2.5-flash' => ['in' => GEMINI_COST_25F_IN, 'out' => GEMINI_COST_25F_OUT], '2.0-flash' => ['in' => GEMINI_COST_20F_IN, 'out' => GEMINI_COST_20F_OUT], ], // System 'log_bytes' => $logBytes, 'log_level' => EverLog::levelName(), 'log_files' => count($logFilesInfo), 'last_backup_ts' => $lastBackupTs, 'last_backup_bytes' => $lastBackupBytes, 'bring_expires_ts' => $bringExpiresTs, // History (last 13 months for trend) 'history' => array_map(fn($k, $v) => [ 'month' => $k, 'input_tokens' => (int)($v['input_tokens'] ?? 0), 'output_tokens'=> (int)($v['output_tokens'] ?? 0), 'calls' => (int)($v['calls'] ?? 0), 'cost_usd' => $calcCost((int)($v['input_tokens'] ?? 0), (int)($v['output_tokens'] ?? 0)), ], array_keys($aiData), array_values($aiData)), ], JSON_UNESCAPED_UNICODE); exit; } // ── Health check — startup diagnostic (no rate-limit, no auth required) ────── // ── Tracked usage (ai_usage.json) ──────────────────────────────────────── $aiData = file_exists(AI_USAGE_PATH) ? (json_decode(file_get_contents(AI_USAGE_PATH), true) ?: []) : []; $month = date('Y-m'); $year = date('Y'); $cur = $aiData[$month] ?? ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_action' => [], 'by_model' => []]; // Yearly totals (sum all months of current year) $yearBucket = ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_model' => []]; foreach ($aiData as $k => $v) { if (!str_starts_with($k, $year)) continue; $yearBucket['input_tokens'] += (int)($v['input_tokens'] ?? 0); $yearBucket['output_tokens'] += (int)($v['output_tokens'] ?? 0); $yearBucket['calls'] += (int)($v['calls'] ?? 0); foreach (($v['by_model'] ?? []) as $mdl => $mu) { if (!isset($yearBucket['by_model'][$mdl])) { $yearBucket['by_model'][$mdl] = ['in' => 0, 'out' => 0, 'calls' => 0]; } $yearBucket['by_model'][$mdl]['in'] += $mu['in'] ?? 0; $yearBucket['by_model'][$mdl]['out'] += $mu['out'] ?? 0; $yearBucket['by_model'][$mdl]['calls'] += $mu['calls'] ?? 0; } } // ── Health check — minimal public probe; full diagnostics require API token ── if (($_GET['action'] ?? '') === 'health_check') { if (evershelfApiTokenRequired() && !evershelfApiTokenValid()) { header('Content-Type: application/json'); echo json_encode([ 'ok' => true, 'public' => true, 'api_token_required' => true, ], JSON_UNESCAPED_UNICODE); exit; } $checks = []; // ── Helper: read .env values without triggering app init ───────────────── $envVals = loadEnv(); // already cached by loadEnv() $envGet = fn($k) => $envVals[$k] ?? ''; // ── 1. PHP version ──────────────────────────────────────────────────────── $checks['php_version'] = [ 'ok' => version_compare(PHP_VERSION, '8.0.0', '>='), 'value' => PHP_VERSION, ]; // ── 2. Critical PHP extensions ──────────────────────────────────────────── foreach (['pdo_sqlite', 'curl', 'json', 'mbstring'] as $ext) { $checks['ext_' . $ext] = ['ok' => extension_loaded($ext)]; } // ── 3. Optional PHP extensions ──────────────────────────────────────────── foreach (['openssl', 'fileinfo', 'zip', 'intl'] as $ext) { $checks['ext_' . $ext] = ['ok' => extension_loaded($ext), 'optional' => true]; } // ── 4. PHP runtime configuration ───────────────────────────────────────── $memRaw = ini_get('memory_limit'); $memBytes = (function ($v) { $v = trim($v); if ($v === '-1') return PHP_INT_MAX; $u = strtolower(substr($v, -1)); $n = (int)$v; return match($u) { 'g' => $n*1073741824, 'm' => $n*1048576, 'k' => $n*1024, default => $n }; })($memRaw); $checks['php_memory'] = ['ok' => $memBytes >= 64*1048576, 'value' => $memRaw, 'optional' => true]; $maxExec = (int) ini_get('max_execution_time'); $checks['php_max_exec'] = ['ok' => $maxExec === 0 || $maxExec >= 30, 'value' => $maxExec === 0 ? '∞' : $maxExec.'s', 'optional' => true]; $checks['php_upload'] = ['ok' => true, 'value' => ini_get('upload_max_filesize'), 'optional' => true]; // ── 5. data/ directory ──────────────────────────────────────────────────── $dataDir = __DIR__ . '/../data'; if (!is_dir($dataDir)) @mkdir($dataDir, 0775, true); $dataDirOk = is_dir($dataDir) && is_writable($dataDir); $checks['data_dir'] = ['ok' => $dataDirOk]; // data/rate_limits/ $rlDir = $dataDir . '/rate_limits'; if (!is_dir($rlDir) && $dataDirOk) @mkdir($rlDir, 0775, true); $checks['data_rate_limits'] = ['ok' => is_dir($rlDir) && is_writable($rlDir), 'optional' => true]; // data/backups/ — written by cron as root; just verify dir exists and has recent files $bkDir = $dataDir . '/backups'; $bkDirExists = is_dir($bkDir); $bkFiles = $bkDirExists ? array_filter(scandir($bkDir), fn($f) => str_ends_with($f, '.db')) : []; $lastBkTime = $bkDirExists && $bkFiles ? max(array_map(fn($f) => filemtime($bkDir.'/'.$f), $bkFiles)) : 0; $bkRecent = $lastBkTime > 0 && (time() - $lastBkTime) < 86400*2; // within 2 days $bkCount = count($bkFiles); $checks['data_backups'] = [ 'ok' => $bkDirExists && $bkCount > 0, 'optional' => true, 'value' => $bkDirExists ? ($bkCount . ' backup' . ($bkRecent ? ', ultimo recente' : ', ultimo vecchio')) : null, 'hint' => $bkDirExists ? ($bkCount === 0 ? 'Nessun backup trovato — cron configurato?' : (!$bkRecent ? 'Ultimo backup datato — cron in esecuzione?' : null)) : 'Cartella backup mancante', ]; // ── 6. Actual file-write test ───────────────────────────────────────────── $testFile = $dataDir . '/_hc_' . getmypid() . '.tmp'; $writeOk = $dataDirOk && (@file_put_contents($testFile, 'hc') !== false); if ($writeOk) @unlink($testFile); $checks['data_write_test'] = ['ok' => $writeOk]; // ── 7. Free disk space ──────────────────────────────────────────────────── $freeBytes = $dataDirOk ? @disk_free_space($dataDir) : false; $freeMB = $freeBytes !== false ? round($freeBytes/1048576) : null; $checks['disk_space'] = [ 'ok' => $freeBytes === false || $freeBytes > 50*1048576, 'value' => $freeMB !== null ? $freeMB.' MB liberi' : null, 'optional' => true, 'hint' => $freeBytes !== false && $freeBytes <= 50*1048576 ? 'Less than 50 MB free — free up disk space' : null, ]; // ── 8. SQLite database ──────────────────────────────────────────────────── // Correct DB name is evershelf.db; detect legacy dispensa.db and suggest migration $dbPath = $dataDir . '/evershelf.db'; $legacyDb = $dataDir . '/dispensa.db'; $hasLegacy = file_exists($legacyDb); $isFresh = !file_exists($dbPath) && $dataDirOk; // Auto-migrate: if evershelf.db missing but dispensa.db exists, rename it if ($isFresh && $hasLegacy && is_writable($legacyDb)) { if (@rename($legacyDb, $dbPath)) { $hasLegacy = false; $isFresh = false; } } // Auto-delete legacy dispensa.db if evershelf.db already exists (it's just an empty leftover) if ($hasLegacy && file_exists($dbPath) && filesize($legacyDb) < 1024) { @unlink($legacyDb); $hasLegacy = false; } // Legacy DB still present alongside evershelf.db → warn (should be rare now) $checks['db_legacy'] = [ 'ok' => !$hasLegacy, 'optional' => true, 'hint' => $hasLegacy ? 'Legacy dispensa.db found — the file is obsolete, you can delete it manually' : null, ]; if ($isFresh) { $checks['db_connect'] = ['ok' => true, 'fresh' => true, 'value' => 'fresh install']; $checks['db_tables'] = ['ok' => true, 'fresh' => true]; $checks['db_integrity'] = ['ok' => true, 'fresh' => true]; $checks['db_wal'] = ['ok' => true, 'fresh' => true, 'optional' => true]; $checks['db_size'] = ['ok' => true, 'value' => '0 KB', 'optional' => true]; $checks['db_row_count'] = ['ok' => true, 'value' => '0 prodotti', 'optional' => true]; } else { $pdo = null; $dbConnOk = false; try { $pdo = new PDO('sqlite:' . $dbPath, null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); $pdo->query('SELECT 1'); $dbConnOk = true; $checks['db_connect'] = ['ok' => true, 'value' => basename($dbPath)]; } catch (\Throwable $e) { $checks['db_connect'] = ['ok' => false, 'error' => $e->getMessage(), 'hint' => 'Cannot open the database — check permissions on data/evershelf.db']; } if ($dbConnOk && $pdo) { // Required tables $tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(PDO::FETCH_COLUMN); $required = ['inventory', 'products', 'transactions']; $missing = array_values(array_diff($required, $tables)); $checks['db_tables'] = [ 'ok' => empty($missing), 'missing' => $missing, 'hint' => !empty($missing) ? 'Missing tables: ' . implode(', ', $missing) . ' — call any API endpoint to auto-initialize the DB' : null, ]; // Integrity $integ = $pdo->query("PRAGMA quick_check")->fetchColumn(); $checks['db_integrity'] = [ 'ok' => $integ === 'ok', 'value' => $integ !== 'ok' ? $integ : null, 'hint' => $integ !== 'ok' ? 'Database corrotto: ' . $integ . ' — ripristina da un backup in data/backups/' : null, ]; // WAL $wal = $pdo->query("PRAGMA journal_mode")->fetchColumn(); $checks['db_wal'] = ['ok' => $wal === 'wal', 'value' => $wal, 'optional' => true, 'hint' => $wal !== 'wal' ? 'Journal mode not optimal — will be corrected automatically on next startup' : null]; $dbWritable = is_writable($dbPath); $checks['db_writable'] = [ 'ok' => $dbWritable, 'hint' => !$dbWritable ? 'Database file not writable — run: chown -R www-data:www-data data && chmod 664 data/evershelf.db' : null, ]; // Size & rows $checks['db_size'] = ['ok' => true, 'value' => round(filesize($dbPath)/1024).' KB', 'optional' => true]; if (empty($missing) || !in_array('inventory', $missing)) { $cnt = $pdo->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn(); $checks['db_row_count'] = ['ok' => true, 'value' => $cnt.' prodotti in inventario', 'optional' => true]; } else { $checks['db_row_count'] = ['ok' => true, 'value' => '0 prodotti in inventario', 'optional' => true]; } } else { foreach (['db_tables', 'db_integrity'] as $k) $checks[$k] = ['ok' => false, 'hint' => 'Cannot verify — DB connection failed']; foreach (['db_wal', 'db_size', 'db_row_count'] as $k) $checks[$k] = ['ok' => false, 'optional' => true]; } } // ── 9. .env file ────────────────────────────────────────────────────────── $envExists = file_exists(__DIR__ . '/../.env'); $checks['env_file'] = [ 'ok' => $envExists, 'optional' => true, 'hint' => !$envExists ? 'File .env mancante — copia .env.example in .env e configura i valori' : null, ]; // ── 10. Gemini AI — solo se GEMINI_API_KEY è impostata ─────────────────── $geminiKey = $envGet('GEMINI_API_KEY'); if (!empty($geminiKey)) { $checks['gemini_key'] = ['ok' => strlen($geminiKey) > 20, 'optional' => true, 'hint' => strlen($geminiKey) <= 20 ? 'Gemini AI key looks too short — check the value in .env' : null]; } else { $checks['gemini_key'] = ['ok' => true, 'optional' => true, 'value' => 'not configured', 'hint' => 'Set GEMINI_API_KEY in .env to enable AI features']; } // ── 11. Bring! — solo se EMAIL+PASSWORD sono impostate ─────────────────── // Se non configurata, l'utente ha scelto di non usarla → nessun check, nessun warning. $bringEmail = $envGet('BRING_EMAIL'); $bringPassword = $envGet('BRING_PASSWORD'); $shoppingMode = $envGet('SHOPPING_MODE') ?: 'native'; $bringEnabled = !empty($bringEmail) && !empty($bringPassword) && $shoppingMode === 'bring'; if ($bringEnabled) { $checks['bring_credentials'] = ['ok' => true, 'optional' => true]; // Token file is created automatically on first shopping list access — not an error if missing $bringTokenFile = $dataDir . '/bring_token.json'; $bringTokenOk = true; // default: fine (missing = not yet obtained, will auto-create) $bringTokenHint = null; if (file_exists($bringTokenFile)) { $bringData = @json_decode(@file_get_contents($bringTokenFile), true); $hasToken = !empty($bringData['access_token'] ?? ($bringData['accessToken'] ?? '')); $expired = isset($bringData['expires']) && $bringData['expires'] < time(); if (!$hasToken && !$expired) { // File exists but token field missing — corrupt $bringTokenOk = false; $bringTokenHint = 'Bring! token file present but appears invalid — delete data/bring_token.json to regenerate'; } // Expired token is OK: it will be refreshed automatically } // Missing token file = first launch, will be created automatically → no warning $checks['bring_token'] = ['ok' => $bringTokenOk, 'optional' => true, 'hint' => $bringTokenHint]; } // If Bring! not configured or SHOPPING_MODE != bring, skip entirely — not a warning, it is a deliberate user choice // ── 12. TTS — solo se TTS_ENABLED ──────────────────────────────────────── if ($envGet('TTS_ENABLED') === 'true') { $ttsUrl = $envGet('TTS_URL'); $checks['tts_url'] = [ 'ok' => !empty($ttsUrl), 'optional' => true, 'hint' => empty($ttsUrl) ? 'TTS_ENABLED=true but TTS_URL not configured' : null, ]; } // ── 13. Scale gateway — solo se SCALE_ENABLED ──────────────────────────── if ($envGet('SCALE_ENABLED') === 'true') { $scaleUrl = $envGet('SCALE_GATEWAY_URL'); $checks['scale_gateway'] = [ 'ok' => !empty($scaleUrl), 'optional' => true, 'hint' => empty($scaleUrl) ? 'SCALE_ENABLED=true but SCALE_GATEWAY_URL not configured' : null, ]; } // ── 14. cURL SSL ────────────────────────────────────────────────────────── if (function_exists('curl_version')) { $cv = curl_version(); $checks['curl_ssl'] = ['ok' => !empty($cv['ssl_version']), 'value' => $cv['ssl_version'] ?? null, 'optional' => true, 'hint' => empty($cv['ssl_version']) ? 'cURL senza supporto SSL — le chiamate HTTPS potrebbero fallire' : null]; } else { $checks['curl_ssl'] = ['ok' => false, 'optional' => true, 'hint' => 'cURL non disponibile']; } // ── 15. Internet — raggiungibilità API Gemini (solo se Gemini configurato) ─ if (!empty($geminiKey) && extension_loaded('curl')) { $ch = curl_init(); curl_setopt_array($ch, [CURLOPT_URL => 'https://generativelanguage.googleapis.com/', CURLOPT_NOBODY => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 4, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false]); curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErrNo = curl_errno($ch); curl_close($ch); $internetOk = $httpCode > 0 || $curlErrNo === 0; $checks['internet'] = ['ok' => $internetOk, 'optional' => true, 'hint' => !$internetOk ? 'Cannot reach Gemini servers — AI features will not work without an internet connection' : null]; } // ── Compute overall result ──────────────────────────────────────────────── $criticalKeys = ['php_version', 'ext_pdo_sqlite', 'ext_curl', 'ext_json', 'ext_mbstring', 'data_dir', 'data_write_test', 'db_connect', 'db_tables', 'db_integrity']; $allOk = array_reduce($criticalKeys, fn($c, $k) => $c && ($checks[$k]['ok'] ?? false), true); header('Content-Type: application/json'); echo json_encode(['ok' => $allOk, 'checks' => $checks, 'fresh' => $isFresh], JSON_UNESCAPED_UNICODE); exit; } // ===== RATE LIMITING ===== /** * Simple file-based rate limiter. * Limits: 120 req/min general, 15 req/min for AI endpoints, 5 req/min for login. */ function checkRateLimit(string $action): void { $rateLimitDir = __DIR__ . '/../data/rate_limits'; if (!is_dir($rateLimitDir)) { mkdir($rateLimitDir, 0755, true); } // Determine limit based on action $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient', 'gemini_number_ocr', 'gemini_barcode_visual']; $loginActions = []; $recipeActions = ['generate_recipe', 'generate_recipe_stream']; $errorActions = ['report_error', 'check_update']; $priceActions = ['get_shopping_price', 'get_all_shopping_prices']; if (in_array($action, $aiActions)) { $limit = 15; $window = 60; $bucket = 'ai'; } elseif (in_array($action, $priceActions)) { // Price lookups: up to 30 items × a few retries per minute, shared bucket $limit = 60; $window = 60; $bucket = 'price'; } elseif (in_array($action, $recipeActions)) { $limit = 5; $window = 60; $bucket = 'recipe'; } elseif (in_array($action, $errorActions)) { $limit = 20; $window = 60; $bucket = 'error_report'; } elseif (in_array($action, $loginActions)) { $limit = 5; $window = 60; $bucket = 'login'; } else { $limit = 120; $window = 60; $bucket = 'general'; } $ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; $file = $rateLimitDir . '/' . md5($ip . '_' . $bucket) . '.json'; // Clean up old rate limit files periodically (1% chance per request) if (mt_rand(1, 100) === 1) { foreach (glob($rateLimitDir . '/*.json') as $f) { if (filemtime($f) < time() - 300) @unlink($f); } } $now = time(); $data = []; if (file_exists($file)) { $raw = @file_get_contents($file); if ($raw) $data = json_decode($raw, true) ?: []; } // Remove entries outside the window $data = array_values(array_filter($data, function($ts) use ($now, $window) { return $ts > $now - $window; })); if (count($data) >= $limit) { EverLog::warn('rate_limit hit', ['action' => $action, 'limit' => $limit, 'window_s' => $window]); http_response_code(429); header('Retry-After: ' . $window); echo json_encode(['error' => 'Too many requests. Please try again later.']); exit; } $data[] = $now; @file_put_contents($file, json_encode($data), LOCK_EX); } // Apply rate limiting $rateLimitAction = $_GET['action'] ?? ''; if ($rateLimitAction) { checkRateLimit($rateLimitAction); } // CSRF guard for write actions: POST requests that modify data must include // either X-EverShelf-Request: 1 (webapp) or Content-Type: application/json. // This prevents cross-site HTML form submissions from triggering mutations. // JSON Content-Type already requires a CORS preflight which provides a baseline; // the explicit header is an additional defence-in-depth check for POST writes. $_writeActions = [ 'inventory_add','inventory_use','inventory_update','inventory_remove', 'inventory_confirm_finished','inventory_restore_ghost', 'product_save','product_delete','product_merge', 'bring_add','bring_remove','bring_sync','bring_set_spec','bring_migrate_names', 'shopping_add','shopping_remove', 'dismiss_anomaly','save_settings', ]; if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($rateLimitAction, $_writeActions, true)) { $csrfHeader = $_SERVER['HTTP_X_EVERSHELF_REQUEST'] ?? ''; $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; if ($csrfHeader !== '1' && stripos($contentType, 'application/json') === false) { EverLog::warn('csrf_rejected (403)'); http_response_code(403); echo json_encode(['success' => false, 'error' => 'csrf_rejected']); exit; } } try { $db = getDB(); } catch (Exception $e) { EverLog::exception($e, 'db_connect'); http_response_code(500); echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]); _phpErrorReport($e->getMessage(), $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e)); exit; } $method = (string)($_SERVER['REQUEST_METHOD'] ?? 'GET'); if ($method === '') { $method = 'GET'; } $action = trim((string)($_GET['action'] ?? '')); EverLog::request($action, $method); // API token auth (when API_TOKEN or SETTINGS_TOKEN is configured) evershelfRequireApiAuth($action, $method); } // end !CRON_MODE block for router bootstrap if (!defined('CRON_MODE')): try { // DEMO_MODE — block all writes and AI generation if (evershelfDemoBlocksAction($action, $method)) { EverLog::warn('demo_mode blocked (403)', ['action' => $action]); http_response_code(403); echo json_encode(['success' => false, 'error' => 'demo_mode']); exit; } switch ($action) { // ===== PRODUCTS ===== case 'search_barcode': searchBarcode($db); break; case 'lookup_barcode': lookupBarcode(); break; case 'resolve_barcode': resolveBarcode($db); break; case 'stock_for_name': stockForName($db); break; case 'product_save': saveProduct($db); break; case 'product_get': getProduct($db); break; case 'product_delete': deleteProduct($db); break; case 'product_merge': mergeProduct($db); break; case 'products_list': listProducts($db); break; case 'products_search': searchProducts($db); break; case 'inventory_search': searchInventoryProducts($db); break; case 'ai_product_suggest': aiProductSuggest($db); break; // ===== INVENTORY ===== case 'inventory_list': listInventory($db); break; case 'inventory_add': addToInventory($db); break; case 'family_sibling_suggest': familySiblingSuggest($db); break; case 'inventory_use': useFromInventory($db); break; case 'inventory_update': updateInventory($db); break; case 'inventory_delete': deleteInventory($db); break; case 'inventory_finished_items': getFinishedItems($db); break; case 'inventory_confirm_finished': confirmFinished($db); break; case 'inventory_restore_ghost': restoreGhostInventory($db); break; case 'inventory_summary': inventorySummary($db); break; // ===== TRANSACTIONS ===== case 'transactions_list': listTransactions($db); break; case 'transaction_undo': undoTransaction($db); break; // ===== STATS ===== case 'stats': getStats($db); break; case 'monthly_stats': getMonthlyStats($db); break; case 'consumption_predictions': getConsumptionPredictions($db); break; case 'inventory_anomalies': getInventoryAnomalies($db); break; case 'inventory_duplicate_loss_checks': getDuplicateLossChecks($db); break; case 'dismiss_anomaly': dismissInventoryAnomaly(); break; case 'recent_popular_products': recentPopularProducts($db); break; // ===== AI ===== case 'gemini_expiry': geminiReadExpiry(); break; case 'generate_recipe': generateRecipe($db); break; case 'generate_recipe_stream': generateRecipeStream($db); break; case 'gemini_identify': geminiIdentifyProduct(); break; case 'gemini_chat': geminiChat($db); break; case 'chat_to_recipe': chatToRecipe($db); break; case 'recipe_from_ingredient': recipeFromIngredient($db); break; // ===== BRING! SHOPPING LIST ===== case 'bring_list': bringGetList(); break; case 'bring_add': bringAddItems($db); break; case 'bring_remove': bringRemoveItem(); break; case 'bring_clean_specs': bringCleanSpecs(); break; case 'bring_migrate_names': bringMigrateNames($db); break; case 'bring_sync': bringSyncFull($db, true); break; case 'bring_suggest': bringSuggestItems($db); break; // Shopping abstraction layer (delegates to internal DB or Bring!) case 'shopping_list': shoppingGetList($db); break; case 'shopping_add': shoppingAdd($db); break; case 'shopping_remove': shoppingRemove($db); break; case 'shopping_suggest': bringSuggestItems($db); break; case 'smart_shopping': smartShoppingCached($db); break; case 'save_settings': saveSettings(); break; case 'get_settings': getServerSettings(); break; case 'client_log': clientLog(); break; case 'get_client_log': getClientLog(); break; case 'migrate_units': migrateUnitsToBase($db); break; // ===== SHARED APP DATA ===== case 'app_settings_get': appSettingsGet($db); break; case 'app_settings_save': appSettingsSave($db); break; case 'locations_list': locationsList($db); break; case 'locations_add': locationsAdd($db); break; case 'locations_remove': locationsRemove($db); break; case 'locations_update': locationsUpdate($db); break; case 'subcategories_list': subcategoriesList($db); break; case 'subcategories_add': subcategoriesAdd($db); break; case 'subcategories_remove': subcategoriesRemove($db); break; case 'subcategories_update': subcategoriesUpdate($db); break; case 'categories_list': categoriesList($db); break; case 'categories_add': categoriesAdd($db); break; case 'categories_remove': categoriesRemove($db); break; case 'categories_update': categoriesUpdate($db); break; case 'recipe_library_list': recipeLibraryList($db); break; case 'recipe_library_save': recipeLibrarySave($db); break; case 'recipe_library_delete': recipeLibraryDelete($db); break; case 'recipe_library_toggle_favorite': recipeLibraryToggleFavorite($db); break; case 'recipe_tags_list': recipeTagsList($db); break; case 'recipe_tags_add': recipeTagsAdd($db); break; case 'recipe_tags_remove': recipeTagsRemove($db); break; case 'recipe_tags_update': recipeTagsUpdate($db); break; case 'recipes_list': recipesList($db); break; case 'recipes_save': recipesSave($db); break; case 'recipes_delete': recipesDelete($db); break; case 'recipes_toggle_favorite': recipeToggleFavorite($db); break; case 'macro_stats': getMacroStats($db); break; case 'chat_list': chatList($db); break; case 'chat_save': chatSave($db); break; case 'chat_clear': chatClear($db); break; case 'tts_proxy': ttsProxy(); break; case 'ha_sensor': haInventorySensor(getDB()); break; case 'ha_info': haGetInfo(getDB()); break; case 'ha_shopping_items': haGetShoppingItems(getDB()); break; case 'ha_test': haTestConnection(); break; case 'ha_calendar': haCalendar(getDB()); break; case 'ha_suggest_recipe': haSuggestRecipe(getDB()); break; case 'ha_refresh_prices': haRefreshPrices(getDB()); break; case 'ha_clear_expired': haClearExpired(getDB()); break; case 'expiry_history': getExpiryHistory($db); break; case 'food_facts': getFoodFacts(); break; case 'opened_shelf_life': getOpenedShelfLifeAction(); break; case 'report_error': reportError(); break; case 'report_bug': reportBugManual(); break; case 'check_update': checkUpdate(); break; case 'db_cleanup': dbCleanup(getDB()); break; case 'backup_now': echo json_encode(createLocalBackup($db)); break; case 'backup_list': echo json_encode(listLocalBackups()); break; case 'backup_delete': $fn = json_decode(file_get_contents('php://input'), true)['filename'] ?? ''; echo json_encode(deleteLocalBackup($fn)); break; case 'backup_restore': $fn = json_decode(file_get_contents('php://input'), true)['filename'] ?? ''; echo json_encode(restoreLocalBackup($fn, $db)); break; case 'gdrive_push': echo json_encode(backupToGDrive($db)); break; case 'gdrive_test': $tokResult = _gdriveGetTokenEx(); if (!empty($tokResult['token'])) { echo json_encode(['success' => true]); } else { echo json_encode(['success' => false, 'error' => $tokResult['error'] ?? 'Auth failed']); } break; case 'gdrive_oauth_url': $clientId = env('GDRIVE_CLIENT_ID', ''); if (empty($clientId)) { echo json_encode(['success' => false, 'error' => 'GDRIVE_CLIENT_ID not configured — save settings first']); } else { // Use http://localhost so the flow works on any self-hosted server (IP, local domain, etc.). // Google will redirect to http://localhost?code=... after auth; user copies and pastes the URL. // Override via GDRIVE_REDIRECT_URI env var for installations with a real public domain. $redirectUri = env('GDRIVE_REDIRECT_URI', '') ?: 'http://localhost'; $url = 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([ 'client_id' => $clientId, 'redirect_uri' => $redirectUri, 'scope' => 'https://www.googleapis.com/auth/drive.file', 'response_type' => 'code', 'access_type' => 'offline', 'prompt' => 'consent', ]); echo json_encode(['success' => true, 'url' => $url, 'redirect_uri' => $redirectUri]); } break; case 'gdrive_oauth_exchange': // Manual code exchange: accepts {code, redirect_uri} from the JS after user copies URL. $_exchangeBody = json_decode(file_get_contents('php://input'), true) ?? []; $code = trim($_exchangeBody['code'] ?? ''); $redirectUri = trim($_exchangeBody['redirect_uri'] ?? '') ?: (env('GDRIVE_REDIRECT_URI', '') ?: 'http://localhost'); if (empty($code)) { echo json_encode(['success' => false, 'error' => 'No authorization code provided']); break; } $clientId = env('GDRIVE_CLIENT_ID', ''); $clientSecret = env('GDRIVE_CLIENT_SECRET', ''); if (!$clientId || !$clientSecret) { echo json_encode(['success' => false, 'error' => 'Client ID/Secret not configured — save settings first']); break; } $ch = curl_init('https://oauth2.googleapis.com/token'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query([ 'client_id' => $clientId, 'client_secret' => $clientSecret, 'code' => $code, 'redirect_uri' => $redirectUri, 'grant_type' => 'authorization_code', ]), CURLOPT_TIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => true, ]); $gdriveExResp = curl_exec($ch); $gdriveExErr = curl_error($ch); curl_close($ch); if (!$gdriveExResp) { echo json_encode(['success' => false, 'error' => 'cURL error: ' . $gdriveExErr]); break; } $gdriveExData = json_decode($gdriveExResp, true); if (!empty($gdriveExData['refresh_token'])) { _gdriveSetEnvVar('GDRIVE_REFRESH_TOKEN', $gdriveExData['refresh_token']); echo json_encode(['success' => true]); } else { $errDesc = $gdriveExData['error_description'] ?? $gdriveExData['error'] ?? $gdriveExResp; echo json_encode(['success' => false, 'error' => 'Token exchange failed: ' . $errDesc]); } break; case 'gemini_product_hint': geminiProductHint(); break; case 'gemini_shopping_enrich': geminiShoppingEnrich($db); break; case 'gemini_anomaly_explain': geminiAnomalyExplain(); break; case 'gemini_number_ocr': geminiNumberOCR(); break; case 'gemini_barcode_visual': geminiBarcodeVisual(); break; case 'get_shopping_price': getShoppingPrice($db); break; case 'get_all_shopping_prices': getAllShoppingPrices($db); break; case 'guess_category': guessCategoryFromAI(); break; case 'export_inventory': exportInventory($db); break; default: EverLog::warn('unknown action', ['action' => $action]); http_response_code(404); echo json_encode(['error' => 'Unknown action: ' . $action]); } } catch (Exception $e) { EverLog::exception($e, $action ?? '-'); http_response_code(500); echo json_encode(['error' => $e->getMessage()]); _phpErrorReport($e->getMessage(), $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e)); } endif; // end !CRON_MODE // ===== EXPORT INVENTORY ===== function exportInventory(PDO $db): void { EverLog::info('exportInventory'); $format = strtolower($_GET['format'] ?? 'csv'); $stmt = $db->query(" SELECT p.name, p.brand, p.category, i.location, i.quantity, p.unit, i.expiry_date, i.added_at, i.opened_at, COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, p.barcode, p.notes FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.quantity > 0 ORDER BY p.name ASC "); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $date = date('Y-m-d'); if ($format === 'html') { // Print-ready HTML for browser PDF header('Content-Type: text/html; charset=utf-8'); $rows_html = ''; foreach ($rows as $r) { $loc_icon = ['dispensa'=>'🗄️','frigo'=>'🧊','freezer'=>'❄️','altro'=>'📦'][$r['location']] ?? '📦'; $expiry = $r['expiry_date'] ? htmlspecialchars($r['expiry_date']) : '—'; $brand = $r['brand'] ? htmlspecialchars($r['brand']) : ''; $rows_html .= '' . '' . htmlspecialchars($r['name']) . ($brand ? '
' . $brand . '' : '') . '' . '' . htmlspecialchars(ucfirst($r['category'] ?? '')) . '' . '' . $loc_icon . ' ' . htmlspecialchars(ucfirst($r['location'])) . '' . '' . htmlspecialchars($r['quantity']) . ' ' . htmlspecialchars($r['unit'] ?? 'pz') . '' . '' . $expiry . '' . '' . ($r['opened_at'] ? '📭 ' . htmlspecialchars($r['opened_at']) : '') . '' . ''; } $count = count($rows); echo << EverShelf — Inventory Export {$date}

🏠 EverShelf — Inventory

Exported: {$date}  ·  {$count} items
{$rows_html}
Name / BrandCategoryLocationQtyExpiryOpened
HTML; exit; } // Default: CSV download header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="evershelf-inventory-' . $date . '.csv"'); // UTF-8 BOM for Excel compatibility echo "\xEF\xBB\xBF"; $out = fopen('php://output', 'w'); fputcsv($out, ['Name','Brand','Category','Location','Quantity','Unit','Expiry Date','Added','Opened At','Vacuum Sealed','Barcode','Notes']); foreach ($rows as $r) { fputcsv($out, [ $r['name'], $r['brand'] ?? '', $r['category'] ?? '', $r['location'], $r['quantity'], $r['unit'] ?? 'pz', $r['expiry_date'] ?? '', $r['added_at'] ?? '', $r['opened_at'] ?? '', $r['vacuum_sealed'] ? 'Yes' : 'No', $r['barcode'] ?? '', $r['notes'] ?? '', ]); } fclose($out); exit; } // ===== TTS PROXY ===== function ttsProxy() { EverLog::info('ttsProxy'); $body = json_decode(file_get_contents('php://input'), true); $url = isset($body['url']) ? trim($body['url']) : ''; $method = isset($body['method']) ? strtoupper(trim($body['method'])) : 'POST'; $headers = isset($body['headers']) && is_array($body['headers']) ? $body['headers'] : []; $payload = isset($body['payload']) ? $body['payload'] : ''; // Never trust client-supplied auth headers — inject from server .env $headers = array_filter($headers, static function ($k) { $lk = strtolower((string)$k); return !in_array($lk, ['authorization', 'x-api-key', 'x-auth-token'], true); }, ARRAY_FILTER_USE_KEY); $haBase = rtrim(env('HA_URL', ''), '/'); if ($haBase !== '' && str_starts_with($url, $haBase)) { $haTok = env('HA_TOKEN'); if ($haTok !== '') { $headers['Authorization'] = 'Bearer ' . $haTok; } } elseif ($url !== '' && $url === env('TTS_URL', '')) { $authType = env('TTS_AUTH_TYPE', 'bearer'); if ($authType === 'bearer') { $tok = env('TTS_TOKEN'); if ($tok !== '') { $headers['Authorization'] = 'Bearer ' . $tok; } } elseif ($authType === 'header') { $hn = env('TTS_AUTH_HEADER_NAME'); $hv = env('TTS_AUTH_HEADER_VALUE'); if ($hn !== '') { $headers[$hn] = $hv; } } } if (!$url || !preg_match('/^https?:\/\/.+/', $url)) { EverLog::warn('ttsProxy: invalid URL (400)'); http_response_code(400); echo json_encode(['error' => 'URL non valido']); return; } $curlHeaders = []; foreach ($headers as $k => $v) { $curlHeaders[] = "$k: $v"; } $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); if ($method !== 'GET' && $payload !== '') { curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); } if ($curlHeaders) { curl_setopt($ch, CURLOPT_HTTPHEADER, $curlHeaders); } curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // allow self-signed certs on local network $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); if ($curlErr) { EverLog::error('ttsProxy: curl error (502)'); http_response_code(502); echo json_encode(['error' => 'cURL error: ' . $curlErr]); return; } http_response_code($httpCode ?: 200); echo json_encode(['status' => $httpCode, 'body' => $response]); } // ===== HOME ASSISTANT INTEGRATION ===== /** * Fire an outbound webhook to Home Assistant. * Respects HA_ENABLED, HA_URL, HA_WEBHOOK_ID and HA_WEBHOOK_EVENTS. * Non-blocking: uses a 5 s cURL timeout; failures are logged but never thrown. */ function _fireHaWebhook(string $event, array $data): void { if (env('HA_ENABLED', 'false') !== 'true') return; $haUrl = rtrim(env('HA_URL', ''), '/'); $webhookId = env('HA_WEBHOOK_ID', ''); if (!$haUrl || !$webhookId) return; $allowed = array_map('trim', explode(',', env('HA_WEBHOOK_EVENTS', 'expiry,shopping_add,stock_update,barcode_scan'))); if (!in_array($event, $allowed, true)) return; $url = $haUrl . '/api/webhook/' . urlencode($webhookId); $payload = json_encode(array_merge(['event' => $event, 'source' => 'evershelf', 'ts' => time()], $data), JSON_UNESCAPED_UNICODE); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_TIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_CONNECTTIMEOUT => 3, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($err) { EverLog::warn("_fireHaWebhook[$event]: cURL error – $err"); } else { EverLog::debug("_fireHaWebhook[$event]: HTTP $code"); } } /** * Send a notification via HA notify service (e.g. notify.mobile_app_phone). * Used for expiry alerts when HA_NOTIFY_SERVICE is configured. */ function _sendHaNotify(string $message, array $data = []): void { if (env('HA_ENABLED', 'false') !== 'true') return; $haUrl = rtrim(env('HA_URL', ''), '/'); $token = env('HA_TOKEN', ''); $service = env('HA_NOTIFY_SERVICE', ''); if (!$haUrl || !$token || !$service) return; // service format: "notify.mobile_app_xyz" → POST /api/services/notify/mobile_app_xyz [$domain, $svcName] = array_pad(explode('.', $service, 2), 2, ''); if (!$svcName) return; $url = $haUrl . '/api/services/' . urlencode($domain) . '/' . urlencode($svcName); $payload = json_encode(array_merge(['message' => $message, 'data' => $data], []), JSON_UNESCAPED_UNICODE); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $token, ], CURLOPT_TIMEOUT => 8, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_CONNECTTIMEOUT => 4, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($err) { EverLog::warn("_sendHaNotify: cURL error – $err"); } else { EverLog::debug("_sendHaNotify: HTTP $code"); } } /** * Normalise a DB inventory+product row into a full product info array * used consistently across all HA sensor attributes and webhook payloads. */ function _haFormatProduct(array $row): array { $daysRemaining = null; if (!empty($row['expiry_date'])) { $diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date'])); $daysRemaining = (int)$diff->format('%r%a'); } return [ 'product_id' => (int)($row['product_id'] ?? 0), 'inventory_id' => (int)($row['inventory_id'] ?? 0), 'name' => $row['name'], 'brand' => $row['brand'] ?? null, 'category' => $row['category'] ?? null, 'quantity' => (float)($row['quantity'] ?? 0), 'unit' => $row['unit'] ?? '', 'default_quantity' => (float)($row['default_quantity'] ?? 0), 'package_unit' => $row['package_unit'] ?? null, 'location' => $row['location'] ?? null, 'expiry_date' => $row['expiry_date'] ?? null, 'days_remaining' => $daysRemaining, 'opened_at' => $row['opened_at'] ?? null, 'vacuum_sealed' => !empty($row['vacuum_sealed']), ]; } /** Full product detail SQL fragment reused in all HA queries. */ function _haProductSelect(): string { return "p.id AS product_id, i.id AS inventory_id, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit, i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed"; } /** * HA REST sensor endpoint — returns pantry state in Home Assistant-compatible format. * Use with platform: rest in configuration.yaml. * * GET /api/?action=ha_sensor[&sensor=NAME] * Available sensor names: expiring, expired, total, shopping, product */ function haInventorySensor(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); $sensor = strtolower(trim($_GET['sensor'] ?? 'overview')); $expiryDays = max(1, min(90, (int)($_GET['expiry_days'] ?? env('HA_EXPIRY_DAYS', 3)))); // ── sensor=product: full inventory details, optionally filtered ────────── if ($sensor === 'product') { try { $invId = (int)($_GET['id'] ?? 0); $search = trim($_GET['name'] ?? ''); $loc = trim($_GET['location'] ?? ''); $where = "WHERE i.quantity > 0"; $params = []; if ($invId > 0) { $where .= " AND i.id = ?"; $params[] = $invId; } elseif ($search !== '') { $where .= " AND LOWER(p.name) LIKE ?"; $params[] = '%' . mb_strtolower($search, 'UTF-8') . '%'; } if ($loc !== '') { $where .= " AND i.location = ?"; $params[] = $loc; } $stmt = $db->prepare( "SELECT " . _haProductSelect() . " FROM inventory i JOIN products p ON p.id = i.product_id $where ORDER BY p.name ASC" ); $stmt->execute($params); $items = array_map('_haFormatProduct', $stmt->fetchAll(PDO::FETCH_ASSOC)); header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'state' => count($items), 'items' => $items, 'last_updated' => date('c'), ], JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } return; } try { $expiring = (int)$db->query( "SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND expiry_date IS NOT NULL AND expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')" )->fetchColumn(); $expired = (int)$db->query( "SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND expiry_date IS NOT NULL AND expiry_date < date('now')" )->fetchColumn(); $total = (int)$db->query( "SELECT COUNT(*) FROM inventory WHERE quantity > 0" )->fetchColumn(); $shoppingCount = 0; if (isShoppingBringMode()) { $auth = bringAuth(); if ($auth) { $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}"); $shoppingCount = isset($listData['purchase']) ? count($listData['purchase']) : 0; } } else { $shoppingCount = (int)$db->query("SELECT COUNT(*) FROM shopping_list")->fetchColumn(); } // Expiring items details (full product info, all within $expiryDays window) $expiringItems = $db->query( "SELECT " . _haProductSelect() . " FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days') ORDER BY i.expiry_date ASC" )->fetchAll(PDO::FETCH_ASSOC); // Expired items (full product info) $expiredItemsList = $db->query( "SELECT " . _haProductSelect() . " FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL AND i.expiry_date < date('now') ORDER BY i.expiry_date ASC" )->fetchAll(PDO::FETCH_ASSOC); // Low-stock items (quantity <= 1 but > 0, full product info) $lowStockItemsList = $db->query( "SELECT " . _haProductSelect() . " FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 AND i.quantity <= 1 ORDER BY i.quantity ASC, p.name ASC" )->fetchAll(PDO::FETCH_ASSOC); // Opened items $openedItems = (int)$db->query( "SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND opened_at IS NOT NULL" )->fetchColumn(); // Fixed 3-day expiry count (always 3 days, regardless of expiry_days param) $expiring3d = ($expiryDays === 3) ? $expiring : (int)$db->query( "SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND expiry_date IS NOT NULL AND expiry_date BETWEEN date('now') AND date('now', '+3 days')" )->fetchColumn(); // Items expiring today or tomorrow (max urgency) $expiringToday = (int)$db->query( "SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND expiry_date IS NOT NULL AND expiry_date <= date('now', '+1 days')" )->fetchColumn(); // Location breakdown $locationRows = $db->query( "SELECT location, COUNT(*) as n FROM inventory WHERE quantity > 0 GROUP BY location" )->fetchAll(PDO::FETCH_ASSOC); $locationMap = []; foreach ($locationRows as $row) $locationMap[$row['location']] = (int)$row['n']; $itemsDispensa = $locationMap['dispensa'] ?? 0; $itemsFrigo = $locationMap['frigo'] ?? 0; $itemsFreezer = $locationMap['freezer'] ?? 0; $itemsOther = array_sum($locationMap) - $itemsDispensa - $itemsFrigo - $itemsFreezer; // Low stock (qty > 0 but <= 1) and zero stock $lowStockItems = (int)$db->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND quantity <= 1")->fetchColumn(); $zeroStockItems = (int)$db->query("SELECT COUNT(*) FROM inventory WHERE quantity <= 0")->fetchColumn(); // AI calls this month $aiCallsToday = 0; $aiUsagePath = __DIR__ . '/../data/ai_usage.json'; if (file_exists($aiUsagePath)) { $aiData = json_decode(file_get_contents($aiUsagePath), true) ?? []; $monthKey = date('Y-m'); $aiCallsToday = (int)(($aiData[$monthKey]['calls'] ?? 0)); } // Last backup $lastBackupAt = null; $backupPath = __DIR__ . '/../data/backup_last_ts.json'; if (file_exists($backupPath)) { $bk = json_decode(file_get_contents($backupPath), true) ?? []; if (!empty($bk['ts'])) $lastBackupAt = date('c', (int)$bk['ts']); } // Bring! connected $bringConnected = isShoppingBringMode() && (bool)bringAuth(); // Days to next expiry $daysToNextExpiry = null; if (!empty($expiringItems)) { $diff = (new DateTime('today'))->diff(new DateTime($expiringItems[0]['expiry_date'])); $daysToNextExpiry = (int)$diff->format('%r%a'); } // Shopping total from canonical weekly cache (same source as UI and screensaver). $priceEnabled = env('PRICE_ENABLED', 'false') === 'true'; $priceCurrency = env('PRICE_CURRENCY', 'EUR'); $shoppingTotal = null; if ($priceEnabled) { $country = env('PRICE_COUNTRY', 'Italia'); $shopNames = []; if (isShoppingBringMode()) { $auth = bringAuth(); if ($auth) { $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}"); foreach ($listData['purchase'] ?? [] as $item) { $shopNames[] = bringToItalian($item['name'] ?? ''); } } } else { $shopRows = $db->query(" SELECT sl.name, COALESCE(p.shopping_name, sl.name) AS sname FROM shopping_list sl LEFT JOIN products p ON lower(p.name) = lower(sl.name) ")->fetchAll(PDO::FETCH_ASSOC); $seenNames = []; foreach ($shopRows as $r) { $sname = $r['sname'] ?? $r['name']; if (isset($seenNames[$sname])) continue; $seenNames[$sname] = true; $shopNames[] = $sname; } } if (!empty($shopNames)) { $listHash = _shoppingListHash($shopNames, $country, $priceCurrency); $cached = _loadCanonicalShoppingTotal($listHash); if ($cached !== null) { $shoppingTotal = round((float)($cached['total'] ?? 0), 2); } else { $computed = _computeAllShoppingPrices( array_map(static fn($n) => ['name' => $n], $shopNames), $country, $priceCurrency, 'it', false ); $shoppingTotal = round((float)($computed['total'] ?? 0), 2); } } } $stateValue = match($sensor) { 'expired' => $expired, 'shopping' => $shoppingCount, 'total' => $total, default => $expiring, // 'expiring' or 'overview' }; echo json_encode([ 'state' => $stateValue, 'attributes' => [ 'expiring_soon' => $expiring, 'expiring_3d' => $expiring3d, 'expiring_today' => $expiringToday, 'expired_items' => $expired, 'total_items' => $total, 'opened_items' => $openedItems, 'items_dispensa' => $itemsDispensa, 'items_frigo' => $itemsFrigo, 'items_freezer' => $itemsFreezer, 'items_other' => $itemsOther, 'low_stock_items' => $lowStockItems, 'zero_stock_items' => $zeroStockItems, 'ai_calls_month' => $aiCallsToday, 'last_backup_at' => $lastBackupAt, 'days_to_next_expiry' => $daysToNextExpiry, 'bring_connected' => $bringConnected, 'shopping_items' => $shoppingCount, 'shopping_total' => $shoppingTotal, 'price_tracking_enabled' => $priceEnabled, 'price_currency' => $priceCurrency, 'expiring_list' => array_map('_haFormatProduct', $expiringItems), 'expired_list' => array_map('_haFormatProduct', $expiredItemsList), 'low_stock_list' => array_map('_haFormatProduct', $lowStockItemsList), 'next_expiry_name' => !empty($expiringItems) ? $expiringItems[0]['name'] : null, 'next_expiry_date' => !empty($expiringItems) ? $expiringItems[0]['expiry_date'] : null, 'unit_of_measurement' => 'items', 'friendly_name' => 'EverShelf Pantry', 'icon' => 'mdi:fridge', 'last_updated' => date('c'), ], ], JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } } // ===== HA CALENDAR ===== /** * Returns all inventory items with expiry dates as calendar events. * GET /api/index.php?action=ha_calendar */ function haCalendar(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); try { $rows = $db->query( "SELECT p.name, i.quantity, p.unit, i.location, i.expiry_date FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL ORDER BY i.expiry_date ASC" )->fetchAll(PDO::FETCH_ASSOC); $events = array_map(fn($r) => [ 'summary' => $r['name'], 'description' => number_format((float)$r['quantity'], 2, '.', '') . ' ' . $r['unit'] . ' — ' . $r['location'], 'start' => $r['expiry_date'], 'end' => $r['expiry_date'], 'location' => $r['location'], 'quantity' => (float)$r['quantity'], 'unit' => $r['unit'], ], $rows); echo json_encode(['events' => $events], JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } } // ===== HA SUGGEST RECIPE ===== /** * Suggests a recipe using items that expire soonest. * GET /api/index.php?action=ha_suggest_recipe[&location=frigo] */ function haSuggestRecipe(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); $apiKey = env('GEMINI_API_KEY', ''); if (!$apiKey) { http_response_code(503); echo json_encode(['error' => 'GEMINI_API_KEY not configured']); return; } $location = trim($_GET['location'] ?? ''); $limit = max(3, min(12, (int)($_GET['limit'] ?? 8))); try { $where = "i.quantity > 0"; if ($location) $where .= " AND i.location = " . $db->quote($location); $expiringRows = $db->query( "SELECT p.name, i.quantity, p.unit, i.expiry_date, i.location FROM inventory i JOIN products p ON p.id = i.product_id WHERE $where AND i.expiry_date IS NOT NULL ORDER BY i.expiry_date ASC LIMIT $limit" )->fetchAll(PDO::FETCH_ASSOC); // Also grab other available items (no expiry) $otherRows = $db->query( "SELECT p.name, i.quantity, p.unit FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 AND i.expiry_date IS NULL" . ($location ? " AND i.location = " . $db->quote($location) : "") . " ORDER BY p.name LIMIT 15" )->fetchAll(PDO::FETCH_ASSOC); $expParts = array_map(fn($r) => "{$r['name']} ({$r['quantity']} {$r['unit']}, scade {$r['expiry_date']})", $expiringRows ); $otherParts = array_map(fn($r) => "{$r['name']} ({$r['quantity']} {$r['unit']})", $otherRows ); $locationHint = $location ? " nel $location" : " in dispensa/frigo/freezer"; $ingredientList = implode(', ', $expParts); if ($otherParts) $ingredientList .= '. Altri disponibili: ' . implode(', ', $otherParts); $prompt = "Sei uno chef italiano. Ho questi ingredienti$locationHint che scadono presto: $ingredientList. " . "Proponi UNA ricetta completa che usa prioritariamente quelli in scadenza. " . "Rispondi con: NOME RICETTA, poi INGREDIENTI (lista), poi PREPARAZIONE (passi numerati). " . "Risposta concisa, massimo 300 parole. Solo italiano."; $payload = [ 'contents' => [['role' => 'user', 'parts' => [['text' => $prompt]]]], 'generationConfig' => ['temperature' => 0.7, 'maxOutputTokens' => 512, 'thinkingConfig' => ['thinkingBudget' => 0]], ]; $result = callGeminiWithFallback($apiKey, $payload, 25); $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? null; if (!$text) { http_response_code(503); echo json_encode(['error' => 'No recipe generated']); return; } echo json_encode([ 'recipe' => trim($text), 'ingredients' => array_merge($expParts, $otherParts), 'location' => $location ?: 'all', ], JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } } // ===== HA REFRESH PRICES ===== /** * Computes shopping list total using only existing price cache (no new AI calls). * GET /api/index.php?action=ha_refresh_prices */ function haRefreshPrices(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); try { $country = env('PRICE_COUNTRY', 'Italia'); $currency = env('PRICE_CURRENCY', 'EUR'); $lang = 'it'; $clientItems = []; if (isShoppingBringMode()) { $auth = bringAuth(); if ($auth) { $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}"); foreach ($listData['purchase'] ?? [] as $item) { $clientItems[] = ['name' => bringToItalian($item['name'] ?? '')]; } } } else { $rows = $db->query(" SELECT sl.name, COALESCE(p.shopping_name, sl.name) AS sname FROM shopping_list sl LEFT JOIN products p ON lower(p.name) = lower(sl.name) ")->fetchAll(PDO::FETCH_ASSOC); $seen = []; foreach ($rows as $r) { $sname = $r['sname'] ?? $r['name']; if (isset($seen[$sname])) continue; $seen[$sname] = true; $clientItems[] = ['name' => $sname]; } } $result = _computeAllShoppingPrices($clientItems, $country, $currency, $lang, false); $priced = count(array_filter($result['prices'] ?? [], static fn($e) => !empty($e['price_per_unit']))); echo json_encode([ 'success' => true, 'total' => $result['total'] ?? 0, 'total_label' => $result['total_label'] ?? _formatPrice(0, $currency), 'priced_items' => $priced, 'missing_items' => max(0, count($clientItems) - $priced), ], JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } } // ===== HA CLEAR EXPIRED ===== /** * Removes inventory rows that are expired AND have quantity <= 0. * POST /api/index.php?action=ha_clear_expired */ function haClearExpired(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); try { $stmt = $db->prepare( "DELETE FROM inventory WHERE expiry_date < date('now') AND quantity <= 0" ); $stmt->execute(); $deleted = $stmt->rowCount(); echo json_encode(['success' => true, 'deleted' => $deleted], JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } } // ===== CLIENT LOG ===== /** * Test reachability of a Home Assistant instance. * Accepts POST body: {url, token} * Uses server-env HA_TOKEN if token === '__server__' (token already saved on server). */ function haTestConnection(): void { header('Content-Type: application/json; charset=utf-8'); $input = json_decode(file_get_contents('php://input'), true) ?? []; $url = rtrim($input['url'] ?? '', '/'); $token = $input['token'] ?? ''; if ($token === '__server__') { $token = env('HA_TOKEN', ''); } if (!$url) { http_response_code(400); echo json_encode(['ok' => false, 'error' => 'No URL provided']); return; } $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url . '/api/', CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_HTTPHEADER => array_filter([ 'Content-Type: application/json', $token ? 'Authorization: Bearer ' . $token : null, ]), ]); $raw = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($err) { echo json_encode(['ok' => false, 'error' => $err, 'http_code' => 0]); return; } $data = json_decode($raw, true); $version = $data['version'] ?? null; if ($code === 200) { echo json_encode(['ok' => true, 'version' => $version, 'http_code' => $code]); } elseif ($code === 401) { echo json_encode(['ok' => false, 'error' => 'bad_token', 'http_code' => $code]); } else { echo json_encode(['ok' => false, 'error' => 'http_' . $code, 'http_code' => $code]); } } // ===== HA DISCOVERY INFO ===== /** * Returns device info for HA Zeroconf discovery confirmation. * GET /api/index.php?action=ha_info * Response: { name, instance, version, unique_id, has_token, api_version, items_count } */ function haGetInfo(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); // Stable unique_id derived from server identity (survives restarts) $uniqueId = 'evershelf_' . substr(md5(__DIR__ . php_uname('n')), 0, 12); $itemsCount = (int)$db->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn(); echo json_encode([ 'name' => 'EverShelf', 'instance' => env('INSTANCE_NAME', php_uname('n')), 'version' => _appVersion(), 'unique_id' => $uniqueId, 'has_token' => evershelfApiTokenRequired(), 'api_token_required' => evershelfApiTokenRequired(), 'api_version' => 1, 'items_count' => $itemsCount, ], JSON_UNESCAPED_UNICODE); } /** * Returns shopping list items in a clean format suitable for HA todo entity. * GET /api/index.php?action=ha_shopping_items * Response: { items: [{id, name, note}], count, mode } */ function haGetShoppingItems(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); try { if (isShoppingBringMode()) { $auth = bringAuth(); if (!$auth) { echo json_encode(['items' => [], 'count' => 0, 'mode' => 'bring']); return; } $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}"); $items = array_map(fn($r) => [ 'id' => $r['uuid'] ?? md5(($r['name'] ?? '') . uniqid()), 'name' => $r['name'] ?? '', 'note' => $r['specification'] ?? '', ], $listData['purchase'] ?? []); echo json_encode(['items' => $items, 'count' => count($items), 'mode' => 'bring'], JSON_UNESCAPED_UNICODE); } else { $rows = $db->query( "SELECT rowid AS id, name, specification AS note FROM shopping_list ORDER BY sort_order ASC, added_at ASC" )->fetchAll(PDO::FETCH_ASSOC); $items = array_map(fn($r) => [ 'id' => (string)$r['id'], 'name' => $r['name'], 'note' => $r['note'] ?? '', ], $rows); echo json_encode(['items' => $items, 'count' => count($items), 'mode' => 'internal'], JSON_UNESCAPED_UNICODE); } } catch (Throwable $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } } // ===== FOOD FACTS (cached daily) ===== function getFoodFacts(): void { EverLog::info('getFoodFacts'); header('Content-Type: application/json; charset=utf-8'); $cacheFile = __DIR__ . '/../data/food_facts_cache.json'; $maxAgeSeconds = 86400; // 24 hours // Return valid cache if fresh if (file_exists($cacheFile)) { $cached = @json_decode(file_get_contents($cacheFile), true); if ($cached && !empty($cached['ts']) && (time() - $cached['ts']) < $maxAgeSeconds) { echo json_encode($cached); return; } } // Build facts dataset (sourced from UNEP Food Waste Index 2024, Waste Watcher IT 2024, // ISPRA 2024, USDA 2021, Eurostat 2023, FAO 2024 — verified against public reports) $facts = [ 'it' => [ "Nel 2024 ogni italiano spreca ~554 g di cibo a settimana (Waste Watcher 2024)", "Lo spreco domestico in Italia vale oltre €7,5 miliardi l'anno", "La frutta fresca è l'alimento più sprecato in Italia: ~22g/persona/settimana", "Nel mondo si sprecano ~1,05 miliardi di tonnellate di cibo ogni anno (UNEP 2024)", "Il 19% del cibo globale disponibile al consumo viene buttato (UNEP 2024)", "Le famiglie sono responsabili del 60% dello spreco alimentare totale", "Lo spreco alimentare conta per l'8-10% delle emissioni globali di gas serra", "Se fosse un Paese, lo spreco alimentare sarebbe il 3° emettitore di CO₂ al mondo", "Lo spreco alimentare consuma il 25% dell'acqua dolce usata in agricoltura", "Un'area grande quanto la Cina viene coltivata per cibo mai mangiato", "Lo spreco alimentare costa al mondo ~€1.000 miliardi l'anno", "Eliminare lo spreco potrebbe ridurre le emissioni globali del 10%", "Il lunedì è il giorno in cui gli italiani buttano più cibo (residui del weekend)", "Solo il 30% degli italiani sa distinguere 'da consumarsi entro' da 'preferibilmente entro'", "Il ricorso al congelatore riduce lo spreco domestico del 20%", "1 kg di pane sprecato = 1.300 litri d'acqua consumati inutilmente", "Sprecare 1 hamburger = stessa acqua di una doccia da 90 minuti", "Lo spreco alimentare pro capite in Italia è ~29 kg/anno (domestico)", "Il 42% degli italiani dichiara di sprecare meno grazie all'aumento dei prezzi", "La Gen Z spreca più dei Boomers per minori competenze in cucina", "Le app anti-spreco come Too Good To Go hanno salvato milioni di pasti in Italia", "Solo il 15% degli italiani chiede la 'doggy bag' al ristorante (per imbarazzo)", "Un quarto del cibo sprecato basterebbe a sfamare tutti gli affamati del mondo", "Il packaging intelligente potrebbe ridurre lo spreco del 15%", "Educare i bambini a scuola riduce lo spreco familiare del 15%", "La Legge Gadda (166/2016) è tra le norme anti-spreco più avanzate d'Europa", "Il Sud Italia spreca in media l'8% in più rispetto al Nord", "Le città metropolitane sprecano più dei piccoli centri rurali", "Il 70% degli italiani cerca più offerte per via dell'inflazione", "L'uso dei discount in Italia è cresciuto del 12% negli ultimi due anni", "L'Italia è il 1° paese europeo per consumo di pasta: 23 kg pro capite/anno", "Il consumo di carne rossa in Italia è calato del 5% rispetto al decennio scorso", "Il biologico rappresenta ~4% della spesa alimentare totale italiana", "L'85% degli italiani preferisce ancora il negozio fisico per i prodotti freschi", "Nel 2024 oltre 780 milioni di persone hanno sofferto la fame nel mondo (FAO)", ], 'de' => [ "Deutsche Haushalte werfen pro Person rund 82 kg Lebensmittel pro Jahr weg (Destatis 2024)", "Weltweit werden ~1,05 Milliarden Tonnen Lebensmittel pro Jahr verschwendet (UNEP 2024)", "19% des global verfügbaren Lebensmittelangebots landet im Müll (UNEP 2024)", "Haushalte verursachen 60% der gesamten Lebensmittelverschwendung", "Lebensmittelverschwendung ist für 8-10% der globalen Treibhausgase verantwortlich", "Wäre Lebensmittelverschwendung ein Land, wäre es der 3. größte CO₂-Emittent weltweit", "25% des in der Landwirtschaft genutzten Süßwassers wird für nie gegessenes Essen verbraucht", "Die weltweiten Kosten der Lebensmittelverschwendung betragen ~€1 Billion jährlich", "1 kg verschwendetes Rindfleisch ≈ 27 kg CO₂-Emissionen", "Das Einfrieren von Lebensmitteln reduziert Haushaltsabfälle um bis zu 20%", "Nur ein Viertel der weltweit verschwendeten Lebensmittel würde alle Hungernden ernähren", "In Deutschland zeigt die Inflation: 60% der Verbraucher kaufen gezielter ein", "Bio-Lebensmittel machen ~6% der deutschen Lebensmittelausgaben aus", "Deutsche Familien geben im Schnitt ~€3.000/Jahr für Lebensmittel aus", "Schlaue Verpackungen könnten den Lebensmittelabfall um 15% senken", ], 'en' => [ "~1.05 billion tonnes of food are wasted globally every year (UNEP 2024)", "19% of food available for human consumption is wasted globally (UNEP 2024)", "Households account for 60% of all food waste globally", "Food waste represents 8-10% of global greenhouse gas emissions", "If food waste were a country, it would be the world's 3rd largest CO₂ emitter", "25% of freshwater used in farming grows food that is never eaten", "Food waste costs the world ~$1 trillion per year", "Eliminating food waste could cut global emissions by up to 10%", "30–40% of the US food supply is wasted each year (USDA 2021)", "Americans spend ~$1,800/year on food they never eat", "Using a freezer can reduce household food waste by 20%", "Just a quarter of wasted food would be enough to feed all the world's hungry", "Smart packaging that changes color near expiry could cut waste by 15%", "Gen Z wastes more food than Boomers due to fewer cooking skills", "In 2024, over 780 million people faced hunger despite global food abundance (FAO)", "1 kg of wasted bread = 1,300 litres of water wasted", "Wasting one hamburger uses as much water as a 90-minute shower", "Food loss (field→store) and food waste (store→table) together waste ~30% of all food", "Fruits & vegetables are the most wasted food category worldwide", "Teaching children about food waste reduces household waste by 15%", ], 'source' => 'UNEP Food Waste Index 2024 · Waste Watcher IT 2024 · USDA 2021 · FAO 2024 · Eurostat 2023', 'ts' => time(), ]; // Write cache @file_put_contents($cacheFile, json_encode($facts)); echo json_encode($facts); } // ===== EXPIRY HISTORY ===== function getExpiryHistory($db): void { $productId = (int)($_GET['product_id'] ?? $_POST['product_id'] ?? 0); if (!$productId) { EverLog::debug('getExpiryHistory'); echo json_encode(['avg_days' => null, 'count' => 0]); return; } // Average shelf life from the last 3 insertions (expiry_date − added_at). // Requires at least 3 valid samples before returning a prediction. $minSamples = 3; $stmt = $db->prepare(" SELECT ROUND(AVG(shelf_days)) AS avg_days, COUNT(*) AS count FROM ( SELECT CAST(JULIANDAY(expiry_date) - JULIANDAY(added_at) AS REAL) AS shelf_days FROM inventory WHERE product_id = ? AND expiry_date IS NOT NULL AND expiry_date > date(added_at) AND added_at >= date('now', '-730 days') ORDER BY added_at DESC LIMIT {$minSamples} ) recent "); $stmt->execute([$productId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); $count = (int)($row['count'] ?? 0); if ($count < $minSamples || $row['avg_days'] === null) { echo json_encode([ 'avg_days' => null, 'count' => $count, 'min_samples' => $minSamples, ]); return; } echo json_encode([ 'avg_days' => (int)$row['avg_days'], 'count' => $count, 'min_samples' => $minSamples, ]); } function clientLog(): void { EverLog::debug('clientLog'); $input = json_decode(file_get_contents('php://input'), true); $logFile = __DIR__ . '/../data/client_debug.log'; $ua = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'; // Identify device from UA $device = 'unknown'; if (preg_match('/tablet|ipad|playbook|silk/i', $ua)) $device = 'tablet'; elseif (preg_match('/mobile|android|iphone/i', $ua)) $device = 'phone'; else $device = 'desktop'; $ts = date('Y-m-d H:i:s'); $msgs = $input['messages'] ?? []; $lines = []; foreach ($msgs as $m) { $lines[] = "[$ts] [$device] $m"; } if ($lines) { // Keep log under 100KB — truncate oldest if needed if (file_exists($logFile) && filesize($logFile) > 100000) { $existing = file($logFile); $existing = array_slice($existing, -200); file_put_contents($logFile, implode('', $existing)); } file_put_contents($logFile, implode("\n", $lines) . "\n", FILE_APPEND | LOCK_EX); } echo json_encode(['ok' => true]); } function getClientLog(): void { $logFile = __DIR__ . '/../data/client_debug.log'; $lines = 100; if (isset($_GET['lines'])) $lines = min(500, max(1, (int)$_GET['lines'])); if (!file_exists($logFile)) { EverLog::debug('getClientLog'); echo json_encode(['log' => '(empty)', 'lines' => 0]); return; } $all = file($logFile); $tail = array_slice($all, -$lines); echo json_encode(['log' => implode('', $tail), 'lines' => count($tail), 'total' => count($all)]); } // ===== PRODUCT FUNCTIONS ===== function searchBarcode(PDO $db): void { $barcode = barcodeNormalizeDigits($_GET['barcode'] ?? ''); if ($barcode === '') { EverLog::info('searchBarcode'); echo json_encode(['found' => false]); return; } $product = barcodeFindLocalProduct($db, $barcode); if ($product) { echo json_encode(['found' => true, 'product' => $product]); } else { echo json_encode(['found' => false]); } } /** Strip non-digits; used for lookup keys. */ function barcodeNormalizeDigits(string $barcode): string { return preg_replace('/\D/', '', trim($barcode)); } /** EAN-13 / UPC-A variant barcodes to try against local DB and external APIs. */ function barcodeLookupCandidates(string $barcode): array { $barcode = barcodeNormalizeDigits($barcode); if ($barcode === '') { return []; } $candidates = [$barcode]; if (strlen($barcode) === 12 && ctype_digit($barcode)) { $candidates[] = '0' . $barcode; } if (strlen($barcode) === 13 && $barcode[0] === '0') { $candidates[] = substr($barcode, 1); } return array_values(array_unique($candidates)); } function barcodeFindLocalProduct(PDO $db, string $barcode): ?array { $stmt = $db->prepare("SELECT * FROM products WHERE barcode = ?"); foreach (barcodeLookupCandidates($barcode) as $bc) { $stmt->execute([$bc]); $product = $stmt->fetch(PDO::FETCH_ASSOC); if ($product) { return $product; } } return null; } function barcodeCacheGet(PDO $db, string $barcode): ?array { $stmt = $db->prepare("SELECT found, source, payload, updated_at FROM barcode_cache WHERE barcode = ?"); $stmt->execute([$barcode]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $found = (int)$row['found'] === 1; if (!$found) { $age = time() - strtotime((string)$row['updated_at']); if ($age > 1800) { // 30 min negative cache return null; } return ['found' => false, 'source' => $row['source'] ?? 'cache']; } $payload = json_decode((string)$row['payload'], true); if (!is_array($payload)) { return null; } $payload['source'] = $row['source'] ?? ($payload['source'] ?? 'cache'); return $payload; } function barcodeCacheSet(PDO $db, string $barcode, array $payload, bool $found): void { $stmt = $db->prepare("INSERT INTO barcode_cache (barcode, found, source, payload, updated_at) VALUES (?, ?, ?, ?, datetime('now')) ON CONFLICT(barcode) DO UPDATE SET found = excluded.found, source = excluded.source, payload = excluded.payload, updated_at = excluded.updated_at"); $stmt->execute([ $barcode, $found ? 1 : 0, $payload['source'] ?? ($found ? 'external' : 'miss'), json_encode($payload, JSON_UNESCAPED_UNICODE), ]); } /** Parallel HTTP GET — returns map key => body (or null). */ function barcodeHttpParallel(array $requests, int $timeoutSec = 4): array { if (empty($requests)) { return []; } $mh = curl_multi_init(); $handles = []; foreach ($requests as $key => $url) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeoutSec, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_HTTPHEADER => ['User-Agent: EverShelf/1.0'], CURLOPT_FOLLOWLOCATION => true, ]); curl_multi_add_handle($mh, $ch); $handles[$key] = $ch; } $running = null; do { $status = curl_multi_exec($mh, $running); if ($running && $status === CURLM_OK) { curl_multi_select($mh, 0.15); } } while ($running > 0); $out = []; foreach ($handles as $key => $ch) { $body = curl_multi_getcontent($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $out[$key] = ($body !== false && $body !== '' && $code >= 200 && $code < 300) ? $body : null; curl_multi_remove_handle($mh, $ch); curl_close($ch); } curl_multi_close($mh); return $out; } function _parseOffProductJson(?string $json): ?array { if (!$json) { return null; } $data = json_decode($json, true); if (!isset($data['status']) || (int)$data['status'] !== 1 || empty($data['product'])) { return null; } $p = $data['product']; $name = ''; foreach (['product_name_it', 'generic_name_it', 'product_name', 'generic_name'] as $f) { if (!empty($p[$f])) { $name = $p[$f]; break; } } if ($name === '') { return null; } if (preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $name)) { $latinName = ''; foreach (['generic_name_it', 'generic_name', 'product_name_it', 'product_name'] as $f) { if (!empty($p[$f]) && !preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $p[$f])) { $latinName = $p[$f]; break; } } $name = $latinName !== '' ? $latinName : (!empty($p['brands']) ? $p['brands'] : 'Prodotto sconosciuto'); } $ingredients = $p['ingredients_text_it'] ?? $p['ingredients_text'] ?? ''; $catHierarchy = $p['categories_hierarchy'] ?? []; $category = $p['categories_tags'][0] ?? (empty($catHierarchy) ? null : end($catHierarchy)) ?? $p['categories'] ?? ''; $allergens = ''; if (!empty($p['allergens_tags'])) { $allergens = implode(', ', array_map(fn($a) => str_replace('en:', '', $a), $p['allergens_tags'])); } $nutriments = null; if (!empty($p['nutriments']) && is_array($p['nutriments'])) { $nm = $p['nutriments']; $nutriments = [ 'energy_kcal_100g' => isset($nm['energy-kcal_100g']) ? round((float)$nm['energy-kcal_100g'], 1) : (isset($nm['energy_100g']) ? round((float)$nm['energy_100g'] / 4.184, 1) : null), 'proteins_100g' => isset($nm['proteins_100g']) ? round((float)$nm['proteins_100g'], 1) : null, 'carbohydrates_100g' => isset($nm['carbohydrates_100g']) ? round((float)$nm['carbohydrates_100g'], 1) : null, 'fat_100g' => isset($nm['fat_100g']) ? round((float)$nm['fat_100g'], 1) : null, 'fiber_100g' => isset($nm['fiber_100g']) ? round((float)$nm['fiber_100g'], 1) : null, 'salt_100g' => isset($nm['salt_100g']) ? round((float)$nm['salt_100g'], 1) : null, ]; if (!array_filter(array_values($nutriments))) { $nutriments = null; } } return [ 'name' => $name, 'brand' => $p['brands'] ?? '', 'category' => $category, 'image_url' => $p['image_front_small_url'] ?? $p['image_url'] ?? '', 'quantity_info' => $p['quantity'] ?? '', 'nutriscore' => $p['nutriscore_grade'] ?? '', 'ingredients' => $ingredients, 'allergens' => $allergens, 'conservation' => $p['conservation_conditions_it'] ?? $p['conservation_conditions'] ?? '', 'origin' => $p['origins_it'] ?? $p['origins'] ?? $p['manufacturing_places'] ?? '', 'nova_group' => $p['nova_group'] ?? '', 'ecoscore' => $p['ecoscore_grade'] ?? '', 'labels' => $p['labels'] ?? '', 'stores' => $p['stores'] ?? '', 'nutriments' => $nutriments, ]; } function _parseAltFactsProductJson(?string $json): ?array { if (!$json) { return null; } $data = json_decode($json, true); if (!isset($data['status']) || (int)$data['status'] !== 1 || empty($data['product'])) { return null; } $p = $data['product']; $altName = $p['product_name_it'] ?? $p['product_name'] ?? ''; if ($altName === '') { return null; } $altCat = $p['categories_tags'][0] ?? end($p['categories_hierarchy'] ?? []) ?? ''; return [ 'name' => $altName, 'brand' => $p['brands'] ?? '', 'category' => $altCat, 'image_url' => $p['image_front_small_url'] ?? $p['image_url'] ?? '', 'quantity_info' => $p['quantity'] ?? '', 'nutriscore' => '', 'ingredients' => '', 'allergens' => '', 'conservation' => '', 'origin' => '', 'nova_group' => '', 'ecoscore' => '', 'labels' => '', 'stores' => '', ]; } function _parseUpcItemDbJson(?string $json): ?array { if (!$json) { return null; } $data = json_decode($json, true); if (empty($data['items'][0])) { return null; } $item = $data['items'][0]; if (empty($item['title'])) { return null; } return [ 'name' => $item['title'] ?? '', 'brand' => $item['brand'] ?? '', 'category' => $item['category'] ?? '', 'image_url' => $item['images'][0] ?? '', 'quantity_info' => '', 'nutriscore' => '', 'ingredients' => '', 'allergens' => '', 'conservation' => '', 'origin' => '', 'nova_group' => '', 'ecoscore' => '', 'labels' => '', 'stores' => '', ]; } /** * Query all external barcode DBs in parallel (first wave per candidate, then Gemini). */ function barcodeResolveExternal(PDO $db, string $barcode): ?array { $barcode = barcodeNormalizeDigits($barcode); if ($barcode === '') { return null; } $cached = barcodeCacheGet($db, $barcode); if ($cached !== null) { return $cached['found'] ? $cached : null; } $offFields = 'product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores,nutriments'; $altFields = 'product_name,product_name_it,brands,categories_tags,categories_hierarchy,image_front_small_url,image_url,quantity'; $priority = ['off_it', 'off_world', 'opf', 'obf', 'upc']; foreach (barcodeLookupCandidates($barcode) as $bc) { $requests = [ 'off_it' => "https://world.openfoodfacts.org/api/v2/product/{$bc}.json?fields={$offFields}&lc=it", 'off_world' => "https://world.openfoodfacts.org/api/v2/product/{$bc}.json?fields={$offFields}", 'upc' => "https://api.upcitemdb.com/prod/trial/lookup?upc={$bc}", 'opf' => "https://world.openproductsfacts.org/api/v2/product/{$bc}.json?fields={$altFields}", 'obf' => "https://world.openbeautyfacts.org/api/v2/product/{$bc}.json?fields={$altFields}", ]; $bodies = barcodeHttpParallel($requests, 4); foreach ($priority as $key) { $body = $bodies[$key] ?? null; $product = null; $source = null; if ($key === 'off_it' || $key === 'off_world') { $product = _parseOffProductJson($body); $source = $key === 'off_it' ? 'openfoodfacts_it' : 'openfoodfacts'; } elseif ($key === 'opf') { $product = _parseAltFactsProductJson($body); $source = 'openproductsfacts'; } elseif ($key === 'obf') { $product = _parseAltFactsProductJson($body); $source = 'openbeautyfacts'; } elseif ($key === 'upc') { $product = _parseUpcItemDbJson($body); $source = 'upcitemdb'; } if ($product) { $result = ['found' => true, 'source' => $source, 'product' => $product]; barcodeCacheSet($db, $barcode, $result, true); return $result; } } } $apiKey = env('GEMINI_API_KEY'); if ($apiKey) { $geminiProduct = _barcodeLookupGemini($barcode, $apiKey); if ($geminiProduct !== null) { $result = ['found' => true, 'source' => 'gemini', 'product' => $geminiProduct]; barcodeCacheSet($db, $barcode, $result, true); return $result; } } barcodeCacheSet($db, $barcode, ['found' => false, 'source' => 'miss'], false); return null; } /** Local DB first, then parallel external lookup — single round-trip for the client. */ function resolveBarcode(PDO $db): void { $barcode = barcodeNormalizeDigits($_GET['barcode'] ?? ''); if ($barcode === '') { echo json_encode(['found' => false, 'error' => 'No barcode provided']); return; } $local = barcodeFindLocalProduct($db, $barcode); if ($local) { echo json_encode(['found' => true, 'source' => 'local', 'product' => $local], JSON_UNESCAPED_UNICODE); return; } $external = barcodeResolveExternal($db, $barcode); if ($external) { echo json_encode($external, JSON_UNESCAPED_UNICODE); return; } echo json_encode(['found' => false, 'source' => 'none']); } /** * Returns all in-stock inventory items whose product name shares the same first * significant token as the given name (e.g. "Carote" matches "Carote Bio", "Carote DOP"). * Used by the scan UI to show "you already have X in pantry" before adding a product. */ function stockForName(PDO $db): void { $name = trim($_GET['name'] ?? ''); if (empty($name)) { echo json_encode(['items' => []]); return; } $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']; $tokenize = function(string $s) use ($stop): array { $clean = mb_strtolower(preg_replace('/[^\p{L}0-9\s]/u', ' ', $s)); return array_values(array_filter( preg_split('/\s+/', trim($clean)), fn($t) => mb_strlen($t) > 2 && !in_array($t, $stop) )); }; $searchTokens = $tokenize($name); if (empty($searchTokens)) { echo json_encode(['items' => []]); return; } $firstToken = $searchTokens[0]; $rows = $db->query( "SELECT i.quantity, i.unit, i.location, p.name AS product_name, p.brand, p.default_quantity, p.package_unit FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 ORDER BY p.name" )->fetchAll(PDO::FETCH_ASSOC); $matches = []; foreach ($rows as $row) { $rowTokens = $tokenize($row['product_name']); if (empty($rowTokens)) continue; if ($rowTokens[0] === $firstToken) { $matches[] = [ 'name' => $row['product_name'], 'brand' => $row['brand'] ?? '', 'quantity' => (float)$row['quantity'], 'unit' => $row['unit'], 'location' => $row['location'] ?? '', 'default_quantity' => (int)($row['default_quantity'] ?? 0), 'package_unit' => $row['package_unit'] ?? '', ]; } } echo json_encode(['items' => $matches], JSON_UNESCAPED_UNICODE); } function lookupBarcode(): void { $barcode = barcodeNormalizeDigits($_GET['barcode'] ?? ''); if ($barcode === '') { EverLog::info('lookupBarcode'); echo json_encode(['found' => false, 'error' => 'No barcode provided']); return; } $db = getDB(); $external = barcodeResolveExternal($db, $barcode); if ($external) { echo json_encode($external, JSON_UNESCAPED_UNICODE); return; } echo json_encode(['found' => false, 'source' => 'none']); } /** * Ask Gemini to identify a product by barcode number. * Only used as a last resort when all open databases fail. * Returns null if Gemini doesn't know the product. */ function _barcodeLookupGemini(string $barcode, string $apiKey): ?array { $payload = [ 'contents' => [[ 'role' => 'user', 'parts' => [[ 'text' => "You are a product database. A user scanned barcode: {$barcode}\n" . "Identify this product. If you know it, respond with ONLY valid JSON (no markdown, no explanation):\n" . "{\"name\":\"...\",\"brand\":\"...\",\"category\":\"...\"}\n" . "Use the Italian product name if the product is sold in Italy.\n" . "If you do not know this specific barcode, respond with: {\"unknown\":true}" ]], ]], 'generationConfig' => [ 'temperature' => 0, 'maxOutputTokens' => 150, 'responseMimeType' => 'application/json', ], ]; $result = callGeminiWithFallback($apiKey, $payload, 10); if (!$result) return null; $text = ''; foreach ($result['candidates'][0]['content']['parts'] ?? [] as $part) { $text .= ($part['text'] ?? ''); } $text = trim($text); if (empty($text)) return null; $data = json_decode($text, true); if (!$data || !empty($data['unknown']) || empty($data['name'])) return null; return [ 'name' => $data['name'], 'brand' => $data['brand'] ?? '', 'category' => $data['category'] ?? '', 'image_url' => '', 'quantity_info' => '', 'nutriscore' => '', 'ingredients' => '', 'allergens' => '', 'conservation' => '', 'origin' => '', 'nova_group' => '', 'ecoscore' => '', 'labels' => '', 'stores' => '', ]; } function saveProduct(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); if (!$input || empty($input['name'])) { EverLog::info('saveProduct'); http_response_code(400); echo json_encode(['error' => 'Product name is required']); return; } // Auto-compute shopping_name unless the caller explicitly provides one. // A caller may pass shopping_name=null or omit it to always trigger auto-compute. $shoppingName = array_key_exists('shopping_name', $input) && $input['shopping_name'] !== null && $input['shopping_name'] !== '' ? $input['shopping_name'] : computeShoppingName($input['name'], $input['category'] ?? '', $input['brand'] ?? ''); // Sous-catégorie : validée et rendue obligatoire dynamiquement selon la config en base $category = $input['category'] ?? ''; $subcategory = trim($input['subcategory'] ?? ''); if ($subcategory !== '' && !isValidSubcategory($db, $category, $subcategory)) { $subcategory = ''; // invalide pour cette catégorie -> ignorée plutôt que de planter } if (in_array($category, getRequiredSubcategoryCategories($db), true) && $subcategory === '') { http_response_code(400); echo json_encode(['error' => 'subcategory_required', 'message' => 'Sous-catégorie requise pour cette catégorie']); return; } $subcategory = $subcategory !== '' ? $subcategory : null; $barcode = normalizeProductBarcode($input['barcode'] ?? null); $id = !empty($input['id']) ? (int)$input['id'] : 0; $merged = false; if ($barcode !== null) { $barcodeOwner = findDuplicateProductId($db, $input['name'], $input['brand'] ?? '', $barcode, $id ?: null); if ($barcodeOwner && (!$id || $barcodeOwner !== $id)) { if (!$id) { $id = $barcodeOwner; $merged = true; } else { http_response_code(409); echo json_encode([ 'success' => false, 'error' => 'barcode_already_used', 'existing_id' => $barcodeOwner, 'message' => 'Barcode already assigned to another product', ]); return; } } } if (!$id) { $dupId = findDuplicateProductId($db, $input['name'], $input['brand'] ?? '', $barcode, null); if ($dupId) { $id = $dupId; $merged = true; } } $nutriJson = isset($input['nutriments']) ? json_encode($input['nutriments']) : null; $params = [ $input['name'], $input['brand'] ?? '', $category, $subcategory, $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, $input['notes'] ?? '', $barcode, $input['package_unit'] ?? '', $shoppingName, $nutriJson, ]; try { if ($id) { $stmt = $db->prepare(" UPDATE products SET name=?, brand=?, category=?, subcategory=?, image_url=?, unit=?, default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?, nutriments_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=? "); $stmt->execute([...$params, $id]); echo json_encode(['success' => true, 'id' => $id, 'merged' => $merged]); return; } $stmt = $db->prepare(" INSERT INTO products (name, brand, category, subcategory, image_url, unit, default_quantity, notes, barcode, package_unit, shopping_name, nutriments_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute($params); echo json_encode(['success' => true, 'id' => (int)$db->lastInsertId(), 'merged' => false]); } catch (PDOException $e) { if (str_contains($e->getMessage(), 'UNIQUE constraint failed: products.barcode') && $barcode !== null) { $owner = findDuplicateProductId($db, $input['name'], $input['brand'] ?? '', $barcode, null); if ($owner) { $stmt = $db->prepare(" UPDATE products SET name=?, brand=?, category=?, subcategory=?, image_url=?, unit=?, default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?, nutriments_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=? "); $stmt->execute([...$params, $owner]); echo json_encode(['success' => true, 'id' => $owner, 'merged' => true]); return; } http_response_code(409); echo json_encode([ 'success' => false, 'error' => 'barcode_already_used', 'existing_id' => $owner, 'message' => 'Barcode already assigned to another product', ]); return; } throw $e; } } function getProduct(PDO $db): void { $id = $_GET['id'] ?? 0; $stmt = $db->prepare("SELECT * FROM products WHERE id = ?"); $stmt->execute([$id]); $product = $stmt->fetch(); if ($product) { EverLog::debug('getProduct'); echo json_encode(['success' => true, 'product' => $product]); } else { http_response_code(404); echo json_encode(['error' => 'Product not found']); } } function deleteProduct(PDO $db): void { EverLog::info('deleteProduct'); $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? 0; $stmt = $db->prepare("DELETE FROM products WHERE id = ?"); $stmt->execute([$id]); echo json_encode(['success' => true]); } function listProducts(PDO $db): void { $stmt = $db->query("SELECT * FROM products ORDER BY name ASC"); echo json_encode(['products' => $stmt->fetchAll()]); } function searchProducts(PDO $db): void { EverLog::debug('listProducts'); $q = $_GET['q'] ?? ''; $stmt = $db->prepare("SELECT * FROM products WHERE name LIKE ? OR brand LIKE ? OR barcode LIKE ? ORDER BY name ASC LIMIT 20"); $like = "%{$q}%"; $stmt->execute([$like, $like, $like]); echo json_encode(['products' => $stmt->fetchAll()]); } function searchInventoryProducts(PDO $db): void { EverLog::debug('searchInventoryProducts'); $q = trim((string)($_GET['q'] ?? '')); $limit = (int)($_GET['limit'] ?? 3); if ($limit < 1) $limit = 1; if ($limit > 10) $limit = 10; if ($q === '' || mb_strlen($q) < 2) { echo json_encode(['items' => []]); return; } $like = "%{$q}%"; $prefix = mb_strtolower($q) . '%'; $exact = mb_strtolower($q); $sql = " SELECT p.id, p.name, p.brand, p.category, p.barcode, p.image_url, p.unit, p.default_quantity, p.package_unit, p.notes, SUM(i.quantity) AS total_qty, GROUP_CONCAT(DISTINCT i.location) AS locations FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 AND (p.name LIKE ? OR p.brand LIKE ?) GROUP BY p.id ORDER BY CASE WHEN lower(p.name) = ? THEN 0 WHEN lower(p.name) LIKE ? THEN 1 ELSE 2 END, total_qty DESC, p.name ASC LIMIT {$limit} "; $stmt = $db->prepare($sql); $stmt->execute([$like, $like, $exact, $prefix]); echo json_encode(['items' => $stmt->fetchAll()]); } /** * AI identification helper: in-stock, finished (zero qty), and catalog matches by name. */ function aiProductSuggest(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $q = trim((string)($input['q'] ?? '')); $limit = (int)($input['limit'] ?? 5); if ($limit < 1) { $limit = 1; } if ($limit > 8) { $limit = 8; } if ($q === '' || mb_strlen($q) < 2) { echo json_encode(['success' => true, 'in_stock' => [], 'finished' => [], 'catalog' => []]); return; } $like = "%{$q}%"; $prefix = mb_strtolower($q) . '%'; $exact = mb_strtolower($q); $orderCase = " CASE WHEN lower(p.name) = ? THEN 0 WHEN lower(p.name) LIKE ? THEN 1 ELSE 2 END"; $inStockStmt = $db->prepare(" SELECT p.id, p.name, p.brand, p.category, p.barcode, p.image_url, p.unit, p.default_quantity, p.package_unit, p.notes, SUM(i.quantity) AS total_qty, GROUP_CONCAT(DISTINCT i.location) AS locations FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 AND (p.name LIKE ? OR p.brand LIKE ?) GROUP BY p.id ORDER BY {$orderCase}, total_qty DESC, p.name ASC LIMIT {$limit} "); $inStockStmt->execute([$like, $like, $exact, $prefix]); $inStock = $inStockStmt->fetchAll(PDO::FETCH_ASSOC); $inStockIds = array_map(fn($r) => (int)$r['id'], $inStock); $finishedStmt = $db->prepare(" SELECT p.id, p.name, p.brand, p.category, p.barcode, p.image_url, p.unit, p.default_quantity, p.package_unit, p.notes, COALESCE(SUM(CASE WHEN t.type = 'in' AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_in, COALESCE(SUM(CASE WHEN t.type IN ('out','waste') AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_out, COALESCE((SELECT SUM(i2.quantity) FROM inventory i2 WHERE i2.product_id = p.id), 0) AS stock_qty, (SELECT i4.location FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS location, (SELECT i4.updated_at FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS updated_at FROM products p LEFT JOIN transactions t ON t.product_id = p.id WHERE (p.name LIKE ? OR p.brand LIKE ?) GROUP BY p.id HAVING stock_qty <= 0.001 AND total_in > 0 ORDER BY {$orderCase}, (total_in - total_out) DESC, p.name ASC LIMIT {$limit} "); $finishedStmt->execute([$like, $like, $exact, $prefix]); $finishedRows = $finishedStmt->fetchAll(PDO::FETCH_ASSOC); $finished = []; foreach ($finishedRows as $r) { $pid = (int)$r['id']; if (in_array($pid, $inStockIds, true)) { continue; } $expected = round((float)$r['total_in'] - (float)$r['total_out'], 3); $finished[] = [ 'id' => $pid, 'name' => $r['name'], 'brand' => $r['brand'] ?? '', 'category' => $r['category'] ?? '', 'barcode' => $r['barcode'] ?? '', 'image_url' => $r['image_url'] ?? '', 'unit' => $r['unit'] ?? 'pz', 'default_quantity' => $r['default_quantity'] ?? 1, 'package_unit' => $r['package_unit'] ?? '', 'notes' => $r['notes'] ?? '', 'location' => $r['location'] ?: 'dispensa', 'updated_at' => $r['updated_at'] ?? null, 'expected_qty' => $expected, 'ghost' => $expected > productQtyThreshold((string)($r['unit'] ?? 'pz')), ]; } $finishedIds = array_map(fn($r) => (int)$r['id'], $finished); $excludeIds = array_unique(array_merge($inStockIds, $finishedIds)); $excludePlaceholders = $excludeIds ? implode(',', array_fill(0, count($excludeIds), '?')) : ''; $catalogSql = " SELECT p.id, p.name, p.brand, p.category, p.barcode, p.image_url, p.unit, p.default_quantity, p.package_unit, p.notes FROM products p WHERE (p.name LIKE ? OR p.brand LIKE ?) AND NOT EXISTS ( SELECT 1 FROM inventory i WHERE i.product_id = p.id AND i.quantity > 0.001 )"; if ($excludePlaceholders) { $catalogSql .= " AND p.id NOT IN ({$excludePlaceholders})"; } $catalogSql .= " ORDER BY {$orderCase}, p.name ASC LIMIT {$limit}"; $catalogStmt = $db->prepare($catalogSql); $catalogParams = [$like, $like]; if ($excludeIds) { $catalogParams = array_merge($catalogParams, $excludeIds); } $catalogParams[] = $exact; $catalogParams[] = $prefix; $catalogStmt->execute($catalogParams); $catalog = $catalogStmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode([ 'success' => true, 'in_stock' => $inStock, 'finished' => $finished, 'catalog' => $catalog, ], JSON_UNESCAPED_UNICODE); } // ===== INVENTORY FUNCTIONS ===== function listInventory(PDO $db): void { EverLog::debug('listInventory'); $location = $_GET['location'] ?? ''; $query = " SELECT i.*, p.name, p.brand, p.category, p.subcategory, p.image_url, p.unit, p.barcode, p.default_quantity, p.package_unit, COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, i.opened_at, p.shopping_name FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.quantity > 0 "; $params = []; if (!empty($location)) { $query .= " AND i.location = ?"; $params[] = $location; } $query .= " ORDER BY p.name ASC"; $stmt = $db->prepare($query); $stmt->execute($params); $rows = $stmt->fetchAll(); EverLog::debug('inventory_list fetched', ['rows' => count($rows), 'location' => $location ?: 'all']); echo json_encode(['inventory' => $rows]); } function addToInventory(PDO $db): void { EverLog::info('addToInventory'); $input = json_decode(file_get_contents('php://input'), true); $productId = (int)($input['product_id'] ?? 0); $quantity = (float)($input['quantity'] ?? 1); $location = $input['location'] ?? 'dispensa'; $expiry = $input['expiry_date'] ?? null; $unit = $input['unit'] ?? null; if (!$productId) { EverLog::warn('addToInventory: product_id missing (400)'); http_response_code(400); echo json_encode(['error' => 'Product ID required']); return; } // Validate quantity bounds if ($quantity <= 0 || $quantity > 100000) { EverLog::warn('addToInventory: invalid quantity (400)'); http_response_code(400); echo json_encode(['error' => 'Invalid quantity']); return; } // Validate location if (!isValidLocation($db, $location)) { EverLog::warn('addToInventory: invalid location (400)'); http_response_code(400); echo json_encode(['error' => 'Invalid location']); return; } // If a different unit was specified, update the product's unit. // NOTE: default_quantity is the PACKAGE SIZE, not the quantity being added — // do NOT overwrite it here. It is managed via product_save / the edit form. if ($unit) { $stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$unit, $productId]); } else { // Auto-set default_quantity if product has none (first add sets package size) $stmt = $db->prepare("SELECT default_quantity, unit FROM products WHERE id = ?"); $stmt->execute([$productId]); $prod = $stmt->fetch(); if ($prod && (float)($prod['default_quantity'] ?? 0) == 0 && !in_array($prod['unit'], ['pz', 'conf'])) { $stmt = $db->prepare("UPDATE products SET default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$quantity, $productId]); } } // Update package info if conf $packageUnit = $input['package_unit'] ?? null; $packageSize = $input['package_size'] ?? null; if ($packageUnit !== null) { $stmt = $db->prepare("UPDATE products SET package_unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$packageUnit, $packageSize ?: 0, $productId]); } $vacuumSealed = (int)($input['vacuum_sealed'] ?? 0); $expiryUserSet = (int)($input['expiry_user_set'] ?? 0); // Check if a SEALED (not yet opened) row exists for this product+location. // We merge new stock into a sealed row only — never into an already-opened // pack, because that would conflate two physically distinct containers and // corrupt the opened_at timestamp tracking. $stmt = $db->prepare(" SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND opened_at IS NULL ORDER BY added_at ASC LIMIT 1 "); $stmt->execute([$productId, $location]); $existing = $stmt->fetch(); if ($existing) { // Merge into the existing sealed row $newQty = $existing['quantity'] + $quantity; $stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), vacuum_sealed = ?, expiry_user_set = CASE WHEN ? = 1 THEN 1 ELSE expiry_user_set END, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$newQty, $expiry, $vacuumSealed, $expiryUserSet, $existing['id']]); } else { $newQty = $quantity; // All existing rows (if any) are opened packs — insert a new sealed row $stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, expiry_user_set) VALUES (?, ?, ?, ?, ?, ?)"); $stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed, $expiryUserSet]); } // Get total across all locations $stmt = $db->prepare("SELECT SUM(quantity) FROM inventory WHERE product_id = ? AND quantity > 0"); $stmt->execute([$productId]); $totalQty = (float)($stmt->fetchColumn() ?: $newQty); // Get product unit info for display $stmt = $db->prepare("SELECT unit, default_quantity, package_unit FROM products WHERE id = ?"); $stmt->execute([$productId]); $prodInfo = $stmt->fetch(); // Log transaction $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location) VALUES (?, 'in', ?, ?)"); $stmt->execute([$productId, $quantity, $location]); $bringRemoval = bringRemoveProductFromList($db, $productId); echo json_encode([ 'success' => true, 'new_qty' => $newQty, 'total_qty' => $totalQty, 'unit' => $prodInfo['unit'] ?? 'pz', 'default_quantity' => (float)($prodInfo['default_quantity'] ?? 0), 'package_unit' => $prodInfo['package_unit'] ?? null, 'removed_from_bring' => !empty($bringRemoval['removed']), 'removed_names' => $bringRemoval['removed_names'] ?? [], ]); EverLog::info('inventory_add ok', [ 'product_id' => $productId, 'qty' => $quantity, 'location' => $location, 'removed_from_bring' => !empty($bringRemoval['removed']), 'removed_names' => $bringRemoval['removed_names'] ?? [], ]); bringMarkPurchasedForProduct($db, $productId); invalidateSmartShoppingCache(); } /** Waste transaction notes use format Buttato|reason_key (legacy: plain "Buttato"). */ function _isWasteNotes(string $notes): bool { return $notes === 'Buttato' || str_starts_with($notes, 'Buttato|'); } function _wasteReasonKey(string $notes): ?string { if ($notes === 'Buttato') { return 'unknown'; } if (preg_match('/^Buttato\|([a-z_]+)/', $notes, $m)) { return $m[1]; } return null; } function _loadWasteLearning(PDO $db): array { static $cache = null; if ($cache !== null) { return $cache; } $row = $db->query("SELECT value FROM app_settings WHERE key = 'waste_learning'")->fetchColumn(); $cache = ($row !== false && $row !== '') ? (json_decode((string)$row, true) ?: []) : []; return $cache; } function _saveWasteLearning(PDO $db, array $data): void { $stmt = $db->prepare("INSERT INTO app_settings (key, value, updated_at) VALUES ('waste_learning', ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"); $stmt->execute([json_encode($data, JSON_UNESCAPED_UNICODE)]); invalidateSmartShoppingCache(); } function _guessPreferredStorageLocation(string $name, string $category): string { $n = mb_strtolower($name . ' ' . $category); if (preg_match('/surgelat|gelato|congelat|frozen|piselli surg|spinaci surg|basilico surg/', $n)) { return 'freezer'; } if (preg_match('/latte|yogurt|formaggio|burro|panna|uova|insalata|rucola|spinaci|pollo|carne|pesce|prosciutto|salame|mortadella|bresaola|affettato/', $n)) { return 'frigo'; } return 'dispensa'; } function _applyWasteLearning(PDO $db, int $productId, string $reason, string $location, array $product): void { if ($reason === '' || $reason === 'other') { return; } $data = _loadWasteLearning($db); $pid = (string)$productId; if (!isset($data[$pid])) { $data[$pid] = []; } $data[$pid]['last_reason'] = $reason; $data[$pid]['last_at'] = time(); $data[$pid]['count_' . $reason] = (int)($data[$pid]['count_' . $reason] ?? 0) + 1; switch ($reason) { case 'expired': case 'spoiled': $data[$pid]['alert_days_sooner'] = min(5, (int)($data[$pid]['alert_days_sooner'] ?? 0) + 1); break; case 'wrong_location': $preferred = _guessPreferredStorageLocation($product['name'] ?? '', $product['category'] ?? ''); if ($preferred !== $location) { $data[$pid]['preferred_location'] = $preferred; } break; case 'kept_too_long': case 'forgotten': $data[$pid]['buy_smaller'] = true; $data[$pid]['max_suggested_pz'] = 2; break; case 'bought_too_much': $data[$pid]['buy_less'] = true; $data[$pid]['max_suggested_conf'] = 1; $data[$pid]['max_suggested_pz'] = 2; break; case 'bad_quality': $data[$pid]['buy_less'] = true; break; } _saveWasteLearning($db, $data); } function _maybeApplyWasteLearning(PDO $db, int $productId, string $notes, string $location): void { if (!_isWasteNotes($notes)) { return; } $reason = _wasteReasonKey($notes) ?? 'unknown'; $stmt = $db->prepare("SELECT name, category FROM products WHERE id = ?"); $stmt->execute([$productId]); $product = $stmt->fetch(PDO::FETCH_ASSOC); if (!$product) { return; } _applyWasteLearning($db, $productId, $reason, $location, $product); } function _applyWasteHintsToSuggestion(int $productId, $suggestedQty, string $suggestedUnit, array $wasteLearning): array { $hint = $wasteLearning[(string)$productId] ?? []; if ($suggestedQty === null || empty($hint)) { return [$suggestedQty, $suggestedUnit]; } if (!empty($hint['buy_less']) || !empty($hint['buy_smaller'])) { if ($suggestedUnit === 'conf') { $cap = (float)($hint['max_suggested_conf'] ?? 1); $suggestedQty = min((float)$suggestedQty, max(1.0, $cap)); } elseif ($suggestedUnit === 'pz') { $cap = (float)($hint['max_suggested_pz'] ?? 2); $suggestedQty = min((float)$suggestedQty, max(1.0, $cap)); } } return [$suggestedQty, $suggestedUnit]; } function useFromInventory(PDO $db): void { EverLog::info('useFromInventory'); $input = json_decode(file_get_contents('php://input'), true); $productId = $input['product_id'] ?? 0; $quantity = $input['quantity'] ?? 0; $useAll = $input['use_all'] ?? false; $location = $input['location'] ?? 'dispensa'; $notes = $input['notes'] ?? ''; if (!$productId) { EverLog::warn('useFromInventory: product_id missing (400)'); http_response_code(400); echo json_encode(['error' => 'Product ID required']); return; } try { dbWithRetry(function () use ($db, $productId, $quantity, $useAll, $location, $notes): void { useFromInventoryCore($db, $productId, $quantity, $useAll, $location, $notes); }); } catch (\PDOException $e) { EverLog::error('useFromInventory db error', ['msg' => $e->getMessage()]); http_response_code(500); echo json_encode(['success' => false, 'error' => 'Database busy — please retry']); } } function useFromInventoryCore(PDO $db, $productId, $quantity, $useAll, $location, $notes): void { // ── Server-side deduplication ───────────────────────────────────────── // Guard against accidental double-consume triggers (scale jitter, double tap, // delayed/offline replay burst). We only apply this stricter gate to manual // uses with empty notes, so recipe uses (notes="Ricetta: ...") remain unaffected. $dedupWindow = $useAll ? 60 : (($notes === '') ? 120 : 12); if ($useAll) { $dedup = $db->prepare( "SELECT id, quantity, created_at FROM transactions WHERE product_id = ? AND type IN ('out','waste') AND undone = 0 AND created_at >= datetime('now', '-' || ? || ' seconds') ORDER BY id DESC LIMIT 1" ); $dedup->execute([$productId, $dedupWindow]); } else { $dedup = $db->prepare( "SELECT id, quantity, created_at FROM transactions WHERE product_id = ? AND location = ? AND type IN ('out','waste') AND undone = 0 AND COALESCE(notes, '') = ? AND created_at >= datetime('now', '-' || ? || ' seconds') ORDER BY id DESC LIMIT 1" ); $dedup->execute([$productId, $location, $notes, $dedupWindow]); } $recent = $dedup->fetch(); if ($recent) { EverLog::warn('useFromInventory duplicate blocked', [ 'product_id' => $productId, 'location' => $location, 'use_all' => $useAll, 'window_s' => $dedupWindow, 'recent_tx_id' => $recent['id'] ?? null, 'recent_qty' => $recent['quantity'] ?? null, 'recent_created_at' => $recent['created_at'] ?? null, 'requested_qty' => $quantity, 'notes' => $notes, ]); echo json_encode([ 'success' => false, 'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.', 'duplicate' => true, ]); return; } // ───────────────────────────────────────────────────────────────────── // Handle "throw all from all locations" if ($useAll && $location === '__all__') { $stmt = $db->prepare("SELECT id, quantity, location FROM inventory WHERE product_id = ? AND quantity > 0"); $stmt->execute([$productId]); $allItems = $stmt->fetchAll(); $totalRemoved = 0; $explicitFinish = !_isWasteNotes($notes); foreach ($allItems as $item) { $totalRemoved += $item['quantity']; $type = _isWasteNotes($notes) ? 'waste' : 'out'; $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$productId, $type, $item['quantity'], $item['location'], $notes]); // User explicitly chose "use all/finished": do not keep qty=0 rows that // would trigger a redundant "are you sure it's finished" banner. if ($explicitFinish) { $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); $stmt->execute([$item['id']]); } else { $stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$item['id']]); } } _maybeApplyWasteLearning($db, (int)$productId, $notes, $location === '__all__' ? 'dispensa' : $location); echo json_encode(['success' => true, 'remaining' => 0, 'removed' => $totalRemoved]); return; } $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(); if (!$existing) { EverLog::warn('useFromInventory: product not found in inventory (404)'); http_response_code(404); echo json_encode(['error' => 'Product not found in inventory at this location']); return; } if ($useAll) { $quantity = $existing['quantity']; } // Auto-split conf products: separate whole confs from opened (fractional) part $openedId = null; $stmt2 = $db->prepare("SELECT name, category, unit, default_quantity, package_unit FROM products WHERE id = ?"); $stmt2->execute([$productId]); $prodInfo = $stmt2->fetch(); if ($prodInfo && $prodInfo['unit'] === 'conf' && $prodInfo['default_quantity'] > 0 && !$useAll) { $totalQty = (float)$existing['quantity']; $wholeConfs = floor($totalQty + 0.001); $fraction = round($totalQty - $wholeConfs, 6); // Has both whole and fractional, and we're using less than or equal to the fractional part if ($wholeConfs >= 1 && $fraction > 0.001 && $quantity <= $fraction + 0.001) { // Split: keep whole confs in original row, create new row for opened part $stmt3 = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt3->execute([$wholeConfs, $existing['id']]); // Get expiry and vacuum_sealed from original row $stmt3 = $db->prepare("SELECT expiry_date, vacuum_sealed FROM inventory WHERE id = ?"); $stmt3->execute([$existing['id']]); $origRow = $stmt3->fetch(); $newFraction = round($fraction - $quantity, 6); if ($newFraction > 0.001) { // 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")); // Respect original sealed expiry if it expires sooner if (!empty($origRow['expiry_date']) && strtotime($origRow['expiry_date']) < strtotime($openedExpiry)) { $openedExpiry = $origRow['expiry_date']; } $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(); } // Log transaction $type = _isWasteNotes($notes) ? 'waste' : 'out'; $stmt3 = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt3->execute([$productId, $type, $quantity, $location, $notes]); _maybeApplyWasteLearning($db, (int)$productId, $notes, $location); $remaining = $newFraction > 0.001 ? $newFraction : 0; // Skip the normal flow — jump to Bring! check and response goto afterDeduct; } } $newQty = max(0, $existing['quantity'] - $quantity); // Cap actual deducted quantity to what was available (prevent phantom over-deduction) $actualDeducted = min($quantity, $existing['quantity']); if ($newQty <= 0) { $stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$existing['id']]); } else { // Check if item is now opened (first use creates a fractional/partial package) $wasOpened = !empty($existing['opened_at']); $isNowOpened = false; $unit = $prodInfo['unit'] ?? 'pz'; $defQty = (float)($prodInfo['default_quantity'] ?? 0); if ($unit === 'conf') { // Opened = a fractional (non-integer) quantity remains $f = round($newQty - floor($newQty + 0.001), 6); if ($f > 0.001) $isNowOpened = true; } elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0) { // Opened = remaining qty is not a clean multiple of the package size $pkgRem = round($newQty - floor($newQty / $defQty + 0.001) * $defQty, 6); if ($pkgRem > $defQty * 0.01) $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")); // Respect original sealed expiry if it expires sooner if (!empty($existing['expiry_date']) && strtotime($existing['expiry_date']) < strtotime($openedExpiry)) { $openedExpiry = $existing['expiry_date']; } // Split opened portion from sealed packages into two separate rows: // closed packages stay at original location, opened portion is offered to move. if ($unit === 'conf') { $newWhole = (int)floor($newQty + 0.001); $newFrac = round($newQty - $newWhole, 6); if ($newFrac > 0.001 && $newWhole >= 1) { // Keep whole confs in original row (no opened_at, sealed expiry unchanged) $stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$newWhole, $existing['id']]); // New row for the opened fraction with short shelf-life expiry $stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)"); $stmt->execute([$productId, $location, $newFrac, $openedExpiry, $vacuum]); $openedId = (int)$db->lastInsertId(); } else { // Only the opened fraction remains (≤ 1 conf) — single row $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']]); } } elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0) { $newWholePkgs = (int)floor($newQty / $defQty + 0.001); $newRemainder = round($newQty - $newWholePkgs * $defQty, 6); if ($newRemainder > $defQty * 0.01 && $newWholePkgs >= 1) { // Keep whole packages in original row (no opened_at, sealed expiry unchanged) $stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$newWholePkgs * $defQty, $existing['id']]); // New row for the opened partial package with short shelf-life expiry $stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)"); $stmt->execute([$productId, $location, $newRemainder, $openedExpiry, $vacuum]); $openedId = (int)$db->lastInsertId(); } else { // Only the opened remainder (last package) — single row $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 = ?, 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 (actual amount removed, not requested) $type = _isWasteNotes($notes) ? 'waste' : 'out'; $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$productId, $type, $actualDeducted, $location, $notes]); _maybeApplyWasteLearning($db, (int)$productId, $notes, $location); // User explicitly chose "use all/finished": remove this row now instead of // leaving quantity=0 pending confirmation. if ($useAll && !_isWasteNotes($notes) && $newQty <= 0) { $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); $stmt->execute([$existing['id']]); } $remaining = $newQty; // Check if opened part remains (for non-split path, only when not already set by split above) if ($openedId === null && $remaining > 0 && $prodInfo) { $unitFb = $prodInfo['unit'] ?? ''; $defQtyFb = (float)($prodInfo['default_quantity'] ?? 0); if ($unitFb === 'conf') { $f = round($remaining - floor($remaining + 0.001), 6); if ($f > 0.001) $openedId = (int)$existing['id']; } elseif (in_array($unitFb, ['g','kg','ml','l']) && $defQtyFb > 0) { $pkgRemFb = round($remaining - floor($remaining / $defQtyFb + 0.001) * $defQtyFb, 6); if ($pkgRemFb > $defQtyFb * 0.01) $openedId = (int)$existing['id']; } } afterDeduct: // Auto-add to Bring! if product is completely finished (no inventory left anywhere) $addedToBring = false; if ($remaining <= 0) { $stmt = $db->prepare("SELECT SUM(quantity) as total FROM inventory WHERE product_id = ? AND quantity > 0"); $stmt->execute([$productId]); $totalLeft = (float)($stmt->fetchColumn() ?: 0); if ($totalLeft <= 0) { $bringResult = bringAddDepletedProduct($db, $productId); $addedToBring = !empty($bringResult['added']) || !empty($bringResult['updated']); } } try { bringSyncProductFromCache($db, $productId); } catch (Throwable $e) { EverLog::warn('bringSyncProductFromCache after deduct: ' . $e->getMessage()); } // Calculate total remaining across ALL locations (this product only) $stmt = $db->prepare("SELECT SUM(quantity) as total FROM inventory WHERE product_id = ? AND quantity > 0"); $stmt->execute([$productId]); $totalRemaining = round((float)($stmt->fetchColumn() ?: 0), 6); // Get product info for low-stock prompt $stmt = $db->prepare("SELECT name, brand, unit, default_quantity, package_unit, shopping_name FROM products WHERE id = ?"); $stmt->execute([$productId]); $prodInfo = $stmt->fetch(); // Also sum related products in the same shopping_name family (same unit) so that // e.g. "Uova Sfoglia Gialla" + "Uova biologiche" are evaluated together for low stock. $totalFamilyRemaining = $totalRemaining; if ($prodInfo) { $sNameKey = strtolower(trim($prodInfo['shopping_name'] ?? '')); $prodUnit = $prodInfo['unit'] ?? ''; if ($sNameKey !== '' && $prodUnit !== '') { $famStmt = $db->prepare(" SELECT SUM(i.quantity) FROM inventory i JOIN products p ON i.product_id = p.id WHERE LOWER(TRIM(p.shopping_name)) = ? AND i.product_id != ? AND p.unit = ? AND i.quantity > 0 "); $famStmt->execute([$sNameKey, $productId, $prodUnit]); $totalFamilyRemaining = round($totalRemaining + (float)($famStmt->fetchColumn() ?: 0), 6); } } $response = ['success' => true, 'remaining' => $remaining, 'added_to_bring' => $addedToBring, 'total_remaining' => $totalRemaining, 'total_family_remaining' => $totalFamilyRemaining]; if ($prodInfo) { $response['product_name'] = $prodInfo['name']; $response['product_brand'] = $prodInfo['brand'] ?: ''; $response['product_unit'] = $prodInfo['unit']; $response['product_default_qty'] = (float)($prodInfo['default_quantity'] ?: 0); $response['product_package_unit'] = $prodInfo['package_unit'] ?: ''; // Generic shopping name for Bring! (e.g. "Affettato" for "Mortadella IGP") $shopping = $prodInfo['shopping_name'] ?: computeShoppingName($prodInfo['name'], '', $prodInfo['brand']); $response['product_shopping_name'] = $shopping; } if ($openedId) { $response['opened_id'] = $openedId; $response['opened_vacuum_sealed'] = (int)($existing['vacuum_sealed'] ?? 0); } elseif ($remaining > 0 && isset($existing['id'])) { // Fallback: for any partial use (including pz items) where no dedicated // "opened" row was created, still provide the row ID so the UI can ask // about vacuum sealing the remaining portion. $response['opened_id'] = (int)$existing['id']; $response['opened_vacuum_sealed'] = (int)($existing['vacuum_sealed'] ?? 0); } echo json_encode($response); // Inventory changed — force smart-shopping recompute on next request invalidateSmartShoppingCache(); } function updateInventory(PDO $db): void { EverLog::info('updateInventory'); $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? 0; // Read current state before update (needed for transaction reconciliation) $prev = $db->prepare("SELECT quantity, location, product_id FROM inventory WHERE id = ?"); $prev->execute([$id]); $prevRow = $prev->fetch(PDO::FETCH_ASSOC); $fields = []; $params = []; if (isset($input['quantity'])) { $fields[] = "quantity = ?"; $params[] = $input['quantity']; } if (isset($input['location'])) { $fields[] = "location = ?"; $params[] = $input['location']; } if (isset($input['expiry_date'])) { $fields[] = "expiry_date = ?"; $params[] = $input['expiry_date'] ?: null; } if (array_key_exists('expiry_user_set', $input)) { $fields[] = "expiry_user_set = ?"; $params[] = (int)$input['expiry_user_set']; } if (isset($input['vacuum_sealed'])) { $fields[] = "vacuum_sealed = ?"; $params[] = (int)$input['vacuum_sealed']; } if (isset($input['opened_at_clear']) && $input['opened_at_clear']) { $fields[] = "opened_at = NULL"; } $fields[] = "updated_at = CURRENT_TIMESTAMP"; $params[] = $id; // Wrap all writes in a single transaction; retry on SQLITE_BUSY (cron + PWA overlap). dbWithRetry(function () use ($db, $fields, $params, $input, $prevRow, $id): void { $db->beginTransaction(); try { $stmt = $db->prepare("UPDATE inventory SET " . implode(', ', $fields) . " WHERE id = ?"); $stmt->execute($params); // Record a compensating transaction so anomaly detection stays accurate if (isset($input['quantity']) && $prevRow) { $oldQty = (float)$prevRow['quantity']; $newQty = (float)$input['quantity']; $diff = round($newQty - $oldQty, 6); $loc = $input['location'] ?? $prevRow['location']; $pid = (int)$prevRow['product_id']; if (abs($diff) > 0.001) { $txType = $diff > 0 ? 'in' : 'out'; $txQty = abs($diff); $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, '[Manual correction]')") ->execute([$pid, $txType, $txQty, $loc]); } } // Update unit on the product if provided. // When setting unit back to 'pz', also ensure default_quantity >= 1 so the // barcode-scan auto-detect (which only fires on default_quantity === 0) won't // silently revert the user's correction on the next scan. if (isset($input['unit']) && isset($input['product_id'])) { $newUnit = $input['unit']; if ($newUnit === 'pz') { $stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = CASE WHEN default_quantity < 1 THEN 1 ELSE default_quantity END, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); } else { $stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); } $stmt->execute([$newUnit, $input['product_id']]); } // Update package info if provided if (isset($input['package_unit']) && isset($input['product_id'])) { $stmt = $db->prepare("UPDATE products SET package_unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$input['package_unit'], $input['package_size'] ?? 0, $input['product_id']]); } $db->commit(); } catch (Throwable $e) { if ($db->inTransaction()) $db->rollBack(); throw $e; } }); // Real-time shopping sync: done after commit so DB lock is not held during HTTP call if (isset($input['quantity']) && $prevRow && abs((float)$input['quantity'] - (float)$prevRow['quantity']) > 0.001) { try { bringSyncProductFromCache($db, (int)$prevRow['product_id']); } catch (Throwable $e) {} // HA: stock update event $prodRow = $db->prepare("SELECT name FROM products WHERE id = ?")->execute([(int)$prevRow['product_id']]) ? $db->query("SELECT name FROM products WHERE id = " . (int)$prevRow['product_id'])->fetchColumn() : ''; _fireHaWebhook('stock_update', [ 'item' => (string)$prodRow, 'quantity' => (float)$input['quantity'], 'location' => $input['location'] ?? $prevRow['location'] ?? '', ]); } echo json_encode(['success' => true]); } function deleteInventory(PDO $db): void { EverLog::info('deleteInventory'); $input = json_decode(file_get_contents('php://input'), true); $id = (int)($input['id'] ?? 0); if (!$id) { http_response_code(400); echo json_encode(['error' => 'Inventory ID required']); return; } $stmt = $db->prepare("SELECT id, product_id, quantity, location FROM inventory WHERE id = ?"); $stmt->execute([$id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { http_response_code(404); echo json_encode(['error' => 'Inventory row not found']); return; } $qty = (float)$row['quantity']; if ($qty > 0.0001) { $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, ?)") ->execute([(int)$row['product_id'], $qty, $row['location'], '[Eliminazione inventario]']); } $db->prepare("DELETE FROM inventory WHERE id = ?")->execute([$id]); echo json_encode(['success' => true]); } function productQtyThreshold(string $unit): float { static $thresholds = ['g' => 20, 'ml' => 20, 'kg' => 0.02, 'l' => 0.02, 'conf' => 0.1, 'pz' => 0.5]; return $thresholds[$unit] ?? 0.5; } function normalizeProductBarcode($barcode): ?string { if ($barcode === null) { return null; } $barcode = trim((string)$barcode); return $barcode === '' ? null : $barcode; } function normalizeProductName(string $name): string { return mb_strtolower(trim($name)); } function normalizeProductBrand(string $brand): string { return mb_strtolower(trim($brand)); } function brandsCompatible(string $a, string $b): bool { $na = normalizeProductBrand($a); $nb = normalizeProductBrand($b); return $na === $nb || $na === '' || $nb === ''; } function findDuplicateProductId(PDO $db, string $name, string $brand, ?string $barcode, ?int $excludeId = null): ?int { if ($barcode !== null && trim($barcode) !== '') { $sql = "SELECT id FROM products WHERE barcode = ? AND barcode IS NOT NULL AND TRIM(barcode) != ''"; $params = [$barcode]; if ($excludeId) { $sql .= " AND id != ?"; $params[] = $excludeId; } $sql .= " ORDER BY id ASC LIMIT 1"; $stmt = $db->prepare($sql); $stmt->execute($params); $id = $stmt->fetchColumn(); if ($id) { return (int)$id; } } $nName = normalizeProductName($name); if ($nName === '') { return null; } $sql = "SELECT id, brand FROM products WHERE lower(trim(name)) = ?"; $params = [$nName]; if ($excludeId) { $sql .= " AND id != ?"; $params[] = $excludeId; } $stmt = $db->prepare($sql); $stmt->execute($params); $candidates = $stmt->fetchAll(PDO::FETCH_ASSOC); if (!$candidates) { return null; } $targetBrand = normalizeProductBrand($brand); $compatible = null; foreach ($candidates as $c) { $cBrand = normalizeProductBrand($c['brand'] ?? ''); if ($cBrand === $targetBrand) { return (int)$c['id']; } if ($compatible === null && brandsCompatible($brand, $c['brand'] ?? '')) { $compatible = (int)$c['id']; } } return $compatible; } function getProductLedgerBalance(PDO $db, int $productId): array { $stmt = $db->prepare(" SELECT COALESCE(SUM(CASE WHEN type = 'in' AND undone = 0 THEN quantity ELSE 0 END), 0) AS total_in, COALESCE(SUM(CASE WHEN type IN ('out','waste') AND undone = 0 THEN quantity ELSE 0 END), 0) AS total_out FROM transactions WHERE product_id = ? "); $stmt->execute([$productId]); $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0]; $stockStmt = $db->prepare("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = ?"); $stockStmt->execute([$productId]); return [ 'total_in' => (float)$row['total_in'], 'total_out' => (float)$row['total_out'], 'stock' => (float)$stockStmt->fetchColumn(), ]; } function mergeProducts(PDO $db, int $keepId, int $dropId): void { if ($keepId === $dropId) { return; } $check = $db->prepare("SELECT id FROM products WHERE id IN (?, ?)"); $check->execute([$keepId, $dropId]); if ($check->rowCount() < 2) { throw new RuntimeException('One or both products not found'); } $db->beginTransaction(); try { $db->prepare("UPDATE inventory SET product_id = ? WHERE product_id = ?")->execute([$keepId, $dropId]); $db->prepare("UPDATE transactions SET product_id = ? WHERE product_id = ?")->execute([$keepId, $dropId]); $db->prepare("DELETE FROM products WHERE id = ?")->execute([$dropId]); $db->commit(); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } throw $e; } } function mergeProduct(PDO $db): void { EverLog::info('mergeProduct'); $input = json_decode(file_get_contents('php://input'), true); $keepId = (int)($input['keep_id'] ?? $input['canonical_id'] ?? 0); $dropId = (int)($input['drop_id'] ?? $input['duplicate_id'] ?? 0); if (!$keepId || !$dropId) { http_response_code(400); echo json_encode(['error' => 'keep_id and drop_id required']); return; } try { mergeProducts($db, $keepId, $dropId); echo json_encode(['success' => true, 'keep_id' => $keepId, 'drop_id' => $dropId]); } catch (Throwable $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } } /** * Returns products whose ledger balance exceeds stock (including vanished rows). * transaction balance (total_in - total_out) is still significantly positive — * meaning the system suspects the product ran out prematurely (scale drift, * missed registration, deleted inventory row, etc.). * * Products where the balance is at/near zero are legitimately finished by the * user; those rows are silently deleted here (no banner needed). */ function getFinishedItems(PDO $db): void { EverLog::debug('getFinishedItems'); $rows = $db->query(" SELECT p.id AS product_id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, p.image_url, p.barcode, COALESCE(SUM(CASE WHEN t.type = 'in' AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_in, COALESCE(SUM(CASE WHEN t.type IN ('out','waste') AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_out, COALESCE((SELECT SUM(i2.quantity) FROM inventory i2 WHERE i2.product_id = p.id), 0) AS stock_qty, (SELECT COUNT(*) FROM inventory i3 WHERE i3.product_id = p.id) AS inv_rows, (SELECT i4.location FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS inv_location, (SELECT i4.updated_at FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS inv_updated, (SELECT t2.location FROM transactions t2 WHERE t2.product_id = p.id AND t2.undone = 0 ORDER BY t2.created_at DESC LIMIT 1) AS tx_location FROM products p LEFT JOIN transactions t ON t.product_id = p.id GROUP BY p.id HAVING stock_qty <= 0.001 AND total_in > 0 ORDER BY (total_in - total_out) DESC ")->fetchAll(PDO::FETCH_ASSOC); $suspicious = []; foreach ($rows as $r) { $expected = (float)$r['total_in'] - (float)$r['total_out']; $threshold = productQtyThreshold($r['unit']); if ($expected > $threshold) { $location = $r['inv_location'] ?: $r['tx_location'] ?: 'dispensa'; $suspicious[] = [ 'product_id' => (int)$r['product_id'], 'name' => $r['name'], 'brand' => $r['brand'], 'unit' => $r['unit'], 'default_quantity' => $r['default_quantity'], 'package_unit' => $r['package_unit'], 'image_url' => $r['image_url'], 'barcode' => $r['barcode'], 'location' => $location, 'updated_at' => $r['inv_updated'], 'expected_qty' => round($expected, 3), 'ghost' => true, 'vanished' => ((int)$r['inv_rows']) === 0, ]; } else { $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0") ->execute([$r['product_id']]); } } echo json_encode(['success' => true, 'finished' => $suspicious], JSON_UNESCAPED_UNICODE); } /** * Permanently reconcile a finished/ghost product: log the missing quantity as * an explicit out transaction, then delete any zero-qty inventory rows. */ function confirmFinished(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); $productId = (int)($input['product_id'] ?? 0); if (!$productId) { EverLog::info('confirmFinished'); http_response_code(400); echo json_encode(['error' => 'product_id required']); return; } $prod = $db->prepare("SELECT unit FROM products WHERE id = ?"); $prod->execute([$productId]); $unit = $prod->fetchColumn(); if (!$unit) { http_response_code(404); echo json_encode(['error' => 'Product not found']); return; } $bal = getProductLedgerBalance($db, $productId); $expected = $bal['total_in'] - $bal['total_out']; $threshold = productQtyThreshold((string)$unit); if ($expected > $threshold) { $locStmt = $db->prepare("SELECT location FROM inventory WHERE product_id = ? ORDER BY updated_at DESC LIMIT 1"); $locStmt->execute([$productId]); $location = $locStmt->fetchColumn(); if (!$location) { $locStmt = $db->prepare("SELECT location FROM transactions WHERE product_id = ? AND undone = 0 ORDER BY created_at DESC LIMIT 1"); $locStmt->execute([$productId]); $location = $locStmt->fetchColumn(); } $location = $location ?: 'dispensa'; $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, ?)") ->execute([$productId, round($expected, 3), $location, '[Riconciliazione] Confermato esaurito']); } $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0")->execute([$productId]); $bring = bringAddDepletedProduct($db, $productId); echo json_encode(['success' => true, 'bring' => $bring], JSON_UNESCAPED_UNICODE); } /** * Restore stock for a ghost product without adding a new purchase (in) transaction. */ function restoreGhostInventory(PDO $db): void { EverLog::info('restoreGhostInventory'); $input = json_decode(file_get_contents('php://input'), true); $productId = (int)($input['product_id'] ?? 0); $quantity = (float)($input['quantity'] ?? 0); $location = trim((string)($input['location'] ?? 'dispensa')) ?: 'dispensa'; if (!$productId || $quantity <= 0) { http_response_code(400); echo json_encode(['error' => 'product_id and quantity required']); return; } $prod = $db->prepare("SELECT id FROM products WHERE id = ?"); $prod->execute([$productId]); if (!$prod->fetchColumn()) { http_response_code(404); echo json_encode(['error' => 'Product not found']); return; } $stmt = $db->prepare(" SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND opened_at IS NULL ORDER BY CASE WHEN quantity > 0 THEN 0 ELSE 1 END, updated_at DESC LIMIT 1 "); $stmt->execute([$productId, $location]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row) { $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") ->execute([$quantity, (int)$row['id']]); $invId = (int)$row['id']; } else { $db->prepare("INSERT INTO inventory (product_id, location, quantity) VALUES (?, ?, ?)") ->execute([$productId, $location, $quantity]); $invId = (int)$db->lastInsertId(); } echo json_encode([ 'success' => true, 'inventory_id' => $invId, 'quantity' => $quantity, 'location' => $location, ]); } function inventorySummary(PDO $db): void { EverLog::debug('inventorySummary'); $stmt = $db->query(" SELECT i.location, COUNT(DISTINCT i.product_id) as product_count, SUM(i.quantity) as total_items FROM inventory i GROUP BY i.location "); echo json_encode(['summary' => $stmt->fetchAll()]); } // ===== TRANSACTION FUNCTIONS ===== function listTransactions(PDO $db): void { EverLog::debug('listTransactions'); $limit = (int)($_GET['limit'] ?? 50); $offset = (int)($_GET['offset'] ?? 0); $productId = $_GET['product_id'] ?? ''; $query = " SELECT t.*, p.name, p.brand, p.unit, p.default_quantity, p.package_unit FROM transactions t JOIN products p ON t.product_id = p.id "; $params = []; if (!empty($productId)) { $query .= " WHERE t.product_id = ?"; $params[] = $productId; } $query .= " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"; $params[] = $limit; $params[] = $offset; $stmt = $db->prepare($query); $stmt->execute($params); echo json_encode(['transactions' => $stmt->fetchAll()]); } /** * Undo a transaction (reverse its effect on inventory). * Only available within 24 hours of the original transaction. * - type='in' (add) → removes that quantity from inventory at the same location * - type='out'/'waste' → adds that quantity back to inventory at the same location * Marks the original as undone=1 and logs a counter-transaction with notes='[Annullato]'. */ function undoTransaction(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); $txId = (int)($input['id'] ?? 0); if (!$txId) { EverLog::info('undoTransaction'); http_response_code(400); echo json_encode(['error' => 'Transaction ID required']); return; } // Fetch original transaction $stmt = $db->prepare("SELECT t.*, p.name FROM transactions t JOIN products p ON t.product_id = p.id WHERE t.id = ?"); $stmt->execute([$txId]); $tx = $stmt->fetch(); if (!$tx) { EverLog::warn('undoTransaction: transaction not found (404)'); http_response_code(404); echo json_encode(['error' => 'Transaction not found']); return; } if ($tx['undone']) { echo json_encode(['error' => 'Transaction already undone', 'already_undone' => true]); return; } // Only allow within 24 hours $ageSeconds = time() - strtotime($tx['created_at'] . ' UTC'); if ($ageSeconds > 86400) { echo json_encode(['error' => 'Can only undo transactions within 24 hours', 'too_old' => true]); return; } $db->beginTransaction(); try { $productId = (int)$tx['product_id']; $quantity = (float)$tx['quantity']; $location = $tx['location'] ?: 'dispensa'; $type = $tx['type']; if ($type === 'in') { // Reverse an ADD: remove quantity from inventory $stmt2 = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0 ORDER BY quantity DESC LIMIT 1"); $stmt2->execute([$productId, $location]); $row = $stmt2->fetch(); if ($row) { $newQty = max(0, (float)$row['quantity'] - $quantity); if ($newQty <= 0) { $db->prepare("DELETE FROM inventory WHERE id = ?")->execute([$row['id']]); } else { $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")->execute([$newQty, $row['id']]); } } // Log counter-transaction $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, '[Undone]')")->execute([$productId, $quantity, $location]); } elseif ($type === 'out' || $type === 'waste') { // Reverse a USE: add quantity back to inventory $stmt2 = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? ORDER BY quantity DESC LIMIT 1"); $stmt2->execute([$productId, $location]); $row = $stmt2->fetch(); if ($row) { $db->prepare("UPDATE inventory SET quantity = quantity + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")->execute([$quantity, $row['id']]); } else { // No row at this location — create one without expiry $db->prepare("INSERT INTO inventory (product_id, location, quantity) VALUES (?, ?, ?)")->execute([$productId, $location, $quantity]); } // Log counter-transaction $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'in', ?, ?, '[Undone]')")->execute([$productId, $quantity, $location]); } // Mark original as undone $db->prepare("UPDATE transactions SET undone = 1 WHERE id = ?")->execute([$txId]); $db->commit(); echo json_encode(['success' => true, 'name' => $tx['name']]); } catch (Exception $e) { $db->rollBack(); EverLog::error('undoTransaction: DB error (500)'); http_response_code(500); echo json_encode(['error' => 'DB error: ' . $e->getMessage()]); _phpErrorReport($e->getMessage(), $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e)); } } // ===== STATS ===== /** * Detect inventory items where the stored quantity is significantly inconsistent * with the transaction history (sum of in - sum of out/waste). * * Two anomaly directions: * - PHANTOM (+diff): inventory > tx balance → quantity was manually inflated without an 'in' tx * - MISSING (-diff): inventory < tx balance → tx history says more should be here than stored */ function getInventoryAnomalies(PDO $db): void { EverLog::info('getInventoryAnomalies'); $rows = $db->query(" SELECT p.id AS product_id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, MIN(i.id) AS inventory_id, SUM(i.quantity) AS inv_qty, COALESCE(tx_in.tot, 0) AS total_in, COALESCE(tx_out.tot, 0) AS total_out FROM inventory i JOIN products p ON p.id = i.product_id LEFT JOIN ( SELECT product_id, SUM(quantity) AS tot FROM transactions WHERE type = 'in' AND undone = 0 GROUP BY product_id ) tx_in ON tx_in.product_id = p.id LEFT JOIN ( SELECT product_id, SUM(quantity) AS tot FROM transactions WHERE type IN ('out','waste') AND undone = 0 GROUP BY product_id ) tx_out ON tx_out.product_id = p.id WHERE i.quantity > 0 GROUP BY p.id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, tx_in.tot, tx_out.tot ")->fetchAll(PDO::FETCH_ASSOC); // Anomaly dismissed keys stored in a simple JSON file $dismissFile = __DIR__ . '/../data/anomaly_dismissed.json'; $dismissed = []; if (file_exists($dismissFile)) { $dismissed = json_decode(file_get_contents($dismissFile), true) ?: []; } $anomalies = []; foreach ($rows as $r) { $invQty = floatval($r['inv_qty']); $expected = floatval($r['total_in']) - floatval($r['total_out']); $diff = $invQty - $expected; // Threshold: difference must be >20% of inventory AND >50 units (avoid noise) $threshold = max(1.0, $invQty * 0.20); if (abs($diff) <= $threshold || abs($diff) <= 50) continue; // Dismiss key: stable identifier based on product_id + direction. // Previously used round($expected) which changed whenever transactions were added, // causing dismissed anomalies to reappear. Now anchored to direction only, // so it stays dismissed until the user explicitly resets or the direction changes. // An inventory correction (bringing qty closer to expected) will flip the direction // or drop below threshold — naturally clearing the dismissed state. // If expected <= 0 it means more consumption recorded than purchases — the // transaction history is simply incomplete (very common: users track consumption // but not always purchases). Showing an anomaly here is just noise, skip it. if ($expected <= 0) continue; $direction = $diff > 0 ? 'phantom' : 'missing'; $key = 'a_' . $r['product_id'] . '_' . $direction; if (!empty($dismissed[$key])) continue; $anomalies[] = [ 'inventory_id' => (int)$r['inventory_id'], 'product_id' => (int)$r['product_id'], 'name' => $r['name'], 'brand' => $r['brand'] ?: '', 'unit' => $r['unit'], 'default_quantity' => $r['default_quantity'], 'package_unit' => $r['package_unit'], 'inv_qty' => round($invQty, 2), 'expected_qty' => round($expected, 2), 'diff' => round($diff, 2), 'direction' => $direction, 'dismiss_key' => $key, ]; } // Sort: largest absolute diff first usort($anomalies, fn($a, $b) => abs($b['diff']) <=> abs($a['diff'])); echo json_encode(['success' => true, 'anomalies' => $anomalies], JSON_UNESCAPED_UNICODE); } /** * Detect likely "double consume" losses: * latest pair of out transactions for same product+location within 120s, * empty notes, current inventory at 0, and last tx at that location is out. */ function getDuplicateLossChecks(PDO $db): void { EverLog::info('getDuplicateLossChecks'); $sql = " WITH out_tx AS ( SELECT id, product_id, IFNULL(location, '') AS location, quantity, created_at, COALESCE(notes, '') AS notes FROM transactions WHERE type = 'out' AND undone = 0 ), pairs AS ( SELECT t1.product_id, t1.location, t1.id AS tx1, t2.id AS tx2, t1.quantity AS q1, t2.quantity AS q2, t2.created_at AS c2, ROUND((julianday(t2.created_at) - julianday(t1.created_at)) * 86400.0, 1) AS dt_sec FROM out_tx t1 JOIN out_tx t2 ON t2.product_id = t1.product_id AND t2.location = t1.location AND t2.id > t1.id AND (julianday(t2.created_at) - julianday(t1.created_at)) * 86400.0 BETWEEN 0 AND 120 WHERE TRIM(t1.notes) = '' AND TRIM(t2.notes) = '' ), latest_pair AS ( SELECT p.*, ROW_NUMBER() OVER (PARTITION BY p.product_id, p.location ORDER BY p.c2 DESC) AS rn FROM pairs p ), inv AS ( SELECT product_id, IFNULL(location, '') AS location, MIN(id) AS inventory_id, SUM(quantity) AS quantity FROM inventory GROUP BY product_id, IFNULL(location, '') ), last_tx AS ( SELECT product_id, IFNULL(location, '') AS location, type, created_at, ROW_NUMBER() OVER (PARTITION BY product_id, IFNULL(location, '') ORDER BY id DESC) AS rn FROM transactions WHERE undone = 0 ) SELECT p.id AS product_id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, lp.location, lp.tx1, lp.q1, lp.tx2, lp.q2, lp.dt_sec, lp.c2 AS latest_pair_at, IFNULL(inv.inventory_id, 0) AS inventory_id, IFNULL(inv.quantity, 0) AS inv_qty_now FROM latest_pair lp JOIN products p ON p.id = lp.product_id LEFT JOIN inv ON inv.product_id = lp.product_id AND inv.location = lp.location LEFT JOIN last_tx lt ON lt.product_id = lp.product_id AND lt.location = lp.location AND lt.rn = 1 WHERE lp.rn = 1 AND IFNULL(inv.quantity, 0) = 0 AND lt.type = 'out' ORDER BY lp.c2 DESC LIMIT 30 "; $rows = $db->query($sql)->fetchAll(PDO::FETCH_ASSOC) ?: []; $checks = array_map(function(array $r): array { return [ 'product_id' => (int)$r['product_id'], 'name' => (string)$r['name'], 'brand' => (string)($r['brand'] ?? ''), 'unit' => (string)($r['unit'] ?? 'pz'), 'default_quantity' => isset($r['default_quantity']) ? (float)$r['default_quantity'] : 0.0, 'package_unit' => (string)($r['package_unit'] ?? ''), 'location' => (string)($r['location'] ?? ''), 'tx1' => (int)$r['tx1'], 'q1' => (float)$r['q1'], 'tx2' => (int)$r['tx2'], 'q2' => (float)$r['q2'], 'dt_sec' => (float)$r['dt_sec'], 'latest_pair_at' => (string)$r['latest_pair_at'], 'inventory_id' => (int)$r['inventory_id'], 'inv_qty_now' => (float)$r['inv_qty_now'], 'dismiss_key' => 'dup_' . ((int)$r['product_id']) . '_' . md5((string)($r['location'] ?? '')), ]; }, $rows); echo json_encode(['success' => true, 'checks' => $checks], JSON_UNESCAPED_UNICODE); } /** * Dismiss a specific anomaly so it no longer appears in the banner. */ function dismissInventoryAnomaly(): void { $input = json_decode(file_get_contents('php://input'), true); $key = $input['dismiss_key'] ?? ''; if (empty($key) || !preg_match('/^a_\d+_-?\d+$/', $key)) { EverLog::info('dismissInventoryAnomaly'); echo json_encode(['success' => false, 'error' => 'Invalid key']); return; } $dismissFile = __DIR__ . '/../data/anomaly_dismissed.json'; $dismissed = []; if (file_exists($dismissFile)) { $dismissed = json_decode(file_get_contents($dismissFile), true) ?: []; } $dismissed[$key] = time(); // Clean up entries older than 90 days $dismissed = array_filter($dismissed, fn($ts) => $ts > time() - 90 * 86400); file_put_contents($dismissFile, json_encode($dismissed), LOCK_EX); echo json_encode(['success' => true]); } function getStats(PDO $db): void { EverLog::info('getStats'); // Consolidated summary query: totals + 7-day activity in a single round-trip $summary = $db->query(" SELECT (SELECT COUNT(*) FROM products) AS total_products, (SELECT COALESCE(SUM(quantity),0) FROM inventory) AS total_items, (SELECT COUNT(DISTINCT location) FROM inventory) AS total_locations, (SELECT COUNT(*) FROM transactions WHERE type='in' AND created_at >= datetime('now','-7 days')) AS recent_in, (SELECT COUNT(*) FROM transactions WHERE type='out' AND created_at >= datetime('now','-7 days')) AS recent_out ")->fetch(PDO::FETCH_ASSOC); $totalProducts = (int)$summary['total_products']; $totalItems = (float)$summary['total_items']; $locations = (int)$summary['total_locations']; $recentIn = (int)$summary['recent_in']; $recentOut = (int)$summary['recent_out']; // Expiring soonest (next 4 items to expire) $expiring = $db->query(" SELECT i.*, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit, COALESCE(i.vacuum_sealed, 0) as vacuum_sealed FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.expiry_date IS NOT NULL AND i.expiry_date >= date('now') AND i.quantity > 0 AND (i.opened_at IS NULL OR i.opened_at = '') ORDER BY i.expiry_date ASC LIMIT 4 ")->fetchAll(); // Expired — vacuum-sealed items get extra days beyond printed expiry before being flagged $vacExtDays = (int)env('VACUUM_EXPIRY_EXTENSION_DAYS', '30'); $expiredStmt = $db->prepare(" SELECT i.*, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit, COALESCE(i.vacuum_sealed, 0) as vacuum_sealed FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.expiry_date IS NOT NULL AND julianday('now') - julianday(i.expiry_date) > CASE WHEN COALESCE(i.vacuum_sealed,0)=1 THEN ? ELSE 0 END AND i.quantity > 0 ORDER BY i.expiry_date ASC "); $expiredStmt->execute([$vacExtDays]); $expired = $expiredStmt->fetchAll(); // Opened (items with opened_at set by the app, OR fractional-qty items as legacy fallback) // opened_at IS NOT NULL → already has recalculated expiry_date stored when first opened $openedRaw = $db->query(" SELECT i.*, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit, p.image_url, COALESCE(i.vacuum_sealed, 0) as vacuum_sealed FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.quantity > 0 AND ( -- Primary: tracked as opened by the app (expiry_date already recalculated) i.opened_at IS NOT NULL OR -- Fallback: fractional quantity pattern (legacy items before opened_at tracking) (p.default_quantity > 0 AND ( (p.unit = 'conf' AND p.package_unit IS NOT NULL AND CAST(i.quantity AS REAL) != CAST(CAST(i.quantity AS INTEGER) AS REAL)) OR (p.unit != 'conf' AND ABS(i.quantity - ROUND(CAST(i.quantity AS REAL) / p.default_quantity) * p.default_quantity) > (p.default_quantity * 0.02)) )) ) ")->fetchAll(); // Compute opened_expiry and days_to_expiry for each opened item $opened = []; $today = strtotime('today midnight'); foreach ($openedRaw as $item) { $vacuum = (int)($item['vacuum_sealed'] ?? 0); // originalExpiry = manufacturer date stored in inventory.expiry_date. // For items correctly managed, this is the sealed expiry from the package. $originalExpiry = !empty($item['expiry_date']) ? strtotime($item['expiry_date']) : null; if (!empty($item['opened_at'])) { // For conf unit: if all whole packages (no fraction), the opened_at tracks when the // last package was first used, but the remaining whole confs are still sealed. // Use the original package expiry, not the opened shelf-life. if ($item['unit'] === 'conf' && $originalExpiry !== null) { $qty = (float)$item['quantity']; $frac = round($qty - (float)(int)floor($qty + 0.001), 4); if ($frac < 0.001) { // All whole: treat as sealed — use original expiry $item['opened_expiry'] = $item['expiry_date'] ?? null; $item['days_to_expiry'] = (int)round(($originalExpiry - $today) / 86400); goto after_expiry; } } // Compute opened shelf-life using AI (with rule-based fallback + persistent cache). // The vacuum-sealed multiplier is already handled inside getOpenedShelfLifeDays. $openedDays = getOpenedShelfLifeDays($item['name'], $item['category'], $item['location'], (bool)$vacuum, false); $computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400; // Always respect the manufacturer date: if the package expires before our estimate, // use the manufacturer date (e.g., milk opened 2 days before its sealed expiry). $finalExpiry = ($originalExpiry !== null && $originalExpiry < $computedExpiry) ? $originalExpiry : $computedExpiry; $item['opened_expiry'] = date('Y-m-d', $finalExpiry); $item['days_to_expiry'] = (int)round(($finalExpiry - $today) / 86400); } else { after_expiry: // Legacy: no opened_at, use stored expiry_date as-is $item['opened_expiry'] = $item['expiry_date'] ?? null; $item['days_to_expiry'] = $originalExpiry !== null ? (int)round(($originalExpiry - $today) / 86400) : null; } $item['is_edible'] = $item['days_to_expiry'] === null || $item['days_to_expiry'] >= 0; $item['has_opened_at'] = !empty($item['opened_at']); // For conf items with opened_at that contain both whole and fractional confs: // split into a "sealed" entry (whole confs, package expiry) and an "opened" entry (fraction, shelf-life expiry). // This prevents a row like "1.59 conf" from showing a single misleading entry that mixes // a still-sealed package with an opened portion. if ($item['unit'] === 'conf' && $item['has_opened_at'] && $originalExpiry !== null) { $qty = (float)$item['quantity']; $whole = (int)floor($qty + 0.001); $frac = round($qty - (float)$whole, 4); if ($whole >= 1 && $frac >= 0.001) { // Sealed whole confs: show with original package expiry (only if near expiry ≤ 7 d) $sealedDays = (int)round(($originalExpiry - $today) / 86400); if ($sealedDays <= 7 && $sealedDays >= -30) { $si = $item; $si['quantity'] = (float)$whole; $si['opened_at'] = null; $si['opened_expiry'] = date('Y-m-d', $originalExpiry); $si['days_to_expiry'] = $sealedDays; $si['is_edible'] = $sealedDays >= 0; $si['has_opened_at'] = false; $opened[] = $si; } // Opened fractional part: use the already-computed opened shelf-life expiry if ($item['days_to_expiry'] === null || $item['days_to_expiry'] <= 365) { $fi = $item; $fi['quantity'] = $frac; $opened[] = $fi; } continue; } } // Hide non-perishable items (salt, sugar, spirits, oil, etc.) — they won't expire usefully if ($item['days_to_expiry'] !== null && $item['days_to_expiry'] > 365) continue; // Hide legacy fractional items (no opened_at) with far-off expiry — not useful for home widget if (!$item['has_opened_at'] && ($item['days_to_expiry'] === null || $item['days_to_expiry'] > 14)) continue; $opened[] = $item; } // Sort by days_to_expiry ascending (soonest first; nulls last) usort($opened, function($a, $b) { $da = $a['days_to_expiry']; $db2 = $b['days_to_expiry']; if ($da === null && $db2 === null) return 0; if ($da === null) return 1; if ($db2 === null) return -1; return $da <=> $db2; }); // Waste vs consumption trend (3 × 30-day buckets) $wasteStats3m = $db->query(" SELECT type, SUM(CASE WHEN created_at >= datetime('now', '-30 days') THEN 1 ELSE 0 END) AS m0, SUM(CASE WHEN created_at >= datetime('now', '-60 days') AND created_at < datetime('now', '-30 days') THEN 1 ELSE 0 END) AS m1, SUM(CASE WHEN created_at >= datetime('now', '-90 days') AND created_at < datetime('now', '-60 days') THEN 1 ELSE 0 END) AS m2 FROM transactions WHERE type IN ('out', 'waste') AND created_at >= datetime('now', '-90 days') GROUP BY type ")->fetchAll(); $used30 = 0; $wasted30 = 0; $usedP30 = 0; $wastedP30 = 0; $usedP60 = 0; $wastedP60 = 0; foreach ($wasteStats3m as $ws) { if ($ws['type'] === 'out') { $used30 = (int)$ws['m0']; $usedP30 = (int)$ws['m1']; $usedP60 = (int)$ws['m2']; } if ($ws['type'] === 'waste') { $wasted30 = (int)$ws['m0']; $wastedP30 = (int)$ws['m1']; $wastedP60 = (int)$ws['m2']; } } echo json_encode([ 'total_products' => (int)$totalProducts, 'total_items' => (float)$totalItems, 'locations' => (int)$locations, 'recent_in' => (int)$recentIn, 'recent_out' => (int)$recentOut, 'expiring_soon' => $expiring, 'expired' => $expired, 'opened' => $opened, 'used_30d' => $used30, 'wasted_30d' => $wasted30, 'used_prev_30d' => $usedP30, 'wasted_prev_30d' => $wastedP30, 'used_prev_60d' => $usedP60, 'wasted_prev_60d' => $wastedP60, ]); } // ===== MONTHLY STATS ===== /** * Normalize a raw category string (may contain OpenFoodFacts "en:slug" format) * to one of the app's known Italian category slugs. */ function _normalizeCat(string $raw): string { static $known = [ 'frutta','verdura','carne','pesce','latticini', 'pasta','pane','cereali','bevande','condimenti', 'surgelati','conserve','snack','altro', ]; $raw = trim($raw); if (in_array($raw, $known, true)) return $raw; // Strip language prefix: "en:", "it:", "fr:", etc. $slug = (string)preg_replace('/^[a-z]{2}:/', '', $raw); if (in_array($slug, $known, true)) return $slug; // Map common OpenFoodFacts slugs → app categories static $map = [ // latticini 'dairies'=>'latticini','dairy'=>'latticini','milk'=>'latticini', 'fermented-milk-products'=>'latticini','cheeses'=>'latticini', 'yogurts'=>'latticini','plant-based-milks'=>'latticini', 'cream'=>'latticini','butter'=>'latticini','eggs'=>'latticini', // frutta 'fruits'=>'frutta','fresh-fruits'=>'frutta','tropical-fruits'=>'frutta', 'dried-fruits'=>'frutta','berries'=>'frutta', // verdura 'vegetables'=>'verdura','fresh-vegetables'=>'verdura', 'plant-based-foods'=>'verdura','legumes'=>'verdura', 'mushrooms'=>'verdura','herbs'=>'verdura', // carne 'meats'=>'carne','beef'=>'carne','pork'=>'carne', 'poultry'=>'carne','chicken'=>'carne','processed-meat'=>'carne', 'sausages'=>'carne','charcuterie'=>'carne', // pesce 'fish'=>'pesce','seafood'=>'pesce','fish-products'=>'pesce', 'canned-fish'=>'conserve', // pasta 'pastas'=>'pasta','pasta'=>'pasta','pasta-based-dishes'=>'pasta', 'noodles'=>'pasta', // pane 'breads'=>'pane','bread'=>'pane','baked-goods'=>'pane', 'pastries'=>'pane','cakes'=>'snack', // cereali 'cereals'=>'cereali','breakfast-cereals'=>'cereali', 'rice'=>'cereali','grains'=>'cereali','flours'=>'cereali', 'seeds'=>'cereali', // bevande 'beverages'=>'bevande','drinks'=>'bevande','waters'=>'bevande', 'juices'=>'bevande','fruit-juices'=>'bevande','sodas'=>'bevande', 'plant-based-foods-and-beverages'=>'bevande','coffee'=>'bevande', 'tea'=>'bevande','alcoholic-beverages'=>'bevande','wine'=>'bevande', 'beer'=>'bevande', // condimenti 'sauces'=>'condimenti','condiments'=>'condimenti', 'spreads'=>'condimenti','oils'=>'condimenti', 'vinegars'=>'condimenti','dressings'=>'condimenti', 'sugar'=>'condimenti','salt'=>'condimenti','spices'=>'condimenti', // surgelati 'frozen-foods'=>'surgelati','frozen-vegetables'=>'surgelati', 'frozen-fish'=>'surgelati','ice-cream'=>'surgelati', // conserve 'preserved-foods'=>'conserve','canned-foods'=>'conserve', 'jams'=>'conserve','pickles'=>'conserve','tomato-sauces'=>'conserve', // snack 'snacks'=>'snack','cookies'=>'snack','chips'=>'snack', 'chocolates'=>'snack','candies'=>'snack','sweets'=>'snack', 'crackers'=>'snack','biscuits'=>'snack','nuts'=>'snack', ]; return $map[$slug] ?? $map[strtolower($slug)] ?? 'altro'; } function getMonthlyStats(PDO $db): void { EverLog::debug('getMonthlyStats'); $thisMonthStart = date('Y-m-01'); $lastMonthStart = date('Y-m-01', strtotime('first day of last month')); $lastMonthEnd = date('Y-m-01'); // exclusive upper bound for prev month // Totals: consumed + added + wasted this month vs previous calendar month $totals = $db->query(" SELECT SUM(CASE WHEN created_at >= '{$thisMonthStart}' AND type IN ('out','waste') AND undone=0 THEN 1 ELSE 0 END) AS this_out, SUM(CASE WHEN created_at >= '{$lastMonthStart}' AND created_at < '{$lastMonthEnd}' AND type IN ('out','waste') AND undone=0 THEN 1 ELSE 0 END) AS prev_out, SUM(CASE WHEN created_at >= '{$thisMonthStart}' AND type = 'in' AND undone=0 THEN 1 ELSE 0 END) AS this_in, SUM(CASE WHEN created_at >= '{$thisMonthStart}' AND type = 'waste' AND undone=0 THEN 1 ELSE 0 END) AS this_wasted FROM transactions WHERE created_at >= '{$lastMonthStart}' ")->fetch(PDO::FETCH_ASSOC); $thisOut = (int)($totals['this_out'] ?? 0); $prevOut = (int)($totals['prev_out'] ?? 0); $thisIn = (int)($totals['this_in'] ?? 0); $thisWaste = (int)($totals['this_wasted'] ?? 0); // Top categories consumed this month $catRows = $db->query(" SELECT COALESCE(NULLIF(TRIM(p.category), ''), 'altro') AS cat, COUNT(*) AS cnt FROM transactions t JOIN products p ON t.product_id = p.id WHERE t.type IN ('out','waste') AND t.undone = 0 AND t.created_at >= '{$thisMonthStart}' GROUP BY cat ORDER BY cnt DESC LIMIT 5 ")->fetchAll(PDO::FETCH_ASSOC); $totalCatEvents = array_sum(array_column($catRows, 'cnt')) ?: 1; // Normalize OFF slugs (e.g. "en:dairies" → "latticini"), then re-aggregate $normAgg = []; foreach ($catRows as $r) { $norm = _normalizeCat((string)$r['cat']); $normAgg[$norm] = ($normAgg[$norm] ?? 0) + (int)$r['cnt']; } arsort($normAgg); $normAgg = array_slice($normAgg, 0, 4, true); $totalNorm = array_sum($normAgg) ?: 1; $topCats = array_map(fn($cat, $cnt) => [ 'cat' => $cat, 'count' => $cnt, 'pct' => (int)round($cnt / $totalNorm * 100), ], array_keys($normAgg), array_values($normAgg)); // Top consumed products this month $topProds = $db->query(" SELECT p.name, COUNT(*) AS cnt FROM transactions t JOIN products p ON t.product_id = p.id WHERE t.type IN ('out','waste') AND t.undone = 0 AND t.created_at >= '{$thisMonthStart}' GROUP BY t.product_id ORDER BY cnt DESC LIMIT 3 ")->fetchAll(PDO::FETCH_ASSOC); // Estimated € value of wasted items this month (#117) $wastedValueEur = 0.0; if ($thisWaste > 0 && file_exists(PRICE_CACHE_PATH)) { $priceCache = json_decode(file_get_contents(PRICE_CACHE_PATH), true) ?: []; $country = env('PRICE_COUNTRY', 'Italia'); $wastedProds = $db->query(" SELECT p.name, SUM(t.quantity) AS total_qty, p.unit FROM transactions t JOIN products p ON t.product_id = p.id WHERE t.type = 'waste' AND t.undone = 0 AND t.created_at >= '{$thisMonthStart}' GROUP BY t.product_id ")->fetchAll(PDO::FETCH_ASSOC); foreach ($wastedProds as $wp) { $key = _priceKey($wp['name'], $country); if (isset($priceCache[$key]['unit_price']) && $priceCache[$key]['unit_price'] > 0) { $unitPrice = (float)$priceCache[$key]['unit_price']; $qty = (float)$wp['total_qty']; // For weight/volume units treat qty as single-use events (transactions counted per action) $wastedValueEur += $unitPrice * $qty; } } $wastedValueEur = round($wastedValueEur, 2); } echo json_encode([ 'success' => true, 'month' => date('Y-m'), 'items_consumed' => $thisOut, 'items_consumed_prev' => $prevOut, 'items_added' => $thisIn, 'items_wasted' => $thisWaste, 'wasted_value_eur' => $wastedValueEur, 'top_categories' => $topCats, 'top_products' => array_map(fn($r) => [ 'name' => $r['name'], 'count' => (int)$r['cnt'], ], $topProds), ]); } // ===== MACRO STATS (#118) ===== /** * Aggregate macronutrients from current inventory. * For products with barcode-fetched nutriments_json, uses real data. * For products without, uses per-category static estimates (per 100g). */ function getMacroStats(PDO $db): void { EverLog::debug('getMacroStats'); // Static per-category estimates (per 100g, rough averages) $catDefaults = [ 'frutta' => ['energy_kcal_100g' => 52, 'proteins_100g' => 0.7, 'carbohydrates_100g' => 12.0, 'fat_100g' => 0.3, 'fiber_100g' => 2.0], 'verdura' => ['energy_kcal_100g' => 30, 'proteins_100g' => 2.0, 'carbohydrates_100g' => 5.0, 'fat_100g' => 0.2, 'fiber_100g' => 2.5], 'carne' => ['energy_kcal_100g' => 200, 'proteins_100g' => 20.0,'carbohydrates_100g' => 0.0, 'fat_100g' => 13.0,'fiber_100g' => 0.0], 'pesce' => ['energy_kcal_100g' => 130, 'proteins_100g' => 20.0,'carbohydrates_100g' => 0.0, 'fat_100g' => 5.0, 'fiber_100g' => 0.0], 'latticini' => ['energy_kcal_100g' => 150, 'proteins_100g' => 8.0, 'carbohydrates_100g' => 5.0, 'fat_100g' => 8.0, 'fiber_100g' => 0.0], 'pasta' => ['energy_kcal_100g' => 350, 'proteins_100g' => 12.0,'carbohydrates_100g' => 70.0, 'fat_100g' => 2.0, 'fiber_100g' => 3.0], 'pane' => ['energy_kcal_100g' => 265, 'proteins_100g' => 9.0, 'carbohydrates_100g' => 50.0, 'fat_100g' => 3.0, 'fiber_100g' => 2.5], 'cereali' => ['energy_kcal_100g' => 370, 'proteins_100g' => 10.0,'carbohydrates_100g' => 70.0, 'fat_100g' => 4.0, 'fiber_100g' => 6.0], 'bevande' => ['energy_kcal_100g' => 40, 'proteins_100g' => 0.2, 'carbohydrates_100g' => 10.0, 'fat_100g' => 0.0, 'fiber_100g' => 0.0], 'condimenti' => ['energy_kcal_100g' => 150, 'proteins_100g' => 1.0, 'carbohydrates_100g' => 10.0, 'fat_100g' => 10.0,'fiber_100g' => 0.5], 'conserve' => ['energy_kcal_100g' => 80, 'proteins_100g' => 4.0, 'carbohydrates_100g' => 10.0, 'fat_100g' => 2.0, 'fiber_100g' => 2.0], 'surgelati' => ['energy_kcal_100g' => 100, 'proteins_100g' => 8.0, 'carbohydrates_100g' => 10.0, 'fat_100g' => 3.0, 'fiber_100g' => 2.0], 'snack' => ['energy_kcal_100g' => 480, 'proteins_100g' => 6.0, 'carbohydrates_100g' => 55.0, 'fat_100g' => 28.0,'fiber_100g' => 2.0], 'altro' => ['energy_kcal_100g' => 150, 'proteins_100g' => 4.0, 'carbohydrates_100g' => 20.0, 'fat_100g' => 5.0, 'fiber_100g' => 1.5], ]; $rows = $db->query(" SELECT p.name, p.category, p.unit, p.default_quantity, p.nutriments_json, i.quantity FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.quantity > 0 ")->fetchAll(PDO::FETCH_ASSOC); $totals = ['energy_kcal' => 0.0, 'proteins' => 0.0, 'carbohydrates' => 0.0, 'fat' => 0.0, 'fiber' => 0.0]; $itemsWithData = 0; $totalItems = count($rows); foreach ($rows as $row) { $nm = null; if (!empty($row['nutriments_json'])) { $nm = json_decode($row['nutriments_json'], true); } // Estimate grams in inventory for this row $unit = $row['unit'] ?: 'pz'; $qty = (float)$row['quantity']; $defQty = (float)($row['default_quantity'] ?: 0); $grams = 100; // default: assume 100g per item if no unit info if ($unit === 'g') $grams = $qty; elseif ($unit === 'kg') $grams = $qty * 1000; elseif ($unit === 'ml') $grams = $qty; // approx 1g/ml elseif ($unit === 'l') $grams = $qty * 1000; elseif (in_array($unit, ['pz','conf']) && $defQty >= 20) $grams = $qty * $defQty; elseif (in_array($unit, ['pz','conf']) && $defQty > 0) $grams = $qty * $defQty; if ($grams <= 0) $grams = 100; // Use real nutriments if available, else fallback to category default if ($nm && isset($nm['proteins_100g'])) { $macro = $nm; } else { $cat = mb_strtolower(trim(_normalizeCat($row['category'] ?? 'altro'))); $macro = $catDefaults[$cat] ?? $catDefaults['altro']; } $factor = $grams / 100.0; $totals['energy_kcal'] += ($macro['energy_kcal_100g'] ?? 0) * $factor; $totals['proteins'] += ($macro['proteins_100g'] ?? 0) * $factor; $totals['carbohydrates'] += ($macro['carbohydrates_100g'] ?? 0) * $factor; $totals['fat'] += ($macro['fat_100g'] ?? 0) * $factor; $totals['fiber'] += ($macro['fiber_100g'] ?? 0) * $factor; if ($nm && isset($nm['proteins_100g'])) $itemsWithData++; } // Round foreach ($totals as $k => $v) $totals[$k] = round($v); // Macro ratio percentages (of kcal from P/C/F) $pKcal = $totals['proteins'] * 4; $cKcal = $totals['carbohydrates'] * 4; $fKcal = $totals['fat'] * 9; $sumKcal = max($pKcal + $cKcal + $fKcal, 1); echo json_encode([ 'success' => true, 'total_items' => $totalItems, 'items_with_data' => $itemsWithData, 'totals' => $totals, 'ratios' => [ 'proteins' => round($pKcal / $sumKcal * 100), 'carbohydrates' => round($cKcal / $sumKcal * 100), 'fat' => round($fKcal / $sumKcal * 100), ], ]); } // ===== RECENT & POPULAR PRODUCTS ===== function recentPopularProducts(PDO $db): void { EverLog::debug('recentPopularProducts'); // Last 4 distinct products used (type='out'), most recent first $recentStmt = $db->query(" SELECT DISTINCT t.product_id, p.name, p.brand, p.category, p.image_url, p.unit, MAX(t.created_at) as last_used FROM transactions t JOIN products p ON p.id = t.product_id WHERE t.type = 'out' GROUP BY t.product_id ORDER BY last_used DESC LIMIT 4 "); $recent = $recentStmt->fetchAll(PDO::FETCH_ASSOC); $recentIds = array_map(fn($r) => (int)$r['product_id'], $recent); // Top 12 most frequently used products (to allow filtering out recent ones client-side) $popularStmt = $db->query(" SELECT t.product_id, p.name, p.brand, p.category, p.image_url, p.unit, COUNT(*) as usage_count FROM transactions t JOIN products p ON p.id = t.product_id WHERE t.type = 'out' AND t.created_at >= datetime('now', '-90 days') GROUP BY t.product_id ORDER BY usage_count DESC LIMIT 12 "); $popular = $popularStmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode([ 'recent' => $recent, 'popular' => $popular, 'recent_ids' => $recentIds, ]); } // ===== CONSUMPTION PREDICTIONS ===== /** * Analyze transaction history to predict expected quantity of each product * and flag items whose current quantity deviates significantly from the prediction. */ function getConsumptionPredictions(PDO $db): void { EverLog::info('getConsumptionPredictions'); // Get all current inventory items with their consumption history $items = $db->query(" SELECT i.id AS inventory_id, i.product_id, i.quantity, i.location, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, i.updated_at FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 ")->fetchAll(PDO::FETCH_ASSOC); $predictions = []; foreach ($items as $item) { $pid = $item['product_id']; $loc = $item['location']; // Get last 90 days of 'out' transactions for this product+location $txns = $db->prepare(" SELECT quantity, created_at FROM transactions WHERE product_id = ? AND location = ? AND type = 'out' AND created_at >= datetime('now', '-90 days') ORDER BY created_at ASC "); $txns->execute([$pid, $loc]); $rows = $txns->fetchAll(PDO::FETCH_ASSOC); if (count($rows) < 5) continue; // Need at least 5 data points for a reliable rate // Calculate average daily consumption $totalUsed = 0; foreach ($rows as $r) $totalUsed += abs(floatval($r['quantity'])); $firstDate = strtotime($rows[0]['created_at']); $lastDate = strtotime($rows[count($rows) - 1]['created_at']); $daySpan = ($lastDate - $firstDate) / 86400; // If all transactions are clustered within a week, the rate is unreliable if ($daySpan < 7) continue; $historicalRate = $totalUsed / $daySpan; if ($historicalRate < 0.01) continue; // negligible consumption // Get the most recent restock (last 'in' transaction) $lastIn = $db->prepare(" SELECT quantity, created_at FROM transactions WHERE product_id = ? AND location = ? AND type = 'in' AND undone = 0 ORDER BY created_at DESC LIMIT 1 "); $lastIn->execute([$pid, $loc]); $restock = $lastIn->fetch(PDO::FETCH_ASSOC); if (!$restock) continue; $restockDate = strtotime($restock['created_at']); // Baseline = current inventory + what was consumed since the last restock. // This avoids false positives when pre-existing stock + new restock exceeds // what the model expected from the restock alone. $consumedSinceRestock = $db->prepare(" SELECT COALESCE(SUM(quantity), 0) FROM transactions WHERE product_id = ? AND location = ? AND type = 'out' AND undone = 0 AND created_at >= datetime(?, 'unixepoch') "); $consumedSinceRestock->execute([$pid, $loc, $restockDate]); $usedSinceRestock = floatval($consumedSinceRestock->fetchColumn() ?: 0); $baselineQty = floatval($item['quantity']) + $usedSinceRestock; $daysSinceRestock = max(1, (time() - $restockDate) / 86400); // Recalculate the expected consumption with an adaptive rate: // blend long-term history with post-restock behavior when available. $txSinceRestock = 0; foreach ($rows as $r) { if (strtotime($r['created_at']) >= $restockDate) $txSinceRestock++; } $observedRate = $daysSinceRestock > 0 ? ($usedSinceRestock / $daysSinceRestock) : 0; $dailyRate = $historicalRate; if ($observedRate > 0) { if ($txSinceRestock >= 3) { $dailyRate = ($historicalRate * 0.45) + ($observedRate * 0.55); } elseif ($txSinceRestock >= 1) { $dailyRate = ($historicalRate * 0.70) + ($observedRate * 0.30); } } // If the model predicts you should have consumed less than 15% of baseline // in this period, the daily rate is too low to make reliable predictions: // any single normal use will look like an anomaly. Skip it. $predictedConsumption = $dailyRate * $daysSinceRestock; if ($baselineQty > 0 && $predictedConsumption < $baselineQty * 0.15) continue; // Predicted remaining qty = baseline - (adaptive daily rate * days since restock) $expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock)); $actualQty = floatval($item['quantity']); // Aggregate total stock for this product across ALL inventory rows. // A product may be split into multiple rows (e.g. one opened pack + one // sealed pack at a different location). The opened row alone may look // depleted while the total is healthy — do not flag in that case. $totalQtyStmt = $db->prepare(" SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = ? AND quantity > 0 "); $totalQtyStmt->execute([$pid]); $totalQtyAllRows = floatval($totalQtyStmt->fetchColumn() ?: 0); // If the aggregate total is above the expected remaining, the "depletion" // is just stock spread across rows — suppress the anomaly. if ($totalQtyAllRows >= $expectedQty) continue; // Use the aggregate total as the visible actual qty so the banner shows // the real combined stock, not just the single opened row. $actualQty = $totalQtyAllRows; // Need at least some post-restock usage observations before warning. if ($txSinceRestock < 2) continue; // Flag if deviation > 30% and absolute diff > meaningful threshold $deviation = abs($actualQty - $expectedQty); $threshold = max($dailyRate * 3, 0.5); // at least 3 days worth or 0.5 units // If expected = 0 and actual > 0, the model simply thinks the product // should have been used up by now. This is NOT an anomaly — the user // either restocked (not yet tracked) or consumed less than usual. // Only flag "less" direction when expected = 0 (actual ran out faster). if ($expectedQty <= 0 && $actualQty >= 0) continue; $pctDev = $expectedQty > 0 ? ($deviation / $expectedQty) : ($actualQty > 0 ? 1 : 0); // "More than expected" usually means slower real consumption, not bad data. // Suppress this direction to avoid noisy/accusatory banners. if ($actualQty > $expectedQty) continue; // Only keep meaningful "less than expected" deviations. $flagThreshold = 0.45; if ($pctDev > $flagThreshold && $deviation > $threshold) { $unit = $item['unit']; // Format expected/actual in human units if ($unit === 'conf' && $item['default_quantity'] > 0 && $item['package_unit']) { $pu = $item['package_unit']; $sz = floatval($item['default_quantity']); $expDisplay = round($expectedQty * $sz); $actDisplay = round($actualQty * $sz); $displayUnit = $pu; } else { $expDisplay = round($expectedQty, 1); $actDisplay = round($actualQty, 1); $displayUnit = $unit; } $predictions[] = [ 'inventory_id' => (int)$item['inventory_id'], 'product_id' => (int)$item['product_id'], 'name' => $item['name'], 'brand' => $item['brand'], 'location' => $item['location'], 'unit' => $displayUnit, 'expected_qty' => $expDisplay, 'actual_qty' => $actDisplay, 'daily_rate' => round($dailyRate, 3), 'deviation_pct' => round($pctDev * 100), 'days_since_restock' => (int)round($daysSinceRestock), 'direction' => 'less', 'tx_count' => count($rows), ]; } } echo json_encode(['success' => true, 'predictions' => $predictions]); } // ===== SETTINGS ===== function getKioskUpdate(): void { $root = dirname(__DIR__); $jsonPath = $root . '/releases/kiosk-version.json'; $apkPath = $root . '/releases/evershelf-kiosk.apk'; if (!is_file($jsonPath) || !is_file($apkPath)) { echo json_encode(['success' => false, 'error' => 'not_available']); return; } $meta = json_decode((string)file_get_contents($jsonPath), true); if (!is_array($meta)) { echo json_encode(['success' => false, 'error' => 'invalid_metadata']); return; } $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https' ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; $script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php'; $basePath = preg_replace('#/api/index\.php$#', '', $script) ?: ''; $defaultApkUrl = $scheme . '://' . $host . $basePath . '/releases/evershelf-kiosk.apk'; echo json_encode([ 'success' => true, 'version' => (string)($meta['version'] ?? ''), 'version_code' => (int)($meta['version_code'] ?? 0), 'apk_url' => (string)($meta['apk_url'] ?? $defaultApkUrl), ], JSON_UNESCAPED_UNICODE); } function getServerSettings(): void { EverLog::debug('getServerSettings'); $geminiKey = env('GEMINI_API_KEY'); $bringEmail = env('BRING_EMAIL'); echo json_encode([ 'gemini_key_set' => !empty($geminiKey), 'api_token_required' => evershelfApiTokenRequired(), 'bring_email' => $bringEmail, 'settings_token_set' => evershelfApiTokenRequired(), 'demo_mode' => env('DEMO_MODE') === 'true', 'bring_password_set' => !empty(env('BRING_PASSWORD')), 'tts_url' => env('TTS_URL'), 'tts_token_set' => !empty(env('TTS_TOKEN')), 'tts_method' => env('TTS_METHOD', 'POST'), 'tts_auth_type' => env('TTS_AUTH_TYPE', 'bearer'), 'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'), 'tts_payload_key' => env('TTS_PAYLOAD_KEY', 'message'), 'tts_enabled' => env('TTS_ENABLED', 'false') === 'true', 'tts_engine' => env('TTS_ENGINE', ''), 'tts_rate' => (float)env('TTS_RATE', '1'), 'tts_pitch' => (float)env('TTS_PITCH', '1'), 'tts_auth_header_name' => env('TTS_AUTH_HEADER_NAME', ''), 'tts_auth_header_value_set' => !empty(env('TTS_AUTH_HEADER_VALUE', '')), 'tts_extra_fields' => env('TTS_EXTRA_FIELDS', ''), // User preferences (now server-side) 'default_persons' => intval(env('DEFAULT_PERSONS', '1')), 'pref_veloce' => env('PREF_VELOCE', 'false') === 'true', 'pref_pocafame' => env('PREF_POCAFAME', 'false') === 'true', 'pref_scadenze' => env('PREF_SCADENZE', 'false') === 'true', 'pref_healthy' => env('PREF_HEALTHY', 'false') === 'true', 'pref_opened' => env('PREF_OPENED', 'false') === 'true', 'pref_zerowaste' => env('PREF_ZEROWASTE', 'false') === 'true', 'dietary' => env('DIETARY', ''), 'appliances' => env('APPLIANCES', '') ? explode(',', env('APPLIANCES', '')) : [], 'camera_facing' => env('CAMERA_FACING', 'environment'), 'scale_enabled' => env('SCALE_ENABLED', 'false') === 'true', 'scale_gateway_url' => env('SCALE_GATEWAY_URL', ''), 'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true', 'screensaver_enabled' => env('SCREENSAVER_ENABLED', 'false') === 'true', 'screensaver_timeout' => (int)env('SCREENSAVER_TIMEOUT', '5'), 'zerowaste_tips_enabled' => env('ZEROWASTE_TIPS_ENABLED', 'false') === 'true', 'price_enabled' => env('PRICE_ENABLED', 'false') === 'true', 'price_country' => env('PRICE_COUNTRY', 'Italia'), 'price_currency' => env('PRICE_CURRENCY', 'EUR'), 'price_update_months' => (int)env('PRICE_UPDATE_MONTHS', '3'), 'price_update_weeks' => (int)env('PRICE_UPDATE_WEEKS', '1'), 'recipe_retention_days' => (int)env('RECIPE_RETENTION_DAYS', '7'), 'transaction_retention_days' => (int)env('TRANSACTION_RETENTION_DAYS', '90'), 'vacuum_expiry_extension_days' => (int)env('VACUUM_EXPIRY_EXTENSION_DAYS', '30'), // Backup 'backup_enabled' => env('BACKUP_ENABLED', 'true') === 'true', 'backup_retention_days' => (int)env('BACKUP_RETENTION_DAYS', '3'), 'gdrive_enabled' => env('GDRIVE_ENABLED', 'false') === 'true', 'gdrive_folder_id' => env('GDRIVE_FOLDER_ID', ''), 'gdrive_retention_days' => (int)env('GDRIVE_RETENTION_DAYS', '30'), 'gdrive_client_id_set' => !empty(env('GDRIVE_CLIENT_ID')), 'gdrive_refresh_token_set'=> !empty(env('GDRIVE_REFRESH_TOKEN')), // Shopping list 'shopping_enabled' => env('SHOPPING_ENABLED', 'true') === 'true', 'shopping_mode' => env('SHOPPING_MODE', 'internal'), 'shopping_smart_suggestions' => env('SHOPPING_SMART_SUGGESTIONS', 'true') === 'true', 'shopping_forecast' => env('SHOPPING_FORECAST', 'true') === 'true', 'shopping_auto_add_threshold' => (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'), 'dark_mode' => env('DARK_MODE', 'auto'), 'barcode_ai_fallback' => env('BARCODE_AI_FALLBACK', 'false') === 'true', // Home Assistant Integration 'ha_enabled' => env('HA_ENABLED', 'false') === 'true', 'ha_url' => env('HA_URL', ''), 'ha_token_set' => !empty(env('HA_TOKEN', '')), 'ha_tts_entity' => env('HA_TTS_ENTITY', ''), 'ha_webhook_id' => env('HA_WEBHOOK_ID', ''), 'ha_webhook_events' => env('HA_WEBHOOK_EVENTS', 'expiry,shopping_add,stock_update,barcode_scan'), 'ha_notify_service' => env('HA_NOTIFY_SERVICE', ''), 'ha_expiry_days' => (int)env('HA_EXPIRY_DAYS', '3'), ]); } function dbCleanup(?PDO $db = null): void { $recipeDays = max(1, (int)env('RECIPE_RETENTION_DAYS', '7')); // Minimum 90 days: smart shopping needs months of history to compute frequencies. // A value below 30 will cause the shopping list to appear nearly empty. $txDays = max(30, (int)env('TRANSACTION_RETENTION_DAYS', '90')); $pdo = $db ?? getDB(); try { // Delete old recipes (generated recipe plans) $pdo->prepare("DELETE FROM recipes WHERE date < date('now', ? || ' days')") ->execute(["-$recipeDays"]); // Delete old transactions (keep at least the last $txDays of history) $pdo->prepare("DELETE FROM transactions WHERE created_at < datetime('now', ? || ' days') AND undone = 0") ->execute(["-$txDays"]); // Compact the database $pdo->exec('VACUUM'); echo json_encode(['success' => true, 'recipe_retention_days' => $recipeDays, 'transaction_retention_days' => $txDays]); } catch (Throwable $e) { echo json_encode(['success' => false, 'error' => $e->getMessage()]); } } function saveSettings(): void { // Require API token if configured $requiredToken = evershelfEffectiveApiToken(); if ($requiredToken !== '') { EverLog::debug('saveSettings'); $provided = evershelfGetProvidedApiToken(); if (!hash_equals($requiredToken, $provided)) { http_response_code(403); echo json_encode(['success' => false, 'error' => 'unauthorized']); return; } } $input = json_decode(file_get_contents('php://input'), true); $envFile = __DIR__ . '/../.env'; $envVars = loadEnv(); // Map of input key → .env key — only update if present in input $keyMap = [ 'gemini_key' => 'GEMINI_API_KEY', 'bring_email' => 'BRING_EMAIL', 'bring_password' => 'BRING_PASSWORD', 'tts_url' => 'TTS_URL', 'tts_token' => 'TTS_TOKEN', 'tts_method' => 'TTS_METHOD', 'tts_auth_type' => 'TTS_AUTH_TYPE', 'tts_content_type'=> 'TTS_CONTENT_TYPE', 'tts_payload_key' => 'TTS_PAYLOAD_KEY', 'camera_facing' => 'CAMERA_FACING', 'dietary' => 'DIETARY', 'scale_gateway_url' => 'SCALE_GATEWAY_URL', 'price_country' => 'PRICE_COUNTRY', 'price_currency' => 'PRICE_CURRENCY', 'tts_engine' => 'TTS_ENGINE', 'tts_auth_header_name' => 'TTS_AUTH_HEADER_NAME', 'tts_auth_header_value' => 'TTS_AUTH_HEADER_VALUE', 'tts_extra_fields' => 'TTS_EXTRA_FIELDS', 'gdrive_folder_id' => 'GDRIVE_FOLDER_ID', 'gdrive_client_id' => 'GDRIVE_CLIENT_ID', 'gdrive_client_secret' => 'GDRIVE_CLIENT_SECRET', 'shopping_mode' => 'SHOPPING_MODE', 'dark_mode' => 'DARK_MODE', // Home Assistant 'ha_url' => 'HA_URL', 'ha_token' => 'HA_TOKEN', 'ha_tts_entity' => 'HA_TTS_ENTITY', 'ha_webhook_id' => 'HA_WEBHOOK_ID', 'ha_webhook_events' => 'HA_WEBHOOK_EVENTS', 'ha_notify_service' => 'HA_NOTIFY_SERVICE', ]; // Boolean keys $boolMap = [ 'tts_enabled' => 'TTS_ENABLED', 'pref_veloce' => 'PREF_VELOCE', 'pref_pocafame' => 'PREF_POCAFAME', 'pref_scadenze' => 'PREF_SCADENZE', 'pref_healthy' => 'PREF_HEALTHY', 'pref_opened' => 'PREF_OPENED', 'pref_zerowaste' => 'PREF_ZEROWASTE', 'scale_enabled' => 'SCALE_ENABLED', 'meal_plan_enabled' => 'MEAL_PLAN_ENABLED', 'screensaver_enabled' => 'SCREENSAVER_ENABLED', 'price_enabled' => 'PRICE_ENABLED', 'zerowaste_tips_enabled' => 'ZEROWASTE_TIPS_ENABLED', 'backup_enabled' => 'BACKUP_ENABLED', 'gdrive_enabled' => 'GDRIVE_ENABLED', 'shopping_enabled' => 'SHOPPING_ENABLED', 'shopping_smart_suggestions' => 'SHOPPING_SMART_SUGGESTIONS', 'shopping_forecast' => 'SHOPPING_FORECAST', 'barcode_ai_fallback' => 'BARCODE_AI_FALLBACK', // Home Assistant 'ha_enabled' => 'HA_ENABLED', ]; // Integer keys $intMap = [ 'default_persons' => 'DEFAULT_PERSONS', 'screensaver_timeout' => 'SCREENSAVER_TIMEOUT', 'price_update_months' => 'PRICE_UPDATE_MONTHS', 'recipe_retention_days' => 'RECIPE_RETENTION_DAYS', 'transaction_retention_days' => 'TRANSACTION_RETENTION_DAYS', 'vacuum_expiry_extension_days'=> 'VACUUM_EXPIRY_EXTENSION_DAYS', 'backup_retention_days' => 'BACKUP_RETENTION_DAYS', 'gdrive_retention_days' => 'GDRIVE_RETENTION_DAYS', 'shopping_auto_add_threshold' => 'SHOPPING_AUTO_ADD_THRESHOLD', // Home Assistant 'ha_expiry_days' => 'HA_EXPIRY_DAYS', ]; // Float keys $floatMap = [ 'tts_rate' => 'TTS_RATE', 'tts_pitch' => 'TTS_PITCH', ]; foreach ($keyMap as $inKey => $envKey) { if (array_key_exists($inKey, $input)) { $envVars[$envKey] = (string)$input[$inKey]; } } foreach ($boolMap as $inKey => $envKey) { if (array_key_exists($inKey, $input)) { $envVars[$envKey] = $input[$inKey] ? 'true' : 'false'; } } foreach ($intMap as $inKey => $envKey) { if (array_key_exists($inKey, $input)) { $envVars[$envKey] = (string)intval($input[$inKey]); } } foreach ($floatMap as $inKey => $envKey) { if (array_key_exists($inKey, $input)) { $envVars[$envKey] = (string)(float)$input[$inKey]; } } // Arrays stored as comma-separated if (array_key_exists('appliances', $input)) { $envVars['APPLIANCES'] = is_array($input['appliances']) ? implode(',', $input['appliances']) : (string)$input['appliances']; } // Write .env file $lines = []; foreach ($envVars as $key => $val) { $lines[] = "{$key}={$val}"; } $result = file_put_contents($envFile, implode("\n", $lines) . "\n"); // Clear cached env static $cache = null; $cache = null; if ($result !== false) { echo json_encode(['success' => true]); } else { echo json_encode(['success' => false, 'error' => 'Could not write .env file']); } } // ===== GEMINI AI FUNCTIONS ===== /** * Calls the Gemini REST API with exponential backoff on 429 / 503. * - Reads Google's Retry-After response header. * - Reads Google's retryDelay field inside the error body (e.g. "10s"). * - Up to 4 attempts; default wait sequence: 2 s, 4 s, 8 s. * * @return array{http_code:int, body:string, data:array|null} */ function callGemini(string $url, array $payload, int $timeout = 60): array { $maxAttempts = 4; $maxTotalElapsed = 45; // budget de sécurité pour ne jamais approcher le set_time_limit(120) de PHP $lastCode = 0; $lastBody = ''; $promptLen = strlen(json_encode($payload)); $t0 = microtime(true); for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { $retryAfterHeader = null; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeout, // Capture response headers to read Retry-After CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$retryAfterHeader) { if (stripos($header, 'retry-after:') === 0) { $val = intval(trim(substr($header, strlen('retry-after:')))); if ($val > 0) $retryAfterHeader = $val; } return strlen($header); }, ]); $body = curl_exec($ch); $lastCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($body !== false) $lastBody = $body; // Success or non-retryable error → stop immediately if ($lastCode === 200) break; if ($lastCode !== 429 && $lastCode !== 503) break; if ($attempt >= $maxAttempts) break; // Quota épuisé (RESOURCE_EXHAUSTED) n'est PAS transitoire — retenter le même modèle // dans cette requête ne servira à rien. On échoue vite plutôt que de risquer que PHP // tue le script en plein milieu (ce qui remonte comme un 502/504 moche côté client). $errData = $body ? json_decode($body, true) : null; if (($errData['error']['status'] ?? '') === 'RESOURCE_EXHAUSTED') { EverLog::warn('AI quota exhausted, failing fast', ['code' => $lastCode]); break; } // Determine how long to wait ----------------------------------------------- // Priority 1: Retry-After header (set by Google in some 429 responses) $waitSec = $retryAfterHeader ?? ($attempt * 2); // default: 2 s, 4 s, 6 s // Priority 2: Google's retryDelay inside the error body (e.g. {"retryDelay":"10s"}) foreach (($errData['error']['details'] ?? []) as $detail) { if (!empty($detail['retryDelay'])) { $parsed = intval(preg_replace('/\D/', '', $detail['retryDelay'])); if ($parsed > 0) { $waitSec = min($parsed, 60); break; } } } // Filet de sécurité : jamais laisser le cumul des attentes dépasser le budget if ((microtime(true) - $t0) + $waitSec >= $maxTotalElapsed) { EverLog::warn('AI retry budget exhausted, failing fast', ['elapsed' => microtime(true) - $t0]); break; } EverLog::warn('AI rate-limited, retrying', ['attempt' => $attempt, 'wait_s' => $waitSec, 'code' => $lastCode]); sleep($waitSec); } $elapsed = microtime(true) - $t0; if ($lastCode === 200) { EverLog::aiResponse('gemini', strlen($lastBody), $elapsed, true); } else { EverLog::aiResponse('gemini', strlen($lastBody), $elapsed, false, "HTTP {$lastCode}: " . substr($lastBody, 0, 300)); } $data = $lastBody ? json_decode($lastBody, true) : null; // Extract token counts from Gemini usageMetadata $usage = $data['usageMetadata'] ?? []; $tokIn = (int)($usage['promptTokenCount'] ?? 0); $tokOut = (int)($usage['candidatesTokenCount'] ?? 0); return [ 'http_code' => $lastCode, 'body' => $lastBody, 'data' => $data, 'tokens_in' => $tokIn, 'tokens_out' => $tokOut, ]; } /** * Record Gemini token usage to the monthly ai_usage.json file. * Called by callGeminiWithFallback after each successful call. */ function _recordAiUsage(string $model, int $tokIn, int $tokOut, string $action = ''): void { if ($tokIn === 0 && $tokOut === 0) return; $month = date('Y-m'); $data = []; if (file_exists(AI_USAGE_PATH)) { $data = json_decode(file_get_contents(AI_USAGE_PATH), true) ?: []; } if (!isset($data[$month])) { $data[$month] = ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_action' => [], 'by_model' => []]; } $m = &$data[$month]; $m['input_tokens'] += $tokIn; $m['output_tokens'] += $tokOut; $m['calls']++; if ($action) { $m['by_action'][$action] = ($m['by_action'][$action] ?? 0) + 1; } if ($model) { if (!isset($m['by_model'][$model])) $m['by_model'][$model] = ['in' => 0, 'out' => 0, 'calls' => 0]; $m['by_model'][$model]['in'] += $tokIn; $m['by_model'][$model]['out'] += $tokOut; $m['by_model'][$model]['calls'] += 1; } // Keep only last 13 months krsort($data); $data = array_slice($data, 0, 13, true); @file_put_contents(AI_USAGE_PATH, json_encode($data, JSON_PRETTY_PRINT)); EverLog::debug('ai_usage recorded', ['model' => $model, 'in' => $tokIn, 'out' => $tokOut, 'action' => $action]); } /** * Like callGemini() but tries gemini-2.5-flash first, falls back to gemini-2.0-flash * on quota/rate-limit errors (429/503). Builds the URL from model name + API key. */ function callGeminiWithFallback(string $apiKey, array $payload, int $timeout = 30, string $usageAction = ''): array { $models = ['gemini-2.5-flash', 'gemini-2.0-flash']; $last = ['http_code' => 0, 'body' => '', 'data' => null, 'tokens_in' => 0, 'tokens_out' => 0]; $promptLen = strlen(json_encode($payload)); foreach ($models as $idx => $model) { $isFallback = $idx > 0; EverLog::aiCall($model, $promptLen, $isFallback); $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; $last = callGemini($url, $payload, $timeout); if ($last['http_code'] === 200) { _recordAiUsage($model, $last['tokens_in'], $last['tokens_out'], $usageAction); return $last; } if ($last['http_code'] !== 429 && $last['http_code'] !== 503) return $last; // non-retryable EverLog::warn('AI model exhausted, trying fallback', ['model' => $model, 'code' => $last['http_code']]); } return $last; } // ===== AI-POWERED OPENED SHELF LIFE ===== /** * Cron helper: pre-warm the opened shelf life cache for opened inventory items that * have no cache entry yet. Called once per cron cycle; capped to $limit items to * avoid blocking or hitting Gemini rate limits. * Returns ['warmed' => int, 'skipped' => int]. */ function prewarmShelfLifeCache(PDO $db, int $limit = 5): array { $cacheFile = __DIR__ . '/../data/opened_shelf_cache.json'; $cache = []; if (file_exists($cacheFile)) { EverLog::debug('prewarmShelfLifeCache'); $cache = json_decode(file_get_contents($cacheFile), true) ?: []; } // Fetch opened items from inventory (only those still with quantity > 0) $rows = $db->query(" SELECT p.name, p.category, i.location FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.opened_at IS NOT NULL AND i.quantity > 0 ORDER BY i.opened_at ASC ")->fetchAll(PDO::FETCH_ASSOC); $warmed = 0; $skipped = 0; foreach ($rows as $row) { if ($warmed >= $limit) { $skipped++; continue; } $cacheKey = md5(mb_strtolower($row['name']) . '|' . mb_strtolower($row['location'])); if (isset($cache[$cacheKey])) { $skipped++; continue; } // Call with AI enabled — this writes to cache internally getOpenedShelfLifeDays($row['name'], $row['category'] ?? '', $row['location'], false, true); $warmed++; } return ['warmed' => $warmed, 'skipped' => $skipped]; } /** * Return the number of days a product remains safe after opening, depending on storage location. * Checks a local JSON cache first (keyed by product name+location); on cache miss, asks Gemini AI. * Falls back to the rule-based estimate if AI is unavailable or returns an unusable answer. * Cache has no expiry — shelf-life science doesn't change; the file can be manually deleted to refresh. */ function getOpenedShelfLifeDays(string $name, string $category, string $location, bool $vacuumSealed = false, bool $allowAI = true): int { EverLog::debug('getOpenedShelfLifeDays'); $cacheFile = __DIR__ . '/../data/opened_shelf_cache.json'; $cacheKey = md5(mb_strtolower($name) . '|' . mb_strtolower($location) . '|v2'); // Static in-memory cache: the file is read only ONCE per PHP request, // even when this function is called for many items in a loop (e.g. getStats). static $cache = null; static $cacheDirty = false; if ($cache === null) { $cache = []; if (file_exists($cacheFile)) { $cache = json_decode(file_get_contents($cacheFile), true) ?: []; } } if (isset($cache[$cacheKey]['days'])) { $days = (int)$cache[$cacheKey]['days']; return $vacuumSealed ? (int)round($days * 1.5) : $days; } // Try Gemini AI (only when explicitly allowed — NOT during bulk stats loops) $apiKey = env('GEMINI_API_KEY'); $days = 0; if ($allowAI && !empty($apiKey)) { $locLabel = match($location) { 'frigo' => 'refrigerator (4 °C / 39 °F)', 'freezer' => 'freezer (-18 °C / 0 °F)', default => 'pantry / room temperature (18-22 °C)', }; $catHint = $category ? " (category: {$category})" : ''; $prompt = "How many days can \"{$name}\"{$catHint} be safely consumed after being OPENED and stored in a {$locLabel}? " . "Reply with ONLY a single integer (the number of days). No units, no explanation, just the number."; $payload = [ 'contents' => [['parts' => [['text' => $prompt]]]], 'generationConfig' => ['maxOutputTokens' => 8, 'temperature' => 0], ]; $result = callGeminiWithFallback($apiKey, $payload, 12, 'shelf_life'); if ($result['http_code'] === 200) { $text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''); $parsed = (int)preg_replace('/\D/', '', $text); // Reject AI values if they are suspiciously low compared to the rule-based estimate // (protects against Gemini hallucinations like "1 day for butter"). $ruleMin = estimateOpenedExpiryDaysPHP($name, $category, $location); // Accept AI value only if within a reasonable multiple of the rule estimate. // Upper bound: 4× rule (or 30 days minimum ceiling) — blocks Gemini hallucinations // like "60 days for yogurt" (rule=5 → max allowed = 20). $aiMax = max($ruleMin * 4, 30); if ($parsed > 0 && $parsed <= $aiMax && $parsed >= max(1, (int)floor($ruleMin * 0.5))) { $days = $parsed; } } } // Fall back to rule-based estimate if AI unavailable / unusable $source = 'rule'; if ($days <= 0) { $days = estimateOpenedExpiryDaysPHP($name, $category, $location); $source = 'rule'; } else { $source = 'ai'; } // Persist to in-memory cache (file will be flushed at end of request via register_shutdown_function) $cache[$cacheKey] = ['days' => $days, 'source' => $source, 'name' => $name, 'location' => $location, 'ts' => time()]; $cacheDirty = true; // Write immediately so single-item requests (opened_shelf_life action) are persisted @file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); return $vacuumSealed ? (int)round($days * 1.5) : $days; } /** * Expose the shelf-life cache via API so the JS can pre-warm it when a user marks an item opened. * Accepts: POST { name, category, location, vacuum_sealed? } * Returns: { days, source } */ function getOpenedShelfLifeAction(): void { EverLog::info('getOpenedShelfLifeAction'); header('Content-Type: application/json; charset=utf-8'); $input = json_decode(file_get_contents('php://input'), true) ?? []; $name = trim($input['name'] ?? ''); $cat = trim($input['category'] ?? ''); $loc = trim($input['location'] ?? 'frigo'); $vac = !empty($input['vacuum_sealed']); if ($name === '') { echo json_encode(['error' => 'name required']); return; } $days = getOpenedShelfLifeDays($name, $cat, $loc, $vac); echo json_encode(['days' => $days]); } // ===== TESSERACT OFFLINE OCR HELPER ===== /** * Try to extract an expiry date from a base64 image using Tesseract OCR (offline). * Returns ['found'=>true,'date'=>'YYYY-MM-DD','raw_text'=>'...','confidence'=>float] * or ['found'=>false,'raw_text'=>'...'] * * Strategy: * 1. Decode base64 → temp JPEG * 2. Pre-process with GD: desaturate, auto-contrast, sharpen, 2× upscale * 3. Run tesseract with Italian+English langs, PSM-6 (block of text) * 4. Run date-format regexes (Italian & international patterns) * 5. Normalise to YYYY-MM-DD * * Returns null if tesseract binary is not available or GD is not compiled in. */ function tesseractReadExpiry(string $imageBase64): ?array { EverLog::info('tesseractReadExpiry'); // Require both the binary and the GD extension if (!function_exists('imagecreatefromstring')) return null; $tesseract = trim(shell_exec('which tesseract 2>/dev/null') ?? ''); if (empty($tesseract)) return null; // ── 1. Decode image ──────────────────────────────────────────────────── $imgData = base64_decode($imageBase64); if ($imgData === false || strlen($imgData) < 100) return null; $src = @imagecreatefromstring($imgData); if (!$src) return null; $w = imagesx($src); $h = imagesy($src); // ── 2. Pre-process ───────────────────────────────────────────────────── // 2a. Upscale ×2 – Tesseract performs best on ≥300 DPI; packaging photos // are often low-res so doubling helps character recognition. $w2 = $w * 2; $h2 = $h * 2; $dst = imagecreatetruecolor($w2, $h2); imagecopyresampled($dst, $src, 0, 0, 0, 0, $w2, $h2, $w, $h); imagedestroy($src); // 2b. Greyscale + auto-contrast imagefilter($dst, IMG_FILTER_GRAYSCALE); imagefilter($dst, IMG_FILTER_CONTRAST, -40); // negative = increase contrast in GD // 2c. Sharpen (convolution kernel) $kernel = [[0,-1,0],[-1,5,-1],[0,-1,0]]; imageconvolution($dst, $kernel, 1, 0); // ── 3. Write temp file & run Tesseract ──────────────────────────────── $tmpIn = sys_get_temp_dir() . '/ocr_in_' . uniqid() . '.png'; $tmpOut = sys_get_temp_dir() . '/ocr_out_' . uniqid(); imagepng($dst, $tmpIn); imagedestroy($dst); // PSM 6 = assume a single uniform block of text (good for cropped label areas) $cmd = escapeshellcmd($tesseract) . ' ' . escapeshellarg($tmpIn) . ' ' . escapeshellarg($tmpOut) . ' -l ita+eng --psm 6 --oem 1' . ' quiet 2>/dev/null'; shell_exec($cmd); $rawText = ''; if (file_exists($tmpOut . '.txt')) { $rawText = trim(file_get_contents($tmpOut . '.txt')); unlink($tmpOut . '.txt'); } if (file_exists($tmpIn)) unlink($tmpIn); if (empty($rawText)) return ['found' => false, 'raw_text' => '']; // ── 4. Parse date patterns ───────────────────────────────────────────── $today = new DateTime(); $currentYear = (int)$today->format('Y'); // Normalise confusable OCR chars: O→0, I/l→1, S→5 $clean = preg_replace('/\bO\b/', '0', $rawText); $clean = preg_replace('/[Il](?=\d)/', '1', $clean); $patterns = [ // DD/MM/YYYY or DD-MM-YYYY or DD.MM.YYYY '/\b(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{4})\b/', // MM/YYYY or MM-YYYY (best-before month/year only) '/\b(\d{1,2})[\/\-\.](\d{4})\b/', // YYYY-MM-DD (ISO) '/\b(\d{4})-(\d{2})-(\d{2})\b/', // DD MMM YYYY (e.g. 15 APR 2026) '/\b(\d{1,2})\s+(gen|feb|mar|apr|mag|giu|lug|ago|set|ott|nov|dic|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\.?\s*(\d{4})\b/i', // MMM YYYY (e.g. APR 2026) '/\b(gen|feb|mar|apr|mag|giu|lug|ago|set|ott|nov|dic|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\.?\s*(\d{4})\b/i', ]; $monthMap = [ 'gen'=>1,'jan'=>1,'feb'=>2,'mar'=>3,'apr'=>4,'mag'=>5,'may'=>5, 'giu'=>6,'jun'=>6,'lug'=>7,'jul'=>7,'ago'=>8,'aug'=>8, 'set'=>9,'sep'=>9,'ott'=>10,'oct'=>10,'nov'=>11,'dic'=>12,'dec'=>12, ]; $candidates = []; foreach ($patterns as $pat) { if (!preg_match_all($pat, $clean, $m, PREG_SET_ORDER)) continue; foreach ($m as $match) { $full = $match[0]; // Determine Y/M/D from which pattern matched if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $full)) { // ISO $y = (int)$match[1]; $mo = (int)$match[2]; $d = (int)$match[3]; } elseif (isset($monthMap[strtolower($match[2] ?? '')])) { // DD MMM YYYY $d = (int)$match[1]; $mo = $monthMap[strtolower($match[2])]; $y = (int)$match[3]; } elseif (isset($monthMap[strtolower($match[1] ?? '')])) { // MMM YYYY $d = 1; $mo = $monthMap[strtolower($match[1])]; $y = (int)$match[2]; } elseif (count($match) === 3) { // MM/YYYY $mo = (int)$match[1]; $y = (int)$match[2]; $d = 1; } else { // DD/MM/YYYY $d = (int)$match[1]; $mo = (int)$match[2]; $y = (int)$match[3]; } // Sanity if ($y < 2020 || $y > 2040) continue; if ($mo < 1 || $mo > 12) continue; if ($d < 1 || $d > 31) continue; $dateStr = sprintf('%04d-%02d-%02d', $y, $mo, $d); // Prefer dates in the future or near past (within 2 years) $dt = new DateTime($dateStr); $diff = (int)$today->diff($dt)->days * ($dt >= $today ? 1 : -1); $candidates[] = ['date' => $dateStr, 'score' => $diff, 'raw' => $full]; } } if (empty($candidates)) { return ['found' => false, 'raw_text' => $rawText]; } // Pick candidate closest to today (but prefer future dates, then near-past) usort($candidates, fn($a, $b) => abs($a['score']) - abs($b['score'])); $best = $candidates[0]; return [ 'found' => true, 'date' => $best['date'], 'raw_text' => $rawText, 'raw_match' => $best['raw'], 'confidence' => count($candidates) === 1 ? 0.9 : 0.75, 'source' => 'tesseract', ]; } function geminiReadExpiry(): void { $input = json_decode(file_get_contents('php://input'), true); $imageBase64 = $input['image'] ?? ''; if (empty($imageBase64)) { EverLog::info('geminiReadExpiry'); echo json_encode(['success' => false, 'error' => 'No image provided']); return; } // ── Step 1: Try Tesseract offline OCR first ──────────────────────────── $ocrResult = tesseractReadExpiry($imageBase64); if ($ocrResult !== null && !empty($ocrResult['found']) && !empty($ocrResult['date'])) { echo json_encode([ 'success' => true, 'expiry_date' => $ocrResult['date'], 'raw_text' => $ocrResult['raw_text'] ?? '', 'source' => 'ocr', ]); return; } // ── Step 2: Fall back to Gemini Vision ──────────────────────────────── $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { // No Gemini key and OCR failed/unavailable echo json_encode([ 'success' => false, 'error' => 'no_api_key', 'raw_text' => $ocrResult['raw_text'] ?? '', ]); return; } // Call Gemini API $payload = [ 'contents' => [ [ 'parts' => [ [ 'text' => "Analizza questa immagine di un prodotto alimentare. Cerca la data di scadenza (\"da consumarsi entro\", \"da consumarsi preferibilmente entro\", \"scad.\", \"exp\", \"best before\", \"TMC\", o date stampate).\n\nRispondi SOLO con un JSON nel formato: {\"found\": true, \"date\": \"YYYY-MM-DD\", \"raw_text\": \"testo letto\"}\nSe non trovi una data: {\"found\": false, \"raw_text\": \"testo letto se presente\"}\n\nSe la data ha solo mese e anno (es. 03/2027), usa il primo giorno del mese. Se ha solo giorno e mese (es. 15/04), assumi l'anno corrente o il prossimo se la data è già passata." ], [ 'inline_data' => [ 'mime_type' => 'image/jpeg', 'data' => $imageBase64 ] ] ] ] ], 'generationConfig' => [ 'temperature' => 0.1, 'maxOutputTokens' => 256 ] ]; $result = callGeminiWithFallback($apiKey, $payload, 30, 'expiry_ocr'); $httpCode = $result['http_code']; if ($httpCode !== 200) { $errMsg = $result['data']['error']['message'] ?? 'Gemini API error'; echo json_encode(['success' => false, 'error' => $errMsg, 'http_code' => $httpCode]); return; } $data = $result['data']; $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; // Parse the JSON response from Gemini // Remove potential markdown code block wrapping $text = preg_replace('/^```json\\s*/i', '', $text); $text = preg_replace('/\\s*```$/i', '', $text); $text = trim($text); $parsed = json_decode($text, true); if ($parsed && !empty($parsed['found']) && !empty($parsed['date'])) { // Validate date format $date = $parsed['date']; if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { echo json_encode(['success' => true, 'expiry_date' => $date, 'raw_text' => $parsed['raw_text'] ?? '', 'source' => 'gemini']); return; } } echo json_encode([ 'success' => false, 'error' => 'Could not parse expiry date', 'raw_text' => $parsed['raw_text'] ?? $text ]); } // ===== GEMINI CHAT ===== function geminiChat(PDO $db): void { $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { EverLog::info('geminiChat'); echo json_encode(['success' => false, 'error' => 'no_api_key']); return; } $input = json_decode(file_get_contents('php://input'), true); $message = $input['message'] ?? ''; $history = $input['history'] ?? []; $appliances = $input['appliances'] ?? []; $dietaryRestrictions = $input['dietary_restrictions'] ?? ''; $lang = recipeNormalizeLang($input['lang'] ?? 'it'); $langName = recipeLangName($lang); if (empty($message)) { echo json_encode(['success' => false, 'error' => 'Empty message']); return; } // Fetch inventory context $stmt = $db->query(" SELECT p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at, CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 ORDER BY days_left ASC "); $items = $stmt->fetchAll(PDO::FETCH_ASSOC); $ingredientLines = []; foreach ($items as $item) { $line = "- {$item['name']}"; if ($item['brand']) $line .= " ({$item['brand']})"; $line .= ": {$item['quantity']} {$item['unit']}"; if ($item['unit'] === 'conf' && !empty($item['package_unit']) && $item['default_quantity'] > 0) { $line .= " (da {$item['default_quantity']} {$item['package_unit']} ciascuna)"; } $isOpen = !empty($item['opened_at']) || (floatval($item['quantity']) > 0 && floatval($item['quantity']) < 1 && $item['unit'] === 'conf'); if ($isOpen) $line .= ' [APERTO]'; if ($item['expiry_date']) { $daysLeft = intval($item['days_left']); if ($daysLeft < 0) { $line .= " [SCADUTO da " . abs($daysLeft) . " giorni]"; } elseif ($daysLeft <= 3) { $line .= " [SCADE TRA $daysLeft GIORNI]"; } elseif ($daysLeft <= 7) { $line .= " [scade tra $daysLeft giorni]"; } } $line .= " (in {$item['location']})"; $ingredientLines[] = $line; } $ingredientsText = implode("\n", $ingredientLines); $appliancesText = _buildAppliancesPrompt($appliances, compact: true); $dietaryText = ''; if (!empty($dietaryRestrictions)) { $dietaryText = "\nUser dietary restrictions: {$dietaryRestrictions}. Always respect these restrictions."; } $langName = recipeLangName($lang); $systemPrompt = << $role, 'parts' => [['text' => $msg['text']]] ]; } // Add current message $contents[] = [ 'role' => 'user', 'parts' => [['text' => $message]] ]; $payload = [ 'contents' => $contents, 'systemInstruction' => [ 'parts' => [['text' => $systemPrompt]] ], 'generationConfig' => [ 'temperature' => 0.8, 'maxOutputTokens' => 4096 ] ]; $result = callGeminiWithFallback($apiKey, $payload, 90, 'chat'); $httpCode = $result['http_code']; if ($httpCode !== 200) { $errMsg = $result['data']['error']['message'] ?? 'Gemini API error'; echo json_encode(['success' => false, 'error' => $errMsg, 'http_code' => $httpCode]); return; } $reply = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; if (empty($reply)) { echo json_encode(['success' => false, 'error' => 'Empty response from Gemini']); return; } echo json_encode(['success' => true, 'reply' => $reply]); } function recipeNormalizeLang($lang): string { $lang = is_string($lang) ? strtolower(trim($lang)) : 'it'; return in_array($lang, ['it', 'en', 'de', 'fr', 'es'], true) ? $lang : 'it'; } function recipeLangName(string $lang): string { return [ 'it' => 'Italian', 'en' => 'English', 'de' => 'German', 'fr' => 'French', 'es' => 'Spanish', ][$lang] ?? 'Italian'; } function recipeText(string $lang, string $key, array $vars = []): string { $dict = [ 'it' => [ 'status_analyze_pantry' => '📦 Analizzo la dispensa...', 'status_products_found' => '{n} prodotti trovati', 'status_passed_ai' => ' ({n} passati all\'AI)', 'status_all_passed_ai' => ' — tutti passati all\'AI', 'status_urgent' => '⚠️ {n} urgenti: {items}', 'status_evaluate_ingredients' => '🧠 Valuto gli ingredienti disponibili...', 'status_preparing_recipe' => '👨‍🍳 Preparo la ricetta...', 'status_recipe_with' => '🥘 Ricetta con {a} e {b}', 'status_variant' => ' — variante #{n}', 'status_dish_based_on' => '🎯 Piatto a base di {type}', 'status_creating_full_recipe' => '✍️ Creo la ricetta completa...', 'status_quota_wait' => '⏳ Quota TPM esaurita ({model}), attendo {s}s... (tentativo {a}/{m})', 'status_retry_generation' => '✍️ Riprovo la generazione...', 'status_switch_model' => '🔄 Cambio modello → {model}...', 'error_pantry_empty' => 'La dispensa è vuota!', 'error_gemini_api' => 'Errore API Gemini', 'error_cannot_generate' => 'Impossibile generare la ricetta', 'error_empty_reply' => 'Risposta vuota da Gemini', 'prompt_lang_rule' => 'IMPORTANTE: scrivi tutti i campi testuali della ricetta in Italiano.', 'prompt_step_example' => 'Passo 1…', 'tools_title' => 'Strumenti necessari', ], 'en' => [ 'status_analyze_pantry' => '📦 Analyzing pantry...', 'status_products_found' => '{n} products found', 'status_passed_ai' => ' ({n} sent to AI)', 'status_all_passed_ai' => ' — all sent to AI', 'status_urgent' => '⚠️ {n} urgent: {items}', 'status_evaluate_ingredients' => '🧠 Evaluating available ingredients...', 'status_preparing_recipe' => '👨‍🍳 Preparing recipe...', 'status_recipe_with' => '🥘 Recipe with {a} and {b}', 'status_variant' => ' — variation #{n}', 'status_dish_based_on' => '🎯 Dish based on {type}', 'status_creating_full_recipe' => '✍️ Creating full recipe...', 'status_quota_wait' => '⏳ TPM quota reached ({model}), waiting {s}s... (attempt {a}/{m})', 'status_retry_generation' => '✍️ Retrying generation...', 'status_switch_model' => '🔄 Switching model → {model}...', 'error_pantry_empty' => 'Pantry is empty!', 'error_gemini_api' => 'Gemini API error', 'error_cannot_generate' => 'Unable to generate recipe', 'error_empty_reply' => 'Empty response from Gemini', 'prompt_lang_rule' => 'IMPORTANT: write all textual recipe fields in English only. Do not use Italian or German.', 'prompt_step_example' => 'Step 1…', 'tools_title' => 'Equipment needed', ], 'de' => [ 'status_analyze_pantry' => '📦 Vorrat wird analysiert...', 'status_products_found' => '{n} Produkte gefunden', 'status_passed_ai' => ' ({n} an die KI gesendet)', 'status_all_passed_ai' => ' — alle an die KI gesendet', 'status_urgent' => '⚠️ {n} dringend: {items}', 'status_evaluate_ingredients' => '🧠 Verfuegbare Zutaten werden bewertet...', 'status_preparing_recipe' => '👨‍🍳 Rezept wird vorbereitet...', 'status_recipe_with' => '🥘 Rezept mit {a} und {b}', 'status_variant' => ' — Variante #{n}', 'status_dish_based_on' => '🎯 Gericht auf Basis von {type}', 'status_creating_full_recipe' => '✍️ Vollstaendiges Rezept wird erstellt...', 'status_quota_wait' => '⏳ TPM-Limit erreicht ({model}), warte {s}s... (Versuch {a}/{m})', 'status_retry_generation' => '✍️ Generierung wird erneut versucht...', 'status_switch_model' => '🔄 Modellwechsel → {model}...', 'error_pantry_empty' => 'Die Vorratskammer ist leer!', 'error_gemini_api' => 'Gemini-API-Fehler', 'error_cannot_generate' => 'Rezept konnte nicht erstellt werden', 'error_empty_reply' => 'Leere Antwort von Gemini', 'prompt_lang_rule' => 'WICHTIG: schreibe alle textuellen Rezeptfelder nur auf Deutsch. Verwende kein Italienisch oder Englisch.', 'prompt_step_example' => 'Schritt 1…', 'tools_title' => 'Benötigte Geräte', ], ]; $text = $dict[$lang][$key] ?? $dict['it'][$key] ?? $key; foreach ($vars as $name => $value) { $text = str_replace('{' . $name . '}', (string)$value, $text); } return $text; } /** Parse "200 g" / "2 pz" style recipe qty strings. */ function recipeParseQtyString(string $qty): array { $val = 0.0; $unit = ''; if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $qty, $qm)) { $val = (float)str_replace(',', '.', $qm[1]); $ru = strtolower($qm[2]); if (strpos($ru, 'g') === 0) $unit = 'g'; elseif ($ru === 'kg') { $unit = 'g'; $val *= 1000; } elseif ($ru === 'ml') $unit = 'ml'; elseif ($ru === 'cl') { $unit = 'ml'; $val *= 10; } elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $unit = 'ml'; $val *= 1000; } elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $unit = 'pz'; elseif (strpos($ru, 'conf') === 0) $unit = 'conf'; } return ['val' => $val, 'unit' => $unit]; } function recipeGetProductTotalStock(PDO $db, int $productId): float { $stmt = $db->prepare('SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = ? AND quantity > 0'); $stmt->execute([$productId]); return (float)$stmt->fetchColumn(); } /** Round to nearest quarter-piece (½, ¼, ¾). */ function recipeRoundPieceQty(float $n): float { return max(0.25, round($n * 4) / 4); } /** Display piece count with optional fractions (1½ pz, ¼ pz). */ function recipeFormatPieceQtyLabel(float $n): string { $whole = (int)floor($n); $frac = round($n - $whole, 2); $fracStr = ''; if (abs($frac - 0.25) < 0.02) $fracStr = '¼'; elseif (abs($frac - 0.5) < 0.02) $fracStr = '½'; elseif (abs($frac - 0.75) < 0.02) $fracStr = '¾'; if ($whole === 0) { return ($fracStr !== '' ? $fracStr : '0') . ' pz'; } return $whole . $fracStr . ' pz'; } /** * Resolve how many PIECES to use when inventory unit is pz. * Never derives piece count from default_quantity / grams. */ function recipeResolvePieceQty(float $rawQty, float $recipeVal, string $recipeUnit, float $stockPieces): float { $stockPieces = max(0, $stockPieces); if ($recipeUnit === 'pz' && $recipeVal > 0) { return recipeRoundPieceQty(min($recipeVal, $stockPieces > 0 ? $stockPieces : $recipeVal)); } if ($rawQty >= 0.25 && $rawQty <= min($stockPieces > 0 ? $stockPieces : 50, 50)) { return recipeRoundPieceQty($rawQty); } // AI sometimes puts grams (e.g. 150) in qty_number for a pz product if ($rawQty >= 20 && ($stockPieces <= 0 || $rawQty > $stockPieces)) { return recipeRoundPieceQty(min(1.0, $stockPieces > 0 ? $stockPieces : 1.0)); } if ($recipeVal >= 0.25 && $recipeVal <= 50 && !in_array($recipeUnit, ['g', 'ml', 'kg', 'l'], true)) { return recipeRoundPieceQty(min($recipeVal, $stockPieces > 0 ? $stockPieces : $recipeVal)); } return recipeRoundPieceQty(min(1.0, $stockPieces > 0 ? $stockPieces : 1.0)); } /** Full sealed unit size for % remainder (conf → default_quantity in g/ml per conf). */ function recipeGetClosedProductBaseQty(array $ing): float { $unit = $ing['inventory_unit'] ?? 'pz'; $pkgSize = (float)($ing['default_quantity'] ?? 0); $pkgUnit = strtolower($ing['package_unit'] ?? ''); // Countable items (cipolle, limoni…): one piece is the package unit — never default_quantity in grams. if ($unit === 'pz') { return 1.0; } if ($unit === 'conf' && $pkgSize > 0 && in_array($pkgUnit, ['g', 'ml'], true)) { return $pkgSize; } if ($unit === 'conf' && $pkgSize > 0) { return $pkgSize; } if ($pkgSize > 0 && in_array($unit, ['g', 'ml'], true)) { return $pkgSize; } if ($unit === 'conf') { return 1.0; } return 0.0; } /** Per-person quantity ceiling by ingredient type (pz, g, ml). */ function recipeGetServingCapForIngredient(string $name, string $unit, int $persons): ?float { if ($persons <= 0) { return null; } $n = recipeNormalizeName($name); if ($unit === 'pz') { if (preg_match('/\b(cipoll\w*|porr\w*|scalog\w*)\b/u', $n)) { return (float)$persons; } if (preg_match('/\b(peperon\w*|melanzan\w*|zucchin\w*|finocchi\w*|melone)\b/u', $n)) { return (float)$persons; } if (preg_match('/\b(limon\w*|aranc\w*|limett\w*)\b/u', $n)) { return max(1.0, ceil(0.5 * $persons)); } if (preg_match('/\b(dado|brodo)\b/u', $n)) { return min((float)$persons, 1.0); } if (preg_match('/\b(baulett\w*|panin\w*|toast|piadin\w*|grissin\w*)\b/u', $n)) { return min(2.0, (float)$persons); } return null; } if ($unit === 'g' || $unit === 'ml') { if (preg_match('/\b(spinac\w*|bietol\w*|rucol\w*|lattug\w*|valerian\w*|songin\w*|misticanz\w*|indivi\w*|radicchi\w*|cicori\w*)\b/u', $n)) { return 150.0 * $persons; } if (preg_match('/\b(minestr\w*|verdure)\b/u', $n)) { return 200.0 * $persons; } if (preg_match('/\b(pane\s*gratt|grattugi\w*|pangratt)\b/u', $n)) { return 30.0 * $persons; } if (preg_match('/\b(zucchin\w*|melanzan\w*|peperon\w*|carot\w*|sedan\w*|finocchi\w*|cavolf\w*|broccol\w*|zucc\w*|pomodor\w*|verdur\w*)\b/u', $n)) { return 150.0 * $persons; } } return null; } /** Per-serving caps for bulky countables and generous AI / use-all amounts. */ function recipeClampQtyForServings(array &$ing, int $persons): void { if ($persons <= 0) { return; } $unit = $ing['inventory_unit'] ?? 'pz'; $qty = (float)($ing['qty_number'] ?? 0); if ($qty <= 0) { return; } $cap = recipeGetServingCapForIngredient((string)($ing['name'] ?? ''), $unit, $persons); if ($cap === null || $qty <= $cap) { return; } $ing['qty_number'] = round($cap, 2); if ($unit === 'pz') { $ing['qty'] = recipeFormatPieceQtyLabel($cap); } elseif ($unit === 'g' || $unit === 'ml') { $ing['qty'] = round($cap) . ' ' . $unit; } unset($ing['use_all_suggested']); if (isset($ing['stock_have'])) { $ing['stock_remain'] = max(0, round((float)$ing['stock_have'] - $cap, 2)); } } /** Use-all when leftover is < 5% of the sealed package (not current stock). */ function recipeShouldUseAllRemainder(float $remainDisp, array $ing, float $stockDisp = 0): bool { if ($remainDisp <= 0) { return false; } $packageBase = recipeGetClosedProductBaseQty($ing); if ($packageBase <= 0) { return false; } $pct = $remainDisp / $packageBase; if ($pct < 0.05) { return true; } // Opened/partial: less than one full sealed unit on hand — allow up to 10% tail waste if ($stockDisp > 0 && $stockDisp < $packageBase && $pct < 0.10) { return true; } return false; } /** Normalize use qty, apply <5% remainder → use-all, set stock_have/stock_remain hints. */ function recipeFinalizeIngQty(array &$ing, float $totalStockQty): void { $parsed = recipeParseQtyString($ing['qty'] ?? ''); $recipeVal = $parsed['val']; $recipeUnit = $parsed['unit']; $unit = $ing['inventory_unit'] ?? 'pz'; $pkgSize = (float)($ing['default_quantity'] ?? 0); $pkgUnit = strtolower($ing['package_unit'] ?? ''); $isConfSub = ($unit === 'conf' && $pkgSize > 0 && in_array($pkgUnit, ['g', 'ml'], true)); $useQty = (float)($ing['qty_number'] ?? 0); // Piece inventory: always count in pz (or fractions), never grams via default_quantity if ($unit === 'pz') { $useQty = recipeResolvePieceQty($useQty, $recipeVal, $recipeUnit, $totalStockQty); $ing['qty_number'] = round($useQty, 3); $ing['qty'] = recipeFormatPieceQtyLabel($useQty); } // conf+weight: always prefer the recipe amount from the qty string (not inventory conf count) if ($isConfSub && $recipeVal > 0 && $recipeUnit === $pkgUnit) { $useQty = $recipeVal; $ing['qty_number'] = round($useQty, 3); $ing['qty'] = round($useQty) . ' ' . $pkgUnit; } if ($isConfSub) { $stockDisp = $totalStockQty * $pkgSize; $useDisp = $useQty; $dispUnit = $pkgUnit; } else { $stockDisp = $totalStockQty; $useDisp = $useQty; $dispUnit = $unit; } if ($stockDisp <= 0 || $useDisp <= 0) { $ing['stock_have'] = round($stockDisp, 2); $ing['stock_remain'] = max(0, round($stockDisp - $useDisp, 2)); $ing['stock_unit'] = $dispUnit; return; } $remainDisp = $stockDisp - $useDisp; if (recipeShouldUseAllRemainder($remainDisp, $ing, $stockDisp)) { $ing['use_all_suggested'] = true; $useDisp = $stockDisp; $remainDisp = 0; if ($isConfSub) { $ing['qty_number'] = round($useDisp, 1); $ing['qty'] = round($useDisp) . ' ' . $pkgUnit; } else { $ing['qty_number'] = round($totalStockQty, 3); if ($unit === 'pz') { $ing['qty'] = recipeFormatPieceQtyLabel((float)$totalStockQty); } else { $ing['qty'] = round($totalStockQty, ($unit === 'g' || $unit === 'ml') ? 0 : 2) . ' ' . $unit; } } } $ing['stock_have'] = round($stockDisp, 2); $ing['stock_remain'] = round($remainDisp, 2); $ing['stock_unit'] = $dispUnit; $ing['package_base'] = recipeGetClosedProductBaseQty($ing); } function recipeApplyStockHintsToRecipe(PDO $db, array &$recipe): void { if (empty($recipe['ingredients']) || !is_array($recipe['ingredients'])) return; $persons = max(1, (int)($recipe['persons'] ?? 1)); foreach ($recipe['ingredients'] as &$ing) { if (empty($ing['from_pantry']) || empty($ing['product_id'])) continue; $totalStock = recipeGetProductTotalStock($db, (int)$ing['product_id']); if ($totalStock <= 0) { recipeClearPantryIngredient($ing); continue; } $ing['inventory_qty_total'] = $totalStock; recipeFinalizeIngQty($ing, $totalStock); recipeClampQtyForServings($ing, $persons); } unset($ing); } /** Ingredient not linked to real in-stock pantry product. */ function recipeIsUnavailableIngredient(array $ing): bool { if (recipeIsFreeStaple((string)($ing['name'] ?? ''))) { return false; } return empty($ing['from_pantry']) || empty($ing['product_id']); } /** * Drop ingredients not in pantry. Returns removed rows for shopping suggestions. * Recipes must be cookable NOW with what the user has. */ function recipeEnforcePantryOnly(array &$recipe): array { $removed = []; if (empty($recipe['ingredients']) || !is_array($recipe['ingredients'])) { return $removed; } $kept = []; foreach ($recipe['ingredients'] as $ing) { if (!recipeIsUnavailableIngredient($ing)) { $kept[] = $ing; continue; } $name = trim((string)($ing['name'] ?? '')); if ($name === '') { continue; } $removed[] = [ 'name' => $name, 'qty' => trim((string)($ing['qty'] ?? '')), 'reason' => 'not_in_pantry', ]; } $recipe['ingredients'] = $kept; return $removed; } /** Merge removed ingredients into recipe shopping_suggestions (deduped by name). */ function recipeAttachShoppingSuggestions(array &$recipe, array $removed): void { if (empty($removed)) { return; } $existing = []; foreach ($recipe['shopping_suggestions'] ?? [] as $row) { $k = recipeNormalizeName((string)($row['name'] ?? '')); if ($k !== '') { $existing[$k] = true; } } foreach ($removed as $row) { $k = recipeNormalizeName((string)($row['name'] ?? '')); if ($k === '' || isset($existing[$k])) { continue; } $recipe['shopping_suggestions'][] = $row; $existing[$k] = true; } } /** Enrich, stock hints, then pantry-only filter + shopping suggestions. */ function recipePostProcessGenerated(PDO $db, array &$recipe, array $pantryItems): array { if (!empty($recipe['ingredients'])) { recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $pantryItems); recipeApplyStockHintsToRecipe($db, $recipe); $removed = recipeEnforcePantryOnly($recipe); recipeAttachShoppingSuggestions($recipe, $removed); return $removed; } return []; } function recipeNormalizeName(string $name): string { $n = mb_strtolower(trim($name), 'UTF-8'); return preg_replace('/\s+/u', ' ', $n) ?? $n; } /** Location / state flags appended to pantry lines sent to the recipe AI. */ function recipePantryLineExtraFlags(array $item, ?int $expiryGroup = null): string { $flags = ''; $loc = strtolower((string)($item['location'] ?? '')); if ($loc === 'freezer') { $flags .= ' [❄️ SURGELATO — in freezer, non fresco]'; } $qty = (float)($item['quantity'] ?? 0); $isOpen = !empty($item['opened_at']) || ($qty > 0 && $qty < 1 && ($item['unit'] ?? '') === 'conf'); if ($isOpen) { $flags .= ' [APERTO]'; } return $flags; } /** Always-available staples — never link to a pantry product row. */ function recipeIsFreeStaple(string $name): bool { $n = recipeNormalizeName($name); return (bool)preg_match('/^(acqua|sale|pepe|peper|olio(\s|$|e)|extraverg|evoo)\b/u', $n); } /** Strict name match — no generic alias expansion (formaggio ≠ grana). */ function recipeScorePantryMatch(string $ingName, string $productName): int { $a = recipeNormalizeName($ingName); $b = recipeNormalizeName($productName); if ($a === '' || $b === '') return 0; if ($a === $b) return 100; if (mb_strpos($a, $b) !== false) { return mb_strlen($b) >= 4 ? 92 : 0; } if (mb_strpos($b, $a) !== false) { return mb_strlen($a) >= 4 ? 88 : 0; } $aw = preg_split('/[\s,.\-\/]+/u', $a, -1, PREG_SPLIT_NO_EMPTY); $bw = preg_split('/[\s,.\-\/]+/u', $b, -1, PREG_SPLIT_NO_EMPTY); if (!empty($aw[0]) && !empty($bw[0]) && mb_strlen($aw[0]) >= 4 && $aw[0] === $bw[0]) { return 80; } return 0; } function recipePickBestInventoryRow(array $rows): array { usort($rows, static function (array $a, array $b): int { $aOpen = !empty($a['opened_at']) || ((float)($a['quantity'] ?? 0) > 0 && (float)($a['quantity'] ?? 0) < 1 && ($a['unit'] ?? '') === 'conf'); $bOpen = !empty($b['opened_at']) || ((float)($b['quantity'] ?? 0) > 0 && (float)($b['quantity'] ?? 0) < 1 && ($b['unit'] ?? '') === 'conf'); if ($aOpen !== $bOpen) return $bOpen <=> $aOpen; $da = (float)($a['days_left'] ?? 999); $db = (float)($b['days_left'] ?? 999); if ($da !== $db) return $da <=> $db; return (float)($b['quantity'] ?? 0) <=> (float)($a['quantity'] ?? 0); }); return $rows[0]; } function recipeClearPantryIngredient(array &$ing): void { $ing['from_pantry'] = false; foreach ([ 'product_id', 'location', 'inventory_unit', 'inventory_qty', 'inventory_qty_total', 'default_quantity', 'package_unit', 'available_qty', 'vacuum_sealed', 'brand', 'expiry_date', 'stock_have', 'stock_remain', 'stock_unit', 'package_base', 'use_all_suggested', 'used', ] as $k) { unset($ing[$k]); } } function recipeApplyPantryQtyFields(array &$ing, array $bestMatch): void { $qtyNum = (float)($ing['qty_number'] ?? 0); $invUnit = $bestMatch['unit'] ?? 'pz'; $invQty = (float)$bestMatch['quantity']; if ($qtyNum <= 0) return; $recipeQty = $ing['qty'] ?? ''; $recipeUnit = ''; $recipeVal = 0; if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $recipeQty, $qm)) { $recipeVal = (float)str_replace(',', '.', $qm[1]); $ru = strtolower($qm[2]); if (strpos($ru, 'g') === 0) $recipeUnit = 'g'; elseif ($ru === 'kg') { $recipeUnit = 'g'; $recipeVal *= 1000; } elseif ($ru === 'ml') $recipeUnit = 'ml'; elseif ($ru === 'cl') { $recipeUnit = 'ml'; $recipeVal *= 10; } elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $recipeUnit = 'ml'; $recipeVal *= 1000; } elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $recipeUnit = 'pz'; elseif (strpos($ru, 'conf') === 0) $recipeUnit = 'conf'; } $confAlreadyInSubUnit = false; if ($recipeUnit && $recipeUnit !== $invUnit) { if ($recipeUnit === 'g' && $invUnit === 'kg') { $qtyNum = $recipeVal / 1000; } elseif ($recipeUnit === 'g' && $invUnit === 'g') { $qtyNum = $recipeVal; } elseif ($recipeUnit === 'ml' && $invUnit === 'l') { $qtyNum = $recipeVal / 1000; } elseif ($recipeUnit === 'ml' && $invUnit === 'ml') { $qtyNum = $recipeVal; } elseif ($invUnit === 'conf') { $defQty = (float)($bestMatch['default_quantity'] ?? 0); $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && ($recipeUnit === 'g' || $recipeUnit === 'ml')) { $qtyNum = $recipeVal; $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; $confAlreadyInSubUnit = true; } else { $qtyNum = $defQty > 0 ? max(0.25, round(($recipeVal / $defQty) * 4) / 4) : 1; } } elseif ($invUnit === 'pz') { $qtyNum = recipeResolvePieceQty( (float)($ing['qty_number'] ?? 0), $recipeVal, $recipeUnit, $invQty ); } } elseif ($invUnit === 'pz') { $qtyNum = recipeResolvePieceQty($qtyNum, $recipeVal, $recipeUnit, $invQty); } if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { $defQty = (float)($bestMatch['default_quantity'] ?? 0); $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) { if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) { $qtyNum = $recipeVal; $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; } elseif ($qtyNum <= $invQty) { $qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; } } } if ($invUnit === 'pz') { $qtyNum = recipeResolvePieceQty($qtyNum, $recipeVal, $recipeUnit, $invQty); if ($qtyNum > $invQty && $invQty > 0) { $qtyNum = recipeRoundPieceQty($invQty); } $ing['qty'] = recipeFormatPieceQtyLabel($qtyNum); } else { if ($qtyNum > $invQty) $qtyNum = $invQty; if ($recipeVal > 0 && $recipeUnit === $invUnit && $qtyNum < $recipeVal * 0.01) { $qtyNum = $recipeVal; } } $ing['qty_number'] = round($qtyNum, 3); } /** Link recipe ingredients ONLY to real in-stock pantry products (strict name match). */ function recipeEnrichIngredientsFromPantry(PDO $db, array &$ingredients, array $items): void { if (empty($ingredients) || empty($items)) return; $catalog = []; foreach ($items as $item) { if ((float)($item['quantity'] ?? 0) <= 0) continue; $pid = (int)$item['product_id']; if (!isset($catalog[$pid])) { $catalog[$pid] = ['name' => $item['name'], 'rows' => []]; } $catalog[$pid]['rows'][] = $item; } foreach ($ingredients as &$ing) { $ingName = trim($ing['name'] ?? ''); if ($ingName === '' || recipeIsFreeStaple($ingName)) { recipeClearPantryIngredient($ing); continue; } $bestPid = null; $bestScore = 0; foreach ($catalog as $pid => $meta) { $score = recipeScorePantryMatch($ingName, $meta['name']); if ($score > $bestScore) { $bestScore = $score; $bestPid = $pid; } } if ($bestScore < RECIPE_PANTRY_MIN_MATCH_SCORE || !$bestPid) { recipeClearPantryIngredient($ing); continue; } $totalStock = recipeGetProductTotalStock($db, $bestPid); if ($totalStock <= 0) { recipeClearPantryIngredient($ing); continue; } $bestMatch = recipePickBestInventoryRow($catalog[$bestPid]['rows']); $ing['from_pantry'] = true; $ing['name'] = $catalog[$bestPid]['name']; $ing['product_id'] = $bestPid; $ing['location'] = $bestMatch['location']; $ing['inventory_unit'] = $bestMatch['unit']; $ing['inventory_qty'] = (float)$bestMatch['quantity']; $ing['default_quantity'] = (float)($bestMatch['default_quantity'] ?? 0); $ing['package_unit'] = $bestMatch['package_unit'] ?? ''; $ing['available_qty'] = $bestMatch['quantity'] . ' ' . $bestMatch['unit']; $ing['vacuum_sealed'] = !empty($bestMatch['vacuum_sealed']) ? 1 : 0; if (!empty($bestMatch['brand'])) $ing['brand'] = $bestMatch['brand']; if (!empty($bestMatch['expiry_date'])) $ing['expiry_date'] = $bestMatch['expiry_date']; recipeApplyPantryQtyFields($ing, $bestMatch); } unset($ing); } // ===== RECIPE GENERATION WITH GEMINI ===== function generateRecipe(PDO $db): void { EverLog::debug('generateRecipe start'); $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { echo json_encode(['success' => false, 'error' => 'no_api_key']); return; } $input = json_decode(file_get_contents('php://input'), true); $lang = recipeNormalizeLang($input['lang'] ?? 'it'); $recipeLangName = recipeLangName($lang); $mealType = $input['meal'] ?? 'pranzo'; $persons = max(1, intval($input['persons'] ?? 1)); $subType = $input['sub_type'] ?? ''; $options = $input['options'] ?? []; $appliances = $input['appliances'] ?? []; $dietaryRestrictions = $input['dietary_restrictions'] ?? ''; $todayRecipes = $input['today_recipes'] ?? []; $mealPlanType = $input['meal_plan_type'] ?? ''; // e.g. 'pasta', 'pesce', 'legumi', ... $variation = max(0, intval($input['variation'] ?? 0)); // 0=first attempt, 1+=re-generation $rejectedIngredients = $input['rejected_ingredients'] ?? []; // ingredient names from previous rejected recipes // Fetch all inventory items with expiry info $stmt = $db->query(" SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at, CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 ORDER BY days_left ASC "); $items = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($items)) { echo json_encode(['success' => false, 'error' => recipeText($lang, 'error_pantry_empty')]); return; } // Helper to compute priority group for an item: // 1=scaduto, 2=scadenza imminente ≤3gg, 3=scadenza ravvicinata ≤7gg, // 4=scadenza lontana, 5=aperto (opened_at set o conf parziale), 6=chiuso $getItemPriority = function($item) { $daysLeft = floatval($item['days_left']); // "Aperto" = opened_at è impostato (frutta/verdura/qualsiasi cosa usata parzialmente) // OPPURE confezione parzialmente usata (qty < 1 conf) $isOpen = !empty($item['opened_at']) || (floatval($item['quantity']) > 0 && floatval($item['quantity']) < 1 && $item['unit'] === 'conf'); if (!empty($item['expiry_date']) && $daysLeft < 0) return 1; if (!empty($item['expiry_date']) && $daysLeft <= 3) return 2; if (!empty($item['expiry_date']) && $daysLeft <= 7) return 3; if ($isOpen) return 3; // opened items: same priority as expiring this week — must be used soon if (!empty($item['expiry_date'])) return 4; return 6; }; // Sort by priority group, then by days_left within each group usort($items, function($a, $b) use ($getItemPriority) { $pa = $getItemPriority($a); $pb = $getItemPriority($b); if ($pa !== $pb) return $pa - $pb; return floatval($a['days_left']) - floatval($b['days_left']); }); // Build ingredient list grouped by priority // ---- Build compact ingredient list for AI prompt ---- // Skip common staples that are always assumed available (rule says: acqua, sale, pepe, olio) $staplePatterns = '/\b(sale|pepe|olio d.oliva|olio di semi|olio extra|acqua|aceto balsamico|aceto di|sel marin)\b/i'; $priorityGroups = []; foreach ($items as $item) { $group = $getItemPriority($item); // Skip always-available staples from category 6 (closed, no expiry concern) if ($group >= 5 && preg_match($staplePatterns, $item['name'])) continue; $qty = floatval($item['quantity']); $isOpen = !empty($item['opened_at']) || ($qty > 0 && $qty < 1 && $item['unit'] === 'conf'); $daysLeft = intval($item['days_left']); // Compact line: name + qty (with conf expansion) + flags only when relevant $line = "- {$item['name']}: {$item['quantity']} {$item['unit']}"; if ($item['unit'] === 'conf' && !empty($item['package_unit']) && $item['default_quantity'] > 0) { $line .= " ({$item['default_quantity']}{$item['package_unit']}/conf)"; } if ($item['unit'] === 'pz') { $line .= ' [usa PEZZI interi — qty_number in pz, non grammi]'; } // Add expiry info only for priority groups 1-4 if ($group <= 4 && $item['expiry_date']) { if ($daysLeft < 0) { $line .= " ⚠️SCADUTO"; } elseif ($daysLeft <= 3) { $line .= " 🔴{$daysLeft}gg"; } elseif ($daysLeft <= 7) { $line .= " 🟠{$daysLeft}gg"; } else { $line .= " {$daysLeft}gg"; } } $line .= recipePantryLineExtraFlags($item, $group); $priorityGroups[$group][] = $line; } // Build sections: detailed headers for urgent groups, brief for rest $ingredientSections = []; $priorityHeaders = [ 1 => 'SCADUTI — usa subito', 2 => 'SCADENZA ≤3gg — priorità alta', 3 => 'SCADENZA ≤7gg / APERTI — usa presto', 4 => 'ALTRI CON SCADENZA', 6 => 'DISPENSA', ]; // Include all in-stock items in the prompt (no truncation — AI must not invent products). foreach ($priorityHeaders as $g => $header) { if (empty($priorityGroups[$g])) continue; $ingredientSections[] = "[$header]\n" . implode("\n", $priorityGroups[$g]); } $ingredientsText = implode("\n", $ingredientSections); // Build mandatory/recommended lists ONLY when user explicitly selected // 'scadenze' (expiry priority) or 'zerowaste' (zero waste) options. // Without these options, the recipe should use ALL available ingredients freely // without being biased toward expiring items. $mandatoryItems = []; $recommendedItems = []; $wantsExpiryPriority = in_array('scadenze', $options) || in_array('zerowaste', $options); $wantsOpenedPriority = in_array('opened', $options); if ($wantsExpiryPriority || $wantsOpenedPriority) { foreach ($items as $item) { $g = $getItemPriority($item); $daysLeft = floatval($item['days_left']); $isOpen = !empty($item['opened_at']) || (floatval($item['quantity']) > 0 && floatval($item['quantity']) < 1 && $item['unit'] === 'conf'); $expiryNote = !empty($item['expiry_date']) ? " — scade: {$item['expiry_date']}" : ''; $openNote = $isOpen ? ' [APERTO]' : ''; $label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . $openNote . $expiryNote; if ($wantsExpiryPriority) { // Expired or expiring within 3 days → mandatory if ($g === 1 || $g === 2) { $mandatoryItems[] = $label; // Expiring within 7 days → strongly recommended } elseif ($g === 3) { $recommendedItems[] = $label; } } if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 7 && $daysLeft >= 0) { // Opened items expiring within 7 days if (!in_array($label, $mandatoryItems) && !in_array($label, $recommendedItems)) { $recommendedItems[] = $label; } } } } $mustUseText = ''; if (!empty($mandatoryItems)) { $mustUseText .= "\n\n⚠️ OBBLIGATORI (scaduti/imminenti — DEVE usarne almeno 1):\n" . implode("\n", array_map(fn($n) => "→ $n", $mandatoryItems)); } if (!empty($recommendedItems)) { $mustUseText .= "\n\n🔶 CONSIGLIATI (aperti/in scadenza):\n" . implode("\n", array_map(fn($n) => "· $n", $recommendedItems)); } $mealLabels = [ 'colazione' => 'colazione (mattina)', 'pranzo' => 'pranzo (mezzogiorno)', 'cena' => 'cena (sera)', 'dolce' => 'dolce/dessert', 'succo' => 'succo di frutta/bevanda' ]; $mealLabel = $mealLabels[$mealType] ?? $mealType; // Sub-type specialization for dolce/succo $subTypeLabels = [ 'dolce' => [ 'torta' => 'Torta (soffice, da forno: torta di mele, ciambellone, plumcake, angel cake, ecc.)', 'crema' => 'Crema o Budino (crema pasticcera, panna cotta, mousse, tiramisù, budino, semifreddo)', 'crumble' => 'Crumble o Crostata (base croccante: crumble di frutta, crostata, sbriciolata)', 'biscotti' => 'Biscotti o Pasticcini (biscotti, cookies, muffin, cupcake, pasticcini)', 'frutta' => 'Dolce alla Frutta (macedonia creativa, frutta caramellata, sorbetto, frullato dolce)', ], 'succo' => [ 'dolce' => 'Succo Dolce e Fruttato (mix di frutta dolce: pesca, mela, pera, fragola, banana)', 'energizzante' => 'Succo Energizzante (con zenzero, curcuma, barbabietola, carota, mela verde)', 'detox' => 'Succo Detox / Verde (cetriolo, sedano, spinaci, mela verde, limone)', 'rinfrescante' => 'Succo Rinfrescante (anguria, menta, lime, cetriolo, acqua di cocco)', 'vitaminico' => 'Succo Vitaminico / Agrumi (arancia, pompelmo, limone, kiwi, mandarino)', ] ]; $subTypeText = ''; if (!empty($subType) && isset($subTypeLabels[$mealType][$subType])) { $subHint = $subTypeLabels[$mealType][$subType]; $mealLabel .= " — tipo: $subHint"; $subTypeText = "\n\n🎨 SOTTO-TIPO: {$subHint}. La ricetta DEVE essere di questo tipo."; } // Build extra rules from options $extraRules = []; $optionLabels = [ 'veloce' => 'VELOCE: max 15-20 min totali.', 'pocafame' => 'POCA FAME: porzione leggera, snack o insalata.', 'scadenze' => 'PRIORITÀ SCADENZE: usa per primi i prodotti in scadenza.', 'salutare' => 'SALUTARE: ingredienti integrali, verdure, pochi grassi.', 'opened' => 'PRIORITÀ APERTI: usa per primi i prodotti [APERTO].', 'zerowaste' => 'ZERO SPRECHI: usa il più possibile ingredienti in scadenza.' ]; foreach ($options as $opt) { if (isset($optionLabels[$opt])) { $extraRules[] = $optionLabels[$opt]; } } $extraRulesText = ''; if (!empty($extraRules)) { $extraRulesText = "\n\n⚠️ PREFERENZE OBBLIGATORIE (RISPETTALE SEMPRE, non sono suggerimenti):\n" . implode("\n", array_map(fn($r) => "→ $r", $extraRules)); } // Appliances $appliancesText = _buildAppliancesPrompt($appliances, compact: false); // Dietary restrictions $dietaryText = ''; if (!empty($dietaryRestrictions)) { $dietaryText = "\n\nRESTRIZIONI ALIMENTARI:\n{$dietaryRestrictions}\nRispetta SEMPRE queste restrizioni."; } // Weekly meal plan type hint $mealPlanTypeLabels = [ 'pasta' => 'Pasta (primo piatto a base di pasta)', 'riso' => 'Riso (risotto, insalata di riso, riso saltato, ecc.)', 'carne' => 'Carne (secondo piatto a base di carne)', 'pesce' => 'Pesce (secondo piatto a base di pesce o frutti di mare)', 'legumi' => 'Legumi (zuppa, insalata, hummus, pasta e fagioli, ecc.)', 'uova' => 'Uova (frittata, uova strapazzate, quiche, ecc.)', 'formaggio' => 'Formaggio (fonduta, gnocchi al formaggio, torta salata, ecc.)', 'pizza' => 'Pizza o focaccia (impastata in casa o usi ingredienti simili)', 'affettati' => 'Affettati (tagliere misto, piadina, panino, ecc.)', 'verdure' => 'Verdure (piatto principale a base di verdure, contorno abbondante)', 'zuppa' => 'Zuppa o minestra (zuppe, vellutate, minestrone)', 'insalata' => 'Insalata (insalata mista, insalata di riso o pasta, poke)', 'pane' => 'Pane / Sandwich (toast, tramezzino, bruschette)', 'dolce' => 'Dolce o dessert', 'libero' => '', ]; // Keywords to match inventory names against each meal plan type $typeKeywords = [ 'pesce' => ['tonno', 'salmone', 'merluzzo', 'branzino', 'orata', 'sardine', 'acciughe', 'alici', 'gamberi', 'cozze', 'vongole', 'polpo', 'calamari', 'seppia', 'sgombro', 'trota', 'baccalà', 'dentice', 'spigola', 'pesce'], 'carne' => ['pollo', 'manzo', 'maiale', 'vitello', 'agnello', 'tacchino', 'salsiccia', 'hamburger', 'bistecca', 'cotoletta', 'pancetta', 'speck', 'carne', 'arrosto', 'filetto', 'lonza', 'braciola'], 'pasta' => ['pasta', 'spaghetti', 'penne', 'rigatoni', 'fusilli', 'tagliatelle', 'lasagne', 'farfalle', 'orecchiette', 'bucatini', 'linguine', 'maccheroni', 'gnocchi', 'pennette', 'bavette'], 'riso' => ['riso', 'basmati', 'arborio', 'carnaroli', 'parboiled', 'riso integrale'], 'legumi' => ['fagioli', 'ceci', 'lenticchie', 'piselli', 'fave', 'lupini', 'soia', 'legumi', 'borlotti', 'cannellini', 'azuki'], 'uova' => ['uova', 'uovo'], 'formaggio' => ['formaggio', 'parmigiano', 'mozzarella', 'ricotta', 'pecorino', 'grana', 'gorgonzola', 'scamorza', 'fontina', 'emmental', 'asiago', 'provola', 'provolone', 'taleggio', 'stracchino'], 'pizza' => ['farina', 'lievito', 'pizza', 'focaccia'], 'affettati' => ['prosciutto', 'salame', 'bresaola', 'mortadella', 'speck', 'coppa', 'affettati', 'wurstel', 'würstel', 'piadina', 'pancetta cotta'], 'verdure' => ['zucchine', 'zucchina', 'melanzane', 'peperoni', 'spinaci', 'cavolfiore', 'broccoli', 'carote', 'zucca', 'bietole', 'cavolo', 'carciofi', 'asparagi', 'lattuga', 'rucola', 'radicchio', 'cicoria', 'finocchio', 'cipolla', 'porri', 'verdure'], 'zuppa' => ['brodo', 'zuppa', 'minestra', 'minestrone', 'vellutata', 'orzo', 'farro', 'fagioli', 'ceci', 'lenticchie'], 'insalata' => ['insalata', 'lattuga', 'rucola', 'spinaci', 'radicchio', 'misticanza', 'valeriana', 'songino'], 'pane' => ['pane', 'pancarrè', 'baguette', 'toast', 'tramezzino', 'crackers', 'grissini', 'ciabatta', 'rosetta'], 'dolce' => ['cioccolato', 'cacao', 'zucchero', 'miele', 'marmellata', 'nutella', 'creme caramel', 'savoiardi', 'biscotti', 'pan di spagna', 'panna'], ]; $mealPlanText = ''; $mealPlanRule = ''; if (!empty($mealPlanType) && isset($mealPlanTypeLabels[$mealPlanType]) && $mealPlanTypeLabels[$mealPlanType] !== '') { $hint = $mealPlanTypeLabels[$mealPlanType]; // Scan inventory for ingredients matching this meal plan type $matchingItems = []; if (isset($typeKeywords[$mealPlanType])) { foreach ($items as $item) { $nameLower = mb_strtolower($item['name'] . ' ' . ($item['brand'] ?? '')); foreach ($typeKeywords[$mealPlanType] as $kw) { if (mb_strpos($nameLower, $kw) !== false) { $entry = "→ {$item['name']}" . ($item['brand'] ? " ({$item['brand']})" : '') . ": {$item['quantity']} {$item['unit']}"; if (!empty($item['expiry_date'])) { $dl = intval($item['days_left']); $entry .= $dl < 0 ? " [SCADUTO]" : " [scade tra $dl giorni]"; } $matchingItems[] = $entry; break; } } } $matchingItems = array_unique($matchingItems); } if (!empty($matchingItems)) { $matchingList = implode("\n", $matchingItems); $matchingBlock = "Ingredienti disponibili in dispensa compatibili con questa tipologia (usa almeno uno di questi come BASE della ricetta):\n{$matchingList}"; } else { $matchingBlock = "Nessun ingrediente perfettamente corrispondente trovato — usa la cosa più affine disponibile e segnalalo in nutrition_note."; } $mealPlanText = "\n\n🎯 TIPO OBBLIGATORIO: {$hint}\n{$matchingBlock}"; $mealPlanRule = "0. La ricetta DEVE essere: {$hint}. Usa gli ingredienti compatibili come base.\n "; } // Today's previous recipes from DB - avoid repetition $todayText = ''; $today = date('Y-m-d'); $weekAgo = date('Y-m-d', strtotime('-7 days')); // Get this week's recipes for variety $weekStmt = $db->prepare("SELECT date, meal, recipe_json FROM recipes WHERE date >= ? ORDER BY date DESC"); $weekStmt->execute([$weekAgo]); $weekDbRecipes = $weekStmt->fetchAll(); $todayTitles = []; $weekTitles = []; foreach ($weekDbRecipes as $tr) { $rj = json_decode($tr['recipe_json'], true); if (!empty($rj['title'])) { $weekTitles[] = $rj['title']; if ($tr['date'] === $today) { $todayTitles[] = $rj['title']; } } } if (!empty($todayRecipes)) { $todayTitles = array_unique(array_merge($todayTitles, $todayRecipes)); } $varietyText = ''; if (!empty($todayTitles)) { $todayList = implode(', ', array_map(function($t) { return '"' . $t . '"'; }, $todayTitles)); $varietyText .= "\n\nGIÀ FATTO OGGI: {$todayList} — proponi qualcosa di DIVERSO."; } // Weekly variety: list all recent recipes so AI avoids repetition $weekOnly = array_diff($weekTitles, $todayTitles); if (!empty($weekOnly)) { $weekList = implode(', ', array_map(function($t) { return '"' . $t . '"'; }, array_values($weekOnly))); $varietyText .= "\n\nULTIMI 7GG: {$weekList} — varia."; } // If this is a re-generation, stress the need for a truly different recipe $regenText = ''; if ($variation > 0) { $regenText = "\n\n🔁 RIGENERA #{$variation}: proponi qualcosa di COMPLETAMENTE DIVERSO (altro stile, altro ingrediente principale, altra tecnica)."; if (!empty($rejectedIngredients)) { $rejList = implode(', ', array_map(fn($n) => '"' . $n . '"', $rejectedIngredients)); $regenText .= " Evita come ingrediente principale: {$rejList}."; } } $promptLanguageRule = recipeText($lang, 'prompt_lang_rule'); $promptStepExample = recipeText($lang, 'prompt_step_example'); $prompt = << [ [ 'parts' => [ ['text' => $prompt] ] ] ], 'generationConfig' => [ 'temperature' => min(1.4, 0.7 + $variation * 0.25), 'maxOutputTokens' => 2048 ] ]; $result = callGeminiWithFallback($apiKey, $payload, 60, 'recipe'); $httpCode = $result['http_code']; if ($httpCode !== 200) { $errDetail = $result['data']['error']['message'] ?? substr($result['body'], 0, 300); echo json_encode(['success' => false, 'error' => recipeText($lang, 'error_gemini_api'), 'http_code' => $httpCode, 'detail' => $errDetail]); return; } $data = $result['data']; $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; // Clean markdown wrapping $text = preg_replace('/^```json\\s*/i', '', $text); $text = preg_replace('/\\s*```$/i', '', $text); $text = trim($text); $recipe = json_decode($text, true); if ($recipe && !empty($recipe['title'])) { $removed = recipePostProcessGenerated($db, $recipe, $items); EverLog::info('recipe generated', ['title' => $recipe['title'] ?? '?', 'meal' => $mealType, 'persons' => $persons, 'ingredients' => count($recipe['ingredients'] ?? []), 'shopping_suggestions' => count($removed)]); echo json_encode(['success' => true, 'recipe' => $recipe]); } else { EverLog::warn('recipe generation failed, empty parse', ['raw_len' => strlen($text)]); echo json_encode(['success' => false, 'error' => recipeText($lang, 'error_cannot_generate'), 'raw' => $text]); } } function chatToRecipe(PDO $db): void { $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { EverLog::debug('chatToRecipe'); echo json_encode(['success' => false, 'error' => 'no_api_key']); return; } $input = json_decode(file_get_contents('php://input'), true); $replyText = trim($input['text'] ?? ''); $lang = recipeNormalizeLang($input['lang'] ?? 'it'); if (empty($replyText)) { echo json_encode(['success' => false, 'error' => 'empty_text']); return; } // Fetch full inventory — same query as generateRecipe $stmt = $db->query(" SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at, CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 ORDER BY days_left ASC "); $items = $stmt->fetchAll(PDO::FETCH_ASSOC); // Ask Gemini to convert the chat recipe text into the full structured recipe JSON. // Prompt is tiny — no inventory sent to Gemini (PHP does all the matching below). $prompt = << [['role' => 'user', 'parts' => [['text' => $prompt]]]], 'generationConfig' => ['temperature' => 0.1, 'maxOutputTokens' => 8192] ]; $result = callGeminiWithFallback($apiKey, $payload, 45, 'chat_recipe'); if ($result['http_code'] !== 200) { echo json_encode(['success' => false, 'error' => $result['data']['error']['message'] ?? 'gemini_error']); return; } $text = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; if (empty($text)) { echo json_encode(['success' => false, 'error' => 'gemini_error']); return; } // Strip markdown code fences (handles ```json ... ``` anywhere in the response) $text = preg_replace('/```(?:json)?\s*/i', '', $text); $text = str_replace('```', '', $text); // Extract the first complete JSON object from the text (ignores any preamble text) $start = strpos($text, '{'); $end = strrpos($text, '}'); if ($start === false || $end === false || $end <= $start) { echo json_encode(['success' => false, 'error' => 'parse_error', 'raw' => mb_substr($text, 0, 500)]); return; } $text = substr($text, $start, $end - $start + 1); $recipe = json_decode($text, true); if (!is_array($recipe) || empty($recipe['title'])) { echo json_encode(['success' => false, 'error' => 'parse_error', 'raw' => mb_substr($text, 0, 500)]); return; } if (!empty($recipe['ingredients'])) { recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items); } recipeApplyStockHintsToRecipe($db, $recipe); $removed = recipeEnforcePantryOnly($recipe); recipeAttachShoppingSuggestions($recipe, $removed); echo json_encode(['success' => true, 'recipe' => $recipe]); } // ===== RECIPE FROM INGREDIENT ===== function recipeFromIngredient(PDO $db): void { EverLog::info('recipeFromIngredient'); $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { echo json_encode(['success' => false, 'error' => 'no_api_key']); return; } $input = json_decode(file_get_contents('php://input'), true); $ingredientName = trim($input['ingredient'] ?? ''); if (empty($ingredientName)) { echo json_encode(['success' => false, 'error' => 'empty_ingredient']); return; } $lang = recipeNormalizeLang($input['lang'] ?? 'it'); $langName = recipeLangName($lang); $persons = max(1, intval($input['persons'] ?? 1)); // Fetch inventory (same as generateRecipe) $stmt = $db->query(" SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at, CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 ORDER BY days_left ASC "); $items = $stmt->fetchAll(PDO::FETCH_ASSOC); // Build compact pantry text (same logic as generateRecipe) $ingredientLines = []; foreach ($items as $item) { $line = "- {$item['name']}: {$item['quantity']} {$item['unit']}"; if ($item['unit'] === 'conf' && !empty($item['package_unit']) && $item['default_quantity'] > 0) { $line .= " ({$item['default_quantity']}{$item['package_unit']}/conf)"; } if ($item['unit'] === 'pz') $line .= ' [usa PEZZI interi]'; $dl = intval($item['days_left']); if (!empty($item['expiry_date'])) { if ($dl < 0) $line .= ' ⚠️SCADUTO'; elseif ($dl <= 3) $line .= " 🔴{$dl}gg"; elseif ($dl <= 7) $line .= " 🟠{$dl}gg"; } $line .= recipePantryLineExtraFlags($item); $ingredientLines[] = $line; } $ingredientsText = implode("\n", $ingredientLines); $safeName = htmlspecialchars($ingredientName, ENT_QUOTES, 'UTF-8'); $prompt = << [['role' => 'user', 'parts' => [['text' => $prompt]]]], 'generationConfig' => ['temperature' => 0.7, 'maxOutputTokens' => 8192], ]; $result = callGeminiWithFallback($apiKey, $payload, 45, 'recipe_ingredient'); if ($result['http_code'] !== 200) { echo json_encode(['success' => false, 'error' => $result['data']['error']['message'] ?? 'gemini_error']); return; } $text = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; if (empty($text)) { echo json_encode(['success' => false, 'error' => 'gemini_error']); return; } $text = preg_replace('/```(?:json)?\s*/i', '', $text); $text = str_replace('```', '', $text); $start = strpos($text, '{'); $end = strrpos($text, '}'); if ($start === false || $end === false || $end <= $start) { echo json_encode(['success' => false, 'error' => 'parse_error', 'raw' => mb_substr($text, 0, 500)]); return; } $text = substr($text, $start, $end - $start + 1); $recipe = json_decode($text, true); if (!is_array($recipe) || empty($recipe['title'])) { echo json_encode(['success' => false, 'error' => 'parse_error', 'raw' => mb_substr($text, 0, 500)]); return; } recipePostProcessGenerated($db, $recipe, $items); EverLog::info('recipe_from_ingredient ok', ['ingredient' => $ingredientName, 'title' => $recipe['title'] ?? '?', 'persons' => $persons]); echo json_encode(['success' => true, 'recipe' => $recipe]); } function _enrichChatIngredients(array &$ingredients, array $items, PDO $db): void { recipeEnrichIngredientsFromPantry($db, $ingredients, $items); } // ===== RECIPE GENERATION — STREAMING AGENT ===== function generateRecipeStream(PDO $db): void { EverLog::info('generateRecipeStream'); // Override content-type for SSE before any output is sent header('Content-Type: text/event-stream'); header('Cache-Control: no-cache, no-store, must-revalidate'); header('X-Accel-Buffering: no'); header('Content-Encoding: identity'); set_time_limit(600); // up to 10 min: worst-case 2 models x 2 retries x 90s wait + generation time ignore_user_abort(true); while (ob_get_level() > 0) ob_end_clean(); $send = function(string $type, array $data): void { echo 'data: ' . json_encode(['type' => $type] + $data, JSON_UNESCAPED_UNICODE) . "\n\n"; flush(); }; try { $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { $send('error', ['error' => 'no_api_key']); return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; $lang = recipeNormalizeLang($input['lang'] ?? 'it'); $recipeLangName = recipeLangName($lang); $mealType = $input['meal'] ?? 'pranzo'; $persons = max(1, intval($input['persons'] ?? 1)); $subType = $input['sub_type'] ?? ''; $options = $input['options'] ?? []; $appliances = $input['appliances'] ?? []; $dietaryRestrictions = $input['dietary_restrictions'] ?? ''; $todayRecipes = $input['today_recipes'] ?? []; $mealPlanType = $input['meal_plan_type'] ?? ''; $variation = max(0, intval($input['variation'] ?? 0)); $rejectedIngredients = $input['rejected_ingredients'] ?? []; // ── AGENTE PASSO 1: Analisi dispensa ───────────────────────────────────── $send('status', ['step' => 1, 'message' => recipeText($lang, 'status_analyze_pantry')]); $stmt = $db->query(" SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at, CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 ORDER BY days_left ASC "); $items = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($items)) { $send('error', ['error' => recipeText($lang, 'error_pantry_empty')]); return; } $getItemPriority = function($item): int { $daysLeft = floatval($item['days_left']); $isOpen = !empty($item['opened_at']) || (floatval($item['quantity']) > 0 && floatval($item['quantity']) < 1 && $item['unit'] === 'conf'); if (!empty($item['expiry_date']) && $daysLeft < 0) return 1; if (!empty($item['expiry_date']) && $daysLeft <= 3) return 2; if (!empty($item['expiry_date']) && $daysLeft <= 7) return 3; if ($isOpen) return 3; // opened items: same priority as expiring this week — must be used soon if (!empty($item['expiry_date'])) return 4; return 6; }; usort($items, function($a, $b) use ($getItemPriority) { $pa = $getItemPriority($a); $pb = $getItemPriority($b); if ($pa !== $pb) return $pa - $pb; return floatval($a['days_left']) - floatval($b['days_left']); }); $staplePatterns = '/\b(sale|pepe|olio d.oliva|olio di semi|olio extra|acqua|aceto balsamico|aceto di|sel marin)\b/i'; $priorityGroups = []; foreach ($items as $item) { $group = $getItemPriority($item); if ($group >= 5 && preg_match($staplePatterns, $item['name'])) continue; $qty = floatval($item['quantity']); $isOpen = !empty($item['opened_at']) || ($qty > 0 && $qty < 1 && $item['unit'] === 'conf'); $daysLeft = intval($item['days_left']); $line = "- {$item['name']}: {$item['quantity']} {$item['unit']}"; if ($item['unit'] === 'conf' && !empty($item['package_unit']) && $item['default_quantity'] > 0) $line .= " ({$item['default_quantity']}{$item['package_unit']}/conf)"; if ($item['unit'] === 'pz') $line .= ' [usa PEZZI interi — qty_number in pz, non grammi]'; // Annotazioni urgenza: solo gruppi 1-3 (riduce token per gruppi 4-6) if ($group <= 3 && $item['expiry_date']) { if ($daysLeft < 0) $line .= ' ⚠️SCADUTO'; elseif ($daysLeft <= 3) $line .= " 🔴{$daysLeft}gg"; else $line .= " 🟠{$daysLeft}gg"; } $line .= recipePantryLineExtraFlags($item, $group); $priorityGroups[$group][] = $line; } // Send the full in-stock list — AI must not invent products outside this list. $ingredientSections = []; $priorityHeaders = [1=>'SCADUTI — usa subito',2=>'SCADENZA ≤3gg — priorità alta',3=>'SCADENZA ≤7gg / APERTI — usa presto',4=>'ALTRI CON SCADENZA',6=>'DISPENSA']; $totalIngredientsSent = 0; foreach ($priorityHeaders as $g => $header) { if (empty($priorityGroups[$g])) continue; $gi = $priorityGroups[$g]; $ingredientSections[] = "[$header]\n" . implode("\n", $gi); $totalIngredientsSent += count($gi); } $ingredientsText = implode("\n", $ingredientSections); // Inventory status event $urgentCount = count($priorityGroups[1] ?? []) + count($priorityGroups[2] ?? []); if ($urgentCount > 0) { $urgentRaw = array_merge($priorityGroups[1] ?? [], $priorityGroups[2] ?? []); $urgentNames = array_slice(array_map( fn($l) => trim(preg_replace('/\s[\[\x{26A0}\x{1F534}\x{1F7E0}].*/u', '', explode(':', ltrim($l, '- '))[0])), $urgentRaw), 0, 3); $send('status', ['step' => 1, 'message' => recipeText($lang, 'status_urgent', ['n' => $urgentCount, 'items' => implode(', ', $urgentNames)])]); } else { $countMsg = recipeText($lang, 'status_products_found', ['n' => count($items)]); if ($hasMealPlan && $totalIngredientsSent < count($items)) { $countMsg .= recipeText($lang, 'status_passed_ai', ['n' => $totalIngredientsSent]); } elseif ($hasMealPlan) { $countMsg .= recipeText($lang, 'status_all_passed_ai'); } $send('status', ['step' => 1, 'message' => '✅ ' . $countMsg]); } // Mandatory/recommended items $mandatoryItems = []; $recommendedItems = []; $wantsExpiryPriority = in_array('scadenze', $options) || in_array('zerowaste', $options); $wantsOpenedPriority = in_array('opened', $options); if ($wantsExpiryPriority || $wantsOpenedPriority) { foreach ($items as $item) { $g = $getItemPriority($item); $daysLeft = floatval($item['days_left']); $isOpen = !empty($item['opened_at']) || (floatval($item['quantity']) > 0 && floatval($item['quantity']) < 1 && $item['unit'] === 'conf'); $expiryNote = !empty($item['expiry_date']) ? " — scade: {$item['expiry_date']}" : ''; $openNote = $isOpen ? ' [APERTO]' : ''; $label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . $openNote . $expiryNote; if ($wantsExpiryPriority) { if ($g === 1 || $g === 2) $mandatoryItems[] = $label; elseif ($g === 3) $recommendedItems[] = $label; } if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 7 && $daysLeft >= 0) { if (!in_array($label, $mandatoryItems) && !in_array($label, $recommendedItems)) $recommendedItems[] = $label; } } } $mustUseText = ''; if (!empty($mandatoryItems)) $mustUseText .= "\n\n⚠️ OBBLIGATORI (scaduti/imminenti — DEVE usarne almeno 1):\n" . implode("\n", array_map(fn($n) => "→ $n", $mandatoryItems)); if (!empty($recommendedItems)) $mustUseText .= "\n\n🔶 CONSIGLIATI (aperti/in scadenza):\n" . implode("\n", array_map(fn($n) => "· $n", $recommendedItems)); // Meal labels $mealLabels = ['colazione'=>'colazione (mattina)','pranzo'=>'pranzo (mezzogiorno)','cena'=>'cena (sera)','dolce'=>'dolce/dessert','succo'=>'succo di frutta/bevanda']; $mealLabel = $mealLabels[$mealType] ?? $mealType; $mealLabelSimple = ['colazione'=>'colazione','pranzo'=>'pranzo','cena'=>'cena','dolce'=>'dolce','succo'=>'succo']; $subTypeLabels = [ 'dolce' => ['torta'=>'Torta (soffice, da forno: torta di mele, ciambellone, plumcake, angel cake, ecc.)','crema'=>'Crema o Budino (crema pasticcera, panna cotta, mousse, tiramisù, budino, semifreddo)','crumble'=>'Crumble o Crostata (base croccante: crumble di frutta, crostata, sbriciolata)','biscotti'=>'Biscotti o Pasticcini (biscotti, cookies, muffin, cupcake, pasticcini)','frutta'=>'Dolce alla Frutta (macedonia creativa, frutta caramellata, sorbetto, frullato dolce)'], 'succo' => ['dolce'=>'Succo Dolce e Fruttato (mix di frutta dolce: pesca, mela, pera, fragola, banana)','energizzante'=>'Succo Energizzante (con zenzero, curcuma, barbabietola, carota, mela verde)','detox'=>'Succo Detox / Verde (cetriolo, sedano, spinaci, mela verde, limone)','rinfrescante'=>'Succo Rinfrescante (anguria, menta, lime, cetriolo, acqua di cocco)','vitaminico'=>'Succo Vitaminico / Agrumi (arancia, pompelmo, limone, kiwi, mandarino)'], ]; $subTypeText = ''; if (!empty($subType) && isset($subTypeLabels[$mealType][$subType])) { $subHint = $subTypeLabels[$mealType][$subType]; $mealLabel .= " — tipo: $subHint"; $subTypeText = "\n\n🎨 SOTTO-TIPO: {$subHint}. La ricetta DEVE essere di questo tipo."; } $extraRules = []; $optionLabels = ['veloce'=>'VELOCE: max 15-20 min totali.','pocafame'=>'POCA FAME: porzione leggera, snack o insalata.','scadenze'=>'PRIORITÀ SCADENZE: usa per primi i prodotti in scadenza.','salutare'=>'SALUTARE: ingredienti integrali, verdure, pochi grassi.','opened'=>'PRIORITÀ APERTI: usa per primi i prodotti [APERTO].','zerowaste'=>'ZERO SPRECHI: usa il più possibile ingredienti in scadenza.']; foreach ($options as $opt) { if (isset($optionLabels[$opt])) $extraRules[] = $optionLabels[$opt]; } $extraRulesText = !empty($extraRules) ? "\n\nPREFERENZE DELL'UTENTE:\n" . implode("\n", $extraRules) : ''; $appliancesText = _buildAppliancesPrompt($appliances, compact: false); $dietaryText = !empty($dietaryRestrictions) ? "\n\nRESTRIZIONI ALIMENTARI:\n{$dietaryRestrictions}\nRispetta SEMPRE queste restrizioni." : ''; $mealPlanTypeLabels = ['pasta'=>'Pasta (primo piatto a base di pasta)','riso'=>'Riso (risotto, insalata di riso, riso saltato, ecc.)','carne'=>'Carne (secondo piatto a base di carne)','pesce'=>'Pesce (secondo piatto a base di pesce o frutti di mare)','legumi'=>'Legumi (zuppa, insalata, hummus, pasta e fagioli, ecc.)','uova'=>'Uova (frittata, uova strapazzate, quiche, ecc.)','formaggio'=>'Formaggio (fonduta, gnocchi al formaggio, torta salata, ecc.)','pizza'=>'Pizza o focaccia (impastata in casa o usi ingredienti simili)','affettati'=>'Affettati (tagliere misto, piadina, panino, ecc.)','verdure'=>'Verdure (piatto principale a base di verdure, contorno abbondante)','zuppa'=>'Zuppa o minestra (zuppe, vellutate, minestrone)','insalata'=>'Insalata (insalata mista, insalata di riso o pasta, poke)','pane'=>'Pane / Sandwich (toast, tramezzino, bruschette)','dolce'=>'Dolce o dessert','libero'=>'']; $typeKeywords = ['pesce'=>['tonno','salmone','merluzzo','branzino','orata','sardine','acciughe','alici','gamberi','cozze','vongole','polpo','calamari','seppia','sgombro','trota','baccalà','dentice','spigola','pesce'],'carne'=>['pollo','manzo','maiale','vitello','agnello','tacchino','salsiccia','hamburger','bistecca','cotoletta','pancetta','speck','carne','arrosto','filetto','lonza','braciola'],'pasta'=>['pasta','spaghetti','penne','rigatoni','fusilli','tagliatelle','lasagne','farfalle','orecchiette','bucatini','linguine','maccheroni','gnocchi','pennette','bavette'],'riso'=>['riso','basmati','arborio','carnaroli','parboiled','riso integrale'],'legumi'=>['fagioli','ceci','lenticchie','piselli','fave','lupini','soia','legumi','borlotti','cannellini','azuki'],'uova'=>['uova','uovo'],'formaggio'=>['formaggio','parmigiano','mozzarella','ricotta','pecorino','grana','gorgonzola','scamorza','fontina','emmental','asiago','provola','provolone','taleggio','stracchino'],'pizza'=>['farina','lievito','pizza','focaccia'],'affettati'=>['prosciutto','salame','bresaola','mortadella','speck','coppa','affettati','wurstel','würstel','piadina','pancetta cotta'],'verdure'=>['zucchine','zucchina','melanzane','peperoni','spinaci','cavolfiore','broccoli','carote','zucca','bietole','cavolo','carciofi','asparagi','lattuga','rucola','radicchio','cicoria','finocchio','cipolla','porri','verdure'],'zuppa'=>['brodo','zuppa','minestra','minestrone','vellutata','orzo','farro','fagioli','ceci','lenticchie'],'insalata'=>['insalata','lattuga','rucola','spinaci','radicchio','misticanza','valeriana','songino'],'pane'=>['pane','pancarrè','baguette','toast','tramezzino','crackers','grissini','ciabatta','rosetta'],'dolce'=>['cioccolato','cacao','zucchero','miele','marmellata','nutella','creme caramel','savoiardi','biscotti','pan di spagna','panna']]; $mealPlanText = ''; $mealPlanRule = ''; if (!empty($mealPlanType) && isset($mealPlanTypeLabels[$mealPlanType]) && $mealPlanTypeLabels[$mealPlanType] !== '') { $hint = $mealPlanTypeLabels[$mealPlanType]; $matchingItems = []; if (isset($typeKeywords[$mealPlanType])) { foreach ($items as $item) { $nameLower = mb_strtolower($item['name'] . ' ' . ($item['brand'] ?? '')); foreach ($typeKeywords[$mealPlanType] as $kw) { if (mb_strpos($nameLower, $kw) !== false) { $entry = "→ {$item['name']}" . ($item['brand'] ? " ({$item['brand']})" : '') . ": {$item['quantity']} {$item['unit']}"; if (!empty($item['expiry_date'])) { $dl = intval($item['days_left']); $entry .= $dl < 0 ? " [SCADUTO]" : " [scade tra $dl giorni]"; } $matchingItems[] = $entry; break; } } } $matchingItems = array_unique($matchingItems); } $matchingBlock = !empty($matchingItems) ? "Ingredienti disponibili compatibili (usa almeno uno come BASE):\n" . implode("\n", $matchingItems) : "Nessun ingrediente perfettamente corrispondente — usa la cosa più affine disponibile e segnalalo in nutrition_note."; $mealPlanText = "\n\n🎯 TIPO OBBLIGATORIO: {$hint}\n{$matchingBlock}"; $mealPlanRule = "0. La ricetta DEVE essere: {$hint}. Usa gli ingredienti compatibili come base.\n "; } $varietyText = ''; $today = date('Y-m-d'); $weekAgo = date('Y-m-d', strtotime('-7 days')); $weekStmt = $db->prepare("SELECT date, meal, recipe_json FROM recipes WHERE date >= ? ORDER BY date DESC"); $weekStmt->execute([$weekAgo]); $weekDbRecipes = $weekStmt->fetchAll(); $todayTitles = []; $weekTitles = []; foreach ($weekDbRecipes as $tr) { $rj = json_decode($tr['recipe_json'], true); if (!empty($rj['title'])) { $weekTitles[] = $rj['title']; if ($tr['date'] === $today) $todayTitles[] = $rj['title']; } } if (!empty($todayRecipes)) $todayTitles = array_unique(array_merge($todayTitles, $todayRecipes)); if (!empty($todayTitles)) { $todayList = implode(', ', array_map(fn($t) => '"' . $t . '"', $todayTitles)); $varietyText .= "\n\nGIÀ FATTO OGGI: {$todayList} — proponi qualcosa di DIVERSO."; } $weekOnly = array_diff($weekTitles, $todayTitles); if (!empty($weekOnly)) { $weekList = implode(', ', array_map(fn($t) => '"' . $t . '"', array_values($weekOnly))); $varietyText .= "\n\nULTIMI 7GG: {$weekList} — varia."; } $regenText = ''; if ($variation > 0) { $regenText = "\n\n🔁 RIGENERA #{$variation}: proponi qualcosa di COMPLETAMENTE DIVERSO (altro stile, altro ingrediente principale, altra tecnica)."; if (!empty($rejectedIngredients)) { $rejList = implode(', ', array_map(fn($n) => '"' . $n . '"', $rejectedIngredients)); $regenText .= " Evita come ingrediente principale: {$rejList}."; } } // ── AGENTE PASSO 2: Selezione concetto (locale, nessuna chiamata AI) ──────── // Determina il concetto della ricetta in base agli ingredienti disponibili // e ai parametri selezionati — senza consumare quote Gemini. $send('status', ['step' => 2, 'message' => recipeText($lang, 'status_evaluate_ingredients')]); // Raccoglie i nomi degli ingredienti di maggiore priorità $conceptIngredients = []; foreach ([1, 2, 3, 5, 6] as $g) { foreach (array_slice($priorityGroups[$g] ?? [], 0, 4) as $line) { $name = trim(explode(':', ltrim($line, '- '))[0]); // Rimuove emoji e flag di urgenza $name = trim(preg_replace('/\s*[\x{26A0}\x{1F534}\x{1F7E0}].*$/u', '', $name)); $name = trim(preg_replace('/\s*\[.*\]/', '', $name)); if ($name) $conceptIngredients[] = $name; } if (count($conceptIngredients) >= 6) break; } // Costruisce un messaggio di stato informativo basato su ciò che verrà cucinato $conceptMsg = recipeText($lang, 'status_preparing_recipe'); if (!empty($mealPlanType) && isset($mealPlanTypeLabels[$mealPlanType]) && $mealPlanTypeLabels[$mealPlanType] !== '') { // Tipo di pasto dal piano settimanale — mostra la categoria $shortLabel = explode(' (', $mealPlanTypeLabels[$mealPlanType])[0]; $conceptMsg = recipeText($lang, 'status_dish_based_on', ['type' => $shortLabel]); // Aggiungi l'ingrediente principale se disponibile if (!empty($matchingItems)) { $firstMatch = ltrim(reset($matchingItems), '→ '); $fName = trim(explode(':', $firstMatch)[0]); if ($fName) $conceptMsg .= " ({$fName})"; } } elseif (!empty($conceptIngredients)) { // Mostra i primi 2 ingredienti più urgenti $shown = array_slice($conceptIngredients, 0, 2); $a = mb_strtolower($shown[0] ?? ''); $b = mb_strtolower($shown[1] ?? ''); $conceptMsg = recipeText($lang, 'status_recipe_with', ['a' => $a, 'b' => $b]); if ($variation > 0) $conceptMsg .= recipeText($lang, 'status_variant', ['n' => $variation]); } elseif (!empty($subType) && !empty($subTypeLabels[$mealType][$subType])) { $conceptMsg = "🎨 " . explode(' (', $subTypeLabels[$mealType][$subType])[0]; } $send('status', ['step' => 2, 'message' => $conceptMsg]); // ── AGENTE PASSO 3: Generazione ricetta (A+C: retry SSE-aware + fallback modello) ── $conceptHint = ''; $send('status', ['step' => 3, 'message' => recipeText($lang, 'status_creating_full_recipe')]); $promptLanguageRule = recipeText($lang, 'prompt_lang_rule'); $promptStepExample = recipeText($lang, 'prompt_step_example'); $prompt = << min(1.4, 0.7 + $variation * 0.25), 'maxOutputTokens' => 4096, 'thinkingConfig' => ['thinkingBudget' => 0], // disabilita thinking: libera token per output ]; $payload = ['contents' => [['parts' => [['text' => $prompt]]]], 'generationConfig' => $genConfig]; // A: retry SSE-aware con feedback live; C: fallback automatico su quota separata // Ordine: 2.5-flash (quota separata e spesso più disponibile) → 2.0-flash $models = [ 'gemini-2.5-flash', // primario: quota TPM separata da 2.0 'gemini-2.0-flash', // fallback ]; $result = null; $httpCode = 0; foreach ($models as $modelIdx => $model) { $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; $maxRetries = 3; // 1 chiamata + max 2 retry con attesa for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { $retryAfterHeader = null; $curlErrno = 0; $curlErrMsg = ''; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 90, CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$retryAfterHeader) { if (stripos($header, 'retry-after:') === 0) { $val = intval(trim(substr($header, strlen('retry-after:')))); if ($val > 0) $retryAfterHeader = $val; } return strlen($header); }, ]); $body = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($body === false) { $curlErrno = curl_errno($ch); $curlErrMsg = curl_error($ch); $body = ''; } curl_close($ch); $result = [ 'http_code' => $httpCode, 'body' => $body, 'data' => $body ? json_decode($body, true) : null, ]; // Successo o errore non-retry → esci dal loop retry if ($httpCode === 200) break 2; if ($httpCode !== 429 && $httpCode !== 503) break; if ($attempt >= $maxRetries) break; // Calcola attesa: usa Retry-After se presente, altrimenti 30s (poi cambieremo modello) $waitSec = $retryAfterHeader ?? 30; if ($body) { $errData = json_decode($body, true); foreach (($errData['error']['details'] ?? []) as $detail) { if (!empty($detail['retryDelay'])) { $parsed = intval(preg_replace('/\D/', '', $detail['retryDelay'])); if ($parsed > 0) { $waitSec = min($parsed + 2, 60); break; } } } } $waitSec = min($waitSec, 60); // cap a 60s // A: feedback live con countdown $modelName = str_replace('gemini-', 'Gemini ', $model); $send('status', ['step' => 3, 'message' => recipeText($lang, 'status_quota_wait', ['model' => $modelName, 's' => $waitSec, 'a' => $attempt, 'm' => $maxRetries])]); sleep($waitSec); $send('status', ['step' => 3, 'message' => recipeText($lang, 'status_retry_generation')]); } // C: se primario esaurito dopo tutti i retry, cambia modello immediatamente if ($httpCode === 429 && $modelIdx === 0) { $fallbackName = str_replace('gemini-', 'Gemini ', $models[1]); $send('status', ['step' => 3, 'message' => recipeText($lang, 'status_switch_model', ['model' => $fallbackName])]); continue; } break; } if ($httpCode !== 200) { if ($httpCode === 0) { // cURL-level failure: timeout, DNS, network down $curlLabel = $curlErrMsg ?: "cURL errno {$curlErrno}"; $send('error', ['error' => recipeText($lang, 'error_gemini_api'), 'http_code' => 0, 'detail' => "Nessuna risposta da Gemini ({$curlLabel}) — verifica la connessione del server o riprova tra qualche istante."]); } else { $errDetail = $result['data']['error']['message'] ?? substr($result['body'], 0, 300); $statusLabels = [429 => 'Quota API esaurita (429)', 503 => 'Servizio Gemini non disponibile (503)', 401 => 'API key non valida (401)', 403 => 'API key non autorizzata (403)', 500 => 'Errore interno Gemini (500)']; $statusLabel = $statusLabels[$httpCode] ?? "HTTP {$httpCode}"; $send('error', ['error' => recipeText($lang, 'error_gemini_api'), 'http_code' => $httpCode, 'detail' => "{$statusLabel}" . ($errDetail ? ": {$errDetail}" : '')]); } return; } $text = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; $text = preg_replace('/^```json\s*/i', '', $text); $text = preg_replace('/\s*```$/i', '', $text); $text = trim($text); $recipe = json_decode($text, true); if (!$recipe || empty($recipe['title'])) { $send('error', ['error' => recipeText($lang, 'error_cannot_generate'), 'raw' => $text]); return; } // Normalize steps: Gemini sometimes returns [{"text":"..."}, ...] instead of ["...", ...] if (!empty($recipe['steps']) && is_array($recipe['steps'])) { $recipe['steps'] = array_values(array_map(function($s) { if (is_string($s)) return $s; if (is_array($s)) return $s['text'] ?? $s['description'] ?? $s['step'] ?? json_encode($s, JSON_UNESCAPED_UNICODE); return (string)$s; }, $recipe['steps'])); } recipePostProcessGenerated($db, $recipe, $items); $send('status', ['step' => 4, 'message' => '✅ Ricetta pronta!']); $send('recipe', ['recipe' => $recipe]); } catch (\Throwable $e) { EverLog::error('generateRecipeStream fatal: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); $send('error', [ 'error' => 'Errore interno del server', 'detail' => $e->getMessage() . ' (' . basename($e->getFile()) . ':' . $e->getLine() . ')', ]); } } // ===== GEMINI AI PRODUCT IDENTIFICATION ===== function geminiIdentifyProduct(): void { $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { EverLog::info('geminiIdentifyProduct'); echo json_encode(['success' => false, 'error' => 'no_api_key']); return; } $input = json_decode(file_get_contents('php://input'), true); $imageBase64 = $input['image'] ?? ''; if (empty($imageBase64)) { echo json_encode(['success' => false, 'error' => 'No image provided']); return; } // Step 1: Ask Gemini to identify the product $prompt = << [ [ 'parts' => [ ['text' => $prompt], [ 'inline_data' => [ 'mime_type' => 'image/jpeg', 'data' => $imageBase64 ] ] ] ] ], 'generationConfig' => [ 'temperature' => 0.2, 'maxOutputTokens' => 512 ] ]; $result = callGeminiWithFallback($apiKey, $payload, 30, 'identify_product'); $httpCode = $result['http_code']; if ($httpCode !== 200) { $errMsg = $result['data']['error']['message'] ?? 'Gemini API error'; echo json_encode(['success' => false, 'error' => $errMsg, 'http_code' => $httpCode]); return; } $data = $result['data']; $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; $text = preg_replace('/^```json\\s*/i', '', $text); $text = preg_replace('/\\s*```$/i', '', $text); $text = trim($text); $identified = json_decode($text, true); if (!$identified || empty($identified['name'])) { echo json_encode(['success' => false, 'error' => 'Cannot identify the product', 'raw' => $text]); return; } // Step 2: Search Open Food Facts by product name to find a matching barcode $searchTerms = $identified['search_terms'] ?? $identified['name']; $offProducts = searchOpenFoodFacts($searchTerms, $identified['name'], $identified['brand'] ?? ''); echo json_encode([ 'success' => true, 'identified' => $identified, 'off_matches' => $offProducts ]); } function searchOpenFoodFacts(string $searchTerms, string $name, string $brand): array { $results = []; // Try multiple search strategies $queries = []; if (!empty($brand)) { EverLog::debug('searchOpenFoodFacts'); $queries[] = trim($brand . ' ' . $name); } $queries[] = $name; if ($searchTerms !== $name) { $queries[] = $searchTerms; } $seen = []; foreach ($queries as $query) { $encodedQuery = urlencode($query); $url = "https://world.openfoodfacts.org/cgi/search.pl?search_terms={$encodedQuery}&search_simple=1&action=process&json=1&page_size=5&fields=code,product_name,product_name_it,brands,image_front_small_url,quantity,categories_tags&lc=it"; $ctx = stream_context_create([ 'http' => [ 'timeout' => 8, 'header' => "User-Agent: DispensaManager/1.0\r\n" ] ]); $response = @file_get_contents($url, false, $ctx); if ($response === false) continue; $data = json_decode($response, true); if (empty($data['products'])) continue; foreach ($data['products'] as $p) { $code = $p['code'] ?? ''; if (empty($code) || isset($seen[$code])) continue; $seen[$code] = true; $pName = $p['product_name_it'] ?? $p['product_name'] ?? ''; if (empty($pName)) continue; $results[] = [ 'barcode' => $code, 'name' => $pName, 'brand' => $p['brands'] ?? '', 'image_url' => $p['image_front_small_url'] ?? '', 'quantity_info' => $p['quantity'] ?? '', 'category' => $p['categories_tags'][0] ?? '', ]; if (count($results) >= 6) break 2; } } return $results; } /** * Build a detailed appliances prompt fragment for Gemini recipe generation. * * For multi-function appliances (Cookeo, Bimby, Thermomix, Monsieur Cuisine, etc.) * the prompt explicitly instructs the AI to consolidate as many steps as possible * into that single machine rather than using multiple appliances or the stove. * * @param string[] $appliances List of appliance names from user settings. * @param bool $compact True = one-line format (chat); False = multi-line (recipe gen). */ function _buildAppliancesPrompt(array $appliances, bool $compact = false): string { if (empty($appliances)) return ''; // Multi-function all-in-one cookers: can sauté, boil, steam, pressure-cook, blend, etc. $multiFunction = [ 'cookeo', 'bimby', 'thermomix', 'monsieur cuisine', 'bimby tm', 'vorwerk', 'instant pot', 'multicooker', 'robot da cucina', 'robot cucina', 'macchina del pane', 'bread machine', ]; $detectedMulti = []; foreach ($appliances as $a) { $aLow = mb_strtolower(trim($a)); foreach ($multiFunction as $kw) { if (str_contains($aLow, $kw)) { $detectedMulti[] = $a; break; } } } $allList = implode(', ', $appliances); if (empty($detectedMulti)) { // No multi-function appliance: standard wording return $compact ? "\nElettrodomestici disponibili: {$allList} (più fornelli e forno sempre disponibili)." : "\n\nELETTRODOMESTICI: {$allList} (+ fornelli e forno). Usa SOLO questi."; } // Build capability hint per multi-function appliance $capabilityMap = [ 'cookeo' => 'rosolare, stufare, cuocere a pressione, vapore, saltare, riscaldare', 'bimby' => 'tritare, frullare, cuocere, soffriggere, vapore, impastare, pesare, emulsionare', 'thermomix' => 'tritare, frullare, cuocere, soffriggere, vapore, impastare, pesare, emulsionare', 'monsieur cuisine' => 'tritare, frullare, cuocere, soffriggere, vapore, impastare, pesare', 'instant pot' => 'rosolare, cuocere a pressione, stufare, vapore, slow cook, riscaldare', 'multicooker' => 'rosolare, cuocere a pressione, stufare, vapore, slow cook', 'robot da cucina' => 'tritare, frullare, cuocere, mescolare, impastare', 'robot cucina' => 'tritare, frullare, cuocere, mescolare, impastare', 'macchina del pane'=> 'impastare, lievitare, cuocere pane (ordine ingredienti: liquidi → farina → sale → zucchero → lievito in cima; scegliere programma: Base, Integrale, Francese, Rapido, Dolce, Solo impasto)', 'bread machine' => 'impastare, lievitare, cuocere pane (ordine: liquidi → farina → sale → zucchero → lievito in cima)', ]; $multiDetails = []; foreach ($detectedMulti as $a) { $aLow = mb_strtolower(trim($a)); $cap = ''; foreach ($capabilityMap as $kw => $caps) { if (str_contains($aLow, $kw)) { $cap = $caps; break; } } $multiDetails[] = $cap ? "{$a} ({$cap})" : $a; } $multiStr = implode(' e ', $multiDetails); // The other (non-multi) appliances available as backup $others = array_filter($appliances, fn($a) => !in_array($a, $detectedMulti)); $othersStr = !empty($others) ? ', ' . implode(', ', $others) . ' (accessori di supporto se serve)' : ''; if ($compact) { // When multiple specialized appliances are present, list each with capabilities. // Do NOT force-prefer one over another — the user may explicitly ask for a specific one. if (count($detectedMulti) === 1) { $single = $multiDetails[0]; return "\nElettrodomestici: {$allList}. Se la ricetta lo consente, preferisci usare {$single} per quanti più passaggi possibile."; } // Multiple specialized appliances: describe each, let the user's request decide $multiStr = implode('; ', $multiDetails); return "\nElettrodomestici: {$allList}. Apparecchi specializzati disponibili: {$multiStr}. Usa quello più adatto alla ricetta richiesta dall'utente, rispettando sempre la sua preferenza esplicita."; } $ruleLines = implode("\n", array_map(fn($d) => " → {$d}", $multiDetails)); return << time()) { return $cached; } } $url = 'https://api.getbring.com/rest/v2/bringauth'; $ctx = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => "Content-Type: application/x-www-form-urlencoded\r\nX-BRING-API-KEY: cof4Nc6D8sOprah0hUXrFl\r\nX-BRING-CLIENT: webApp\r\n", 'content' => http_build_query(['email' => $email, 'password' => $password]), 'timeout' => 10, ] ]); $response = @file_get_contents($url, false, $ctx); if ($response === false) return null; $data = json_decode($response, true); if (!isset($data['access_token'])) return null; $tokenData = [ 'access_token' => $data['access_token'], 'uuid' => $data['uuid'], 'bringListUUID' => $data['bringListUUID'] ?? '', 'expires' => time() + 3500, // tokens last ~1 hour ]; // Cache token @file_put_contents($cacheFile, json_encode($tokenData)); return $tokenData; } function bringRequest(string $method, string $url, ?string $body = null): ?array { $auth = bringAuth(); if (!$auth) { EverLog::debug('bringRequest'); return null; } $headers = "Authorization: Bearer {$auth['access_token']}\r\n" . "X-BRING-API-KEY: cof4Nc6D8sOprah0hUXrFl\r\n" . "X-BRING-CLIENT: webApp\r\n" . "Content-Type: application/x-www-form-urlencoded\r\n"; $opts = [ 'http' => [ 'method' => $method, 'header' => $headers, 'timeout' => 10, 'ignore_errors' => true, ] ]; if ($body !== null) { $opts['http']['content'] = $body; } $response = @file_get_contents($url, false, stream_context_create($opts)); if ($response === false) return null; $data = json_decode($response, true); return $data ?? ['_raw' => $response]; } /** * Load and cache the Bring! IT↔DE catalog mapping. * Returns ['de2it' => [German => Italian], 'it2de' => [italian_lower => German]] */ function bringCatalog(): array { $cacheFile = __DIR__ . '/../data/bring_catalog.json'; // Cache for 24 hours if (file_exists($cacheFile) && filemtime($cacheFile) > time() - 86400) { EverLog::debug('bringCatalog'); return json_decode(file_get_contents($cacheFile), true) ?: ['de2it' => [], 'it2de' => []]; } $json = @file_get_contents('https://web.getbring.com/locale/articles.it-IT.json'); if (!$json) return ['de2it' => [], 'it2de' => []]; $data = json_decode($json, true); if (!$data) return ['de2it' => [], 'it2de' => []]; $de2it = []; $it2de = []; foreach ($data as $deKey => $itVal) { if (!is_string($itVal) || empty($itVal)) continue; $de2it[$deKey] = $itVal; $it2de[mb_strtolower($itVal)] = $deKey; } $catalog = ['de2it' => $de2it, 'it2de' => $it2de]; @file_put_contents($cacheFile, json_encode($catalog, JSON_UNESCAPED_UNICODE)); return $catalog; } /** Translate a Bring! item name from German key to Italian display name */ function bringToItalian(string $name): string { $catalog = bringCatalog(); return $catalog['de2it'][$name] ?? $name; } /** Translate an Italian product name to the Bring! German catalog key (fuzzy match) */ function italianToBring(string $italianName): string { $catalog = bringCatalog(); $lower = mb_strtolower(trim($italianName)); // Pass 1: exact match if (isset($catalog['it2de'][$lower])) { return $catalog['it2de'][$lower]; } // Pass 2: whole-word match — catalog key must be a whole word inside the input. // Uses word-boundary logic (split on spaces) to avoid substring false positives like // "gin" inside "original", "rum" inside "crumble", "aceto" inside "pancetta", etc. // Only considers single-word catalog keys (multi-word keys need Pass 1 exact match). // To avoid ambiguous mappings (e.g. "pancetta dolce" => "mais"), skip generic qualifiers // and pick the most specific (longest) matching token. $inputWords = array_filter( preg_split('/\s+/', $lower), fn($w) => mb_strlen($w) >= 4 // skip very short words — too ambiguous ); $genericQualifiers = [ 'dolce','salato','light','bio','classico','original','naturale','fresco','fresca', 'intero','intera','magro','magra','piccolo','piccola','grande','rosso','bianco', // Generic descriptors that appear inside multi-word product names (e.g. "succo e polpa frutta", // "muesli frutta secca") but do NOT represent the item category on their own. // Pass 1 (exact match on shopping_name) still works correctly for truly generic items // like shopping_name='Frutta' → it2de['frutta'] = 'Früchte'. 'frutta','verdura','frutti', ]; $candidates = []; foreach ($catalog['it2de'] as $itLower => $deKey) { if (str_contains($itLower, ' ')) continue; // multi-word key → exact-only if (mb_strlen($itLower) < 4) continue; // too short → skip (gin, rum, etc.) if (in_array($itLower, $genericQualifiers, true)) continue; if (in_array($itLower, $inputWords, true)) { $candidates[] = ['it' => $itLower, 'de' => $deKey, 'len' => mb_strlen($itLower)]; } } if (!empty($candidates)) { usort($candidates, fn($a, $b) => $b['len'] <=> $a['len']); return $candidates[0]['de']; } // No match — return the original Italian name so Bring! shows it as a custom item return $italianName; } /** * Auto-compute a generic shopping/Bring! name for a product. * * Priority: * 1. Curated keyword map — groups cured meats, etc. that the catalog doesn't unify * 2. Bring! catalog back-translation — "Latte di Montagna" → "Milch" → "Latte" * 3. First significant token capitalized * * The returned string is always a valid Bring! catalog name where possible, * so that italianToBring(computeShoppingName($n)) resolves to a catalog key. */ /** * Ask Gemini to classify a product name into a short Italian shopping category word. * Results are cached in a local JSON file to avoid repeated API calls. * Returns null on failure so the caller can fall back gracefully. */ function _geminiClassifyProduct(string $name, string $brand, string $category): ?string { EverLog::debug('_geminiClassifyProduct'); $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) return null; // Load/save classification cache $cacheFile = __DIR__ . '/../data/shopping_name_cache.json'; $cache = []; if (file_exists($cacheFile)) { $raw = @file_get_contents($cacheFile); if ($raw) $cache = json_decode($raw, true) ?: []; } $cacheKey = md5(mb_strtolower($name . '|' . $brand)); if (isset($cache[$cacheKey])) return $cache[$cacheKey]; // Build catalog list so Gemini picks an existing Bring! entry when possible $catalog = bringCatalog(); $catalogList = implode(', ', array_slice(array_values($catalog['de2it']), 0, 200)); $prompt = << [['parts' => [['text' => $prompt]]]], 'generationConfig' => ['temperature' => 0.1, 'maxOutputTokens' => 16], ]; $result = callGeminiWithFallback($apiKey, $payload, 15, 'classify_category'); if ($result['http_code'] !== 200 || !isset($result['data']['candidates'][0])) return null; $text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''); // Sanitize: keep only letters and spaces, max 30 chars, capitalize first letter $text = preg_replace('/[^\p{L}\s]/u', '', $text); $text = trim(preg_replace('/\s+/', ' ', $text)); if (mb_strlen($text) < 2 || mb_strlen($text) > 30) return null; $text = mb_strtoupper(mb_substr($text, 0, 1)) . mb_substr($text, 1); // Persist to cache $cache[$cacheKey] = $text; @file_put_contents($cacheFile, json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); return $text; } /** True when a product truly belongs to its assigned shopping_name family (excludes mis-tags like "Tè al limone" → Limone). */ function productMatchesShoppingFamily(string $productName, string $shoppingName): bool { $sn = mb_strtolower(trim($shoppingName)); if ($sn === '') return false; $computed = mb_strtolower(computeShoppingName($productName)); $snComputed = mb_strtolower(computeShoppingName($shoppingName)); if ($computed === $sn || $computed === $snComputed) return true; $nameLower = mb_strtolower(trim($productName)); return $nameLower === $sn || str_starts_with($nameLower, $sn . ' '); } /** Rice/pasta prepared salads (Ponti etc.) — not fresh leafy salad. */ function isPreparedSaladProduct(string $name, string $brand = ''): bool { $n = mb_strtolower(trim($name)); $b = mb_strtolower(trim($brand)); if (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous|quinoa|bulgur|cereali|legumi)\b/u', $n)) { return true; } if (preg_match('/\binsalata\b/u', $n) && preg_match('/\b(ponti|rio mare|orogel|findus|star)\b/u', $b)) { return true; } return false; } function computeShoppingName(string $name, string $category = '', string $brand = ''): string { $lower = mb_strtolower(trim($name)); if (isPreparedSaladProduct($name, $brand) && !preg_match('/insalata\s+di\s+riso/u', $lower)) { return 'Insalata di riso'; } $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', 'parzialmente','scremato','uht','bio','light','freschi','fresca','fresco']; $tokens = array_values(array_filter( preg_split('/\s+/', preg_replace('/[^\p{L}\s]/u', ' ', $lower)), fn($w) => mb_strlen($w) > 2 && !in_array($w, $stop) )); // 0. Compound-phrase map — checked against the FULL lowercase name (stop words included) // so multi-word product types are classified BEFORE single-token lookup. // This prevents "Pane grattugiato" → "Pane", "Panna da cucina" → "Panna", etc. $phraseMap = [ // Breadcrumbs (MUST come before generic "pane") 'pangrattato' => 'Pangrattato', 'pan grattato' => 'Pangrattato', 'pane grattato' => 'Pangrattato', 'pane grattugiato' => 'Pangrattato', 'pan grattugiato' => 'Pangrattato', // Cooking cream (MUST come before generic "panna") 'panna da cucina' => 'Panna da cucina', 'panna cucina' => 'Panna da cucina', 'panna chef' => 'Panna da cucina', // Tea (must not collapse to "Limone" via token) 'tè al limone' => 'Tè al limone', 'te al limone' => 'Tè al limone', 'the al limone' => 'Tè al limone', 'panna acida' => 'Panna acida', // Tomato preparations (MUST come before generic "pomodoro/pomodori") 'passata di pomodoro' => 'Passata', 'passata pomodoro' => 'Passata', 'polpa di pomodoro' => 'Polpa di pomodoro', 'polpa pomodoro' => 'Polpa di pomodoro', 'sugo al pomodoro' => 'Sugo', 'sugo di pomodoro' => 'Sugo', 'salsa di pomodoro' => 'Sugo', 'pomodori pelati' => 'Pelati', 'pomodoro pelato' => 'Pelati', 'datterini pelati' => 'Pelati', 'pelati' => 'Pelati', // Frozen / prep vegetables 'misto soffritto' => 'Misto soffritto', 'misto per soffritto' => 'Misto soffritto', // Plant-based milks (MUST come before generic "latte") 'latte condensato' => 'Latte condensato', 'latte evaporato' => 'Latte condensato', 'latte di soia' => 'Latte di soia', 'latte soia' => 'Latte di soia', 'latte vegetale' => 'Latte vegetale', 'latte di mandorla' => 'Latte di mandorla', 'latte mandorla' => 'Latte di mandorla', 'latte di avena' => 'Latte di avena', 'latte avena' => 'Latte di avena', 'latte di riso' => 'Latte di riso', 'latte riso' => 'Latte di riso', 'latte di cocco' => 'Latte di cocco', 'latte cocco' => 'Latte di cocco', // Baked bakery — different from bread 'fette biscottate' => 'Fette biscottate', 'pan di spagna' => 'Pan di Spagna', // Specific vinegars 'aceto balsamico' => 'Aceto balsamico', 'glassa balsamico' => 'Aceto balsamico', 'glassa balsamic' => 'Aceto balsamico', // Cold cuts — specific cuts 'prosciutto cotto' => 'Prosciutto cotto', // Flour subtypes (MUST come before generic "farina") 'farina di riso' => 'Farina di riso', 'farina riso' => 'Farina di riso', 'farina di mais' => 'Farina di mais', 'farina mais' => 'Farina di mais', 'farina integrale' => 'Farina integrale', 'farina 00' => 'Farina', // Roux / sugar subtypes 'zucchero di canna' => 'Zucchero di canna', 'zucchero canna' => 'Zucchero di canna', 'zucchero velo' => 'Zucchero a velo', 'zucchero a velo' => 'Zucchero a velo', // Fresh pasta 'pasta fresca' => 'Pasta fresca', // Broth / stock 'brodo vegetale' => 'Brodo', 'brodo pollo' => 'Brodo', 'brodo manzo' => 'Brodo', // Mixed vegetable purée / passato (MUST come before generic carote/patate) 'passato di verdure' => 'Verdure', 'passato di patate' => 'Verdure', // Water 'acqua frizzante' => 'Acqua', 'acqua gassata' => 'Acqua', 'acqua minerale' => 'Acqua', // Aroma / flavouring 'aroma vaniglia' => 'Ingredienti Spezie', 'aroma mandorla' => 'Ingredienti Spezie', 'aroma limone' => 'Ingredienti Spezie', 'aroma rum' => 'Ingredienti Spezie', 'aroma arancia' => 'Ingredienti Spezie', // Prepared salads (not fresh greens) 'insalata di riso' => 'Insalata di riso', 'insalata di pasta' => 'Insalata di pasta', 'insalata di farro' => 'Insalata di farro', 'insalata di orzo' => 'Insalata di orzo', 'insalata di couscous' => 'Insalata di couscous', 'insalata di quinoa' => 'Insalata di quinoa', ]; foreach ($phraseMap as $phrase => $canonical) { if (mb_strpos($lower, $phrase) !== false) { return $canonical; } } // 1. Curated keyword → canonical group name. // Extended list covers the most common Italian pantry items and avoids Gemini calls. $keywordMap = [ // Cold cuts / affettati 'mortadella' => 'Affettato', 'nduja' => 'Affettato', 'salame' => 'Affettato', 'salami' => 'Affettato', 'coppa' => 'Affettato', 'capicola' => 'Affettato', 'speck' => 'Affettato', 'schinkenspeck' => 'Affettato', 'schinken' => 'Affettato', 'prosciutto' => 'Affettato', // Items with their own Bring! entry 'bresaola' => 'Bresaola', 'pancetta' => 'Pancetta', 'salsiccia' => 'Salsiccia', 'wurstel' => 'Wurstel', // Bread & bakery 'pane' => 'Pane', 'bauletto' => 'Pane', 'pancarrè' => 'Pane', 'pancare' => 'Pane', 'toast' => 'Pane', 'focaccia' => 'Pane', 'ciabatta' => 'Pane', 'baguette' => 'Pane', 'grissini' => 'Grissini', 'crackers' => 'Cracker', 'cracker' => 'Cracker', 'taralli' => 'Taralli', 'tarallini' => 'Taralli', 'piadina' => 'Piadina', 'piadelle' => 'Piadina', 'biscotto' => 'Biscotti', 'biscotti' => 'Biscotti', // Breadcrumbs single-token safety net (phrase map has priority, but just in case) 'grattugiato' => 'Pangrattato', 'grattato' => 'Pangrattato', 'pangrattato' => 'Pangrattato', 'biscottate' => 'Fette biscottate', // Leavening agents 'lievito' => 'Lievito', // Flavourings / aromas (single-token fallback; phrases handled above) 'aroma' => 'Ingredienti Spezie', // Dairy 'latte' => 'Latte', 'yogurt' => 'Yogurt', 'yaourt' => 'Yogurt', 'yougurt' => 'Yogurt', 'burro' => 'Burro', 'panna' => 'Panna', 'mozzarella' => 'Mozzarella', 'formaggio' => 'Formaggio', 'ricotta' => 'Ricotta', 'ricottina' => 'Ricotta', 'casatella' => 'Formaggio', 'philadelphia' => 'Formaggio cremoso', // "Bel Paese" — known Italian cheese brand 'bel' => 'Formaggio', // Pasta 'pasta' => 'Pasta', 'spaghetti' => 'Pasta', 'penne' => 'Pasta', 'rigatoni' => 'Pasta', 'fusilli' => 'Pasta', 'orecchiette' => 'Pasta', 'tortiglioni' => 'Pasta', 'linguine' => 'Pasta', 'sedani' => 'Pasta', 'lasagne' => 'Pasta', 'tortellini' => 'Pasta', 'gnocchi' => 'Gnocchi', // Rice 'riso' => 'Riso', // Eggs 'uova' => 'Uova', 'uovo' => 'Uova', // Fruit & veg 'mela' => 'Mele', 'mele' => 'Mele', 'pera' => 'Pere', 'arancia' => 'Arance', 'arance' => 'Arance', 'limone' => 'Limone', 'banana' => 'Banane', 'banane' => 'Banane', 'kiwi' => 'Kiwi', 'avocado' => 'Avocado', 'pomodoro' => 'Pomodori', 'pomodori' => 'Pomodori', 'pomodorini' => 'Pomodorini', 'carota' => 'Carote', 'carote' => 'Carote', 'cipolla' => 'Cipolla', 'cipolle' => 'Cipolla', 'aglio' => 'Aglio', 'zucchina' => 'Zucchine', 'zucchine' => 'Zucchine', 'spinaci' => 'Spinaci', 'lattuga gentile' => 'Insalata', 'lattuga' => 'Insalata', 'melone' => 'Melone', 'finocchio' => 'Finocchio', // Condiments & pantry 'olio' => 'Olio', 'aceto' => 'Aceto', 'sale' => 'Sale', 'zucchero' => 'Zucchero', 'farina' => 'Farina', 'lievito' => 'Lievito', 'miele' => 'Miele', 'marmellata' => 'Marmellata', 'confettura' => 'Marmellata', 'maionese' => 'Maionese', 'senape' => 'Senape', 'ketchup' => 'Ketchup', // Canned / preserved 'passata' => 'Passata', 'polpa' => 'Polpa di pomodoro', 'pelati' => 'Pelati', 'tonno' => 'Tonno', 'sardine' => 'Sardine', 'ceci' => 'Ceci', 'lenticchie' => 'Lenticchie', 'fagioli' => 'Fagioli', 'piselli' => 'Piselli', 'mais' => 'Mais', // Frozen 'surgelato' => 'Surgelati', 'surgelati' => 'Surgelati', // Drinks 'vino' => 'Vino', 'birra' => 'Birra', 'succo' => 'Succo', // Cereals & snacks 'muesli' => 'Muesli', 'cereali' => 'Cereali', // Frozen & desserts (before coffee/tea tokens to avoid "gelato caffè → Caffè") 'gelato' => 'Gelato', 'semifreddo' => 'Gelato', // Beverages (coffee, tea, herbal) 'camomilla' => 'Camomilla', 'camomille' => 'Camomilla', 'tisana' => 'Tè', // Cat food / pet 'gatto' => 'Cibo per gatti', 'cane' => 'Cibo per cani', // Known product/brand single tokens → category override 'risofrolle' => 'Cracker', 'zuppalatte' => 'Biscotti', 'kaffee' => 'Caffè', 'ovomaltine' => 'Bevande', 'ciobar' => 'Cioccolata calda', 'apfelsaft' => 'Succo', 'kartoffelpüree'=> 'Purè', 'purée' => 'Purè', 'pure' => 'Purè', 'inchusa' => 'Birra', 'ichnusa' => 'Birra', 'vesoletto' => 'Vino', 'trebbiano' => 'Vino', 'sangiovese' => 'Vino', 'barbera' => 'Vino', 'chianti' => 'Vino', 'soave' => 'Vino', 'prosecco' => 'Vino', 'frizzante' => 'Acqua', 'semolino' => 'Semolino', 'bicarbonato' => 'Bicarbonato', 'sambuca' => 'Liquore', 'limoncello' => 'Liquore', 'grappa' => 'Liquore', 'dado' => 'Brodo', 'zuccheri' => 'Zucchero', 'zucchero' => 'Zucchero', // Foreign-language tokens 'jus' => 'Succo', 'zumo' => 'Succo', 'arome' => 'Aroma', 'caffe' => 'Caffè', 'caffè' => 'Caffè', ]; foreach ($tokens as $token) { if (isset($keywordMap[$token])) { return $keywordMap[$token]; } } // 2. Bring! catalog back-translation: "Latte di Montagna" → "Milch" → "Latte" $bringKey = italianToBring($name); if ($bringKey !== $name) { $italian = bringToItalian($bringKey); if ($italian && mb_strtolower($italian) !== $lower) { return $italian; } } // 3. Gemini AI classification — called when: // - The name has 2+ tokens (e.g. "Gran bauletto rustico"), // - OR the single token doesn't look like a clean Italian product word // (contains non-Italian chars, uppercase mix, brand-style length, etc.), // - OR category/brand context is available to help Gemini disambiguate. // Single-token ultra-common words (5+ lowercase Italian chars) that already look // like valid category names are skipped (unlikely to need AI). $firstToken = $tokens[0] ?? ''; $isCleanItalianToken = count($tokens) === 1 && mb_strlen($firstToken) >= 5 && mb_strtolower($firstToken) === $firstToken // all lowercase → already in stop-word-free form && preg_match('/^[a-z]+$/', $firstToken); // only ASCII lowercase (no accents = usually Italian noun) $hasCategoryHint = $category !== '' || $brand !== ''; $needsAI = !$isCleanItalianToken || ($hasCategoryHint && count($tokens) >= 2); if ($needsAI) { $aiResult = _geminiClassifyProduct($name, $brand, $category); if ($aiResult !== null) return $aiResult; } // 4. Fallback: capitalize the first meaningful token. if (!empty($tokens)) { return mb_strtoupper(mb_substr($firstToken, 0, 1)) . mb_substr($firstToken, 1); } return ucfirst($name); } /** * Real-time shopping sync for a single product. * Called after inventory changes (use/update/add) to keep the shopping list in sync immediately. * Delegates to Bring! or internal DB depending on SHOPPING_MODE. */ function bringQuickSyncProduct(PDO $db, int $productId): void { $stmt = $db->prepare("SELECT SUM(quantity) FROM inventory WHERE product_id = ? AND quantity > 0"); $stmt->execute([$productId]); $totalQty = (float)($stmt->fetchColumn() ?: 0); $stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE id = ?"); $stmt->execute([$productId]); $prod = $stmt->fetch(); if (!$prod) return; $genericName = $prod['shopping_name'] ?: computeShoppingName($prod['name'], '', $prod['brand']); if (isShoppingBringMode()) { // Delegate to Bring! $auth = bringAuth(); if (!$auth) return; $listUUID = $auth['bringListUUID']; $bringName = italianToBring($genericName); $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$listData || !isset($listData['purchase'])) return; $onBring = false; foreach ($listData['purchase'] as $item) { if (strcasecmp($item['name'] ?? '', $bringName) === 0) { $onBring = true; break; } } $smartItems = loadSmartShoppingCacheItems(); $si = findSmartItemForProduct($smartItems, $productId); $needsRestock = $si !== null && smartItemShouldSyncToBring($si); if ($needsRestock) { $onBringMap = []; foreach ($listData['purchase'] as $bi) { $onBringMap[strtolower($bi['name'] ?? '')] = true; } bringUpsertSmartItem($db, $si, $listUUID, $listData, $onBringMap); return; } if ($totalQty <= 0 && !$onBring) { $spec = $genericName !== $prod['name'] ? $prod['name'] . ($prod['brand'] ? ' · ' . $prod['brand'] : '') . ' · 🛒 Esaurito' : ($prod['brand'] ? $prod['brand'] . ' · ' : '') . '🛒 Esaurito'; bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", http_build_query(['uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $spec])); EverLog::info('bringQuickSync: added to Bring!', ['product_id' => $productId, 'name' => $bringName]); } elseif ($totalQty > 0 && $onBring && !familyHasRecentlyDepletedSiblings($db, $productId, $genericName)) { bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", http_build_query(['uuid' => $listUUID, 'remove' => $bringName])); EverLog::info('bringQuickSync: removed from Bring!', ['product_id' => $productId, 'name' => $bringName]); } } else { // Internal mode $smartItems = loadSmartShoppingCacheItems(); $si = findSmartItemForProduct($smartItems, $productId); if ($si !== null && smartItemShouldSyncToBring($si)) { shoppingSyncProductFromCache($db, $productId); return; } $threshold = (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'); $stmtCheck = $db->prepare("SELECT id FROM shopping_list WHERE lower(name) = lower(?)"); $stmtCheck->execute([$genericName]); $onList = (bool)$stmtCheck->fetch(); if ($totalQty <= $threshold && !$onList) { $spec = $genericName !== $prod['name'] ? $prod['name'] . ($prod['brand'] ? ' · ' . $prod['brand'] : '') : ($prod['brand'] ?: ''); $db->prepare("INSERT OR IGNORE INTO shopping_list (name, raw_name, specification) VALUES (?, ?, ?)") ->execute([$genericName, $prod['name'], $spec]); EverLog::info('shoppingQuickSync: added to internal list', ['product_id' => $productId, 'name' => $genericName]); } elseif ($totalQty > $threshold && $onList) { $db->prepare("DELETE FROM shopping_list WHERE lower(name) = lower(?)")->execute([$genericName]); EverLog::info('shoppingQuickSync: removed from internal list', ['product_id' => $productId, 'name' => $genericName]); } } } // ===== LOCAL BACKUP ===== /** * Create a timestamped local backup of evershelf.db. * WAL-checkpointed before copy. Purges backups older than BACKUP_RETENTION_DAYS. */ function createLocalBackup(?PDO $db = null): array { EverLog::info('createLocalBackup'); $backupDir = BACKUP_DIR; if (!is_dir($backupDir) && !mkdir($backupDir, 0755, true)) { return ['success' => false, 'error' => 'Cannot create backup directory']; } $dbFile = __DIR__ . '/../data/evershelf.db'; if (!file_exists($dbFile)) { return ['success' => false, 'error' => 'Database file not found']; } // WAL checkpoint: flush WAL into main DB file before copying try { $pdo = $db ?? getDB(); $pdo->exec('PRAGMA wal_checkpoint(FULL)'); } catch (Throwable $e) { /* non-fatal */ } $date = date('Y-m-d_Hi'); $filename = "evershelf_{$date}.db"; $destPath = "$backupDir/$filename"; if (!copy($dbFile, $destPath)) { return ['success' => false, 'error' => 'Failed to copy database file']; } // Purge local backups older than retention $retentionDays = max(1, (int)env('BACKUP_RETENTION_DAYS', '3')); $cutoff = strtotime("-{$retentionDays} days"); $purged = 0; foreach (glob("$backupDir/evershelf_*.db") ?: [] as $f) { if ($f !== $destPath && filemtime($f) < $cutoff) { unlink($f); $purged++; } } $sizeKb = (int)round(filesize($destPath) / 1024); $result = [ 'success' => true, 'filename' => $filename, 'path' => $destPath, 'size_kb' => $sizeKb, 'purged' => $purged, 'created_at' => date('c'), ]; // Update last-backup timestamp file file_put_contents(BACKUP_LAST_TS_PATH, json_encode(['ts' => time(), 'filename' => $filename, 'size_kb' => $sizeKb])); return $result; } /** * List local backup files with metadata. */ function listLocalBackups(): array { $backupDir = BACKUP_DIR; $backups = []; foreach (glob("$backupDir/evershelf_*.db") ?: [] as $f) { $backups[] = [ 'filename' => basename($f), 'size_kb' => (int)round(filesize($f) / 1024), 'created_at' => date('c', filemtime($f)), ]; } usort($backups, fn($a, $b) => strcmp($b['created_at'], $a['created_at'])); $lastTs = []; if (file_exists(BACKUP_LAST_TS_PATH)) { $lastTs = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: []; } return [ 'success' => true, 'backups' => $backups, 'last_backup_ts' => $lastTs['ts'] ?? null, 'last_backup_file'=> $lastTs['filename'] ?? null, 'retention_days' => max(1, (int)env('BACKUP_RETENTION_DAYS', '3')), ]; } /** * Delete a specific local backup file. */ function deleteLocalBackup(string $filename): array { if (!preg_match('/^evershelf_\d{4}-\d{2}-\d{2}_\d{4}\.db$/', $filename)) { return ['success' => false, 'error' => 'Invalid backup filename']; } $path = BACKUP_DIR . '/' . $filename; if (!file_exists($path)) { return ['success' => false, 'error' => 'File not found']; } return unlink($path) ? ['success' => true] : ['success' => false, 'error' => 'Failed to delete file']; } /** * Restore a local backup: replaces the current evershelf.db. * Clears WAL/SHM files and invalidates smart shopping cache. */ function restoreLocalBackup(string $filename, PDO $db): array { if (!preg_match('/^evershelf_\d{4}-\d{2}-\d{2}_\d{4}\.db$/', $filename)) { return ['success' => false, 'error' => 'Invalid backup filename']; } $backupPath = BACKUP_DIR . '/' . $filename; if (!file_exists($backupPath)) { return ['success' => false, 'error' => 'Backup file not found']; } $dbPath = __DIR__ . '/../data/evershelf.db'; // Flush WAL before replacing DB try { $db->exec('PRAGMA wal_checkpoint(FULL)'); } catch (Throwable $e) {} if (!copy($backupPath, $dbPath)) { return ['success' => false, 'error' => 'Failed to restore backup']; } // Remove stale WAL/SHM so next connection starts clean @unlink($dbPath . '-wal'); @unlink($dbPath . '-shm'); // Invalidate dependent caches @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); EverLog::info('restoreLocalBackup', ['filename' => $filename]); return ['success' => true, 'message' => 'Restore complete — reload the page to see the restored data.']; } // ===== GOOGLE DRIVE BACKUP ===== /** Write / overwrite a single key in the .env file (used by OAuth callback). */ function _gdriveSetEnvVar(string $key, string $value): void { $envFile = __DIR__ . '/../.env'; $envVars = loadEnv(); $envVars[$key] = $value; $lines = []; foreach ($envVars as $k => $v) { $lines[] = "$k=$v"; } file_put_contents($envFile, implode("\n", $lines) . "\n"); } /** * Build the OAuth 2.0 redirect URI for the server-side callback. * Used only for _gdriveHandleOAuthCallback (legacy flow). * The interactive auth URL now uses GDRIVE_REDIRECT_URI or http://localhost instead. */ function _gdriveRedirectUri(): string { $override = env('GDRIVE_REDIRECT_URI', ''); if (!empty($override)) return $override; $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; return "$scheme://$host/api/index.php?action=gdrive_oauth_callback"; } /** * Get an access token using a stored OAuth 2.0 refresh token. */ function _gdriveGetTokenOAuth(): array { $clientId = env('GDRIVE_CLIENT_ID', ''); $clientSecret = env('GDRIVE_CLIENT_SECRET', ''); $refreshToken = env('GDRIVE_REFRESH_TOKEN', ''); if (!$clientId || !$clientSecret) { return ['error' => 'GDRIVE_CLIENT_ID and GDRIVE_CLIENT_SECRET are required for OAuth']; } if (!$refreshToken) { return ['error' => 'Not authorized yet — click "Authorize with Google" first']; } $ch = curl_init('https://oauth2.googleapis.com/token'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query([ 'client_id' => $clientId, 'client_secret' => $clientSecret, 'refresh_token' => $refreshToken, 'grant_type' => 'refresh_token', ]), CURLOPT_TIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => true, ]); $response = curl_exec($ch); $curlErr = curl_error($ch); curl_close($ch); if (!$response) return ['error' => 'cURL failed: ' . $curlErr]; $data = json_decode($response, true); if (!empty($data['access_token'])) return ['token' => $data['access_token']]; return ['error' => 'OAuth refresh error: ' . ($data['error_description'] ?? $data['error'] ?? $response)]; } /** * Handle the OAuth 2.0 callback: exchange the code for tokens, store refresh_token. * Returns HTML (not JSON) — must be called before Content-Type header is sent. */ function _gdriveHandleOAuthCallback(): void { $code = $_GET['code'] ?? ''; if (empty($code)) { http_response_code(400); header('Content-Type: text/html; charset=utf-8'); echo '

❌ Error

No authorization code received.

'; return; } $clientId = env('GDRIVE_CLIENT_ID', ''); $clientSecret = env('GDRIVE_CLIENT_SECRET', ''); $redirectUri = _gdriveRedirectUri(); $ch = curl_init('https://oauth2.googleapis.com/token'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query([ 'client_id' => $clientId, 'client_secret' => $clientSecret, 'code' => $code, 'redirect_uri' => $redirectUri, 'grant_type' => 'authorization_code', ]), CURLOPT_TIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => true, ]); $response = curl_exec($ch); curl_close($ch); $data = json_decode($response, true); header('Content-Type: text/html; charset=utf-8'); if (!empty($data['refresh_token'])) { _gdriveSetEnvVar('GDRIVE_REFRESH_TOKEN', $data['refresh_token']); echo 'EverShelf ✔' . '

✔ Google Drive Authorized!

' . '

EverShelf can now back up to your Google Drive.

' . '

This tab will close automatically.

' . '' . ''; } else { $err = htmlspecialchars($data['error_description'] ?? $data['error'] ?? 'Unknown error'); http_response_code(400); echo "

❌ Authorization failed

$err

"; } } /** * Obtain a short-lived Google API access token via OAuth 2.0 refresh token. * Returns ['token' => string] on success, ['error' => string] on failure. */ function _gdriveGetToken(): ?string { return _gdriveGetTokenOAuth()['token'] ?? null; } function _gdriveGetTokenEx(): array { return _gdriveGetTokenOAuth(); } /** * Upload a file to Google Drive using multipart upload. * Returns the Drive file ID on success, null on failure. */ /** Returns ['id' => string] on success or ['error' => string] on failure. */ function _gdriveUploadFile(string $token, string $folderId, string $filePath, string $remoteName): array { if (!file_exists($filePath)) return ['error' => 'Local backup file not found: ' . $filePath]; $mimeType = 'application/x-sqlite3'; $metadata = json_encode(['name' => $remoteName, 'parents' => [$folderId]]); $fileContent = file_get_contents($filePath); $boundary = 'es_backup_' . bin2hex(random_bytes(8)); $body = "--$boundary\r\n" . "Content-Type: application/json; charset=UTF-8\r\n\r\n" . $metadata . "\r\n" . "--$boundary\r\n" . "Content-Type: $mimeType\r\n\r\n" . $fileContent . "\r\n" . "--$boundary--"; $ch = curl_init('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ "Authorization: Bearer $token", "Content-Type: multipart/related; boundary=$boundary", "Content-Length: " . strlen($body), ], CURLOPT_TIMEOUT => 120, CURLOPT_SSL_VERIFYPEER => true, ]); $response = curl_exec($ch); $curlErr = curl_error($ch); curl_close($ch); if (!$response) return ['error' => 'cURL upload failed: ' . $curlErr]; $data = json_decode($response, true); if (!empty($data['id'])) return ['id' => $data['id']]; $apiErr = $data['error']['message'] ?? $data['error']['status'] ?? json_encode($data); return ['error' => 'Drive API error: ' . $apiErr]; } /** * Delete Drive backups older than $retentionDays. * Returns count of deleted files. */ function _gdrivePurgeOld(string $token, string $folderId, int $retentionDays): int { if ($retentionDays <= 0) return 0; $cutoff = date('c', strtotime("-{$retentionDays} days")); $q = "'$folderId' in parents and name contains 'evershelf_' and trashed=false"; $url = 'https://www.googleapis.com/drive/v3/files?' . http_build_query(['q' => $q, 'fields' => 'files(id,name,createdTime)', 'pageSize' => '1000']); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"], CURLOPT_TIMEOUT => 30, ]); $response = curl_exec($ch); curl_close($ch); if (!$response) return 0; $data = json_decode($response, true); $deleted = 0; foreach ($data['files'] ?? [] as $file) { if (!empty($file['createdTime']) && $file['createdTime'] < $cutoff) { $ch = curl_init("https://www.googleapis.com/drive/v3/files/{$file['id']}"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"], CURLOPT_TIMEOUT => 15, ]); curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code === 204) $deleted++; } } return $deleted; } /** * Full backup flow: create local snapshot, upload to Google Drive, purge old Drive files. */ function backupToGDrive(?PDO $db = null): array { EverLog::info('backupToGDrive'); if (env('GDRIVE_ENABLED', 'false') !== 'true') { return ['success' => false, 'error' => 'Google Drive backup is not enabled']; } $folderId = env('GDRIVE_FOLDER_ID', ''); if (empty($folderId)) { return ['success' => false, 'error' => 'GDRIVE_FOLDER_ID not configured']; } // 1. Create (or reuse recent) local backup $local = createLocalBackup($db); if (!$local['success']) return $local; // 2. Authenticate with Google $tokResult = _gdriveGetTokenEx(); if (empty($tokResult['token'])) { return ['success' => false, 'error' => $tokResult['error'] ?? 'Google Drive authentication failed']; } $token = $tokResult['token']; // 3. Upload $uploadResult = _gdriveUploadFile($token, $folderId, $local['path'], $local['filename']); if (empty($uploadResult['id'])) { return ['success' => false, 'error' => $uploadResult['error'] ?? 'Upload to Google Drive failed']; } $driveFileId = $uploadResult['id']; // 4. Purge old files on Drive $retentionDays = max(0, (int)env('GDRIVE_RETENTION_DAYS', '30')); $purgedRemote = $retentionDays > 0 ? _gdrivePurgeOld($token, $folderId, $retentionDays) : 0; EverLog::info('backupToGDrive ok', ['file' => $local['filename'], 'drive_id' => $driveFileId, 'purged_remote' => $purgedRemote]); return [ 'success' => true, 'filename' => $local['filename'], 'size_kb' => $local['size_kb'], 'drive_file_id' => $driveFileId, 'purged_local' => $local['purged'], 'purged_remote' => $purgedRemote, 'created_at' => $local['created_at'], ]; } /** Format suggested qty for Bring! spec (uses suggested_unit, never inventory unit). */ function formatSmartSuggestQty(array $si): ?string { $qty = (float)($si['suggested_qty'] ?? 0); if ($qty <= 0) return null; $unit = $si['suggested_unit'] ?? $si['unit'] ?? 'pz'; $approx = !empty($si['suggested_approx']); $prefix = $approx ? 'Almeno: ' : 'Compra: '; if ($unit === 'g' && $qty >= 1000) { $kg = $qty / 1000; $kgStr = ($kg == floor($kg)) ? (string)(int)$kg : rtrim(rtrim(number_format($kg, 1, '.', ''), '0'), '.'); return $prefix . $kgStr . ' kg'; } if ($unit === 'ml' && $qty >= 1000) { $l = $qty / 1000; $lStr = ($l == floor($l)) ? (string)(int)$l : rtrim(rtrim(number_format($l, 1, '.', ''), '0'), '.'); return $prefix . $lStr . ' l'; } if ($unit === 'conf') return $prefix . (int)$qty . ' conf'; if ($unit === 'pz') return $prefix . (int)$qty . ' pz'; return $prefix . round($qty) . ' ' . $unit; } /** Build full Bring! specification from a smart-shopping row. */ function buildSmartBringSpec(array $si): string { $generic = $si['shopping_name'] ?: $si['name']; $parts = []; if (!empty($si['name']) && $si['name'] !== $generic) { $parts[] = $si['name'] . (!empty($si['brand']) ? ' · ' . $si['brand'] : ''); } $urg = match ($si['urgency'] ?? '') { 'critical' => '⚡ Urgente', 'high' => '🟠 Presto', 'medium' => '🟡 A breve', 'low' => '🔵 Previsione', default => '', }; if ($urg !== '') $parts[] = $urg; $qtyLabel = formatSmartSuggestQty($si); if ($qtyLabel !== null) $parts[] = '🛒 ' . $qtyLabel; return implode(' · ', $parts); } /** True when a smart-shopping row should be kept on Bring!/internal list. */ function smartItemShouldSyncToBring(array $si): bool { return in_array($si['urgency'] ?? 'none', ['critical', 'high', 'medium', 'low'], true); } // ===== BRING PURCHASED BLOCKLIST (server-side, synced with app_settings.bring_blocklist) ===== function bringBlocklistTokens(string $name): array { $stop = ['di','del','della','dei','degli','delle','da','in','con','per','a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo']; $clean = mb_strtolower(trim(preg_replace('/[^\p{L}\s]/u', ' ', $name) ?? $name)); $tokens = preg_split('/\s+/', $clean, -1, PREG_SPLIT_NO_EMPTY); return array_values(array_filter($tokens, fn($t) => mb_strlen($t) > 2 && !in_array($t, $stop, true))); } function bringNamesShareToken(string $a, string $b): bool { $ta = bringBlocklistTokens($a); $tb = bringBlocklistTokens($b); if (empty($ta) || empty($tb)) { return false; } return ($ta[0] ?? '') === ($tb[0] ?? ''); } /** * Blocklist name match — strict: exact, same first token, or Bring! DE/IT locale pair. * Does NOT treat peperone/peperoni or mela/mele as the same family (different shopping groups). */ function bringBlocklistKeyMatches(string $blockedKey, string $candidate): bool { $bk = mb_strtolower(trim($blockedKey)); $c = mb_strtolower(trim($candidate)); if ($bk === '' || $c === '') { return false; } if ($bk === $c || bringNamesShareToken($bk, $c)) { return true; } foreach ([$bk, $c] as $raw) { $it = mb_strtolower(bringToItalian($raw)); $de = mb_strtolower(italianToBring($raw)); if ($it !== '' && $it !== $raw && ($it === $bk || $it === $c)) { return true; } if ($de !== '' && $de !== $raw && ($de === $bk || $de === $c)) { return true; } } return false; } /** Live stock total for a shopping_name family (matching variants only). */ function bringShoppingFamilyStockQty(PDO $db, string $shoppingName): float { $key = mb_strtolower(trim($shoppingName)); if ($key === '') { return 0.0; } $stmt = $db->prepare(" SELECT p.name, p.shopping_name, COALESCE(SUM(i.quantity), 0) AS qty FROM products p INNER JOIN inventory i ON p.id = i.product_id AND i.quantity > 0 WHERE LOWER(TRIM(COALESCE(NULLIF(p.shopping_name, ''), p.name))) = ? GROUP BY p.id "); $stmt->execute([$key]); $total = 0.0; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $sn = trim((string)($row['shopping_name'] ?? '')) ?: (string)$row['name']; if (productMatchesShoppingFamily((string)$row['name'], $sn)) { $total += (float)$row['qty']; } } return $total; } /** Days since the last inbound transaction for a shopping_name family; null if never bought. */ function bringShoppingFamilyDaysSinceLastBuy(PDO $db, string $shoppingName): ?float { $key = mb_strtolower(trim($shoppingName)); if ($key === '') { return null; } $stmt = $db->prepare(" SELECT MAX(t.created_at) FROM transactions t INNER JOIN products p ON p.id = t.product_id WHERE t.type = 'in' AND t.undone = 0 AND LOWER(TRIM(COALESCE(NULLIF(p.shopping_name, ''), p.name))) = ? "); $stmt->execute([$key]); $last = $stmt->fetchColumn(); if (!$last) { return null; } $ts = strtotime((string)$last); return $ts ? max(0, (time() - $ts) / 86400) : null; } /** Hide a Bring! row only when explicitly blocklisted after a spesa purchase (72h). */ function bringListItemShouldHide(PDO $db, string $displayName, string $rawName = '', string $spec = ''): bool { $bl = bringGetActiveBlocklist($db); if (empty($bl['exact'])) { return false; } $generic = trim($displayName) ?: bringToItalian($rawName); if ($generic === '') { return false; } foreach (array_unique(array_filter([ mb_strtolower($generic), mb_strtolower(trim($rawName)), mb_strtolower(bringToItalian($rawName)), mb_strtolower(bringToItalian($displayName)), ])) as $key) { if (isset($bl['exact'][$key])) { return true; } } $tok = bringBlocklistTokens(mb_strtolower($generic))[0] ?? ''; if ($tok !== '' && isset($bl['byToken'][$tok])) { $gLower = mb_strtolower($generic); foreach (array_keys($bl['byToken'][$tok]) as $blockedKey) { if ($blockedKey === $gLower || bringNamesShareToken($blockedKey, $gLower)) { return true; } } } return false; } /** Filter blocklisted rows from the in-memory list (no Bring! API calls — fast read path). */ function bringFilterPurchasedFromList(PDO $db, array $purchase, string $listUUID = ''): array { $filtered = []; foreach ($purchase as $item) { $displayName = (string)($item['name'] ?? ''); $rawName = (string)($item['rawName'] ?? ''); $spec = (string)($item['specification'] ?? ''); if (!bringListItemShouldHide($db, $displayName, $rawName, $spec)) { $filtered[] = $item; } } return $filtered; } /** Hide from smart-shopping "In previsione" after a recent spesa purchase. */ function smartItemHideFromPredictions(PDO $db, array $item): bool { $name = (string)($item['name'] ?? ''); $generic = trim((string)($item['shopping_name'] ?? '')) ?: $name; if (bringIsPurchasedBlocked($db, $name, $generic)) { return true; } $stock = bringShoppingFamilyStockQty($db, $generic); if ($stock <= 0) { return false; } $daysSince = bringShoppingFamilyDaysSinceLastBuy($db, $generic); return $daysSince !== null && $daysSince <= 7; } function smartShoppingFilterPurchased(PDO $db, array $items): array { return array_values(array_filter( $items, static fn(array $item): bool => !smartItemHideFromPredictions($db, $item) )); } /** Skip Bring! sync only for families blocklisted after an actual spesa purchase. */ function bringSmartItemSkipBringSync(PDO $db, array $si): bool { $name = (string)($si['name'] ?? ''); $generic = trim((string)($si['shopping_name'] ?? '')) ?: $name; return bringIsPurchasedBlocked($db, $name, $generic); } /** All blocklist keys to record when the user buys a product (Italian, German, plural forms). */ function bringExpandPurchasedNames(array $names): array { $out = []; foreach ($names as $name) { $name = trim((string)$name); if ($name === '') { continue; } $out[] = $name; $lower = mb_strtolower($name); $out[] = $lower; $italian = bringToItalian($name); if ($italian !== '' && $italian !== $name) { $out[] = $italian; $out[] = mb_strtolower($italian); } $bringKey = italianToBring($name); if ($bringKey !== '' && $bringKey !== $name) { $out[] = $bringKey; $out[] = mb_strtolower($bringKey); } } return array_values(array_unique(array_filter($out, fn($n) => trim((string)$n) !== ''))); } /** Rebuild blocklist from today's actual inventory adds (fixes over-broad bulk blocklists). */ function bringRebuildBlocklistFromTodayPurchases(PDO $db): int { $rows = $db->query(" SELECT DISTINCT TRIM(COALESCE(NULLIF(p.shopping_name, ''), p.name)) AS family, p.name AS product_name FROM transactions t INNER JOIN products p ON p.id = t.product_id WHERE t.type = 'in' AND t.undone = 0 AND t.created_at >= date('now') ")->fetchAll(PDO::FETCH_ASSOC); bringSaveBlocklist($db, []); $names = []; foreach ($rows as $row) { $family = trim((string)($row['family'] ?? '')); $prod = trim((string)($row['product_name'] ?? '')); if ($family !== '') { $names[] = $family; } if ($prod !== '' && mb_strtolower($prod) !== mb_strtolower($family)) { $names[] = $prod; } } bringMarkPurchased($db, $names); return count($rows); } function bringGetBlocklist(PDO $db): array { $stmt = $db->prepare("SELECT value FROM app_settings WHERE key = 'bring_blocklist'"); $stmt->execute(); $raw = $stmt->fetchColumn(); if (!$raw) { return []; } $data = json_decode((string)$raw, true); return is_array($data) ? $data : []; } function bringSaveBlocklist(PDO $db, array $map): void { $stmt = $db->prepare("INSERT INTO app_settings (key, value, updated_at) VALUES ('bring_blocklist', ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"); $stmt->execute([json_encode($map, JSON_UNESCAPED_UNICODE)]); $GLOBALS['_bringActiveBlocklist'] = null; } /** Cached active blocklist for the current request (exact keys + first-token index). */ function bringGetActiveBlocklist(PDO $db): array { if (isset($GLOBALS['_bringActiveBlocklist']) && is_array($GLOBALS['_bringActiveBlocklist'])) { return $GLOBALS['_bringActiveBlocklist']; } $map = bringPruneBlocklist($db); $now = (int)(microtime(true) * 1000); $exact = []; $byToken = []; foreach ($map as $key => $ts) { if ($now - (int)$ts > BRING_PURCHASED_BLOCK_MS) { continue; } $kl = mb_strtolower((string)$key); $exact[$kl] = true; $tok = bringBlocklistTokens($kl)[0] ?? ''; if ($tok !== '') { $byToken[$tok][$kl] = true; } } $GLOBALS['_bringActiveBlocklist'] = ['exact' => $exact, 'byToken' => $byToken]; return $GLOBALS['_bringActiveBlocklist']; } function bringPruneBlocklist(PDO $db): array { $map = bringGetBlocklist($db); $now = (int)(microtime(true) * 1000); $changed = false; foreach ($map as $key => $ts) { if ($now - (int)$ts > BRING_PURCHASED_BLOCK_MS) { unset($map[$key]); $changed = true; } } if ($changed) { bringSaveBlocklist($db, $map); } return $map; } /** PUT remove on Bring! list — returns true only on HTTP 2xx (Bring returns 204). */ function bringPutRemove(string $listUUID, string $rawRemoveName): bool { $auth = bringAuth(); if (!$auth || $listUUID === '' || $rawRemoveName === '') { return false; } $url = "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"; $body = http_build_query(['uuid' => $listUUID, 'remove' => $rawRemoveName]); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $auth['access_token'], 'X-BRING-API-KEY: cof4Nc6D8sOprah0hUXrFl', 'X-BRING-CLIENT: webApp', 'Content-Type: application/x-www-form-urlencoded', ], ]); curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return $code >= 200 && $code < 300; } /** Tokenizer shared by Bring list matching (purchase removal). */ function bringListTokenize(string $s): array { $stop = ['di','del','della','dei','degli','dalle','delle','da','in','con','per', 'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo']; $clean = mb_strtolower(preg_replace('/[^\p{L}\s]/u', ' ', $s)); return array_values(array_filter( preg_split('/\s+/', trim($clean)), fn($t) => mb_strlen($t) > 2 && !in_array($t, $stop, true) )); } /** Does a Bring! purchase row match this product (generic + specific names)? */ function bringListItemMatchesProduct(string $rawName, string $displayName, string $prodName, string $bringKey): bool { $rawItalian = bringToItalian($rawName); if (strcasecmp($rawName, $bringKey) === 0 || strcasecmp($rawName, $displayName) === 0 || strcasecmp($rawName, $prodName) === 0 || strcasecmp($rawItalian, $displayName) === 0 || strcasecmp($rawItalian, $prodName) === 0) { return true; } $displayFirst = bringListTokenize($displayName)[0] ?? ''; $prodFirst = bringListTokenize($prodName)[0] ?? ''; $keyFirst = bringListTokenize($bringKey)[0] ?? ''; $rawFirst = bringListTokenize($rawName)[0] ?? ''; $rawItalFirst = bringListTokenize($rawItalian)[0] ?? ''; if ($rawFirst === '' && $rawItalFirst === '') { return false; } $rawTokens = bringListTokenize($rawName); $rawItalTokens = bringListTokenize($rawItalian); foreach ([$displayFirst, $prodFirst, $keyFirst] as $needle) { if ($needle === '') { continue; } if ($needle === $rawFirst || $needle === $rawItalFirst || in_array($needle, $rawTokens, true) || in_array($needle, $rawItalTokens, true)) { return true; } } return false; } /** * Remove matching Bring! purchase row(s) for a catalog product. * @return array{removed: bool, removed_names: string[]} */ function bringRemoveProductFromList(PDO $db, int $productId): array { $out = ['removed' => false, 'removed_names' => []]; $stmt = $db->prepare("SELECT name, shopping_name FROM products WHERE id = ?"); $stmt->execute([$productId]); $prod = $stmt->fetch(PDO::FETCH_ASSOC); if (!$prod) { return $out; } $auth = bringAuth(); if (!$auth) { return $out; } $listUUID = $auth['bringListUUID'] ?? ''; if ($listUUID === '') { return $out; } $prodName = (string)($prod['name'] ?? ''); $displayName = trim((string)($prod['shopping_name'] ?? '')) ?: computeShoppingName($prodName); $bringKey = italianToBring($displayName); $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$listData || empty($listData['purchase'])) { return $out; } foreach ($listData['purchase'] as $item) { $rawName = (string)($item['name'] ?? ''); if ($rawName === '') { continue; } if (!bringListItemMatchesProduct($rawName, $displayName, $prodName, $bringKey)) { continue; } if (bringPutRemove($listUUID, $rawName)) { $out['removed'] = true; $out['removed_names'][] = bringToItalian($rawName); $out['removed_names'][] = $rawName; break; } } $out['removed_names'] = array_values(array_unique(array_filter($out['removed_names']))); if ($out['removed']) { @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); } return $out; } /** Remove by display name when product_id is unknown (shopping_remove API). */ function bringRemoveByNames(PDO $db, string $name, string $rawName = ''): bool { $auth = bringAuth(); if (!$auth) { return false; } $listUUID = $auth['bringListUUID'] ?? ''; if ($listUUID === '' || trim($name) === '') { return false; } $displayName = trim($name); $bringKey = italianToBring($displayName); $candidates = array_values(array_unique(array_filter([ $rawName, $bringKey, $displayName, ]))); foreach ($candidates as $removeName) { if ($removeName !== '' && bringPutRemove($listUUID, $removeName)) { bringMarkPurchased($db, array_filter([$name, $rawName, $removeName])); @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); return true; } } $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$listData || empty($listData['purchase'])) { return false; } foreach ($listData['purchase'] as $item) { $raw = (string)($item['name'] ?? ''); if ($raw === '') { continue; } if (!bringListItemMatchesProduct($raw, $displayName, $displayName, $bringKey)) { continue; } if (bringPutRemove($listUUID, $raw)) { bringMarkPurchased($db, array_filter([$name, $rawName, $raw, bringToItalian($raw)])); @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); return true; } } return false; } function bringMarkPurchasedForProduct(PDO $db, int $productId): void { $stmt = $db->prepare("SELECT name, shopping_name FROM products WHERE id = ?"); $stmt->execute([$productId]); $prod = $stmt->fetch(PDO::FETCH_ASSOC); if (!$prod) { return; } $prodName = (string)($prod['name'] ?? ''); $generic = trim((string)($prod['shopping_name'] ?? '')) ?: computeShoppingName($prodName); $bringKey = italianToBring($generic); bringMarkPurchased($db, bringExpandPurchasedNames(array_filter([ $prodName, $generic, $bringKey, bringToItalian($bringKey), ]))); } function bringMarkPurchased(PDO $db, array $names): void { $names = bringExpandPurchasedNames($names); if (empty($names)) { return; } $map = bringPruneBlocklist($db); $now = (int)(microtime(true) * 1000); foreach ($names as $name) { $map[mb_strtolower($name)] = $now; } bringSaveBlocklist($db, $map); } function bringClearPurchasedForProduct(PDO $db, int $productId): void { $stmt = $db->prepare("SELECT name, shopping_name FROM products WHERE id = ?"); $stmt->execute([$productId]); $prod = $stmt->fetch(PDO::FETCH_ASSOC); if (!$prod) { return; } $keys = array_filter([ mb_strtolower(trim($prod['name'] ?? '')), mb_strtolower(trim($prod['shopping_name'] ?? '')), mb_strtolower(italianToBring($prod['shopping_name'] ?: $prod['name'])), ]); if (empty($keys)) { return; } $map = bringGetBlocklist($db); $changed = false; foreach (array_keys($map) as $blockedKey) { foreach ($keys as $key) { if ($key === '' || $blockedKey === $key || bringNamesShareToken($blockedKey, $key)) { unset($map[$blockedKey]); $changed = true; break; } } } if ($changed) { bringSaveBlocklist($db, $map); } } function bringIsPurchasedBlocked(PDO $db, string $name, ?string $shoppingName = null): bool { $bl = bringGetActiveBlocklist($db); if (empty($bl['exact'])) { return false; } $names = array_values(array_unique(array_filter([ $name, $shoppingName, bringToItalian($name), $shoppingName ? bringToItalian($shoppingName) : '', $shoppingName ? italianToBring($shoppingName) : '', italianToBring($name), ], static fn($n) => trim((string)$n) !== ''))); foreach ($names as $n) { $nl = mb_strtolower(trim((string)$n)); if ($nl === '') { continue; } if (isset($bl['exact'][$nl])) { return true; } $it = mb_strtolower(bringToItalian($n)); if ($it !== $nl && isset($bl['exact'][$it])) { return true; } $tok = bringBlocklistTokens($nl)[0] ?? ''; if ($tok === '' || !isset($bl['byToken'][$tok])) { continue; } foreach (array_keys($bl['byToken'][$tok]) as $blockedKey) { if ($blockedKey === $nl || bringNamesShareToken($blockedKey, $nl)) { return true; } } } return false; } /** Mark Bring! "recently purchased" items so cron/client do not re-add them. */ function bringSyncPurchasedFromBringList(PDO $db, array $recently): void { if (empty($recently)) { return; } $names = []; foreach ($recently as $item) { if (!empty($item['name'])) { $names[] = $item['name']; } if (!empty($item['rawName'])) { $names[] = $item['rawName']; } } bringMarkPurchased($db, $names); } /** Find grouped smart-shopping row for a product id (representative or variant). */ function findSmartItemForProduct(array $items, int $productId): ?array { foreach ($items as $si) { if ((int)($si['product_id'] ?? 0) === $productId) { return $si; } foreach ($si['variants'] ?? [] as $variant) { if ((int)($variant['product_id'] ?? 0) === $productId) { return $si; } } } return null; } function loadSmartShoppingCacheItems(): array { $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; if (!file_exists($cacheFile)) { return []; } $data = json_decode(file_get_contents($cacheFile), true); return ($data && !empty($data['success'])) ? ($data['items'] ?? []) : []; } /** * Upsert one smart-shopping row onto Bring! (add or merge specification). * Mutates $bringData / $onBring when provided (batch cron path). */ function bringUpsertSmartItem(PDO $db, array $si, string $listUUID, array &$bringData, array &$onBring): array { $out = ['added' => false, 'updated' => false, 'skipped' => false]; $genericName = $si['shopping_name'] ?: $si['name']; if (bringSmartItemSkipBringSync($db, $si)) { bringRemoveByNames($db, $genericName, italianToBring($genericName)); $out['skipped'] = true; return $out; } $target = bringResolveListTarget($db, $genericName, $bringData['purchase'] ?? []); $bringName = $target['purchase']; $existing = $target['existing'] ?? null; $bringKey = strtolower($bringName); $spec = buildSmartBringSpec($si); if (isset($onBring[$bringKey]) || $existing !== null) { $existingSpec = $existing !== null ? ($existing['specification'] ?? '') : ''; if ($existingSpec === '') { foreach ($bringData['purchase'] ?? [] as $bi) { if (strcasecmp($bi['name'] ?? '', $bringName) === 0) { $existingSpec = $bi['specification'] ?? ''; $bringName = $bi['name']; break; } } } $productHint = $si['name'] ?? ''; $shoppingHint = $genericName; $alreadyNoted = ($productHint !== '' && mb_stripos($existingSpec, $productHint) !== false) || ($shoppingHint !== '' && mb_stripos($existingSpec, $shoppingHint) !== false); if ($alreadyNoted) { $out['skipped'] = true; return $out; } if ($existing === null && isset($onBring[$bringKey]) && $existingSpec !== '') { $label = ($shoppingHint !== $productHint && $shoppingHint !== '') ? $shoppingHint : $productHint; $newSpec = $existingSpec . ' · ' . $label; $qtyLabel = formatSmartSuggestQty($si); if ($qtyLabel !== null) { $newSpec .= ' · 🛒 ' . $qtyLabel; } } elseif ($existing !== null && $productHint !== '' && $existingSpec !== '') { $variant = $productHint . (!empty($si['brand']) ? ' · ' . $si['brand'] : ''); $newSpec = $existingSpec . ' · ' . $variant; $qtyLabel = formatSmartSuggestQty($si); if ($qtyLabel !== null) { $newSpec .= ' · 🛒 ' . $qtyLabel; } } else { $newSpec = $spec; } $newSpec = dedupeBringSpec($newSpec); if ($existingSpec !== $newSpec && $newSpec !== '') { $body = http_build_query(['uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $newSpec]); if (bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body) !== null) { $out['updated'] = true; } } else { $out['skipped'] = true; } return $out; } $body = http_build_query(['uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $spec]); if (bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body) !== null) { $out['added'] = true; $onBring[$bringKey] = true; $bringData['purchase'][] = ['name' => $bringName, 'specification' => $spec]; } return $out; } /** * Real-time sync after stock change: use smart-shopping cache to add/update/remove on Bring!. */ function bringSyncProductFromCache(PDO $db, int $productId): void { if (!isShoppingBringMode()) { shoppingSyncProductFromCache($db, $productId); return; } $auth = bringAuth(); if (!$auth || empty($auth['bringListUUID'])) { return; } $listUUID = $auth['bringListUUID']; $stmt = $db->prepare("SELECT SUM(quantity) FROM inventory WHERE product_id = ? AND quantity > 0"); $stmt->execute([$productId]); $totalQty = (float)($stmt->fetchColumn() ?: 0); if ($totalQty <= 0) { bringAddDepletedProduct($db, $productId); return; } $smartItems = loadSmartShoppingCacheItems(); $si = findSmartItemForProduct($smartItems, $productId); if ($si === null || !smartItemShouldSyncToBring($si)) { bringQuickSyncProduct($db, $productId); return; } $bringData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$bringData || !isset($bringData['purchase'])) { return; } $onBring = []; foreach ($bringData['purchase'] as $bi) { $onBring[strtolower($bi['name'] ?? '')] = true; } bringUpsertSmartItem($db, $si, $listUUID, $bringData, $onBring); } /** Internal shopping-list mirror of bringSyncProductFromCache. */ function shoppingSyncProductFromCache(PDO $db, int $productId): void { $smartItems = loadSmartShoppingCacheItems(); $si = findSmartItemForProduct($smartItems, $productId); if ($si === null || !smartItemShouldSyncToBring($si)) { bringQuickSyncProduct($db, $productId); return; } $genericName = $si['shopping_name'] ?: $si['name']; $specParts = []; if (!empty($si['name']) && $si['name'] !== $genericName) { $specParts[] = $si['name'] . (!empty($si['brand']) ? ' · ' . $si['brand'] : ''); } $qtyLabel = formatSmartSuggestQty($si); if ($qtyLabel !== null) { $specParts[] = '🛒 ' . $qtyLabel; } $spec = implode(' · ', $specParts); $stmt = $db->prepare("SELECT id, specification FROM shopping_list WHERE lower(name) = lower(?)"); $stmt->execute([$genericName]); $existing = $stmt->fetch(PDO::FETCH_ASSOC); if ($existing) { if ($spec !== '' && $existing['specification'] !== $spec) { $db->prepare("UPDATE shopping_list SET specification = ?, raw_name = ? WHERE id = ?") ->execute([$spec, $si['name'] ?? $genericName, (int)$existing['id']]); } } else { $db->prepare("INSERT OR IGNORE INTO shopping_list (name, raw_name, specification) VALUES (?, ?, ?)") ->execute([$genericName, $si['name'] ?? $genericName, $spec]); } } /** Remove repeated segments from a Bring! specification string. */ function dedupeBringSpec(string $spec): string { $parts = preg_split('/\s*·\s*/u', $spec, -1, PREG_SPLIT_NO_EMPTY); $seen = []; $out = []; foreach ($parts as $part) { $key = mb_strtolower(trim($part)); if ($key === '' || isset($seen[$key])) continue; $seen[$key] = true; $out[] = trim($part); } return implode(' · ', $out); } /** Resolve the generic shopping group key for a product/list name (uses DB shopping_name when known). */ function resolveBringGenericKey(PDO $db, string $itName): string { static $lookup = null; if ($lookup === null) { $lookup = []; foreach ($db->query("SELECT name, shopping_name FROM products WHERE shopping_name IS NOT NULL AND shopping_name != ''") as $row) { $lookup[mb_strtolower(trim($row['name']))] = trim($row['shopping_name']); } } $key = mb_strtolower(trim($itName)); $sn = $lookup[$key] ?? null; return mb_strtolower(computeShoppingName($sn ?: $itName)); } /** * Resolve the canonical Bring! purchase key for a name. * Always prefers an existing generic item on the list over creating a product-specific entry. */ function bringResolveListTarget(PDO $db, string $name, array $purchase): array { $stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE lower(name) = lower(?) LIMIT 1"); $stmt->execute([$name]); $prod = $stmt->fetch(PDO::FETCH_ASSOC); $genericName = !empty($prod['shopping_name']) ? $prod['shopping_name'] : computeShoppingName($name, '', $prod['brand'] ?? ''); $existing = bringGenericAlreadyOnList($purchase, $genericName, $db); if ($existing !== null) { return [ 'purchase' => $existing['name'], 'generic' => $genericName, 'product' => $prod['name'] ?? $name, 'covered' => true, 'existing' => $existing, ]; } return [ 'purchase' => italianToBring($genericName), 'generic' => $genericName, 'product' => $prod['name'] ?? $name, 'covered' => false, ]; } /** True if a Bring! list item already covers this generic shopping group. */ function bringGenericAlreadyOnList(array $purchase, string $genericName, ?PDO $db = null): ?array { $targetGen = $db instanceof PDO ? resolveBringGenericKey($db, $genericName) : mb_strtolower(computeShoppingName($genericName)); foreach ($purchase as $bi) { $itName = bringToItalian($bi['name'] ?? ''); $itemGen = $db instanceof PDO ? resolveBringGenericKey($db, $itName) : mb_strtolower(computeShoppingName($itName)); if ($itemGen === $targetGen) { return $bi; } } return null; } /** * Merge duplicate Bring! items that map to the same generic (Pasta+Spaghetti, Succo variants). */ function bringDedupeGenerics(PDO $db): array { EverLog::debug('bringDedupeGenerics'); $auth = bringAuth(); if (!$auth) return ['skipped' => 'no_bring_auth']; $listUUID = $auth['bringListUUID']; if (empty($listUUID)) return ['skipped' => 'no_list_uuid']; $bringData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$bringData || !isset($bringData['purchase'])) return ['skipped' => 'bring_fetch_failed']; $byGeneric = []; foreach ($bringData['purchase'] as $item) { $itName = bringToItalian($item['name'] ?? ''); $genKey = resolveBringGenericKey($db, $itName); $byGeneric[$genKey][] = ['item' => $item, 'itName' => $itName]; } $removed = 0; $merged = 0; $errors = 0; foreach ($byGeneric as $genKey => $group) { if (count($group) < 2) continue; usort($group, function ($a, $b) use ($genKey) { $aIt = mb_strtolower($a['itName']); $bIt = mb_strtolower($b['itName']); // Prefer canonical generic label (e.g. "Pane" over "Pan Bauletto...") $canonical = mb_strtolower(computeShoppingName($genKey)); if ($aIt === $canonical || $aIt === $genKey) return -1; if ($bIt === $canonical || $bIt === $genKey) return 1; return mb_strlen($aIt) <=> mb_strlen($bIt); }); $keep = $group[0]; $keepSpec = dedupeBringSpec($keep['item']['specification'] ?? ''); $keepKey = $keep['item']['name'] ?? ''; for ($i = 1; $i < count($group); $i++) { $dup = $group[$i]; $dupSpec = trim($dup['item']['specification'] ?? ''); $dupIt = $dup['itName']; $dupKey = $dup['item']['name'] ?? ''; if ($dupSpec !== '' && mb_stripos($keepSpec, $dupIt) === false) { $keepSpec = dedupeBringSpec($keepSpec !== '' ? $keepSpec . ' · ' . $dupSpec : $dupSpec); } elseif ($dupIt !== '' && mb_stripos($keepSpec, $dupIt) === false && mb_strtolower($dupIt) !== mb_strtolower($keep['itName'])) { $keepSpec = dedupeBringSpec($keepSpec !== '' ? $keepSpec . ' · ' . $dupIt : $dupIt); } $body = http_build_query(['uuid' => $listUUID, 'remove' => $dupKey]); $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body); if ($result === null) { $catalogKey = italianToBring($dupIt); if ($catalogKey !== $dupKey) { $body = http_build_query(['uuid' => $listUUID, 'remove' => $catalogKey]); $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body); } } if ($result !== null) $removed++; else $errors++; usleep(200_000); } if ($keepSpec !== ($keep['item']['specification'] ?? '')) { $body = http_build_query([ 'uuid' => $listUUID, 'purchase' => $keepKey, 'specification' => $keepSpec, ]); if (bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body) !== null) { $merged++; } else { $errors++; } } } return ['removed' => $removed, 'merged' => $merged, 'errors' => $errors]; } /** Fix Bring! specs for smart-shopping matches (wrong units, stale urgency). */ function bringSyncSpecs(PDO $db): array { EverLog::debug('bringSyncSpecs'); $smartItems = _loadSmartShoppingItems(); if (empty($smartItems)) return ['skipped' => 'no_cache']; $auth = bringAuth(); if (!$auth) return ['skipped' => 'no_bring_auth']; $listUUID = $auth['bringListUUID']; if (empty($listUUID)) return ['skipped' => 'no_list_uuid']; $bringData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$bringData || !isset($bringData['purchase'])) return ['skipped' => 'bring_fetch_failed']; $appMarkers = ['⚡', '🟠', '🛒']; $updated = 0; $errors = 0; foreach ($bringData['purchase'] as $item) { $itName = bringToItalian($item['name'] ?? ''); $si = _matchSmartShoppingItem($itName, $smartItems); if ($si === null) continue; $currentSpec = $item['specification'] ?? ''; $hasMarker = false; foreach ($appMarkers as $m) { if (mb_strpos($currentSpec, $m) !== false) { $hasMarker = true; break; } } $wrongUnit = false; if (preg_match('/(\d+)\s*conf/u', $currentSpec, $m) && !empty($si['suggested_unit']) && $si['suggested_unit'] !== 'conf' && (int)$m[1] === (int)($si['suggested_qty'] ?? 0)) { $wrongUnit = true; } if (!$hasMarker && !$wrongUnit && !in_array($si['urgency'], ['critical', 'high'], true)) { continue; } $newSpec = buildSmartBringSpec($si); if ($newSpec === '' || strcasecmp(trim($currentSpec), trim($newSpec)) === 0) continue; $newSpec = dedupeBringSpec($newSpec); $body = http_build_query([ 'uuid' => $listUUID, 'purchase' => $item['name'], 'specification' => $newSpec, ]); if (bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body) !== null) { $updated++; } else { $errors++; } usleep(200_000); } return ['updated' => $updated, 'errors' => $errors]; } /** * Full Bring! sync: refresh smart cache, migrate names, dedupe, fix specs, * remove obsolete app-added items, add missing critical/high. */ function bringSyncFull(PDO $db, bool $refreshSmart = false): void { EverLog::info('bringSyncFull'); $summary = ['success' => true]; if ($refreshSmart) { ob_start(); smartShopping($db); $json = ob_get_clean(); $decoded = json_decode($json, true); if ($decoded && !empty($decoded['success'])) { $decoded['cached_at'] = date('c'); $decoded['cached_ts'] = time(); file_put_contents( __DIR__ . '/../data/smart_shopping_cache.json', json_encode($decoded, JSON_UNESCAPED_UNICODE) ); $summary['smart_items'] = count($decoded['items'] ?? []); } } $auth = bringAuth(); if (!$auth) { echo json_encode(['success' => false, 'error' => 'Bring! credentials not configured']); return; } $listUUID = $auth['bringListUUID']; if (empty($listUUID)) { echo json_encode(['success' => false, 'error' => 'No Bring! list UUID']); return; } $bringData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$bringData || !isset($bringData['purchase'])) { echo json_encode(['success' => false, 'error' => 'Cannot fetch Bring! list']); return; } $summary['migrate'] = bringMigrateNamesInternal($db, $bringData['purchase'], $listUUID); $summary['dedupe'] = bringDedupeGenerics($db); $summary['specs'] = bringSyncSpecs($db); $summary['cleanup'] = bringCleanupObsolete($db); $summary['auto_add'] = bringAutoAddCritical($db); $summary['dedupe_final'] = bringDedupeGenerics($db); // Re-write cache if something invalidated it during sync (e.g. concurrent API writes) $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; if (!file_exists($cacheFile)) { ob_start(); smartShopping($db); $reJson = ob_get_clean(); $reDecoded = json_decode($reJson, true); if ($reDecoded && !empty($reDecoded['success'])) { $reDecoded['cached_at'] = date('c'); $reDecoded['cached_ts'] = time(); file_put_contents($cacheFile, json_encode($reDecoded, JSON_UNESCAPED_UNICODE)); $summary['cache_restored'] = count($reDecoded['items'] ?? []); } } echo json_encode($summary, JSON_UNESCAPED_UNICODE); } /** * Server-side Bring! cleanup: remove items from Bring! that the app auto-added * but are no longer flagged by smart shopping (stock is now adequate). * Called by the cron after recomputing the smart shopping cache. * Returns a summary array for logging. */ function bringCleanupObsolete(PDO $db): array { EverLog::debug('bringCleanupObsolete'); // Load the freshly-computed smart shopping cache $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; if (!file_exists($cacheFile)) return ['skipped' => 'no_cache']; $smartData = json_decode(file_get_contents($cacheFile), true); $smartItems = $smartData['items'] ?? []; $auth = bringAuth(); if (!$auth) return ['skipped' => 'no_bring_auth']; $listUUID = $auth['bringListUUID']; if (empty($listUUID)) return ['skipped' => 'no_list_uuid']; $bringData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$bringData || !isset($bringData['purchase'])) return ['skipped' => 'bring_fetch_failed']; // Reuse nameTokens closure $stopwords = ['di','del','della','dei','il','la','le','lo','gli','un','una','e','con','per','da', 'al','alla','in','su','se','che','non','ma','o','a','i','nel','nei','tra','delle', 'degli','agli','dai','dalle','sui','sulle','sugli']; $ntFn = function(string $name) use ($stopwords): array { $name = mb_strtolower(trim($name)); $toks = preg_split('/[^a-z0-9àáâãäåèéêëìíîïòóôõöùúûü]+/u', $name, -1, PREG_SPLIT_NO_EMPTY); return array_values(array_unique(array_filter($toks, fn($t) => mb_strlen($t) > 2 && !in_array($t, $stopwords)))); }; // Build smart map by shopping_name tokens AND by exact name. // Exact match is tried first to prevent loose token collisions like // 'Panna' (Bring! item, in stock) matching 'Panna da cucina' (depleted, critical) // because they share the 'panna' token. $smartByTok = []; $smartByExactName = []; foreach ($smartItems as $si) { $sName = !empty($si['shopping_name']) ? $si['shopping_name'] : $si['name']; $sNameNorm = strtolower(trim($sName)); if ($sNameNorm !== '') $smartByExactName[$sNameNorm] = $si; foreach ($ntFn($sName) as $tok) { if (!isset($smartByTok[$tok])) $smartByTok[$tok] = $si; } } // App-added marker: urgency + quantity hints written by EverShelf $appMarkers = ['⚡', '🟠', '🟡', '🔵', '🛒']; $toRemove = []; foreach ($bringData['purchase'] as $bringItem) { $spec = $bringItem['specification'] ?? ''; $rawName = $bringItem['name'] ?? ''; $name = bringToItalian($rawName); // Only clean up items the app put there (identified by urgency markers in spec) $isAppAdded = false; foreach ($appMarkers as $m) { if (mb_strpos($spec, $m) !== false) { $isAppAdded = true; break; } } if (!$isAppAdded) continue; // Keep entries that explicitly mark a recently finished variant if (mb_strpos($spec, '🛒 Esaurito') !== false) continue; // Match against smart items: exact shopping_name first, then first-token fallback. // Exact match prevents e.g. 'Panna' → 'Panna da cucina' via shared token 'panna'. $nameToks = $ntFn($name); $exactKey = strtolower(trim($name)); $smartSi = $smartByExactName[$exactKey] ?? null; if ($smartSi === null) { $firstTok = $nameToks[0] ?? ''; $smartSi = $firstTok ? ($smartByTok[$firstTok] ?? null) : null; } if ($smartSi !== null && smartItemShouldSyncToBring($smartSi) && !bringSmartItemSkipBringSync($db, $smartSi)) { continue; } // Still flagged by smart cache but user just bought → schedule for removal $toRemove[] = ['name' => $name, 'rawName' => $rawName]; } $removed = 0; $errors = 0; foreach ($toRemove as $item) { // Try with the catalog key (rawName as returned from Bring! list) $body = http_build_query(['uuid' => $listUUID, 'remove' => $item['rawName']]); $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body); // Retry: if rawName is the Italian locale name, also try the German catalog key if ($result === null) { $catalogKey = italianToBring($item['name']); if ($catalogKey !== $item['rawName']) { $body = http_build_query(['uuid' => $listUUID, 'remove' => $catalogKey]); $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body); } } if ($result !== null) $removed++; else { $errors++; } // Small delay between removals to avoid hammering the Bring! API if (count($toRemove) > 3) usleep(300_000); // 300ms } return ['candidates' => count($toRemove), 'removed' => $removed, 'errors' => $errors]; } /** * Server-side Bring! auto-add: sync all smart_shopping items that need restocking * (esauriti, quasi finiti, in scadenza, previsione) to Bring!. Runs every cron cycle. */ function bringAutoAddCritical(PDO $db): array { EverLog::debug('bringAutoAddCritical'); $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; if (!file_exists($cacheFile)) return ['skipped' => 'no_cache']; $smartData = json_decode(file_get_contents($cacheFile), true); $smartItems = $smartData['items'] ?? []; $auth = bringAuth(); if (!$auth) return ['skipped' => 'no_bring_auth']; $listUUID = $auth['bringListUUID']; if (empty($listUUID)) return ['skipped' => 'no_list_uuid']; $bringData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$bringData || !isset($bringData['purchase'])) return ['skipped' => 'bring_fetch_failed']; $recentlyMapped = []; foreach ($bringData['recently'] ?? [] as $ri) { $recentlyMapped[] = [ 'name' => bringToItalian($ri['name'] ?? ''), 'rawName' => $ri['name'] ?? '', ]; } bringSyncPurchasedFromBringList($db, $recentlyMapped); $onBring = []; foreach ($bringData['purchase'] as $bi) { $onBring[strtolower($bi['name'] ?? '')] = true; } $added = 0; $updated = 0; foreach ($smartItems as $si) { if (!smartItemShouldSyncToBring($si)) continue; $result = bringUpsertSmartItem($db, $si, $listUUID, $bringData, $onBring); if (!empty($result['added'])) $added++; if (!empty($result['updated'])) $updated++; } return ['added' => $added, 'updated' => $updated]; } function bringGetList(): void { $auth = bringAuth(); if (!$auth) { EverLog::info('bringGetList'); echo json_encode(['success' => false, 'error' => 'Bring! credentials not configured. Add BRING_EMAIL and BRING_PASSWORD to .env']); return; } $listUUID = $auth['bringListUUID']; if (empty($listUUID)) { // Try to get lists $lists = bringRequest('GET', "https://api.getbring.com/rest/v2/bringusers/{$auth['uuid']}/lists"); if ($lists && isset($lists['lists'][0]['listUuid'])) { $listUUID = $lists['lists'][0]['listUuid']; } else { echo json_encode(['success' => false, 'error' => 'No Bring! list found']); return; } } $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$data) { echo json_encode(['success' => false, 'error' => 'Error fetching the list']); return; } $purchase = []; $recently = []; if (isset($data['purchase'])) { foreach ($data['purchase'] as $item) { $rawName = $item['name'] ?? ''; $purchase[] = [ 'name' => bringToItalian($rawName), 'rawName' => $rawName, 'specification' => $item['specification'] ?? '', ]; } } if (isset($data['recently'])) { foreach ($data['recently'] as $item) { $rawName = $item['name'] ?? ''; $recently[] = [ 'name' => bringToItalian($rawName), 'rawName' => $rawName, 'specification' => $item['specification'] ?? '', ]; } } // User checked items off in Bring → block auto-re-add (server + cron respect this) $db = getDB(); try { bringSyncPurchasedFromBringList($db, $recently); } catch (Throwable $e) { EverLog::warn('bringSyncPurchasedFromBringList: ' . $e->getMessage()); } // Drop rows the user already bought (blocklist + recent stock) before sending to client $purchase = bringFilterPurchasedFromList($db, $purchase, $listUUID); echo json_encode([ 'success' => true, 'listUUID' => $listUUID, 'purchase' => $purchase, 'recently' => $recently, ], JSON_UNESCAPED_UNICODE); // Release the HTTP response before slow Bring! maintenance (migration/dedupe). if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } else { while (ob_get_level() > 0) { ob_end_flush(); } flush(); } // ── Background auto-migration ───────────────────────────────────────── // After sending the response, silently migrate any item that still uses // the specific product name instead of the generic shopping_name. // This runs at most once every 10 minutes (flag file throttle) to avoid // hammering the Bring! API on every page load. $flagFile = __DIR__ . '/../data/bring_migrate_ts.json'; $doMigrate = true; if (file_exists($flagFile)) { $ts = (int)(json_decode(file_get_contents($flagFile), true)['ts'] ?? 0); if ((time() - $ts) < 600) $doMigrate = false; } if ($doMigrate) { file_put_contents($flagFile, json_encode(['ts' => time()])); // Use a global PDO instance if available, otherwise open a new connection global $db; if ($db instanceof PDO) { bringMigrateNamesInternal($db, $data['purchase'] ?? [], $listUUID); bringDedupeGenerics($db); } } } /** True when another product in the same shopping_name family is depleted recently. */ function familyHasRecentlyDepletedSiblings(PDO $db, int $productId, string $shoppingName, int $withinDays = RECENTLY_EXHAUSTED_DAYS): bool { $sNameKey = strtolower(trim($shoppingName)); if ($sNameKey === '') return false; $stmt = $db->prepare(" SELECT COUNT(*) FROM products p WHERE p.id != ? AND LOWER(TRIM(COALESCE(p.shopping_name, ''))) = ? AND COALESCE((SELECT SUM(i.quantity) FROM inventory i WHERE i.product_id = p.id), 0) <= 0.001 AND ( SELECT MAX(t.created_at) FROM transactions t WHERE t.product_id = p.id AND t.undone = 0 AND t.type IN ('out','waste') ) >= datetime('now', '-' || ? || ' days') "); $stmt->execute([$productId, $sNameKey, $withinDays]); return (int)$stmt->fetchColumn() > 0; } /** * Add or update a depleted product on Bring! under its generic shopping_name. * If the generic item is already on the list, appends the specific variant to the specification. */ function bringAddDepletedProduct(PDO $db, int $productId): array { $out = ['added' => false, 'updated' => false, 'skipped' => false, 'generic_name' => '']; $stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE id = ?"); $stmt->execute([$productId]); $product = $stmt->fetch(PDO::FETCH_ASSOC); if (!$product) { $out['skipped'] = true; return $out; } $auth = bringAuth(); if (!$auth) { $out['skipped'] = true; return $out; } $listUUID = $auth['bringListUUID'] ?? ''; if ($listUUID === '') { $out['skipped'] = true; return $out; } $genericName = $product['shopping_name'] ?: computeShoppingName($product['name'], '', $product['brand'] ?? ''); $out['generic_name'] = $genericName; if (bringIsPurchasedBlocked($db, $product['name'], $genericName)) { $out['skipped'] = true; return $out; } $bringName = italianToBring($genericName); $bringKey = strtolower($bringName); $specificLine = $genericName !== $product['name'] ? $product['name'] . (!empty($product['brand']) ? ' · ' . $product['brand'] : '') : (!empty($product['brand']) ? $product['brand'] : $product['name']); $finishedMarker = '🛒 Esaurito'; $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); $existingSpec = ''; $alreadyOnList = false; if ($listData && isset($listData['purchase'])) { foreach ($listData['purchase'] as $existingItem) { if (strcasecmp($existingItem['name'] ?? '', $bringName) === 0) { $alreadyOnList = true; $existingSpec = $existingItem['specification'] ?? ''; break; } } } if ($alreadyOnList) { $newSpec = $existingSpec; if ($specificLine !== '' && mb_stripos($existingSpec, $specificLine) === false) { $base = trim(preg_replace('/\s*·\s*🛒\s*Esaurito\s*$/u', '', $existingSpec) ?? $existingSpec); $newSpec = $base !== '' ? $base . ' · ' . $specificLine . ' · ' . $finishedMarker : $specificLine . ' · ' . $finishedMarker; } elseif ($existingSpec === '' || mb_stripos($existingSpec, $finishedMarker) === false) { $newSpec = trim($existingSpec) !== '' ? trim($existingSpec) . ' · ' . $finishedMarker : $specificLine . ' · ' . $finishedMarker; } if ($newSpec === $existingSpec) { $out['skipped'] = true; return $out; } $body = http_build_query([ 'uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $newSpec, ]); if (bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body) !== null) { $out['updated'] = true; @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); } return $out; } $spec = $genericName !== $product['name'] ? $specificLine . ' · ' . $finishedMarker : $specificLine . ' · ' . $finishedMarker; $body = http_build_query([ 'uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $spec, ]); if (bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body) !== null) { $out['added'] = true; EverLog::info('bringAddDepletedProduct: added', ['product_id' => $productId, 'bring' => $bringName]); @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); _fireHaWebhook('shopping_add', ['item' => $genericName, 'specification' => $spec]); } return $out; } function bringAddItems(PDO $db): void { EverLog::info('bringAddItems'); $auth = bringAuth(); if (!$auth) { EverLog::info('bringAddItems'); echo json_encode(['success' => false, 'error' => 'Bring! credentials not configured']); return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; $items = $input['items'] ?? []; $listUUID = $input['listUUID'] ?? $auth['bringListUUID']; if (empty($listUUID)) { echo json_encode(['success' => false, 'error' => 'List not found']); return; } $added = 0; $updated = 0; $skipped = 0; $errors = []; // Fetch current list to check for duplicates and existing specs $existingItems = []; // strtolower(name) => specification $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if ($listData && isset($listData['purchase'])) { foreach ($listData['purchase'] as $existingItem) { $existingItems[strtolower($existingItem['name'] ?? '')] = $existingItem['specification'] ?? ''; } } $purchase = $listData['purchase'] ?? []; foreach ($items as $item) { $name = $item['name'] ?? ''; if (empty($name)) continue; $target = bringResolveListTarget($db, $name, $purchase); $bringName = $target['purchase']; $bringKey = strtolower($bringName); $spec = $item['specification'] ?? ''; $update_spec = $item['update_spec'] ?? false; if ($target['covered'] || array_key_exists($bringKey, $existingItems)) { $existingSpec = $existingItems[$bringKey] ?? ''; if ($update_spec && $spec !== '' && $existingSpec !== $spec) { $body = http_build_query([ 'uuid' => $listUUID, 'purchase' => $bringName, 'specification' => dedupeBringSpec($spec), ]); $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body); if ($result !== null) { $updated++; $existingItems[$bringKey] = $spec; } } else { $skipped++; } continue; } $body = http_build_query([ 'uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $spec, ]); $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body); if ($result !== null) { $added++; $existingItems[$bringKey] = $spec; } else { $errors[] = $name; } } if ($added > 0 || $updated > 0) { if ($added > 0) { @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); } // Fire HA webhook for each newly added item foreach ($items as $item) { $iName = $item['name'] ?? ''; if ($iName === '') continue; _fireHaWebhook('shopping_add', ['item' => $iName, 'specification' => $item['specification'] ?? '']); } } echo json_encode(['success' => true, 'added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'errors' => $errors]); } function bringRemoveItem(): void { $auth = bringAuth(); if (!$auth) { EverLog::info('bringRemoveItem'); echo json_encode(['success' => false, 'error' => 'Bring! credentials not configured']); return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; $name = trim((string)($input['name'] ?? '')); if ($name === '') { echo json_encode(['success' => false, 'error' => 'Missing parameters']); return; } $rawName = trim((string)($input['rawName'] ?? '')); $ok = bringRemoveByNames(getDB(), $name, $rawName); echo json_encode(['success' => $ok]); } function bringCleanSpecs(): void { EverLog::debug('bringCleanSpecs'); $auth = bringAuth(); if (!$auth) { EverLog::info('bringCleanSpecs'); echo json_encode(['success' => false, 'error' => 'Bring! credentials not configured']); return; } $listUUID = $auth['bringListUUID']; if (empty($listUUID)) { echo json_encode(['success' => false, 'error' => 'List not found']); return; } $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$data || !isset($data['purchase'])) { echo json_encode(['success' => false, 'error' => 'Error fetching the list']); return; } $cleaned = 0; foreach ($data['purchase'] as $item) { $spec = $item['specification'] ?? ''; if ($spec !== '') { $body = http_build_query([ 'uuid' => $listUUID, 'purchase' => $item['name'], 'specification' => '', ]); bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body); $cleaned++; } } echo json_encode(['success' => true, 'cleaned' => $cleaned]); } /** * Core migration logic: iterate $purchaseItems and replace specific product * names with generic shopping_name in the Bring! list identified by $listUUID. * Returns ['migrated'=>int, 'skipped'=>int, 'errors'=>int]. */ function bringMigrateNamesInternal(PDO $db, array $purchaseItems, string $listUUID): array { // Build lookup: product name (lowercase) → [shopping_name, brand] $products = $db->query("SELECT name, brand, shopping_name FROM products WHERE shopping_name IS NOT NULL AND shopping_name != ''")->fetchAll(); $lookup = []; foreach ($products as $p) { EverLog::debug('bringMigrateNamesInternal'); $lookup[mb_strtolower($p['name'])] = ['shopping_name' => $p['shopping_name'], 'brand' => $p['brand'] ?? '']; } $migrated = 0; $skipped = 0; $errors = 0; foreach ($purchaseItems as $item) { $rawName = $item['name'] ?? ''; $itName = bringToItalian($rawName); $key = mb_strtolower($itName); $spec = $item['specification'] ?? ''; if (!isset($lookup[$key])) { $skipped++; continue; } $shoppingName = $lookup[$key]['shopping_name']; $brand = $lookup[$key]['brand']; // Resolve to the correct Bring! catalog key (German) $bringKey = italianToBring($shoppingName); // Already using the correct catalog key or the shopping name → nothing to do if (mb_strtolower($rawName) === mb_strtolower($bringKey)) { $skipped++; continue; } if (mb_strtolower($rawName) === mb_strtolower($shoppingName)) { $skipped++; continue; } if (mb_strtolower($itName) === mb_strtolower($shoppingName)) { $skipped++; continue; } // Build spec: "Specific Name · Brand" $newSpec = $itName . ($brand ? " · {$brand}" : ''); if ($spec !== '' && $spec !== $newSpec && stripos($spec, $itName) === false) { $newSpec = $itName . ($brand ? " · {$brand}" : '') . ' — ' . $spec; } // Check if the correct catalog key is already in the list $alreadyAdded = false; $existingItem = null; foreach ($purchaseItems as $existing) { if (strcasecmp($existing['name'] ?? '', $bringKey) === 0) { $alreadyAdded = true; $existingItem = $existing; break; } } // Also check generic group (e.g. "Pane" already present as "Brot") if (!$alreadyAdded) { $genExisting = bringGenericAlreadyOnList($purchaseItems, $shoppingName, $db); if ($genExisting !== null) { $alreadyAdded = true; $existingItem = $genExisting; $bringKey = $existingItem['name'] ?? $bringKey; } } // Remove old item using the correct API (PUT with remove param) bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", http_build_query(['uuid' => $listUUID, 'remove' => $rawName])); if (!$alreadyAdded) { $addBody = http_build_query([ 'uuid' => $listUUID, 'purchase' => $bringKey, 'specification' => $newSpec, ]); $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $addBody); if ($result !== false) { $migrated++; } else { $errors++; } } else { // Merge spec into the existing generic item instead of leaving a duplicate $mergedSpec = dedupeBringSpec(($existingItem['specification'] ?? '') . ' · ' . $newSpec); $mergeBody = http_build_query([ 'uuid' => $listUUID, 'purchase' => $bringKey, 'specification' => $mergedSpec, ]); bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $mergeBody); $migrated++; } } return ['migrated' => $migrated, 'skipped' => $skipped, 'errors' => $errors]; } function bringMigrateNames(PDO $db): void { EverLog::info('bringMigrateNames'); $auth = bringAuth(); if (!$auth) { EverLog::info('bringMigrateNames'); echo json_encode(['success' => false, 'error' => 'Bring! credentials not configured']); return; } $listUUID = $auth['bringListUUID']; if (empty($listUUID)) { echo json_encode(['success' => false, 'error' => 'List not found']); return; } $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$data || !isset($data['purchase'])) { echo json_encode(['success' => false, 'error' => 'Error fetching the list']); return; } $result = bringMigrateNamesInternal($db, $data['purchase'], $listUUID); // Reset throttle so next bring_list load re-checks @unlink(__DIR__ . '/../data/bring_migrate_ts.json'); echo json_encode(array_merge(['success' => true], $result)); } function invalidateSmartShoppingCache(): void { $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; if (file_exists($cacheFile)) { @unlink($cacheFile); } } function smartShoppingCached(PDO $db): void { EverLog::info('smartShoppingCached'); set_time_limit(120); // Never let the browser or proxy cache this — urgency is time-sensitive header('Cache-Control: no-cache, no-store, must-revalidate'); header('Pragma: no-cache'); header('Expires: 0'); $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; $maxAge = 3 * 60; // 3 minutes — keep urgency fresh if (file_exists($cacheFile)) { $mtime = filemtime($cacheFile); if ((time() - $mtime) <= $maxAge) { $raw = file_get_contents($cacheFile); if ($raw !== false) { // Inject how many seconds ago the cache was created $data = json_decode($raw, true); if ($data && isset($data['success'])) { $data['cache_age_seconds'] = time() - ($data['cached_ts'] ?? $mtime); $data['items'] = smartShoppingFilterPurchased($db, $data['items'] ?? []); echo json_encode($data, JSON_UNESCAPED_UNICODE); return; } } } } // Cache missing or stale — compute live smartShopping($db); } /** * Smart Shopping List: analyzes usage frequency, stock levels, expiry to produce * intelligent urgency-ranked shopping recommendations. */ /** * Token-based fuzzy match: returns true if the product name shares at least one * significant word (> 2 chars, not a stopword) with any key in $bringItems. * Mirrors the JS _findSimilarItem / _nameTokens logic. */ /** * Strict matching: returns true only when a Bring item's name "covers" the product name, * i.e. the FIRST significant token of the product matches the FIRST significant token of * a Bring item name. This prevents false positives like "Früchte/Frutta" matching the * product "Muesli Frutta Secca" (which has "frutta" as a secondary token, not the first). * Mirrors JS _matchBringToSmart / _syncOnBringFlags logic. */ function _productOnBring(string $productName, array $bringItems, string $shoppingName = ''): bool { // Check by shopping_name first (covers catalog-matched generic names like "Latte", "Affettato") if ($shoppingName !== '') { if (isset($bringItems[mb_strtolower($shoppingName)])) return true; $snKey = italianToBring($shoppingName); if (isset($bringItems[mb_strtolower($snKey)])) return true; } // Exact key match (both German raw and Italian translated keys are stored) if (isset($bringItems[mb_strtolower($productName)])) return true; static $stop = ['di','del','della','dei','degli','dalle','delle','da','in','con','per','su', 'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo']; $tokenize = function(string $s) use ($stop): array { $clean = mb_strtolower(preg_replace('/[^\p{L}\s]/u', ' ', $s)); return array_values(array_filter( preg_split('/\s+/', trim($clean)), fn($t) => mb_strlen($t) > 2 && !in_array($t, $stop) )); }; $pTokens = $tokenize($productName); if (empty($pTokens)) return false; $pFirst = $pTokens[0]; foreach (array_keys($bringItems) as $bKey) { $bTokens = $tokenize($bKey); if (empty($bTokens)) continue; // First token of product must equal first token of Bring item if ($bTokens[0] === $pFirst) return true; } return false; } function smartShopping(PDO $db): void { EverLog::info('smartShopping'); set_time_limit(120); $now = time(); $today = date('Y-m-d'); // Helper: extract significant tokens from a product name (mirrors JS _nameTokens) // Includes synonym expansion so French/Italian variants match (e.g. yaourt = yogurt) $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']; $synonyms = [ 'yaourt' => 'yogurt', 'yogourt' => 'yogurt', 'lait' => 'latte', 'fromage' => 'formaggio', 'sucre' => 'zucchero', 'jus' => 'succo', 'orange' => 'arancia', 'pomme' => 'mela', 'poire' => 'pera', ]; $tokens = preg_split('/\s+/', strtolower(preg_replace('/[^\p{L}\s]/u', ' ', $name))); $tokens = array_filter($tokens, fn($t) => strlen($t) > 2 && !in_array($t, $stop)); // Apply synonyms $tokens = array_map(fn($t) => $synonyms[$t] ?? $t, $tokens); return array_values(array_unique($tokens)); }; // 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, p.shopping_name 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, SUM(CASE WHEN i.expiry_date IS NULL OR i.expiry_date >= date('now') THEN i.quantity ELSE 0 END) as fresh_qty 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 (exclude undone=1 corrections) // Also compute rolling 90-day consumption for smarter quantity suggestions (#70) $txStmt = $db->query(" SELECT product_id, COUNT(CASE WHEN type IN ('out','waste') AND undone=0 THEN 1 END) as use_count, SUM(CASE WHEN type IN ('out','waste') AND undone=0 THEN quantity ELSE 0 END) as total_used, COUNT(CASE WHEN type = 'in' AND undone=0 THEN 1 END) as buy_count, SUM(CASE WHEN type = 'in' AND undone=0 THEN quantity ELSE 0 END) as total_bought, MIN(CASE WHEN type = 'in' AND undone=0 THEN created_at END) as first_in, MAX(CASE WHEN type = 'in' AND undone=0 THEN created_at END) as last_in, MAX(CASE WHEN type IN ('out','waste') AND undone=0 THEN created_at END) as last_out, SUM(CASE WHEN type IN ('out','waste') AND undone=0 AND created_at >= datetime('now','-90 days') THEN quantity ELSE 0 END) as used_90d, SUM(CASE WHEN type IN ('out','waste') AND undone=0 AND created_at >= datetime('now','-30 days') THEN quantity ELSE 0 END) as used_30d 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 */ } // 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 = []; // Also build stockByShoppingName: normalized generic name → total qty. // And freshStockByShoppingName: same but only counting non-expired batches. $stockByShoppingName = []; $freshStockByShoppingName = []; foreach ($products as $pStock) { $qty = isset($inventory[$pStock['id']]) ? (float)$inventory[$pStock['id']]['total_qty'] : 0; if ($qty <= 0) continue; foreach ($nameTokens($pStock['name']) as $tok) { $stockByAnyToken[$tok] = ($stockByAnyToken[$tok] ?? 0) + $qty; } $sName = strtolower(trim($pStock['shopping_name'] ?? '')); if ($sName !== '' && productMatchesShoppingFamily($pStock['name'], $pStock['shopping_name'])) { $stockByShoppingName[$sName] = ($stockByShoppingName[$sName] ?? 0) + $qty; $fQty = isset($inventory[$pStock['id']]) ? (float)($inventory[$pStock['id']]['fresh_qty'] ?? $qty) : 0; if ($fQty > 0) { $freshStockByShoppingName[$sName] = ($freshStockByShoppingName[$sName] ?? 0) + $fQty; } } } // 5. Analyze each product $items = []; $wasteLearning = _loadWasteLearning($db); 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 — rolling 90-day window with EWMA weighting (#70). // Priority: if we have ≥3 use events in last 90 days, use weighted blend // 70% weight on last 30 days, 30% on days 31-90 → reacts to habit changes. // Fallback: all-time effective-period rate (original logic). $used90d = (float)($tx['used_90d'] ?? 0); $used30d = (float)($tx['used_30d'] ?? 0); $used60_90d = max(0, $used90d - $used30d); // consumption in days 31-90 $dailyRate30 = $used30d > 0 ? $used30d / 30.0 : 0; $dailyRate60 = $used60_90d > 0 ? $used60_90d / 60.0 : 0; // Use EWMA only when we have enough recent data $useEwma = ($used90d > 0 && $daysSinceFirst >= 14); if ($useEwma) { if ($dailyRate30 > 0 && $dailyRate60 > 0) { // Both windows have data → blend 70/30 $dailyRate = 0.70 * $dailyRate30 + 0.30 * $dailyRate60; } elseif ($dailyRate30 > 0) { $dailyRate = $dailyRate30; // only recent data } else { $dailyRate = $dailyRate60; // only older data } } else { // Fallback: all-time effective-period rate (original logic) $lastActivity = max($lastIn ?? 0, $lastOut ?? 0); $activitySpan = ($firstIn && $lastActivity > $firstIn) ? ($lastActivity - $firstIn) : 0; // Guard: if all activity fits within 24h (e.g. bought & consumed same day / seconds apart), // effectiveDays would collapse to 1 → wildly inflated daily rate (e.g. Pizza: in+out 9s apart). // Fall back to daysSinceFirst (first purchase → now) for a conservative estimate. $effectiveDays = ($activitySpan >= 86400) ? max(1, $activitySpan / 86400) : $daysSinceFirst; $dailyRate = $effectiveDays < 999 && $totalUsed > 0 ? $totalUsed / $effectiveDays : 0; } // --- Buy-cycle proxy (for products tracked without individual 'out' events) --- // Products like salt, spices, cleaning products are never logged per-use. // When the user buys them again it implicitly means the previous pack ran out. // If we have ≥ 3 buy events and no (or very few) out events, we estimate // the average cycle duration = (lastIn - firstIn) / (buyCount - 1) and // project how many days of stock are likely left in the current cycle. // estimatedDaysLeft = avgCycleDays − daysSinceLastBuy // This dailyRate proxy is ONLY used when the regular out-based rate is 0. $buyCycleDays = null; // avg days per buy cycle $buyCycleDaysLeft = null; // estimated days remaining in current cycle if ($dailyRate == 0 && $buyCount >= 3 && $firstIn && $lastIn && $lastIn > $firstIn) { $buyCycleDays = ($lastIn - $firstIn) / 86400 / ($buyCount - 1); if ($buyCycleDays >= 7) { // ignore implausible < 1-week cycles $daysSinceLastBuyFloat = ($now - $lastIn) / 86400; $buyCycleDaysLeft = max(0, $buyCycleDays - $daysSinceLastBuyFloat); // Derive a synthetic dailyRate so existing daysLeft / pctLeft logic works naturally // 1 restock event ≈ consuming 1 "average package" over avgCycleDays if ($qty > 0 && $buyCycleDays > 0) { $dailyRate = $qty / max(1, $buyCycleDaysLeft > 0 ? $buyCycleDaysLeft : $buyCycleDays); } } } // 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; // 7-day warning window: enough to plan the next shopping trip. // The tighter 3-day threshold was often too late for staple products. $isExpiringSoon = !$isExpired && $daysToExpiry <= 7; // Fresh (non-expired) quantity — used for suppression when only part of stock is expired $freshQty = $inv ? (float)($inv['fresh_qty'] ?? $qty) : 0; // --- 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); // pctLeft based on FRESH (non-expired) stock only — used for expiry-aware suppression $freshPctLeft = $refQty > 0 ? min(200, ($freshQty / $refQty) * 100) : ($freshQty > 0 ? 100 : 0); // Cap daysLeft at a reasonable ceiling to avoid 999-day noise in reason strings $daysLeft = min($daysLeft, 365); // --- Frequency & recency metrics --- // Uses per month (30 days) — measures how frequently the product is actually used // For items tracked < 30 days, normalize over at least 14 days to avoid inflation $usesPerMonth = $daysSinceFirst >= 30 ? ($useCount / $daysSinceFirst) * 30 : ($daysSinceFirst >= 7 ? ($useCount / $daysSinceFirst) * 30 : $useCount * 0.5); // Days since last use/purchase — measures recency $daysSinceLastUse = $lastOut ? ($now - $lastOut) / 86400 : ($lastIn ? ($now - $lastIn) / 86400 : 999); // Days since last PURCHASE specifically $daysSinceLastBuy = $lastIn ? ($now - $lastIn) / 86400 : 999; // Product was restocked very recently (within 7 days) — suppress non-expiry urgency after spesa $justRestocked = $daysSinceLastBuy <= 7; // Is this a frequently used product? (≥ 1.5 uses/month) $isFrequent = $usesPerMonth >= 1.5; // Is it a regular product? (≥ 0.5 uses/month = at least once every 2 months) // Also treat buy-cycle products (≥3 buys, no out events) as regular — they are // by definition products the user buys periodically. $isRegular = $usesPerMonth >= 0.5 || ($buyCycleDays !== null && $buyCount >= 3); // Is it recently relevant? (used/bought in last 60 days) $isRecent = $daysSinceLastUse <= 60; $recentlyExhausted = $lastOut && ($now - $lastOut) / 86400 <= RECENTLY_EXHAUSTED_DAYS; // --- Determine urgency --- $urgency = 'none'; // none, low, medium, high, critical $reasons = []; $score = 0; // Out of stock if ($qty <= 0) { // If ANY *specific* token of this depleted product also appears in an in-stock product, // the user's need is already covered — skip flagging it. // Generic preparation/type words (succo, polpa, crema, ecc.) are excluded from this check // to avoid false coverage: 'limmi succo di limone' must NOT be suppressed by 'Succo e polpa di pera'. // A token must appear in both names AND be specific (not in the generic list) to count. $coverageGeneric = ['succo','polpa','crema','salsa','frutta','verdura','intero', 'parzialmente','scremato','biologico','naturale','integrale', 'cotto','fresco','secco','arrostito','bollito','sgusciato', 'bianco','rosso','nero','giallo','verde','misto','dolce','light']; $pToks = array_diff($nameTokens($p['name']), $coverageGeneric); $coveredByEquivalent = false; if (!$recentlyExhausted) { foreach ($pToks as $tok) { if (($stockByAnyToken[$tok] ?? 0) > 0) { $coveredByEquivalent = true; break; } } } // Same shopping_name family: suppress only when not recently exhausted // (e.g. still show "Yogurt fragola" even if another yogurt flavor is in stock). if (!$coveredByEquivalent && !$recentlyExhausted) { $sName = strtolower(trim($p['shopping_name'] ?? '')); if ($sName !== '' && ($stockByShoppingName[$sName] ?? 0) > 0) { $coveredByEquivalent = true; } } if ($coveredByEquivalent) continue; // For DEPLETED products: recency is misleading — the product may not have been // "used recently" precisely because it ran out. Base urgency on usage rate only. $reasons[] = 'Esaurito'; if ($isFrequent && $useCount >= 5) { $urgency = 'critical'; $score += 120; $reasons[] = 'Uso frequente (~' . max(1, (int)round($usesPerMonth)) . '/mese)'; } elseif ($isFrequent && $useCount >= 2) { $urgency = 'critical'; $score += 100; } elseif ($isFrequent) { // usesPerMonth >= 1.5 but few recorded uses (new product) → high $urgency = 'high'; $score += 75; } elseif ($isRegular && ($useCount >= 3 || $buyCount >= 2)) { $urgency = 'high'; $score += 65; } elseif ($isRegular) { $urgency = 'medium'; $score += 45; } elseif ($useCount >= 2 || $buyCount >= 2) { $urgency = 'low'; $score += 30; } else { $urgency = 'low'; $score += 10; } } // Almost finished — only flag if usage frequency justifies it. // Suppress if the same shopping_name family has adequate stock from OTHER products // (e.g. "Burro g" at 12% but "Burro conf" at 99% → no need to flag). $sNameLow = strtolower(trim($p['shopping_name'] ?? '')); $familyOtherStock = ($sNameLow !== '') ? max(0, ($stockByShoppingName[$sNameLow] ?? 0) - $qty) : 0; // For g/ml/kg/l: any conf/pz family stock ≥ 0.5 means a package is available. // For conf/pz: needs at least 1 full unit from other family products. $familyCovered = $sNameLow !== '' && $qty > 0 && ( (!in_array($unit, ['conf', 'pz']) && $familyOtherStock >= 0.5) || (in_array($unit, ['conf', 'pz']) && $familyOtherStock >= 1.0) ); if (!$familyCovered && $qty > 0 && $pctLeft <= 15 && $isRegular) { $urgency = $isFrequent ? 'high' : 'medium'; $reasons[] = 'Quasi finito (' . round($pctLeft) . '%)'; $score += 80; } elseif (!$familyCovered && $qty > 0 && $pctLeft <= 30 && $isRegular) { if ($dailyRate > 0 && $daysLeft <= 5 && $isFrequent) { $urgency = 'high'; $reasons[] = 'Finisce tra ~' . round($daysLeft) . 'gg'; $score += 75; } elseif ($dailyRate > 0 && $daysLeft <= 10 && $isRecent) { $urgency = 'medium'; $reasons[] = 'Finisce tra ~' . round($daysLeft) . 'gg'; $score += 50; } elseif ($isRecent) { $urgency = 'low'; $reasons[] = 'Scorta bassa (' . round($pctLeft) . '%)'; $score += 30; } } // Expiring soon or expired (needs replacement) if ($isExpired && $qty > 0) { // Check if the product's shopping_name FAMILY has adequate FRESH stock // from other (non-expired) products. If so, no need to buy more. $sNameKey = strtolower(trim($p['shopping_name'] ?? '')); $familyFreshQty = $sNameKey !== '' ? ($freshStockByShoppingName[$sNameKey] ?? 0) : 0; $refQtyLocal = $refQty > 0 ? $refQty : 1; $familyFreshPct = min(200, ($familyFreshQty / $refQtyLocal) * 100); if (($justRestocked && $freshPctLeft >= 50) || $familyFreshPct >= 50) { // Fresh stock from this product or same-family products is adequate. // The expired batch will show in the dashboard expiry banner — don't add to shopping list. } elseif ($isRegular || $buyCount >= 2) { // Only suggest restocking if this is a product the user buys regularly. // If it expired without ever being a staple, the expiry banner is enough. $urgency = 'critical'; $reasons[] = 'Scaduto!'; $score += 90; } // else: one-off product expired unused → expiry banner handles it, no shopping noise } elseif ($isExpiringSoon && $qty > 0) { // Flag if: // (a) regular consumer + stock low (<50%) → needs restock soon // (b) regular consumer + will expire before finishing it // (daysLeft based on consumption rate > days to expiry) // (c) non-regular + within 3 days + low stock → minimal safety net $willExpireBeforeUsed = $dailyRate > 0 && $daysToExpiry < $daysLeft; if ($isRegular && ($pctLeft < 50 || $willExpireBeforeUsed)) { if ($urgency === 'none') $urgency = 'medium'; if ($willExpireBeforeUsed && $pctLeft >= 50) { // Has stock but won't finish it in time → buy fresh and use this one now $reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg — ricompra'; } else { $reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg'; } $score += 40; } elseif (!$isRegular && $daysToExpiry <= 3 && $pctLeft < 50) { // Non-regular product: only flag when very close and running low if ($urgency === 'none') $urgency = 'low'; $reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg'; $score += 20; } } // Frequently used but stock getting low (predictive) — scale urgency by imminence if ($urgency === 'none' && $dailyRate > 0 && $daysLeft <= 14 && $isFrequent && $isRecent) { $daysLeftDisplay = (int)round($daysLeft); $reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg'; if ($daysLeftDisplay <= 3) { $urgency = 'high'; $score += 70; } elseif ($daysLeftDisplay <= 7) { $urgency = 'medium'; $score += 45; } else { $urgency = 'low'; $score += 25; } } // Buy-cycle prediction for products not tracked per-use (e.g. salt, spices): // if daily rate was derived from buy cycles and we have < 21 days left → flag. if ($urgency === 'none' && $buyCycleDays !== null && $dailyRate > 0 && $daysLeft <= 21 && $isRegular && !$justRestocked) { $daysLeftDisplay = (int)round($daysLeft); $cycleDisplay = (int)round($buyCycleDays); $reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg (ciclo medio ' . $cycleDisplay . 'gg)'; if ($daysLeftDisplay <= 7) { $urgency = 'medium'; $score += 45; } else { $urgency = 'low'; $score += 25; } } // Also upgrade existing low urgency when imminent depletion is detected if ($urgency === 'low' && $dailyRate > 0 && (int)round($daysLeft) <= 3 && $isFrequent) { $urgency = 'high'; $daysLeftLbl = 'Finisce tra ~' . (int)round($daysLeft) . 'gg'; if (!in_array($daysLeftLbl, $reasons)) { $reasons[] = $daysLeftLbl; } $score += 45; } // Opened item with fast consumption — only if actually used regularly if ($isOpened && $urgency === 'none' && $dailyRate > 0 && $daysLeft <= 7 && $isRegular) { $urgency = 'low'; $reasons[] = 'Aperto, finisce presto'; $score += 20; } // Absolute minimum stock fallback: flag items with critically low stock. // Requires: product is regularly consumed (isRegular), bought ≥2 times (proven staple), // and stock is clearly depleted relative to normal purchase (pctLeft < 80). if ($urgency === 'none' && $isRegular && $buyCount >= 2 && $qty > 0 && $pctLeft < 80) { if ($unit === 'conf') { if ($qty <= 1) { $urgency = 'high'; $reasons[] = 'Solo 1 confezione rimasta'; $score += 60; } elseif ($qty <= 2) { $urgency = 'medium'; $reasons[] = 'Solo 2 confezioni rimaste'; $score += 40; } } elseif ($unit === 'pz') { if ($qty <= 1) { $urgency = 'high'; $reasons[] = 'Solo 1 pezzo rimasto'; $score += 60; } elseif ($qty <= 2) { $urgency = 'medium'; $reasons[] = 'Solo 2 pezzi rimasti'; $score += 40; } } elseif (($unit === 'g' || $unit === 'ml') && $defQty > 0 && $qty <= $defQty * 0.20) { $urgency = 'medium'; $reasons[] = 'Scorta minima (' . round($qty) . $unit . ')'; $score += 40; } } // Extended predictive horizon for staple items (high-frequency products). // The default predictive block triggers at daysLeft <= 14 for isFrequent (≥1.5/month). // Very frequent items (daily-ish: ≥4/month) or weekly items (≥2/month) should appear // in the shopping list earlier, so the user always has them on their radar when shopping. // ≥ 4/month → 28-day horizon (daily staples: latte, pane, uova…) // ≥ 2/month → 21-day horizon (weekly staples: yogurt, frutta, carne…) if ($urgency === 'none' && $dailyRate > 0 && $isRecent && !$justRestocked) { if ($usesPerMonth >= 4 && $daysLeft <= 28) { $urgency = 'low'; $reasons[] = 'Finisce tra ~' . (int)round($daysLeft) . 'gg'; $score += 20; } elseif ($usesPerMonth >= 2 && $daysLeft <= 21) { $urgency = 'low'; $reasons[] = 'Finisce tra ~' . (int)round($daysLeft) . 'gg'; $score += 15; } } if ($urgency === 'none') continue; // Family stock coverage: suppress items covered by other products in the same generic family. // For non-expired items: suppress if family has other stock (already bought an equivalent). // For expired items: suppress if the family has FRESH stock >= the expired qty in other products // e.g. Minestrone tradizione (expired 1/5) but Minesteone 12 verdure + Buon Minestrone = 590g → suppress // Critical-without-family-cover always shows so user knows something needs replacing. $sNameFamily = strtolower(trim($p['shopping_name'] ?? '')); if ($sNameFamily !== '') { if (!$isExpired && $urgency !== 'critical' && !($qty <= 0 && $recentlyExhausted)) { $familyTotal = $stockByShoppingName[$sNameFamily] ?? 0; $otherFamilyQty = $familyTotal - $qty; if ($otherFamilyQty > 0) { continue; } } elseif ($isExpired) { // For expired: check if OTHER family members have fresh stock covering the expired amount $familyFreshTotal = $freshStockByShoppingName[$sNameFamily] ?? 0; // freshStockByShoppingName counts this product's fresh_qty too (which is 0 if all expired) // So if familyFreshTotal > 0 it means OTHER products in family have fresh stock if ($familyFreshTotal > 0) { continue; // family has fresh stock → expired product is covered } } } if ($useCount >= 8) $score += 15; elseif ($useCount >= 5) $score += 10; // Compute generic shopping name for this product $shoppingName = $p['shopping_name'] ?: computeShoppingName($p['name'], $p['category'], $p['brand']); // Is already on Bring? check both product name and generic shopping name $onBring = _productOnBring($p['name'], $bringItems, $shoppingName); // Blocklisted after spesa — never show in predictions until TTL expires. if (bringIsPurchasedBlocked($db, $p['name'], $shoppingName)) { continue; } // Just restocked (≤7 days): family has stock → skip predictions until next shopping cycle. if ($justRestocked && !$isExpired) { $sNameRestock = strtolower(trim($shoppingName)); $familyStockNow = $sNameRestock !== '' ? ($stockByShoppingName[$sNameRestock] ?? 0) : $qty; if ($familyStockNow > 0) { continue; } } // --- Suggested purchase quantity (based on 14-day consumption) --- // Rules: // unit='conf' → conf count from dailyRate directly // unit=g/ml/pz + package_unit non-empty → # confezioni (definitive) // unit=g/ml + defQty > 0 (no pkg_unit) → round to nearest defQty multiple (approx) // unit=g/ml, no defQty, no pkg_unit → raw amount, rounded to sensible step // unit=pz, no pkg_unit → raw pz count (approx) // dailyRate=0 → null (no data) $suggestedQty = null; $suggestedUnit = $unit; $suggestedApprox = false; // true = show "almeno" in badge $pkgUnit = trim($p['package_unit'] ?? ''); // non-empty only when user set a real package if ($dailyRate > 0) { $need14 = $dailyRate * 14; if ($unit === 'conf') { // Guard against unit mismatch: transactions may have been recorded in g/ml // (e.g. product unit was changed from 'g' to 'conf' after initial tracking). // If totalUsed is much larger than buy_count (e.g. 900 vs 4), it's clearly grams. // In that case fall back to purchase-frequency as the daily rate. if ($buyCount > 0 && $totalUsed > $buyCount * 5 && $daysSinceFirst < 999) { $need14 = ($buyCount / $daysSinceFirst) * 14; } // conf + package weight: express suggestion in g/ml, not raw conf count from mis-tracked grams. if ($defQty > 0 && in_array(strtolower($pkgUnit), ['g', 'ml'], true)) { $pkgs = (int) max(1, min(3, (int)($need14 + 0.3))); $suggestedQty = $pkgs * (int) $defQty; $suggestedUnit = strtolower($pkgUnit); $suggestedApprox = $pkgs > 1; } else { $suggestedQty = (int) max(1, min(3, (int)($need14 + 0.3))); $suggestedUnit = 'conf'; } } elseif ($pkgUnit !== '' && $defQty > 0) { // Real package info available → express in confezioni (definitive) $pkgs = (int) max(1, min(3, (int)($need14 / $defQty + 0.3))); $suggestedQty = $pkgs; $suggestedUnit = 'conf'; } elseif (($unit === 'g' || $unit === 'ml') && $defQty > 0) { // defQty known but no pkg_unit (e.g. Pomodorini 400g, Salame 100g) → // use defQty as the minimum purchase unit and round to nearest multiple. // This ensures we never suggest less than one "reference pack". $pkgs = (int) max(1, (int)($need14 / $defQty + 0.3)); $pkgs = min(3, $pkgs); $suggestedQty = $pkgs * (int)$defQty; $suggestedUnit = $unit; $suggestedApprox = true; // always "almeno" — no confirmed pkg size } elseif ($unit === 'g' || $unit === 'ml') { // No reference at all → raw amount, approximate // Skip if consumption is negligible (< 30 units/14gg) if ($need14 >= 30) { if ($need14 < 500) { $rounded = (int) max(100, round($need14 / 100) * 100); } elseif ($need14 < 2000) { $rounded = (int) max(250, round($need14 / 250) * 250); } else { $rounded = (int) max(500, round($need14 / 500) * 500); } $suggestedQty = $rounded; $suggestedUnit = $unit; $suggestedApprox = true; } } elseif ($unit === 'pz') { // No package info → raw pz count, approximate (cap 5 — not 14-day bulk buy) $suggestedQty = (int) max(1, min(5, (int)($need14 + 0.3))); $suggestedUnit = 'pz'; $suggestedApprox = ($suggestedQty > 1); } } // If stock is still >50% suggest minimum purchase — but NOT when the user already // put the item on the shopping list, when urgency is high/critical, or when depleted. $needsRestock = $onBring || in_array($urgency, ['critical', 'high'], true) || $qty <= 0; if ($suggestedQty !== null && $pctLeft > 50 && !$needsRestock) { if ($suggestedUnit === 'conf') { $suggestedQty = 1; $suggestedApprox = false; } elseif ($suggestedUnit === 'pz') { $suggestedQty = 1; $suggestedApprox = false; } else { // g/ml with >50% stock: suggest minimum reference pack or skip if ($defQty > 0) { $suggestedQty = (int)$defQty; $suggestedApprox = true; } else { $suggestedQty = null; } } } // On shopping list with consumption data: cover the 14-day gap vs current stock. if ($onBring && $dailyRate > 0 && $qty > 0) { $need14 = $dailyRate * 14; $stockBase = $qty; if ($unit === 'conf' && $defQty > 0 && $pkgUnit !== '') { $pu = strtolower($pkgUnit); if (in_array($pu, ['g', 'kg'])) { $stockBase = $qty * ($pu === 'kg' ? $defQty * 1000 : $defQty); } elseif (in_array($pu, ['ml', 'l', 'lt'])) { $stockBase = $qty * (in_array($pu, ['l', 'lt']) ? $defQty * 1000 : $defQty); } } $gap = max(0, $need14 - $stockBase); if ($gap > 0) { if ($unit === 'conf') { if ($defQty > 0 && in_array(strtolower($pkgUnit), ['g', 'ml'])) { $pkgs = (int)max(1, min(3, (int)ceil($gap / $defQty))); $suggestedQty = $pkgs * (int)$defQty; $suggestedUnit = strtolower($pkgUnit); $suggestedApprox = true; } else { $suggestedQty = (int)max(1, min(3, (int)ceil($gap))); $suggestedUnit = 'conf'; $suggestedApprox = false; } } elseif ($unit === 'pz') { $suggestedQty = (int)max(1, min(5, (int)ceil($gap))); $suggestedUnit = 'pz'; $suggestedApprox = $suggestedQty > 1; } elseif ($unit === 'g' || $unit === 'ml') { if ($gap < 500) { $suggestedQty = (int)max(100, (int)(round($gap / 100) * 100)); } elseif ($gap < 2000) { $suggestedQty = (int)max(250, (int)(round($gap / 250) * 250)); } else { $suggestedQty = (int)max(500, (int)(round($gap / 500) * 500)); } $suggestedUnit = $unit; $suggestedApprox = true; } } } // Frequent staples on the list with no computed qty: sensible minimum (not "1 pz" only). if ($onBring && $suggestedQty === null && $isFrequent) { if ($unit === 'conf') { $suggestedQty = 1; $suggestedUnit = 'conf'; } elseif ($unit === 'pz') { $suggestedQty = min(3, max(2, (int)ceil($usesPerMonth / 4))); $suggestedUnit = 'pz'; $suggestedApprox = true; } elseif ($defQty > 0) { $suggestedQty = (int)$defQty; $suggestedUnit = $unit; $suggestedApprox = true; } } [$suggestedQty, $suggestedUnit] = _applyWasteHintsToSuggestion($pid, $suggestedQty, $suggestedUnit ?? $unit, $wasteLearning); $wHint = $wasteLearning[(string)$pid] ?? []; if (!empty($wHint['preferred_location'])) { $locLabel = $wHint['preferred_location']; $reasons[] = "Past waste: store in {$locLabel}"; } $items[] = [ 'product_id' => $pid, 'name' => $p['name'], 'shopping_name' => $shoppingName, '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), 'uses_per_month' => round($usesPerMonth, 1), 'days_since_last_use' => round($daysSinceLastUse), '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'] : '', 'variants' => [], 'suggested_qty' => $suggestedQty, // null = no badge 'suggested_unit' => $suggestedUnit, 'suggested_approx' => $suggestedApprox, // true = show "almeno" prefix ]; } // Group items by shopping_name: keep the most urgent representative per group, // collect the rest as variants so the UI can show "Affettato (Mortadella, Speck, Nduja)". $grouped = []; foreach ($items as $item) { $sn = $item['shopping_name']; if (!isset($grouped[$sn])) { $grouped[$sn] = $item; } else { // Merge: keep the higher-score item as the representative if ($item['score'] > $grouped[$sn]['score']) { $demoted = [ 'product_id' => $grouped[$sn]['product_id'], 'name' => $grouped[$sn]['name'], 'brand' => $grouped[$sn]['brand'], 'urgency' => $grouped[$sn]['urgency'], ]; $variants = array_merge([$demoted], $grouped[$sn]['variants']); $grouped[$sn] = $item; $grouped[$sn]['variants'] = $variants; } else { $grouped[$sn]['variants'][] = [ 'product_id' => $item['product_id'], 'name' => $item['name'], 'brand' => $item['brand'], 'urgency' => $item['urgency'], ]; } // on_bring is true if ANY variant in the group is already on Bring! if ($item['on_bring']) $grouped[$sn]['on_bring'] = true; // Keep the highest suggested purchase qty across variants $curSq = (float)($grouped[$sn]['suggested_qty'] ?? 0); $newSq = (float)($item['suggested_qty'] ?? 0); if ($newSq > $curSq) { $grouped[$sn]['suggested_qty'] = $item['suggested_qty']; $grouped[$sn]['suggested_unit'] = $item['suggested_unit']; $grouped[$sn]['suggested_approx'] = $item['suggested_approx']; } } } $items = smartShoppingFilterPurchased($db, array_values($grouped)); // 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 { EverLog::info('bringSuggestItems'); $apiKey = env('GEMINI_API_KEY'); // 1. Load smart shopping data from cache or compute fresh $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; $smartItems = null; if (file_exists($cacheFile)) { $raw = file_get_contents($cacheFile); if ($raw) { $cached = json_decode($raw, true); if ($cached && isset($cached['items'])) { $smartItems = $cached['items']; } } } if ($smartItems === null) { ob_start(); smartShopping($db); $raw = ob_get_clean(); $data = json_decode($raw, true); $smartItems = $data['items'] ?? []; } // 2. Get Bring! listUUID for response $listUUID = ''; $auth = bringAuth(); if ($auth) $listUUID = $auth['bringListUUID'] ?? ''; // 3. Convert smart shopping items → suggestions (alta/media priority only, skip on_bring) $suggestions = []; $knownNames = []; // names already in suggestion list (to deduplicate AI output) foreach ($smartItems as $item) { if ($item['on_bring'] ?? false) continue; $urgency = $item['urgency'] ?? 'low'; if ($urgency === 'low') continue; $priority = ($urgency === 'critical' || $urgency === 'high') ? 'alta' : 'media'; $reasons = $item['reasons'] ?? []; $reason = !empty($reasons) ? implode(', ', $reasons) : 'Scorte basse'; $suggestions[] = [ 'name' => $item['name'], 'specification' => '', 'reason' => $reason, 'category' => $item['category'] ?: 'altro', 'priority' => $priority, 'source' => 'stock', ]; $knownNames[] = mb_strtolower($item['name']); if (count($suggestions) >= 15) break; } // 4. Seasonal tip (fallback static, overridden by Gemini below) $monthTips = [ 1 => 'Gennaio: arance, mandarini, kiwi, carciofi e verze sono di stagione.', 2 => 'Febbraio: radicchio, finocchi, pere e agrumi da non perdere.', 3 => 'Marzo: arrivano gli asparagi! Ottimo anche con piselli freschi e spinaci.', 4 => 'Aprile: stagione di asparagi, carciofi, fave e fragole.', 5 => 'Maggio: zucchine, fragole, ciliegie — ottimo mese per frutta e verdura fresca.', 6 => 'Giugno: albicocche, pesche, pomodori freschi, melanzane — estate in arrivo.', 7 => 'Luglio: cocomero, pesche, melanzane e pomodori sono al loro meglio.', 8 => 'Agosto: prugne, fichi, peperoni e basilico fresco di stagione.', 9 => 'Settembre: uva, fichi, funghi porcini, melograno e more.', 10 => 'Ottobre: melograni, castagne, funghi, mele e pere autunnali.', 11 => 'Novembre: cachi, melograni, cavoli, broccoli e radicchio tardivo.', 12 => 'Dicembre: arance, mandarini, cachi, verze e cavolfiori.', ]; $seasonalTip = $monthTips[(int)date('n')] ?? ''; // 5. Try to enrich with Gemini: generate ADDITIONAL seasonal / complementary suggestions if (!empty($apiKey)) { // Cache key: month + list of known names (so it refreshes each month) $gemCacheFile = __DIR__ . '/../data/food_facts_cache.json'; $gemCache = file_exists($gemCacheFile) ? (json_decode(file_get_contents($gemCacheFile), true) ?: []) : []; $gemCacheKey = 'suggest_ai_' . date('Y-m') . '_' . md5(implode('|', $knownNames)); // Cache valid for 6 hours $cached = $gemCache[$gemCacheKey] ?? null; $cacheTs = $gemCache[$gemCacheKey . '_ts'] ?? 0; $cacheValid = $cached && (time() - $cacheTs < 21600); if ($cacheValid) { $aiResult = $cached; } else { // Build inventory snapshot for Gemini (what the user already has) $inStockNames = array_map(fn($i) => $i['name'], array_filter($smartItems, fn($i) => ($i['current_qty'] ?? 0) > 0)); $dietary = trim(env('DIETARY') ?? ''); $monthName = [1=>'Gennaio',2=>'Febbraio',3=>'Marzo',4=>'Aprile',5=>'Maggio',6=>'Giugno', 7=>'Luglio',8=>'Agosto',9=>'Settembre',10=>'Ottobre',11=>'Novembre',12=>'Dicembre'][(int)date('n')]; $inStockJson = json_encode(array_values(array_slice($inStockNames, 0, 40)), JSON_UNESCAPED_UNICODE); $alreadyJson = json_encode(array_values($knownNames), JSON_UNESCAPED_UNICODE); $dietaryLine = $dietary ? "- Dietary preferences: {$dietary}" : ''; $prompt = "You are a helpful Italian household shopping assistant.\n" . "Today is {$monthName} " . date('Y') . ".\n" . "The user already has these products in stock: {$inStockJson}\n" . "The following products are already in the shopping list: {$alreadyJson}\n" . ($dietaryLine ? $dietaryLine . "\n" : '') . "\nTask: suggest 3 to 6 additional products the user should buy this month.\n" . "Focus on:\n" . " a) Seasonal Italian fruits and vegetables for {$monthName}\n" . " b) Complementary staples that pair well with what the user has\n" . " c) Anything commonly forgotten but regularly needed\n" . "Do NOT suggest products already in stock or already in the shopping list.\n" . "Also write one short seasonal tip (max 15 words) in Italian.\n" . "\nReply ONLY with valid JSON in this exact format (no markdown):\n" . "{\"seasonal_tip\":\"...\",\"suggestions\":[{\"name\":\"...\",\"reason\":\"...\",\"category\":\"...\",\"priority\":\"bassa\"}]}\n" . "Category must be one of: frutta,verdura,latticini,carne,pesce,pane,cereali,condimenti,bevande,surgelati,altro\n" . "Priority must be: bassa\n" . "Name and reason must be in Italian. Reason max 8 words."; $payload = ['contents' => [['parts' => [['text' => $prompt]]]]]; $gemResult = callGeminiWithFallback($apiKey, $payload, 20, 'bring_suggest'); $aiResult = null; if ($gemResult['http_code'] === 200) { $text = $gemResult['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; $text = preg_replace('/^```json\s*/i', '', trim($text)); $text = preg_replace('/\s*```$/i', '', $text); $parsed = json_decode(trim($text), true); if (is_array($parsed) && isset($parsed['suggestions'])) { $aiResult = $parsed; // Cache result $gemCache[$gemCacheKey] = $aiResult; $gemCache[$gemCacheKey . '_ts'] = time(); file_put_contents($gemCacheFile, json_encode($gemCache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); } } } if ($aiResult) { // Override seasonal tip with AI-generated one if (!empty($aiResult['seasonal_tip'])) { $seasonalTip = $aiResult['seasonal_tip']; } // Append AI suggestions (deduplicate against stock-based ones) foreach ($aiResult['suggestions'] ?? [] as $ai) { $aiName = mb_strtolower(trim($ai['name'] ?? '')); if (!$aiName) continue; // Skip if already in list (first-token check) $aiFirst = explode(' ', $aiName)[0]; $isDup = false; foreach ($knownNames as $kn) { if (str_starts_with($kn, $aiFirst)) { $isDup = true; break; } } if ($isDup) continue; $suggestions[] = [ 'name' => ucfirst(trim($ai['name'])), 'specification' => '', 'reason' => trim($ai['reason'] ?? 'Stagionale'), 'category' => $ai['category'] ?? 'altro', 'priority' => 'bassa', 'source' => 'ai', ]; $knownNames[] = $aiName; } } } echo json_encode([ 'success' => true, 'suggestions' => $suggestions, 'seasonal_tip' => $seasonalTip, 'listUUID' => $listUUID, ], JSON_UNESCAPED_UNICODE); } // ===== SHOPPING ABSTRACTION (internal DB or Bring!) ===== function isShoppingBringMode(): bool { return env('SHOPPING_MODE', 'internal') === 'bring' && !empty(env('BRING_EMAIL')) && !empty(env('BRING_PASSWORD')); } function shoppingGetList(PDO $db): void { if (isShoppingBringMode()) { bringGetList(); return; } $items = $db->query( "SELECT name, raw_name, specification FROM shopping_list ORDER BY sort_order ASC, added_at ASC" )->fetchAll(); $purchase = array_map(fn($r) => [ 'name' => $r['name'], 'rawName' => $r['raw_name'] ?: $r['name'], 'specification' => $r['specification'], ], $items); echo json_encode([ 'success' => true, 'listUUID' => 'internal-list', 'purchase' => $purchase, 'recently' => [], ], JSON_UNESCAPED_UNICODE); } function shoppingAdd(PDO $db): void { if (isShoppingBringMode()) { try { dbWithRetry(function () use ($db): void { bringAddItems($db); }); } catch (\PDOException $e) { EverLog::error('shoppingAdd/bring db error', ['msg' => $e->getMessage()]); http_response_code(500); echo json_encode(['success' => false, 'error' => 'Database busy — please retry']); } return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; try { dbWithRetry(function () use ($db, $input): void { shoppingAddInternal($db, $input); }); } catch (\PDOException $e) { EverLog::error('shoppingAdd db error', ['msg' => $e->getMessage()]); http_response_code(500); echo json_encode(['success' => false, 'error' => 'Database busy — please retry']); } } function shoppingAddInternal(PDO $db, array $input): void { $items = $input['items'] ?? []; $added = 0; $updated = 0; $skipped = 0; foreach ($items as $item) { $name = trim($item['name'] ?? ''); if ($name === '') continue; $rawName = trim($item['rawName'] ?? $item['raw_name'] ?? $name); $spec = $item['specification'] ?? ''; $updateSpec = !empty($item['update_spec']); $stmt = $db->prepare("SELECT id, specification FROM shopping_list WHERE lower(name) = lower(?)"); $stmt->execute([$name]); $existing = $stmt->fetch(); if ($existing) { if ($updateSpec && $existing['specification'] !== $spec) { $db->prepare("UPDATE shopping_list SET specification=?, raw_name=? WHERE id=?")->execute([$spec, $rawName, $existing['id']]); $updated++; } else { $skipped++; } } else { $db->prepare("INSERT INTO shopping_list (name, raw_name, specification) VALUES (?, ?, ?)")->execute([$name, $rawName, $spec]); $added++; _fireHaWebhook('shopping_add', ['item' => $name, 'specification' => $spec]); } } echo json_encode(['success' => true, 'added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'errors' => []]); } function shoppingRemove(PDO $db): void { if (isShoppingBringMode()) { bringRemoveItem(); return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; $name = trim($input['name'] ?? ''); if ($name === '') { echo json_encode(['success' => false, 'error' => 'Missing name']); return; } $db->prepare("DELETE FROM shopping_list WHERE lower(name) = lower(?)")->execute([$name]); bringMarkPurchased($db, [$name]); echo json_encode(['success' => true]); } /** * Suggest one in-stock sibling in the same shopping_name family (for spesa-mode hint). */ function familySiblingSuggest(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $productId = (int)($input['product_id'] ?? 0); if ($productId <= 0) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'product_id required']); return; } $location = $input['location'] ?? 'dispensa'; if (!isValidLocation($db, $location)) { $location = 'dispensa'; } $stmt = $db->prepare("SELECT name, shopping_name, unit, default_quantity, package_unit FROM products WHERE id = ?"); $stmt->execute([$productId]); $product = $stmt->fetch(PDO::FETCH_ASSOC); if (!$product) { echo json_encode(['success' => true, 'sibling' => null]); return; } $sName = trim($product['shopping_name'] ?? ''); if ($sName === '') { echo json_encode(['success' => true, 'sibling' => null]); return; } $sibStmt = $db->prepare(" SELECT p.id, p.name, p.brand, p.category, p.image_url, p.unit, p.default_quantity, p.package_unit, COALESCE(SUM(i.quantity), 0) AS stock_qty, (SELECT i2.id FROM inventory i2 WHERE i2.product_id = p.id AND i2.quantity > 0 AND i2.location = ? ORDER BY i2.updated_at DESC LIMIT 1) AS inventory_id, (SELECT i2.added_at FROM inventory i2 WHERE i2.product_id = p.id AND i2.quantity > 0 AND i2.location = ? ORDER BY i2.updated_at DESC LIMIT 1) AS added_at, (SELECT MAX(t.created_at) FROM transactions t WHERE t.product_id = p.id AND t.type = 'in' AND t.undone = 0 AND t.location = ?) AS last_purchase_at FROM products p LEFT JOIN inventory i ON i.product_id = p.id AND i.quantity > 0 AND i.location = ? WHERE p.id != ? AND LOWER(TRIM(COALESCE(p.shopping_name, ''))) = LOWER(?) GROUP BY p.id HAVING stock_qty > 0.001 ORDER BY stock_qty DESC, p.name ASC LIMIT 1 "); $sibStmt->execute([$location, $location, $location, $location, $productId, $sName]); $sibling = $sibStmt->fetch(PDO::FETCH_ASSOC); if (!$sibling) { echo json_encode(['success' => true, 'sibling' => null]); return; } $inventoryId = (int)($sibling['inventory_id'] ?? 0); if ($inventoryId <= 0) { echo json_encode(['success' => true, 'sibling' => null]); return; } $invChk = $db->prepare("SELECT quantity FROM inventory WHERE id = ? AND quantity > 0.001"); $invChk->execute([$inventoryId]); $liveQty = $invChk->fetchColumn(); if ($liveQty === false) { echo json_encode(['success' => true, 'sibling' => null]); return; } $stockQty = (float)$liveQty; $unit = $sibling['unit'] ?: 'pz'; echo json_encode([ 'success' => true, 'sibling' => [ 'product_id' => (int)$sibling['id'], 'inventory_id' => (int)($sibling['inventory_id'] ?? 0), 'name' => $sibling['name'], 'brand' => $sibling['brand'] ?? '', 'category' => $sibling['category'] ?? '', 'image_url' => $sibling['image_url'] ?? '', 'stock_qty' => round($stockQty, 3), 'unit' => $unit, 'default_quantity' => (float)($sibling['default_quantity'] ?? 0), 'package_unit' => $sibling['package_unit'] ?? '', 'family' => $sName, 'location' => $location, 'added_at' => $sibling['added_at'] ?? null, 'last_purchase_at' => $sibling['last_purchase_at'] ?? null, ], ], JSON_UNESCAPED_UNICODE); } // ===== CUSTOM LOCATIONS ===== function locationsList(PDO $db): void { $rows = $db->query("SELECT key, label, icon, sort_order, is_builtin FROM locations ORDER BY sort_order ASC, id ASC")->fetchAll(); echo json_encode(['success' => true, 'locations' => $rows]); } function locationsAdd(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $label = trim($input['label'] ?? ''); $icon = trim($input['icon'] ?? '📦'); if ($label === '') { echo json_encode(['success' => false, 'error' => 'label required']); return; } // Generate a safe key from the label (slug-like, ASCII-only) $key = mb_strtolower(trim($label)); $key = preg_replace('/[^a-z0-9]+/u', '_', $key); $key = trim($key, '_'); if ($key === '') { echo json_encode(['success' => false, 'error' => 'invalid label']); return; } $stmt = $db->prepare("SELECT id FROM locations WHERE key = ?"); $stmt->execute([$key]); if ($stmt->fetch()) { echo json_encode(['success' => false, 'error' => 'location already exists']); return; } $maxOrder = (int)$db->query("SELECT COALESCE(MAX(sort_order), 0) FROM locations")->fetchColumn(); $stmt = $db->prepare("INSERT INTO locations (key, label, icon, sort_order, is_builtin) VALUES (?, ?, ?, ?, 0)"); $stmt->execute([$key, $label, $icon, $maxOrder + 1]); echo json_encode(['success' => true, 'key' => $key]); } function locationsRemove(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $key = trim($input['key'] ?? ''); if ($key === '') { echo json_encode(['success' => false, 'error' => 'key required']); return; } $stmt = $db->prepare("SELECT is_builtin FROM locations WHERE key = ?"); $stmt->execute([$key]); $row = $stmt->fetch(); if (!$row) { echo json_encode(['success' => false, 'error' => 'location not found']); return; } if ((int)$row['is_builtin'] === 1) { echo json_encode(['success' => false, 'error' => 'cannot delete a builtin location']); return; } // Guard: refuse deletion if inventory items still reference this location $stmt = $db->prepare("SELECT COUNT(*) FROM inventory WHERE location = ? AND quantity > 0"); $stmt->execute([$key]); if ((int)$stmt->fetchColumn() > 0) { echo json_encode(['success' => false, 'error' => 'location still has items in inventory']); return; } $db->prepare("DELETE FROM locations WHERE key = ?")->execute([$key]); echo json_encode(['success' => true]); } function locationsUpdate(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $key = trim($input['key'] ?? ''); $label = trim($input['label'] ?? ''); $icon = trim($input['icon'] ?? ''); if ($key === '' || $label === '') { echo json_encode(['success' => false, 'error' => 'key and label required']); return; } $stmt = $db->prepare("SELECT id FROM locations WHERE key = ?"); $stmt->execute([$key]); if (!$stmt->fetch()) { echo json_encode(['success' => false, 'error' => 'location not found']); return; } $stmt = $db->prepare("UPDATE locations SET label = ?, icon = ? WHERE key = ?"); $stmt->execute([$label, $icon ?: '📦', $key]); echo json_encode(['success' => true]); } function subcategoriesList(PDO $db): void { $rows = $db->query("SELECT id, category, key, label, sort_order FROM subcategories ORDER BY category ASC, sort_order ASC, id ASC")->fetchAll(); echo json_encode(['success' => true, 'subcategories' => $rows]); } function subcategoriesAdd(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $category = trim($input['category'] ?? ''); $label = trim($input['label'] ?? ''); if ($category === '' || $label === '') { echo json_encode(['success' => false, 'error' => 'category and label required']); return; } $key = mb_strtolower(trim($label)); $key = preg_replace('/[^a-z0-9]+/u', '_', $key); $key = trim($key, '_'); if ($key === '') { echo json_encode(['success' => false, 'error' => 'invalid label']); return; } $stmt = $db->prepare("SELECT id FROM subcategories WHERE category = ? AND key = ?"); $stmt->execute([$category, $key]); if ($stmt->fetch()) { echo json_encode(['success' => false, 'error' => 'subcategory already exists for this category']); return; } $stmt = $db->prepare("SELECT COALESCE(MAX(sort_order), 0) FROM subcategories WHERE category = ?"); $stmt->execute([$category]); $maxOrder = (int)$stmt->fetchColumn(); $stmt = $db->prepare("INSERT INTO subcategories (category, key, label, sort_order) VALUES (?, ?, ?, ?)"); $stmt->execute([$category, $key, $label, $maxOrder + 1]); echo json_encode(['success' => true, 'id' => (int)$db->lastInsertId(), 'key' => $key]); } function subcategoriesRemove(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $id = (int)($input['id'] ?? 0); if ($id <= 0) { echo json_encode(['success' => false, 'error' => 'id required']); return; } $stmt = $db->prepare("SELECT category, key FROM subcategories WHERE id = ?"); $stmt->execute([$id]); $row = $stmt->fetch(); if (!$row) { echo json_encode(['success' => false, 'error' => 'subcategory not found']); return; } $stmt = $db->prepare("SELECT COUNT(*) FROM products WHERE category = ? AND subcategory = ?"); $stmt->execute([$row['category'], $row['key']]); if ((int)$stmt->fetchColumn() > 0) { echo json_encode(['success' => false, 'error' => 'subcategory still used by products']); return; } $db->prepare("DELETE FROM subcategories WHERE id = ?")->execute([$id]); echo json_encode(['success' => true]); } function subcategoriesUpdate(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $id = (int)($input['id'] ?? 0); $label = trim($input['label'] ?? ''); if ($id <= 0 || $label === '') { echo json_encode(['success' => false, 'error' => 'id and label required']); return; } $stmt = $db->prepare("SELECT id FROM subcategories WHERE id = ?"); $stmt->execute([$id]); if (!$stmt->fetch()) { echo json_encode(['success' => false, 'error' => 'subcategory not found']); return; } $db->prepare("UPDATE subcategories SET label = ? WHERE id = ?")->execute([$label, $id]); echo json_encode(['success' => true]); } function categoriesList(PDO $db): void { $rows = $db->query("SELECT key, label, icon, keywords, sort_order, is_builtin FROM categories ORDER BY sort_order ASC, id ASC")->fetchAll(); echo json_encode(['success' => true, 'categories' => $rows]); } function categoriesAdd(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $label = trim($input['label'] ?? ''); $icon = trim($input['icon'] ?? '📦'); $keywords = trim($input['keywords'] ?? ''); if ($label === '') { echo json_encode(['success' => false, 'error' => 'label required']); return; } $key = mb_strtolower(trim($label)); $key = preg_replace('/[^a-z0-9]+/u', '_', $key); $key = trim($key, '_'); if ($key === '') { echo json_encode(['success' => false, 'error' => 'invalid label']); return; } $stmt = $db->prepare("SELECT id FROM categories WHERE key = ?"); $stmt->execute([$key]); if ($stmt->fetch()) { echo json_encode(['success' => false, 'error' => 'category already exists']); return; } $maxOrder = (int)$db->query("SELECT COALESCE(MAX(sort_order), 0) FROM categories")->fetchColumn(); $stmt = $db->prepare("INSERT INTO categories (key, label, icon, keywords, sort_order, is_builtin) VALUES (?, ?, ?, ?, ?, 0)"); $stmt->execute([$key, $label, $icon, $keywords, $maxOrder + 1]); echo json_encode(['success' => true, 'key' => $key]); } function categoriesRemove(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $key = trim($input['key'] ?? ''); if ($key === '') { echo json_encode(['success' => false, 'error' => 'key required']); return; } $stmt = $db->prepare("SELECT is_builtin FROM categories WHERE key = ?"); $stmt->execute([$key]); $row = $stmt->fetch(); if (!$row) { echo json_encode(['success' => false, 'error' => 'category not found']); return; } if ((int)$row['is_builtin'] === 1) { echo json_encode(['success' => false, 'error' => 'cannot delete a builtin category']); return; } $stmt = $db->prepare("SELECT COUNT(*) FROM products WHERE category = ?"); $stmt->execute([$key]); if ((int)$stmt->fetchColumn() > 0) { echo json_encode(['success' => false, 'error' => 'category still used by products']); return; } $db->prepare("DELETE FROM categories WHERE key = ?")->execute([$key]); echo json_encode(['success' => true]); } function categoriesUpdate(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $key = trim($input['key'] ?? ''); $label = trim($input['label'] ?? ''); $icon = trim($input['icon'] ?? ''); $keywords = trim($input['keywords'] ?? ''); if ($key === '' || $label === '') { echo json_encode(['success' => false, 'error' => 'key and label required']); return; } $stmt = $db->prepare("SELECT id FROM categories WHERE key = ?"); $stmt->execute([$key]); if (!$stmt->fetch()) { echo json_encode(['success' => false, 'error' => 'category not found']); return; } $stmt = $db->prepare("UPDATE categories SET label = ?, icon = ?, keywords = ? WHERE key = ?"); $stmt->execute([$label, $icon ?: '📦', $keywords, $key]); echo json_encode(['success' => true]); } function recipeLibraryList(PDO $db): void { $rows = $db->query("SELECT id, title, recipe_json, is_favorite, created_at FROM recipe_library ORDER BY is_favorite DESC, created_at DESC")->fetchAll(); $recipes = []; foreach ($rows as $row) { $recipes[] = [ 'id' => (int)$row['id'], 'title' => $row['title'], 'recipe' => json_decode($row['recipe_json'], true), 'is_favorite' => (bool)$row['is_favorite'], 'created_at' => $row['created_at'], ]; } echo json_encode(['success' => true, 'recipes' => $recipes]); } function recipeLibrarySave(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $id = (int)($input['id'] ?? 0); $recipe = $input['recipe'] ?? null; $title = trim($recipe['title'] ?? ''); if (!$recipe || $title === '') { echo json_encode(['success' => false, 'error' => 'title and recipe required']); return; } $json = json_encode($recipe, JSON_UNESCAPED_UNICODE); if ($id > 0) { $stmt = $db->prepare("SELECT id FROM recipe_library WHERE id = ?"); $stmt->execute([$id]); if (!$stmt->fetch()) { echo json_encode(['success' => false, 'error' => 'recipe not found']); return; } $db->prepare("UPDATE recipe_library SET title = ?, recipe_json = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") ->execute([$title, $json, $id]); echo json_encode(['success' => true, 'id' => $id]); return; } $db->prepare("INSERT INTO recipe_library (title, recipe_json) VALUES (?, ?)")->execute([$title, $json]); echo json_encode(['success' => true, 'id' => (int)$db->lastInsertId()]); } function recipeLibraryDelete(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $id = (int)($input['id'] ?? 0); if ($id > 0) { $db->prepare("DELETE FROM recipe_library WHERE id = ?")->execute([$id]); } echo json_encode(['success' => true]); } function recipeLibraryToggleFavorite(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $id = (int)($input['id'] ?? 0); if ($id <= 0) { echo json_encode(['success' => false, 'error' => 'id required']); return; } $db->prepare("UPDATE recipe_library SET is_favorite = 1 - is_favorite WHERE id = ?")->execute([$id]); $fav = (int)$db->query("SELECT is_favorite FROM recipe_library WHERE id = {$id}")->fetchColumn(); echo json_encode(['success' => true, 'is_favorite' => (bool)$fav]); } function recipeTagsList(PDO $db): void { $rows = $db->query("SELECT id, key, label, icon, sort_order FROM recipe_tags ORDER BY sort_order ASC, id ASC")->fetchAll(); echo json_encode(['success' => true, 'tags' => $rows]); } function recipeTagsAdd(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $label = trim($input['label'] ?? ''); $icon = trim($input['icon'] ?? '🏷️'); if ($label === '') { echo json_encode(['success' => false, 'error' => 'label required']); return; } $key = mb_strtolower(trim($label)); $key = preg_replace('/[^a-z0-9]+/u', '_', $key); $key = trim($key, '_'); if ($key === '') { echo json_encode(['success' => false, 'error' => 'invalid label']); return; } $stmt = $db->prepare("SELECT id FROM recipe_tags WHERE key = ?"); $stmt->execute([$key]); if ($stmt->fetch()) { echo json_encode(['success' => false, 'error' => 'tag already exists']); return; } $maxOrder = (int)$db->query("SELECT COALESCE(MAX(sort_order), 0) FROM recipe_tags")->fetchColumn(); $stmt = $db->prepare("INSERT INTO recipe_tags (key, label, icon, sort_order) VALUES (?, ?, ?, ?)"); $stmt->execute([$key, $label, $icon, $maxOrder + 1]); echo json_encode(['success' => true, 'key' => $key]); } function recipeTagsRemove(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $key = trim($input['key'] ?? ''); if ($key === '') { echo json_encode(['success' => false, 'error' => 'key required']); return; } $rows = $db->query("SELECT recipe_json FROM recipe_library")->fetchAll(PDO::FETCH_COLUMN); foreach ($rows as $json) { $data = json_decode($json, true); if (in_array($key, $data['tags'] ?? [], true)) { echo json_encode(['success' => false, 'error' => 'tag still used by a recipe']); return; } } $db->prepare("DELETE FROM recipe_tags WHERE key = ?")->execute([$key]); echo json_encode(['success' => true]); } function recipeTagsUpdate(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $key = trim($input['key'] ?? ''); $label = trim($input['label'] ?? ''); $icon = trim($input['icon'] ?? ''); if ($key === '' || $label === '') { echo json_encode(['success' => false, 'error' => 'key and label required']); return; } $stmt = $db->prepare("SELECT id FROM recipe_tags WHERE key = ?"); $stmt->execute([$key]); if (!$stmt->fetch()) { echo json_encode(['success' => false, 'error' => 'tag not found']); return; } $stmt = $db->prepare("UPDATE recipe_tags SET label = ?, icon = ? WHERE key = ?"); $stmt->execute([$label, $icon ?: '🏷️', $key]); echo json_encode(['success' => true]); } // ===== SHARED APP DATA FUNCTIONS ===== function appSettingsGet(PDO $db): void { $rows = $db->query("SELECT key, value FROM app_settings")->fetchAll(); $settings = []; foreach ($rows as $row) { EverLog::debug('appSettingsGet'); $settings[$row['key']] = json_decode($row['value'], true) ?? $row['value']; } echo json_encode(['success' => true, 'settings' => $settings]); } function appSettingsSave(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); if (!$input || !is_array($input['settings'] ?? null)) { EverLog::debug('appSettingsSave'); echo json_encode(['error' => 'Missing settings object']); return; } $stmt = $db->prepare("INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"); foreach ($input['settings'] as $key => $value) { $stmt->execute([$key, json_encode($value)]); } echo json_encode(['success' => true]); } function recipesList(PDO $db): void { $limit = min(intval($_GET['limit'] ?? 60), 200); $rows = $db->query("SELECT id, date, meal, recipe_json, created_at, is_favorite FROM recipes ORDER BY is_favorite DESC, date DESC, created_at DESC LIMIT {$limit}")->fetchAll(); EverLog::debug('recipesList'); $recipes = []; foreach ($rows as $row) { $recipes[] = [ 'id' => $row['id'], 'date' => $row['date'], 'meal' => $row['meal'], 'recipe' => json_decode($row['recipe_json'], true), 'savedAt' => strtotime($row['created_at']) * 1000, 'is_favorite' => (bool)$row['is_favorite'], ]; } echo json_encode(['success' => true, 'recipes' => $recipes]); } function recipeToggleFavorite(PDO $db): void { EverLog::info('recipeToggleFavorite'); $input = json_decode(file_get_contents('php://input'), true); $id = intval($input['id'] ?? 0); if ($id <= 0) { echo json_encode(['error' => 'Invalid id']); return; } $db->prepare("UPDATE recipes SET is_favorite = 1 - is_favorite WHERE id = ?")->execute([$id]); $fav = (int)$db->query("SELECT is_favorite FROM recipes WHERE id = {$id}")->fetchColumn(); echo json_encode(['success' => true, 'is_favorite' => (bool)$fav]); } function recipesSave(PDO $db): void { EverLog::info('recipesSave'); $input = json_decode(file_get_contents('php://input'), true); $date = $input['date'] ?? date('Y-m-d'); $meal = trim($input['meal'] ?? '') ?: 'libero'; $recipe = $input['recipe'] ?? null; if (!$recipe) { echo json_encode(['error' => 'Missing recipe']); return; } // UPSERT: one recipe per meal per day (last one wins) $stmt = $db->prepare("INSERT INTO recipes (date, meal, recipe_json, created_at) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(date, meal) DO UPDATE SET recipe_json = excluded.recipe_json, created_at = excluded.created_at"); $stmt->execute([$date, $meal, json_encode($recipe)]); echo json_encode(['success' => true, 'id' => $db->lastInsertId()]); } function recipesDelete(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); $id = intval($input['id'] ?? 0); if ($id > 0) { EverLog::info('recipesDelete'); $db->prepare("DELETE FROM recipes WHERE id = ?")->execute([$id]); } echo json_encode(['success' => true]); } function chatList(PDO $db): void { $rows = $db->query("SELECT id, role, text, created_at FROM chat_messages ORDER BY id ASC LIMIT 100")->fetchAll(); echo json_encode(['success' => true, 'messages' => $rows]); } function chatSave(PDO $db): void { EverLog::debug('chatList'); $input = json_decode(file_get_contents('php://input'), true); $messages = $input['messages'] ?? []; if (empty($messages)) { echo json_encode(['error' => 'No messages']); return; } $stmt = $db->prepare("INSERT INTO chat_messages (role, text, created_at) VALUES (?, ?, datetime('now'))"); foreach ($messages as $msg) { if (!empty($msg['role']) && isset($msg['text'])) { $stmt->execute([$msg['role'], $msg['text']]); } } // Prune: keep only the last 200 messages (cap to avoid unbounded growth) $db->exec("DELETE FROM chat_messages WHERE id NOT IN (SELECT id FROM chat_messages ORDER BY id DESC LIMIT 200)"); echo json_encode(['success' => true]); } function chatClear(PDO $db): void { EverLog::info('chatClear'); $db->exec("DELETE FROM chat_messages"); echo json_encode(['success' => true]); } /** * One-time migration: convert all kg→g and l→ml in products table, * and scale inventory quantities accordingly. */ function migrateUnitsToBase(PDO $db): void { EverLog::info('migrateUnitsToBase'); $changes = 0; // Get products with kg or l units $stmt = $db->query("SELECT id, unit, default_quantity, package_unit FROM products WHERE unit IN ('kg','l') OR package_unit IN ('kg','l')"); $products = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($products as $p) { $newUnit = $p['unit']; $newDefQty = (float)$p['default_quantity']; $newPkgUnit = $p['package_unit']; $scaleInventory = false; if ($p['unit'] === 'kg') { $newUnit = 'g'; $newDefQty = $newDefQty * 1000; $scaleInventory = true; } elseif ($p['unit'] === 'l') { $newUnit = 'ml'; $newDefQty = $newDefQty * 1000; $scaleInventory = true; } if ($p['package_unit'] === 'kg') { $newPkgUnit = 'g'; if ($p['unit'] === 'conf') $newDefQty = $newDefQty * 1000; } elseif ($p['package_unit'] === 'l') { $newPkgUnit = 'ml'; if ($p['unit'] === 'conf') $newDefQty = $newDefQty * 1000; } $upd = $db->prepare("UPDATE products SET unit = ?, default_quantity = ?, package_unit = ? WHERE id = ?"); $upd->execute([$newUnit, $newDefQty, $newPkgUnit, $p['id']]); $changes++; // Scale inventory quantities (kg→g means multiply by 1000) if ($scaleInventory) { $db->prepare("UPDATE inventory SET quantity = quantity * 1000 WHERE product_id = ?")->execute([$p['id']]); } } echo json_encode(['success' => true, 'changes' => $changes]); } // ============================================================================= // ===== CENTRALIZED ERROR REPORTING → GITHUB ISSUES ========================== // ============================================================================= // GH_REPO is defined at the very top of this file so they // are available to the global exception handler even before this point. // The token is accessed via _ghToken() which decodes it at runtime. /** * POST /api/?action=report_error * * Accepts error payloads from any client (PWA browser, Android kiosk, cron). * Creates a GitHub issue on dadaloop82/EverShelf with deduplication: * if an open issue with the same fingerprint already exists it posts a comment * instead of opening a duplicate. * * Expected JSON body: * source string 'pwa'|'kiosk'|'php'|'cron'|'scale' * type string e.g. 'js-error'|'php-crash'|'unhandled-promise'|… * message string Error message (required) * stack string? Stack trace * context object? Arbitrary key→value extra info * url string? Page URL where the error occurred * user_agent string? Navigator UA * version string? App version */ function reportError(): void { EverLog::info('reportError'); $input = json_decode(file_get_contents('php://input'), true) ?: []; $source = preg_replace('/[^a-z0-9_\-]/', '', strtolower($input['source'] ?? 'unknown')); $type = preg_replace('/[^a-z0-9_\-]/', '', strtolower($input['type'] ?? 'error')); $message = substr(trim($input['message'] ?? ''), 0, 500); $stack = substr(trim($input['stack'] ?? ''), 0, 4000); $pageUrl = substr(trim($input['url'] ?? ''), 0, 300); $ua = substr(trim($input['user_agent'] ?? $_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 300); $version = substr(trim($input['version'] ?? ''), 0, 50); $context = $input['context'] ?? []; if (empty($message)) { echo json_encode(['ok' => false, 'error' => 'message required']); return; } // ── Write to local log regardless of GitHub availability ────────────── _appendErrorLog($source, $type, $message, $stack, $pageUrl, $ua, $context); // ── Version guard: skip GitHub issue if client is not on latest release ─ // Avoids noise from bugs already fixed in a newer version. // Exception: install/update errors are ALWAYS reported regardless of version, // because a device that is failing to install the update is by definition on // an old version — suppressing the issue is the opposite of useful. $installErrorTypes = ['install_download_failed', 'install_failure', 'install-failure', 'install_packager_exception']; $bypassVersionGuard = in_array($type, $installErrorTypes, true) || ($context['version_guard_bypass'] ?? false); if (!$bypassVersionGuard && !_isLatestVersion($version)) { echo json_encode(['ok' => true, 'skipped' => 'outdated_version']); return; } // ── Fire GitHub issue (non-blocking: we always return ok to client) ─── _createOrCommentGithubIssue(_ghToken(), GH_REPO, $source, $type, $message, $stack, $pageUrl, $ua, $version, $context); echo json_encode(['ok' => true]); } /** * POST /api/?action=report_bug * * Manual bug/feature/question report submitted by the user via the in-app form. * Creates a GitHub issue directly with the provided title and description. * * Expected JSON body: * type string 'bug'|'feature'|'question' * title string Issue title (required, max 150 chars) * description string Main description (required, max 3000 chars) * steps string? Steps to reproduce (optional, max 2000 chars) * lang string? UI language the user is running * url string? Page URL * user_agent string? Navigator UA * version string? App version */ function reportBugManual(): void { EverLog::info('reportBugManual'); $input = json_decode(file_get_contents('php://input'), true) ?: []; $allowedTypes = ['bug', 'feature', 'question']; $type = in_array($input['type'] ?? '', $allowedTypes, true) ? $input['type'] : 'bug'; $title = substr(trim($input['title'] ?? ''), 0, 150); $desc = substr(trim($input['description'] ?? ''), 0, 3000); $steps = substr(trim($input['steps'] ?? ''), 0, 2000); $ua = substr(trim($input['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? '')), 0, 300); $url = substr(trim($input['url'] ?? ''), 0, 300); $ver = substr(trim($input['version'] ?? ''), 0, 50); $lang = preg_replace('/[^a-z\-]/', '', strtolower($input['lang'] ?? 'it')); if (empty($title) || empty($desc)) { echo json_encode(['ok' => false, 'error' => 'title and description required']); return; } $token = _ghToken(); if (!$token) { // No GitHub token configured — log locally and return ok so the UX is not broken _appendErrorLog('pwa', 'manual_report', $title, $desc, $url, $ua, ['type' => $type, 'version' => $ver, 'lang' => $lang]); echo json_encode(['ok' => true, 'issue' => null]); return; } // Labels: always 'user-report' + type-specific label $labelMap = [ 'bug' => ['bug', 'user-report'], 'feature' => ['enhancement', 'user-report'], 'question' => ['question', 'user-report'], ]; $labels = $labelMap[$type]; $typeEmoji = ['bug' => '🐛', 'feature' => '💡', 'question' => '❓'][$type]; $ts = date('Y-m-d H:i:s T'); $body = "## {$typeEmoji} User Report\n\n"; $body .= "**Description:**\n{$desc}\n\n"; if ($steps) { $body .= "**Steps to reproduce:**\n{$steps}\n\n"; } $body .= "---\n"; $body .= "**Version:** `{$ver}` \n"; $body .= "**Language:** `{$lang}` \n"; if ($url) $body .= "**URL:** `{$url}` \n"; if ($ua) $body .= "**User-Agent:** `{$ua}` \n"; $body .= "**Reported at:** {$ts}\n\n"; $body .= "_This issue was submitted via the in-app bug report form._"; $res = _githubRequest($token, 'POST', 'https://api.github.com/repos/' . GH_REPO . '/issues', ['title' => $title, 'body' => $body, 'labels' => $labels] ); $issueNum = $res['body']['number'] ?? null; $issueUrl = $res['body']['html_url'] ?? null; if ($issueNum) { echo json_encode(['ok' => true, 'issue' => $issueNum, 'url' => $issueUrl]); } else { echo json_encode(['ok' => false, 'error' => 'github_api_error']); } } /** * Append to data/error_reports.log (local safety net, max 500 KB) */ function _appendErrorLog(string $source, string $type, string $message, string $stack, string $url, string $ua, array $context): void { $logFile = __DIR__ . '/../data/error_reports.log'; // Rotate if > 500 KB if (file_exists($logFile) && filesize($logFile) > 500000) { $lines = file($logFile); $lines = array_slice($lines, -300); file_put_contents($logFile, implode('', $lines)); } $ts = date('Y-m-d H:i:s'); $ctx = $context ? ' ctx=' . json_encode($context, JSON_UNESCAPED_UNICODE) : ''; $line = "[$ts] [$source] [$type] $message" . ($url ? " | url=$url" : '') . $ctx . "\n"; if ($stack) $line .= " STACK: " . str_replace("\n", "\n ", $stack) . "\n"; file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX); } /** * Fingerprint = sha1(source:type:first-120-chars-of-message) * Used to deduplicate open issues. */ function _errorFingerprint(string $source, string $type, string $message): string { return sha1($source . ':' . $type . ':' . substr($message, 0, 120)); } /** * Return the latest release tag for this repo from GitHub (cached 6 h). * Returns '' if no release exists or the API is unreachable. */ function _latestReleaseTag(): string { static $cached = null; if ($cached !== null) return $cached; $cacheFile = __DIR__ . '/../data/latest_release_cache.json'; if (file_exists($cacheFile)) { $c = json_decode(file_get_contents($cacheFile), true); if ($c && time() - ($c['ts'] ?? 0) < 21600) { // 6 h return $cached = ($c['tag'] ?? ''); } } $res = _githubRequest(_ghToken(), 'GET', 'https://api.github.com/repos/' . GH_REPO . '/releases/latest'); $tag = $res['body']['tag_name'] ?? ''; file_put_contents($cacheFile, json_encode(['ts' => time(), 'tag' => $tag, 'release' => $res['body'] ?? []])); return $cached = $tag; } /** * Read the webapp version from manifest.json (cached per process). */ function _appVersion(): string { static $ver = null; if ($ver !== null) return $ver; $manifest = @json_decode(@file_get_contents(__DIR__ . '/../manifest.json'), true); return $ver = ($manifest['version'] ?? ''); } /** * Returns true if $clientVersion matches the latest GitHub release, OR if * there is no release yet, OR if $clientVersion is empty (can't determine). * A leading 'v' is stripped from both sides before comparison. */ function _isLatestVersion(string $clientVersion): bool { if ($clientVersion === '') return true; // unknown → allow (don't suppress) $latest = _latestReleaseTag(); if ($latest === '') return true; // no release yet → allow $latestNorm = ltrim($latest, 'v'); // If tag is not semver-like (e.g. "latest", "rolling") we can't compare // meaningfully, so don't suppress error reporting. if (!preg_match('/^\d+\.\d+/', $latestNorm)) return true; return ltrim($clientVersion, 'v') === $latestNorm; } /** * GET/POST /api/?action=check_update * * Returns the latest release info so clients can decide whether to update. * Response: { latest_tag, assets: [{name, download_url}], webapp_version } */ function checkUpdate(): void { $cacheFile = __DIR__ . '/../data/latest_release_cache.json'; $release = []; if (file_exists($cacheFile)) { EverLog::info('checkUpdate'); $c = json_decode(file_get_contents($cacheFile), true); if ($c && time() - ($c['ts'] ?? 0) < 21600) { $release = $c['release'] ?? []; } } if (empty($release)) { $res = _githubRequest(_ghToken(), 'GET', 'https://api.github.com/repos/' . GH_REPO . '/releases/latest'); $release = $res['body'] ?? []; $tag = $release['tag_name'] ?? ''; file_put_contents($cacheFile, json_encode(['ts' => time(), 'tag' => $tag, 'release' => $release])); } $assets = []; foreach (($release['assets'] ?? []) as $a) { $assets[] = ['name' => $a['name'] ?? '', 'download_url' => $a['browser_download_url'] ?? '']; } echo json_encode([ 'ok' => true, 'latest_tag' => $release['tag_name'] ?? '', 'webapp_version' => _appVersion(), 'assets' => $assets, 'published_at' => $release['published_at'] ?? '', 'html_url' => $release['html_url'] ?? '', ]); } /** * Return path to the local fingerprint deduplication cache. * Falls back to /tmp when data/ is not writable (e.g. fresh install with wrong perms). */ function _getFpCachePath(): string { $primary = __DIR__ . '/../data/reported_issue_fps.json'; return is_writable(dirname($primary)) ? $primary : (sys_get_temp_dir() . '/evershelf_fps.json'); } /** Load & prune (> 30 days) the local FP cache. */ function _loadFpCache(): array { $path = _getFpCachePath(); if (!file_exists($path)) return []; $data = @json_decode(@file_get_contents($path), true) ?: []; $cutoff = time() - 30 * 86400; return array_filter($data, fn($v) => ($v['ts'] ?? 0) > $cutoff); } /** Persist the local FP cache. */ function _saveFpCache(array $cache): void { @file_put_contents(_getFpCachePath(), json_encode($cache), LOCK_EX); } /** * Create a GitHub issue, or add a comment to an existing open issue with the * same fingerprint. Uses the REST API v3 directly (no library needed). * * Deduplication strategy (two-layer): * 1. Local file cache (data/reported_issue_fps.json or /tmp fallback) — checked * first to avoid the GitHub Search API indexing delay that caused duplicate * issues to be created in rapid succession. * 2. GitHub Search API — used only on first occurrence (cache miss) as backup. * * Comment throttle: at most one recurrence comment per 30 minutes per fingerprint, * to avoid flooding an issue when an error fires on every request. */ function _createOrCommentGithubIssue( string $token, string $repo, string $source, string $type, string $message, string $stack, string $pageUrl, string $ua, string $version, array $context ): void { $fp = _errorFingerprint($source, $type, $message); EverLog::debug('_createOrCommentGithubIssue', ['fp' => $fp, 'type' => $type]); // ── 1. Check local cache (fast, avoids Search API indexing lag) ──────── $fpCache = _loadFpCache(); $existingIssueNumber = null; if (isset($fpCache[$fp])) { $existingIssueNumber = $fpCache[$fp]['issue']; // Comment throttle: skip if we already commented within the last 30 min $lastComment = $fpCache[$fp]['last_comment'] ?? 0; if (time() - $lastComment < 1800) { EverLog::debug('_createOrCommentGithubIssue: throttled', ['fp' => $fp]); return; } } else { // ── 2. Fall back to GitHub Search (handles first run / cache cleared) ─ $searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body"); $searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1"); if (!empty($searchResult['body']['items'][0]['number'])) { $existingIssueNumber = (int)$searchResult['body']['items'][0]['number']; // Populate local cache with what we found $fpCache[$fp] = ['issue' => $existingIssueNumber, 'ts' => time(), 'last_comment' => 0]; _saveFpCache($fpCache); } } // ── Build the common details block ───────────────────────────────────── $ts = date('Y-m-d H:i:s T'); $ctxMd = ''; if ($context) { $ctxMd = "\n**Context:**\n```json\n" . json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n```\n"; } $stackMd = $stack ? "\n**Stack trace:**\n```\n$stack\n```\n" : ''; $urlMd = $pageUrl ? "\n**URL:** `$pageUrl`" : ''; $uaMd = $ua ? "\n**User-Agent:** `$ua`" : ''; $verMd = $version ? "\n**Version:** `$version`" : ''; if ($existingIssueNumber) { // ── 3a. Post a comment to the existing issue ────────────────────── $body = "### 🔁 Recurrence — $ts\n" . "**Source:** `$source` | **Type:** `$type`\n" . $urlMd . $uaMd . $verMd . "\n" . $ctxMd . $stackMd . "\n---\n_fp:{$fp}_"; _githubRequest($token, 'POST', "https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments", ['body' => $body] ); // Update throttle timestamp $fpCache[$fp]['last_comment'] = time(); _saveFpCache($fpCache); } else { // ── 3b. Create a new issue ──────────────────────────────────────── // Determine labels from source $labelMap = [ 'pwa' => 'js-error', 'kiosk' => 'kiosk-error', 'php' => 'php-crash', 'cron' => 'php-crash', 'scale' => 'scale-error', ]; $typeLabel = $labelMap[$source] ?? 'js-error'; $shortMsg = strlen($message) > 70 ? substr($message, 0, 70) . '…' : $message; $title = "[" . strtoupper($source) . "] $shortMsg"; $body = "## 🚨 Automatic Error Report\n\n" . "**Source:** `$source` \n" . "**Type:** `$type` \n" . "**Reported at:** $ts \n" . $urlMd . "\n" . $uaMd . "\n" . $verMd . "\n\n" . "**Error message:**\n> $message\n" . $stackMd . $ctxMd . "\n---\n" . "\n" . "_This issue was created automatically by EverShelf's error reporter. fp:`{$fp}`_"; $newIssueRes = _githubRequest($token, 'POST', "https://api.github.com/repos/$repo/issues", [ 'title' => $title, 'body' => $body, 'labels' => ['auto-report', $typeLabel], ] ); // Save to local cache immediately to prevent duplicates on rapid recurrences $newNum = $newIssueRes['body']['number'] ?? null; if ($newNum) { $fpCache[$fp] = ['issue' => (int)$newNum, 'ts' => time(), 'last_comment' => time()]; _saveFpCache($fpCache); } } } /** * Minimal GitHub REST API helper (curl). * Returns ['http_code' => int, 'body' => array]. */ function _githubRequest(string $token, string $method, string $url, array $payload = []): array { EverLog::debug('_githubRequest'); $ch = curl_init($url); $headers = [ 'Authorization: token ' . $token, 'Accept: application/vnd.github+json', 'X-GitHub-Api-Version: 2022-11-28', 'User-Agent: EverShelf-ErrorReporter/1.0', 'Content-Type: application/json', ]; curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, ]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); } $raw = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []]; } /** * Called by the PHP exception/shutdown handlers registered at the top of this file. * Writes to local log + creates a GitHub issue. */ function _phpErrorReport(string $message, string $file, int $line, string $trace, string $type): void { EverLog::error('_phpErrorReport'); // Prevent infinite loops if this function itself throws static $running = false; if ($running) return; $running = true; $source = 'php'; $errType = 'php-crash'; $appVer = _appVersion(); $context = [ 'file' => $file, 'line' => $line, 'php' => PHP_VERSION, 'app_ver' => $appVer, 'action' => $_GET['action'] ?? '', 'method' => $_SERVER['REQUEST_METHOD'] ?? '', ]; _appendErrorLog($source, $errType, "[$type] $message", $trace, '', '', $context); // Only create GitHub issue if running the latest released version if (_isLatestVersion($appVer)) { _createOrCommentGithubIssue( _ghToken(), GH_REPO, $source, $errType, "[$type] $message", $trace, '', '', $appVer, $context ); } $running = false; } // ============================================================================= // ===== GEMINI AI: PRODUCT HINT (shelf-life + storage suggestion) ============= // ============================================================================= /** * POST /api/?action=gemini_product_hint * Body: { name, category, lang } * Returns: { success, location, expiry_days, reason, source } * Uses a permanent cache keyed by (name, lang) — science doesn't change. */ function geminiProductHint(): void { EverLog::info('geminiProductHint'); $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { EverLog::info('geminiProductHint'); echo json_encode(['success' => false, 'error' => 'no_api_key']); return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; $name = trim($input['name'] ?? ''); $category = trim($input['category'] ?? ''); $lang = trim($input['lang'] ?? 'it'); if (empty($name)) { echo json_encode(['success' => false, 'error' => 'missing name']); return; } // Cache keyed by normalised name + lang $cacheFile = __DIR__ . '/../data/food_facts_cache.json'; $cacheKey = 'phint_' . md5(mb_strtolower($name) . '|' . $lang); $cache = []; if (file_exists($cacheFile)) { $cache = json_decode(file_get_contents($cacheFile), true) ?: []; } if (!empty($cache[$cacheKey])) { echo json_encode(array_merge(['success' => true, 'source' => 'cache'], $cache[$cacheKey])); return; } $langLabel = match($lang) { 'en' => 'English', 'de' => 'German', default => 'Italian' }; $prompt = "You are a food safety expert. For the food product named \"{$name}\" (category: {$category}), " . "answer in {$langLabel} with a strict JSON object and NOTHING else:\n" . "{\n" . " \"location\": \"dispensa\" | \"frigo\" | \"freezer\",\n" . " \"expiry_days\": ,\n" . " \"reason\": \"<1 short sentence explaining location and duration>\"\n" . "}\n" . "Rules: location must be one of the three values. expiry_days must be a positive integer. " . "If the product is typically refrigerated use 'frigo'. If frozen use 'freezer'. Otherwise 'dispensa'. " . "Output ONLY the JSON, no markdown, no extra text."; $payload = ['contents' => [['parts' => [['text' => $prompt]]]]]; $result = callGeminiWithFallback($apiKey, $payload, 15, 'product_hint'); if ($result['http_code'] !== 200) { echo json_encode(['success' => false, 'error' => 'gemini_error', 'http_code' => $result['http_code']]); return; } $text = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; // Strip potential markdown fences $text = preg_replace('/^```json\s*/i', '', trim($text)); $text = preg_replace('/\s*```$/i', '', $text); $parsed = json_decode(trim($text), true); $allowedLocations = ['dispensa', 'frigo', 'freezer']; if ( !is_array($parsed) || empty($parsed['location']) || !in_array($parsed['location'], $allowedLocations, true) || empty($parsed['expiry_days']) || !is_numeric($parsed['expiry_days']) ) { echo json_encode(['success' => false, 'error' => 'parse_error', 'raw' => $text]); return; } $data = [ 'location' => $parsed['location'], 'expiry_days' => (int)$parsed['expiry_days'], 'reason' => $parsed['reason'] ?? '', ]; // Persist to cache (permanent — no expiry) $cache[$cacheKey] = $data; file_put_contents($cacheFile, json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); echo json_encode(array_merge(['success' => true, 'source' => 'gemini'], $data)); } // ============================================================================= // ===== GEMINI AI: SHOPPING SUGGESTION ENRICHMENT ============================ // ============================================================================= /** * POST /api/?action=gemini_shopping_enrich * Body: { items: [{name, reason, category, priority}], lang } * Returns: { success, items: [{name, reason, tip}] } * Enriches shopping suggestions with a short actionable tip per item. * Batches all items in a single Gemini call. Cached by name+lang hash. */ function geminiShoppingEnrich(PDO $db): void { EverLog::info('geminiShoppingEnrich'); $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { EverLog::info('geminiShoppingEnrich'); echo json_encode(['success' => false, 'error' => 'no_api_key']); return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; $items = $input['items'] ?? []; $lang = trim($input['lang'] ?? 'it'); if (empty($items)) { echo json_encode(['success' => true, 'items' => []]); return; } // Cache keyed by sorted item names + lang (so reorder doesn't bust it) $names = array_column($items, 'name'); sort($names); $cacheFile = __DIR__ . '/../data/food_facts_cache.json'; $cacheKey = 'senrich_' . md5(implode('|', $names) . '|' . $lang); $cache = []; if (file_exists($cacheFile)) { $cache = json_decode(file_get_contents($cacheFile), true) ?: []; } if (!empty($cache[$cacheKey])) { echo json_encode(['success' => true, 'items' => $cache[$cacheKey], 'source' => 'cache']); return; } $langLabel = match($lang) { 'en' => 'English', 'de' => 'German', default => 'Italian' }; $itemsJson = json_encode(array_map(fn($i) => [ 'name' => $i['name'], 'reason' => $i['reason'] ?? '', 'category' => $i['category'] ?? '', 'priority' => $i['priority'] ?? 'media', ], $items), JSON_UNESCAPED_UNICODE); $prompt = "You are a practical household assistant. " . "For each item in this shopping list, add a very short tip (max 10 words) in {$langLabel} " . "on what to look for when buying or how to store it. " . "Input JSON array:\n{$itemsJson}\n\n" . "Reply ONLY with a JSON array of objects with exactly these keys:\n" . "[{\"name\":\"...\",\"tip\":\"...\"},...]\n" . "Keep the same order and count as the input. Output ONLY the JSON array, no markdown."; $payload = ['contents' => [['parts' => [['text' => $prompt]]]]]; $result = callGeminiWithFallback($apiKey, $payload, 20, 'shopping_enrich'); if ($result['http_code'] !== 200) { echo json_encode(['success' => false, 'error' => 'gemini_error']); return; } $text = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; $text = preg_replace('/^```json\s*/i', '', trim($text)); $text = preg_replace('/\s*```$/i', '', $text); $parsed = json_decode(trim($text), true); if (!is_array($parsed)) { echo json_encode(['success' => false, 'error' => 'parse_error']); return; } // Build tip map by name for safe merging $tipMap = []; foreach ($parsed as $p) { if (!empty($p['name'])) $tipMap[mb_strtolower($p['name'])] = $p['tip'] ?? ''; } $enriched = array_map(function($item) use ($tipMap) { $item['tip'] = $tipMap[mb_strtolower($item['name'])] ?? ''; return $item; }, $items); // Cache for 24 h (TTL stored alongside) $cache[$cacheKey] = $enriched; file_put_contents($cacheFile, json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); echo json_encode(['success' => true, 'items' => $enriched, 'source' => 'gemini']); } // ============================================================================= // ===== GEMINI AI: NUMBER OCR (read barcode digits from image) ================ // ============================================================================= /** * POST /api/?action=gemini_number_ocr * Body: { image: base64-jpeg } * Returns: { success, barcode } or { success: false, error } * Uses Gemini vision to read the barcode number printed on a product label. */ function geminiNumberOCR(): void { EverLog::info('geminiNumberOCR'); $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { echo json_encode(['success' => false, 'error' => 'no_api_key']); return; } EverLog::info('geminiNumberOCR'); $input = json_decode(file_get_contents('php://input'), true); $imageBase64 = $input['image'] ?? ''; if (!$imageBase64) { echo json_encode(['success' => false, 'error' => 'no_image']); return; } $payload = [ 'contents' => [[ 'parts' => [ ['text' => 'Look at this product image. Find the barcode number (EAN-13 or EAN-8) printed on the label — it is usually a sequence of 8 or 13 digits printed below or near the barcode stripes. Return ONLY the digit sequence, nothing else. If you cannot find a valid barcode number, return exactly: none'], ['inline_data' => ['mime_type' => 'image/jpeg', 'data' => $imageBase64]] ] ]], 'generationConfig' => ['temperature' => 0, 'maxOutputTokens' => 20, 'thinkingConfig' => ['thinkingBudget' => 0]] ]; $result = callGeminiWithFallback($apiKey, $payload, 10, 'number_ocr'); $text = trim($result['text'] ?? ''); $digits = preg_replace('/\D/', '', $text); if (strlen($digits) === 13 || strlen($digits) === 8) { echo json_encode(['success' => true, 'barcode' => $digits]); } else { echo json_encode(['success' => false, 'error' => 'not_found']); } } // ============================================================================= // ===== GEMINI AI: BARCODE VISUAL FALLBACK ==================================== // ============================================================================= /** * POST /api/?action=gemini_barcode_visual * Body: { image: base64-jpeg, lang: 'it'|'en'|'de'|... } * Returns: { found, source, product } or { found: false, error } * Uses Gemini vision to visually identify a product from a camera frame * when the barcode scanner fails to read the barcode after 5 seconds. */ function geminiBarcodeVisual(): void { EverLog::info('geminiBarcodeVisual'); $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { echo json_encode(['found' => false, 'error' => 'no_api_key']); return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; $imageBase64 = $input['image'] ?? ''; $lang = $input['lang'] ?? 'it'; if (empty($imageBase64)) { echo json_encode(['found' => false, 'error' => 'no_image']); return; } $langNote = match($lang) { 'de' => 'Use the German product name if known.', 'fr' => 'Use the French product name if known.', 'es' => 'Use the Spanish product name if known.', default => 'Use the Italian product name if known.', }; $payload = [ 'contents' => [[ 'parts' => [ ['text' => "Identify the product shown in this image. {$langNote}\n" . "Respond with ONLY valid JSON (no markdown, no backticks):\n" . "{\"name\":\"...\",\"brand\":\"...\",\"category\":\"...\"}\n" . "- name: the product name (as specific as possible, not just the brand)\n" . "- brand: the brand/manufacturer, or empty string if not visible\n" . "- category: one of: latticini, pasta, bevande, snack, carne, pesce, " . "frutta, verdura, surgelati, condimenti, conserve, cereali, pane, " . "igiene, pulizia, altro\n" . "If you cannot identify the product at all, respond with: {\"unknown\":true}"], ['inline_data' => ['mime_type' => 'image/jpeg', 'data' => $imageBase64]], ], ]], 'generationConfig' => [ 'temperature' => 0, 'maxOutputTokens' => 200, 'responseMimeType' => 'application/json', 'thinkingConfig' => ['thinkingBudget' => 0], ], ]; $result = callGeminiWithFallback($apiKey, $payload, 15, 'barcode_visual'); if ($result['http_code'] !== 200) { echo json_encode(['found' => false, 'error' => 'gemini_error_' . $result['http_code']]); return; } $text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''); // Strip accidental markdown fences $text = preg_replace('/^```json\s*/i', '', $text); $text = preg_replace('/\s*```$/i', '', trim($text)); $data = json_decode($text, true); if (!$data || !empty($data['unknown']) || empty($data['name'])) { echo json_encode(['found' => false]); return; } echo json_encode([ 'found' => true, 'source' => 'gemini_visual', 'product' => [ 'name' => $data['name'] ?? '', 'brand' => $data['brand'] ?? '', 'category' => $data['category'] ?? '', 'image_url' => '', 'quantity_info' => '', 'nutriscore' => '', 'ingredients' => '', 'allergens' => '', 'conservation' => '', 'origin' => '', 'nova_group' => '', 'ecoscore' => '', 'labels' => '', 'stores' => '', ], ], JSON_UNESCAPED_UNICODE); } // ============================================================================= // ===== GEMINI AI: ANOMALY EXPLANATION ======================================= // ============================================================================= /** * POST /api/?action=gemini_anomaly_explain * Body: { name, inv_qty, expected_qty, diff, direction, unit, lang } * Returns: { success, explanation } * Explains in plain language why the anomaly likely occurred and what to do. */ function geminiAnomalyExplain(): void { EverLog::info('geminiAnomalyExplain'); $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { EverLog::info('geminiAnomalyExplain'); echo json_encode(['success' => false, 'error' => 'no_api_key']); return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; $name = trim($input['name'] ?? ''); $invQty = $input['inv_qty'] ?? 0; $expQty = $input['expected_qty'] ?? 0; $diff = $input['diff'] ?? 0; $direction = $input['direction'] ?? 'missing'; $unit = $input['unit'] ?? 'pz'; $lang = trim($input['lang'] ?? 'it'); if (empty($name)) { echo json_encode(['success' => false, 'error' => 'missing name']); return; } $langLabel = match($lang) { 'en' => 'English', 'de' => 'German', default => 'Italian' }; $directionDesc = match($direction) { 'phantom' => "The inventory shows {$invQty} {$unit} but transaction history predicts only {$expQty} {$unit} (excess of " . abs($diff) . " {$unit}).", 'missing' => "The inventory shows {$invQty} {$unit} but transaction history predicts {$expQty} {$unit} (shortage of " . abs($diff) . " {$unit}).", 'untracked' => "More consumption was recorded than purchase entries. The initial stock was likely never registered as an 'in' transaction. Current inventory: {$invQty} {$unit}.", default => "Inventory discrepancy detected for {$name}.", }; $prompt = "You are a helpful home pantry assistant. " . "An inventory discrepancy has been detected for the product \"{$name}\". " . $directionDesc . " " . "In 2-3 sentences in {$langLabel}, explain in simple friendly language: " . "(1) the most likely everyday reason this happened, and " . "(2) the simplest action the user should take to fix it. " . "Do NOT mention databases, transactions, or technical terms. " . "Be conversational and practical."; $payload = ['contents' => [['parts' => [['text' => $prompt]]]]]; $result = callGeminiWithFallback($apiKey, $payload, 15, 'anomaly_explain'); if ($result['http_code'] !== 200) { echo json_encode(['success' => false, 'error' => 'gemini_error']); return; } $explanation = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''); echo json_encode(['success' => true, 'explanation' => $explanation]); } // ───────────────────────────────────────────────────────────────────────────── // SHOPPING LIST PRICE ESTIMATION (AI-powered, cached) // ───────────────────────────────────────────────────────────────────────────── // Note: PRICE_CACHE_PATH constant is defined at the top of the file. function _loadPriceCache(): array { if (!file_exists(PRICE_CACHE_PATH)) return []; try { return json_decode(file_get_contents(PRICE_CACHE_PATH), true) ?? []; } catch (\Throwable $e) { return []; } } function _savePriceCache(array $data): void { file_put_contents(PRICE_CACHE_PATH, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } /** * Return cache key: md5(lowercase name + country + schema version) * Bump version suffix when AI prompt format changes to auto-invalidate old entries. */ function _priceKey(string $name, string $country): string { return md5(mb_strtolower(trim($name)) . '|' . mb_strtolower(trim($country)) . '|v3'); } /** Max age for cached unit prices and canonical shopping total (default: 1 week). */ function _shoppingPriceMaxAgeSeconds(): int { $weeks = (int)env('PRICE_UPDATE_WEEKS', '1'); if ($weeks > 0) return $weeks * 7 * 86400; $months = (int)env('PRICE_UPDATE_MONTHS', '3'); return max(7 * 86400, $months * 30 * 86400); } function _shoppingListHash(array $names, string $country, string $currency): string { $sorted = array_values(array_unique(array_map( static fn($n) => mb_strtolower(trim((string)$n)), array_filter($names, static fn($n) => trim((string)$n) !== '') ))); sort($sorted); return md5(json_encode($sorted, JSON_UNESCAPED_UNICODE) . '|' . mb_strtolower(trim($country)) . '|' . mb_strtolower(trim($currency))); } function _shoppingListPriceHash(array $items, string $country, string $currency): string { $key = array_map(static fn($i) => [ mb_strtolower(trim($i['name'] ?? '')), round((float)($i['quantity'] ?? 1), 2), mb_strtolower(trim($i['unit'] ?? 'conf')), ], $items); usort($key, static fn($a, $b) => strcmp($a[0], $b[0])); return md5(json_encode($key, JSON_UNESCAPED_UNICODE) . '|' . mb_strtolower(trim($country)) . '|' . mb_strtolower(trim($currency))); } function _loadCanonicalShoppingTotal(string $listHash): ?array { $path = __DIR__ . '/../data/shopping_total_cache.json'; if (!file_exists($path)) return null; $tc = json_decode(file_get_contents($path), true) ?? []; $entry = $tc['_canonical'] ?? null; if (!$entry || ($entry['list_hash'] ?? '') !== $listHash) return null; if (time() - (int)($entry['ts'] ?? 0) >= _shoppingPriceMaxAgeSeconds()) return null; $result = $entry['result'] ?? null; return is_array($result) ? $result : null; } function _saveCanonicalShoppingTotal(string $listHash, array $result): void { $path = __DIR__ . '/../data/shopping_total_cache.json'; $tc = file_exists($path) ? (json_decode(file_get_contents($path), true) ?? []) : []; $tc['_canonical'] = ['ts' => time(), 'list_hash' => $listHash, 'result' => $result]; file_put_contents($path, json_encode($tc, JSON_UNESCAPED_UNICODE)); } function _loadSmartShoppingItems(): array { $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; if (!file_exists($cacheFile)) return []; $raw = file_get_contents($cacheFile); if (!$raw) return []; $sc = json_decode($raw, true); return (is_array($sc) && isset($sc['items'])) ? $sc['items'] : []; } /** Match a Bring! list name to a smart-shopping row (exact name or shopping_name). */ function _matchSmartShoppingItem(string $name, array $smartItems): ?array { $nameLower = mb_strtolower(trim($name)); if ($nameLower === '') return null; foreach ($smartItems as $si) { if (mb_strtolower($si['name'] ?? '') === $nameLower) return $si; if (mb_strtolower($si['shopping_name'] ?? '') === $nameLower) return $si; } $computed = mb_strtolower(computeShoppingName($name)); foreach ($smartItems as $si) { $sn = mb_strtolower($si['shopping_name'] ?? $si['name'] ?? ''); if ($sn !== '' && $sn === $computed) return $si; } foreach ($smartItems as $si) { $sn = mb_strtolower($si['shopping_name'] ?? $si['name'] ?? ''); if ($sn !== '' && (str_starts_with($sn, $nameLower) || str_starts_with($nameLower, $sn))) { return $si; } } return null; } /** * Resolve qty/unit/defQty for price estimation from smart-shopping suggestions. * Each shopping-list line is priced as ONE typical retail purchase — not 14-day restock stock. */ function _resolveShoppingPriceItem(string $name, array $smartItems): array { $si = _matchSmartShoppingItem($name, $smartItems); if ($si) { $unit = trim($si['unit'] ?? 'conf'); $defQty = (float)($si['default_qty'] ?? 0); $pkgUnit = trim($si['package_unit'] ?? ''); // Packaged goods (conf + weight/volume): one package at list price. if ($unit === 'conf' && $defQty > 0 && $pkgUnit !== '') { return [ 'name' => $name, 'quantity' => $defQty, 'unit' => strtolower($pkgUnit), 'default_quantity' => $defQty, 'package_unit' => $pkgUnit, ]; } // Sold by piece: 2–3 items typical for a single shop trip. if ($unit === 'pz') { $gramsPerPiece = ($defQty >= 20) ? $defQty : 200.0; return [ 'name' => $name, 'quantity' => 2, 'unit' => 'pz', 'default_quantity' => $gramsPerPiece, 'package_unit' => 'g', ]; } // Bulk g/ml with known reference pack: one pack, not multi-week stock. if (($unit === 'g' || $unit === 'ml') && $defQty > 0) { return [ 'name' => $name, 'quantity' => $defQty, 'unit' => $unit, 'default_quantity' => $defQty, 'package_unit' => $pkgUnit, ]; } } return [ 'name' => $name, 'quantity' => 1, 'unit' => 'conf', 'default_quantity' => 0, 'package_unit' => '', ]; } function _shoppingListPriceItems(array $clientItems, array $smartItems = []): array { $items = []; foreach ($clientItems as $ci) { $name = trim($ci['name'] ?? ''); if ($name === '') continue; $items[] = _resolveShoppingPriceItem($name, $smartItems); } return $items; } /** * Compute shopping list prices + canonical total (shared by UI, HA and screensaver). */ function _computeAllShoppingPrices(array $clientItems, string $country, string $currency, string $lang, bool $forceRefresh): array { $smartItems = _loadSmartShoppingItems(); $items = _shoppingListPriceItems($clientItems, $smartItems); if (empty($items)) { return [ 'success' => true, 'prices' => [], 'total' => 0, 'total_label' => _formatPrice(0, $currency), 'from_total_cache' => false, ]; } $listHash = _shoppingListPriceHash($items, $country, $currency); if (!$forceRefresh) { $cached = _loadCanonicalShoppingTotal($listHash); if ($cached !== null) { $cached['from_total_cache'] = true; return $cached; } } $priceCache = _loadPriceCache(); $now = time(); $maxAge = _shoppingPriceMaxAgeSeconds(); $prices = []; $total = 0.0; $missing = []; foreach ($items as $item) { $name = $item['name']; $key = _priceKey($name, $country); $key0 = md5(mb_strtolower(trim($name)) . '|' . mb_strtolower(trim($country))); $entry = $priceCache[$key] ?? $priceCache[$key0] ?? null; if ($entry !== null && !$forceRefresh) { $est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']); $prices[$name] = array_merge($entry, [ 'estimated_total' => $est, 'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null, 'from_cache' => true, '_resolved_qty' => $item['quantity'], '_resolved_unit' => $item['unit'], ]); $total += $est ?? 0; continue; } if ($entry !== null && $forceRefresh && ($now - (int)($entry['cached_at'] ?? 0)) < $maxAge) { $est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']); $prices[$name] = array_merge($entry, [ 'estimated_total' => $est, 'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null, 'from_cache' => true, '_resolved_qty' => $item['quantity'], '_resolved_unit' => $item['unit'], ]); $total += $est ?? 0; continue; } if ($entry === null || $forceRefresh) { $missing[] = $item; } } if (!empty($missing)) { $missingNames = array_column($missing, 'name'); $batchPrices = _fetchPricesBatchFromAI($missingNames, $country, $currency, $lang); $missingByName = []; foreach ($missing as $item) $missingByName[$item['name']] = $item; foreach ($missingNames as $name) { $item = $missingByName[$name]; $key = _priceKey($name, $country); $priceData = $batchPrices[$name] ?? null; if ($priceData && isset($priceData['price_per_unit'])) { $entry = [ 'name' => $name, 'price_per_unit' => (float)$priceData['price_per_unit'], 'unit_label' => $priceData['unit_label'] ?? 'pz', 'currency' => $currency, 'source_note' => $priceData['source_note'] ?? '', 'country' => $country, 'cached_at' => $now, ]; $priceCache[$key] = $entry; $est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'], $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']); $prices[$name] = array_merge($entry, [ 'estimated_total' => $est, 'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null, 'from_cache' => false, '_resolved_qty' => $item['quantity'], '_resolved_unit' => $item['unit'], ]); $total += $est ?? 0; } else { $prices[$name] = ['name' => $name, 'error' => 'not_found', 'estimated_total' => null]; } } _savePriceCache($priceCache); } $total = round($total, 2); $result = [ 'success' => true, 'prices' => $prices, 'total' => $total, 'total_label' => _formatPrice($total, $currency), 'from_total_cache' => false, 'priced_at' => $now, 'valid_until' => $now + $maxAge, ]; _saveCanonicalShoppingTotal($listHash, $result); return $result; } /** * Ask Gemini for the estimated retail price per unit (kg, l, pz as appropriate) * for a product in a given country/currency. Returns an array: * { price_per_unit, unit_label, currency, source_note } or null on failure. */ function _fetchPriceFromAI(string $name, string $country, string $currency, string $lang): ?array { EverLog::info('_fetchPriceFromAI'); $result = _fetchPricesBatchFromAI([$name], $country, $currency, $lang); return $result[$name] ?? null; } /** * Ask Gemini to price multiple items in a SINGLE API call. * Returns: { name => { price_per_unit, unit_label, currency, source_note } } * Items that could not be priced are omitted from the result. */ function _fetchPricesBatchFromAI(array $names, string $country, string $currency, string $lang): array { $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey) || empty($names)) return []; EverLog::info('price_batch_ai start', ['count' => count($names), 'country' => $country]); // Build a numbered list for the prompt $list = ''; foreach ($names as $i => $n) { $list .= ($i + 1) . '. ' . $n . "\n"; } $prompt = << [['parts' => [['text' => $prompt]]]]]; // 55s timeout — generous for large batches (set_time_limit(120) in getAllShoppingPrices) $result = callGeminiWithFallback($apiKey, $payload, 55, 'price_batch'); if ($result['http_code'] !== 200) return []; $text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''); $text = preg_replace('/^```json\s*/i', '', $text); $text = preg_replace('/\s*```$/i', '', $text); $data = json_decode(trim($text), true); if (!is_array($data)) return []; // Validate and return only items with valid price $out = []; foreach ($data as $name => $entry) { if (isset($entry['price_per_unit']) && is_numeric($entry['price_per_unit'])) { $out[$name] = $entry; } } EverLog::info('price_batch_ai done', ['requested' => count($names), 'returned' => count($out)]); return $out; } /** /** * GET /api/?action=guess_category&name=... * Returns the macro-category for a product name, using a file cache + Gemini AI fallback. * Response: { category: string } */ function guessCategoryFromAI(): void { $name = trim($_GET['name'] ?? ''); if ($name === '') { echo json_encode(['category' => 'altro']); return; } EverLog::info('guessCategoryFromAI'); // Load cache $cache = []; if (file_exists(CATEGORY_CACHE_PATH)) { $cache = json_decode(file_get_contents(CATEGORY_CACHE_PATH), true) ?? []; } $key = md5(mb_strtolower($name)); if (isset($cache[$key])) { echo json_encode(['category' => $cache[$key]]); return; } $apiKey = env('GEMINI_API_KEY', ''); if ($apiKey === '') { echo json_encode(['category' => 'altro']); return; } $cats = 'latticini, carne, pesce, frutta, verdura, pasta, pane, surgelati, bevande, condimenti, snack, conserve, cereali, igiene, pulizia, altro'; $prompt = "Sei un classificatore di prodotti alimentari e domestici italiani.\n" . "Classifica il prodotto \"" . addslashes($name) . "\" in UNA di queste categorie esatte: $cats.\n" . "Rispondi con SOLO la parola chiave della categoria, senza spiegazioni né punteggiatura aggiuntiva."; $payload = [ 'contents' => [['parts' => [['text' => $prompt]]]], 'generationConfig' => [ 'temperature' => 0, 'maxOutputTokens' => 20, 'thinkingConfig' => ['thinkingBudget' => 0], ], ]; $result = callGeminiWithFallback($apiKey, $payload, 10, 'guess_category'); $raw = strtolower(trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? '')); $raw = preg_replace('/[^a-z_ ]/', '', $raw); $raw = trim($raw); $valid = ['latticini','carne','pesce','frutta','verdura','pasta','pane','surgelati', 'bevande','condimenti','snack','conserve','cereali','igiene','pulizia','altro']; $cat = in_array($raw, $valid, true) ? $raw : 'altro'; // Persist to cache $cache[$key] = $cat; @file_put_contents(CATEGORY_CACHE_PATH, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); echo json_encode(['category' => $cat]); } // ───────────────────────────────────────────────────────────────────────────── /** * GET /api/?action=get_shopping_price * POST body: { name, quantity, unit, default_quantity, package_unit, country, currency, lang, force_refresh } * * Returns: { success, name, price_per_unit, unit_label, currency, estimated_total, estimated_total_label, cached_at, source_note } */ function getShoppingPrice(PDO $db): void { EverLog::info('getShoppingPrice'); $input = json_decode(file_get_contents('php://input'), true) ?? []; $name = trim($input['name'] ?? ''); $qty = (float)($input['quantity'] ?? 1); $unit = trim($input['unit'] ?? 'pz'); $defQty = (float)($input['default_quantity'] ?? 0); $pkgUnit = trim($input['package_unit'] ?? ''); $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']); $maxAge = _shoppingPriceMaxAgeSeconds(); if (empty($name)) { echo json_encode(['success' => false, 'error' => 'missing name']); return; } // Guard: price estimation requires Gemini API key if (empty(env('GEMINI_API_KEY'))) { echo json_encode(['success' => false, 'error' => 'no_api_key']); return; } $cache = _loadPriceCache(); $key = _priceKey($name, $country); $now = time(); // Use cache if fresh if (!$forceRefresh && isset($cache[$key])) { $entry = $cache[$key]; $age = $now - ($entry['cached_at'] ?? 0); if ($age < $maxAge) { $entry['success'] = true; $entry['from_cache'] = true; $entry['estimated_total'] = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $qty, $unit, $defQty, $pkgUnit); $entry['estimated_total_label'] = _formatPrice($entry['estimated_total'], $currency); echo json_encode($entry); return; } } $priceData = _fetchPriceFromAI($name, $country, $currency, $lang); if (!$priceData || $priceData['price_per_unit'] === null) { echo json_encode(['success' => false, 'error' => 'price_not_found', 'name' => $name]); return; } $entry = [ 'name' => $name, 'price_per_unit'=> (float)$priceData['price_per_unit'], 'unit_label' => $priceData['unit_label'] ?? 'kg', 'currency' => $currency, 'source_note' => $priceData['source_note'] ?? '', 'country' => $country, 'cached_at' => $now, ]; $cache[$key] = $entry; _savePriceCache($cache); $entry['success'] = true; $entry['from_cache'] = false; $entry['estimated_total'] = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'], $qty, $unit, $defQty, $pkgUnit); $entry['estimated_total_label'] = _formatPrice($entry['estimated_total'], $currency); echo json_encode($entry); } /** * GET /api/?action=get_all_shopping_prices * 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, from_total_cache } */ function getAllShoppingPrices(PDO $db): void { EverLog::info('getAllShoppingPrices'); set_time_limit(120); $input = json_decode(file_get_contents('php://input'), true) ?? []; $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']); $result = _computeAllShoppingPrices($clientItems, $country, $currency, $lang, $forceRefresh); echo json_encode($result, JSON_UNESCAPED_UNICODE); } /** * Calculate estimated cost for a shopping item given price_per_unit and the item's quantity/unit. * Price unit: kg, l, pz/unit */ function _calcEstimatedTotal(float $pricePerUnit, string $priceUnitLabel, float $qty, string $unit, float $defQty, string $pkgUnit): ?float { if ($pricePerUnit <= 0) return null; $label = strtolower(trim($priceUnitLabel)); // ── Weight-based price (per kg) ─────────────────────────────────────────── // Only exact 'kg' triggers weight conversion; retail-unit labels like // "pacco 500g" or "mazzo" fall through to the countable path below. if ($label === 'kg') { $weightKg = 0.0; if (($unit === 'conf' || $unit === 'pz') && $defQty > 0 && !empty($pkgUnit)) { // Each conf/pz weighs defQty pkgUnit (e.g. defQty=250, pkgUnit='g') $sub = strtolower($pkgUnit); if ($sub === 'g') $weightKg = $qty * $defQty / 1000.0; elseif ($sub === 'kg') $weightKg = $qty * $defQty; } elseif (($unit === 'conf' || $unit === 'pz') && $defQty > 0 && empty($pkgUnit)) { // pkgUnit not recorded in DB — for /kg prices assume defQty is in grams // (vast majority of grocery packages: pancetta 80g, formaggio 200g, etc.) // GUARD: if defQty < 20 it is almost certainly a piece/unit count (e.g. "1 pz // per purchase"), not a gram weight. Treating 1 as 1g would give a nonsense // price (e.g. Peperoni defQty=1 → 0.001 kg → €0.003 displayed as €0.00). // Skip the weight conversion for these; the item falls through to the // countable path at the bottom (ppu × qty) which returns a rough estimate. if ($defQty >= 20) { $weightKg = $qty * $defQty / 1000.0; } } elseif ($unit === 'g') { $weightKg = $qty / 1000.0; } elseif ($unit === 'kg') { $weightKg = $qty; } if ($weightKg <= 0) { // Piece/count units with €/kg AI price: estimate weight per piece (never €/kg × piece count). if (in_array($unit, ['pz', 'conf'], true)) { $gramsPerPiece = ($defQty >= 20) ? $defQty : 200.0; $weightKg = max(1.0, $qty) * $gramsPerPiece / 1000.0; return round($pricePerUnit * $weightKg, 2); } return null; } return round($pricePerUnit * $weightKg, 2); } // ── Volume-based price (per liter) ──────────────────────────────────────── if (in_array($label, ['l', 'lt', 'litre', 'liter', 'litro'])) { $volumeL = 0.0; if (($unit === 'conf' || $unit === 'pz') && $defQty > 0 && !empty($pkgUnit)) { $sub = strtolower($pkgUnit); if ($sub === 'ml') $volumeL = $qty * $defQty / 1000.0; elseif ($sub === 'l') $volumeL = $qty * $defQty; } elseif (($unit === 'conf' || $unit === 'pz') && $defQty > 0 && empty($pkgUnit)) { // pkgUnit not recorded — for /L prices assume defQty is in ml $volumeL = $qty * $defQty / 1000.0; } elseif ($unit === 'ml') { $volumeL = $qty / 1000.0; } elseif ($unit === 'l') { $volumeL = $qty; } if ($volumeL <= 0) return null; return round($pricePerUnit * $volumeL, 2); } // ── Countable retail unit (mazzo, pacco, barattolo, pz, conf, …) ───────── // price_per_unit is already the price for ONE retail unit. // // Special case: shopping qty is in g/ml but price is per-package. // We must convert grams→packages so we don't multiply 100×€2.75=€275. if (in_array(strtolower($unit), ['g', 'ml'])) { $pkgWeight = 0.0; // 1) Use defQty if package unit matches (e.g. defQty=250, pkgUnit='g', unit='g') if ($defQty > 0 && !empty($pkgUnit) && strtolower($pkgUnit) === strtolower($unit)) { $pkgWeight = $defQty; } // 2) Extract weight/volume from label: "confezione 250g", "vasetto 125ml", "pacco 500g", // "pacco 1kg" (convert kg→g), "bottiglia 1.5L" (convert L→ml) if ($pkgWeight <= 0) { if (preg_match('/\b(\d+(?:[.,]\d+)?)\s*(g|ml|kg|l|lt)\b/i', $priceUnitLabel, $m)) { $rawVal = (float)str_replace(',', '.', $m[1]); $rawUnit = strtolower($m[2]); if ($rawUnit === strtolower($unit)) { $pkgWeight = $rawVal; } elseif ($rawUnit === 'kg' && strtolower($unit) === 'g') { $pkgWeight = $rawVal * 1000.0; } elseif (in_array($rawUnit, ['l', 'lt']) && strtolower($unit) === 'ml') { $pkgWeight = $rawVal * 1000.0; } } } // 3) Also try defQty alone (no pkgUnit set but defQty likely in same unit) if ($pkgWeight <= 0 && $defQty > 0) { $pkgWeight = $defQty; } if ($pkgWeight > 0) { $packages = (int) max(1, ceil($qty / $pkgWeight)); return round($pricePerUnit * $packages, 2); } // No conversion possible → return single-unit price (1 package minimum) return round($pricePerUnit, 2); } // Special case: unit='pz' (individual pieces) vs. container retail unit. // If the AI priced per-container and the user requested individual pieces, // buy ceil(qty / piecesPerContainer) containers — or just 1 if unknown. if (strtolower($unit) === 'pz') { static $containerKw = [ 'confezione', 'pacco', 'pack', 'busta', 'sacchetto', 'vasetto', 'barattolo', 'rete', 'casco', 'mazzo', 'bottiglia', 'brick', 'lattina', 'latta', 'vaschetta', 'scatola', 'tray', ]; $isContainer = false; foreach ($containerKw as $kw) { if (str_contains($label, $kw)) { $isContainer = true; break; } } if ($isContainer) { // Try to extract pieces-per-container from label (e.g. "confezione 6 uova" → 6). // Ignore numbers followed by a weight/volume unit (e.g. "rete 1kg" → 0). $pcsPerContainer = 0; if (preg_match('/\b(\d+)\b(?!\s*(?:g|kg|ml|l|lt|cl|gr)\b)/i', $priceUnitLabel, $pm)) { $pcsPerContainer = (int)$pm[1]; } $containers = ($pcsPerContainer >= 2) ? (int) max(1, ceil($qty / $pcsPerContainer)) : 1; return round($pricePerUnit * $containers, 2); } } // ── conf/pz with known package weight vs weight-labeled AI price ────────── // E.g. unit='conf', defQty=170g, AI priced 'pacco 500g' @ €3.20 // → need ceil(7×170 / 500) = 3 packs × €3.20 = €9.60, not 7×€3.20 = €22.40 if (in_array(strtolower($unit), ['conf', 'pz']) && $defQty > 0 && !empty($pkgUnit)) { $pkgL = strtolower($pkgUnit); $isWt = in_array($pkgL, ['g', 'kg']); $isVol = in_array($pkgL, ['ml', 'l', 'lt']); if (($isWt || $isVol) && preg_match('/\b(\d+(?:[.,]\d+)?)\s*(g|kg|ml|l|lt)\b/i', $priceUnitLabel, $m)) { $rawVal = (float) str_replace(',', '.', $m[1]); $rawUnit = strtolower($m[2]); $labelIsWt = in_array($rawUnit, ['g', 'kg']); $labelIsVol = in_array($rawUnit, ['ml', 'l', 'lt']); if (($isWt && $labelIsWt) || ($isVol && $labelIsVol)) { // Convert to base units (g or ml) $defBase = $pkgL === 'kg' ? $defQty * 1000.0 : $defQty; $labelBase = match($rawUnit) { 'kg','l','lt' => $rawVal * 1000.0, default => $rawVal }; if ($labelBase > 0) { $totalBase = $qty * $defBase; $packs = (int) max(1, ceil($totalBase / $labelBase)); return round($pricePerUnit * $packs, 2); } } } } $buyQty = max(1.0, $qty); return round($pricePerUnit * $buyQty, 2); } function _formatPrice(float $amount, string $currency): string { $sym = match(strtoupper($currency)) { 'EUR' => '€', 'USD' => '$', 'GBP' => '£', 'CHF' => 'CHF', 'JPY' => '¥', 'CNY' => '¥', 'CAD' => 'CA$', 'AUD' => 'A$', 'BRL' => 'R$', 'RUB' => '₽', 'INR' => '₹', 'MXN' => '$', 'SEK' => 'kr', 'NOK' => 'kr', 'DKK' => 'kr', 'PLN' => 'zł', 'CZK' => 'Kč', 'HUF' => 'Ft', 'RON' => 'lei', default => $currency, }; return $sym . number_format($amount, 2, '.', ''); }