From 19926b30e36ff932d571825c99b6cfae63bcd26f Mon Sep 17 00:00:00 2001 From: BTekno Dev Date: Thu, 1 Jan 2026 23:38:13 +0700 Subject: [PATCH] Fix: Data inconsistency pada transisi tahun/bulan dan setup API lokal - Implementasi fallback mechanism untuk daily_summary (threshold 5%) - Auto-detect base path untuk subdirectory installation - Perbaikan query dengan CAST(? AS DATE) untuk semua tanggal - Script utilities: check_daily_summary.php dan check_and_fix_hourly_summary.php - Setup .htaccess untuk routing Slim Framework - Test script untuk verifikasi API lokal - Dokumentasi SETUP_LOCAL_API.md --- SETUP_LOCAL_API.md | 142 ++++++++++++ bin/check_and_fix_hourly_summary.php | 124 ++++++++++ bin/check_daily_summary.php | 107 +++++++++ public/.htaccess | 22 ++ public/test.php | 62 +++++ src/Bootstrap/AppBootstrap.php | 17 ++ .../Retribusi/Dashboard/DashboardService.php | 214 ++++++++++++++---- .../Summary/HourlySummaryService.php | 59 ++++- 8 files changed, 704 insertions(+), 43 deletions(-) create mode 100644 SETUP_LOCAL_API.md create mode 100644 bin/check_and_fix_hourly_summary.php create mode 100644 bin/check_daily_summary.php create mode 100644 public/.htaccess create mode 100644 public/test.php diff --git a/SETUP_LOCAL_API.md b/SETUP_LOCAL_API.md new file mode 100644 index 0000000..b0dc0ce --- /dev/null +++ b/SETUP_LOCAL_API.md @@ -0,0 +1,142 @@ +# Setup API Lokal untuk Testing + +## Struktur Folder + +``` +C:\laragon\www\ +├── api-btekno\ # Backend API +│ └── public\ # Document root +│ ├── index.php # Entry point +│ └── .htaccess # URL rewrite +└── Retribusi\ # Frontend + └── public\ # Document root frontend +``` + +## Cara Akses API Lokal + +### Opsi 1: Menggunakan Path Langsung (Laragon/XAMPP) + +Jika menggunakan Laragon/XAMPP dengan struktur folder di atas: + +**Base URL:** `http://localhost/api-btekno/public` + +**Contoh endpoint:** +- Health: `http://localhost/api-btekno/public/health` +- Login: `http://localhost/api-btekno/public/auth/v1/login` +- Dashboard Summary: `http://localhost/api-btekno/public/retribusi/v1/dashboard/summary?date=2026-01-01` + +### Opsi 2: Setup Virtual Host (Recommended) + +Buat virtual host di Laragon untuk akses yang lebih clean: + +1. Buka Laragon → Menu → Apache → Sites-enabled +2. Buat file baru: `api-retribusi.test.conf` +3. Isi dengan: + +```apache + + ServerName api-retribusi.test + DocumentRoot "C:/laragon/www/api-btekno/public" + + + Options Indexes FollowSymLinks + AllowOverride All + Require all granted + + +``` + +4. Restart Apache di Laragon +5. Edit `C:\Windows\System32\drivers\etc\hosts` (run as Administrator): + ``` + 127.0.0.1 api-retribusi.test + ``` + +6. Akses: `http://api-retribusi.test/health` + +**Base URL untuk config.js:** `http://api-retribusi.test` + +## Test API Lokal + +### 1. Test Health Endpoint + +```bash +# Via browser +http://localhost/api-btekno/public/health + +# Via curl +curl http://localhost/api-btekno/public/health + +# Expected response: +# {"status":"ok","time":1767284697} +``` + +### 2. Test Login + +```bash +curl -X POST http://localhost/api-btekno/public/auth/v1/login \ + -H "Content-Type: application/json" \ + -H "X-API-KEY: POKOKEIKISEKOYOLO" \ + -d '{"username":"admin","password":"password"}' +``` + +### 3. Test Dashboard Summary + +```bash +curl http://localhost/api-btekno/public/retribusi/v1/dashboard/summary?date=2026-01-01 \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "X-API-KEY: POKOKEIKISEKOYOLO" +``` + +## Troubleshooting + +### Error: 404 Not Found + +**Kemungkinan penyebab:** +1. `.htaccess` belum ada di folder `public/` +2. `mod_rewrite` belum aktif di Apache +3. Path salah + +**Solusi:** +1. Pastikan file `api-btekno/public/.htaccess` ada +2. Aktifkan `mod_rewrite` di Laragon: + - Menu → Apache → Modules → Centang `rewrite_module` + - Restart Apache +3. Cek path: `http://localhost/api-btekno/public/health` (dengan `/public`) + +### Error: 500 Internal Server Error + +**Kemungkinan penyebab:** +1. Database connection error +2. `.env` file tidak ada atau salah konfigurasi +3. PHP version tidak sesuai + +**Solusi:** +1. Cek file `api-btekno/.env` ada dan konfigurasi database benar +2. Cek PHP version: `php -v` (harus >= 8.2.0) +3. Cek error log di Laragon + +### Error: CORS + +**Kemungkinan penyebab:** +1. CORS middleware belum aktif +2. Origin tidak di-allow + +**Solusi:** +1. Pastikan `CorsMiddleware` sudah di-register di `index.php` +2. Cek konfigurasi CORS di `src/Middleware/CorsMiddleware.php` + +## Update Frontend Config + +Setelah API lokal bisa diakses, update `Retribusi/public/dashboard/js/config.js`: + +```javascript +// Force local mode +const FORCE_LOCAL_MODE = true; + +// Atau auto-detect (akan otomatis detect localhost) +const FORCE_LOCAL_MODE = false; +``` + +Base URL akan otomatis: `http://localhost/api-btekno/public` + diff --git a/bin/check_and_fix_hourly_summary.php b/bin/check_and_fix_hourly_summary.php new file mode 100644 index 0000000..9ecba4e --- /dev/null +++ b/bin/check_and_fix_hourly_summary.php @@ -0,0 +1,124 @@ +#!/usr/bin/env php +format('Y-m-d') !== $dateInput) { + echo "Error: Invalid date format. Expected Y-m-d (e.g., 2026-01-01)\n"; + exit(1); + } + $dates = [$dateInput]; +} else { + // Get all unique dates from hourly_summary + $stmt = $db->query("SELECT DISTINCT summary_date FROM hourly_summary ORDER BY summary_date DESC LIMIT 30"); + $dates = []; + foreach ($stmt->fetchAll() as $row) { + $dates[] = $row['summary_date']; + } + + if (empty($dates)) { + echo "No dates found in hourly_summary table.\n"; + exit(0); + } +} + +echo "=== Checking and Fixing hourly_summary ===\n\n"; + +foreach ($dates as $date) { + echo "--- Date: $date ---\n"; + + // Check entry_events count + $stmt = $db->prepare(' + SELECT COUNT(*) as total_count + FROM entry_events e + INNER JOIN locations l ON e.location_code = l.code AND l.is_active = 1 + INNER JOIN gates g ON e.location_code = g.location_code + AND e.gate_code = g.gate_code + AND g.is_active = 1 + WHERE DATE(e.event_time) = CAST(? AS DATE) + '); + $stmt->execute([$date]); + $entryResult = $stmt->fetch(); + $entryCount = (int) ($entryResult['total_count'] ?? 0); + + // Check hourly_summary count + $stmt = $db->prepare(' + SELECT SUM(total_count) as total_count + FROM hourly_summary + WHERE summary_date = CAST(? AS DATE) + '); + $stmt->execute([$date]); + $summaryResult = $stmt->fetch(); + $summaryCount = (int) ($summaryResult['total_count'] ?? 0); + + echo " entry_events: $entryCount events\n"; + echo " hourly_summary: $summaryCount events\n"; + + if ($entryCount > 0 && $summaryCount == 0) { + echo " ❌ PROBLEM: entry_events has data but hourly_summary is empty!\n"; + echo " → Running aggregation...\n"; + try { + $result = $service->aggregateForDate($date); + echo " ✓ Aggregation completed: {$result['rows_processed']} rows processed\n"; + } catch (Exception $e) { + echo " ✗ Error: " . $e->getMessage() . "\n"; + } + } elseif ($entryCount > 0 && $summaryCount > 0 && abs($entryCount - $summaryCount) > ($entryCount * 0.1)) { + // Perbedaan lebih dari 10% + $diff = abs($entryCount - $summaryCount); + $diffPercent = ($diff / max($entryCount, $summaryCount)) * 100; + echo " ⚠️ WARNING: Count mismatch! Difference: $diff ($diffPercent%)\n"; + + if ($diffPercent > 50) { + echo " → Re-running aggregation to fix...\n"; + try { + $result = $service->aggregateForDate($date); + echo " ✓ Re-aggregation completed: {$result['rows_processed']} rows processed\n"; + } catch (Exception $e) { + echo " ✗ Error: " . $e->getMessage() . "\n"; + } + } + } elseif ($entryCount > 0 && $summaryCount > 0) { + echo " ✓ OK: Counts match\n"; + } elseif ($entryCount == 0 && $summaryCount == 0) { + echo " ℹ️ No data for this date\n"; + } elseif ($entryCount == 0 && $summaryCount > 0) { + echo " ⚠️ WARNING: hourly_summary has data but entry_events is empty (orphaned data)\n"; + } + + echo "\n"; +} + +echo "Done!\n"; + diff --git a/bin/check_daily_summary.php b/bin/check_daily_summary.php new file mode 100644 index 0000000..213be1f --- /dev/null +++ b/bin/check_daily_summary.php @@ -0,0 +1,107 @@ +#!/usr/bin/env php +format('Y-m-d') !== $dateInput) { + echo "Error: Invalid date format. Expected Y-m-d (e.g., 2026-01-01)\n"; + exit(1); +} + +$date = $dateInput; + +echo "=== Checking daily_summary for date: $date ===\n\n"; + +// Check entry_events count +$stmt = $db->prepare(' + SELECT COUNT(*) as total_count + FROM entry_events e + INNER JOIN locations l ON e.location_code = l.code AND l.is_active = 1 + INNER JOIN gates g ON e.location_code = g.location_code + AND e.gate_code = g.gate_code + AND g.is_active = 1 + WHERE DATE(e.event_time) = CAST(? AS DATE) +'); +$stmt->execute([$date]); +$entryResult = $stmt->fetch(); +$entryCount = (int) ($entryResult['total_count'] ?? 0); + +// Check daily_summary count +$stmt = $db->prepare(' + SELECT SUM(total_count) as total_count, SUM(total_amount) as total_amount + FROM daily_summary + WHERE summary_date = CAST(? AS DATE) +'); +$stmt->execute([$date]); +$summaryResult = $stmt->fetch(); +$summaryCount = (int) ($summaryResult['total_count'] ?? 0); +$summaryAmount = (int) ($summaryResult['total_amount'] ?? 0); + +// Check detail per location/gate/category +$stmt = $db->prepare(' + SELECT location_code, gate_code, category, total_count, total_amount + FROM daily_summary + WHERE summary_date = CAST(? AS DATE) + ORDER BY total_count DESC +'); +$stmt->execute([$date]); +$details = $stmt->fetchAll(); + +echo "entry_events: $entryCount events\n"; +echo "daily_summary: $summaryCount events (Rp " . number_format($summaryAmount, 0, ',', '.') . ")\n\n"; + +if ($entryCount > 0 && $summaryCount == 0) { + echo "❌ PROBLEM: entry_events has data but daily_summary is empty!\n"; + echo "Run: php bin/daily_summary.php $date\n"; +} elseif ($entryCount > 0 && $summaryCount > 0 && abs($entryCount - $summaryCount) > ($entryCount * 0.1)) { + $diff = abs($entryCount - $summaryCount); + $diffPercent = ($diff / max($entryCount, $summaryCount)) * 100; + echo "⚠️ WARNING: Count mismatch! Difference: $diff ($diffPercent%)\n"; + echo "Run: php bin/daily_summary.php $date\n"; +} elseif ($entryCount > 0 && $summaryCount > 0) { + echo "✓ OK: Counts match\n"; +} + +if (!empty($details)) { + echo "\nDetails (top 10):\n"; + $count = 0; + foreach ($details as $row) { + if ($count++ >= 10) break; + echo sprintf( + " %s / %s / %s: %d events (Rp %s)\n", + $row['location_code'], + $row['gate_code'], + $row['category'], + $row['total_count'], + number_format($row['total_amount'], 0, ',', '.') + ); + } + if (count($details) > 10) { + echo " ... and " . (count($details) - 10) . " more\n"; + } +} + +echo "\n"; + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..1ca419f --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,22 @@ +# Slim Framework 4 .htaccess +# Pastikan semua request diarahkan ke index.php + + + RewriteEngine On + + # Redirect to index.php if file doesn't exist + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# Security headers + + Header set X-Content-Type-Options "nosniff" + Header set X-Frame-Options "SAMEORIGIN" + Header set X-XSS-Protection "1; mode=block" + + +# Disable directory browsing +Options -Indexes + diff --git a/public/test.php b/public/test.php new file mode 100644 index 0000000..8f716bd --- /dev/null +++ b/public/test.php @@ -0,0 +1,62 @@ +API Test"; +echo "

