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
This commit is contained in:
142
SETUP_LOCAL_API.md
Normal file
142
SETUP_LOCAL_API.md
Normal file
@@ -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
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName api-retribusi.test
|
||||||
|
DocumentRoot "C:/laragon/www/api-btekno/public"
|
||||||
|
|
||||||
|
<Directory "C:/laragon/www/api-btekno/public">
|
||||||
|
Options Indexes FollowSymLinks
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
124
bin/check_and_fix_hourly_summary.php
Normal file
124
bin/check_and_fix_hourly_summary.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Script untuk check dan fix data hourly_summary yang tidak valid
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php bin/check_and_fix_hourly_summary.php [date]
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* php bin/check_and_fix_hourly_summary.php 2026-01-01
|
||||||
|
* php bin/check_and_fix_hourly_summary.php # Check semua tanggal
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Support\Database;
|
||||||
|
use App\Modules\Retribusi\Summary\HourlySummaryService;
|
||||||
|
|
||||||
|
AppConfig::loadEnv(__DIR__ . '/..');
|
||||||
|
|
||||||
|
$db = Database::getConnection(
|
||||||
|
AppConfig::get('DB_HOST'),
|
||||||
|
AppConfig::get('DB_NAME'),
|
||||||
|
AppConfig::get('DB_USER'),
|
||||||
|
AppConfig::get('DB_PASS')
|
||||||
|
);
|
||||||
|
|
||||||
|
$service = new HourlySummaryService($db);
|
||||||
|
|
||||||
|
// Get date from command line or check all dates
|
||||||
|
$dateInput = $argv[1] ?? null;
|
||||||
|
|
||||||
|
if ($dateInput) {
|
||||||
|
// Validate date format
|
||||||
|
$dateTime = DateTime::createFromFormat('Y-m-d', $dateInput);
|
||||||
|
if ($dateTime === false || $dateTime->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";
|
||||||
|
|
||||||
107
bin/check_daily_summary.php
Normal file
107
bin/check_daily_summary.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Script untuk check data daily_summary
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php bin/check_daily_summary.php [date]
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Support\Database;
|
||||||
|
|
||||||
|
AppConfig::loadEnv(__DIR__ . '/..');
|
||||||
|
|
||||||
|
$db = Database::getConnection(
|
||||||
|
AppConfig::get('DB_HOST'),
|
||||||
|
AppConfig::get('DB_NAME'),
|
||||||
|
AppConfig::get('DB_USER'),
|
||||||
|
AppConfig::get('DB_PASS')
|
||||||
|
);
|
||||||
|
|
||||||
|
$dateInput = $argv[1] ?? '2026-01-01';
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
$dateTime = DateTime::createFromFormat('Y-m-d', $dateInput);
|
||||||
|
if ($dateTime === false || $dateTime->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";
|
||||||
|
|
||||||
22
public/.htaccess
Normal file
22
public/.htaccess
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Slim Framework 4 .htaccess
|
||||||
|
# Pastikan semua request diarahkan ke index.php
|
||||||
|
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Redirect to index.php if file doesn't exist
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^ index.php [QSA,L]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set X-Content-Type-Options "nosniff"
|
||||||
|
Header set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header set X-XSS-Protection "1; mode=block"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Disable directory browsing
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
62
public/test.php
Normal file
62
public/test.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Test script untuk cek apakah API bisa diakses
|
||||||
|
* Akses: http://localhost/api-btekno/public/test.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Support\Database;
|
||||||
|
|
||||||
|
echo "<h1>API Test</h1>";
|
||||||
|
echo "<p>PHP Version: " . phpversion() . "</p>";
|
||||||
|
echo "<p>Document Root: " . __DIR__ . "</p>";
|
||||||
|
echo "<p>Request URI: " . ($_SERVER['REQUEST_URI'] ?? 'N/A') . "</p>";
|
||||||
|
|
||||||
|
// 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 "<p style='color: green;'>✓ Database connection: OK</p>";
|
||||||
|
|
||||||
|
// Test query
|
||||||
|
$stmt = $db->query("SELECT COUNT(*) as count FROM entry_events");
|
||||||
|
$result = $stmt->fetch();
|
||||||
|
echo "<p>Total entry_events: " . ($result['count'] ?? 0) . "</p>";
|
||||||
|
|
||||||
|
// 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 "<p>daily_summary for 2026-01-01: " . ($dailyResult['total'] ?? 0) . " events</p>";
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "<p style='color: red;'>✗ Database connection: FAILED</p>";
|
||||||
|
echo "<p>Error: " . htmlspecialchars($e->getMessage()) . "</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test health endpoint
|
||||||
|
echo "<hr>";
|
||||||
|
echo "<h2>Test Health Endpoint</h2>";
|
||||||
|
echo "<p><a href='health' target='_blank'>Click here to test /health endpoint</a></p>";
|
||||||
|
echo "<p>Or access directly: <code>http://localhost/api-btekno/public/health</code></p>";
|
||||||
|
|
||||||
|
// Test dengan curl jika tersedia
|
||||||
|
echo "<hr>";
|
||||||
|
echo "<h2>Manual Test Commands</h2>";
|
||||||
|
echo "<pre>";
|
||||||
|
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 "</pre>";
|
||||||
|
|
||||||
@@ -19,6 +19,23 @@ class AppBootstrap
|
|||||||
{
|
{
|
||||||
$app = AppFactory::create();
|
$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
|
// Add body parsing middleware
|
||||||
$app->addBodyParsingMiddleware();
|
$app->addBodyParsingMiddleware();
|
||||||
|
|
||||||
|
|||||||
@@ -204,13 +204,20 @@ class DashboardService
|
|||||||
*/
|
*/
|
||||||
public function getSummary(string $date, ?string $locationCode = null, ?string $gateCode = null): array
|
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
|
// Get total count and amount from daily_summary
|
||||||
|
// Use CAST to ensure date comparison is correct (especially for year transitions)
|
||||||
$sql = "
|
$sql = "
|
||||||
SELECT
|
SELECT
|
||||||
SUM(total_count) as total_count,
|
SUM(total_count) as total_count,
|
||||||
SUM(total_amount) as total_amount
|
SUM(total_amount) as total_amount
|
||||||
FROM daily_summary
|
FROM daily_summary
|
||||||
WHERE summary_date = ?
|
WHERE summary_date = CAST(? AS DATE)
|
||||||
";
|
";
|
||||||
|
|
||||||
$params = [$date];
|
$params = [$date];
|
||||||
@@ -232,8 +239,85 @@ class DashboardService
|
|||||||
$totalCount = (int) ($summary['total_count'] ?? 0);
|
$totalCount = (int) ($summary['total_count'] ?? 0);
|
||||||
$totalAmount = (int) ($summary['total_amount'] ?? 0);
|
$totalAmount = (int) ($summary['total_amount'] ?? 0);
|
||||||
|
|
||||||
// Fallback: jika daily_summary kosong, hitung langsung dari entry_events
|
// Fallback: jika daily_summary kosong atau data tidak lengkap, hitung langsung dari entry_events
|
||||||
if ($totalCount == 0 && $totalAmount == 0) {
|
// 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 = "
|
$fallbackSql = "
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total_count,
|
COUNT(*) as total_count,
|
||||||
@@ -246,7 +330,7 @@ class DashboardService
|
|||||||
LEFT JOIN tariffs t ON e.location_code = t.location_code
|
LEFT JOIN tariffs t ON e.location_code = t.location_code
|
||||||
AND e.gate_code = t.gate_code
|
AND e.gate_code = t.gate_code
|
||||||
AND e.category = t.category
|
AND e.category = t.category
|
||||||
WHERE DATE(e.event_time) = ?
|
WHERE DATE(e.event_time) = CAST(? AS DATE)
|
||||||
";
|
";
|
||||||
|
|
||||||
$fallbackParams = [$date];
|
$fallbackParams = [$date];
|
||||||
@@ -280,7 +364,7 @@ class DashboardService
|
|||||||
LEFT JOIN tariffs t ON e.location_code = t.location_code
|
LEFT JOIN tariffs t ON e.location_code = t.location_code
|
||||||
AND e.gate_code = t.gate_code
|
AND e.gate_code = t.gate_code
|
||||||
AND e.category = t.category
|
AND e.category = t.category
|
||||||
WHERE DATE(e.event_time) = ?
|
WHERE DATE(e.event_time) = CAST(? AS DATE)
|
||||||
";
|
";
|
||||||
|
|
||||||
$amountParams = [$date];
|
$amountParams = [$date];
|
||||||
@@ -304,49 +388,97 @@ class DashboardService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get active gates count
|
// Get active gates count - use entry_events if daily_summary is not reliable
|
||||||
$gatesSql = "
|
if ($useFallback) {
|
||||||
SELECT COUNT(DISTINCT gate_code) as active_gates
|
// Count from entry_events if using fallback
|
||||||
FROM daily_summary
|
$gatesSql = "
|
||||||
WHERE summary_date = ?
|
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
|
||||||
$gatesParams = [$date];
|
INNER JOIN gates g ON e.location_code = g.location_code
|
||||||
|
AND e.gate_code = g.gate_code
|
||||||
if ($locationCode !== null) {
|
AND g.is_active = 1
|
||||||
$gatesSql .= " AND location_code = ?";
|
WHERE DATE(e.event_time) = CAST(? AS DATE)
|
||||||
$gatesParams[] = $locationCode;
|
";
|
||||||
|
$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 = $this->db->prepare($gatesSql);
|
||||||
$gatesStmt->execute($gatesParams);
|
$gatesStmt->execute($gatesParams);
|
||||||
$gatesResult = $gatesStmt->fetch();
|
$gatesResult = $gatesStmt->fetch();
|
||||||
$activeGates = (int) ($gatesResult['active_gates'] ?? 0);
|
$activeGates = (int) ($gatesResult['active_gates'] ?? 0);
|
||||||
|
|
||||||
// Get active locations count
|
// Get active locations count - use entry_events if daily_summary is not reliable
|
||||||
$locationsSql = "
|
if ($useFallback) {
|
||||||
SELECT COUNT(DISTINCT location_code) as active_locations
|
// Count from entry_events if using fallback
|
||||||
FROM daily_summary
|
$locationsSql = "
|
||||||
WHERE summary_date = ?
|
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
|
||||||
$locationsParams = [$date];
|
INNER JOIN gates g ON e.location_code = g.location_code
|
||||||
|
AND e.gate_code = g.gate_code
|
||||||
if ($locationCode !== null) {
|
AND g.is_active = 1
|
||||||
$locationsSql .= " AND location_code = ?";
|
WHERE DATE(e.event_time) = CAST(? AS DATE)
|
||||||
$locationsParams[] = $locationCode;
|
";
|
||||||
|
$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 = $this->db->prepare($locationsSql);
|
||||||
$locationsStmt->execute($locationsParams);
|
$locationsStmt->execute($locationsParams);
|
||||||
$locationsResult = $locationsStmt->fetch();
|
$locationsResult = $locationsStmt->fetch();
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ class HourlySummaryService
|
|||||||
/**
|
/**
|
||||||
* Get hourly summary data for chart
|
* Get hourly summary data for chart
|
||||||
*
|
*
|
||||||
* @param string $date
|
* @param string $date Format: Y-m-d
|
||||||
* @param string|null $locationCode
|
* @param string|null $locationCode
|
||||||
* @param string|null $gateCode
|
* @param string|null $gateCode
|
||||||
* @return array
|
* @return array
|
||||||
@@ -154,13 +154,21 @@ class HourlySummaryService
|
|||||||
?string $locationCode = null,
|
?string $locationCode = null,
|
||||||
?string $gateCode = null
|
?string $gateCode = null
|
||||||
): array {
|
): 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 = "
|
$sql = "
|
||||||
SELECT
|
SELECT
|
||||||
summary_hour,
|
summary_hour,
|
||||||
SUM(total_count) as total_count,
|
SUM(total_count) as total_count,
|
||||||
SUM(total_amount) as total_amount
|
SUM(total_amount) as total_amount
|
||||||
FROM hourly_summary
|
FROM hourly_summary
|
||||||
WHERE summary_date = ?
|
WHERE summary_date = CAST(? AS DATE)
|
||||||
";
|
";
|
||||||
|
|
||||||
$params = [$date];
|
$params = [$date];
|
||||||
@@ -180,6 +188,53 @@ class HourlySummaryService
|
|||||||
$stmt = $this->db->prepare($sql);
|
$stmt = $this->db->prepare($sql);
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
$results = $stmt->fetchAll();
|
$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)
|
// Initialize arrays for all 24 hours (0-23)
|
||||||
$hourlyData = [];
|
$hourlyData = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user