Files
EverShelf/api/cron_smart_shopping.php
T
dadaloop82 34dcb05c05 Improve scan flow, AI UX, expiry history, and shopping sync.
Manual AI identification replaces auto-fallback; add duplicate-add guard,
AI product match UI, ZBar/Tesseract offline scanning, expiry averages from
last 3 insertions, family sibling hints, and missing i18n keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 09:38:05 +00:00

276 lines
14 KiB
PHP

<?php
/**
* Cron: pre-compute smart shopping list and save to cache.
* Install with: crontab -e
* *\/5 * * * * php /var/www/html/evershelf/api/cron_smart_shopping.php >> /var/www/html/evershelf/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 bootstrap so the HTTP router is skipped
define('CRON_MODE', true);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/index.php';
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
evershelfRotateCronLog();
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);
}
$itemCount = count($decoded['items'] ?? []);
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
// ── Bring! server-side sync ───────────────────────────────────────────
// After computing smart shopping, remove stale Bring! items and push every
// product that needs restocking (esauriti, quasi finiti, previsione).
// Runs fully server-side every cron cycle (~5 min).
try {
$cleanupResult = bringCleanupObsolete($db);
if (isset($cleanupResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup skipped: ' . $cleanupResult['skipped'] . "\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup — removed: ' . ($cleanupResult['removed'] ?? 0)
. '/' . ($cleanupResult['candidates'] ?? 0) . ' candidates'
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n";
}
$dedupeResult = bringDedupeGenerics($db);
if (isset($dedupeResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe skipped: ' . $dedupeResult['skipped'] . "\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe — removed: ' . ($dedupeResult['removed'] ?? 0)
. ', merged specs: ' . ($dedupeResult['merged'] ?? 0) . "\n";
}
$specsResult = bringSyncSpecs($db);
if (isset($specsResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! specs skipped: ' . $specsResult['skipped'] . "\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Bring! specs — updated: ' . ($specsResult['updated'] ?? 0) . "\n";
}
$addResult = bringAutoAddCritical($db);
if (isset($addResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n";
}
$dedupeFinal = bringDedupeGenerics($db);
if (!isset($dedupeFinal['skipped']) && (($dedupeFinal['removed'] ?? 0) > 0)) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe (final) — removed: ' . ($dedupeFinal['removed'] ?? 0) . "\n";
}
} catch (Throwable $be) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
}
// ── Shelf life pre-warming ────────────────────────────────────────────
// Pre-warm the opened shelf life cache for opened items not yet cached.
// Capped at 5 items per cron cycle to avoid Gemini rate limits.
try {
$prewarmResult = prewarmShelfLifeCache($db, 5);
if ($prewarmResult['warmed'] > 0) {
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm — warmed: ' . $prewarmResult['warmed']
. ', skipped: ' . $prewarmResult['skipped'] . "\n";
}
} catch (Throwable $pe) {
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm warning: ' . $pe->getMessage() . "\n";
}
// ── DB cleanup (retention policy) ────────────────────────────────────
// Delete old recipes and transactions based on .env retention settings.
try {
ob_start();
dbCleanup($db);
ob_end_clean();
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done'
. ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd'
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','90') . 'd' . ")\n";
} catch (Throwable $ce) {
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
}
// ── Daily incremental backup ──────────────────────────────────────────
// Create a local backup at most once every 23 h; also push to Google Drive
// if GDRIVE_ENABLED=true. The guard prevents multiple backups per day even
// though the cron runs every 5 minutes.
if (env('BACKUP_ENABLED', 'true') === 'true') {
try {
$lastBackupTs = 0;
if (file_exists(BACKUP_LAST_TS_PATH)) {
$lastData = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: [];
$lastBackupTs = (int)($lastData['ts'] ?? 0);
}
if (time() - $lastBackupTs >= 82800) { // 23 h
$backupResult = createLocalBackup($db);
if ($backupResult['success']) {
echo '[' . date('Y-m-d H:i:s') . '] Backup local: ' . $backupResult['filename']
. ' (' . $backupResult['size_kb'] . 'KB, purged ' . $backupResult['purged'] . " old)\n";
if (env('GDRIVE_ENABLED', 'false') === 'true') {
$gResult = backupToGDrive($db);
if ($gResult['success']) {
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive: OK'
. ' (purged remote: ' . ($gResult['purged_remote'] ?? 0) . ")\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive warning: ' . ($gResult['error'] ?? 'unknown') . "\n";
}
}
} else {
echo '[' . date('Y-m-d H:i:s') . '] Backup warning: ' . ($backupResult['error'] ?? 'unknown') . "\n";
}
}
} catch (Throwable $be) {
echo '[' . date('Y-m-d H:i:s') . '] Backup error: ' . $be->getMessage() . "\n";
}
}
} catch (Throwable $e) {
$msg = $e->getMessage();
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
// Report to GitHub Issues (uses the same _phpErrorReport from index.php)
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
exit(1);
}
// ── Home Assistant: expiry alerts ─────────────────────────────────────────────
// Fire one HA webhook per expiring item (once per day guard via a simple flag file).
if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
try {
$haFlagFile = __DIR__ . '/../data/ha_expiry_notified_' . date('Y-m-d') . '.json';
if (!file_exists($haFlagFile)) {
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
$expiringItems = $db->query(
"SELECT p.id AS product_id, i.id AS inventory_id,
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
FROM inventory i JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
ORDER BY i.expiry_date ASC LIMIT 20"
)->fetchAll(PDO::FETCH_ASSOC);
$expiredItems = $db->query(
"SELECT p.id AS product_id, i.id AS inventory_id,
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
FROM inventory i JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
AND i.expiry_date < date('now')
ORDER BY i.expiry_date ASC LIMIT 10"
)->fetchAll(PDO::FETCH_ASSOC);
// Normalise rows to full product format
if (!function_exists('_haFormatProduct')) {
function _haFormatProduct(array $row): array {
$daysRemaining = null;
if (!empty($row['expiry_date'])) {
$diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date']));
$daysRemaining = (int)$diff->format('%r%a');
}
return [
'product_id' => (int)($row['product_id'] ?? 0),
'inventory_id' => (int)($row['inventory_id'] ?? 0),
'name' => $row['name'],
'brand' => $row['brand'] ?? null,
'category' => $row['category'] ?? null,
'quantity' => (float)($row['quantity'] ?? 0),
'unit' => $row['unit'] ?? '',
'default_quantity' => (float)($row['default_quantity'] ?? 0),
'package_unit' => $row['package_unit'] ?? null,
'location' => $row['location'] ?? null,
'expiry_date' => $row['expiry_date'] ?? null,
'days_remaining' => $daysRemaining,
'opened_at' => $row['opened_at'] ?? null,
'vacuum_sealed' => !empty($row['vacuum_sealed']),
];
}
}
$expiringItems = array_map('_haFormatProduct', $expiringItems);
$expiredItems = array_map('_haFormatProduct', $expiredItems);
if (!empty($expiringItems)) {
$names = implode(', ', array_column($expiringItems, 'name'));
_fireHaWebhook('expiry_alert', [
'count' => count($expiringItems),
'items' => $expiringItems,
'type' => 'expiring_soon',
'days' => $expiryDays,
'summary' => $names,
]);
// Also send HA notification if service configured
if (env('HA_NOTIFY_SERVICE', '') !== '') {
$msg = count($expiringItems) . ' product(s) expiring within ' . $expiryDays . ' days: ' . $names;
_sendHaNotify($msg, ['expiring_items' => $expiringItems]);
}
echo '[' . date('Y-m-d H:i:s') . '] HA expiry_alert fired: ' . count($expiringItems) . " items\n";
}
if (!empty($expiredItems)) {
$expNames = implode(', ', array_column($expiredItems, 'name'));
_fireHaWebhook('expiry_alert', [
'count' => count($expiredItems),
'items' => $expiredItems,
'type' => 'expired',
'summary' => $expNames,
]);
echo '[' . date('Y-m-d H:i:s') . '] HA expired fired: ' . count($expiredItems) . " items\n";
}
// Mark as done for today
file_put_contents($haFlagFile, json_encode(['ts' => time(), 'expiring' => count($expiringItems ?? []), 'expired' => count($expiredItems ?? [])]));
// Clean up old flag files (keep last 7 days)
foreach (glob(__DIR__ . '/../data/ha_expiry_notified_*.json') as $oldFlag) {
$flagDate = str_replace([__DIR__ . '/../data/ha_expiry_notified_', '.json'], '', $oldFlag);
if ($flagDate < date('Y-m-d', strtotime('-7 days'))) @unlink($oldFlag);
}
}
} catch (Throwable $haE) {
echo '[' . date('Y-m-d H:i:s') . '] HA expiry hook warning: ' . $haE->getMessage() . "\n";
}
}
// ── Avahi/mDNS discovery registration ─────────────────────────────────────────
// If avahi-daemon is running on this host, register the _evershelf._tcp service
// so that Home Assistant can auto-discover this instance via Zeroconf.
if (function_exists('shell_exec')) {
try {
$avahiService = '/etc/avahi/services/evershelf.xml';
// Only create/update if avahi-daemon is installed and the file doesn't exist yet
if (!file_exists($avahiService) && (shell_exec('which avahi-daemon 2>/dev/null') || shell_exec('which avahi-publish 2>/dev/null'))) {
$template = __DIR__ . '/../docker/avahi-evershelf.xml';
if (file_exists($template)) {
$xml = file_get_contents($template);
@file_put_contents($avahiService, $xml);
echo '[' . date('Y-m-d H:i:s') . '] Avahi mDNS service registered at ' . $avahiService . "\n";
}
}
} catch (Throwable $avahiE) {
// Non-fatal: avahi not available
}
}