PHP Version: " . phpversion() . "

"; +echo "

Document Root: " . __DIR__ . "

"; +echo "

Request URI: " . ($_SERVER['REQUEST_URI'] ?? 'N/A') . "

"; + +// Test database connection +try { + AppConfig::loadEnv(__DIR__ . '/..'); + + $db = Database::getConnection( + AppConfig::get('DB_HOST'), + AppConfig::get('DB_NAME'), + AppConfig::get('DB_USER'), + AppConfig::get('DB_PASS') + ); + + echo "

✓ Database connection: OK

"; + + // Test query + $stmt = $db->query("SELECT COUNT(*) as count FROM entry_events"); + $result = $stmt->fetch(); + echo "

Total entry_events: " . ($result['count'] ?? 0) . "

"; + + // Test daily_summary untuk 2026-01-01 + $stmt = $db->prepare("SELECT SUM(total_count) as total FROM daily_summary WHERE summary_date = CAST(? AS DATE)"); + $stmt->execute(['2026-01-01']); + $dailyResult = $stmt->fetch(); + echo "

daily_summary for 2026-01-01: " . ($dailyResult['total'] ?? 0) . " events

"; + +} catch (Exception $e) { + echo "

✗ Database connection: FAILED

"; + echo "

Error: " . htmlspecialchars($e->getMessage()) . "

