Smart shopping: cron ogni 5min pre-calcola cache server-side, API serve da cache (risposta istantanea)
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* Cron: pre-compute smart shopping list and save to cache.
|
||||
* Install with: crontab -e
|
||||
* *\/5 * * * * php /var/www/html/dispensa/api/cron_smart_shopping.php >> /var/www/html/dispensa/data/cron.log 2>&1
|
||||
*/
|
||||
|
||||
// Only allow CLI execution — block HTTP access
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('Forbidden');
|
||||
}
|
||||
|
||||
// Define CRON_MODE before loading index.php so the router is skipped
|
||||
define('CRON_MODE', true);
|
||||
|
||||
// Load all API functions without running the HTTP router
|
||||
require_once __DIR__ . '/index.php';
|
||||
|
||||
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||
|
||||
try {
|
||||
$db = getDB();
|
||||
|
||||
// Capture the JSON output of smartShopping()
|
||||
ob_start();
|
||||
smartShopping($db);
|
||||
$json = ob_get_clean();
|
||||
|
||||
$decoded = json_decode($json, true);
|
||||
if (!$decoded || !isset($decoded['success'])) {
|
||||
throw new RuntimeException('Invalid JSON from smartShopping(): ' . substr($json, 0, 200));
|
||||
}
|
||||
|
||||
$decoded['cached_at'] = date('c');
|
||||
$decoded['cached_ts'] = time();
|
||||
|
||||
if (file_put_contents(CACHE_FILE, json_encode($decoded, JSON_UNESCAPED_UNICODE)) === false) {
|
||||
throw new RuntimeException('Cannot write cache file: ' . CACHE_FILE);
|
||||
}
|
||||
|
||||
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . count($decoded['items'] ?? []) . " items cached\n";
|
||||
} catch (Throwable $e) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
+60
-9
@@ -4,6 +4,12 @@
|
||||
* Handles all CRUD operations for products and inventory
|
||||
*/
|
||||
|
||||
// database.php must always be loaded (used both by HTTP router and cron)
|
||||
require_once __DIR__ . '/database.php';
|
||||
|
||||
// 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');
|
||||
@@ -14,8 +20,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/database.php';
|
||||
|
||||
try {
|
||||
$db = getDB();
|
||||
} catch (Exception $e) {
|
||||
@@ -27,6 +31,9 @@ try {
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
} // end !CRON_MODE block for router bootstrap
|
||||
|
||||
if (!defined('CRON_MODE')):
|
||||
try {
|
||||
switch ($action) {
|
||||
// ===== PRODUCTS =====
|
||||
@@ -116,7 +123,7 @@ try {
|
||||
bringSuggestItems($db);
|
||||
break;
|
||||
case 'smart_shopping':
|
||||
smartShopping($db);
|
||||
smartShoppingCached($db);
|
||||
break;
|
||||
|
||||
case 'save_settings':
|
||||
@@ -192,6 +199,7 @@ try {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
endif; // end !CRON_MODE
|
||||
|
||||
// ===== CLIENT LOG =====
|
||||
|
||||
@@ -1426,17 +1434,31 @@ function generateRecipe(PDO $db): void {
|
||||
}
|
||||
$ingredientsText = implode("\n\n", $ingredientSections);
|
||||
|
||||
// Build a mandatory-use list from the most urgent items (groups 1 + 2)
|
||||
$urgentItems = [];
|
||||
// Build mandatory/recommended lists:
|
||||
// - Truly mandatory: expired (group 1) OR expiring within 1 day (group 2 + daysLeft ≤ 1)
|
||||
// These are genuinely at risk of being wasted RIGHT NOW.
|
||||
// - Highly recommended: expiring in 2-3 days (group 2 + daysLeft > 1)
|
||||
// Fresh products like milk naturally have short shelf lives and are often used anyway,
|
||||
// so we suggest rather than force them.
|
||||
$mandatoryItems = [];
|
||||
$recommendedItems = [];
|
||||
foreach ($items as $item) {
|
||||
$g = $getItemPriority($item);
|
||||
if ($g <= 2) {
|
||||
$urgentItems[] = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . " — scade: {$item['expiry_date']}";
|
||||
$daysLeft = floatval($item['days_left']);
|
||||
$label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . " — scade: {$item['expiry_date']}";
|
||||
if ($g === 1 || ($g === 2 && $daysLeft <= 1)) {
|
||||
$mandatoryItems[] = $label;
|
||||
} elseif ($g === 2) {
|
||||
$recommendedItems[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$mustUseText = '';
|
||||
if (!empty($urgentItems)) {
|
||||
$mustUseText = "\n\n⚠️⚠️⚠️ INGREDIENTI OBBLIGATORI (SCADUTI O IN SCADENZA IMMINENTE) ⚠️⚠️⚠️\nLa ricetta DEVE usare ALMENO uno (meglio se tutti) di questi ingredienti come ingrediente PRINCIPALE della ricetta. Non sono opzionali!\n" . implode("\n", array_map(fn($n) => "→ $n", $urgentItems));
|
||||
if (!empty($mandatoryItems)) {
|
||||
$mustUseText .= "\n\n⚠️⚠️⚠️ INGREDIENTI OBBLIGATORI (SCADUTI O IN SCADENZA OGGI/DOMANI) ⚠️⚠️⚠️\nLa ricetta DEVE usare ALMENO uno (meglio se tutti) di questi ingredienti come ingrediente PRINCIPALE. Non sono opzionali!\n" . implode("\n", array_map(fn($n) => "→ $n", $mandatoryItems));
|
||||
}
|
||||
if (!empty($recommendedItems)) {
|
||||
$mustUseText .= "\n\n🔶 INGREDIENTI FORTEMENTE CONSIGLIATI (scadono tra 2-3 giorni)\nCerca di includerli se la ricetta lo permette, ma non è obbligatorio se non si abbinano bene:\n" . implode("\n", array_map(fn($n) => "· $n", $recommendedItems));
|
||||
}
|
||||
|
||||
$mealLabels = [
|
||||
@@ -1532,6 +1554,7 @@ REGOLE IMPORTANTI:
|
||||
f) PRODOTTI CHIUSI SENZA SCADENZA: usa per ultimi
|
||||
Costruisci la ricetta partendo dagli ingredienti delle categorie più urgenti! Usa il maggior numero possibile di ingredienti ad alta priorità.
|
||||
*** OBBLIGO: se nella sezione "INGREDIENTI OBBLIGATORI" sopra ci sono prodotti, la ricetta DEVE contenere ALMENO UNO di quei prodotti come ingrediente principale. Se li ignori, la ricetta è SBAGLIATA. ***
|
||||
*** CONSIGLIO: gli "INGREDIENTI FORTEMENTE CONSIGLIATI" vanno usati se si abbinano bene alla ricetta, ma puoi escluderli se non si adattano — spesso sono prodotti freschi come il latte che si usano comunque. ***
|
||||
2. Prediligi una ricetta SANA, EQUILIBRATA e NUTRIENTE
|
||||
3. Usa SOLO ingredienti dalla lista sotto, più al massimo acqua, sale, pepe e olio che si presumono sempre disponibili
|
||||
4. Adatta le quantità per $persons persona/e
|
||||
@@ -2250,6 +2273,34 @@ function bringCleanSpecs(): void {
|
||||
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.
|
||||
*/
|
||||
function smartShoppingCached(PDO $db): void {
|
||||
$cacheFile = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||
$maxAge = 10 * 60; // 10 minutes
|
||||
|
||||
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.
|
||||
|
||||
+44
-13
@@ -4513,17 +4513,48 @@ function estimateItemPrice(product, spec) {
|
||||
// ===== SMART SHOPPING =====
|
||||
let smartShoppingItems = [];
|
||||
let smartShoppingFilter = 'all';
|
||||
let _smartShoppingLastFetch = 0; // timestamp of last successful fetch
|
||||
let _bgShoppingInterval = null; // kept for compatibility, cron handles refresh server-side
|
||||
|
||||
/** Update dashboard badge from already-cached data */
|
||||
function _updateSmartUrgencyBadge() {
|
||||
const urgentEl = document.getElementById('stat-urgent');
|
||||
if (!urgentEl) return;
|
||||
const urgent = smartShoppingItems.filter(i => i.urgency === 'critical' || i.urgency === 'high').length;
|
||||
if (urgent > 0) {
|
||||
urgentEl.textContent = `⚠ ${urgent}`;
|
||||
urgentEl.style.display = '';
|
||||
} else {
|
||||
urgentEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function _renderSmartLastUpdate() {
|
||||
const el = document.getElementById('smart-last-update');
|
||||
if (!el || !_smartShoppingLastFetch) return;
|
||||
const d = new Date(_smartShoppingLastFetch);
|
||||
el.textContent = `Aggiornato ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function startBgShoppingRefresh() {
|
||||
// No-op: server-side cron handles refresh every 5 minutes.
|
||||
// The JS fetches pre-computed cache on demand (instant response).
|
||||
}
|
||||
|
||||
async function loadSmartShopping() {
|
||||
try {
|
||||
const data = await api('smart_shopping');
|
||||
if (data.success && data.items && data.items.length > 0) {
|
||||
smartShoppingItems = data.items;
|
||||
_smartShoppingLastFetch = Date.now();
|
||||
renderSmartShopping();
|
||||
_renderSmartLastUpdate();
|
||||
_updateSmartUrgencyBadge();
|
||||
document.getElementById('smart-shopping-empty').style.display = 'none';
|
||||
document.getElementById('smart-shopping-content').style.display = 'block';
|
||||
} else {
|
||||
smartShoppingItems = [];
|
||||
_smartShoppingLastFetch = Date.now();
|
||||
document.getElementById('smart-shopping-empty').style.display = 'block';
|
||||
document.getElementById('smart-shopping-content').style.display = 'none';
|
||||
}
|
||||
@@ -4713,20 +4744,19 @@ async function loadShoppingCount() {
|
||||
} catch {
|
||||
document.getElementById('stat-spesa').textContent = '-';
|
||||
}
|
||||
// Smart urgency badge
|
||||
try {
|
||||
const smart = await api('smart_shopping');
|
||||
const urgentEl = document.getElementById('stat-urgent');
|
||||
if (smart.success && smart.items) {
|
||||
const urgent = smart.items.filter(i => i.urgency === 'critical' || i.urgency === 'high').length;
|
||||
if (urgent > 0) {
|
||||
urgentEl.textContent = `⚠ ${urgent}`;
|
||||
urgentEl.style.display = '';
|
||||
} else {
|
||||
urgentEl.style.display = 'none';
|
||||
// Smart urgency badge: use cached data if fresh (< 2 min), else fetch
|
||||
if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) {
|
||||
_updateSmartUrgencyBadge();
|
||||
} else {
|
||||
try {
|
||||
const smart = await api('smart_shopping');
|
||||
if (smart.success && smart.items) {
|
||||
smartShoppingItems = smart.items;
|
||||
_smartShoppingLastFetch = Date.now();
|
||||
_updateSmartUrgencyBadge();
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function loadShoppingList() {
|
||||
@@ -7290,6 +7320,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
initInactivityWatcher();
|
||||
initSpesaMode();
|
||||
initScreensaverShortcuts();
|
||||
startBgShoppingRefresh();
|
||||
});
|
||||
|
||||
// ===== DUPLICLICK (SPESA ONLINE) =====
|
||||
|
||||
Reference in New Issue
Block a user