380 lines
14 KiB
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)));
|
|
}
|
|
}
|