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
+
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 "Click here to test /health endpoint
"; +echo "Or access directly: http://localhost/api-btekno/public/health
";
+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 = [];