diff --git a/README.md b/README.md index 7fd9da9..451a2e8 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,26 @@ 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). +## 📊 Analityka (MVP) + +W projekcie działa eventowa analityka oparta o MySQL: + +- Endpoint zapisu eventów: `api/analytics.php` (POST JSON). +- Endpoint raportowy pod panel: `api/analytics_reports.php` (GET, parametr `days`). +- Skrypt agregacji dziennej: `scripts/analytics_aggregate_daily.php`. + +Przykładowe uruchomienie agregacji: + +```bash +php scripts/analytics_aggregate_daily.php +php scripts/analytics_aggregate_daily.php 2026-05-28 +``` + +Przykładowe raporty JSON: + +- `api/analytics_reports.php?days=7` +- `api/analytics_reports.php?days=30` + ## 💡 Jak działa mapowanie zamówień? - Aplikacja KDS wyciąga dane łącząc tabele `dbo.NGastroDTRachunek` i `dbo.NGastroDTRachunekPozycja`. diff --git a/api/analytics.php b/api/analytics.php new file mode 100644 index 0000000..831aa27 --- /dev/null +++ b/api/analytics.php @@ -0,0 +1,157 @@ + 'error', + 'message' => 'Method not allowed' + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$rawBody = file_get_contents('php://input'); +$data = json_decode($rawBody, true); +if (!is_array($data)) { + http_response_code(400); + echo json_encode([ + 'status' => 'error', + 'message' => 'Invalid JSON payload' + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$eventName = isset($data['eventName']) ? trim((string)$data['eventName']) : ''; +$sessionId = isset($data['sessionId']) ? trim((string)$data['sessionId']) : ''; +$tableId = isset($data['tableId']) ? trim((string)$data['tableId']) : null; +$zone = isset($data['zone']) ? trim((string)$data['zone']) : null; +$qrHash = isset($data['qrHash']) ? trim((string)$data['qrHash']) : null; +$deviceType = isset($data['deviceType']) ? trim((string)$data['deviceType']) : null; +$browser = isset($data['browser']) ? trim((string)$data['browser']) : null; +$payload = isset($data['payload']) && is_array($data['payload']) ? $data['payload'] : null; + +$allowedEvents = [ + 'qr_scan', + 'session_start', + 'geo_check_started', + 'geo_check_passed', + 'geo_check_failed', + 'geo_bypass_host', + 'waiter_call_requested', + 'view_status', + 'view_menu', + 'menu_search', + 'bill_dialog_opened', + 'bill_request_sent', +]; + +if ($eventName === '' || !in_array($eventName, $allowedEvents, true)) { + http_response_code(422); + echo json_encode([ + 'status' => 'error', + 'message' => 'Invalid eventName' + ], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($sessionId === '' || strlen($sessionId) > 64) { + http_response_code(422); + echo json_encode([ + 'status' => 'error', + 'message' => 'Invalid sessionId' + ], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($qrHash && !$tableId && isset($conn)) { + $resolved = getTableNameByHash($conn, $qrHash); + if ($resolved !== '') { + $tableId = $resolved; + } +} + +if ($tableId !== null && strlen($tableId) > 32) { + $tableId = substr($tableId, 0, 32); +} +if ($zone !== null && strlen($zone) > 16) { + $zone = substr($zone, 0, 16); +} +if ($qrHash !== null && strlen($qrHash) > 128) { + $qrHash = substr($qrHash, 0, 128); +} +if ($deviceType !== null && strlen($deviceType) > 16) { + $deviceType = substr($deviceType, 0, 16); +} +if ($browser !== null && strlen($browser) > 32) { + $browser = substr($browser, 0, 32); +} + +$ip = ''; +if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $parts = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']); + $ip = trim($parts[0]); +} elseif (!empty($_SERVER['REMOTE_ADDR'])) { + $ip = trim((string)$_SERVER['REMOTE_ADDR']); +} +$ipHash = $ip !== '' ? hash('sha256', $ip . '|karczma_analytics_v1') : null; +$payload = is_array($payload) ? $payload : []; +if ($ip !== '' && !isset($payload['ipAddress'])) { + $payload['ipAddress'] = $ip; +} +$payloadJson = $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null; + +try { + $pdo = getAnalyticsPdo(); + $stmt = $pdo->prepare(" + INSERT INTO analytics_events ( + created_at, + event_name, + session_id, + table_id, + zone, + qr_hash, + device_type, + browser, + ip_hash, + payload_json + ) VALUES ( + NOW(3), + :event_name, + :session_id, + :table_id, + :zone, + :qr_hash, + :device_type, + :browser, + :ip_hash, + :payload_json + ) + "); + + $stmt->execute([ + ':event_name' => $eventName, + ':session_id' => $sessionId, + ':table_id' => $tableId, + ':zone' => $zone, + ':qr_hash' => $qrHash, + ':device_type' => $deviceType, + ':browser' => $browser, + ':ip_hash' => $ipHash, + ':payload_json' => $payloadJson, + ]); + + echo json_encode([ + 'status' => 'success' + ], JSON_UNESCAPED_UNICODE); +} catch (Throwable $e) { + // Best-effort analytics: return success=false, but do not crash frontend flow. + http_response_code(200); + echo json_encode([ + 'status' => 'error', + 'message' => 'Analytics insert failed' + ], JSON_UNESCAPED_UNICODE); +} + diff --git a/api/analytics_reports.php b/api/analytics_reports.php new file mode 100644 index 0000000..ab5b9d5 --- /dev/null +++ b/api/analytics_reports.php @@ -0,0 +1,220 @@ + 'error', + 'message' => 'Method not allowed' + ], JSON_UNESCAPED_UNICODE); + exit; +} + +if (!isAdminLoggedIn()) { + http_response_code(401); + echo json_encode([ + 'status' => 'error', + 'message' => 'Unauthorized' + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$range = isset($_GET['days']) ? trim((string)$_GET['days']) : '7'; +$allowedRanges = ['7', '30', '90', 'all', 'this_year', 'last_year']; +if (!in_array($range, $allowedRanges, true)) { + $range = '7'; +} + +$whereWindow = ''; +$baseParams = []; +$now = new DateTimeImmutable('now'); + +switch ($range) { + case 'all': + break; + case 'this_year': + $start = new DateTimeImmutable(date('Y-01-01 00:00:00')); + $end = $start->modify('+1 year'); + $whereWindow = ' AND created_at >= :start_at AND created_at < :end_at'; + $baseParams = [ + ':start_at' => $start->format('Y-m-d H:i:s'), + ':end_at' => $end->format('Y-m-d H:i:s'), + ]; + break; + case 'last_year': + $start = new DateTimeImmutable((date('Y') - 1) . '-01-01 00:00:00'); + $end = $start->modify('+1 year'); + $whereWindow = ' AND created_at >= :start_at AND created_at < :end_at'; + $baseParams = [ + ':start_at' => $start->format('Y-m-d H:i:s'), + ':end_at' => $end->format('Y-m-d H:i:s'), + ]; + break; + default: + $daysInt = (int)$range; + if ($daysInt < 1) $daysInt = 1; + if ($daysInt > 3650) $daysInt = 3650; + $start = $now->modify("-{$daysInt} days"); + $whereWindow = ' AND created_at >= :start_at'; + $baseParams = [ + ':start_at' => $start->format('Y-m-d H:i:s'), + ]; + break; +} + +try { + $pdo = getAnalyticsPdo(); + + $sqlTopTables = " + SELECT + COALESCE(NULLIF(table_id, ''), 'unknown') AS table_id, + SUM(CASE WHEN event_name = 'qr_scan' THEN 1 ELSE 0 END) AS qr_scans, + SUM(CASE WHEN event_name = 'session_start' THEN 1 ELSE 0 END) AS sessions, + SUM(CASE WHEN event_name = 'geo_check_passed' THEN 1 ELSE 0 END) AS geo_pass, + SUM(CASE WHEN event_name = 'geo_check_failed' THEN 1 ELSE 0 END) AS geo_fail + FROM analytics_events + WHERE 1=1 {$whereWindow} + GROUP BY COALESCE(NULLIF(table_id, ''), 'unknown') + ORDER BY qr_scans DESC, sessions DESC + LIMIT 20 + "; + $stmtTopTables = $pdo->prepare($sqlTopTables); + $stmtTopTables->execute($baseParams); + $topTables = $stmtTopTables->fetchAll(); + + $sqlByZone = " + SELECT + COALESCE(NULLIF(zone, ''), 'unknown') AS zone, + SUM(CASE WHEN event_name = 'qr_scan' THEN 1 ELSE 0 END) AS qr_scans, + SUM(CASE WHEN event_name = 'session_start' THEN 1 ELSE 0 END) AS sessions, + SUM(CASE WHEN event_name = 'geo_check_passed' THEN 1 ELSE 0 END) AS geo_pass, + SUM(CASE WHEN event_name = 'geo_check_failed' THEN 1 ELSE 0 END) AS geo_fail + FROM analytics_events + WHERE 1=1 {$whereWindow} + GROUP BY COALESCE(NULLIF(zone, ''), 'unknown') + ORDER BY sessions DESC, qr_scans DESC + "; + $stmtByZone = $pdo->prepare($sqlByZone); + $stmtByZone->execute($baseParams); + $zoneStats = $stmtByZone->fetchAll(); + + $sqlFunnel = " + SELECT + event_name, + COUNT(*) AS total + FROM analytics_events + WHERE 1=1 {$whereWindow} + AND event_name IN ('qr_scan','session_start','view_menu','bill_dialog_opened','bill_request_sent','waiter_call_requested') + GROUP BY event_name + "; + $stmtFunnel = $pdo->prepare($sqlFunnel); + $stmtFunnel->execute($baseParams); + $funnelRows = $stmtFunnel->fetchAll(); + $funnelMap = []; + foreach ($funnelRows as $row) { + $funnelMap[$row['event_name']] = (int)$row['total']; + } + + $sqlGeo = " + SELECT + SUM(CASE WHEN event_name = 'geo_check_passed' THEN 1 ELSE 0 END) AS geo_passed, + SUM(CASE WHEN event_name = 'geo_check_failed' THEN 1 ELSE 0 END) AS geo_failed, + SUM(CASE WHEN event_name = 'geo_bypass_host' THEN 1 ELSE 0 END) AS geo_bypass + FROM analytics_events + WHERE 1=1 {$whereWindow} + "; + $stmtGeo = $pdo->prepare($sqlGeo); + $stmtGeo->execute($baseParams); + $geo = $stmtGeo->fetch() ?: ['geo_passed' => 0, 'geo_failed' => 0, 'geo_bypass' => 0]; + + $sqlRecentOpens = " + SELECT + created_at, + table_id, + zone, + device_type, + browser, + JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$.ipAddress')) AS ip_address + FROM analytics_events + WHERE event_name = 'qr_scan' {$whereWindow} + ORDER BY created_at DESC + LIMIT 50 + "; + $stmtRecentOpens = $pdo->prepare($sqlRecentOpens); + $stmtRecentOpens->execute($baseParams); + $recentOpens = $stmtRecentOpens ? $stmtRecentOpens->fetchAll() : []; + + $sqlQueueSummary = " + SELECT + COUNT(*) AS total_actions, + SUM(CASE WHEN api_sent = 0 THEN 1 ELSE 0 END) AS pending_api, + SUM(CASE WHEN status_kds = 0 THEN 1 ELSE 0 END) AS pending_kds, + SUM(CASE WHEN status_kds = 1 THEN 1 ELSE 0 END) AS done_kds + FROM guest_action_queue + WHERE 1=1 {$whereWindow} + "; + $stmtQueueSummary = $pdo->prepare($sqlQueueSummary); + $stmtQueueSummary->execute($baseParams); + $queueSummary = $stmtQueueSummary->fetch() ?: [ + 'total_actions' => 0, + 'pending_api' => 0, + 'pending_kds' => 0, + 'done_kds' => 0, + ]; + + $sqlQueueItems = " + SELECT + id, + table_id, + message_type, + message_text, + api_sent, + status_kds, + created_at + FROM guest_action_queue + WHERE 1=1 {$whereWindow} + ORDER BY created_at DESC + LIMIT 100 + "; + $stmtQueueItems = $pdo->prepare($sqlQueueItems); + $stmtQueueItems->execute($baseParams); + $queueItems = $stmtQueueItems->fetchAll(); + + echo json_encode([ + 'status' => 'success', + 'days' => $range, + 'topTables' => $topTables, + 'zoneStats' => $zoneStats, + 'funnel' => [ + 'qr_scan' => (int)($funnelMap['qr_scan'] ?? 0), + 'session_start' => (int)($funnelMap['session_start'] ?? 0), + 'view_menu' => (int)($funnelMap['view_menu'] ?? 0), + 'bill_dialog_opened' => (int)($funnelMap['bill_dialog_opened'] ?? 0), + 'bill_request_sent' => (int)($funnelMap['bill_request_sent'] ?? 0), + 'waiter_call_requested' => (int)($funnelMap['waiter_call_requested'] ?? 0), + ], + 'geolocation' => [ + 'passed' => (int)$geo['geo_passed'], + 'failed' => (int)$geo['geo_failed'], + 'bypass' => (int)$geo['geo_bypass'], + ], + 'recentOpens' => $recentOpens, + 'guestQueueSummary' => [ + 'total' => (int)$queueSummary['total_actions'], + 'pendingApi' => (int)$queueSummary['pending_api'], + 'pendingKds' => (int)$queueSummary['pending_kds'], + 'doneKds' => (int)$queueSummary['done_kds'], + ], + 'guestQueue' => $queueItems, + ], JSON_UNESCAPED_UNICODE); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'message' => 'Nie udało się pobrać raportów analitycznych.' + ], JSON_UNESCAPED_UNICODE); +} + diff --git a/api/guest_action_queue.php b/api/guest_action_queue.php new file mode 100644 index 0000000..58ec744 --- /dev/null +++ b/api/guest_action_queue.php @@ -0,0 +1,108 @@ + 'error', + 'message' => 'Method not allowed' + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$rawBody = file_get_contents('php://input'); +$data = json_decode($rawBody, true); +if (!is_array($data)) { + http_response_code(400); + echo json_encode([ + 'status' => 'error', + 'message' => 'Invalid JSON payload' + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$tableId = isset($data['tableId']) ? trim((string)$data['tableId']) : ''; +$messageType = isset($data['messageType']) ? trim((string)$data['messageType']) : ''; +$messageText = isset($data['messageText']) ? trim((string)$data['messageText']) : ''; +$qrHash = isset($data['qrHash']) ? trim((string)$data['qrHash']) : ''; + +$allowedTypes = ['waiter_call', 'bill_request']; +if ($messageType === '' || !in_array($messageType, $allowedTypes, true)) { + http_response_code(422); + echo json_encode([ + 'status' => 'error', + 'message' => 'Invalid messageType' + ], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($tableId === '' && $qrHash !== '' && isset($conn)) { + $resolved = getTableNameByHash($conn, $qrHash); + if ($resolved !== '') { + $tableId = $resolved; + } +} + +if ($tableId === '') { + http_response_code(422); + echo json_encode([ + 'status' => 'error', + 'message' => 'tableId is required' + ], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($messageText === '') { + http_response_code(422); + echo json_encode([ + 'status' => 'error', + 'message' => 'messageText is required' + ], JSON_UNESCAPED_UNICODE); + exit; +} + +if (strlen($tableId) > 32) { + $tableId = substr($tableId, 0, 32); +} + +try { + $pdo = getAnalyticsPdo(); + $stmt = $pdo->prepare(" + INSERT INTO guest_action_queue ( + table_id, + message_type, + message_text, + api_sent, + status_kds, + created_at + ) VALUES ( + :table_id, + :message_type, + :message_text, + 0, + 0, + NOW(3) + ) + "); + + $stmt->execute([ + ':table_id' => $tableId, + ':message_type' => $messageType, + ':message_text' => $messageText, + ]); + + echo json_encode([ + 'status' => 'success', + 'id' => (int)$pdo->lastInsertId() + ], JSON_UNESCAPED_UNICODE); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'message' => 'Queue insert failed' + ], JSON_UNESCAPED_UNICODE); +} + diff --git a/config/database.php b/config/database.php index fd376ac..ec08643 100644 --- a/config/database.php +++ b/config/database.php @@ -18,3 +18,30 @@ if (!$conn) { 'errors' => sqlsrv_errors() ], JSON_UNESCAPED_UNICODE)); } + +// Analytics MySQL (event tracking) +$DB_HOST = '192.168.20.24'; +$DB_NAME = 'karczma_stoliki'; +$DB_USER = 'karczma_stoliki'; +$DB_PASS = 'Reuse-Splicing-Backfire-Bouncing-Operable4'; +$DB_CHARSET = 'utf8mb4'; + +function getAnalyticsPdo() +{ + global $DB_HOST, $DB_NAME, $DB_USER, $DB_PASS, $DB_CHARSET; + static $pdo = null; + + if ($pdo instanceof PDO) { + return $pdo; + } + + $dsn = "mysql:host={$DB_HOST};dbname={$DB_NAME};charset={$DB_CHARSET}"; + $options = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + + $pdo = new PDO($dsn, $DB_USER, $DB_PASS, $options); + return $pdo; +} diff --git a/index.php b/index.php index 4be6d8b..7b07512 100644 --- a/index.php +++ b/index.php @@ -68,8 +68,8 @@

Szybki dostęp do modułów aplikacji Karczma-Stoliki.