"; +} + +// Test health endpoint +echo "
"; +echo "

Test Health Endpoint

"; +echo "

Click here to test /health endpoint

"; +echo "

Or access directly: http://localhost/api-btekno/public/health

"; + +// Test dengan curl jika tersedia +echo "
"; +echo "

Manual Test Commands

"; +echo "
";
+echo "curl http://localhost/api-btekno/public/health\n";
+echo "curl -X POST http://localhost/api-btekno/public/auth/v1/login \\\n";
+echo "  -H \"Content-Type: application/json\" \\\n";
+echo "  -H \"X-API-KEY: POKOKEIKISEKOYOLO\" \\\n";
+echo "  -d '{\"username\":\"admin\",\"password\":\"password\"}'\n";
+echo "
"; + diff --git a/src/Bootstrap/AppBootstrap.php b/src/Bootstrap/AppBootstrap.php index 33b2c9e..86b3309 100644 --- a/src/Bootstrap/AppBootstrap.php +++ b/src/Bootstrap/AppBootstrap.php @@ -19,6 +19,23 @@ class AppBootstrap { $app = AppFactory::create(); + // Set base path jika API di-subdirectory (misal: /api-btekno/public) + // Auto-detect base path dari SCRIPT_NAME + $scriptName = $_SERVER['SCRIPT_NAME'] ?? ''; + + // Extract base path dari script name + // Contoh: /api-btekno/public/index.php -> /api-btekno/public + if ($scriptName && strpos($scriptName, '/index.php') !== false) { + $basePath = dirname($scriptName); + // Normalize: remove trailing slash, but keep leading slash + $basePath = rtrim($basePath, '/'); + if ($basePath !== '' && $basePath !== '/') { + $app->setBasePath($basePath); + // Log untuk debugging + error_log("[AppBootstrap] Base path set to: " . $basePath); + } + } + // Add body parsing middleware $app->addBodyParsingMiddleware(); diff --git a/src/Modules/Retribusi/Dashboard/DashboardService.php b/src/Modules/Retribusi/Dashboard/DashboardService.php index 79fa432..6cff027 100644 --- a/src/Modules/Retribusi/Dashboard/DashboardService.php +++ b/src/Modules/Retribusi/Dashboard/DashboardService.php @@ -204,13 +204,20 @@ class DashboardService */ public function getSummary(string $date, ?string $locationCode = null, ?string $gateCode = null): array { + // Validate date format to prevent SQL injection and ensure correct format + $dateTime = \DateTime::createFromFormat('Y-m-d', $date); + if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) { + throw new \InvalidArgumentException('Invalid date format. Expected Y-m-d'); + } + // Get total count and amount from daily_summary + // Use CAST to ensure date comparison is correct (especially for year transitions) $sql = " SELECT SUM(total_count) as total_count, SUM(total_amount) as total_amount FROM daily_summary - WHERE summary_date = ? + WHERE summary_date = CAST(? AS DATE) "; $params = [$date]; @@ -232,8 +239,85 @@ class DashboardService $totalCount = (int) ($summary['total_count'] ?? 0); $totalAmount = (int) ($summary['total_amount'] ?? 0); - // Fallback: jika daily_summary kosong, hitung langsung dari entry_events - if ($totalCount == 0 && $totalAmount == 0) { + // Fallback: jika daily_summary kosong atau data tidak lengkap, hitung langsung dari entry_events + // Check if entry_events has more data than daily_summary (indicates aggregation issue) + $checkSql = " + SELECT COUNT(*) as event_count + FROM entry_events e + INNER JOIN locations l ON e.location_code = l.code AND l.is_active = 1 + INNER JOIN gates g ON e.location_code = g.location_code + AND e.gate_code = g.gate_code + AND g.is_active = 1 + WHERE DATE(e.event_time) = CAST(? AS DATE) + "; + $checkParams = [$date]; + + if ($locationCode !== null) { + $checkSql .= " AND e.location_code = ?"; + $checkParams[] = $locationCode; + } + + if ($gateCode !== null) { + $checkSql .= " AND e.gate_code = ?"; + $checkParams[] = $gateCode; + } + + $checkStmt = $this->db->prepare($checkSql); + $checkStmt->execute($checkParams); + $checkResult = $checkStmt->fetch(); + $eventCount = (int) ($checkResult['event_count'] ?? 0); + + // If daily_summary is empty or significantly different from entry_events, use fallback + // Threshold: jika perbedaan > 5% atau daily_summary < 5% dari entry_events, gunakan fallback + $useFallback = false; + if ($totalCount == 0 && $totalAmount == 0 && $eventCount > 0) { + // daily_summary kosong tapi entry_events ada data + $useFallback = true; + error_log(sprintf( + "[Dashboard] daily_summary is empty but entry_events has %d events for date %s. Using fallback.", + $eventCount, + $date + )); + } elseif ($totalCount > 0 && $eventCount > 0) { + // Hitung perbedaan persentase + $diff = abs($eventCount - $totalCount); + $maxCount = max($eventCount, $totalCount); + $diffPercent = $maxCount > 0 ? ($diff / $maxCount) * 100 : 0; + $ratio = $totalCount / $eventCount; // Ratio daily_summary / entry_events + + // Gunakan fallback jika: + // 1. Perbedaan > 5% ATAU + // 2. daily_summary < 5% dari entry_events (indikasi data tidak lengkap) + // 3. daily_summary jauh lebih kecil dari entry_events (ratio < 0.05) + if ($diffPercent > 5 || $ratio < 0.05) { + $useFallback = true; + + // Log warning dengan detail + error_log(sprintf( + "[Dashboard] Warning: daily_summary (%d) differs from entry_events (%d) for date %s. " . + "Diff: %d (%.2f%%), Ratio: %.4f. Using fallback calculation. " . + "Run aggregation: php bin/daily_summary.php %s", + $totalCount, + $eventCount, + $date, + $diff, + $diffPercent, + $ratio, + $date + )); + } + } elseif ($totalCount > 0 && $eventCount == 0) { + // Entry_events kosong tapi daily_summary ada data - ini normal (data sudah di-aggregate) + // Tidak perlu fallback + error_log(sprintf( + "[Dashboard] Info: daily_summary has %d events but entry_events is empty for date %s. " . + "This is normal if data has been aggregated and entry_events cleaned up.", + $totalCount, + $date + )); + } + + if ($useFallback) { $fallbackSql = " SELECT COUNT(*) as total_count, @@ -246,7 +330,7 @@ class DashboardService LEFT JOIN tariffs t ON e.location_code = t.location_code AND e.gate_code = t.gate_code AND e.category = t.category - WHERE DATE(e.event_time) = ? + WHERE DATE(e.event_time) = CAST(? AS DATE) "; $fallbackParams = [$date]; @@ -280,7 +364,7 @@ class DashboardService LEFT JOIN tariffs t ON e.location_code = t.location_code AND e.gate_code = t.gate_code AND e.category = t.category - WHERE DATE(e.event_time) = ? + WHERE DATE(e.event_time) = CAST(? AS DATE) "; $amountParams = [$date]; @@ -304,49 +388,97 @@ class DashboardService } } - // Get active gates count - $gatesSql = " - SELECT COUNT(DISTINCT gate_code) as active_gates - FROM daily_summary - WHERE summary_date = ? - "; - - $gatesParams = [$date]; - - if ($locationCode !== null) { - $gatesSql .= " AND location_code = ?"; - $gatesParams[] = $locationCode; + // Get active gates count - use entry_events if daily_summary is not reliable + if ($useFallback) { + // Count from entry_events if using fallback + $gatesSql = " + SELECT COUNT(DISTINCT e.gate_code) as active_gates + FROM entry_events e + INNER JOIN locations l ON e.location_code = l.code AND l.is_active = 1 + INNER JOIN gates g ON e.location_code = g.location_code + AND e.gate_code = g.gate_code + AND g.is_active = 1 + WHERE DATE(e.event_time) = CAST(? AS DATE) + "; + $gatesParams = [$date]; + + if ($locationCode !== null) { + $gatesSql .= " AND e.location_code = ?"; + $gatesParams[] = $locationCode; + } + + if ($gateCode !== null) { + $gatesSql .= " AND e.gate_code = ?"; + $gatesParams[] = $gateCode; + } + } else { + // Count from daily_summary if data is reliable + $gatesSql = " + SELECT COUNT(DISTINCT gate_code) as active_gates + FROM daily_summary + WHERE summary_date = CAST(? AS DATE) + "; + $gatesParams = [$date]; + + if ($locationCode !== null) { + $gatesSql .= " AND location_code = ?"; + $gatesParams[] = $locationCode; + } + + if ($gateCode !== null) { + $gatesSql .= " AND gate_code = ?"; + $gatesParams[] = $gateCode; + } } - - if ($gateCode !== null) { - $gatesSql .= " AND gate_code = ?"; - $gatesParams[] = $gateCode; - } - + $gatesStmt = $this->db->prepare($gatesSql); $gatesStmt->execute($gatesParams); $gatesResult = $gatesStmt->fetch(); $activeGates = (int) ($gatesResult['active_gates'] ?? 0); - // Get active locations count - $locationsSql = " - SELECT COUNT(DISTINCT location_code) as active_locations - FROM daily_summary - WHERE summary_date = ? - "; - - $locationsParams = [$date]; - - if ($locationCode !== null) { - $locationsSql .= " AND location_code = ?"; - $locationsParams[] = $locationCode; + // Get active locations count - use entry_events if daily_summary is not reliable + if ($useFallback) { + // Count from entry_events if using fallback + $locationsSql = " + SELECT COUNT(DISTINCT e.location_code) as active_locations + FROM entry_events e + INNER JOIN locations l ON e.location_code = l.code AND l.is_active = 1 + INNER JOIN gates g ON e.location_code = g.location_code + AND e.gate_code = g.gate_code + AND g.is_active = 1 + WHERE DATE(e.event_time) = CAST(? AS DATE) + "; + $locationsParams = [$date]; + + if ($locationCode !== null) { + $locationsSql .= " AND e.location_code = ?"; + $locationsParams[] = $locationCode; + } + + if ($gateCode !== null) { + $locationsSql .= " AND e.gate_code = ?"; + $locationsParams[] = $gateCode; + } + } else { + // Count from daily_summary if data is reliable + $locationsSql = " + SELECT COUNT(DISTINCT location_code) as active_locations + FROM daily_summary + WHERE summary_date = CAST(? AS DATE) + "; + $locationsParams = [$date]; + + if ($locationCode !== null) { + $locationsSql .= " AND location_code = ?"; + $locationsParams[] = $locationCode; + } + + if ($gateCode !== null) { + $locationsSql .= " AND gate_code = ?"; + $locationsParams[] = $gateCode; + } } - - if ($gateCode !== null) { - $locationsSql .= " AND gate_code = ?"; - $locationsParams[] = $gateCode; - } - + $locationsStmt = $this->db->prepare($locationsSql); $locationsStmt->execute($locationsParams); $locationsResult = $locationsStmt->fetch(); diff --git a/src/Modules/Retribusi/Summary/HourlySummaryService.php b/src/Modules/Retribusi/Summary/HourlySummaryService.php index 877ef0d..305dec7 100644 --- a/src/Modules/Retribusi/Summary/HourlySummaryService.php +++ b/src/Modules/Retribusi/Summary/HourlySummaryService.php @@ -143,7 +143,7 @@ class HourlySummaryService /** * Get hourly summary data for chart * - * @param string $date + * @param string $date Format: Y-m-d * @param string|null $locationCode * @param string|null $gateCode * @return array @@ -154,13 +154,21 @@ class HourlySummaryService ?string $locationCode = null, ?string $gateCode = null ): array { + // Validate date format to prevent SQL injection and ensure correct format + $dateTime = \DateTime::createFromFormat('Y-m-d', $date); + if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) { + throw new \InvalidArgumentException('Invalid date format. Expected Y-m-d'); + } + + // Use CAST or STR_TO_DATE to ensure date comparison is correct + // This is especially important for year transitions $sql = " SELECT summary_hour, SUM(total_count) as total_count, SUM(total_amount) as total_amount FROM hourly_summary - WHERE summary_date = ? + WHERE summary_date = CAST(? AS DATE) "; $params = [$date]; @@ -180,6 +188,53 @@ class HourlySummaryService $stmt = $this->db->prepare($sql); $stmt->execute($params); $results = $stmt->fetchAll(); + + // Debug: Log query result untuk troubleshooting + $totalFromSummary = 0; + foreach ($results as $row) { + $totalFromSummary += (int) $row['total_count']; + } + + // Jika tidak ada data di hourly_summary, cek apakah ada data di entry_events + // Ini membantu detect jika aggregation belum dijalankan + if (empty($results) || $totalFromSummary == 0) { + $checkSql = " + SELECT COUNT(*) as event_count + FROM entry_events e + INNER JOIN locations l ON e.location_code = l.code AND l.is_active = 1 + INNER JOIN gates g ON e.location_code = g.location_code + AND e.gate_code = g.gate_code + AND g.is_active = 1 + WHERE DATE(e.event_time) = CAST(? AS DATE) + "; + $checkParams = [$date]; + + if ($locationCode !== null) { + $checkSql .= " AND e.location_code = ?"; + $checkParams[] = $locationCode; + } + + if ($gateCode !== null) { + $checkSql .= " AND e.gate_code = ?"; + $checkParams[] = $gateCode; + } + + $checkStmt = $this->db->prepare($checkSql); + $checkStmt->execute($checkParams); + $checkResult = $checkStmt->fetch(); + $eventCount = (int) ($checkResult['event_count'] ?? 0); + + // Log warning jika ada data di entry_events tapi tidak ada di hourly_summary + if ($eventCount > 0) { + error_log(sprintf( + "[HourlySummary] Warning: Date %s has %d events in entry_events but no data in hourly_summary. " . + "Run aggregation: php bin/hourly_summary.php %s", + $date, + $eventCount, + $date + )); + } + } // Initialize arrays for all 24 hours (0-23) $hourlyData = [];