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))); } }