* @license MIT */ // database.php must always be loaded (used both by HTTP router and cron) require_once __DIR__ . '/database.php'; /** * Load environment variables from .env file. * Returns associative array of key => value pairs. */ function loadEnv(): array { static $cache = null; if ($cache !== null) return $cache; $envFile = __DIR__ . '/../.env'; $cache = []; if (file_exists($envFile)) { $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); foreach ($lines as $line) { if (strpos($line, '#') === 0 || strpos($line, '=') === false) continue; list($key, $val) = explode('=', $line, 2); $cache[trim($key)] = trim($val); } } return $cache; } /** * Get a single environment variable, with optional default. */ function env(string $key, string $default = ''): string { $vars = loadEnv(); return $vars[$key] ?? $default; } // When included by the cron script, skip HTTP headers and routing entirely if (!defined('CRON_MODE')) { header('Content-Type: application/json; charset=utf-8'); header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); 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']; $loginActions = ['dupliclick_login']; $recipeActions = ['generate_recipe', 'generate_recipe_stream']; if (in_array($action, $aiActions)) { $limit = 15; $window = 60; $bucket = 'ai'; } elseif (in_array($action, $recipeActions)) { $limit = 5; $window = 60; $bucket = 'recipe'; } 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) { 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); } try { $db = getDB(); } catch (Exception $e) { http_response_code(500); echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]); exit; } $method = $_SERVER['REQUEST_METHOD']; $action = $_GET['action'] ?? ''; } // end !CRON_MODE block for router bootstrap if (!defined('CRON_MODE')): try { switch ($action) { // ===== PRODUCTS ===== case 'search_barcode': searchBarcode($db); break; case 'lookup_barcode': lookupBarcode(); break; case 'product_save': saveProduct($db); break; case 'product_get': getProduct($db); break; case 'product_delete': deleteProduct($db); break; case 'products_list': listProducts($db); break; case 'products_search': searchProducts($db); break; // ===== INVENTORY ===== case 'inventory_list': listInventory($db); break; case 'inventory_add': addToInventory($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_summary': inventorySummary($db); break; // ===== TRANSACTIONS ===== case 'transactions_list': listTransactions($db); break; case 'transaction_undo': undoTransaction($db); break; // ===== STATS ===== case 'stats': getStats($db); break; case 'consumption_predictions': getConsumptionPredictions($db); break; case 'inventory_anomalies': getInventoryAnomalies($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; // ===== BRING! SHOPPING LIST ===== case 'bring_list': bringGetList(); break; case 'bring_add': bringAddItems(); break; case 'bring_remove': bringRemoveItem(); break; case 'bring_clean_specs': bringCleanSpecs(); break; case 'bring_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; // ===== SPESA ONLINE ===== case 'dupliclick_login': dupliclickLogin(); break; case 'dupliclick_search': dupliclickSearch(); break; case 'dupliclick_status': $tokenFile = __DIR__ . '/../data/dupliclick_token.json'; if (file_exists($tokenFile)) { $td = json_decode(file_get_contents($tokenFile), true); echo json_encode(['logged_in' => !empty($td['token']), 'email' => $td['email'] ?? '']); } else { echo json_encode(['logged_in' => false]); } break; // ===== SHARED APP DATA ===== case 'app_settings_get': appSettingsGet($db); break; case 'app_settings_save': appSettingsSave($db); break; case 'recipes_list': recipesList($db); break; case 'recipes_save': recipesSave($db); break; case 'recipes_delete': recipesDelete($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 'expiry_history': getExpiryHistory($db); break; default: http_response_code(404); echo json_encode(['error' => 'Unknown action: ' . $action]); } } catch (Exception $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } endif; // end !CRON_MODE // ===== TTS PROXY ===== function 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'] : ''; $contentType = ''; foreach ($headers as $k => $v) { if (strtolower($k) === 'content-type') { $contentType = $v; break; } } if (!$url || !preg_match('/^https?:\/\/.+/', $url)) { 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) { http_response_code(502); echo json_encode(['error' => 'cURL error: ' . $curlErr]); return; } http_response_code($httpCode ?: 200); echo json_encode(['status' => $httpCode, 'body' => $response]); } // ===== CLIENT LOG ===== // ===== EXPIRY HISTORY ===== function getExpiryHistory($db): void { $productId = (int)($_GET['product_id'] ?? $_POST['product_id'] ?? 0); if (!$productId) { echo json_encode(['avg_days' => null, 'count' => 0]); return; } // Compute average shelf life (expiry_date - added_at) for this product // Only use entries where expiry_date is clearly in the future relative to added_at $stmt = $db->prepare(" SELECT ROUND(AVG(CAST(JULIANDAY(expiry_date) - JULIANDAY(added_at) AS REAL))) AS avg_days, COUNT(*) AS count FROM inventory WHERE product_id = ? AND expiry_date IS NOT NULL AND expiry_date > date(added_at) AND added_at >= date('now', '-730 days') "); $stmt->execute([$productId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row || !$row['count'] || $row['avg_days'] === null) { echo json_encode(['avg_days' => null, 'count' => 0]); return; } echo json_encode(['avg_days' => (int)$row['avg_days'], 'count' => (int)$row['count']]); } function clientLog(): void { $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)) { 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 = $_GET['barcode'] ?? ''; if (empty($barcode)) { echo json_encode(['found' => false]); return; } $stmt = $db->prepare("SELECT * FROM products WHERE barcode = ?"); $stmt->execute([$barcode]); $product = $stmt->fetch(); if ($product) { echo json_encode(['found' => true, 'product' => $product]); } else { echo json_encode(['found' => false]); } } function lookupBarcode(): void { $barcode = $_GET['barcode'] ?? ''; if (empty($barcode)) { echo json_encode(['found' => false, 'error' => 'No barcode provided']); return; } // Try Open Food Facts API (Italian version first for better localized data) $url = "https://world.openfoodfacts.org/api/v2/product/{$barcode}.json?fields=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&lc=it"; $ctx = stream_context_create([ 'http' => [ 'timeout' => 10, 'header' => "User-Agent: DispensaManager/1.0\r\n" ] ]); $response = @file_get_contents($url, false, $ctx); if ($response === false) { echo json_encode(['found' => false, 'source' => 'openfoodfacts', 'error' => 'API request failed']); return; } $data = json_decode($response, true); if (isset($data['status']) && $data['status'] === 1 && !empty($data['product'])) { $p = $data['product']; // Prefer Italian name, fall back to generic // Also request localized name via abbreviated_product_name $name = ''; if (!empty($p['product_name_it'])) { $name = $p['product_name_it']; } elseif (!empty($p['generic_name_it'])) { $name = $p['generic_name_it']; } elseif (!empty($p['product_name'])) { $name = $p['product_name']; } elseif (!empty($p['generic_name'])) { $name = $p['generic_name']; } // If the name looks like it's in a non-Latin script (Arabic, Chinese, Thai, etc.) // try to use a fallback from brands + generic category if (!empty($name) && 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)) { // Try other name fields that might be in Latin script $latinName = ''; foreach (['generic_name_it', 'generic_name', 'product_name_it', 'product_name'] as $field) { if (!empty($p[$field]) && !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[$field])) { $latinName = $p[$field]; break; } } // If still no Latin name, construct from brand + category if (empty($latinName)) { $brand = $p['brands'] ?? ''; $latinName = !empty($brand) ? $brand : 'Prodotto sconosciuto'; } $name = $latinName; } // Get Italian ingredients, fall back to generic $ingredients = ''; if (!empty($p['ingredients_text_it'])) { $ingredients = $p['ingredients_text_it']; } elseif (!empty($p['ingredients_text'])) { $ingredients = $p['ingredients_text']; } // Category: prefer Italian categories_tags, fallback $category = ''; if (!empty($p['categories_tags'])) { // Try to find an Italian-friendly category $category = $p['categories_tags'][0] ?? ''; } elseif (!empty($p['categories_hierarchy'])) { $category = end($p['categories_hierarchy']); } elseif (!empty($p['categories'])) { $category = $p['categories']; } // Allergens $allergens = ''; if (!empty($p['allergens_tags'])) { $allergens = implode(', ', array_map(function($a) { return str_replace('en:', '', $a); }, $p['allergens_tags'])); } // Conservation / storage $conservation = $p['conservation_conditions_it'] ?? $p['conservation_conditions'] ?? ''; // Origin $origin = $p['origins_it'] ?? $p['origins'] ?? $p['manufacturing_places'] ?? ''; $result = [ 'found' => true, 'source' => 'openfoodfacts', 'product' => [ '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' => $conservation, 'origin' => $origin, 'nova_group' => $p['nova_group'] ?? '', 'ecoscore' => $p['ecoscore_grade'] ?? '', 'labels' => $p['labels'] ?? '', 'stores' => $p['stores'] ?? '', ] ]; echo json_encode($result); } else { // Try UPC Item DB as fallback $url2 = "https://api.upcitemdb.com/prod/trial/lookup?upc={$barcode}"; $ctx2 = stream_context_create([ 'http' => [ 'timeout' => 10, 'header' => "User-Agent: DispensaManager/1.0\r\n" ] ]); $response2 = @file_get_contents($url2, false, $ctx2); if ($response2 !== false) { $data2 = json_decode($response2, true); if (!empty($data2['items'][0])) { $item = $data2['items'][0]; echo json_encode([ 'found' => true, 'source' => 'upcitemdb', 'product' => [ 'name' => $item['title'] ?? '', 'brand' => $item['brand'] ?? '', 'category' => $item['category'] ?? '', 'image_url' => $item['images'][0] ?? '', ] ]); return; } } echo json_encode(['found' => false, 'source' => 'openfoodfacts']); } } function saveProduct(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); if (!$input || empty($input['name'])) { http_response_code(400); echo json_encode(['error' => 'Product name is required']); return; } if (!empty($input['id'])) { // Update existing $stmt = $db->prepare(" UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?, default_quantity=?, notes=?, barcode=?, package_unit=?, updated_at=CURRENT_TIMESTAMP WHERE id=? "); $stmt->execute([ $input['name'], $input['brand'] ?? '', $input['category'] ?? '', $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, $input['notes'] ?? '', $input['barcode'] ?? null, $input['package_unit'] ?? '', $input['id'] ]); echo json_encode(['success' => true, 'id' => $input['id']]); } else { // Insert new $stmt = $db->prepare(" INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes, package_unit) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "); $barcode = !empty($input['barcode']) ? $input['barcode'] : null; $stmt->execute([ $barcode, $input['name'], $input['brand'] ?? '', $input['category'] ?? '', $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, $input['notes'] ?? '', $input['package_unit'] ?? '' ]); echo json_encode(['success' => true, 'id' => $db->lastInsertId()]); } } 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) { echo json_encode(['success' => true, 'product' => $product]); } else { http_response_code(404); echo json_encode(['error' => 'Product not found']); } } function deleteProduct(PDO $db): void { $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 { $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()]); } // ===== INVENTORY FUNCTIONS ===== function listInventory(PDO $db): void { $location = $_GET['location'] ?? ''; $query = " SELECT i.*, p.name, p.brand, p.category, p.image_url, p.unit, p.barcode, p.default_quantity, p.package_unit, COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, i.opened_at 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); echo json_encode(['inventory' => $stmt->fetchAll()]); } function addToInventory(PDO $db): void { $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) { http_response_code(400); echo json_encode(['error' => 'Product ID required']); return; } // Validate quantity bounds if ($quantity <= 0 || $quantity > 100000) { http_response_code(400); echo json_encode(['error' => 'Invalid quantity']); return; } // Validate location $validLocations = ['dispensa', 'frigo', 'freezer', 'altro']; if (!in_array($location, $validLocations)) { http_response_code(400); echo json_encode(['error' => 'Invalid location']); return; } // If a different unit was specified, update the product's unit if ($unit) { $stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$unit, $quantity, $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); // Check if product already exists in this location $stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ?"); $stmt->execute([$productId, $location]); $existing = $stmt->fetch(); if ($existing) { // Update quantity $newQty = $existing['quantity'] + $quantity; $stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), vacuum_sealed = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$newQty, $expiry, $vacuumSealed, $existing['id']]); } else { $newQty = $quantity; // Insert new inventory entry $stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed]); } // 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]); // Auto-remove from Bring! if product is on the shopping list $removedFromBring = false; try { $stmt = $db->prepare("SELECT name FROM products WHERE id = ?"); $stmt->execute([$productId]); $prodName = $stmt->fetchColumn(); if ($prodName) { $auth = bringAuth(); if ($auth) { $listUUID = $auth['bringListUUID']; $bringKey = italianToBring($prodName); $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if ($listData && isset($listData['purchase'])) { // Token-based matching — same logic as _productOnBring() in smart_shopping $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']; $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) )); }; $prodTokens = $tokenize($prodName); $keyTokens = $tokenize($bringKey); $prodFirst = $prodTokens[0] ?? ''; $keyFirst = $keyTokens[0] ?? ''; foreach ($listData['purchase'] as $item) { $rawName = $item['name'] ?? ''; // 1. Exact match on translated catalog key or original Italian name if (strcasecmp($rawName, $bringKey) === 0 || strcasecmp($rawName, $prodName) === 0) { bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", http_build_query(['uuid' => $listUUID, 'remove' => $rawName])); $removedFromBring = true; break; } // 2. Token-based fuzzy: first significant word must match if ($prodFirst || $keyFirst) { $rawTokens = $tokenize($rawName); $rawFirst = $rawTokens[0] ?? ''; if ($rawFirst && ( $rawFirst === $prodFirst || $rawFirst === $keyFirst || in_array($prodFirst, $rawTokens) || in_array($keyFirst, $rawTokens) )) { bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", http_build_query(['uuid' => $listUUID, 'remove' => $rawName])); $removedFromBring = true; break; } } } } } } } catch (Exception $e) { // Silently fail } 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' => $removedFromBring, ]); // Inventory changed — force smart-shopping recompute on next request invalidateSmartShoppingCache(); } function useFromInventory(PDO $db): void { $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) { http_response_code(400); echo json_encode(['error' => 'Product ID required']); 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; foreach ($allItems as $item) { $totalRemoved += $item['quantity']; $stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$item['id']]); $type = ($notes === 'Buttato') ? 'waste' : 'out'; $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$productId, $type, $item['quantity'], $item['location'], $notes]); } 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) { 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 = ?, 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 = ($notes === 'Buttato') ? 'waste' : 'out'; $stmt3 = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt3->execute([$productId, $type, $quantity, $location, $notes]); $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 reduces quantity) $wasOpened = !empty($existing['opened_at']); $isNowOpened = false; $unit = $prodInfo['unit'] ?? 'pz'; $defQty = (float)($prodInfo['default_quantity'] ?? 0); if ($unit === 'conf') { $w = floor($newQty + 0.001); $f = round($newQty - $w, 6); if ($f > 0.001) $isNowOpened = true; } elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0 && $newQty < $defQty - 0.001) { $isNowOpened = true; } if ($isNowOpened && !$wasOpened) { // First time opened: recalculate expiry with shorter shelf life $pName = $prodInfo['name'] ?? ''; $pCat = $prodInfo['category'] ?? ''; $vacuum = (int)($existing['vacuum_sealed'] ?? 0); $openedDays = estimateOpenedExpiryDaysPHP($pName, $pCat, $location); if ($vacuum) $openedDays = (int)round($openedDays * 1.5); $openedExpiry = date('Y-m-d', strtotime("+{$openedDays} days")); // Respect original sealed expiry if it expires sooner if (!empty($existing['expiry_date']) && strtotime($existing['expiry_date']) < strtotime($openedExpiry)) { $openedExpiry = $existing['expiry_date']; } $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 = ($notes === 'Buttato') ? 'waste' : 'out'; $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$productId, $type, $actualDeducted, $location, $notes]); $remaining = $newQty; // Check if opened part remains (for non-split path) if ($remaining > 0 && $prodInfo && $prodInfo['unit'] === 'conf') { $w = floor($remaining + 0.001); $f = round($remaining - $w, 6); if ($f > 0.001) { $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) { // Get product name and brand for Bring! $stmt = $db->prepare("SELECT name, brand FROM products WHERE id = ?"); $stmt->execute([$productId]); $product = $stmt->fetch(); if ($product) { try { $auth = bringAuth(); if ($auth) { $listUUID = $auth['bringListUUID']; $bringName = italianToBring($product['name']); // Check if already on the Bring! list $alreadyOnList = false; $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if ($listData && isset($listData['purchase'])) { foreach ($listData['purchase'] as $existingItem) { if (strcasecmp($existingItem['name'] ?? '', $bringName) === 0) { $alreadyOnList = true; break; } } } if ($alreadyOnList) { // Already on the list, skip adding $addedToBring = false; } else { // Build specification from product name (variant info, not brand) $spec = $product['name'] ? $product['name'] : ''; $body = http_build_query([ 'uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $spec, ]); $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body); $addedToBring = ($result !== null); // Log Bring! addition if ($addedToBring) { $logStmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'bring', 0, '', 'Auto-aggiunto a Bring!')"); $logStmt->execute([$productId]); } } // end else (not already on list) } } catch (Exception $e) { // Silently fail — don't block inventory operation } } } } // Calculate total remaining across ALL locations $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 FROM products WHERE id = ?"); $stmt->execute([$productId]); $prodInfo = $stmt->fetch(); $response = ['success' => true, 'remaining' => $remaining, 'added_to_bring' => $addedToBring, 'total_remaining' => $totalRemaining]; 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'] ?: ''; } if ($openedId) $response['opened_id'] = $openedId; echo json_encode($response); // Inventory changed — force smart-shopping recompute on next request invalidateSmartShoppingCache(); } function updateInventory(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? 0; $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']; } if (isset($input['vacuum_sealed'])) { $fields[] = "vacuum_sealed = ?"; $params[] = (int)$input['vacuum_sealed']; } $fields[] = "updated_at = CURRENT_TIMESTAMP"; $params[] = $id; $stmt = $db->prepare("UPDATE inventory SET " . implode(', ', $fields) . " WHERE id = ?"); $stmt->execute($params); // Update unit on the product if provided if (isset($input['unit']) && isset($input['product_id'])) { $stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$input['unit'], $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']]); } echo json_encode(['success' => true]); } function deleteInventory(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? 0; $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); $stmt->execute([$id]); echo json_encode(['success' => true]); } /** * Returns products whose entire inventory is at quantity = 0 AND whose * transaction balance (total_in - total_out) is still significantly positive — * meaning the system suspects the product ran out prematurely (scale drift, * missed registration, 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 { $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, MIN(i.location) AS location, MAX(i.updated_at) AS updated_at, 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 FROM products p JOIN inventory i ON i.product_id = p.id LEFT JOIN transactions t ON t.product_id = p.id WHERE NOT EXISTS ( SELECT 1 FROM inventory i2 WHERE i2.product_id = p.id AND i2.quantity > 0 ) GROUP BY p.id ORDER BY MAX(i.updated_at) DESC ")->fetchAll(PDO::FETCH_ASSOC); // Per-unit threshold: residue below this is considered normal rounding/finish $thresholds = ['g' => 20, 'ml' => 20, 'kg' => 0.02, 'l' => 0.02, 'conf' => 0.1, 'pz' => 0.5]; $suspicious = []; foreach ($rows as $r) { $expected = (float)$r['total_in'] - (float)$r['total_out']; $threshold = $thresholds[$r['unit']] ?? 0.5; if ($expected > $threshold) { // Transaction balance says stock should remain — show banner $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' => $r['location'], 'updated_at' => $r['updated_at'], 'expected_qty' => round($expected, 3), ]; } else { // Legitimately finished — delete silently, no banner $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 delete all qty=0 inventory rows for a product after user confirms it is finished. */ function confirmFinished(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); $productId = (int)($input['product_id'] ?? 0); if (!$productId) { http_response_code(400); echo json_encode(['error' => 'product_id required']); return; } $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0")->execute([$productId]); echo json_encode(['success' => true]); } function inventorySummary(PDO $db): void { $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 { $limit = (int)($_GET['limit'] ?? 50); $offset = (int)($_GET['offset'] ?? 0); $productId = $_GET['product_id'] ?? ''; $query = " SELECT t.*, p.name, p.brand, p.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) { 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) { 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', ?, ?, '[Annullato]')")->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', ?, ?, '[Annullato]')")->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(); http_response_code(500); echo json_encode(['error' => 'DB error: ' . $e->getMessage()]); } } // ===== 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 { $rows = $db->query(" SELECT p.id AS product_id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, i.id AS inventory_id, i.quantity AS inv_qty, i.location, 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 ")->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: product_id + rounded expected (so re-adding stock resets the alert) $key = 'a_' . $r['product_id'] . '_' . round($expected); if (!empty($dismissed[$key])) continue; $direction = $diff > 0 ? 'phantom' : 'missing'; $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); } /** * 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)) { 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 { $totalProducts = $db->query("SELECT COUNT(*) FROM products")->fetchColumn(); $totalItems = $db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory")->fetchColumn(); $locations = $db->query("SELECT COUNT(DISTINCT location) FROM inventory")->fetchColumn(); $recentIn = $db->query("SELECT COUNT(*) FROM transactions WHERE type='in' AND created_at >= datetime('now', '-7 days')")->fetchColumn(); $recentOut = $db->query("SELECT COUNT(*) FROM transactions WHERE type='out' AND created_at >= datetime('now', '-7 days')")->fetchColumn(); // 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 ORDER BY i.expiry_date ASC LIMIT 4 ")->fetchAll(); // Expired $expired = $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') ORDER BY i.expiry_date ASC ")->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 = !empty($item['expiry_date']) ? strtotime($item['expiry_date']) : null; if (!empty($item['opened_at'])) { // Compute the opened shelf-life from the moment it was opened $openedDays = estimateOpenedExpiryDaysPHP($item['name'], $item['category'], $item['location']); if ($vacuum) $openedDays = (int)round($openedDays * 1.5); $computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400; // Use the computed opened expiry only — stored expiry_date may have been set by // an older (inaccurate) estimation and would give wrong results if mixed in. $finalExpiry = $computedExpiry; $item['opened_expiry'] = date('Y-m-d', $finalExpiry); $item['days_to_expiry'] = (int)round(($finalExpiry - $today) / 86400); } else { // 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']); // 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 stats (last 30 days) $wasteStats = $db->query(" SELECT type, COUNT(*) as count FROM transactions WHERE type IN ('out', 'waste') AND created_at >= datetime('now', '-30 days') GROUP BY type ")->fetchAll(); $used30 = 0; $wasted30 = 0; foreach ($wasteStats as $ws) { if ($ws['type'] === 'out') $used30 = (int)$ws['count']; if ($ws['type'] === 'waste') $wasted30 = (int)$ws['count']; } 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, ]); } // ===== RECENT & POPULAR PRODUCTS ===== function recentPopularProducts(PDO $db): void { // 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 { // 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) < 3) continue; // Need at least 3 data points // 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 = max(1, ($lastDate - $firstDate) / 86400); $dailyRate = $totalUsed / $daySpan; if ($dailyRate < 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' 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']); $restockQty = floatval($restock['quantity']); // If inventory was manually edited (updated_at > last restock), use the // manual update as baseline instead — otherwise the prediction is comparing // against a stale restock quantity that no longer reflects reality. $lastManualUpdate = strtotime($item['updated_at']); if ($lastManualUpdate > $restockDate) { // Inventory was manually corrected after last restock → use current qty // as a fresh baseline from that point; only consider OUT transactions // that happened AFTER the manual update. $txnsSinceUpdate = $db->prepare(" SELECT SUM(quantity) as total FROM transactions WHERE product_id = ? AND location = ? AND type = 'out' AND created_at > ? "); $txnsSinceUpdate->execute([$pid, $loc, $item['updated_at']]); $usedSinceUpdate = floatval($txnsSinceUpdate->fetchColumn() ?: 0); $daysSinceBaseline = max(1, (time() - $lastManualUpdate) / 86400); // The effective "restock" qty is what inventory had at manual edit time // which is current qty + what was consumed since then $restockQty = floatval($item['quantity']) + $usedSinceUpdate; $restockDate = $lastManualUpdate; } $daysSinceRestock = max(1, (time() - $restockDate) / 86400); // Predicted remaining qty = restock qty - (daily rate * days since restock) $expectedQty = max(0, $restockQty - ($dailyRate * $daysSinceRestock)); $actualQty = floatval($item['quantity']); // 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 $pctDev = $expectedQty > 0 ? ($deviation / $expectedQty) : ($actualQty > 0 ? 1 : 0); if ($pctDev > 0.30 && $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' => $actualQty > $expectedQty ? 'more' : 'less', 'tx_count' => count($rows), ]; } } echo json_encode(['success' => true, 'predictions' => $predictions]); } // ===== SETTINGS ===== function getServerSettings(): void { $geminiKey = env('GEMINI_API_KEY'); $bringEmail = env('BRING_EMAIL'); echo json_encode([ 'gemini_key' => $geminiKey, 'gemini_key_set' => !empty($geminiKey), 'bring_email' => $bringEmail, 'bring_password_set' => !empty(env('BRING_PASSWORD')), 'tts_url' => env('TTS_URL'), 'tts_token' => 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', // 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', ''), 'spesa_provider' => env('SPESA_PROVIDER', 'bring'), 'spesa_ai_prompt' => env('SPESA_AI_PROMPT', ''), 'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true', ]); } function saveSettings(): void { $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', 'spesa_provider' => 'SPESA_PROVIDER', 'spesa_ai_prompt' => 'SPESA_AI_PROMPT', ]; // 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', ]; // Integer keys $intMap = [ 'default_persons' => 'DEFAULT_PERSONS', ]; 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]); } } // 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; $lastCode = 0; $lastBody = ''; 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; // 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"}) 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, 60); break; } } } } sleep($waitSec); } return [ 'http_code' => $lastCode, 'body' => $lastBody, 'data' => $lastBody ? json_decode($lastBody, true) : null, ]; } function geminiReadExpiry(): void { $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); $imageBase64 = $input['image'] ?? ''; if (empty($imageBase64)) { echo json_encode(['success' => false, 'error' => 'No image provided']); return; } // Call Gemini API $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; $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 = callGemini($url, $payload, 30); $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'] ?? '']); 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)) { 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'] ?? ''; if (empty($message)) { echo json_encode(['success' => false, 'error' => 'Messaggio vuoto']); 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 = ''; if (!empty($appliances)) { $appliancesText = "\nElettodomestici disponibili: " . implode(', ', $appliances) . " (più fornelli e forno sempre disponibili)."; } $dietaryText = ''; if (!empty($dietaryRestrictions)) { $dietaryText = "\nRestrizioni alimentari dell'utente: {$dietaryRestrictions}. Rispetta SEMPRE queste restrizioni."; } $systemPrompt = << 'user', 'parts' => [['text' => $systemPrompt]] ]; $contents[] = [ 'role' => 'model', 'parts' => [['text' => 'Ciao! Sono il tuo assistente cucina. Conosco tutto quello che hai in dispensa e sono pronto ad aiutarti. Cosa ti va di preparare? 😊']] ]; // Add conversation history foreach ($history as $msg) { $role = ($msg['role'] === 'user') ? 'user' : 'model'; $contents[] = [ 'role' => $role, 'parts' => [['text' => $msg['text']]] ]; } // Add current message $contents[] = [ 'role' => 'user', 'parts' => [['text' => $message]] ]; $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; $payload = [ 'contents' => $contents, 'generationConfig' => [ 'temperature' => 0.8, 'maxOutputTokens' => 1500 ] ]; $result = callGemini($url, $payload, 60); $httpCode = $result['http_code']; if ($httpCode !== 200) { $errMsg = $result['data']['error']['message'] ?? 'Errore API Gemini'; 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' => 'Risposta vuota da Gemini']); return; } echo json_encode(['success' => true, 'reply' => $reply]); } // ===== RECIPE GENERATION WITH GEMINI ===== function generateRecipe(PDO $db): void { $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); $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' => 'La dispensa è vuota!']); 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 (!empty($item['expiry_date'])) return 4; if ($isOpen) return 5; 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)"; } // 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"; } } if ($isOpen) $line .= ' [APERTO]'; $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', 4 => 'ALTRI CON SCADENZA', 5 => 'APERTI', 6 => 'DISPENSA', ]; // Limit groups to keep prompt compact: // 1-3 (urgent): all items; 4 (has expiry): max 40; 5 (opened): all; 6 (pantry): max 20 foreach ($priorityHeaders as $g => $header) { if (empty($priorityGroups[$g])) continue; $groupItems = $priorityGroups[$g]; if ($g === 4 && count($groupItems) > 40) { $groupItems = array_slice($groupItems, 0, 40); } elseif ($g === 6 && count($groupItems) > 20) { $groupItems = array_slice($groupItems, 0, 20); } $ingredientSections[] = "[$header]\n" . implode("\n", $groupItems); } $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\nPREFERENZE DELL'UTENTE:\n" . implode("\n", $extraRules); } // Appliances $appliancesText = ''; if (!empty($appliances)) { $appliancesText = "\n\nELETTRODOMESTICI: " . implode(', ', $appliances) . " (+ fornelli e forno). Usa SOLO questi."; } // 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}."; } } $prompt = << [ [ 'parts' => [ ['text' => $prompt] ] ] ], 'generationConfig' => [ 'temperature' => min(1.4, 0.7 + $variation * 0.25), 'maxOutputTokens' => 2048 ] ]; $result = callGemini($url, $payload, 60); $httpCode = $result['http_code']; if ($httpCode !== 200) { $errDetail = $result['data']['error']['message'] ?? substr($result['body'], 0, 300); echo json_encode(['success' => false, 'error' => 'Errore API Gemini', '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'])) { // Enrich from_pantry ingredients with product_id and location for "use" feature if (!empty($recipe['ingredients'])) { // Build a category map for better fuzzy matching $itemsLookup = []; foreach ($items as $item) { $itemsLookup[] = [ 'item' => $item, 'lower' => mb_strtolower(trim($item['name']), 'UTF-8'), 'words' => preg_split('/[\s,.\-\/]+/', mb_strtolower(trim($item['name']), 'UTF-8')), 'cat' => mb_strtolower($item['category'] ?? '', 'UTF-8'), ]; } // Common Italian food name aliases for better matching $aliases = [ 'uovo' => ['uova','uovo','egg'], 'uova' => ['uovo','uova','egg'], 'latte' => ['latte','milk'], 'formaggio' => ['formaggio','cheese','philadelphia','mozzarella','parmigiano','grana','pecorino','ricotta','mascarpone','stracchino','gorgonzola'], 'pasta' => ['pasta','spaghetti','penne','fusilli','rigatoni','farfalle','tagliatelle','linguine','bucatini','orecchiette','paccheri','maccheroni'], 'pomodoro' => ['pomodoro','pomodori','tomato','passata','pelati','polpa'], 'cipolla' => ['cipolla','cipolle','onion'], 'aglio' => ['aglio','garlic'], 'burro' => ['burro','butter'], 'panna' => ['panna','cream','crema'], 'zucchero' => ['zucchero','sugar'], 'farina' => ['farina','flour'], 'olio' => ['olio','oil'], 'patata' => ['patata','patate','potato'], 'carota' => ['carota','carote','carrot'], 'sedano' => ['sedano','celery'], 'prezzemolo' => ['prezzemolo','parsley'], 'basilico' => ['basilico','basil'], ]; foreach ($recipe['ingredients'] as &$ing) { if (!empty($ing['from_pantry'])) { $ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8'); $ingWords = preg_split('/[\s,.\-\/]+/', $ingNameLower); $bestMatch = null; $bestScore = 0; foreach ($itemsLookup as $entry) { $itemNameLower = $entry['lower']; $itemWords = $entry['words']; $score = 0; // Exact match if ($ingNameLower === $itemNameLower) { $score = 100; } // Ingredient name contained in product name elseif (mb_strpos($itemNameLower, $ingNameLower) !== false) { $score = 80; } // Product name contained in ingredient name elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) { $score = 70; } else { // Word-level matching with alias expansion $expandedIngWords = $ingWords; foreach ($ingWords as $w) { foreach ($aliases as $key => $group) { if (in_array($w, $group) || mb_strpos($w, $key) === 0 || mb_strpos($key, $w) === 0) { $expandedIngWords = array_merge($expandedIngWords, $group); } } } $expandedIngWords = array_unique($expandedIngWords); $common = 0; foreach ($expandedIngWords as $ew) { foreach ($itemWords as $iw) { // Partial stem match (min 4 chars shared prefix) $minLen = min(mb_strlen($ew), mb_strlen($iw)); if ($minLen >= 3) { $prefixLen = 0; for ($c = 0; $c < $minLen; $c++) { if (mb_substr($ew, $c, 1) === mb_substr($iw, $c, 1)) $prefixLen++; else break; } if ($prefixLen >= min(4, $minLen)) { $common++; break; } } if ($ew === $iw) { $common++; break; } } } if ($common > 0) { $score = ($common / max(count($ingWords), 1)) * 65; // Bonus: if the main/first ingredient word matches if (count($ingWords) > 0 && $common > 0) { foreach ($itemWords as $iw) { if (mb_strpos($iw, $ingWords[0]) === 0 || mb_strpos($ingWords[0], $iw) === 0) { $score += 10; break; } } } } } if ($score > $bestScore) { $bestScore = $score; $bestMatch = $entry['item']; } } // Only match if score is reasonable (> 30) if ($bestMatch && $bestScore > 30) { $ing['product_id'] = (int)$bestMatch['product_id']; $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']; } // === FIX qty_number: validate and convert units === $qtyNum = (float)($ing['qty_number'] ?? 0); $invUnit = $bestMatch['unit'] ?? 'pz'; $invQty = (float)$bestMatch['quantity']; if ($qtyNum > 0) { // Parse the recipe qty string to detect what unit Gemini intended $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'; } // Convert qty_number to inventory unit if mismatch detected if ($recipeUnit && $recipeUnit !== $invUnit) { // Weight conversions (both should be 'g' now, but handle legacy 'kg') if ($recipeUnit === 'g' && $invUnit === 'kg') { $qtyNum = $recipeVal / 1000; } elseif ($recipeUnit === 'g' && $invUnit === 'g') { $qtyNum = $recipeVal; // Volume conversions (both should be 'ml' now, but handle legacy 'l') } elseif ($recipeUnit === 'ml' && $invUnit === 'l') { $qtyNum = $recipeVal / 1000; } elseif ($recipeUnit === 'ml' && $invUnit === 'ml') { $qtyNum = $recipeVal; // g/ml → pz (approximate to nearest piece) } elseif ($invUnit === 'pz' || $invUnit === 'conf') { $defQty = (float)($bestMatch['default_quantity'] ?? 0); if ($defQty > 0) { // Convert recipe grams/ml to pieces using default_quantity $qtyNum = $recipeVal / $defQty; $qtyNum = max(0.25, round($qtyNum * 4) / 4); // round to nearest quarter } else { $qtyNum = max(1, round($recipeVal / 100)); // fallback heuristic } } } // Sanity check: qty_number should not exceed available if ($qtyNum > $invQty) { $qtyNum = $invQty; // cap to available } // Sanity check: if qty_number is absurdly small relative to recipe // e.g. recipe says 100g but qty_number is 0.1 and unit is g → likely meant 100 if ($recipeVal > 0 && $recipeUnit === $invUnit && $qtyNum < $recipeVal * 0.01) { $qtyNum = $recipeVal; // Gemini probably confused the units } $ing['qty_number'] = round($qtyNum, 3); } } } } unset($ing); } echo json_encode(['success' => true, 'recipe' => $recipe]); } else { echo json_encode(['success' => false, 'error' => 'Impossibile generare la ricetta', 'raw' => $text]); } } // ===== RECIPE GENERATION — STREAMING AGENT ===== function generateRecipeStream(PDO $db): void { // 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(); }; $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { $send('error', ['error' => 'no_api_key']); return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; $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' => '📦 Analizzo la dispensa...']); $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' => 'La dispensa è vuota!']); 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 (!empty($item['expiry_date'])) return 4; if ($isOpen) return 5; 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)"; // 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"; } if ($isOpen && $group <= 5) $line .= ' [APERTO]'; $priorityGroups[$group][] = $line; } // Limiti ingredienti per gruppo: con piano pasto attivo passa TUTTO (l'AI deve combinare liberamente) // Senza piano pasto: limiti moderati per ridurre token (ora safe grazie a thinkingBudget:0) $hasMealPlan = !empty($mealPlanType); $ingredientSections = []; $priorityHeaders = [1=>'SCADUTI — usa subito',2=>'SCADENZA ≤3gg — priorità alta',3=>'SCADENZA ≤7gg',4=>'ALTRI CON SCADENZA',5=>'APERTI',6=>'DISPENSA']; $totalIngredientsSent = 0; foreach ($priorityHeaders as $g => $header) { if (empty($priorityGroups[$g])) continue; $gi = $priorityGroups[$g]; if (!$hasMealPlan) { // Senza piano: limiti moderati if ($g === 4 && count($gi) > 25) $gi = array_slice($gi, 0, 25); if ($g === 6 && count($gi) > 15) $gi = array_slice($gi, 0, 15); } // Con piano pasto attivo: nessun limite — tutti gli ingredienti disponibili $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' => "⚠️ {$urgentCount} urgenti: " . implode(', ', $urgentNames)]); } else { $countMsg = count($items) . ' prodotti trovati'; if ($hasMealPlan && $totalIngredientsSent < count($items)) { $countMsg .= " ({$totalIngredientsSent} passati all'AI)"; } elseif ($hasMealPlan) { $countMsg .= ' — tutti passati all\'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 = !empty($appliances) ? "\n\nELETTRODOMESTICI: " . implode(', ', $appliances) . " (+ fornelli e forno). Usa SOLO questi." : ''; $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' => "🧠 Valuto gli ingredienti disponibili..."]); // 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 = '👨‍🍳 Preparo la ricetta...'; if (!empty($mealPlanType) && isset($mealPlanTypeLabels[$mealPlanType]) && $mealPlanTypeLabels[$mealPlanType] !== '') { // Tipo di pasto dal piano settimanale — mostra la categoria $shortLabel = explode(' (', $mealPlanTypeLabels[$mealPlanType])[0]; $conceptMsg = "🎯 Piatto a base di {$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); $conceptMsg = "🥘 Ricetta con " . implode(' e ', array_map('mb_strtolower', $shown)); if ($variation > 0) $conceptMsg .= " — variante #{$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' => '✍️ Creo la ricetta completa...']); $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; $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 => 60, 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); curl_close($ch); if ($body === false) $body = ''; $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' => "⏳ Quota TPM esaurita ({$modelName}), attendo {$waitSec}s... (tentativo {$attempt}/{$maxRetries})"]); sleep($waitSec); $send('status', ['step' => 3, 'message' => '✍️ Riprovo la generazione...']); } // 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' => "🔄 Cambio modello → {$fallbackName}..."]); continue; } break; } if ($httpCode !== 200) { $errDetail = $result['data']['error']['message'] ?? substr($result['body'], 0, 300); $send('error', ['error' => 'Errore API Gemini', 'http_code' => $httpCode, 'detail' => $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' => 'Impossibile generare la ricetta', 'raw' => $text]); return; } // ── Post-process: fuzzy-match ingredients → inventory (same as generateRecipe) ── if (!empty($recipe['ingredients'])) { $itemsLookup = []; foreach ($items as $item) { $itemsLookup[] = [ 'item' => $item, 'lower' => mb_strtolower(trim($item['name']), 'UTF-8'), 'words' => preg_split('/[\s,.\-\/]+/', mb_strtolower(trim($item['name']), 'UTF-8')), 'cat' => mb_strtolower($item['category'] ?? '', 'UTF-8'), ]; } $aliases = ['uovo'=>['uova','uovo','egg'],'uova'=>['uovo','uova','egg'],'latte'=>['latte','milk'],'formaggio'=>['formaggio','cheese','philadelphia','mozzarella','parmigiano','grana','pecorino','ricotta','mascarpone','stracchino','gorgonzola'],'pasta'=>['pasta','spaghetti','penne','fusilli','rigatoni','farfalle','tagliatelle','linguine','bucatini','orecchiette','paccheri','maccheroni'],'pomodoro'=>['pomodoro','pomodori','tomato','passata','pelati','polpa'],'cipolla'=>['cipolla','cipolle','onion'],'aglio'=>['aglio','garlic'],'burro'=>['burro','butter'],'panna'=>['panna','cream','crema'],'zucchero'=>['zucchero','sugar'],'farina'=>['farina','flour'],'olio'=>['olio','oil'],'patata'=>['patata','patate','potato'],'carota'=>['carota','carote','carrot'],'sedano'=>['sedano','celery'],'prezzemolo'=>['prezzemolo','parsley'],'basilico'=>['basilico','basil']]; foreach ($recipe['ingredients'] as &$ing) { if (empty($ing['from_pantry'])) continue; $ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8'); $ingWords = preg_split('/[\s,.\-\/]+/', $ingNameLower); $bestMatch = null; $bestScore = 0; foreach ($itemsLookup as $entry) { $itemNameLower = $entry['lower']; $itemWords = $entry['words']; $score = 0; if ($ingNameLower === $itemNameLower) { $score = 100; } elseif (mb_strpos($itemNameLower, $ingNameLower) !== false) { $score = 80; } elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) { $score = 70; } else { $expandedIngWords = $ingWords; foreach ($ingWords as $w) { foreach ($aliases as $key => $group) { if (in_array($w, $group) || mb_strpos($w, $key) === 0 || mb_strpos($key, $w) === 0) $expandedIngWords = array_merge($expandedIngWords, $group); } } $expandedIngWords = array_unique($expandedIngWords); $common = 0; foreach ($expandedIngWords as $ew) { foreach ($itemWords as $iw) { $minLen = min(mb_strlen($ew), mb_strlen($iw)); if ($minLen >= 3) { $prefixLen = 0; for ($c = 0; $c < $minLen; $c++) { if (mb_substr($ew, $c, 1) === mb_substr($iw, $c, 1)) $prefixLen++; else break; } if ($prefixLen >= min(4, $minLen)) { $common++; break; } } if ($ew === $iw) { $common++; break; } } } if ($common > 0) { $score = ($common / max(count($ingWords), 1)) * 65; if (count($ingWords) > 0) { foreach ($itemWords as $iw) { if (mb_strpos($iw, $ingWords[0]) === 0 || mb_strpos($ingWords[0], $iw) === 0) { $score += 10; break; } } } } } if ($score > $bestScore) { $bestScore = $score; $bestMatch = $entry['item']; } } if ($bestMatch && $bestScore > 30) { $ing['product_id'] = (int)$bestMatch['product_id']; $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']; $qtyNum = (float)($ing['qty_number'] ?? 0); $invUnit = $bestMatch['unit'] ?? 'pz'; $invQty = (float)$bestMatch['quantity']; if ($qtyNum > 0) { $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'; } 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 === 'pz' || $invUnit === 'conf') { $defQty = (float)($bestMatch['default_quantity'] ?? 0); if ($defQty > 0) { $qtyNum = $recipeVal / $defQty; $qtyNum = max(0.25, round($qtyNum * 4) / 4); } else $qtyNum = max(1, round($recipeVal / 100)); } } if ($qtyNum > $invQty) $qtyNum = $invQty; if ($recipeVal > 0 && $recipeUnit === $invUnit && $qtyNum < $recipeVal * 0.01) $qtyNum = $recipeVal; $ing['qty_number'] = round($qtyNum, 3); } } } unset($ing); } $send('status', ['step' => 4, 'message' => '✅ Ricetta pronta!']); $send('recipe', ['recipe' => $recipe]); } // ===== GEMINI AI PRODUCT IDENTIFICATION ===== function geminiIdentifyProduct(): void { $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); $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 = callGemini($url, $payload, 30); $httpCode = $result['http_code']; if ($httpCode !== 200) { $errMsg = $result['data']['error']['message'] ?? 'Errore API Gemini'; 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' => 'Impossibile identificare il prodotto', '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)) { $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; } // ===== BRING! SHOPPING LIST INTEGRATION ===== function bringAuth(): ?array { $email = env('BRING_EMAIL'); $password = env('BRING_PASSWORD'); if (empty($email) || empty($password)) { return null; } // Check cache file for valid token $cacheFile = __DIR__ . '/../data/bring_token.json'; if (file_exists($cacheFile)) { $cached = json_decode(file_get_contents($cacheFile), true); if ($cached && isset($cached['expires']) && $cached['expires'] > 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) { 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) { 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' ]; $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; } function bringGetList(): void { $auth = bringAuth(); if (!$auth) { echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate. Aggiungi BRING_EMAIL e BRING_PASSWORD al file .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' => 'Nessuna lista Bring! trovata']); return; } } $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$data) { echo json_encode(['success' => false, 'error' => 'Errore nel recupero della lista']); 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'] ?? '', ]; } } echo json_encode([ 'success' => true, 'listUUID' => $listUUID, 'purchase' => $purchase, 'recently' => $recently, ], JSON_UNESCAPED_UNICODE); } function bringAddItems(): void { $auth = bringAuth(); if (!$auth) { echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate']); 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' => 'Lista non trovata']); 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'] ?? ''; } } foreach ($items as $item) { $name = $item['name'] ?? ''; if (empty($name)) continue; // Map Italian name to Bring! catalog key (German) for proper recognition $bringName = italianToBring($name); $bringKey = strtolower($bringName); $spec = $item['specification'] ?? ''; $update_spec = $item['update_spec'] ?? false; // explicit flag to force spec update if (array_key_exists($bringKey, $existingItems)) { // Item already on the list — only update if specification changed and update_spec requested if ($update_spec && $existingItems[$bringKey] !== $spec) { $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) $updated++; } 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++; } else { $errors[] = $name; } } if ($added > 0 || $updated > 0) { // Invalidate cache so next smart_shopping request reflects the updated Bring! list @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); } echo json_encode(['success' => true, 'added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'errors' => $errors]); } function bringRemoveItem(): void { $auth = bringAuth(); if (!$auth) { echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate']); return; } $input = json_decode(file_get_contents('php://input'), true); $name = $input['name'] ?? ''; $listUUID = $input['listUUID'] ?? $auth['bringListUUID']; if (empty($name) || empty($listUUID)) { echo json_encode(['success' => false, 'error' => 'Parametri mancanti']); return; } // Use rawName (German key) if provided, otherwise try to map $rawName = $input['rawName'] ?? ''; $removeName = !empty($rawName) ? $rawName : italianToBring($name); $body = http_build_query([ 'uuid' => $listUUID, 'remove' => $removeName, ]); $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body); if ($result !== null) { // Invalidate cache so next smart_shopping request reflects the updated Bring! list @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); } echo json_encode(['success' => $result !== null]); } function bringCleanSpecs(): void { $auth = bringAuth(); if (!$auth) { echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate']); return; } $listUUID = $auth['bringListUUID']; if (empty($listUUID)) { echo json_encode(['success' => false, 'error' => 'Lista non trovata']); return; } $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if (!$data || !isset($data['purchase'])) { echo json_encode(['success' => false, 'error' => 'Errore nel recupero della lista']); 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]); } /** * Serve smart shopping from cache (written by cron), falling back to live computation. * Cache is valid for up to 10 minutes; if stale or missing, compute on the fly. */ /** * Invalidate the smart shopping cache so the next request recomputes live. * Call after any inventory_add or inventory_use that changes stock meaningfully. */ function invalidateSmartShoppingCache(): void { $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; if (file_exists($cacheFile)) { @unlink($cacheFile); } } function smartShoppingCached(PDO $db): void { $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); 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): bool { // 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 { $now = time(); $today = date('Y-m-d'); // Helper: extract significant tokens from a product name (mirrors JS _nameTokens) $nameTokens = function(string $name): array { $stop = ['di','del','della','dei','degli','delle','da','in','con','per','su', 'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo']; $tokens = preg_split('/\s+/', strtolower(preg_replace('/[^\p{L}\s]/u', ' ', $name))); return array_values(array_filter($tokens, fn($t) => strlen($t) > 2 && !in_array($t, $stop))); }; // 1. Get all products with their inventory and transaction history $products = $db->query(" SELECT p.id, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit FROM products p ORDER BY p.name ")->fetchAll(); // 2. Get all inventory grouped by product $invStmt = $db->query(" SELECT i.product_id, SUM(i.quantity) as total_qty, MIN(i.expiry_date) as nearest_expiry, GROUP_CONCAT(DISTINCT i.location) as locations, MAX(i.opened_at) as opened_at FROM inventory i WHERE i.quantity > 0 GROUP BY i.product_id "); $inventory = []; foreach ($invStmt->fetchAll() as $inv) { $inventory[$inv['product_id']] = $inv; } // 3. Get transaction stats per product $txStmt = $db->query(" SELECT product_id, COUNT(CASE WHEN type IN ('out','waste') THEN 1 END) as use_count, SUM(CASE WHEN type IN ('out','waste') THEN quantity ELSE 0 END) as total_used, COUNT(CASE WHEN type = 'in' THEN 1 END) as buy_count, SUM(CASE WHEN type = 'in' THEN quantity ELSE 0 END) as total_bought, MIN(CASE WHEN type = 'in' THEN created_at END) as first_in, MAX(CASE WHEN type = 'in' THEN created_at END) as last_in, MAX(CASE WHEN type IN ('out','waste') THEN created_at END) as last_out FROM transactions GROUP BY product_id "); $txData = []; foreach ($txStmt->fetchAll() as $tx) { $txData[$tx['product_id']] = $tx; } // 4. Fetch current Bring! list to know what's already there $bringItems = []; try { $auth = bringAuth(); if ($auth) { $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}"); if ($listData && isset($listData['purchase'])) { foreach ($listData['purchase'] as $bi) { $bringItems[mb_strtolower(bringToItalian($bi['name'] ?? ''))] = true; $bringItems[mb_strtolower($bi['name'] ?? '')] = true; } } } } catch (Exception $e) { /* ignore */ } // 4b. Build stockByAnyToken: every significant token of in-stock products → total qty. // Used to skip depleted products covered by any equivalent in-stock product. // Any-token (not just first) groups product families: // 'Passata di pomodoro' + 'Polpa di pomodoro' + 'Pelato Cirio' all share 'pomodoro' // 'Aglio rosso' + 'Aglio' share 'aglio' // 'Latte di Montagna' + 'Latte Parzialmente Scremato' share 'latte' $stockByAnyToken = []; foreach ($products as $pStock) { $qty = isset($inventory[$pStock['id']]) ? (float)$inventory[$pStock['id']]['total_qty'] : 0; if ($qty <= 0) continue; foreach ($nameTokens($pStock['name']) as $tok) { $stockByAnyToken[$tok] = ($stockByAnyToken[$tok] ?? 0) + $qty; } } // 5. Analyze each product $items = []; foreach ($products as $p) { $pid = $p['id']; $inv = $inventory[$pid] ?? null; $tx = $txData[$pid] ?? null; // Skip products never bought/used and not in inventory if (!$tx && !$inv) continue; $qty = $inv ? (float)$inv['total_qty'] : 0; $unit = $p['unit'] ?: 'pz'; $defQty = (float)($p['default_quantity'] ?: 0); $isOpened = $inv && !empty($inv['opened_at']); // --- Usage frequency --- $useCount = $tx ? (int)$tx['use_count'] : 0; $buyCount = $tx ? (int)$tx['buy_count'] : 0; $totalUsed = $tx ? (float)$tx['total_used'] : 0; $totalBought = $tx ? (float)$tx['total_bought'] : 0; // Days since first purchase $firstIn = $tx && $tx['first_in'] ? strtotime($tx['first_in']) : null; $lastIn = $tx && $tx['last_in'] ? strtotime($tx['last_in']) : null; $lastOut = $tx && $tx['last_out'] ? strtotime($tx['last_out']) : null; $daysSinceFirst = $firstIn ? max(1, ($now - $firstIn) / 86400) : 999; // Average daily consumption rate $dailyRate = $daysSinceFirst < 999 && $totalUsed > 0 ? $totalUsed / $daysSinceFirst : 0; // Days of stock remaining $daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0); // --- Expiry check --- $expiryDate = $inv ? $inv['nearest_expiry'] : null; $daysToExpiry = $expiryDate ? (strtotime($expiryDate) - $now) / 86400 : 999; $isExpired = $daysToExpiry < 0; $isExpiringSoon = !$isExpired && $daysToExpiry <= 3; // --- Stock level assessment --- // percentage_left: how much is left vs typical purchase size // Use average of totalBought/buyCount if available, else default_quantity, else best-guess from defQty or 1 $refQty = $totalBought > 0 && $buyCount > 0 ? $totalBought / $buyCount : ($defQty > 0 ? $defQty : max(1, $qty)); // avoid inflating pctLeft for products with no history $pctLeft = $refQty > 0 ? min(200, ($qty / $refQty) * 100) : ($qty > 0 ? 100 : 0); // Cap daysLeft at a reasonable ceiling to avoid 999-day noise in reason strings $daysLeft = min($daysLeft, 365); // --- 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 3 days) — suppress non-expiry urgency $justRestocked = $daysSinceLastBuy <= 3; // 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) $isRegular = $usesPerMonth >= 0.5; // Is it recently relevant? (used/bought in last 60 days) $isRecent = $daysSinceLastUse <= 60; // --- 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; foreach ($pToks as $tok) { if (($stockByAnyToken[$tok] ?? 0) > 0) { $coveredByEquivalent = true; break; } } if ($coveredByEquivalent) continue; if ($isFrequent && $isRecent && $buyCount >= 2) { // Frequently used, recently active, AND bought multiple times → critical $urgency = 'critical'; $reasons[] = 'Esaurito'; $score += 100; if ($useCount >= 5) { $score += 20; $reasons[] = "Uso frequente ({$useCount}x)"; } } elseif ($isFrequent && $isRecent && $buyCount == 1 && $useCount >= 3) { // Bought once but used ≥3 times → proven consumption pattern → high $urgency = 'high'; $reasons[] = 'Esaurito'; $score += 75; if ($useCount >= 5) { $score += 10; $reasons[] = "Uso frequente ({$useCount}x)"; } } elseif ($isFrequent && $isRecent && $buyCount == 1) { // Frequent use, bought once, <3 uses — not yet proven → medium $urgency = 'medium'; $reasons[] = 'Esaurito'; $score += 45; } elseif ($isRegular && $isRecent && ($useCount >= 3 || $buyCount >= 2)) { // Regularly used, recently active → high $urgency = 'high'; $reasons[] = 'Esaurito'; $score += 70; } elseif ($isRecent && $buyCount >= 2) { // At least bought a couple times recently → low $urgency = 'low'; $reasons[] = 'Esaurito'; $score += 30; } else { // Rarely used or not used recently — skip continue; } } // Almost finished — only flag if usage frequency justifies it if ($qty > 0 && $pctLeft <= 15 && $isRegular) { $urgency = $isFrequent ? 'high' : 'medium'; $reasons[] = 'Quasi finito (' . round($pctLeft) . '%)'; $score += 80; } elseif ($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) — valid regardless of frequency if ($isExpired && $qty > 0) { $urgency = 'critical'; $reasons[] = 'Scaduto!'; $score += 90; } elseif ($isExpiringSoon && $qty > 0) { if ($urgency === 'none') $urgency = 'medium'; $reasons[] = 'Scade tra ' . max(0, round($daysToExpiry)) . 'gg'; $score += 40; } // Frequently used but stock getting low (predictive) — 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) { // Running out within 3 days for a frequent product → high urgency $urgency = 'high'; $score += 70; } elseif ($daysLeftDisplay <= 7) { // Running out within a week → medium $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; } } if ($urgency === 'none') continue; // Boost score for very frequent items if ($useCount >= 8) $score += 15; elseif ($useCount >= 5) $score += 10; // Is already on Bring? (fuzzy token match — mirrors JS _findSimilarItem logic) $onBring = _productOnBring($p['name'], $bringItems); // "Just restocked" suppression: if bought in the last 3 days AND stock is above 50% // of reference qty, skip non-expiry urgency flags. The product doesn't need rebuying yet. if ($justRestocked && $pctLeft >= 50 && !$isExpired && !$isExpiringSoon) { continue; } $items[] = [ 'product_id' => $pid, 'name' => $p['name'], 'brand' => $p['brand'] ?: '', 'category' => $p['category'] ?: '', 'unit' => $unit, 'current_qty' => round($qty, 1), 'default_qty' => $defQty, 'package_unit' => $p['package_unit'] ?: '', 'pct_left' => round($pctLeft), 'use_count' => $useCount, 'buy_count' => $buyCount, 'daily_rate' => round($dailyRate, 2), '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'] : '', ]; } // 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 { // Offline: derive suggestions from smart shopping cache (no AI needed) // 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 = []; $seasonalTips = [ 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 = $seasonalTips[(int)date('n')] ?? ''; foreach ($smartItems as $item) { if ($item['on_bring'] ?? false) continue; // already on shopping list $urgency = $item['urgency'] ?? 'low'; if ($urgency === 'low') continue; // not urgent enough to suggest $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, ]; if (count($suggestions) >= 12) break; } echo json_encode([ 'success' => true, 'suggestions' => $suggestions, 'seasonal_tip' => $seasonalTip, 'listUUID' => $listUUID, ], JSON_UNESCAPED_UNICODE); } // ===== DUPLICLICK (GRUPPO POLI) ===== function dupliclickLogin(): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['error' => 'POST required']); return; } $input = json_decode(file_get_contents('php://input'), true); $email = $input['email'] ?? ''; $password = $input['password'] ?? ''; if (empty($email) || empty($password)) { echo json_encode(['error' => 'Email e password sono obbligatori']); return; } $postData = http_build_query([ 'login' => $email, 'password' => $password, 'remember_me' => 'true', 'show_sectors' => 'false' ]); $ch = curl_init('https://www.dupliclick.it/ebsn/api/auth/login'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $postData, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => [ 'Content-Type: application/x-www-form-urlencoded;charset=UTF-8', 'Accept: application/json', 'Origin: https://www.dupliclick.it', 'Referer: https://www.dupliclick.it/', 'x-ebsn-client: production', 'x-ebsn-client-redirect: production', 'x-ebsn-client-uuid: 64b2d6318bb8f97bb1aba47dd8af38f6', 'x-ebsn-version: 2.0.7' ], CURLOPT_SSL_VERIFYPEER => true, ]); $response = curl_exec($ch); if (curl_errno($ch)) { echo json_encode(['error' => 'Errore connessione: ' . curl_error($ch)]); curl_close($ch); return; } $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); $headerStr = substr($response, 0, $headerSize); $body = substr($response, $headerSize); curl_close($ch); // Extract JWT token from x-ebsn-account header $token = ''; foreach (explode("\r\n", $headerStr) as $line) { if (stripos($line, 'x-ebsn-account:') === 0) { $token = trim(substr($line, strlen('x-ebsn-account:'))); break; } } // The response body may have leading whitespace/newlines - trim it $body = trim($body); $bodyData = json_decode($body, true); // Check login success: status is at response.status (not root level) if ($bodyData === null) { echo json_encode(['error' => 'Risposta non valida dal server DupliClick', 'http_code' => $httpCode, 'raw' => substr($body, 0, 500)]); return; } $respStatus = $bodyData['response']['status'] ?? ($bodyData['status'] ?? -1); if ($respStatus !== 0) { $errors = $bodyData['response']['errors'] ?? $bodyData['errors'] ?? []; $errMsg = $errors[0]['error'] ?? $bodyData['message'] ?? 'Credenziali non valide'; echo json_encode(['error' => $errMsg, 'status' => $respStatus]); return; } // User data is at root level, not inside data.user $userData = $bodyData['data']['user'] ?? $bodyData['user'] ?? null; $cartId = $bodyData['data']['cartId'] ?? $bodyData['cartId'] ?? null; // Save token to file for later use $tokenData = [ 'token' => $token, 'email' => $email, 'logged_at' => date('c'), 'user' => $userData, 'cart_id' => $cartId, ]; file_put_contents(__DIR__ . '/../data/dupliclick_token.json', json_encode($tokenData, JSON_PRETTY_PRINT)); echo json_encode([ 'success' => true, 'token' => !empty($token) ? substr($token, 0, 20) . '...' : '(non trovato)', 'token_full' => $token, 'http_code' => $httpCode, 'data' => $bodyData['data'] ?? null, 'user' => $userData, 'response_status' => $respStatus, 'infos' => $bodyData['response']['infos'] ?? [], ]); } // ===== DUPLICLICK PRODUCT SEARCH ===== function dupliclickSearch(): void { $query = $_GET['q'] ?? ''; $spec = $_GET['spec'] ?? ''; $aiPrompt = $_GET['prompt'] ?? ''; if (empty($query)) { echo json_encode(['error' => 'Parametro q obbligatorio']); return; } // Load saved token $tokenFile = __DIR__ . '/../data/dupliclick_token.json'; if (!file_exists($tokenFile)) { echo json_encode(['error' => 'Non sei loggato a DupliClick. Vai in Configurazione > Spesa Online.']); return; } $tokenData = json_decode(file_get_contents($tokenFile), true); $token = $tokenData['token'] ?? ''; if (empty($token)) { echo json_encode(['error' => 'Token DupliClick non trovato. Effettua il login.']); return; } $baseHeaders = [ 'Accept: application/json', 'Origin: https://www.dupliclick.it', 'Referer: https://www.dupliclick.it/', 'x-ebsn-client: production', 'x-ebsn-client-uuid: 64b2d6318bb8f97bb1aba47dd8af38f6', 'x-ebsn-version: 2.0.7', 'x-ebsn-account: ' . $token, ]; // Search catalog by item name only first $searchResults = dupliclickCatalogSearch($query, $baseHeaders); if ($searchResults === null) { echo json_encode(['error' => 'Errore nella ricerca']); return; } $products = $searchResults['products']; $total = $searchResults['total']; if (empty($products)) { // Fallback: try searching with spec keywords appended $specKeywords = dupliclickExtractSpecKeywords($spec); if ($specKeywords) { $searchResults = dupliclickCatalogSearch($query . ' ' . $specKeywords, $baseHeaders); if ($searchResults && !empty($searchResults['products'])) { $products = $searchResults['products']; $total = $searchResults['total']; } } if (empty($products)) { echo json_encode(['success' => true, 'query' => $query, 'product' => null, 'total' => 0]); return; } } // Format top 10 products $topProducts = array_slice($products, 0, 10); $formatted = array_map('formatDupliclickProduct', $topProducts); // If multiple results, use AI to pick the best match $bestProduct = $formatted[0]; $aiUsed = false; if (count($formatted) > 1) { $aiResult = aiSelectBestProduct($query, $spec, $formatted, $aiPrompt); if ($aiResult !== null) { $bestProduct = $aiResult; $aiUsed = true; } elseif ($aiResult === null && !empty($spec)) { // AI said no match — try refined search with spec keywords $specKeywords = dupliclickExtractSpecKeywords($spec); if ($specKeywords) { $refined = dupliclickCatalogSearch($query . ' ' . $specKeywords, $baseHeaders); if ($refined && !empty($refined['products'])) { $refinedFormatted = array_map('formatDupliclickProduct', array_slice($refined['products'], 0, 10)); $aiResult2 = aiSelectBestProduct($query, $spec, $refinedFormatted, $aiPrompt); if ($aiResult2 !== null) { $bestProduct = $aiResult2; $aiUsed = true; } else { $bestProduct = $refinedFormatted[0]; } } } } } echo json_encode([ 'success' => true, 'query' => $query, 'product' => $bestProduct, 'total' => $total, 'ai_used' => $aiUsed, ]); } /** * Search DupliClick catalog and return raw products array */ function dupliclickCatalogSearch(string $query, array $headers): ?array { $url = 'https://www.dupliclick.it/ebsn/api/products?' . http_build_query([ 'q' => $query, 'page' => 1, 'order_by' => 'search_score desc' ]); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => $headers, CURLOPT_SSL_VERIFYPEER => true, ]); $response = curl_exec($ch); if (curl_errno($ch)) { curl_close($ch); return null; } curl_close($ch); $data = json_decode(trim($response), true); if (!$data || ($data['response']['status'] ?? -1) !== 0) return null; return [ 'products' => $data['data']['products'] ?? [], 'total' => $data['data']['page']['totItems'] ?? 0, ]; } /** * Extract meaningful product keywords from a Bring specification string, * stripping quantities, emojis, and noise words. */ function dupliclickExtractSpecKeywords(string $spec): string { if (empty($spec)) return ''; // Remove priority emojis $clean = preg_replace('/[\x{1F534}\x{1F7E1}\x{1F7E2}]/u', '', $spec); // Remove quantities (150g, 500ml, 2x, 1 flacone, etc.) $clean = preg_replace('/\d+\s*(g|kg|ml|l|pz|pezzi|conf|flacon[ei]|x)\b/i', '', $clean); $clean = preg_replace('/\d+x\d*/i', '', $clean); // Remove standalone numbers $clean = preg_replace('/\b\d+\b/', '', $clean); // Remove noise words $noise = ['senza', 'con', 'più', 'meno', 'circa', 'tipo', 'lidl', 'coop', 'conad', 'esselunga']; $clean = preg_replace('/\b(' . implode('|', $noise) . ')\b/i', '', $clean); // Remove commas and extra spaces $clean = preg_replace('/[,+]/', ' ', $clean); $clean = preg_replace('/\s+/', ' ', trim($clean)); return $clean; } /** * Pick the best product from search results using offline text-scoring (no AI needed). * Returns null when nothing matches well enough (triggers refined search with spec keywords). */ function aiSelectBestProduct(string $itemName, string $spec, array $products, string $customPrompt = ''): ?array { if (empty($products)) return null; if (count($products) === 1) return $products[0]; $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','gr','kg','ml','lt','cl','pz','conf','pack']; $tokenize = function(string $s) use ($stop): array { $clean = mb_strtolower(preg_replace('/[^\p{L}0-9\s]/u', ' ', $s), 'UTF-8'); return array_values(array_filter( preg_split('/\s+/', trim($clean)), fn($t) => mb_strlen($t, 'UTF-8') > 2 && !in_array($t, $stop) )); }; $queryTokens = $tokenize($itemName); $specTokens = $tokenize($spec); if (empty($queryTokens)) return $products[0]; // Variant conflict pairs: if spec says X, penalise products containing opposite $variantConflicts = [ 'cubetti' => ['fette','affettata','intera','arrotolata'], 'fette' => ['cubetti','dadini'], 'cotto' => ['crudo','stagionato'], 'crudo' => ['cotto'], 'intero' => ['macinato','tritato','cubetti','fette'], 'macinato' => ['intero'], 'biologico' => [], ]; // Category mismatch: if query implies a category, penalise products from the wrong one $categoryGuards = [ ['query' => ['frutta','mele','pere','pesche','fragole','uva','arance','limoni','banane','kiwi'], 'exclude' => ['succo','succhi','nettare','sciroppo','aranciata','bevanda','bibita']], ['query' => ['verdura','spinaci','zucchine','carote','finocchio','sedano','broccoli'], 'exclude' => ['surgelat','succo']], ['query' => ['formaggio','mozzarella','parmigiano','ricotta','pecorino'], 'exclude' => ['ravioli','tortellini','cannelloni','lasagne','pizza']], ['query' => ['pasta','spaghetti','penne','fusilli','rigatoni','tagliatelle'], 'exclude' => ['insalata','minestra','zuppa','brodo']], ]; $scores = []; foreach ($products as $idx => $product) { $productName = $product['name'] ?? ''; $productBrand = $product['brand'] ?? ''; $productTokens = $tokenize($productName . ' ' . $productBrand); $nameLower = mb_strtolower($productName, 'UTF-8'); $score = 0; // --- Token overlap: query vs product --- foreach ($queryTokens as $qt) { foreach ($productTokens as $pt) { if ($qt === $pt) { $score += 6; break; } if (str_contains($pt, $qt) || str_contains($qt, $pt)) { $score += 2; break; } } } // --- Spec tokens get extra weight (user specified variant) --- foreach ($specTokens as $st) { foreach ($productTokens as $pt) { if ($st === $pt) { $score += 8; break; } if (str_contains($pt, $st) || str_contains($st, $pt)) { $score += 3; break; } } } // --- First-token anchor bonus --- if (!empty($queryTokens) && !empty($productTokens) && $queryTokens[0] === $productTokens[0]) { $score += 10; } // --- Category mismatch penalty --- foreach ($categoryGuards as $guard) { if (!empty(array_intersect($queryTokens, $guard['query']))) { foreach ($guard['exclude'] as $exc) { if (str_contains($nameLower, $exc)) { $score -= 50; break; } } } } // --- Variant conflict penalty --- foreach ($specTokens as $st) { if (isset($variantConflicts[$st])) { foreach ($variantConflicts[$st] as $conflict) { if (str_contains($nameLower, $conflict)) { $score -= 20; } } } } $scores[$idx] = $score; } arsort($scores); reset($scores); $topIdx = key($scores); $topScore = current($scores); next($scores); $secondScore = current($scores) ?: 0; // Return null (triggers spec-refined search) only when spec is given and no product // matches well, so the caller can retry with more specific keywords. if (!empty($spec) && $topScore < 4) return null; // Otherwise return the best scoring result (fallback to first if score is 0) return $products[$topIdx]; } function formatDupliclickProduct(array $p): array { $promo = $p['warehousePromo'] ?? null; $result = [ 'productId' => $p['productId'] ?? $p['id'] ?? null, 'name' => $p['name'] ?? '', 'brand' => $p['shortDescr'] ?? '', 'price' => $p['price'] ?? 0, 'priceDisplay' => $p['priceDisplay'] ?? $p['price'] ?? 0, 'priceUm' => $p['priceStandardUmDisplay'] ?? null, 'weightUnit' => $p['weightUnitDisplay'] ?? '', 'packageDescr' => $p['productInfos']['PACKAGE_DESCR'] ?? '', 'barcode' => $p['barcode'] ?? '', 'imageUrl' => $p['mediaURL'] ?? '', 'slug' => $p['slug'] ?? '', 'itemUrl' => $p['itemUrl'] ?? '', 'url' => 'https://www.dupliclick.it' . ($p['itemUrl'] ?? ''), 'available' => $p['available'] ?? 0, ]; if ($promo) { $result['promo'] = [ 'discount' => $promo['discount'] ?? 0, 'discountPerc' => $promo['discountPerc'] ?? 0, 'originalPrice' => round(($p['price'] ?? 0) + ($promo['discount'] ?? 0), 2), 'validFrom' => $promo['validityDate'] ?? '', 'validTo' => $promo['expireDate'] ?? '', 'label' => $promo['view']['body'] ?? 'OFFERTA', 'type' => $promo['promoType'] ?? '', ]; } return $result; } // ===== SHARED APP DATA FUNCTIONS ===== function appSettingsGet(PDO $db): void { $rows = $db->query("SELECT key, value FROM app_settings")->fetchAll(); $settings = []; foreach ($rows as $row) { $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)) { 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 FROM recipes ORDER BY date DESC, created_at DESC LIMIT {$limit}")->fetchAll(); $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 ]; } echo json_encode(['success' => true, 'recipes' => $recipes]); } function recipesSave(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); $date = $input['date'] ?? date('Y-m-d'); $meal = $input['meal'] ?? ''; $recipe = $input['recipe'] ?? null; if (!$meal || !$recipe) { echo json_encode(['error' => 'Missing meal or 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) { $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 { $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']]); } } echo json_encode(['success' => true]); } function chatClear(PDO $db): void { $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 { $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]); }