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
|
* 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('Content-Type: application/json; charset=utf-8');
|
||||||
header('Access-Control-Allow-Origin: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
@@ -14,8 +20,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/database.php';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = getDB();
|
$db = getDB();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -27,6 +31,9 @@ try {
|
|||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
$action = $_GET['action'] ?? '';
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
} // end !CRON_MODE block for router bootstrap
|
||||||
|
|
||||||
|
if (!defined('CRON_MODE')):
|
||||||
try {
|
try {
|
||||||
switch ($action) {
|
switch ($action) {
|
||||||
// ===== PRODUCTS =====
|
// ===== PRODUCTS =====
|
||||||
@@ -116,7 +123,7 @@ try {
|
|||||||
bringSuggestItems($db);
|
bringSuggestItems($db);
|
||||||
break;
|
break;
|
||||||
case 'smart_shopping':
|
case 'smart_shopping':
|
||||||
smartShopping($db);
|
smartShoppingCached($db);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'save_settings':
|
case 'save_settings':
|
||||||
@@ -192,6 +199,7 @@ try {
|
|||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['error' => $e->getMessage()]);
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
|
endif; // end !CRON_MODE
|
||||||
|
|
||||||
// ===== CLIENT LOG =====
|
// ===== CLIENT LOG =====
|
||||||
|
|
||||||
@@ -1426,17 +1434,31 @@ function generateRecipe(PDO $db): void {
|
|||||||
}
|
}
|
||||||
$ingredientsText = implode("\n\n", $ingredientSections);
|
$ingredientsText = implode("\n\n", $ingredientSections);
|
||||||
|
|
||||||
// Build a mandatory-use list from the most urgent items (groups 1 + 2)
|
// Build mandatory/recommended lists:
|
||||||
$urgentItems = [];
|
// - 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) {
|
foreach ($items as $item) {
|
||||||
$g = $getItemPriority($item);
|
$g = $getItemPriority($item);
|
||||||
if ($g <= 2) {
|
$daysLeft = floatval($item['days_left']);
|
||||||
$urgentItems[] = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . " — scade: {$item['expiry_date']}";
|
$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 = '';
|
$mustUseText = '';
|
||||||
if (!empty($urgentItems)) {
|
if (!empty($mandatoryItems)) {
|
||||||
$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));
|
$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 = [
|
$mealLabels = [
|
||||||
@@ -1532,6 +1554,7 @@ REGOLE IMPORTANTI:
|
|||||||
f) PRODOTTI CHIUSI SENZA SCADENZA: usa per ultimi
|
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à.
|
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. ***
|
*** 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
|
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
|
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
|
4. Adatta le quantità per $persons persona/e
|
||||||
@@ -2250,6 +2273,34 @@ function bringCleanSpecs(): void {
|
|||||||
echo json_encode(['success' => true, 'cleaned' => $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.
|
||||||
|
*/
|
||||||
|
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
|
* Smart Shopping List: analyzes usage frequency, stock levels, expiry to produce
|
||||||
* intelligent urgency-ranked shopping recommendations.
|
* intelligent urgency-ranked shopping recommendations.
|
||||||
|
|||||||
+44
-13
@@ -4513,17 +4513,48 @@ function estimateItemPrice(product, spec) {
|
|||||||
// ===== SMART SHOPPING =====
|
// ===== SMART SHOPPING =====
|
||||||
let smartShoppingItems = [];
|
let smartShoppingItems = [];
|
||||||
let smartShoppingFilter = 'all';
|
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() {
|
async function loadSmartShopping() {
|
||||||
try {
|
try {
|
||||||
const data = await api('smart_shopping');
|
const data = await api('smart_shopping');
|
||||||
if (data.success && data.items && data.items.length > 0) {
|
if (data.success && data.items && data.items.length > 0) {
|
||||||
smartShoppingItems = data.items;
|
smartShoppingItems = data.items;
|
||||||
|
_smartShoppingLastFetch = Date.now();
|
||||||
renderSmartShopping();
|
renderSmartShopping();
|
||||||
|
_renderSmartLastUpdate();
|
||||||
|
_updateSmartUrgencyBadge();
|
||||||
document.getElementById('smart-shopping-empty').style.display = 'none';
|
document.getElementById('smart-shopping-empty').style.display = 'none';
|
||||||
document.getElementById('smart-shopping-content').style.display = 'block';
|
document.getElementById('smart-shopping-content').style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
smartShoppingItems = [];
|
smartShoppingItems = [];
|
||||||
|
_smartShoppingLastFetch = Date.now();
|
||||||
document.getElementById('smart-shopping-empty').style.display = 'block';
|
document.getElementById('smart-shopping-empty').style.display = 'block';
|
||||||
document.getElementById('smart-shopping-content').style.display = 'none';
|
document.getElementById('smart-shopping-content').style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -4713,20 +4744,19 @@ async function loadShoppingCount() {
|
|||||||
} catch {
|
} catch {
|
||||||
document.getElementById('stat-spesa').textContent = '-';
|
document.getElementById('stat-spesa').textContent = '-';
|
||||||
}
|
}
|
||||||
// Smart urgency badge
|
// Smart urgency badge: use cached data if fresh (< 2 min), else fetch
|
||||||
try {
|
if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) {
|
||||||
const smart = await api('smart_shopping');
|
_updateSmartUrgencyBadge();
|
||||||
const urgentEl = document.getElementById('stat-urgent');
|
} else {
|
||||||
if (smart.success && smart.items) {
|
try {
|
||||||
const urgent = smart.items.filter(i => i.urgency === 'critical' || i.urgency === 'high').length;
|
const smart = await api('smart_shopping');
|
||||||
if (urgent > 0) {
|
if (smart.success && smart.items) {
|
||||||
urgentEl.textContent = `⚠ ${urgent}`;
|
smartShoppingItems = smart.items;
|
||||||
urgentEl.style.display = '';
|
_smartShoppingLastFetch = Date.now();
|
||||||
} else {
|
_updateSmartUrgencyBadge();
|
||||||
urgentEl.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
}
|
} catch { /* ignore */ }
|
||||||
} catch { /* ignore */ }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadShoppingList() {
|
async function loadShoppingList() {
|
||||||
@@ -7290,6 +7320,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initInactivityWatcher();
|
initInactivityWatcher();
|
||||||
initSpesaMode();
|
initSpesaMode();
|
||||||
initScreensaverShortcuts();
|
initScreensaverShortcuts();
|
||||||
|
startBgShoppingRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== DUPLICLICK (SPESA ONLINE) =====
|
// ===== DUPLICLICK (SPESA ONLINE) =====
|
||||||
|
|||||||
Reference in New Issue
Block a user