diff --git a/README.md b/README.md index 451a2e8..21f26c3 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,53 @@ Z powodów bezpieczeństwa, aby klienci w restauracji nie podglądali zamówień 4. Upewnij się, że folder `api/cache` posiada prawa zapisu (`chmod 777` na Linuksie / Pełna kontrola na Windowsie) by skrypt mógł wygenerować `tables_cache.json`. 5. Dla ułatwienia pracy, na roocie znajduje się `index.php` (Dev Portal), z którego możesz klikać we wszystkie moduły (do usunięcia na produkcji). +## 🍽 Odświeżanie cache menu (CLI) + +Menu dla gości (PL/EN/DE + tłumaczenia AI ES/IT/KO) **nie odświeża się automatycznie**. +Cache leży w `api/cache/menu_*.json` i aktualizujesz go ręcznie z terminala. + +### Wymagania + +1. **PHP CLI** (nie `php-fpm`) — np. `/usr/bin/php8` albo `php` w PATH. +2. Katalog `api/cache` zapisywalny przez użytkownika, który odpala skrypt: + +```bash +# na serwerze Linux (dostosuj użytkownika do FPM / SSH) +chown -R www-data:www-data api/cache +chmod 775 api/cache +``` + +3. Klucz OpenAI w `config/menu_ai.local.php` (plik lokalny z `api_key` i modelem — nie commituj klucza). + +### Komendy + +Z katalogu głównego projektu (`karczma-stoliki/`): + +```bash +# Tylko upstream PL/EN/DE + AI gdy zmienił się fingerprint PL +php scripts/refresh_menu_cache.php + +# Wymuś ponowne tłumaczenie ES/IT/KO przez OpenAI (zużywa tokeny) +php scripts/refresh_menu_cache.php --force-ai +``` + +Na produkcji, jeśli `php` wskazuje na FPM albo złą wersję: + +```bash +cd /var/www/html/public/app # ścieżka do projektu na serwerze +/usr/bin/php8 scripts/refresh_menu_cache.php --force-ai +``` + +Skrypt wypisuje log linia po linii. Exit `0` = sukces, `1` = błąd (np. brak praw zapisu, błąd AI). + +### Co robi skrypt? + +1. Pobiera menu z upstreamu Karczmy (PL/EN/DE) → `api/cache/menu_pl.json` itd. +2. Dla ES/IT/KO tłumaczy zmienione pozycje z PL przez OpenAI (`gpt-4o` wg konfiguracji). +3. Zapisuje fingerprint PL, żeby kolejne uruchomienia bez `--force-ai` pomijały AI, gdy menu się nie zmieniło. + +--- + ## 📊 Analityka (MVP) W projekcie działa eventowa analityka oparta o MySQL: diff --git a/api/menu.php b/api/menu.php index 16acfa1..af61ddc 100644 --- a/api/menu.php +++ b/api/menu.php @@ -31,30 +31,18 @@ if ($lang === '' || !isset($languages[$lang])) { $langConfig = $languages[$lang]; $source = (string) ($langConfig['source'] ?? 'upstream'); -$ttl = max(60, (int) ($config['ttl_seconds'] ?? 86400)); $timeout = max(3, (int) ($config['timeout_seconds'] ?? 12)); $cacheFile = menuCachePath($lang); $cached = readMenuCache($cacheFile); -$now = time(); -$cachedAtTs = 0; -if ($cached && !empty($cached['cachedAt'])) { - $cachedAtTs = strtotime((string) $cached['cachedAt']) ?: 0; -} -$isFresh = $cached && $cachedAtTs > 0 && ($now - $cachedAtTs) < $ttl; -if ($isFresh) { +// Cache odświeżamy tylko ręcznie (panel admina / skrypt CLI) — nie fetchujemy upstreamu przy każdym TTL. +if ($cached) { echo json_encode($cached, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } if ($source === 'ai') { - if ($cached) { - // Stary AI-cache jest OK dłużej niż TTL — cron i tak odświeża raz/dzień. - echo json_encode($cached, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - exit; - } - $from = strtolower((string) ($langConfig['from'] ?? 'pl')); $fallback = readMenuCache(menuCachePath($from)); if ($fallback) { @@ -67,7 +55,7 @@ if ($source === 'ai') { http_response_code(503); echo json_encode([ 'status' => 'error', - 'message' => 'AI menu not available yet — run scripts/refresh_menu_cache.php', + 'message' => 'AI menu not available yet — uruchom: php scripts/refresh_menu_cache.php --force-ai', 'lang' => $lang, ], JSON_UNESCAPED_UNICODE); exit; @@ -78,20 +66,20 @@ $url = $base . '/' . rawurlencode($lang); $raw = fetchUpstreamMenu($url, $timeout); if ($raw === null) { - if ($cached) { - echo json_encode($cached, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - exit; - } http_response_code(502); echo json_encode([ 'status' => 'error', - 'message' => 'Failed to fetch menu', + 'message' => 'Failed to fetch menu (brak cache — uruchom odświeżenie w panelu)', 'lang' => $lang, ], JSON_UNESCAPED_UNICODE); exit; } $mapped = mapUpstreamMenu($raw, $lang, 'upstream'); -writeMenuCache($cacheFile, $mapped); +try { + writeMenuCache($cacheFile, $mapped); +} catch (Throwable $e) { + // Serwuj świeże dane nawet gdy cache nie da się zapisać +} echo json_encode($mapped, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); diff --git a/api/menu_helpers.php b/api/menu_helpers.php index 4793f93..0873db4 100644 --- a/api/menu_helpers.php +++ b/api/menu_helpers.php @@ -6,6 +6,21 @@ declare(strict_types=1); * Wspólne funkcje cache/upstream menu (api/menu.php + scripts/refresh_menu_cache.php). */ +if (!function_exists('array_is_list')) { + /** Polyfill PHP < 8.1 */ + function array_is_list(array $array): bool + { + $i = 0; + foreach ($array as $k => $_) { + if ($k !== $i++) { + return false; + } + } + + return true; + } +} + function menuCacheDir(): string { $dir = __DIR__ . '/cache'; @@ -16,6 +31,20 @@ function menuCacheDir(): string return $dir; } +function assertMenuCacheWritable(): void +{ + $dir = menuCacheDir(); + if (!is_dir($dir) || !is_writable($dir)) { + $user = function_exists('posix_geteuid') + ? ((string) (@posix_getpwuid(posix_geteuid())['name'] ?? posix_geteuid())) + : get_current_user(); + throw new RuntimeException( + "Katalog cache nie jest zapisywalny: {$dir} (proces jako: {$user}). " + . 'Na serwerze: chown -R www-data:www-data api/cache && chmod 775 api/cache' + ); + } +} + function menuCachePath(string $lang): string { $safe = preg_replace('/[^a-z0-9_-]/i', '', strtolower($lang)) ?: 'pl'; @@ -46,11 +75,14 @@ function readMenuCache(string $cacheFile): ?array function writeMenuCache(string $cacheFile, array $payload): void { - file_put_contents( - $cacheFile, - json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - LOCK_EX - ); + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Nie udało się zserializować cache: ' . $cacheFile); + } + $ok = @file_put_contents($cacheFile, $json, LOCK_EX); + if ($ok === false) { + throw new RuntimeException('Permission denied przy zapisie: ' . $cacheFile); + } } function fetchUpstreamMenu(string $url, int $timeout): ?array diff --git a/config/menu.php b/config/menu.php index 95b50a6..a39f13f 100644 --- a/config/menu.php +++ b/config/menu.php @@ -10,6 +10,7 @@ declare(strict_types=1); */ return [ 'upstream_base' => 'https://www.karczmabiesiada.eu/restaurant_menu/export', + // Nieużywane do auto-odświeżania — cache odświeża się tylko ręcznie (panel / CLI). 'ttl_seconds' => 86400, 'timeout_seconds' => 12, 'languages' => [ diff --git a/public/staff/index.php b/public/staff/index.php index 5c51cfd..0bc1c91 100644 --- a/public/staff/index.php +++ b/public/staff/index.php @@ -15,17 +15,60 @@ requireAdminAuth(true); body { margin: 0; font-family: Inter, Arial, sans-serif; background: var(--bg); color: var(--text); } .wrap { width: min(1200px, 94vw); margin: 24px auto; } .top { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 16px; } - .title { font-size: 1.5rem; font-weight: 800; } - .btn { display: inline-block; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; color: var(--text); text-decoration: none; background: #0b1220; } + .title { font-size: 1.5rem; font-weight: 800; color: var(--text); } + .btn { + display: inline-block; + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px 12px; + color: var(--text); + text-decoration: none; + background: #0b1220; + cursor: pointer; + font: inherit; + font-size: 0.9rem; + line-height: 1.2; + } + .btn:hover { filter: brightness(1.08); } .btn.primary { background: var(--accent); border-color: var(--accent); color: white; } + .admin-tooltip { + position: relative; + } + .admin-tooltip::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + top: calc(100% + 8px); + transform: translateX(-50%); + background: #020617; + color: #e2e8f0; + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 10px; + font-size: 12px; + font-weight: 500; + line-height: 1.35; + white-space: normal; + width: max-content; + max-width: 260px; + z-index: 50; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; + box-shadow: 0 8px 24px rgba(0,0,0,0.35); + } + .admin-tooltip:hover::after, + .admin-tooltip:focus-visible::after { + opacity: 1; + } .grid { display: grid; gap: 14px; grid-template-columns: repeat(12,1fr); } - .card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px; } + .card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px; color: var(--text); } .card h3 { margin: 0 0 8px; font-size: 1rem; color: #cbd5e1; } - .kpi { font-size: 1.8rem; font-weight: 800; margin-top: 4px; } + .kpi { font-size: 1.8rem; font-weight: 800; margin-top: 4px; color: var(--text); } .muted { color: var(--muted); font-size: .88rem; } .col-3 { grid-column: span 3; } .col-4 { grid-column: span 4; } .col-6 { grid-column: span 6; } .col-8 { grid-column: span 8; } .col-12 { grid-column: span 12; } - table { width: 100%; border-collapse: collapse; } - th, td { border-bottom: 1px solid #263446; padding: 8px 6px; text-align: left; font-size: .9rem; } + table { width: 100%; border-collapse: collapse; color: var(--text); } + th, td { border-bottom: 1px solid #263446; padding: 8px 6px; text-align: left; font-size: .9rem; color: var(--text); } th { color: #cbd5e1; } #recentOpensBody tr.recent-open-row { border-left: 4px solid transparent; @@ -79,10 +122,10 @@ requireAdminAuth(true);
Panel Admina
KDS, generator QR i analityka operacyjna.
-
- Otwórz KDS - Generator QR - Wyloguj +
+ Otwórz KDS + Generator QR + Wyloguj
diff --git a/scripts/refresh_menu_cache.php b/scripts/refresh_menu_cache.php index fdb7371..aade3db 100644 --- a/scripts/refresh_menu_cache.php +++ b/scripts/refresh_menu_cache.php @@ -3,25 +3,24 @@ declare(strict_types=1); /** - * Odświeża cache menu raz na dobę (cron) lub ręcznie. + * Odświeża cache menu ręcznie (tylko CLI / terminal). * * 1) Upstream PL/EN/DE → api/cache/menu_{lang}.json * 2) Języki source=ai (ES/IT/KO): tłumaczenie OpenAI ChatGPT z PL - * tylko gdy fingerprint PL się zmienił (lub brak cache AI). + * (z --force-ai zawsze; bez flagi tylko gdy zmienił się fingerprint PL) * * Użycie: * php scripts/refresh_menu_cache.php * php scripts/refresh_menu_cache.php --force-ai * - * Cron (raz dziennie, np. 04:15): - * 15 4 * * * php /path/to/scripts/refresh_menu_cache.php >> /var/log/menu_refresh.log 2>&1 - * - * Klucz OpenAI: skopiuj config/menu_ai.local.php.example → config/menu_ai.local.php + * Klucz OpenAI: config/menu_ai.local.php + * Instrukcja: README.md → „Odświeżanie cache menu” */ require_once __DIR__ . '/../api/menu_helpers.php'; $forceAi = in_array('--force-ai', $argv ?? [], true); +$hadError = false; $config = require __DIR__ . '/../config/menu.php'; $languages = is_array($config['languages'] ?? null) ? $config['languages'] : []; @@ -33,6 +32,13 @@ function logLine(string $msg): void echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL; } +try { + assertMenuCacheWritable(); +} catch (Throwable $e) { + logLine('ERROR: ' . $e->getMessage()); + exit(1); +} + function targetLanguageName(string $code): string { return match ($code) { @@ -325,11 +331,17 @@ foreach ($languages as $code => $meta) { $raw = fetchUpstreamMenu($url, $timeout); if ($raw === null) { logLine("Upstream {$code}: FAILED — keeping old cache if any"); + $hadError = true; continue; } - $mapped = mapUpstreamMenu($raw, $code, 'upstream'); - writeMenuCache(menuCachePath($code), $mapped); - logLine("Upstream {$code}: OK (" . count($mapped['sections'] ?? []) . ' sections)'); + try { + $mapped = mapUpstreamMenu($raw, $code, 'upstream'); + writeMenuCache(menuCachePath($code), $mapped); + logLine("Upstream {$code}: OK (" . count($mapped['sections'] ?? []) . ' sections)'); + } catch (Throwable $e) { + logLine("Upstream {$code}: ERROR " . $e->getMessage()); + $hadError = true; + } } $plMenu = readMenuCache(menuCachePath('pl')); @@ -343,13 +355,17 @@ $fpFile = menuFingerprintPath(); $prevFp = is_file($fpFile) ? trim((string) file_get_contents($fpFile)) : ''; $plChanged = $forceAi || $prevFp === '' || !hash_equals($prevFp, $fp); -file_put_contents($fpFile, $fp, LOCK_EX); -logLine('PL fingerprint: ' . substr($fp, 0, 12) . '… changed=' . ($plChanged ? 'yes' : 'no')); +if (@file_put_contents($fpFile, $fp, LOCK_EX) === false) { + logLine('ERROR: nie zapisano fingerprint: ' . $fpFile); + $hadError = true; +} else { + logLine('PL fingerprint: ' . substr($fp, 0, 12) . '… changed=' . ($plChanged ? 'yes' : 'no')); +} $aiCfg = loadMenuAiConfig(); if ($aiCfg === null) { - logLine('No config/menu_ai.local.php — skip AI languages (copy from .example)'); - exit(0); + logLine('No config/menu_ai.local.php — skip AI languages'); + exit($hadError ? 1 : 0); } foreach ($languages as $code => $meta) { @@ -371,7 +387,9 @@ foreach ($languages as $code => $meta) { logLine("AI {$code}: wrote cache"); } catch (Throwable $e) { logLine('AI ' . $code . ': ERROR ' . $e->getMessage()); + $hadError = true; } } logLine('Done.'); +exit($hadError ? 1 : 0);