34dcb05c05
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>
276 lines
14 KiB
PHP
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
|
|
}
|
|
}
|