sidakpelem/app/Http/Controllers/Mobile/AbsenController.php

380 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers\Mobile;
use App\Events\AttendanceUpdated;
use App\Events\QrTokenIssued;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use App\Models\AttendanceSetting;
use App\Models\User;
use App\Services\DynamicQrService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AbsenController extends Controller
{
private function qrTtl(): int
{
return 60;
}
public function verify(Request $req, DynamicQrService $svc)
{
$req->validate([
'token' => 'required|string',
'user_id' => 'required|integer',
'device_info' => 'nullable|string',
'latitude' => 'nullable|numeric|between:-90,90',
'longitude' => 'nullable|numeric|between:-180,180',
]);
$lat = $req->input('latitude');
$lng = $req->input('longitude');
$location = (is_numeric($lat) && is_numeric($lng))
? sprintf('%.6f,%.6f', $lat, $lng)
: null;
$check = $svc->verifyShortToken($req->token);
if (!$check['ok']) {
// Token invalid/expired - tetap generate token baru untuk keamanan
$payload = $check['payload'] ?? null;
$sessionId = $payload ? (int) ($payload['session_id'] ?? 0) : 0;
if ($sessionId > 0) {
$this->generateNewToken($svc, $sessionId, $this->qrTtl());
}
return response()->json(['ok' => false, 'reason' => $check['reason']], 422);
}
$payload = $check['payload'];
$sessionId = (int) ($payload['session_id'] ?? 0);
$nonce = $payload['nonce'];
$ttlRemaining = max(1, (int)($payload['exp'] - now()->timestamp));
if (!Cache::add("used_nonce:$nonce", 1, $ttlRemaining)) {
$this->generateNewToken($svc, $sessionId, $this->qrTtl());
return response()->json(['ok' => false, 'reason' => 'replayed'], 409);
}
// Simpan data absensi ke database
$userId = $req->integer('user_id');
$user = User::find($userId);
if (!$user) {
return response()->json(['ok' => false, 'reason' => 'User not found'], 404);
}
$setting = AttendanceSetting::query()->first();
if (!$setting) {
return response()->json([
'ok' => false,
'reason' => 'attendance_setting_not_found',
'message' => 'Pengaturan absensi belum tersedia. Jalankan seeder terlebih dahulu.',
], 500);
}
$timezone = $setting->timezone ?: config('app.timezone', 'Asia/Jakarta');
$now = Carbon::now($timezone);
$today = $now->toDateString();
$officeLat = $setting->office_latitude;
$officeLng = $setting->office_longitude;
$radiusMeters = (int) ($setting->attendance_radius_meters ?? 0);
if ($officeLat !== null && $officeLng !== null && $radiusMeters > 0) {
if (!is_numeric($lat) || !is_numeric($lng)) {
return response()->json([
'ok' => false,
'reason' => 'location_required',
'message' => 'Lokasi wajib dikirim untuk melakukan absensi.',
], 422);
}
$distanceMeters = $this->distanceInMeters(
(float) $officeLat,
(float) $officeLng,
(float) $lat,
(float) $lng
);
if ($distanceMeters > $radiusMeters) {
return response()->json([
'ok' => false,
'reason' => 'outside_attendance_radius',
'message' => 'Anda berada di luar radius absensi yang diizinkan.',
'meta' => [
'distance_meters' => round($distanceMeters, 2),
'allowed_radius_meters' => $radiusMeters,
],
], 422);
}
}
$effectiveWorkdays = collect($setting->effective_workdays ?? [])
->map(fn($day) => (int) $day)
->unique()
->values();
if ($effectiveWorkdays->isNotEmpty() && !$effectiveWorkdays->contains($now->isoWeekday())) {
return response()->json([
'ok' => false,
'reason' => 'not_effective_workday',
'message' => 'Hari ini bukan hari kerja efektif untuk absensi.',
], 422);
}
// Cek apakah sudah absen hari ini
$existingAttendance = Attendance::where('user_id', $userId)
->whereDate('date', $today)
->first();
$checkoutStart = $this->todayAt($setting->checkout_start, $today, $timezone);
if ($existingAttendance) {
// Update check_out jika hari ini belum punya check_out
if (!$existingAttendance->check_out) {
if ($now->lt($checkoutStart)) {
return response()->json([
'ok' => false,
'reason' => 'too_early_checkout',
'message' => 'Belum masuk jam check-out.',
], 422);
}
$isCheckoutOnly = !$existingAttendance->check_in;
$notes = $existingAttendance->notes;
if ($isCheckoutOnly) {
$checkoutOnlyNote = 'Check-out saja: check-in tidak tercatat.';
$notes = $notes
? (str_contains($notes, $checkoutOnlyNote) ? $notes : "{$notes} | {$checkoutOnlyNote}")
: $checkoutOnlyNote;
}
$existingAttendance->update([
'check_out' => $now->format('H:i:s'),
'status' => $isCheckoutOnly ? 'hadir' : $existingAttendance->status,
'notes' => $notes,
'device_info' => $req->string('device_info'),
'location' => $location ?? $existingAttendance->location,
]);
Log::info('ATTENDANCE_CHECKOUT', [
'user_id' => $userId,
'user_name' => $user->name,
'session_id' => $sessionId,
'check_out' => $now->toIso8601String(),
]);
// Broadcast attendance update
try {
broadcast(new AttendanceUpdated(
$userId,
$existingAttendance->status,
$existingAttendance->check_in?->toIso8601String(),
$now->toIso8601String()
));
} catch (\Throwable $e) {
Log::warning('Attendance broadcast failed (checkout): ' . $e->getMessage());
}
} else {
return response()->json(['ok' => false, 'reason' => 'Already attended today'], 409);
}
} else {
if ($now->gte($checkoutStart)) {
$attendance = Attendance::create([
'user_id' => $userId,
'date' => $today,
'check_in' => null,
'check_out' => $now->format('H:i:s'),
'status' => 'hadir',
'lates_minutes' => null,
'notes' => 'Check-out saja: check-in tidak tercatat.',
'device_info' => $req->string('device_info'),
'location' => $location,
]);
Log::info('ATTENDANCE_CHECKOUT_ONLY', [
'user_id' => $userId,
'user_name' => $user->name,
'session_id' => $sessionId,
'check_out' => $now->toIso8601String(),
'attendance_id' => $attendance->id,
]);
try {
broadcast(new AttendanceUpdated(
$userId,
'hadir',
null,
$now->toIso8601String()
));
} catch (\Throwable $e) {
Log::warning('Attendance broadcast failed (checkout-only): ' . $e->getMessage());
}
$this->generateNewToken($svc, $sessionId, $this->qrTtl());
return response()->json(['ok' => true, 'session_id' => $sessionId, 'mode' => 'checkout_only']);
}
$checkinStart = $this->todayAt($setting->checkin_start, $today, $timezone);
$checkinEnd = $this->todayAt($setting->checkin_end, $today, $timezone);
$lateGraceMinutes = max(0, (int) ($setting->late_grace_minutes ?? 0));
$lateLimit = $checkinEnd->copy()->addMinutes($lateGraceMinutes);
if ($now->lt($checkinStart)) {
return response()->json([
'ok' => false,
'reason' => 'too_early_checkin',
'message' => 'Belum masuk jam check-in.',
], 422);
}
if (!$setting->allow_checkin_after_end && $now->gt($lateLimit)) {
return response()->json([
'ok' => false,
'reason' => 'checkin_closed',
'message' => 'Jam check-in sudah ditutup.',
], 422);
}
$lateDiff = max(0, $checkinEnd->diffInMinutes($now, false));
$lateMinutes = $lateDiff > $lateGraceMinutes ? (string) ($lateDiff - $lateGraceMinutes) : null;
// Buat record absensi baru
$attendance = Attendance::create([
'user_id' => $userId,
'date' => $today,
'check_in' => $now->format('H:i:s'),
'status' => 'hadir',
'lates_minutes' => $lateMinutes,
'notes' => $lateMinutes ? "Terlambat {$lateMinutes} menit" : null,
'device_info' => $req->string('device_info'),
'location' => $location,
]);
Log::info('ATTENDANCE_CHECKIN', [
'user_id' => $userId,
'user_name' => $user->name,
'session_id' => $sessionId,
'check_in' => $now->toIso8601String(),
'lates_minutes' => $lateMinutes,
]);
// Broadcast attendance update
try {
broadcast(new AttendanceUpdated(
$userId,
'hadir',
$now->toIso8601String(),
null
));
} catch (\Throwable $e) {
Log::warning('Attendance broadcast failed (checkin): ' . $e->getMessage());
}
}
// berhasil → SELALU ganti token baru
$this->generateNewToken($svc, $sessionId, $this->qrTtl());
return response()->json(['ok' => true, 'session_id' => $sessionId]);
}
public function daily(Request $req)
{
$req->validate([
'user_id' => 'required|integer|exists:users,id',
'date' => 'nullable|date_format:Y-m-d',
]);
$userId = (int) $req->user_id;
$date = $req->filled('date')
? Carbon::createFromFormat('Y-m-d', $req->date)->toDateString()
: now()->toDateString();
$attendance = Attendance::where('user_id', $userId)
->whereDate('date', $date)
->first();
if (!$attendance) {
return response()->json([
'ok' => true,
'data' => null,
'meta' => [
'user_id' => $userId,
'date' => $date,
'message' => 'Belum ada absensi pada tanggal ini',
],
]);
}
return response()->json([
'ok' => true,
'data' => [
'id' => $attendance->id,
'user_id' => $attendance->user_id,
'date' => $attendance->date, // Y-m-d
'check_in' => $attendance->check_in, // HH:MM:SS / null
'check_out' => $attendance->check_out, // HH:MM:SS / null
'status' => $attendance->status,
'lates_minutes' => $attendance->lates_minutes,
'notes' => $attendance->notes,
'device_info' => $attendance->device_info,
'location' => $attendance->location,
'created_at' => $attendance->created_at?->toIso8601String(),
'updated_at' => $attendance->updated_at?->toIso8601String(),
],
'meta' => [
'user_id' => $userId,
'date' => $date,
],
]);
}
private function generateNewToken(DynamicQrService $svc, int $sessionId, int $ttl): array
{
$data = $svc->issueShortToken($sessionId, $ttl);
Cache::put("qr:session:$sessionId:aktif", $data, $ttl);
try {
broadcast(new QrTokenIssued(
$sessionId,
$data['token'],
$data['payload']['exp'],
$ttl
));
} catch (\Throwable $e) {
Log::warning('QR broadcast failed (generateNewToken): ' . $e->getMessage(), [
'session_id' => $sessionId,
]);
}
return $data;
}
private function todayAt(?string $time, string $date, string $timezone): Carbon
{
$value = (string) ($time ?: '00:00:00');
if (strlen($value) === 5) {
$value .= ':00';
}
return Carbon::createFromFormat('Y-m-d H:i:s', "{$date} {$value}", $timezone);
}
private function distanceInMeters(float $lat1, float $lng1, float $lat2, float $lng2): float
{
$earthRadius = 6371000;
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) ** 2
+ cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2;
return 2 * $earthRadius * asin(min(1, sqrt($a)));
}
}