Initial commit: Slim Framework 4 API Retribusi dengan modular architecture
This commit is contained in:
29
.env.example
Normal file
29
.env.example
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
APP_ENV=production
|
||||||
|
APP_DEBUG=false
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
SERVER_NAME=localhost
|
||||||
|
SERVER_PORT=8080
|
||||||
|
|
||||||
|
# Database Configuration (if needed)
|
||||||
|
# DB_HOST=localhost
|
||||||
|
# DB_PORT=3306
|
||||||
|
# DB_NAME=your_database
|
||||||
|
# DB_USER=your_username
|
||||||
|
# DB_PASS=your_password
|
||||||
|
|
||||||
|
# Security
|
||||||
|
# JWT_SECRET=your-secret-key-here
|
||||||
|
|
||||||
|
RETRIBUSI_API_KEY=change_me
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_NAME=sql_retribusi
|
||||||
|
DB_USER=sql_retribusi
|
||||||
|
DB_PASS=8e5yKwC6WPiLXTst
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=change-me-to-secure-random-string
|
||||||
|
JWT_TTL_SECONDS=3600
|
||||||
|
JWT_ISSUER=api-btekno
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Dependencies
|
||||||
|
/vendor/
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
cache/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Composer
|
||||||
|
composer.phar
|
||||||
|
composer.lock
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
*.sql
|
||||||
|
*.bak
|
||||||
|
backup/
|
||||||
|
|
||||||
207
README.md
Normal file
207
README.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# API Retribusi - Slim Framework 4
|
||||||
|
|
||||||
|
Sistem API Retribusi berbasis Slim Framework 4 dengan arsitektur modular untuk infrastruktur pemerintah.
|
||||||
|
|
||||||
|
## 🚀 Fitur
|
||||||
|
|
||||||
|
- **Modular Architecture** - Struktur code yang terorganisir dan mudah di-scale
|
||||||
|
- **JWT Authentication** - Secure authentication dengan role-based access
|
||||||
|
- **CRUD Master Data** - Locations, Gates, Tariffs dengan audit logging
|
||||||
|
- **Realtime Dashboard** - SSE (Server-Sent Events) untuk update real-time
|
||||||
|
- **Data Aggregation** - Daily & Hourly summary untuk reporting
|
||||||
|
- **API Key Protection** - X-API-KEY untuk ingest endpoint (mesin YOLO)
|
||||||
|
|
||||||
|
## 📋 Requirements
|
||||||
|
|
||||||
|
- PHP >= 8.2
|
||||||
|
- MySQL/MariaDB
|
||||||
|
- Composer
|
||||||
|
- aaPanel (recommended) atau web server dengan PHP-FPM
|
||||||
|
|
||||||
|
## 🔧 Installation
|
||||||
|
|
||||||
|
1. Clone repository:
|
||||||
|
```bash
|
||||||
|
git clone https://git.btekno.cloud/kangmin/api-btekno.git
|
||||||
|
cd api-btekno
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Setup environment:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env dengan konfigurasi database dan JWT
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Apply migrations:
|
||||||
|
```bash
|
||||||
|
mysql -u your_user -p your_database < migrations/001_create_audit_logs.sql
|
||||||
|
mysql -u your_user -p your_database < migrations/002_create_hourly_summary.sql
|
||||||
|
mysql -u your_user -p your_database < migrations/003_create_realtime_events.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Setup web server:
|
||||||
|
- DocumentRoot: `public/`
|
||||||
|
- PHP 8.2+
|
||||||
|
- Enable mod_rewrite (Apache) atau nginx config
|
||||||
|
|
||||||
|
## 📁 Struktur Project
|
||||||
|
|
||||||
|
```
|
||||||
|
api-btekno/
|
||||||
|
├── public/ # Entry point (web server root)
|
||||||
|
├── src/
|
||||||
|
│ ├── Bootstrap/ # App initialization
|
||||||
|
│ ├── Config/ # Configuration
|
||||||
|
│ ├── Middleware/ # Auth & security
|
||||||
|
│ ├── Modules/ # Business modules
|
||||||
|
│ └── Support/ # Utilities
|
||||||
|
├── bin/ # CLI scripts
|
||||||
|
├── migrations/ # Database migrations
|
||||||
|
└── vendor/ # Dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Environment Variables
|
||||||
|
|
||||||
|
Edit `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# App
|
||||||
|
APP_ENV=production
|
||||||
|
APP_DEBUG=false
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_NAME=sql_retribusi
|
||||||
|
DB_USER=sql_retribusi
|
||||||
|
DB_PASS=your_password
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-secret-key-here
|
||||||
|
JWT_TTL_SECONDS=3600
|
||||||
|
JWT_ISSUER=api-btekno
|
||||||
|
|
||||||
|
# API Key
|
||||||
|
RETRIBUSI_API_KEY=your-api-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📡 API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /auth/v1/login` - Login & get JWT token
|
||||||
|
|
||||||
|
### Ingest (Mesin)
|
||||||
|
- `POST /retribusi/v1/ingest` - Ingest event data (X-API-KEY required)
|
||||||
|
|
||||||
|
### Frontend CRUD
|
||||||
|
- `GET /retribusi/v1/frontend/locations` - List locations
|
||||||
|
- `POST /retribusi/v1/frontend/locations` - Create location (operator+)
|
||||||
|
- `PUT /retribusi/v1/frontend/locations/{code}` - Update location (operator+)
|
||||||
|
- `DELETE /retribusi/v1/frontend/locations/{code}` - Delete location (admin)
|
||||||
|
|
||||||
|
Similar endpoints untuk `gates` dan `tariffs`.
|
||||||
|
|
||||||
|
### Summary & Dashboard
|
||||||
|
- `GET /retribusi/v1/summary/daily` - Daily summary
|
||||||
|
- `GET /retribusi/v1/summary/hourly` - Hourly summary
|
||||||
|
- `GET /retribusi/v1/dashboard/daily` - Daily chart data
|
||||||
|
- `GET /retribusi/v1/dashboard/by-category` - Category chart data
|
||||||
|
- `GET /retribusi/v1/dashboard/summary` - Summary statistics
|
||||||
|
|
||||||
|
### Realtime
|
||||||
|
- `GET /retribusi/v1/realtime/stream` - SSE stream (real-time events)
|
||||||
|
- `GET /retribusi/v1/realtime/snapshot` - Snapshot data
|
||||||
|
|
||||||
|
## 🛠️ CLI Tools
|
||||||
|
|
||||||
|
### Daily Summary
|
||||||
|
```bash
|
||||||
|
php bin/daily_summary.php [date]
|
||||||
|
# Default: yesterday
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hourly Summary
|
||||||
|
```bash
|
||||||
|
php bin/hourly_summary.php [date]
|
||||||
|
# Default: yesterday
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Job Setup
|
||||||
|
```cron
|
||||||
|
# Daily summary (run at 1 AM)
|
||||||
|
0 1 * * * cd /path/to/api-btekno && php bin/daily_summary.php
|
||||||
|
|
||||||
|
# Hourly summary (run at 1 AM)
|
||||||
|
0 1 * * * cd /path/to/api-btekno && php bin/hourly_summary.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Security
|
||||||
|
|
||||||
|
- JWT authentication untuk semua frontend endpoints
|
||||||
|
- X-API-KEY untuk ingest endpoint
|
||||||
|
- Role-based access control (viewer/operator/admin)
|
||||||
|
- Prepared statements (SQL injection prevention)
|
||||||
|
- Input validation
|
||||||
|
- Audit logging untuk semua perubahan data
|
||||||
|
|
||||||
|
## 📊 Database Schema
|
||||||
|
|
||||||
|
- `users` - User authentication
|
||||||
|
- `locations` - Master lokasi
|
||||||
|
- `gates` - Master pintu masuk/keluar
|
||||||
|
- `tariffs` - Master tarif
|
||||||
|
- `entry_events` - Raw event data
|
||||||
|
- `daily_summary` - Rekap harian
|
||||||
|
- `hourly_summary` - Rekap per jam
|
||||||
|
- `realtime_events` - Ring buffer untuk SSE
|
||||||
|
- `audit_logs` - Audit trail
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
Test endpoint dengan curl atau Postman:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost/health
|
||||||
|
|
||||||
|
# Login
|
||||||
|
curl -X POST http://localhost/auth/v1/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"password"}'
|
||||||
|
|
||||||
|
# Get locations (with JWT)
|
||||||
|
curl http://localhost/retribusi/v1/frontend/locations \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Coding Standards
|
||||||
|
|
||||||
|
- `declare(strict_types=1)` di semua file
|
||||||
|
- Type hints lengkap
|
||||||
|
- PSR-4 autoloading
|
||||||
|
- Controller tipis, logic di service
|
||||||
|
- No ORM (pure PDO)
|
||||||
|
- Response JSON konsisten
|
||||||
|
|
||||||
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
1. Set production environment di `.env`
|
||||||
|
2. Run `composer install --no-dev --optimize-autoloader`
|
||||||
|
3. Apply semua migrations
|
||||||
|
4. Setup cron jobs untuk summary
|
||||||
|
5. Configure web server (Apache/Nginx)
|
||||||
|
6. Enable HTTPS
|
||||||
|
7. Monitor logs dan performance
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
Proprietary
|
||||||
|
|
||||||
|
## 👥 Author
|
||||||
|
|
||||||
|
BTekno Development Team
|
||||||
|
|
||||||
72
bin/daily_summary.php
Normal file
72
bin/daily_summary.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI script untuk generate daily summary
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php bin/daily_summary.php [date]
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* php bin/daily_summary.php 2025-01-01
|
||||||
|
* php bin/daily_summary.php # default: yesterday
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Get project root directory
|
||||||
|
$rootPath = dirname(__DIR__);
|
||||||
|
|
||||||
|
// Load autoloader
|
||||||
|
require $rootPath . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Modules\Retribusi\Summary\DailySummaryService;
|
||||||
|
use App\Support\Database;
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
AppConfig::loadEnv($rootPath);
|
||||||
|
|
||||||
|
// Get date from command line argument or use yesterday
|
||||||
|
$date = $argv[1] ?? date('Y-m-d', strtotime('-1 day'));
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
$dateTime = DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
|
||||||
|
echo "Error: Invalid date format. Expected Y-m-d (e.g., 2025-01-01)\n";
|
||||||
|
echo "Usage: php bin/daily_summary.php [date]\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get database connection
|
||||||
|
$dbHost = AppConfig::get('DB_HOST', 'localhost');
|
||||||
|
$dbName = AppConfig::get('DB_NAME', '');
|
||||||
|
$dbUser = AppConfig::get('DB_USER', '');
|
||||||
|
$dbPass = AppConfig::get('DB_PASS', '');
|
||||||
|
|
||||||
|
if (empty($dbName) || empty($dbUser)) {
|
||||||
|
echo "Error: Database configuration not found in .env\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
|
||||||
|
|
||||||
|
// Initialize service
|
||||||
|
$service = new DailySummaryService($db);
|
||||||
|
|
||||||
|
// Run aggregation
|
||||||
|
echo "Processing daily summary for date: {$date}\n";
|
||||||
|
$result = $service->aggregateForDate($date);
|
||||||
|
|
||||||
|
echo "Success!\n";
|
||||||
|
echo "Date: {$result['date']}\n";
|
||||||
|
echo "Rows processed: {$result['rows_processed']}\n";
|
||||||
|
|
||||||
|
exit(0);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "Error: " . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
79
bin/hourly_summary.php
Normal file
79
bin/hourly_summary.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI script untuk generate hourly summary
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php bin/hourly_summary.php [date]
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* php bin/hourly_summary.php 2025-01-01
|
||||||
|
* php bin/hourly_summary.php # default: yesterday
|
||||||
|
*
|
||||||
|
* Note:
|
||||||
|
* Default menggunakan yesterday karena:
|
||||||
|
* - Data hari ini mungkin belum lengkap (masih ada event yang masuk)
|
||||||
|
* - Cron biasanya jalan di akhir hari untuk rekap hari sebelumnya
|
||||||
|
* - Lebih aman untuk rekap data yang sudah final
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Get project root directory
|
||||||
|
$rootPath = dirname(__DIR__);
|
||||||
|
|
||||||
|
// Load autoloader
|
||||||
|
require $rootPath . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Modules\Retribusi\Summary\HourlySummaryService;
|
||||||
|
use App\Support\Database;
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
AppConfig::loadEnv($rootPath);
|
||||||
|
|
||||||
|
// Get date from command line argument or use yesterday (deterministic)
|
||||||
|
$date = $argv[1] ?? date('Y-m-d', strtotime('-1 day'));
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
$dateTime = DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
|
||||||
|
echo "Error: Invalid date format. Expected Y-m-d (e.g., 2025-01-01)\n";
|
||||||
|
echo "Usage: php bin/hourly_summary.php [date]\n";
|
||||||
|
echo " If date is omitted, defaults to yesterday\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get database connection
|
||||||
|
$dbHost = AppConfig::get('DB_HOST', 'localhost');
|
||||||
|
$dbName = AppConfig::get('DB_NAME', '');
|
||||||
|
$dbUser = AppConfig::get('DB_USER', '');
|
||||||
|
$dbPass = AppConfig::get('DB_PASS', '');
|
||||||
|
|
||||||
|
if (empty($dbName) || empty($dbUser)) {
|
||||||
|
echo "Error: Database configuration not found in .env\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
|
||||||
|
|
||||||
|
// Initialize service
|
||||||
|
$service = new HourlySummaryService($db);
|
||||||
|
|
||||||
|
// Run aggregation
|
||||||
|
echo "Processing hourly summary for date: {$date}\n";
|
||||||
|
$result = $service->aggregateForDate($date);
|
||||||
|
|
||||||
|
echo "Success!\n";
|
||||||
|
echo "Date: {$result['date']}\n";
|
||||||
|
echo "Rows processed: {$result['rows_processed']}\n";
|
||||||
|
|
||||||
|
exit(0);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "Error: " . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
23
composer.json
Normal file
23
composer.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "api-btekno/slim-api",
|
||||||
|
"description": "Slim Framework 4 API for production",
|
||||||
|
"type": "project",
|
||||||
|
"license": "proprietary",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.2",
|
||||||
|
"slim/psr7": "^1.6",
|
||||||
|
"slim/slim": "^4.12",
|
||||||
|
"vlucas/phpdotenv": "^5.6"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"sort-packages": true
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
57
migrations/README.md
Normal file
57
migrations/README.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Database Migrations
|
||||||
|
|
||||||
|
## Cara Apply Migration
|
||||||
|
|
||||||
|
### 1. Backup Database (PENTING!)
|
||||||
|
Sebelum menjalankan migration, pastikan untuk backup database terlebih dahulu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysqldump -u sql_retribusi -p sql_retribusi > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Apply Migration
|
||||||
|
|
||||||
|
#### Menggunakan MySQL Command Line:
|
||||||
|
```bash
|
||||||
|
mysql -u sql_retribusi -p sql_retribusi < migrations/001_create_audit_logs.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Menggunakan phpMyAdmin:
|
||||||
|
1. Login ke phpMyAdmin
|
||||||
|
2. Pilih database `sql_retribusi`
|
||||||
|
3. Klik tab "SQL"
|
||||||
|
4. Copy-paste isi file `001_create_audit_logs.sql`
|
||||||
|
5. Klik "Go" untuk execute
|
||||||
|
|
||||||
|
#### Menggunakan MySQL Workbench:
|
||||||
|
1. Buka MySQL Workbench
|
||||||
|
2. Connect ke database server
|
||||||
|
3. Pilih database `sql_retribusi`
|
||||||
|
4. File → Run SQL Script
|
||||||
|
5. Pilih file `001_create_audit_logs.sql`
|
||||||
|
6. Klik "Run"
|
||||||
|
|
||||||
|
### 3. Verifikasi
|
||||||
|
|
||||||
|
Setelah migration berhasil, verifikasi tabel sudah dibuat:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SHOW TABLES LIKE 'audit_logs';
|
||||||
|
DESCRIBE audit_logs;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Daftar Migration
|
||||||
|
|
||||||
|
### 001_create_audit_logs.sql
|
||||||
|
- **Tanggal**: 2024-12-28
|
||||||
|
- **Deskripsi**: Membuat tabel `audit_logs` untuk tracking semua perubahan data (create/update/delete)
|
||||||
|
- **Tabel**: `audit_logs`
|
||||||
|
- **Rollback**: Tidak ada (tabel ini critical untuk audit, tidak boleh dihapus)
|
||||||
|
|
||||||
|
## Catatan Penting
|
||||||
|
|
||||||
|
- **JANGAN** hapus atau modify migration file yang sudah di-apply
|
||||||
|
- Selalu backup database sebelum apply migration
|
||||||
|
- Test migration di environment development terlebih dahulu
|
||||||
|
- Jika terjadi error, restore dari backup dan perbaiki migration file
|
||||||
|
|
||||||
32
public/index.php
Normal file
32
public/index.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Bootstrap\AppBootstrap;
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Modules\Auth\AuthRoutes;
|
||||||
|
use App\Modules\Health\HealthRoutes;
|
||||||
|
use App\Modules\Retribusi\Dashboard\DashboardRoutes;
|
||||||
|
use App\Modules\Retribusi\Realtime\RealtimeRoutes;
|
||||||
|
use App\Modules\Retribusi\RetribusiRoutes;
|
||||||
|
use App\Modules\Retribusi\Summary\SummaryRoutes;
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
AppConfig::loadEnv(__DIR__ . '/..');
|
||||||
|
|
||||||
|
// Bootstrap application
|
||||||
|
$app = AppBootstrap::create();
|
||||||
|
|
||||||
|
// Register module routes
|
||||||
|
HealthRoutes::register($app);
|
||||||
|
AuthRoutes::register($app);
|
||||||
|
RetribusiRoutes::register($app);
|
||||||
|
SummaryRoutes::register($app);
|
||||||
|
DashboardRoutes::register($app);
|
||||||
|
RealtimeRoutes::register($app);
|
||||||
|
|
||||||
|
// Run application
|
||||||
|
$app->run();
|
||||||
|
|
||||||
34
src/Bootstrap/AppBootstrap.php
Normal file
34
src/Bootstrap/AppBootstrap.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Bootstrap;
|
||||||
|
|
||||||
|
use Slim\App;
|
||||||
|
use Slim\Factory\AppFactory;
|
||||||
|
use Slim\Middleware\BodyParsingMiddleware;
|
||||||
|
|
||||||
|
class AppBootstrap
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create and configure Slim App instance
|
||||||
|
*
|
||||||
|
* @return App
|
||||||
|
*/
|
||||||
|
public static function create(): App
|
||||||
|
{
|
||||||
|
$app = AppFactory::create();
|
||||||
|
|
||||||
|
// Add body parsing middleware
|
||||||
|
$app->addBodyParsingMiddleware();
|
||||||
|
|
||||||
|
// Add routing middleware
|
||||||
|
$app->addRoutingMiddleware();
|
||||||
|
|
||||||
|
// Add error middleware
|
||||||
|
$app->addErrorMiddleware(true, true, true);
|
||||||
|
|
||||||
|
return $app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
34
src/Bootstrap/app.php
Normal file
34
src/Bootstrap/app.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Bootstrap;
|
||||||
|
|
||||||
|
use Slim\App;
|
||||||
|
use Slim\Factory\AppFactory;
|
||||||
|
use Slim\Middleware\BodyParsingMiddleware;
|
||||||
|
|
||||||
|
class AppBootstrap
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create and configure Slim App instance
|
||||||
|
*
|
||||||
|
* @return App
|
||||||
|
*/
|
||||||
|
public static function create(): App
|
||||||
|
{
|
||||||
|
$app = AppFactory::create();
|
||||||
|
|
||||||
|
// Add body parsing middleware
|
||||||
|
$app->addBodyParsingMiddleware();
|
||||||
|
|
||||||
|
// Add routing middleware
|
||||||
|
$app->addRoutingMiddleware();
|
||||||
|
|
||||||
|
// Add error middleware
|
||||||
|
$app->addErrorMiddleware(true, true, true);
|
||||||
|
|
||||||
|
return $app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
41
src/Config/AppConfig.php
Normal file
41
src/Config/AppConfig.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Config;
|
||||||
|
|
||||||
|
use Dotenv\Dotenv;
|
||||||
|
|
||||||
|
class AppConfig
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Load environment variables from .env file
|
||||||
|
* Safe if .env file doesn't exist
|
||||||
|
*
|
||||||
|
* @param string $rootPath
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function loadEnv(string $rootPath): void
|
||||||
|
{
|
||||||
|
$envPath = $rootPath . DIRECTORY_SEPARATOR . '.env';
|
||||||
|
|
||||||
|
if (file_exists($envPath)) {
|
||||||
|
$dotenv = Dotenv::createImmutable($rootPath);
|
||||||
|
$dotenv->load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get environment variable with default value
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @param string|null $default
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public static function get(string $key, ?string $default = null): ?string
|
||||||
|
{
|
||||||
|
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
|
||||||
|
return $value !== false ? (string) $value : $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
28
src/Config/app.php
Normal file
28
src/Config/app.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Config;
|
||||||
|
|
||||||
|
use Dotenv\Dotenv;
|
||||||
|
|
||||||
|
class AppConfig
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Load environment variables from .env file
|
||||||
|
* Safe if .env file doesn't exist
|
||||||
|
*
|
||||||
|
* @param string $rootPath
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function loadEnv(string $rootPath): void
|
||||||
|
{
|
||||||
|
$envPath = $rootPath . DIRECTORY_SEPARATOR . '.env';
|
||||||
|
|
||||||
|
if (file_exists($envPath)) {
|
||||||
|
$dotenv = Dotenv::createImmutable($rootPath);
|
||||||
|
$dotenv->load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
52
src/Middleware/ApiKeyMiddleware.php
Normal file
52
src/Middleware/ApiKeyMiddleware.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
use Slim\Psr7\Factory\ResponseFactory;
|
||||||
|
|
||||||
|
class ApiKeyMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
private string $apiKey;
|
||||||
|
|
||||||
|
public function __construct(string $apiKey)
|
||||||
|
{
|
||||||
|
$this->apiKey = $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
RequestHandlerInterface $handler
|
||||||
|
): ResponseInterface {
|
||||||
|
$headers = $request->getHeaders();
|
||||||
|
$providedKey = null;
|
||||||
|
|
||||||
|
// Case-insensitive check for X-API-KEY header
|
||||||
|
foreach ($headers as $name => $values) {
|
||||||
|
if (strtolower($name) === 'x-api-key') {
|
||||||
|
$providedKey = $values[0] ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if API key is provided and matches
|
||||||
|
if (empty($providedKey) || $providedKey !== $this->apiKey) {
|
||||||
|
$responseFactory = new ResponseFactory();
|
||||||
|
$response = $responseFactory->createResponse();
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
['error' => 'unauthorized'],
|
||||||
|
401
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
85
src/Middleware/JwtMiddleware.php
Normal file
85
src/Middleware/JwtMiddleware.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Support\Jwt;
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Slim\Psr7\Factory\ResponseFactory;
|
||||||
|
|
||||||
|
class JwtMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function process(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
RequestHandlerInterface $handler
|
||||||
|
): ResponseInterface {
|
||||||
|
// Get Authorization header
|
||||||
|
$authHeader = $request->getHeaderLine('Authorization');
|
||||||
|
|
||||||
|
if (empty($authHeader)) {
|
||||||
|
$responseFactory = new ResponseFactory();
|
||||||
|
$response = $responseFactory->createResponse();
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
['error' => 'unauthorized', 'message' => 'Missing authorization header'],
|
||||||
|
401
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract Bearer token
|
||||||
|
if (!preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
|
||||||
|
$responseFactory = new ResponseFactory();
|
||||||
|
$response = $responseFactory->createResponse();
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
['error' => 'unauthorized', 'message' => 'Invalid authorization format'],
|
||||||
|
401
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $matches[1];
|
||||||
|
|
||||||
|
// Get JWT secret
|
||||||
|
$jwtSecret = AppConfig::get('JWT_SECRET', '');
|
||||||
|
|
||||||
|
if (empty($jwtSecret)) {
|
||||||
|
$responseFactory = new ResponseFactory();
|
||||||
|
$response = $responseFactory->createResponse();
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
['error' => 'server_error', 'message' => 'JWT configuration error'],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode and validate token
|
||||||
|
$payload = Jwt::decode($token, $jwtSecret);
|
||||||
|
|
||||||
|
// Inject user context to request attributes
|
||||||
|
$request = $request->withAttribute('user_id', (int) $payload['sub']);
|
||||||
|
$request = $request->withAttribute('username', $payload['username'] ?? '');
|
||||||
|
$request = $request->withAttribute('role', $payload['role'] ?? '');
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
|
||||||
|
} catch (InvalidArgumentException | RuntimeException $e) {
|
||||||
|
$responseFactory = new ResponseFactory();
|
||||||
|
$response = $responseFactory->createResponse();
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
['error' => 'unauthorized', 'message' => 'Invalid or expired token'],
|
||||||
|
401
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
45
src/Middleware/RoleMiddleware.php
Normal file
45
src/Middleware/RoleMiddleware.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
use Slim\Psr7\Factory\ResponseFactory;
|
||||||
|
|
||||||
|
class RoleMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
private array $allowedRoles;
|
||||||
|
|
||||||
|
public function __construct(array $allowedRoles)
|
||||||
|
{
|
||||||
|
$this->allowedRoles = $allowedRoles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
RequestHandlerInterface $handler
|
||||||
|
): ResponseInterface {
|
||||||
|
$userRole = $request->getAttribute('role', '');
|
||||||
|
|
||||||
|
if (empty($userRole) || !in_array($userRole, $this->allowedRoles, true)) {
|
||||||
|
$responseFactory = new ResponseFactory();
|
||||||
|
$response = $responseFactory->createResponse();
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'forbidden',
|
||||||
|
'message' => 'Insufficient permissions'
|
||||||
|
],
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
102
src/Modules/Auth/AuthController.php
Normal file
102
src/Modules/Auth/AuthController.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Auth;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use PDOException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class AuthController
|
||||||
|
{
|
||||||
|
private AuthService $service;
|
||||||
|
|
||||||
|
public function __construct(AuthService $service)
|
||||||
|
{
|
||||||
|
$this->service = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$body = $request->getParsedBody();
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
if (!is_array($body)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['body' => 'Invalid JSON body']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
$username = $body['username'] ?? null;
|
||||||
|
$password = $body['password'] ?? null;
|
||||||
|
|
||||||
|
if (!is_string($username) || empty($username)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['username' => 'Field is required']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_string($password) || empty($password)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['password' => 'Field is required']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->service->login($username, $password);
|
||||||
|
|
||||||
|
if (!$result['success']) {
|
||||||
|
$statusCode = match ($result['error']) {
|
||||||
|
'forbidden' => 403,
|
||||||
|
default => 401
|
||||||
|
};
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
['error' => $result['error']],
|
||||||
|
$statusCode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $result['data'],
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
49
src/Modules/Auth/AuthRoutes.php
Normal file
49
src/Modules/Auth/AuthRoutes.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Auth;
|
||||||
|
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Modules\Auth\AuthController;
|
||||||
|
use App\Modules\Auth\AuthService;
|
||||||
|
use App\Support\Database;
|
||||||
|
use PDO;
|
||||||
|
use Slim\App;
|
||||||
|
|
||||||
|
class AuthRoutes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register auth routes
|
||||||
|
*
|
||||||
|
* @param App $app
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(App $app): void
|
||||||
|
{
|
||||||
|
// Get database connection
|
||||||
|
$dbHost = AppConfig::get('DB_HOST', 'localhost');
|
||||||
|
$dbName = AppConfig::get('DB_NAME', '');
|
||||||
|
$dbUser = AppConfig::get('DB_USER', '');
|
||||||
|
$dbPass = AppConfig::get('DB_PASS', '');
|
||||||
|
|
||||||
|
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
|
||||||
|
|
||||||
|
// Get JWT config
|
||||||
|
$jwtSecret = AppConfig::get('JWT_SECRET', '');
|
||||||
|
$jwtTtl = (int) (AppConfig::get('JWT_TTL_SECONDS', '3600') ?: 3600);
|
||||||
|
$jwtIssuer = AppConfig::get('JWT_ISSUER', 'api-btekno');
|
||||||
|
|
||||||
|
// Initialize service and controller
|
||||||
|
$authService = new AuthService($db, $jwtSecret, $jwtTtl, $jwtIssuer);
|
||||||
|
$authController = new AuthController($authService);
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
$app->group('/auth', function ($group) use ($authController) {
|
||||||
|
$group->group('/v1', function ($v1Group) use ($authController) {
|
||||||
|
$v1Group->post('/login', [$authController, 'login']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
103
src/Modules/Auth/AuthService.php
Normal file
103
src/Modules/Auth/AuthService.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Auth;
|
||||||
|
|
||||||
|
use App\Support\Jwt;
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class AuthService
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
private string $jwtSecret;
|
||||||
|
private int $jwtTtl;
|
||||||
|
private string $jwtIssuer;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
PDO $db,
|
||||||
|
string $jwtSecret,
|
||||||
|
int $jwtTtl,
|
||||||
|
string $jwtIssuer
|
||||||
|
) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->jwtSecret = $jwtSecret;
|
||||||
|
$this->jwtTtl = $jwtTtl;
|
||||||
|
$this->jwtIssuer = $jwtIssuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate user and generate JWT token
|
||||||
|
*
|
||||||
|
* @param string $username
|
||||||
|
* @param string $password
|
||||||
|
* @return array ['success' => bool, 'data' => array|null, 'error' => string|null]
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function login(string $username, string $password): array
|
||||||
|
{
|
||||||
|
// Find user by username
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'SELECT id, username, password, role, is_active
|
||||||
|
FROM users
|
||||||
|
WHERE username = ?
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($user === false) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'data' => null,
|
||||||
|
'error' => 'unauthorized'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if (!password_verify($password, $user['password'])) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'data' => null,
|
||||||
|
'error' => 'unauthorized'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is active
|
||||||
|
if (!$user['is_active']) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'data' => null,
|
||||||
|
'error' => 'forbidden'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
$token = Jwt::encode(
|
||||||
|
[
|
||||||
|
'sub' => (string) $user['id'],
|
||||||
|
'username' => $user['username'],
|
||||||
|
'role' => $user['role']
|
||||||
|
],
|
||||||
|
$this->jwtSecret,
|
||||||
|
$this->jwtTtl,
|
||||||
|
$this->jwtIssuer
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'token' => $token,
|
||||||
|
'expires_in' => $this->jwtTtl,
|
||||||
|
'user' => [
|
||||||
|
'id' => (int) $user['id'],
|
||||||
|
'username' => $user['username'],
|
||||||
|
'role' => $user['role']
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'error' => null
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
35
src/Modules/Health/HealthRoutes.php
Normal file
35
src/Modules/Health/HealthRoutes.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Health;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Slim\App;
|
||||||
|
|
||||||
|
class HealthRoutes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register health check routes
|
||||||
|
*
|
||||||
|
* @param App $app
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(App $app): void
|
||||||
|
{
|
||||||
|
$app->get('/health', function (
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$data = [
|
||||||
|
'status' => 'ok',
|
||||||
|
'time' => time()
|
||||||
|
];
|
||||||
|
|
||||||
|
return ResponseHelper::json($response, $data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
35
src/Modules/Health/Routes.php
Normal file
35
src/Modules/Health/Routes.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Health;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Slim\App;
|
||||||
|
|
||||||
|
class HealthRoutes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register health check routes
|
||||||
|
*
|
||||||
|
* @param App $app
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(App $app): void
|
||||||
|
{
|
||||||
|
$app->get('/health', function (
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$data = [
|
||||||
|
'status' => 'ok',
|
||||||
|
'time' => time()
|
||||||
|
];
|
||||||
|
|
||||||
|
return ResponseHelper::json($response, $data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
269
src/Modules/Retribusi/Dashboard/DashboardController.php
Normal file
269
src/Modules/Retribusi/Dashboard/DashboardController.php
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Dashboard;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use PDOException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class DashboardController
|
||||||
|
{
|
||||||
|
private DashboardService $service;
|
||||||
|
|
||||||
|
public function __construct(DashboardService $service)
|
||||||
|
{
|
||||||
|
$this->service = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daily chart data
|
||||||
|
*
|
||||||
|
* @param ServerRequestInterface $request
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
|
public function getDailyChart(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$queryParams = $request->getQueryParams();
|
||||||
|
|
||||||
|
$startDate = $queryParams['start_date'] ?? null;
|
||||||
|
$endDate = $queryParams['end_date'] ?? null;
|
||||||
|
|
||||||
|
if ($startDate === null || !is_string($startDate)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['start_date' => 'Query parameter start_date is required (Y-m-d format)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($endDate === null || !is_string($endDate)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['end_date' => 'Query parameter end_date is required (Y-m-d format)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
$startDateTime = \DateTime::createFromFormat('Y-m-d', $startDate);
|
||||||
|
if ($startDateTime === false || $startDateTime->format('Y-m-d') !== $startDate) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['start_date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$endDateTime = \DateTime::createFromFormat('Y-m-d', $endDate);
|
||||||
|
if ($endDateTime === false || $endDateTime->format('Y-m-d') !== $endDate) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['end_date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date range
|
||||||
|
if ($startDate > $endDate) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['start_date' => 'start_date must be less than or equal to end_date']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$locationCode = $queryParams['location_code'] ?? null;
|
||||||
|
if ($locationCode !== null && !is_string($locationCode)) {
|
||||||
|
$locationCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateCode = $queryParams['gate_code'] ?? null;
|
||||||
|
if ($gateCode !== null && !is_string($gateCode)) {
|
||||||
|
$gateCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $this->service->getDailyChart($startDate, $endDate, $locationCode, $gateCode);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chart data by category
|
||||||
|
*
|
||||||
|
* @param ServerRequestInterface $request
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
|
public function getByCategoryChart(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$queryParams = $request->getQueryParams();
|
||||||
|
|
||||||
|
$date = $queryParams['date'] ?? null;
|
||||||
|
if ($date === null || !is_string($date)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['date' => 'Query parameter date is required (Y-m-d format)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$locationCode = $queryParams['location_code'] ?? null;
|
||||||
|
if ($locationCode !== null && !is_string($locationCode)) {
|
||||||
|
$locationCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateCode = $queryParams['gate_code'] ?? null;
|
||||||
|
if ($gateCode !== null && !is_string($gateCode)) {
|
||||||
|
$gateCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $this->service->getByCategoryChart($date, $locationCode, $gateCode);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summary statistics
|
||||||
|
*
|
||||||
|
* @param ServerRequestInterface $request
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
|
public function getSummary(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$queryParams = $request->getQueryParams();
|
||||||
|
|
||||||
|
$date = $queryParams['date'] ?? null;
|
||||||
|
if ($date === null || !is_string($date)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['date' => 'Query parameter date is required (Y-m-d format)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$locationCode = $queryParams['location_code'] ?? null;
|
||||||
|
if ($locationCode !== null && !is_string($locationCode)) {
|
||||||
|
$locationCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $this->service->getSummary($date, $locationCode);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
55
src/Modules/Retribusi/Dashboard/DashboardRoutes.php
Normal file
55
src/Modules/Retribusi/Dashboard/DashboardRoutes.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Dashboard;
|
||||||
|
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Middleware\JwtMiddleware;
|
||||||
|
use App\Support\Database;
|
||||||
|
use Slim\App;
|
||||||
|
|
||||||
|
class DashboardRoutes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register dashboard routes
|
||||||
|
*
|
||||||
|
* @param App $app
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(App $app): void
|
||||||
|
{
|
||||||
|
// JWT middleware (read-only, semua role boleh akses)
|
||||||
|
$jwtMiddleware = new JwtMiddleware();
|
||||||
|
|
||||||
|
// Get database connection
|
||||||
|
$dbHost = AppConfig::get('DB_HOST', 'localhost');
|
||||||
|
$dbName = AppConfig::get('DB_NAME', '');
|
||||||
|
$dbUser = AppConfig::get('DB_USER', '');
|
||||||
|
$dbPass = AppConfig::get('DB_PASS', '');
|
||||||
|
|
||||||
|
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
|
||||||
|
|
||||||
|
// Initialize service and controller
|
||||||
|
$dashboardService = new DashboardService($db);
|
||||||
|
$dashboardController = new DashboardController($dashboardService);
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
$app->group('/retribusi', function ($group) use (
|
||||||
|
$jwtMiddleware,
|
||||||
|
$dashboardController
|
||||||
|
) {
|
||||||
|
$group->group('/v1', function ($v1Group) use (
|
||||||
|
$jwtMiddleware,
|
||||||
|
$dashboardController
|
||||||
|
) {
|
||||||
|
$v1Group->group('/dashboard', function ($dashboardGroup) use ($dashboardController) {
|
||||||
|
$dashboardGroup->get('/daily', [$dashboardController, 'getDailyChart']);
|
||||||
|
$dashboardGroup->get('/by-category', [$dashboardController, 'getByCategoryChart']);
|
||||||
|
$dashboardGroup->get('/summary', [$dashboardController, 'getSummary']);
|
||||||
|
})->add($jwtMiddleware);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
220
src/Modules/Retribusi/Dashboard/DashboardService.php
Normal file
220
src/Modules/Retribusi/Dashboard/DashboardService.php
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Dashboard;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class DashboardService
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct(PDO $db)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daily chart data (line chart)
|
||||||
|
*
|
||||||
|
* @param string $startDate
|
||||||
|
* @param string $endDate
|
||||||
|
* @param string|null $locationCode
|
||||||
|
* @param string|null $gateCode
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getDailyChart(
|
||||||
|
string $startDate,
|
||||||
|
string $endDate,
|
||||||
|
?string $locationCode = null,
|
||||||
|
?string $gateCode = null
|
||||||
|
): array {
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
summary_date,
|
||||||
|
SUM(total_count) as total_count,
|
||||||
|
SUM(total_amount) as total_amount
|
||||||
|
FROM daily_summary
|
||||||
|
WHERE summary_date >= ? AND summary_date <= ?
|
||||||
|
";
|
||||||
|
|
||||||
|
$params = [$startDate, $endDate];
|
||||||
|
|
||||||
|
if ($locationCode !== null) {
|
||||||
|
$sql .= " AND location_code = ?";
|
||||||
|
$params[] = $locationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($gateCode !== null) {
|
||||||
|
$sql .= " AND gate_code = ?";
|
||||||
|
$params[] = $gateCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " GROUP BY summary_date ORDER BY summary_date ASC";
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$results = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
$totalCounts = [];
|
||||||
|
$totalAmounts = [];
|
||||||
|
|
||||||
|
foreach ($results as $row) {
|
||||||
|
$labels[] = $row['summary_date'];
|
||||||
|
$totalCounts[] = (int) $row['total_count'];
|
||||||
|
$totalAmounts[] = (int) $row['total_amount'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'labels' => $labels,
|
||||||
|
'series' => [
|
||||||
|
'total_count' => $totalCounts,
|
||||||
|
'total_amount' => $totalAmounts
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chart data by category (bar/donut chart)
|
||||||
|
*
|
||||||
|
* @param string $date
|
||||||
|
* @param string|null $locationCode
|
||||||
|
* @param string|null $gateCode
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getByCategoryChart(
|
||||||
|
string $date,
|
||||||
|
?string $locationCode = null,
|
||||||
|
?string $gateCode = null
|
||||||
|
): array {
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
category,
|
||||||
|
SUM(total_count) as total_count,
|
||||||
|
SUM(total_amount) as total_amount
|
||||||
|
FROM daily_summary
|
||||||
|
WHERE summary_date = ?
|
||||||
|
";
|
||||||
|
|
||||||
|
$params = [$date];
|
||||||
|
|
||||||
|
if ($locationCode !== null) {
|
||||||
|
$sql .= " AND location_code = ?";
|
||||||
|
$params[] = $locationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($gateCode !== null) {
|
||||||
|
$sql .= " AND gate_code = ?";
|
||||||
|
$params[] = $gateCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " GROUP BY category ORDER BY category ASC";
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$results = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
$totalCounts = [];
|
||||||
|
$totalAmounts = [];
|
||||||
|
|
||||||
|
foreach ($results as $row) {
|
||||||
|
$labels[] = $row['category'];
|
||||||
|
$totalCounts[] = (int) $row['total_count'];
|
||||||
|
$totalAmounts[] = (int) $row['total_amount'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'labels' => $labels,
|
||||||
|
'series' => [
|
||||||
|
'total_count' => $totalCounts,
|
||||||
|
'total_amount' => $totalAmounts
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summary statistics (stat cards)
|
||||||
|
*
|
||||||
|
* @param string $date
|
||||||
|
* @param string|null $locationCode
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getSummary(string $date, ?string $locationCode = null): array
|
||||||
|
{
|
||||||
|
// Get total count and amount from daily_summary
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
SUM(total_count) as total_count,
|
||||||
|
SUM(total_amount) as total_amount
|
||||||
|
FROM daily_summary
|
||||||
|
WHERE summary_date = ?
|
||||||
|
";
|
||||||
|
|
||||||
|
$params = [$date];
|
||||||
|
|
||||||
|
if ($locationCode !== null) {
|
||||||
|
$sql .= " AND location_code = ?";
|
||||||
|
$params[] = $locationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$summary = $stmt->fetch();
|
||||||
|
|
||||||
|
$totalCount = (int) ($summary['total_count'] ?? 0);
|
||||||
|
$totalAmount = (int) ($summary['total_amount'] ?? 0);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$locationsStmt = $this->db->prepare($locationsSql);
|
||||||
|
$locationsStmt->execute($locationsParams);
|
||||||
|
$locationsResult = $locationsStmt->fetch();
|
||||||
|
$activeLocations = (int) ($locationsResult['active_locations'] ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_count' => $totalCount,
|
||||||
|
'total_amount' => $totalAmount,
|
||||||
|
'active_gates' => $activeGates,
|
||||||
|
'active_locations' => $activeLocations
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
92
src/Modules/Retribusi/Frontend/AuditService.php
Normal file
92
src/Modules/Retribusi/Frontend/AuditService.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Frontend;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class AuditService
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct(PDO $db)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log audit entry
|
||||||
|
*
|
||||||
|
* @param int $actorUserId
|
||||||
|
* @param string $actorUsername
|
||||||
|
* @param string $actorRole
|
||||||
|
* @param string $action
|
||||||
|
* @param string $entity
|
||||||
|
* @param string $entityKey
|
||||||
|
* @param array|null $beforeData
|
||||||
|
* @param array|null $afterData
|
||||||
|
* @param string $ipAddress
|
||||||
|
* @param string|null $userAgent
|
||||||
|
* @return void
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function log(
|
||||||
|
int $actorUserId,
|
||||||
|
string $actorUsername,
|
||||||
|
string $actorRole,
|
||||||
|
string $action,
|
||||||
|
string $entity,
|
||||||
|
string $entityKey,
|
||||||
|
?array $beforeData,
|
||||||
|
?array $afterData,
|
||||||
|
string $ipAddress,
|
||||||
|
?string $userAgent = null
|
||||||
|
): void {
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'INSERT INTO audit_logs
|
||||||
|
(actor_user_id, actor_username, actor_role, action, entity, entity_key,
|
||||||
|
before_json, after_json, ip_address, user_agent, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'
|
||||||
|
);
|
||||||
|
|
||||||
|
$beforeJson = $beforeData !== null ? json_encode($beforeData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
|
||||||
|
$afterJson = $afterData !== null ? json_encode($afterData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$actorUserId,
|
||||||
|
$actorUsername,
|
||||||
|
$actorRole,
|
||||||
|
$action,
|
||||||
|
$entity,
|
||||||
|
$entityKey,
|
||||||
|
$beforeJson,
|
||||||
|
$afterJson,
|
||||||
|
$ipAddress,
|
||||||
|
$userAgent
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP address from request
|
||||||
|
*
|
||||||
|
* @param array $serverParams
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function getClientIp(array $serverParams): string
|
||||||
|
{
|
||||||
|
// Check for forwarded IP (behind proxy/load balancer)
|
||||||
|
if (isset($serverParams['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
$ips = explode(',', $serverParams['HTTP_X_FORWARDED_FOR']);
|
||||||
|
return trim($ips[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($serverParams['HTTP_X_REAL_IP'])) {
|
||||||
|
return $serverParams['HTTP_X_REAL_IP'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $serverParams['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
364
src/Modules/Retribusi/Frontend/GateController.php
Normal file
364
src/Modules/Retribusi/Frontend/GateController.php
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Frontend;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use App\Support\Validator;
|
||||||
|
use PDOException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class GateController
|
||||||
|
{
|
||||||
|
private RetribusiReadService $readService;
|
||||||
|
private RetribusiWriteService $writeService;
|
||||||
|
private AuditService $auditService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
RetribusiReadService $readService,
|
||||||
|
RetribusiWriteService $writeService,
|
||||||
|
AuditService $auditService
|
||||||
|
) {
|
||||||
|
$this->readService = $readService;
|
||||||
|
$this->writeService = $writeService;
|
||||||
|
$this->auditService = $auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGates(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$queryParams = $request->getQueryParams();
|
||||||
|
[$page, $limit] = Validator::validatePagination($queryParams);
|
||||||
|
|
||||||
|
$locationCode = $queryParams['location_code'] ?? null;
|
||||||
|
if ($locationCode !== null && !is_string($locationCode)) {
|
||||||
|
$locationCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->readService->getGates($page, $limit, $locationCode);
|
||||||
|
$total = $this->readService->getGatesTotal($locationCode);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'meta' => [
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'total' => $total,
|
||||||
|
'pages' => (int) ceil($total / $limit)
|
||||||
|
],
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createGate(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$body = $request->getParsedBody();
|
||||||
|
|
||||||
|
if (!is_array($body)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['body' => 'Invalid JSON body']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = Validator::validateGate($body, false);
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => $errors
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if gate already exists
|
||||||
|
$existing = $this->writeService->getGate($body['location_code'], $body['gate_code']);
|
||||||
|
if ($existing !== null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'conflict',
|
||||||
|
'message' => 'Gate with this location_code and gate_code already exists'
|
||||||
|
],
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create gate
|
||||||
|
$data = $this->writeService->createGate($body);
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
$entityKey = $body['location_code'] . ':' . $body['gate_code'];
|
||||||
|
$serverParams = $request->getServerParams();
|
||||||
|
$this->auditService->log(
|
||||||
|
(int) $request->getAttribute('user_id'),
|
||||||
|
$request->getAttribute('username', ''),
|
||||||
|
$request->getAttribute('role', ''),
|
||||||
|
'create',
|
||||||
|
'gates',
|
||||||
|
$entityKey,
|
||||||
|
null,
|
||||||
|
$data,
|
||||||
|
AuditService::getClientIp($serverParams),
|
||||||
|
$serverParams['HTTP_USER_AGENT'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'timestamp' => time()
|
||||||
|
],
|
||||||
|
201
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ($e->getCode() === '23000') {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'conflict',
|
||||||
|
'message' => 'Gate with this location_code and gate_code already exists'
|
||||||
|
],
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateGate(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response,
|
||||||
|
array $args
|
||||||
|
): ResponseInterface {
|
||||||
|
$locationCode = $args['location_code'] ?? null;
|
||||||
|
$gateCode = $args['gate_code'] ?? null;
|
||||||
|
|
||||||
|
if ($locationCode === null || !is_string($locationCode) ||
|
||||||
|
$gateCode === null || !is_string($gateCode)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['location_code' => 'Invalid location_code or gate_code']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $request->getParsedBody();
|
||||||
|
if (!is_array($body)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['body' => 'Invalid JSON body']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent changing immutable fields
|
||||||
|
if (isset($body['location_code']) && $body['location_code'] !== $locationCode) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['location_code' => 'Location code is immutable']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($body['gate_code']) && $body['gate_code'] !== $gateCode) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['gate_code' => 'Gate code is immutable']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = Validator::validateGate($body, true);
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => $errors
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$before = $this->writeService->getGate($locationCode, $gateCode);
|
||||||
|
if ($before === null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'not_found',
|
||||||
|
'message' => 'Gate not found'
|
||||||
|
],
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = $this->writeService->updateGate($locationCode, $gateCode, $body);
|
||||||
|
if ($after === null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'not_found',
|
||||||
|
'message' => 'Gate not found'
|
||||||
|
],
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityKey = $locationCode . ':' . $gateCode;
|
||||||
|
$serverParams = $request->getServerParams();
|
||||||
|
$this->auditService->log(
|
||||||
|
(int) $request->getAttribute('user_id'),
|
||||||
|
$request->getAttribute('username', ''),
|
||||||
|
$request->getAttribute('role', ''),
|
||||||
|
'update',
|
||||||
|
'gates',
|
||||||
|
$entityKey,
|
||||||
|
$before,
|
||||||
|
$after,
|
||||||
|
AuditService::getClientIp($serverParams),
|
||||||
|
$serverParams['HTTP_USER_AGENT'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $after,
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteGate(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response,
|
||||||
|
array $args
|
||||||
|
): ResponseInterface {
|
||||||
|
$locationCode = $args['location_code'] ?? null;
|
||||||
|
$gateCode = $args['gate_code'] ?? null;
|
||||||
|
|
||||||
|
if ($locationCode === null || !is_string($locationCode) ||
|
||||||
|
$gateCode === null || !is_string($gateCode)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['location_code' => 'Invalid location_code or gate_code']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$before = $this->writeService->getGate($locationCode, $gateCode);
|
||||||
|
if ($before === null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'not_found',
|
||||||
|
'message' => 'Gate not found'
|
||||||
|
],
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = $this->writeService->deleteGate($locationCode, $gateCode);
|
||||||
|
if (!$deleted) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Failed to delete gate'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = $this->writeService->getGate($locationCode, $gateCode);
|
||||||
|
$entityKey = $locationCode . ':' . $gateCode;
|
||||||
|
$serverParams = $request->getServerParams();
|
||||||
|
$this->auditService->log(
|
||||||
|
(int) $request->getAttribute('user_id'),
|
||||||
|
$request->getAttribute('username', ''),
|
||||||
|
$request->getAttribute('role', ''),
|
||||||
|
'delete',
|
||||||
|
'gates',
|
||||||
|
$entityKey,
|
||||||
|
$before,
|
||||||
|
$after,
|
||||||
|
AuditService::getClientIp($serverParams),
|
||||||
|
$serverParams['HTTP_USER_AGENT'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => ['deleted' => true],
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
349
src/Modules/Retribusi/Frontend/LocationController.php
Normal file
349
src/Modules/Retribusi/Frontend/LocationController.php
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Frontend;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use App\Support\Validator;
|
||||||
|
use PDOException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class LocationController
|
||||||
|
{
|
||||||
|
private RetribusiReadService $readService;
|
||||||
|
private RetribusiWriteService $writeService;
|
||||||
|
private AuditService $auditService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
RetribusiReadService $readService,
|
||||||
|
RetribusiWriteService $writeService,
|
||||||
|
AuditService $auditService
|
||||||
|
) {
|
||||||
|
$this->readService = $readService;
|
||||||
|
$this->writeService = $writeService;
|
||||||
|
$this->auditService = $auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLocations(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$queryParams = $request->getQueryParams();
|
||||||
|
[$page, $limit] = Validator::validatePagination($queryParams);
|
||||||
|
|
||||||
|
$data = $this->readService->getLocations($page, $limit);
|
||||||
|
$total = $this->readService->getLocationsTotal();
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'meta' => [
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'total' => $total,
|
||||||
|
'pages' => (int) ceil($total / $limit)
|
||||||
|
],
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createLocation(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$body = $request->getParsedBody();
|
||||||
|
|
||||||
|
if (!is_array($body)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['body' => 'Invalid JSON body']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = Validator::validateLocation($body, false);
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => $errors
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if location already exists
|
||||||
|
$existing = $this->writeService->getLocation($body['code']);
|
||||||
|
if ($existing !== null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'conflict',
|
||||||
|
'message' => 'Location with this code already exists'
|
||||||
|
],
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create location
|
||||||
|
$data = $this->writeService->createLocation($body);
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
$serverParams = $request->getServerParams();
|
||||||
|
$this->auditService->log(
|
||||||
|
(int) $request->getAttribute('user_id'),
|
||||||
|
$request->getAttribute('username', ''),
|
||||||
|
$request->getAttribute('role', ''),
|
||||||
|
'create',
|
||||||
|
'locations',
|
||||||
|
$body['code'],
|
||||||
|
null,
|
||||||
|
$data,
|
||||||
|
AuditService::getClientIp($serverParams),
|
||||||
|
$serverParams['HTTP_USER_AGENT'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'timestamp' => time()
|
||||||
|
],
|
||||||
|
201
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// Check for unique constraint violation
|
||||||
|
if ($e->getCode() === '23000') {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'conflict',
|
||||||
|
'message' => 'Location with this code already exists'
|
||||||
|
],
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateLocation(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response,
|
||||||
|
array $args
|
||||||
|
): ResponseInterface {
|
||||||
|
$code = $args['code'] ?? null;
|
||||||
|
if ($code === null || !is_string($code)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['code' => 'Invalid location code']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $request->getParsedBody();
|
||||||
|
if (!is_array($body)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['body' => 'Invalid JSON body']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent changing code
|
||||||
|
if (isset($body['code']) && $body['code'] !== $code) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['code' => 'Code is immutable']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = Validator::validateLocation($body, true);
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => $errors
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if location exists
|
||||||
|
$before = $this->writeService->getLocation($code);
|
||||||
|
if ($before === null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'not_found',
|
||||||
|
'message' => 'Location not found'
|
||||||
|
],
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update location
|
||||||
|
$after = $this->writeService->updateLocation($code, $body);
|
||||||
|
if ($after === null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'not_found',
|
||||||
|
'message' => 'Location not found'
|
||||||
|
],
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
$serverParams = $request->getServerParams();
|
||||||
|
$this->auditService->log(
|
||||||
|
(int) $request->getAttribute('user_id'),
|
||||||
|
$request->getAttribute('username', ''),
|
||||||
|
$request->getAttribute('role', ''),
|
||||||
|
'update',
|
||||||
|
'locations',
|
||||||
|
$code,
|
||||||
|
$before,
|
||||||
|
$after,
|
||||||
|
AuditService::getClientIp($serverParams),
|
||||||
|
$serverParams['HTTP_USER_AGENT'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $after,
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteLocation(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response,
|
||||||
|
array $args
|
||||||
|
): ResponseInterface {
|
||||||
|
$code = $args['code'] ?? null;
|
||||||
|
if ($code === null || !is_string($code)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['code' => 'Invalid location code']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if location exists
|
||||||
|
$before = $this->writeService->getLocation($code);
|
||||||
|
if ($before === null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'not_found',
|
||||||
|
'message' => 'Location not found'
|
||||||
|
],
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
$deleted = $this->writeService->deleteLocation($code);
|
||||||
|
if (!$deleted) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Failed to delete location'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get after state (is_active=0)
|
||||||
|
$after = $this->writeService->getLocation($code);
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
$serverParams = $request->getServerParams();
|
||||||
|
$this->auditService->log(
|
||||||
|
(int) $request->getAttribute('user_id'),
|
||||||
|
$request->getAttribute('username', ''),
|
||||||
|
$request->getAttribute('role', ''),
|
||||||
|
'delete',
|
||||||
|
'locations',
|
||||||
|
$code,
|
||||||
|
$before,
|
||||||
|
$after,
|
||||||
|
AuditService::getClientIp($serverParams),
|
||||||
|
$serverParams['HTTP_USER_AGENT'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => ['deleted' => true],
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
143
src/Modules/Retribusi/Frontend/RetribusiReadService.php
Normal file
143
src/Modules/Retribusi/Frontend/RetribusiReadService.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Frontend;
|
||||||
|
|
||||||
|
use App\Support\Database;
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class RetribusiReadService
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct(PDO $db)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get locations list with pagination
|
||||||
|
*
|
||||||
|
* @param int $page
|
||||||
|
* @param int $limit
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getLocations(int $page, int $limit): array
|
||||||
|
{
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'SELECT code, name, type, is_active
|
||||||
|
FROM locations
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT ? OFFSET ?'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(1, $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total count of locations
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getLocationsTotal(): int
|
||||||
|
{
|
||||||
|
$stmt = $this->db->query('SELECT COUNT(*) FROM locations');
|
||||||
|
return (int) $stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get gates list with pagination and optional location filter
|
||||||
|
*
|
||||||
|
* @param int $page
|
||||||
|
* @param int $limit
|
||||||
|
* @param string|null $locationCode
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getGates(int $page, int $limit, ?string $locationCode = null): array
|
||||||
|
{
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
if ($locationCode !== null) {
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'SELECT g.location_code, g.gate_code, g.name, g.direction, g.is_active,
|
||||||
|
l.name as location_name
|
||||||
|
FROM gates g
|
||||||
|
INNER JOIN locations l ON g.location_code = l.code
|
||||||
|
WHERE g.location_code = ?
|
||||||
|
ORDER BY g.location_code, g.gate_code ASC
|
||||||
|
LIMIT ? OFFSET ?'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(1, $locationCode, PDO::PARAM_STR);
|
||||||
|
$stmt->bindValue(2, $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(3, $offset, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
} else {
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'SELECT g.location_code, g.gate_code, g.name, g.direction, g.is_active,
|
||||||
|
l.name as location_name
|
||||||
|
FROM gates g
|
||||||
|
INNER JOIN locations l ON g.location_code = l.code
|
||||||
|
ORDER BY g.location_code, g.gate_code ASC
|
||||||
|
LIMIT ? OFFSET ?'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(1, $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total count of gates
|
||||||
|
*
|
||||||
|
* @param string|null $locationCode
|
||||||
|
* @return int
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getGatesTotal(?string $locationCode = null): int
|
||||||
|
{
|
||||||
|
if ($locationCode !== null) {
|
||||||
|
$stmt = $this->db->prepare('SELECT COUNT(*) FROM gates WHERE location_code = ?');
|
||||||
|
$stmt->execute([$locationCode]);
|
||||||
|
} else {
|
||||||
|
$stmt = $this->db->query('SELECT COUNT(*) FROM gates');
|
||||||
|
}
|
||||||
|
return (int) $stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get streams list (alias for gates, sementara)
|
||||||
|
*
|
||||||
|
* @param int $page
|
||||||
|
* @param int $limit
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getStreams(int $page, int $limit): array
|
||||||
|
{
|
||||||
|
// Sementara stream = gate (alias)
|
||||||
|
return $this->getGates($page, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total count of streams
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getStreamsTotal(): int
|
||||||
|
{
|
||||||
|
return $this->getGatesTotal();
|
||||||
|
}
|
||||||
|
}
|
||||||
326
src/Modules/Retribusi/Frontend/RetribusiWriteService.php
Normal file
326
src/Modules/Retribusi/Frontend/RetribusiWriteService.php
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Frontend;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class RetribusiWriteService
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct(PDO $db)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get location by code
|
||||||
|
*
|
||||||
|
* @param string $code
|
||||||
|
* @return array|null
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getLocation(string $code): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'SELECT code, name, type, is_active
|
||||||
|
FROM locations
|
||||||
|
WHERE code = ?
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->execute([$code]);
|
||||||
|
$result = $stmt->fetch();
|
||||||
|
return $result !== false ? $result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create location
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function createLocation(array $data): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'INSERT INTO locations (code, name, type, is_active)
|
||||||
|
VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$data['code'],
|
||||||
|
$data['name'],
|
||||||
|
$data['type'],
|
||||||
|
$data['is_active']
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->getLocation($data['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update location
|
||||||
|
*
|
||||||
|
* @param string $code
|
||||||
|
* @param array $data
|
||||||
|
* @return array|null
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function updateLocation(string $code, array $data): ?array
|
||||||
|
{
|
||||||
|
$updates = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
$updates[] = 'name = ?';
|
||||||
|
$params[] = $data['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['type'])) {
|
||||||
|
$updates[] = 'type = ?';
|
||||||
|
$params[] = $data['type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['is_active'])) {
|
||||||
|
$updates[] = 'is_active = ?';
|
||||||
|
$params[] = $data['is_active'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($updates)) {
|
||||||
|
return $this->getLocation($code);
|
||||||
|
}
|
||||||
|
|
||||||
|
$params[] = $code;
|
||||||
|
$sql = 'UPDATE locations SET ' . implode(', ', $updates) . ' WHERE code = ?';
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
return $this->getLocation($code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete location (set is_active=0)
|
||||||
|
*
|
||||||
|
* @param string $code
|
||||||
|
* @return bool
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function deleteLocation(string $code): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'UPDATE locations SET is_active = 0 WHERE code = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$code]);
|
||||||
|
return $stmt->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get gate by location_code and gate_code
|
||||||
|
*
|
||||||
|
* @param string $locationCode
|
||||||
|
* @param string $gateCode
|
||||||
|
* @return array|null
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getGate(string $locationCode, string $gateCode): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'SELECT location_code, gate_code, name, direction, is_active
|
||||||
|
FROM gates
|
||||||
|
WHERE location_code = ? AND gate_code = ?
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->execute([$locationCode, $gateCode]);
|
||||||
|
$result = $stmt->fetch();
|
||||||
|
return $result !== false ? $result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create gate
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function createGate(array $data): array
|
||||||
|
{
|
||||||
|
$direction = isset($data['direction']) ? strtolower($data['direction']) : $data['direction'];
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'INSERT INTO gates (location_code, gate_code, name, direction, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$data['location_code'],
|
||||||
|
$data['gate_code'],
|
||||||
|
$data['name'],
|
||||||
|
$direction,
|
||||||
|
$data['is_active']
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->getGate($data['location_code'], $data['gate_code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update gate
|
||||||
|
*
|
||||||
|
* @param string $locationCode
|
||||||
|
* @param string $gateCode
|
||||||
|
* @param array $data
|
||||||
|
* @return array|null
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function updateGate(string $locationCode, string $gateCode, array $data): ?array
|
||||||
|
{
|
||||||
|
$updates = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
$updates[] = 'name = ?';
|
||||||
|
$params[] = $data['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['direction'])) {
|
||||||
|
$updates[] = 'direction = ?';
|
||||||
|
$params[] = strtolower($data['direction']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['is_active'])) {
|
||||||
|
$updates[] = 'is_active = ?';
|
||||||
|
$params[] = $data['is_active'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($updates)) {
|
||||||
|
return $this->getGate($locationCode, $gateCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$params[] = $locationCode;
|
||||||
|
$params[] = $gateCode;
|
||||||
|
$sql = 'UPDATE gates SET ' . implode(', ', $updates) . ' WHERE location_code = ? AND gate_code = ?';
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
return $this->getGate($locationCode, $gateCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete gate (set is_active=0)
|
||||||
|
*
|
||||||
|
* @param string $locationCode
|
||||||
|
* @param string $gateCode
|
||||||
|
* @return bool
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function deleteGate(string $locationCode, string $gateCode): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'UPDATE gates SET is_active = 0
|
||||||
|
WHERE location_code = ? AND gate_code = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$locationCode, $gateCode]);
|
||||||
|
return $stmt->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tariff by location_code, gate_code, and category
|
||||||
|
*
|
||||||
|
* @param string $locationCode
|
||||||
|
* @param string $gateCode
|
||||||
|
* @param string $category
|
||||||
|
* @return array|null
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getTariff(string $locationCode, string $gateCode, string $category): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'SELECT location_code, gate_code, category, amount
|
||||||
|
FROM tariffs
|
||||||
|
WHERE location_code = ? AND gate_code = ? AND category = ?
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->execute([$locationCode, $gateCode, $category]);
|
||||||
|
$result = $stmt->fetch();
|
||||||
|
return $result !== false ? $result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tariff
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function createTariff(array $data): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'INSERT INTO tariffs (location_code, gate_code, category, amount)
|
||||||
|
VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$data['location_code'],
|
||||||
|
$data['gate_code'],
|
||||||
|
$data['category'],
|
||||||
|
(int) $data['amount']
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->getTariff($data['location_code'], $data['gate_code'], $data['category']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update tariff
|
||||||
|
*
|
||||||
|
* @param string $locationCode
|
||||||
|
* @param string $gateCode
|
||||||
|
* @param string $category
|
||||||
|
* @param array $data
|
||||||
|
* @return array|null
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function updateTariff(string $locationCode, string $gateCode, string $category, array $data): ?array
|
||||||
|
{
|
||||||
|
$updates = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (isset($data['amount'])) {
|
||||||
|
$updates[] = 'amount = ?';
|
||||||
|
$params[] = (int) $data['amount'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($updates)) {
|
||||||
|
return $this->getTariff($locationCode, $gateCode, $category);
|
||||||
|
}
|
||||||
|
|
||||||
|
$params[] = $locationCode;
|
||||||
|
$params[] = $gateCode;
|
||||||
|
$params[] = $category;
|
||||||
|
$sql = 'UPDATE tariffs SET ' . implode(', ', $updates) . ' WHERE location_code = ? AND gate_code = ? AND category = ?';
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
return $this->getTariff($locationCode, $gateCode, $category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete tariff (delete from table, no is_active field)
|
||||||
|
*
|
||||||
|
* @param string $locationCode
|
||||||
|
* @param string $gateCode
|
||||||
|
* @param string $category
|
||||||
|
* @return bool
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function deleteTariff(string $locationCode, string $gateCode, string $category): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'DELETE FROM tariffs
|
||||||
|
WHERE location_code = ? AND gate_code = ? AND category = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$locationCode, $gateCode, $category]);
|
||||||
|
return $stmt->rowCount() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
47
src/Modules/Retribusi/Frontend/StreamController.php
Normal file
47
src/Modules/Retribusi/Frontend/StreamController.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Frontend;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use App\Support\Validator;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class StreamController
|
||||||
|
{
|
||||||
|
private RetribusiReadService $service;
|
||||||
|
|
||||||
|
public function __construct(RetribusiReadService $service)
|
||||||
|
{
|
||||||
|
$this->service = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStreams(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$queryParams = $request->getQueryParams();
|
||||||
|
[$page, $limit] = Validator::validatePagination($queryParams);
|
||||||
|
|
||||||
|
$data = $this->service->getStreams($page, $limit);
|
||||||
|
$total = $this->service->getStreamsTotal();
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'meta' => [
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'total' => $total,
|
||||||
|
'pages' => (int) ceil($total / $limit)
|
||||||
|
],
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
346
src/Modules/Retribusi/Frontend/TariffController.php
Normal file
346
src/Modules/Retribusi/Frontend/TariffController.php
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Frontend;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use App\Support\Validator;
|
||||||
|
use PDOException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class TariffController
|
||||||
|
{
|
||||||
|
private RetribusiWriteService $writeService;
|
||||||
|
private AuditService $auditService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
RetribusiWriteService $writeService,
|
||||||
|
AuditService $auditService
|
||||||
|
) {
|
||||||
|
$this->writeService = $writeService;
|
||||||
|
$this->auditService = $auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createTariff(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$body = $request->getParsedBody();
|
||||||
|
|
||||||
|
if (!is_array($body)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['body' => 'Invalid JSON body']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = Validator::validateTariff($body, false);
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => $errors
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$existing = $this->writeService->getTariff(
|
||||||
|
$body['location_code'],
|
||||||
|
$body['gate_code'],
|
||||||
|
$body['category']
|
||||||
|
);
|
||||||
|
if ($existing !== null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'conflict',
|
||||||
|
'message' => 'Tariff with this location_code, gate_code, and category already exists'
|
||||||
|
],
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->writeService->createTariff($body);
|
||||||
|
|
||||||
|
$entityKey = $body['location_code'] . ':' . $body['gate_code'] . ':' . $body['category'];
|
||||||
|
$serverParams = $request->getServerParams();
|
||||||
|
$this->auditService->log(
|
||||||
|
(int) $request->getAttribute('user_id'),
|
||||||
|
$request->getAttribute('username', ''),
|
||||||
|
$request->getAttribute('role', ''),
|
||||||
|
'create',
|
||||||
|
'tariffs',
|
||||||
|
$entityKey,
|
||||||
|
null,
|
||||||
|
$data,
|
||||||
|
AuditService::getClientIp($serverParams),
|
||||||
|
$serverParams['HTTP_USER_AGENT'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'timestamp' => time()
|
||||||
|
],
|
||||||
|
201
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ($e->getCode() === '23000') {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'conflict',
|
||||||
|
'message' => 'Tariff with this location_code, gate_code, and category already exists'
|
||||||
|
],
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateTariff(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response,
|
||||||
|
array $args
|
||||||
|
): ResponseInterface {
|
||||||
|
$locationCode = $args['location_code'] ?? null;
|
||||||
|
$gateCode = $args['gate_code'] ?? null;
|
||||||
|
$category = $args['category'] ?? null;
|
||||||
|
|
||||||
|
if ($locationCode === null || !is_string($locationCode) ||
|
||||||
|
$gateCode === null || !is_string($gateCode) ||
|
||||||
|
$category === null || !is_string($category)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['location_code' => 'Invalid location_code, gate_code, or category']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $request->getParsedBody();
|
||||||
|
if (!is_array($body)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['body' => 'Invalid JSON body']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent changing immutable fields
|
||||||
|
if (isset($body['location_code']) && $body['location_code'] !== $locationCode) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['location_code' => 'Location code is immutable']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($body['gate_code']) && $body['gate_code'] !== $gateCode) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['gate_code' => 'Gate code is immutable']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($body['category']) && $body['category'] !== $category) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['category' => 'Category is immutable']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = Validator::validateTariff($body, true);
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => $errors
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$before = $this->writeService->getTariff($locationCode, $gateCode, $category);
|
||||||
|
if ($before === null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'not_found',
|
||||||
|
'message' => 'Tariff not found'
|
||||||
|
],
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = $this->writeService->updateTariff($locationCode, $gateCode, $category, $body);
|
||||||
|
if ($after === null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'not_found',
|
||||||
|
'message' => 'Tariff not found'
|
||||||
|
],
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityKey = $locationCode . ':' . $gateCode . ':' . $category;
|
||||||
|
$serverParams = $request->getServerParams();
|
||||||
|
$this->auditService->log(
|
||||||
|
(int) $request->getAttribute('user_id'),
|
||||||
|
$request->getAttribute('username', ''),
|
||||||
|
$request->getAttribute('role', ''),
|
||||||
|
'update',
|
||||||
|
'tariffs',
|
||||||
|
$entityKey,
|
||||||
|
$before,
|
||||||
|
$after,
|
||||||
|
AuditService::getClientIp($serverParams),
|
||||||
|
$serverParams['HTTP_USER_AGENT'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $after,
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteTariff(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response,
|
||||||
|
array $args
|
||||||
|
): ResponseInterface {
|
||||||
|
$locationCode = $args['location_code'] ?? null;
|
||||||
|
$gateCode = $args['gate_code'] ?? null;
|
||||||
|
$category = $args['category'] ?? null;
|
||||||
|
|
||||||
|
if ($locationCode === null || !is_string($locationCode) ||
|
||||||
|
$gateCode === null || !is_string($gateCode) ||
|
||||||
|
$category === null || !is_string($category)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['location_code' => 'Invalid location_code, gate_code, or category']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$before = $this->writeService->getTariff($locationCode, $gateCode, $category);
|
||||||
|
if ($before === null) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'not_found',
|
||||||
|
'message' => 'Tariff not found'
|
||||||
|
],
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = $this->writeService->deleteTariff($locationCode, $gateCode, $category);
|
||||||
|
if (!$deleted) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Failed to delete tariff'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityKey = $locationCode . ':' . $gateCode . ':' . $category;
|
||||||
|
$serverParams = $request->getServerParams();
|
||||||
|
$this->auditService->log(
|
||||||
|
(int) $request->getAttribute('user_id'),
|
||||||
|
$request->getAttribute('username', ''),
|
||||||
|
$request->getAttribute('role', ''),
|
||||||
|
'delete',
|
||||||
|
'tariffs',
|
||||||
|
$entityKey,
|
||||||
|
$before,
|
||||||
|
null,
|
||||||
|
AuditService::getClientIp($serverParams),
|
||||||
|
$serverParams['HTTP_USER_AGENT'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => ['deleted' => true],
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
108
src/Modules/Retribusi/Ingest/IngestController.php
Normal file
108
src/Modules/Retribusi/Ingest/IngestController.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Ingest;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use App\Support\Validator;
|
||||||
|
use PDOException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class IngestController
|
||||||
|
{
|
||||||
|
private IngestService $service;
|
||||||
|
|
||||||
|
public function __construct(IngestService $service)
|
||||||
|
{
|
||||||
|
$this->service = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ingest(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$body = $request->getParsedBody();
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
if (!is_array($body)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['body' => 'Invalid JSON body']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic format validation
|
||||||
|
$errors = Validator::validateIngest($body);
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => $errors
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get source IP
|
||||||
|
$serverParams = $request->getServerParams();
|
||||||
|
$sourceIp = $serverParams['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process ingest with database validation
|
||||||
|
$result = $this->service->processIngest($body, $sourceIp);
|
||||||
|
|
||||||
|
if (!$result['valid']) {
|
||||||
|
// Not found error
|
||||||
|
if ($result['code'] === 'not_found') {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'not_found',
|
||||||
|
'message' => $result['error']
|
||||||
|
],
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other validation errors
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'message' => $result['error']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => ['stored' => true],
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// Database error
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
src/Modules/Retribusi/Ingest/IngestService.php
Normal file
176
src/Modules/Retribusi/Ingest/IngestService.php
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Ingest;
|
||||||
|
|
||||||
|
use App\Support\Database;
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class IngestService
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct(PDO $db)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate location exists and is active
|
||||||
|
*
|
||||||
|
* @param string $locationCode
|
||||||
|
* @return bool
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
private function validateLocation(string $locationCode): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM locations WHERE code = ? AND is_active = 1'
|
||||||
|
);
|
||||||
|
$stmt->execute([$locationCode]);
|
||||||
|
return (int) $stmt->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate gate exists, is active, and matches location
|
||||||
|
*
|
||||||
|
* @param string $locationCode
|
||||||
|
* @param string $gateCode
|
||||||
|
* @return bool
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
private function validateGate(string $locationCode, string $gateCode): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM gates
|
||||||
|
WHERE location_code = ? AND gate_code = ? AND is_active = 1'
|
||||||
|
);
|
||||||
|
$stmt->execute([$locationCode, $gateCode]);
|
||||||
|
return (int) $stmt->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate tariff exists for location+gate+category
|
||||||
|
*
|
||||||
|
* @param string $locationCode
|
||||||
|
* @param string $gateCode
|
||||||
|
* @param string $category
|
||||||
|
* @return bool
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
private function validateTariff(string $locationCode, string $gateCode, string $category): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM tariffs
|
||||||
|
WHERE location_code = ? AND gate_code = ? AND category = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$locationCode, $gateCode, $category]);
|
||||||
|
return (int) $stmt->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process ingest data with validation and storage
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @param string $sourceIp
|
||||||
|
* @return array ['valid' => bool, 'error' => string|null, 'code' => string]
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function processIngest(array $data, string $sourceIp): array
|
||||||
|
{
|
||||||
|
$locationCode = $data['location_code'];
|
||||||
|
$gateCode = $data['gate_code'];
|
||||||
|
$category = $data['category'];
|
||||||
|
|
||||||
|
// Validate location exists and is active
|
||||||
|
if (!$this->validateLocation($locationCode)) {
|
||||||
|
return [
|
||||||
|
'valid' => false,
|
||||||
|
'error' => 'Location not found or inactive',
|
||||||
|
'code' => 'not_found'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate gate exists, is active, and matches location
|
||||||
|
if (!$this->validateGate($locationCode, $gateCode)) {
|
||||||
|
return [
|
||||||
|
'valid' => false,
|
||||||
|
'error' => 'Gate not found, inactive, or does not match location',
|
||||||
|
'code' => 'not_found'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate tariff exists
|
||||||
|
if (!$this->validateTariff($locationCode, $gateCode, $category)) {
|
||||||
|
return [
|
||||||
|
'valid' => false,
|
||||||
|
'error' => 'Tariff not found for location+gate+category',
|
||||||
|
'code' => 'not_found'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert event
|
||||||
|
$eventTime = date('Y-m-d H:i:s', $data['timestamp']);
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'INSERT INTO entry_events
|
||||||
|
(location_code, gate_code, category, event_time, source_ip, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW())'
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$locationCode,
|
||||||
|
$gateCode,
|
||||||
|
$category,
|
||||||
|
$eventTime,
|
||||||
|
$sourceIp
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Publish to realtime events (best effort - don't fail ingest if this fails)
|
||||||
|
$this->publishRealtimeEvent($locationCode, $gateCode, $category, $data['timestamp']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valid' => true,
|
||||||
|
'error' => null,
|
||||||
|
'code' => null
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish event to realtime_events (best effort)
|
||||||
|
*
|
||||||
|
* @param string $locationCode
|
||||||
|
* @param string $gateCode
|
||||||
|
* @param string $category
|
||||||
|
* @param int $eventTimestamp
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function publishRealtimeEvent(
|
||||||
|
string $locationCode,
|
||||||
|
string $gateCode,
|
||||||
|
string $category,
|
||||||
|
int $eventTimestamp
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'INSERT INTO realtime_events
|
||||||
|
(location_code, gate_code, category, event_time, total_count_delta, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 1, NOW())'
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$locationCode,
|
||||||
|
$gateCode,
|
||||||
|
$category,
|
||||||
|
$eventTimestamp
|
||||||
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// Best effort: log error but don't fail ingest
|
||||||
|
// In production, you might want to log this to a file or monitoring system
|
||||||
|
error_log('Failed to publish realtime event: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/Modules/Retribusi/Realtime/RealtimeController.php
Normal file
200
src/Modules/Retribusi/Realtime/RealtimeController.php
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Realtime;
|
||||||
|
|
||||||
|
use PDOException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class RealtimeController
|
||||||
|
{
|
||||||
|
private RealtimeService $service;
|
||||||
|
|
||||||
|
public function __construct(RealtimeService $service)
|
||||||
|
{
|
||||||
|
$this->service = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE stream endpoint
|
||||||
|
*
|
||||||
|
* @param ServerRequestInterface $request
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
|
public function stream(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
// Disable output buffering for SSE
|
||||||
|
if (function_exists('ini_set')) {
|
||||||
|
ini_set('output_buffering', 'off');
|
||||||
|
ini_set('zlib.output_compression', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
$response = $response
|
||||||
|
->withHeader('Content-Type', 'text/event-stream')
|
||||||
|
->withHeader('Cache-Control', 'no-cache')
|
||||||
|
->withHeader('Connection', 'keep-alive')
|
||||||
|
->withHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
||||||
|
|
||||||
|
// Get query parameters
|
||||||
|
$queryParams = $request->getQueryParams();
|
||||||
|
$lastId = isset($queryParams['last_id']) && is_numeric($queryParams['last_id'])
|
||||||
|
? (int) $queryParams['last_id']
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$locationCode = $queryParams['location_code'] ?? null;
|
||||||
|
if ($locationCode !== null && !is_string($locationCode)) {
|
||||||
|
$locationCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get response body stream
|
||||||
|
$body = $response->getBody();
|
||||||
|
|
||||||
|
// Send initial connection message
|
||||||
|
$this->writeSSE($body, 'connected', ['time' => time()], 0);
|
||||||
|
|
||||||
|
// Bounded loop: max 30 seconds per connection
|
||||||
|
$maxDuration = 30;
|
||||||
|
$startTime = time();
|
||||||
|
$lastPingTime = time();
|
||||||
|
|
||||||
|
while ((time() - $startTime) < $maxDuration) {
|
||||||
|
try {
|
||||||
|
// Get new events
|
||||||
|
$events = $this->service->getNewEvents($lastId, $locationCode, 100);
|
||||||
|
|
||||||
|
if (!empty($events)) {
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$eventData = [
|
||||||
|
'location_code' => $event['location_code'],
|
||||||
|
'gate_code' => $event['gate_code'],
|
||||||
|
'category' => $event['category'],
|
||||||
|
'event_time' => (int) $event['event_time'],
|
||||||
|
'delta' => (int) $event['total_count_delta']
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->writeSSE($body, 'ingest', $eventData, (int) $event['id']);
|
||||||
|
$lastId = (int) $event['id'];
|
||||||
|
$lastPingTime = time();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No new events, send ping every 10 seconds
|
||||||
|
if ((time() - $lastPingTime) >= 10) {
|
||||||
|
$this->writeSSE($body, 'ping', ['time' => time()], $lastId);
|
||||||
|
$lastPingTime = time();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small sleep to prevent CPU spinning
|
||||||
|
usleep(500000); // 0.5 seconds
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// Send error event and break
|
||||||
|
$this->writeSSE($body, 'error', ['message' => 'Database error'], $lastId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send close message
|
||||||
|
$this->writeSSE($body, 'close', ['message' => 'Connection timeout'], $lastId);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write SSE event to stream
|
||||||
|
*
|
||||||
|
* @param \Psr\Http\Message\StreamInterface $body
|
||||||
|
* @param string $eventType
|
||||||
|
* @param array $data
|
||||||
|
* @param int $eventId
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function writeSSE(
|
||||||
|
\Psr\Http\Message\StreamInterface $body,
|
||||||
|
string $eventType,
|
||||||
|
array $data,
|
||||||
|
int $eventId
|
||||||
|
): void {
|
||||||
|
$message = "id: {$eventId}\n";
|
||||||
|
$message .= "event: {$eventType}\n";
|
||||||
|
$message .= "data: " . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
|
||||||
|
|
||||||
|
$body->write($message);
|
||||||
|
$body->flush();
|
||||||
|
|
||||||
|
// Also flush PHP output buffer
|
||||||
|
if (ob_get_level() > 0) {
|
||||||
|
ob_flush();
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
|
||||||
|
// FastCGI finish request if available
|
||||||
|
if (function_exists('fastcgi_finish_request')) {
|
||||||
|
fastcgi_finish_request();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get snapshot data
|
||||||
|
*
|
||||||
|
* @param ServerRequestInterface $request
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
|
public function getSnapshot(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$queryParams = $request->getQueryParams();
|
||||||
|
|
||||||
|
$date = $queryParams['date'] ?? date('Y-m-d');
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
|
||||||
|
return \App\Support\ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$locationCode = $queryParams['location_code'] ?? null;
|
||||||
|
if ($locationCode !== null && !is_string($locationCode)) {
|
||||||
|
$locationCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $this->service->getSnapshot($date, $locationCode);
|
||||||
|
|
||||||
|
return \App\Support\ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return \App\Support\ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
54
src/Modules/Retribusi/Realtime/RealtimeRoutes.php
Normal file
54
src/Modules/Retribusi/Realtime/RealtimeRoutes.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Realtime;
|
||||||
|
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Middleware\JwtMiddleware;
|
||||||
|
use App\Support\Database;
|
||||||
|
use Slim\App;
|
||||||
|
|
||||||
|
class RealtimeRoutes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register realtime routes
|
||||||
|
*
|
||||||
|
* @param App $app
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(App $app): void
|
||||||
|
{
|
||||||
|
// JWT middleware (all roles can access)
|
||||||
|
$jwtMiddleware = new JwtMiddleware();
|
||||||
|
|
||||||
|
// Get database connection
|
||||||
|
$dbHost = AppConfig::get('DB_HOST', 'localhost');
|
||||||
|
$dbName = AppConfig::get('DB_NAME', '');
|
||||||
|
$dbUser = AppConfig::get('DB_USER', '');
|
||||||
|
$dbPass = AppConfig::get('DB_PASS', '');
|
||||||
|
|
||||||
|
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
|
||||||
|
|
||||||
|
// Initialize service and controller
|
||||||
|
$realtimeService = new RealtimeService($db);
|
||||||
|
$realtimeController = new RealtimeController($realtimeService);
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
$app->group('/retribusi', function ($group) use (
|
||||||
|
$jwtMiddleware,
|
||||||
|
$realtimeController
|
||||||
|
) {
|
||||||
|
$group->group('/v1', function ($v1Group) use (
|
||||||
|
$jwtMiddleware,
|
||||||
|
$realtimeController
|
||||||
|
) {
|
||||||
|
$v1Group->group('/realtime', function ($realtimeGroup) use ($realtimeController) {
|
||||||
|
$realtimeGroup->get('/stream', [$realtimeController, 'stream']);
|
||||||
|
$realtimeGroup->get('/snapshot', [$realtimeController, 'getSnapshot']);
|
||||||
|
})->add($jwtMiddleware);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
166
src/Modules/Retribusi/Realtime/RealtimeService.php
Normal file
166
src/Modules/Retribusi/Realtime/RealtimeService.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Realtime;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class RealtimeService
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct(PDO $db)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get new events since last_id
|
||||||
|
*
|
||||||
|
* @param int $lastId
|
||||||
|
* @param string|null $locationCode
|
||||||
|
* @param int $limit
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getNewEvents(int $lastId = 0, ?string $locationCode = null, int $limit = 100): array
|
||||||
|
{
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
location_code,
|
||||||
|
gate_code,
|
||||||
|
category,
|
||||||
|
event_time,
|
||||||
|
total_count_delta,
|
||||||
|
created_at
|
||||||
|
FROM realtime_events
|
||||||
|
WHERE id > ?
|
||||||
|
";
|
||||||
|
|
||||||
|
$params = [$lastId];
|
||||||
|
|
||||||
|
if ($locationCode !== null) {
|
||||||
|
$sql .= " AND location_code = ?";
|
||||||
|
$params[] = $locationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY id ASC LIMIT ?";
|
||||||
|
$params[] = $limit;
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get snapshot data for today
|
||||||
|
*
|
||||||
|
* @param string $date
|
||||||
|
* @param string|null $locationCode
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getSnapshot(string $date, ?string $locationCode = null): array
|
||||||
|
{
|
||||||
|
// Get total count and amount from daily_summary (fast)
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
SUM(total_count) as total_count_today,
|
||||||
|
SUM(total_amount) as total_amount_today
|
||||||
|
FROM daily_summary
|
||||||
|
WHERE summary_date = ?
|
||||||
|
";
|
||||||
|
|
||||||
|
$params = [$date];
|
||||||
|
|
||||||
|
if ($locationCode !== null) {
|
||||||
|
$sql .= " AND location_code = ?";
|
||||||
|
$params[] = $locationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$summary = $stmt->fetch();
|
||||||
|
|
||||||
|
$totalCountToday = (int) ($summary['total_count_today'] ?? 0);
|
||||||
|
$totalAmountToday = (int) ($summary['total_amount_today'] ?? 0);
|
||||||
|
|
||||||
|
// Get by gate from daily_summary
|
||||||
|
$gatesSql = "
|
||||||
|
SELECT
|
||||||
|
gate_code,
|
||||||
|
SUM(total_count) as total_count,
|
||||||
|
SUM(total_amount) as total_amount
|
||||||
|
FROM daily_summary
|
||||||
|
WHERE summary_date = ?
|
||||||
|
";
|
||||||
|
|
||||||
|
$gatesParams = [$date];
|
||||||
|
|
||||||
|
if ($locationCode !== null) {
|
||||||
|
$gatesSql .= " AND location_code = ?";
|
||||||
|
$gatesParams[] = $locationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gatesSql .= " GROUP BY gate_code ORDER BY gate_code ASC";
|
||||||
|
|
||||||
|
$gatesStmt = $this->db->prepare($gatesSql);
|
||||||
|
$gatesStmt->execute($gatesParams);
|
||||||
|
$byGate = $gatesStmt->fetchAll();
|
||||||
|
|
||||||
|
// Format by_gate
|
||||||
|
$byGateFormatted = [];
|
||||||
|
foreach ($byGate as $row) {
|
||||||
|
$byGateFormatted[] = [
|
||||||
|
'gate_code' => $row['gate_code'],
|
||||||
|
'total_count' => (int) $row['total_count'],
|
||||||
|
'total_amount' => (int) $row['total_amount']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get by category from daily_summary
|
||||||
|
$categorySql = "
|
||||||
|
SELECT
|
||||||
|
category,
|
||||||
|
SUM(total_count) as total_count,
|
||||||
|
SUM(total_amount) as total_amount
|
||||||
|
FROM daily_summary
|
||||||
|
WHERE summary_date = ?
|
||||||
|
";
|
||||||
|
|
||||||
|
$categoryParams = [$date];
|
||||||
|
|
||||||
|
if ($locationCode !== null) {
|
||||||
|
$categorySql .= " AND location_code = ?";
|
||||||
|
$categoryParams[] = $locationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categorySql .= " GROUP BY category ORDER BY category ASC";
|
||||||
|
|
||||||
|
$categoryStmt = $this->db->prepare($categorySql);
|
||||||
|
$categoryStmt->execute($categoryParams);
|
||||||
|
$byCategory = $categoryStmt->fetchAll();
|
||||||
|
|
||||||
|
// Format by_category
|
||||||
|
$byCategoryFormatted = [];
|
||||||
|
foreach ($byCategory as $row) {
|
||||||
|
$byCategoryFormatted[] = [
|
||||||
|
'category' => $row['category'],
|
||||||
|
'total_count' => (int) $row['total_count'],
|
||||||
|
'total_amount' => (int) $row['total_amount']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_count_today' => $totalCountToday,
|
||||||
|
'total_amount_today' => $totalAmountToday,
|
||||||
|
'by_gate' => $byGateFormatted,
|
||||||
|
'by_category' => $byCategoryFormatted
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
134
src/Modules/Retribusi/RetribusiRoutes.php
Normal file
134
src/Modules/Retribusi/RetribusiRoutes.php
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi;
|
||||||
|
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Middleware\ApiKeyMiddleware;
|
||||||
|
use App\Middleware\JwtMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
use App\Modules\Retribusi\Frontend\AuditService;
|
||||||
|
use App\Modules\Retribusi\Frontend\GateController;
|
||||||
|
use App\Modules\Retribusi\Frontend\LocationController;
|
||||||
|
use App\Modules\Retribusi\Frontend\RetribusiReadService;
|
||||||
|
use App\Modules\Retribusi\Frontend\RetribusiWriteService;
|
||||||
|
use App\Modules\Retribusi\Frontend\StreamController;
|
||||||
|
use App\Modules\Retribusi\Frontend\TariffController;
|
||||||
|
use App\Modules\Retribusi\Ingest\IngestController;
|
||||||
|
use App\Modules\Retribusi\Ingest\IngestService;
|
||||||
|
use App\Support\Database;
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
use Slim\App;
|
||||||
|
|
||||||
|
class RetribusiRoutes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register retribusi routes
|
||||||
|
*
|
||||||
|
* @param App $app
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(App $app): void
|
||||||
|
{
|
||||||
|
// Get API key from environment
|
||||||
|
$apiKey = AppConfig::get('RETRIBUSI_API_KEY', '');
|
||||||
|
$apiKeyMiddleware = new ApiKeyMiddleware($apiKey);
|
||||||
|
|
||||||
|
// JWT middleware for frontend routes
|
||||||
|
$jwtMiddleware = new JwtMiddleware();
|
||||||
|
|
||||||
|
// Role middleware
|
||||||
|
$operatorRoleMiddleware = new RoleMiddleware(['admin', 'operator']);
|
||||||
|
$adminRoleMiddleware = new RoleMiddleware(['admin']);
|
||||||
|
|
||||||
|
// Get database connection
|
||||||
|
$dbHost = AppConfig::get('DB_HOST', 'localhost');
|
||||||
|
$dbName = AppConfig::get('DB_NAME', '');
|
||||||
|
$dbUser = AppConfig::get('DB_USER', '');
|
||||||
|
$dbPass = AppConfig::get('DB_PASS', '');
|
||||||
|
|
||||||
|
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
|
||||||
|
|
||||||
|
// Initialize services with database
|
||||||
|
$ingestService = new IngestService($db);
|
||||||
|
$ingestController = new IngestController($ingestService);
|
||||||
|
|
||||||
|
$readService = new RetribusiReadService($db);
|
||||||
|
$writeService = new RetribusiWriteService($db);
|
||||||
|
$auditService = new AuditService($db);
|
||||||
|
|
||||||
|
$gateController = new GateController($readService, $writeService, $auditService);
|
||||||
|
$locationController = new LocationController($readService, $writeService, $auditService);
|
||||||
|
$streamController = new StreamController($readService);
|
||||||
|
$tariffController = new TariffController($writeService, $auditService);
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
$app->group('/retribusi', function ($group) use (
|
||||||
|
$apiKeyMiddleware,
|
||||||
|
$jwtMiddleware,
|
||||||
|
$operatorRoleMiddleware,
|
||||||
|
$adminRoleMiddleware,
|
||||||
|
$ingestController,
|
||||||
|
$gateController,
|
||||||
|
$locationController,
|
||||||
|
$streamController,
|
||||||
|
$tariffController
|
||||||
|
) {
|
||||||
|
$group->group('/v1', function ($v1Group) use (
|
||||||
|
$apiKeyMiddleware,
|
||||||
|
$jwtMiddleware,
|
||||||
|
$operatorRoleMiddleware,
|
||||||
|
$adminRoleMiddleware,
|
||||||
|
$ingestController,
|
||||||
|
$gateController,
|
||||||
|
$locationController,
|
||||||
|
$streamController,
|
||||||
|
$tariffController
|
||||||
|
) {
|
||||||
|
// Ingest routes (with API key middleware)
|
||||||
|
$v1Group->post('/ingest', [$ingestController, 'ingest'])
|
||||||
|
->add($apiKeyMiddleware);
|
||||||
|
|
||||||
|
// Frontend routes (with JWT middleware)
|
||||||
|
$v1Group->group('/frontend', function ($frontendGroup) use (
|
||||||
|
$operatorRoleMiddleware,
|
||||||
|
$adminRoleMiddleware,
|
||||||
|
$gateController,
|
||||||
|
$locationController,
|
||||||
|
$streamController,
|
||||||
|
$tariffController
|
||||||
|
) {
|
||||||
|
// Read routes (viewer, operator, admin)
|
||||||
|
$frontendGroup->get('/gates', [$gateController, 'getGates']);
|
||||||
|
$frontendGroup->get('/locations', [$locationController, 'getLocations']);
|
||||||
|
$frontendGroup->get('/streams', [$streamController, 'getStreams']);
|
||||||
|
|
||||||
|
// Write routes (operator, admin)
|
||||||
|
$frontendGroup->post('/locations', [$locationController, 'createLocation'])
|
||||||
|
->add($operatorRoleMiddleware);
|
||||||
|
$frontendGroup->put('/locations/{code}', [$locationController, 'updateLocation'])
|
||||||
|
->add($operatorRoleMiddleware);
|
||||||
|
$frontendGroup->delete('/locations/{code}', [$locationController, 'deleteLocation'])
|
||||||
|
->add($adminRoleMiddleware);
|
||||||
|
|
||||||
|
$frontendGroup->post('/gates', [$gateController, 'createGate'])
|
||||||
|
->add($operatorRoleMiddleware);
|
||||||
|
$frontendGroup->put('/gates/{location_code}/{gate_code}', [$gateController, 'updateGate'])
|
||||||
|
->add($operatorRoleMiddleware);
|
||||||
|
$frontendGroup->delete('/gates/{location_code}/{gate_code}', [$gateController, 'deleteGate'])
|
||||||
|
->add($adminRoleMiddleware);
|
||||||
|
|
||||||
|
$frontendGroup->post('/tariffs', [$tariffController, 'createTariff'])
|
||||||
|
->add($operatorRoleMiddleware);
|
||||||
|
$frontendGroup->put('/tariffs/{location_code}/{gate_code}/{category}', [$tariffController, 'updateTariff'])
|
||||||
|
->add($operatorRoleMiddleware);
|
||||||
|
$frontendGroup->delete('/tariffs/{location_code}/{gate_code}/{category}', [$tariffController, 'deleteTariff'])
|
||||||
|
->add($adminRoleMiddleware);
|
||||||
|
})->add($jwtMiddleware);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
157
src/Modules/Retribusi/Summary/DailySummaryService.php
Normal file
157
src/Modules/Retribusi/Summary/DailySummaryService.php
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Summary;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class DailySummaryService
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct(PDO $db)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate daily summary for a specific date
|
||||||
|
*
|
||||||
|
* @param string $date Format: Y-m-d
|
||||||
|
* @return array ['rows_processed' => int, 'date' => string]
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function aggregateForDate(string $date): array
|
||||||
|
{
|
||||||
|
// Validate date 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Aggregate from entry_events
|
||||||
|
// Only count events from active locations, gates, and tariffs
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
DATE(e.event_time) as summary_date,
|
||||||
|
e.location_code,
|
||||||
|
e.gate_code,
|
||||||
|
e.category,
|
||||||
|
COUNT(*) as total_count,
|
||||||
|
COALESCE(t.amount, 0) as tariff_amount
|
||||||
|
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
|
||||||
|
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) = ?
|
||||||
|
GROUP BY
|
||||||
|
DATE(e.event_time),
|
||||||
|
e.location_code,
|
||||||
|
e.gate_code,
|
||||||
|
e.category,
|
||||||
|
t.amount
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute([$date]);
|
||||||
|
$aggregated = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$rowsProcessed = 0;
|
||||||
|
|
||||||
|
// Upsert to daily_summary
|
||||||
|
$upsertSql = "
|
||||||
|
INSERT INTO daily_summary
|
||||||
|
(summary_date, location_code, gate_code, category, total_count, total_amount, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
total_count = VALUES(total_count),
|
||||||
|
total_amount = VALUES(total_amount),
|
||||||
|
updated_at = NOW()
|
||||||
|
";
|
||||||
|
|
||||||
|
$upsertStmt = $this->db->prepare($upsertSql);
|
||||||
|
|
||||||
|
foreach ($aggregated as $row) {
|
||||||
|
$totalAmount = (int) $row['total_count'] * (int) $row['tariff_amount'];
|
||||||
|
|
||||||
|
$upsertStmt->execute([
|
||||||
|
$row['summary_date'],
|
||||||
|
$row['location_code'],
|
||||||
|
$row['gate_code'],
|
||||||
|
$row['category'],
|
||||||
|
(int) $row['total_count'],
|
||||||
|
$totalAmount
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rowsProcessed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->commit();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows_processed' => $rowsProcessed,
|
||||||
|
'date' => $date
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$this->db->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daily summary data
|
||||||
|
*
|
||||||
|
* @param string $date
|
||||||
|
* @param string|null $locationCode
|
||||||
|
* @param string|null $gateCode
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getDailySummary(
|
||||||
|
string $date,
|
||||||
|
?string $locationCode = null,
|
||||||
|
?string $gateCode = null
|
||||||
|
): array {
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
summary_date,
|
||||||
|
location_code,
|
||||||
|
gate_code,
|
||||||
|
category,
|
||||||
|
total_count,
|
||||||
|
total_amount
|
||||||
|
FROM daily_summary
|
||||||
|
WHERE summary_date = ?
|
||||||
|
";
|
||||||
|
|
||||||
|
$params = [$date];
|
||||||
|
|
||||||
|
if ($locationCode !== null) {
|
||||||
|
$sql .= " AND location_code = ?";
|
||||||
|
$params[] = $locationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($gateCode !== null) {
|
||||||
|
$sql .= " AND gate_code = ?";
|
||||||
|
$params[] = $gateCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY location_code, gate_code, category";
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
195
src/Modules/Retribusi/Summary/HourlySummaryService.php
Normal file
195
src/Modules/Retribusi/Summary/HourlySummaryService.php
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Summary;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class HourlySummaryService
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct(PDO $db)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate hourly summary for a specific date
|
||||||
|
*
|
||||||
|
* @param string $date Format: Y-m-d
|
||||||
|
* @return array ['rows_processed' => int, 'date' => string]
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function aggregateForDate(string $date): array
|
||||||
|
{
|
||||||
|
// Validate date 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Aggregate from entry_events
|
||||||
|
// Group by hour, location, gate, category
|
||||||
|
// Only count events from active locations, gates, and tariffs
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
DATE(e.event_time) as summary_date,
|
||||||
|
HOUR(e.event_time) as summary_hour,
|
||||||
|
e.location_code,
|
||||||
|
e.gate_code,
|
||||||
|
e.category,
|
||||||
|
COUNT(*) as total_count,
|
||||||
|
COALESCE(t.amount, 0) as tariff_amount
|
||||||
|
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
|
||||||
|
LEFT JOIN tariffs t ON e.location_code = t.location_code
|
||||||
|
AND e.gate_code = t.gate_code
|
||||||
|
AND e.category = t.category
|
||||||
|
AND t.is_active = 1
|
||||||
|
WHERE DATE(e.event_time) = ?
|
||||||
|
GROUP BY
|
||||||
|
DATE(e.event_time),
|
||||||
|
HOUR(e.event_time),
|
||||||
|
e.location_code,
|
||||||
|
e.gate_code,
|
||||||
|
e.category,
|
||||||
|
t.amount
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute([$date]);
|
||||||
|
$aggregated = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$rowsProcessed = 0;
|
||||||
|
|
||||||
|
// Upsert to hourly_summary
|
||||||
|
$upsertSql = "
|
||||||
|
INSERT INTO hourly_summary
|
||||||
|
(summary_date, summary_hour, location_code, gate_code, category, total_count, total_amount, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
total_count = VALUES(total_count),
|
||||||
|
total_amount = VALUES(total_amount),
|
||||||
|
updated_at = NOW()
|
||||||
|
";
|
||||||
|
|
||||||
|
$upsertStmt = $this->db->prepare($upsertSql);
|
||||||
|
|
||||||
|
foreach ($aggregated as $row) {
|
||||||
|
$totalAmount = (int) $row['total_count'] * (int) $row['tariff_amount'];
|
||||||
|
|
||||||
|
$upsertStmt->execute([
|
||||||
|
$row['summary_date'],
|
||||||
|
(int) $row['summary_hour'],
|
||||||
|
$row['location_code'],
|
||||||
|
$row['gate_code'],
|
||||||
|
$row['category'],
|
||||||
|
(int) $row['total_count'],
|
||||||
|
$totalAmount
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rowsProcessed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->commit();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows_processed' => $rowsProcessed,
|
||||||
|
'date' => $date
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$this->db->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hourly summary data for chart
|
||||||
|
*
|
||||||
|
* @param string $date
|
||||||
|
* @param string|null $locationCode
|
||||||
|
* @param string|null $gateCode
|
||||||
|
* @return array
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public function getHourlySummary(
|
||||||
|
string $date,
|
||||||
|
?string $locationCode = null,
|
||||||
|
?string $gateCode = null
|
||||||
|
): array {
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
summary_hour,
|
||||||
|
SUM(total_count) as total_count,
|
||||||
|
SUM(total_amount) as total_amount
|
||||||
|
FROM hourly_summary
|
||||||
|
WHERE summary_date = ?
|
||||||
|
";
|
||||||
|
|
||||||
|
$params = [$date];
|
||||||
|
|
||||||
|
if ($locationCode !== null) {
|
||||||
|
$sql .= " AND location_code = ?";
|
||||||
|
$params[] = $locationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($gateCode !== null) {
|
||||||
|
$sql .= " AND gate_code = ?";
|
||||||
|
$params[] = $gateCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " GROUP BY summary_hour ORDER BY summary_hour ASC";
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$results = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Initialize arrays for all 24 hours (0-23)
|
||||||
|
$hourlyData = [];
|
||||||
|
for ($hour = 0; $hour < 24; $hour++) {
|
||||||
|
$hourlyData[$hour] = [
|
||||||
|
'total_count' => 0,
|
||||||
|
'total_amount' => 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill in actual data
|
||||||
|
foreach ($results as $row) {
|
||||||
|
$hour = (int) $row['summary_hour'];
|
||||||
|
$hourlyData[$hour] = [
|
||||||
|
'total_count' => (int) $row['total_count'],
|
||||||
|
'total_amount' => (int) $row['total_amount']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build labels and series
|
||||||
|
$labels = [];
|
||||||
|
$totalCounts = [];
|
||||||
|
$totalAmounts = [];
|
||||||
|
|
||||||
|
for ($hour = 0; $hour < 24; $hour++) {
|
||||||
|
$labels[] = str_pad((string) $hour, 2, '0', STR_PAD_LEFT);
|
||||||
|
$totalCounts[] = $hourlyData[$hour]['total_count'];
|
||||||
|
$totalAmounts[] = $hourlyData[$hour]['total_amount'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'labels' => $labels,
|
||||||
|
'series' => [
|
||||||
|
'total_count' => $totalCounts,
|
||||||
|
'total_amount' => $totalAmounts
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
253
src/Modules/Retribusi/Summary/SummaryController.php
Normal file
253
src/Modules/Retribusi/Summary/SummaryController.php
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Summary;
|
||||||
|
|
||||||
|
use App\Support\ResponseHelper;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PDOException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class SummaryController
|
||||||
|
{
|
||||||
|
private DailySummaryService $dailyService;
|
||||||
|
private HourlySummaryService $hourlyService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
DailySummaryService $dailyService,
|
||||||
|
HourlySummaryService $hourlyService
|
||||||
|
) {
|
||||||
|
$this->dailyService = $dailyService;
|
||||||
|
$this->hourlyService = $hourlyService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger daily summary aggregation (admin only)
|
||||||
|
*
|
||||||
|
* @param ServerRequestInterface $request
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
|
public function aggregateDaily(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$body = $request->getParsedBody();
|
||||||
|
|
||||||
|
if (!is_array($body)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['body' => 'Invalid JSON body']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $body['date'] ?? null;
|
||||||
|
|
||||||
|
if ($date === null || !is_string($date)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['date' => 'Field is required and must be a string (Y-m-d format)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->dailyService->aggregateForDate($date);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $result,
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'message' => $e->getMessage()
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daily summary data (viewer/operator/admin)
|
||||||
|
*
|
||||||
|
* @param ServerRequestInterface $request
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
|
public function getDailySummary(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$queryParams = $request->getQueryParams();
|
||||||
|
|
||||||
|
$date = $queryParams['date'] ?? null;
|
||||||
|
if ($date === null || !is_string($date)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['date' => 'Query parameter date is required (Y-m-d format)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$locationCode = $queryParams['location_code'] ?? null;
|
||||||
|
if ($locationCode !== null && !is_string($locationCode)) {
|
||||||
|
$locationCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateCode = $queryParams['gate_code'] ?? null;
|
||||||
|
if ($gateCode !== null && !is_string($gateCode)) {
|
||||||
|
$gateCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $this->dailyService->getDailySummary($date, $locationCode, $gateCode);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hourly summary data for chart (viewer/operator/admin)
|
||||||
|
*
|
||||||
|
* @param ServerRequestInterface $request
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
|
public function getHourlySummary(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
ResponseInterface $response
|
||||||
|
): ResponseInterface {
|
||||||
|
$queryParams = $request->getQueryParams();
|
||||||
|
|
||||||
|
$date = $queryParams['date'] ?? null;
|
||||||
|
if ($date === null || !is_string($date)) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['date' => 'Query parameter date is required (Y-m-d format)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
$dateTime = \DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'validation_error',
|
||||||
|
'fields' => ['date' => 'Invalid date format. Expected Y-m-d (e.g., 2025-01-01)']
|
||||||
|
],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$locationCode = $queryParams['location_code'] ?? null;
|
||||||
|
if ($locationCode !== null && !is_string($locationCode)) {
|
||||||
|
$locationCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateCode = $queryParams['gate_code'] ?? null;
|
||||||
|
if ($gateCode !== null && !is_string($gateCode)) {
|
||||||
|
$gateCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $this->hourlyService->getHourlySummary($date, $locationCode, $gateCode);
|
||||||
|
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ResponseHelper::json(
|
||||||
|
$response,
|
||||||
|
[
|
||||||
|
'error' => 'server_error',
|
||||||
|
'message' => 'Database error occurred'
|
||||||
|
],
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
68
src/Modules/Retribusi/Summary/SummaryRoutes.php
Normal file
68
src/Modules/Retribusi/Summary/SummaryRoutes.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Retribusi\Summary;
|
||||||
|
|
||||||
|
use App\Config\AppConfig;
|
||||||
|
use App\Middleware\JwtMiddleware;
|
||||||
|
use App\Middleware\RoleMiddleware;
|
||||||
|
use App\Support\Database;
|
||||||
|
use Slim\App;
|
||||||
|
|
||||||
|
class SummaryRoutes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register summary routes
|
||||||
|
*
|
||||||
|
* @param App $app
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(App $app): void
|
||||||
|
{
|
||||||
|
// JWT middleware
|
||||||
|
$jwtMiddleware = new JwtMiddleware();
|
||||||
|
|
||||||
|
// Admin role middleware
|
||||||
|
$adminRoleMiddleware = new RoleMiddleware(['admin']);
|
||||||
|
|
||||||
|
// Get database connection
|
||||||
|
$dbHost = AppConfig::get('DB_HOST', 'localhost');
|
||||||
|
$dbName = AppConfig::get('DB_NAME', '');
|
||||||
|
$dbUser = AppConfig::get('DB_USER', '');
|
||||||
|
$dbPass = AppConfig::get('DB_PASS', '');
|
||||||
|
|
||||||
|
$db = Database::getConnection($dbHost, $dbName, $dbUser, $dbPass);
|
||||||
|
|
||||||
|
// Initialize services and controller
|
||||||
|
$dailySummaryService = new DailySummaryService($db);
|
||||||
|
$hourlySummaryService = new HourlySummaryService($db);
|
||||||
|
$summaryController = new SummaryController($dailySummaryService, $hourlySummaryService);
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
$app->group('/retribusi', function ($group) use (
|
||||||
|
$jwtMiddleware,
|
||||||
|
$adminRoleMiddleware,
|
||||||
|
$summaryController
|
||||||
|
) {
|
||||||
|
$group->group('/v1', function ($v1Group) use (
|
||||||
|
$jwtMiddleware,
|
||||||
|
$adminRoleMiddleware,
|
||||||
|
$summaryController
|
||||||
|
) {
|
||||||
|
// Admin endpoint: trigger aggregation
|
||||||
|
$v1Group->post('/admin/summary/daily', [$summaryController, 'aggregateDaily'])
|
||||||
|
->add($adminRoleMiddleware)
|
||||||
|
->add($jwtMiddleware);
|
||||||
|
|
||||||
|
// Read endpoints: get summaries
|
||||||
|
$v1Group->get('/summary/daily', [$summaryController, 'getDailySummary'])
|
||||||
|
->add($jwtMiddleware);
|
||||||
|
|
||||||
|
$v1Group->get('/summary/hourly', [$summaryController, 'getHourlySummary'])
|
||||||
|
->add($jwtMiddleware);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
60
src/Support/Database.php
Normal file
60
src/Support/Database.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?PDO $connection = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database connection (singleton)
|
||||||
|
*
|
||||||
|
* @param string $host
|
||||||
|
* @param string $dbName
|
||||||
|
* @param string $user
|
||||||
|
* @param string $password
|
||||||
|
* @return PDO
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
public static function getConnection(
|
||||||
|
string $host,
|
||||||
|
string $dbName,
|
||||||
|
string $user,
|
||||||
|
string $password
|
||||||
|
): PDO {
|
||||||
|
if (self::$connection === null) {
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;dbname=%s;charset=utf8mb4',
|
||||||
|
$host,
|
||||||
|
$dbName
|
||||||
|
);
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
PDO::ATTR_STRINGIFY_FETCHES => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
self::$connection = new PDO($dsn, $user, $password, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset connection (for testing)
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function reset(): void
|
||||||
|
{
|
||||||
|
self::$connection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
117
src/Support/Jwt.php
Normal file
117
src/Support/Jwt.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class Jwt
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Encode JWT token
|
||||||
|
*
|
||||||
|
* @param array $payload
|
||||||
|
* @param string $secret
|
||||||
|
* @param int $ttlSeconds
|
||||||
|
* @param string $issuer
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function encode(
|
||||||
|
array $payload,
|
||||||
|
string $secret,
|
||||||
|
int $ttlSeconds,
|
||||||
|
string $issuer
|
||||||
|
): string {
|
||||||
|
$header = [
|
||||||
|
'alg' => 'HS256',
|
||||||
|
'typ' => 'JWT'
|
||||||
|
];
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$claims = [
|
||||||
|
'iss' => $issuer,
|
||||||
|
'iat' => $now,
|
||||||
|
'exp' => $now + $ttlSeconds,
|
||||||
|
...$payload
|
||||||
|
];
|
||||||
|
|
||||||
|
$headerEncoded = self::base64UrlEncode(json_encode($header, JSON_UNESCAPED_SLASHES));
|
||||||
|
$payloadEncoded = self::base64UrlEncode(json_encode($claims, JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
|
$signature = hash_hmac('sha256', $headerEncoded . '.' . $payloadEncoded, $secret, true);
|
||||||
|
$signatureEncoded = self::base64UrlEncode($signature);
|
||||||
|
|
||||||
|
return $headerEncoded . '.' . $payloadEncoded . '.' . $signatureEncoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode and validate JWT token
|
||||||
|
*
|
||||||
|
* @param string $token
|
||||||
|
* @param string $secret
|
||||||
|
* @return array
|
||||||
|
* @throws InvalidArgumentException|RuntimeException
|
||||||
|
*/
|
||||||
|
public static function decode(string $token, string $secret): array
|
||||||
|
{
|
||||||
|
$parts = explode('.', $token);
|
||||||
|
|
||||||
|
if (count($parts) !== 3) {
|
||||||
|
throw new InvalidArgumentException('Invalid token format');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$headerEncoded, $payloadEncoded, $signatureEncoded] = $parts;
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
$signature = self::base64UrlDecode($signatureEncoded);
|
||||||
|
$expectedSignature = hash_hmac(
|
||||||
|
'sha256',
|
||||||
|
$headerEncoded . '.' . $payloadEncoded,
|
||||||
|
$secret,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hash_equals($expectedSignature, $signature)) {
|
||||||
|
throw new RuntimeException('Invalid token signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode payload
|
||||||
|
$payload = json_decode(self::base64UrlDecode($payloadEncoded), true);
|
||||||
|
|
||||||
|
if ($payload === null) {
|
||||||
|
throw new InvalidArgumentException('Invalid token payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (isset($payload['exp']) && $payload['exp'] < time()) {
|
||||||
|
throw new RuntimeException('Token expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64 URL encode
|
||||||
|
*
|
||||||
|
* @param string $data
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function base64UrlEncode(string $data): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64 URL decode
|
||||||
|
*
|
||||||
|
* @param string $data
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function base64UrlDecode(string $data): string
|
||||||
|
{
|
||||||
|
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
32
src/Support/Response.php
Normal file
32
src/Support/Response.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Slim\Psr7\Response;
|
||||||
|
|
||||||
|
class ResponseHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create JSON response
|
||||||
|
*
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @param mixed $data
|
||||||
|
* @param int $statusCode
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
|
public static function json(
|
||||||
|
ResponseInterface $response,
|
||||||
|
mixed $data,
|
||||||
|
int $statusCode = 200
|
||||||
|
): ResponseInterface {
|
||||||
|
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
|
return $response
|
||||||
|
->withHeader('Content-Type', 'application/json')
|
||||||
|
->withStatus($statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
31
src/Support/ResponseHelper.php
Normal file
31
src/Support/ResponseHelper.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
class ResponseHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create JSON response
|
||||||
|
*
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @param mixed $data
|
||||||
|
* @param int $statusCode
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
|
public static function json(
|
||||||
|
ResponseInterface $response,
|
||||||
|
mixed $data,
|
||||||
|
int $statusCode = 200
|
||||||
|
): ResponseInterface {
|
||||||
|
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
|
return $response
|
||||||
|
->withHeader('Content-Type', 'application/json')
|
||||||
|
->withStatus($statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
298
src/Support/Validator.php
Normal file
298
src/Support/Validator.php
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
class Validator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate ingest payload (basic format validation)
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return array Errors array, empty if valid
|
||||||
|
*/
|
||||||
|
public static function validateIngest(array $data): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// Validate timestamp: required integer
|
||||||
|
if (!isset($data['timestamp'])) {
|
||||||
|
$errors['timestamp'] = 'Field is required';
|
||||||
|
} elseif (!is_int($data['timestamp'])) {
|
||||||
|
$errors['timestamp'] = 'Must be an integer';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate location_code: required string, min 1, max 64
|
||||||
|
if (!isset($data['location_code'])) {
|
||||||
|
$errors['location_code'] = 'Field is required';
|
||||||
|
} elseif (!is_string($data['location_code'])) {
|
||||||
|
$errors['location_code'] = 'Must be a string';
|
||||||
|
} elseif (strlen($data['location_code']) < 1) {
|
||||||
|
$errors['location_code'] = 'Must be at least 1 character';
|
||||||
|
} elseif (strlen($data['location_code']) > 64) {
|
||||||
|
$errors['location_code'] = 'Must not exceed 64 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate gate_code: required string, min 1, max 64
|
||||||
|
if (!isset($data['gate_code'])) {
|
||||||
|
$errors['gate_code'] = 'Field is required';
|
||||||
|
} elseif (!is_string($data['gate_code'])) {
|
||||||
|
$errors['gate_code'] = 'Must be a string';
|
||||||
|
} elseif (strlen($data['gate_code']) < 1) {
|
||||||
|
$errors['gate_code'] = 'Must be at least 1 character';
|
||||||
|
} elseif (strlen($data['gate_code']) > 64) {
|
||||||
|
$errors['gate_code'] = 'Must not exceed 64 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate category: required string, min 1, max 64
|
||||||
|
if (!isset($data['category'])) {
|
||||||
|
$errors['category'] = 'Field is required';
|
||||||
|
} elseif (!is_string($data['category'])) {
|
||||||
|
$errors['category'] = 'Must be a string';
|
||||||
|
} elseif (strlen($data['category']) < 1) {
|
||||||
|
$errors['category'] = 'Must be at least 1 character';
|
||||||
|
} elseif (strlen($data['category']) > 64) {
|
||||||
|
$errors['category'] = 'Must not exceed 64 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate pagination parameters
|
||||||
|
*
|
||||||
|
* @param array $queryParams
|
||||||
|
* @return array [page, limit]
|
||||||
|
*/
|
||||||
|
public static function validatePagination(array $queryParams): array
|
||||||
|
{
|
||||||
|
$page = isset($queryParams['page']) && is_numeric($queryParams['page'])
|
||||||
|
? max(1, (int) $queryParams['page'])
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
$limit = isset($queryParams['limit']) && is_numeric($queryParams['limit'])
|
||||||
|
? max(1, min(100, (int) $queryParams['limit']))
|
||||||
|
: 20;
|
||||||
|
|
||||||
|
return [$page, $limit];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate code format (location_code, gate_code, category)
|
||||||
|
*
|
||||||
|
* @param string $code
|
||||||
|
* @param string $fieldName
|
||||||
|
* @return string|null Error message or null if valid
|
||||||
|
*/
|
||||||
|
private static function validateCodeFormat(string $code, string $fieldName): ?string
|
||||||
|
{
|
||||||
|
if (!preg_match('/^[a-z0-9_\\-]{1,64}$/', $code)) {
|
||||||
|
return $fieldName . ' must match pattern: ^[a-z0-9_\\-]{1,64}$';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate location data
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @param bool $isUpdate
|
||||||
|
* @return array Errors array, empty if valid
|
||||||
|
*/
|
||||||
|
public static function validateLocation(array $data, bool $isUpdate = false): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// Code: required for POST, immutable for PUT
|
||||||
|
if (!$isUpdate) {
|
||||||
|
if (!isset($data['code'])) {
|
||||||
|
$errors['code'] = 'Field is required';
|
||||||
|
} elseif (!is_string($data['code'])) {
|
||||||
|
$errors['code'] = 'Must be a string';
|
||||||
|
} else {
|
||||||
|
$codeError = self::validateCodeFormat($data['code'], 'code');
|
||||||
|
if ($codeError !== null) {
|
||||||
|
$errors['code'] = $codeError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name: optional for update, but if provided must be valid
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
if (!is_string($data['name'])) {
|
||||||
|
$errors['name'] = 'Must be a string';
|
||||||
|
} elseif (strlen($data['name']) > 120) {
|
||||||
|
$errors['name'] = 'Must not exceed 120 characters';
|
||||||
|
}
|
||||||
|
} elseif (!$isUpdate) {
|
||||||
|
$errors['name'] = 'Field is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type: optional for update
|
||||||
|
if (isset($data['type'])) {
|
||||||
|
if (!is_string($data['type'])) {
|
||||||
|
$errors['type'] = 'Must be a string';
|
||||||
|
} elseif (strlen($data['type']) > 60) {
|
||||||
|
$errors['type'] = 'Must not exceed 60 characters';
|
||||||
|
}
|
||||||
|
} elseif (!$isUpdate) {
|
||||||
|
$errors['type'] = 'Field is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
// is_active: optional, but if provided must be 0 or 1
|
||||||
|
if (isset($data['is_active'])) {
|
||||||
|
if (!in_array($data['is_active'], [0, 1], true)) {
|
||||||
|
$errors['is_active'] = 'Must be 0 or 1';
|
||||||
|
}
|
||||||
|
} elseif (!$isUpdate) {
|
||||||
|
$errors['is_active'] = 'Field is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate gate data
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @param bool $isUpdate
|
||||||
|
* @return array Errors array, empty if valid
|
||||||
|
*/
|
||||||
|
public static function validateGate(array $data, bool $isUpdate = false): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// Location code: required for POST, immutable for PUT
|
||||||
|
if (!$isUpdate) {
|
||||||
|
if (!isset($data['location_code'])) {
|
||||||
|
$errors['location_code'] = 'Field is required';
|
||||||
|
} elseif (!is_string($data['location_code'])) {
|
||||||
|
$errors['location_code'] = 'Must be a string';
|
||||||
|
} else {
|
||||||
|
$codeError = self::validateCodeFormat($data['location_code'], 'location_code');
|
||||||
|
if ($codeError !== null) {
|
||||||
|
$errors['location_code'] = $codeError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate code: required for POST, immutable for PUT
|
||||||
|
if (!$isUpdate) {
|
||||||
|
if (!isset($data['gate_code'])) {
|
||||||
|
$errors['gate_code'] = 'Field is required';
|
||||||
|
} elseif (!is_string($data['gate_code'])) {
|
||||||
|
$errors['gate_code'] = 'Must be a string';
|
||||||
|
} else {
|
||||||
|
$codeError = self::validateCodeFormat($data['gate_code'], 'gate_code');
|
||||||
|
if ($codeError !== null) {
|
||||||
|
$errors['gate_code'] = $codeError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name: optional for update
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
if (!is_string($data['name'])) {
|
||||||
|
$errors['name'] = 'Must be a string';
|
||||||
|
} elseif (strlen($data['name']) > 120) {
|
||||||
|
$errors['name'] = 'Must not exceed 120 characters';
|
||||||
|
}
|
||||||
|
} elseif (!$isUpdate) {
|
||||||
|
$errors['name'] = 'Field is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction: optional for update, but if provided must be in/out
|
||||||
|
if (isset($data['direction'])) {
|
||||||
|
if (!is_string($data['direction'])) {
|
||||||
|
$errors['direction'] = 'Must be a string';
|
||||||
|
} else {
|
||||||
|
$direction = strtolower($data['direction']);
|
||||||
|
if (!in_array($direction, ['in', 'out'], true)) {
|
||||||
|
$errors['direction'] = 'Must be "in" or "out"';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (!$isUpdate) {
|
||||||
|
$errors['direction'] = 'Field is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
// is_active: optional, but if provided must be 0 or 1
|
||||||
|
if (isset($data['is_active'])) {
|
||||||
|
if (!in_array($data['is_active'], [0, 1], true)) {
|
||||||
|
$errors['is_active'] = 'Must be 0 or 1';
|
||||||
|
}
|
||||||
|
} elseif (!$isUpdate) {
|
||||||
|
$errors['is_active'] = 'Field is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate tariff data
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @param bool $isUpdate
|
||||||
|
* @return array Errors array, empty if valid
|
||||||
|
*/
|
||||||
|
public static function validateTariff(array $data, bool $isUpdate = false): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// Location code: required for POST, immutable for PUT
|
||||||
|
if (!$isUpdate) {
|
||||||
|
if (!isset($data['location_code'])) {
|
||||||
|
$errors['location_code'] = 'Field is required';
|
||||||
|
} elseif (!is_string($data['location_code'])) {
|
||||||
|
$errors['location_code'] = 'Must be a string';
|
||||||
|
} else {
|
||||||
|
$codeError = self::validateCodeFormat($data['location_code'], 'location_code');
|
||||||
|
if ($codeError !== null) {
|
||||||
|
$errors['location_code'] = $codeError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate code: required for POST, immutable for PUT
|
||||||
|
if (!$isUpdate) {
|
||||||
|
if (!isset($data['gate_code'])) {
|
||||||
|
$errors['gate_code'] = 'Field is required';
|
||||||
|
} elseif (!is_string($data['gate_code'])) {
|
||||||
|
$errors['gate_code'] = 'Must be a string';
|
||||||
|
} else {
|
||||||
|
$codeError = self::validateCodeFormat($data['gate_code'], 'gate_code');
|
||||||
|
if ($codeError !== null) {
|
||||||
|
$errors['gate_code'] = $codeError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category: required for POST, immutable for PUT
|
||||||
|
if (!$isUpdate) {
|
||||||
|
if (!isset($data['category'])) {
|
||||||
|
$errors['category'] = 'Field is required';
|
||||||
|
} elseif (!is_string($data['category'])) {
|
||||||
|
$errors['category'] = 'Must be a string';
|
||||||
|
} else {
|
||||||
|
$codeError = self::validateCodeFormat($data['category'], 'category');
|
||||||
|
if ($codeError !== null) {
|
||||||
|
$errors['category'] = $codeError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amount: required for POST, optional for update
|
||||||
|
if (isset($data['amount'])) {
|
||||||
|
if (!is_int($data['amount']) && !is_numeric($data['amount'])) {
|
||||||
|
$errors['amount'] = 'Must be an integer';
|
||||||
|
} elseif ((int) $data['amount'] < 0) {
|
||||||
|
$errors['amount'] = 'Must be >= 0';
|
||||||
|
}
|
||||||
|
} elseif (!$isUpdate) {
|
||||||
|
$errors['amount'] = 'Field is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user