deviceAuthService = new DeviceAuthService(); $this->scheduleResolverService = new ScheduleResolverService(); $this->scheduleModel = new ScheduleModel(); $this->studentModel = new StudentModel(); $this->geoFenceService = new GeoFenceService(); $this->zoneModel = new ZoneModel(); $this->attendanceModel = new AttendanceSessionModel(); $this->qrTokenModel = new QrAttendanceTokenModel(); $this->deviceModel = new DeviceModel(); $this->presenceSettingsModel = new SchoolPresenceSettingsModel(); } /** * Process attendance check-in * * Schedule resolution and attendance timestamp always use server current time; * payload "datetime" is ignored. * * @param array $payload Payload containing: * - device_code: string * - api_key: string * - student_id: int * - datetime: string (ignored; server time is used) * - lat: float * - lng: float * - confidence: float|null * @return array Result with status and attendance session data */ public function checkin(array $payload): array { // Extract payload (datetime from client is ignored; use server time) $deviceCode = $payload['device_code'] ?? ''; $apiKey = $payload['api_key'] ?? ''; $studentId = (int) ($payload['student_id'] ?? 0); $datetime = date('Y-m-d H:i:s'); $lat = isset($payload['lat']) ? (float) $payload['lat'] : 0.0; $lng = isset($payload['lng']) ? (float) $payload['lng'] : 0.0; $confidence = isset($payload['confidence']) ? (float) $payload['confidence'] : null; // Step 1: Authenticate device $deviceData = $this->deviceAuthService->authenticate($deviceCode, $apiKey); if (!$deviceData) { return $this->createResult( AttendanceSession::STATUS_INVALID_DEVICE, $studentId, null, 0, $datetime, $lat, $lng, $confidence ); } $deviceId = $deviceData['device_id']; // Step 2: Geofence & validasi koordinat — cepat gagal sebelum query jadwal yang berat $zoneConfig = null; $zone = $this->zoneModel->findActiveByZoneCode('SMA1-SCHOOL'); if (!$zone) { $zones = $this->zoneModel->findAllActive(); $zone = !empty($zones) ? $zones[0] : null; } if ($zone) { $zoneConfig = [ 'latitude' => $zone->latitude, 'longitude' => $zone->longitude, 'radius_meters' => $zone->radius_meters, ]; } // Jika koordinat null / 0 (belum diambil) langsung OUTSIDE_ZONE (tanpa insert) if ($lat === 0.0 && $lng === 0.0) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_OUTSIDE_ZONE, $studentId, null, $deviceId, $datetime, $lat, $lng, $confidence ); } if ($zoneConfig) { $isInsideZone = $this->geoFenceService->isInsideZone( $lat, $lng, $zoneConfig ); if (!$isInsideZone) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_OUTSIDE_ZONE, $studentId, null, $deviceId, $datetime, $lat, $lng, $confidence ); } } // Step 3: Resolve active schedule (start_time <= now < end_time) $schedule = $this->scheduleResolverService->getActiveSchedule($studentId, $datetime); if (!$schedule) { // No active slot: check latest schedule for class today (may already be ended) for SESSION_CLOSED vs NO_SCHEDULE $student = $this->studentModel->find($studentId); $classId = $student && $student->class_id ? (int) $student->class_id : 0; $dayOfWeek = (int) date('N', strtotime($datetime)); $time = date('H:i:s', strtotime($datetime)); $latest = $classId > 0 ? $this->scheduleModel->getLatestScheduleForClassToday($classId, $dayOfWeek, $time) : null; if ($latest !== null) { $currentTimeTs = strtotime($datetime); $dateOnly = date('Y-m-d', $currentTimeTs); $endTime = $latest['end_time'] ?? ''; $latestEndTs = $endTime !== '' ? strtotime($dateOnly . ' ' . $endTime) : 0; if ($latestEndTs > 0 && $currentTimeTs > ($latestEndTs + $this->sessionCloseGraceSeconds)) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_SESSION_CLOSED, $studentId, $latest['id'], $deviceId, $datetime, $lat, $lng, $confidence ); } } return $this->createResultWithoutInsert( AttendanceSession::STATUS_NO_SCHEDULE, $studentId, null, $deviceId, $datetime, $lat, $lng, $confidence ); } $scheduleId = $schedule['schedule_id']; // Step 3b: Auto-lock — if current server time is past schedule end_time, reject (uses resolved start_time/end_time from ScheduleResolverService) $currentTimeTs = strtotime($datetime); $dateOnly = date('Y-m-d', $currentTimeTs); $endTime = $schedule['end_time'] ?? ''; if ($endTime !== '') { $scheduleEndTs = strtotime($dateOnly . ' ' . $endTime); if ($currentTimeTs > ($scheduleEndTs + $this->sessionCloseGraceSeconds)) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_SESSION_CLOSED, $studentId, $scheduleId, $deviceId, $datetime, $lat, $lng, $confidence ); } } // Step 4: Attendance window — only allow check-in within [start - open_before, start + close_after] minutes if (!$this->isWithinAttendanceWindow($datetime, $schedule['start_time'])) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_ABSENCE_WINDOW_CLOSED, $studentId, $scheduleId, $deviceId, $datetime, $lat, $lng, $confidence ); } // Step 6: Duplicate check — one attendance per (student_id, schedule_id, attendance_date); date from server $attendanceDate = date('Y-m-d'); if ($this->attendanceModel->hasAttendanceFor($studentId, $scheduleId, $attendanceDate)) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_ALREADY_CHECKED_IN, $studentId, $scheduleId, $deviceId, $datetime, $lat, $lng, $confidence ); } // Step 7: Determine if LATE or PRESENT $status = $this->determineStatus($datetime, $schedule['start_time']); // Step 8: Save attendance session return $this->createResult( $status, $studentId, $scheduleId, $deviceId, $datetime, $lat, $lng, $confidence ); } /** * Absen masuk atau pulang dari aplikasi mobile (auth NISN+PIN). * Memakai jam dari Pengaturan Presensi dan zona sekolah. * * @param array $payload ['student_id' => int, 'type' => 'masuk'|'pulang', 'lat' => float, 'lng' => float] * @return array Same shape as checkin(): status, attendance_id, checkin_at, ... */ public function checkinMasukPulang(array $payload): array { $studentId = (int) ($payload['student_id'] ?? 0); $type = strtolower(trim((string) ($payload['type'] ?? ''))); $lat = isset($payload['lat']) ? (float) $payload['lat'] : 0.0; $lng = isset($payload['lng']) ? (float) $payload['lng'] : 0.0; $datetime = date('Y-m-d H:i:s'); $dateOnly = date('Y-m-d'); if ($studentId < 1) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_NO_SCHEDULE, $studentId, null, 0, $datetime, $lat, $lng, null ); } if ($type !== 'masuk' && $type !== 'pulang') { return $this->createResultWithoutInsert( AttendanceSession::STATUS_NO_SCHEDULE, $studentId, null, 0, $datetime, $lat, $lng, null ); } // Device aplikasi mobile (harus ada di dashboard) $device = $this->deviceModel->findActiveByDeviceCode(self::MOBILE_APP_DEVICE_CODE); if (! $device) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_INVALID_DEVICE, $studentId, null, 0, $datetime, $lat, $lng, null ); } $deviceId = (int) $device->id; // Koordinat wajib ada dan di dalam zona sebelum cek jam / duplikasi if ($lat === 0.0 && $lng === 0.0) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_OUTSIDE_ZONE, $studentId, null, $deviceId, $datetime, $lat, $lng, null ); } // Zona sekolah (geofence) $zone = $this->zoneModel->findActiveByZoneCode('SMA1-SCHOOL'); if (! $zone) { $zones = $this->zoneModel->findAllActive(); $zone = ! empty($zones) ? $zones[0] : null; } if ($zone) { $zoneConfig = [ 'latitude' => $zone->latitude, 'longitude' => $zone->longitude, 'radius_meters' => $zone->radius_meters, ]; if (! $this->geoFenceService->isInsideZone($lat, $lng, $zoneConfig)) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_OUTSIDE_ZONE, $studentId, null, $deviceId, $datetime, $lat, $lng, null ); } } // Jam masuk/pulang dari pengaturan presensi $times = $this->presenceSettingsModel->getSettings(); $timeStart = $type === 'masuk' ? ($times['time_masuk_start'] ?? '06:30:00') : ($times['time_pulang_start'] ?? '14:00:00'); $timeEnd = $type === 'masuk' ? ($times['time_masuk_end'] ?? '07:00:00') : ($times['time_pulang_end'] ?? '14:30:00'); $nowTime = date('H:i:s', strtotime($datetime)); if ($nowTime < $timeStart || $nowTime > $timeEnd) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_ABSENCE_WINDOW_CLOSED, $studentId, null, $deviceId, $datetime, $lat, $lng, null ); } // Sudah absen masuk/pulang hari ini? $db = \Config\Database::connect(); $existing = $db->table('attendance_sessions') ->where('student_id', $studentId) ->where('attendance_date', $dateOnly) ->where('checkin_type', $type) ->limit(1) ->get() ->getRowArray(); if ($existing) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_ALREADY_CHECKED_IN, $studentId, null, $deviceId, $datetime, $lat, $lng, null ); } // Simpan PRESENT $attendanceData = [ 'student_id' => $studentId, 'schedule_id' => null, 'checkin_type' => $type, 'attendance_date' => $dateOnly, 'device_id' => $deviceId, 'checkin_at' => $datetime, 'latitude' => $lat, 'longitude' => $lng, 'confidence' => null, 'status' => AttendanceSession::STATUS_PRESENT, ]; $attendanceId = $this->attendanceModel->insert($attendanceData); $this->notifyParentsOfCheckin($studentId, null, $datetime, AttendanceSession::STATUS_PRESENT); return [ 'status' => AttendanceSession::STATUS_PRESENT, 'attendance_id' => $attendanceId, 'student_id' => $studentId, 'schedule_id' => null, 'device_id' => $deviceId, 'checkin_at' => $datetime, 'latitude' => $lat, 'longitude' => $lng, 'confidence' => null, ]; } /** * Determine attendance status (PRESENT or LATE) * * @param string $checkinDatetime Check-in datetime * @param string $scheduleStartTime Schedule start time (H:i:s format) * @return string Status (PRESENT or LATE) */ protected function determineStatus(string $checkinDatetime, string $scheduleStartTime): string { $checkinTimestamp = strtotime($checkinDatetime); $checkinTime = date('H:i:s', $checkinTimestamp); // Parse schedule start time $scheduleParts = explode(':', $scheduleStartTime); $scheduleHour = (int) $scheduleParts[0]; $scheduleMinute = (int) $scheduleParts[1]; $scheduleSecond = isset($scheduleParts[2]) ? (int) $scheduleParts[2] : 0; // Create schedule datetime from checkin date $checkinDate = date('Y-m-d', $checkinTimestamp); $scheduleDatetime = sprintf( '%s %02d:%02d:%02d', $checkinDate, $scheduleHour, $scheduleMinute, $scheduleSecond ); $scheduleTimestamp = strtotime($scheduleDatetime); $toleranceSeconds = $this->lateToleranceMinutes * 60; $lateThreshold = $scheduleTimestamp + $toleranceSeconds; // Check if check-in time exceeds late threshold if ($checkinTimestamp > $lateThreshold) { return AttendanceSession::STATUS_LATE; } return AttendanceSession::STATUS_PRESENT; } /** * Check if check-in time is within the configured attendance window. * Window: [schedule_start - open_before_minutes, schedule_start + close_after_minutes]. * * @param string $checkinDatetime Check-in datetime (Y-m-d H:i:s) * @param string $scheduleStartTime Schedule start time (H:i:s) * @return bool True if within window */ protected function isWithinAttendanceWindow(string $checkinDatetime, string $scheduleStartTime): bool { $config = config('Attendance'); $openMin = $config->attendanceOpenBeforeMinutes ?? 5; $closeMin = $config->attendanceCloseAfterMinutes ?? 15; $checkinTs = strtotime($checkinDatetime); $date = date('Y-m-d', $checkinTs); $scheduleParts = explode(':', $scheduleStartTime); $h = (int) ($scheduleParts[0] ?? 0); $m = (int) ($scheduleParts[1] ?? 0); $s = (int) ($scheduleParts[2] ?? 0); $scheduleStartTs = strtotime(sprintf('%s %02d:%02d:%02d', $date, $h, $m, $s)); $windowOpenTs = $scheduleStartTs - ($openMin * 60); $windowCloseTs = $scheduleStartTs + ($closeMin * 60); return $checkinTs >= $windowOpenTs && $checkinTs <= $windowCloseTs; } /** * Check-in via QR token (guru generate QR, siswa scan). * Payload: student_id, qr_token; optional lat, lng. * Returns same shape as checkin(): status, attendance_id, checkin_at, etc. */ public function checkinByQr(array $payload): array { $studentId = (int) ($payload['student_id'] ?? 0); $qrToken = trim((string) ($payload['qr_token'] ?? '')); $datetime = date('Y-m-d H:i:s'); $lat = (float) ($payload['lat'] ?? 0); $lng = (float) ($payload['lng'] ?? 0); if ($studentId < 1 || $qrToken === '') { return $this->createResultWithoutInsert( 'INVALID_QR_TOKEN', $studentId, null, 0, $datetime, $lat, $lng, null ); } $tokenData = $this->qrTokenModel->validateToken($qrToken); if (!$tokenData) { return $this->createResultWithoutInsert( 'INVALID_QR_TOKEN', $studentId, null, 0, $datetime, $lat, $lng, null ); } $scheduleId = $tokenData['schedule_id']; $schedule = $this->scheduleModel->getScheduleWithSlot($scheduleId); if (!$schedule) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_NO_SCHEDULE, $studentId, $scheduleId, 0, $datetime, $lat, $lng, null ); } $student = $this->studentModel->find($studentId); if (!$student) { return $this->createResultWithoutInsert( 'INVALID_QR_TOKEN', $studentId, $scheduleId, 0, $datetime, $lat, $lng, null ); } $scheduleClassId = (int) $schedule['class_id']; $studentClassId = (int) ($student->class_id ?? 0); if ($studentClassId !== $scheduleClassId) { return $this->createResultWithoutInsert( 'STUDENT_NOT_IN_CLASS', $studentId, $scheduleId, 0, $datetime, $lat, $lng, null ); } $attendanceDate = date('Y-m-d'); if ($this->attendanceModel->hasAttendanceFor($studentId, $scheduleId, $attendanceDate)) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_ALREADY_CHECKED_IN, $studentId, $scheduleId, 0, $datetime, $lat, $lng, null ); } $mobileDevice = $this->deviceModel->findActiveByDeviceCode('MOBILE_APP'); $deviceId = $mobileDevice ? (int) $mobileDevice->id : 0; if ($deviceId < 1) { return $this->createResultWithoutInsert( AttendanceSession::STATUS_INVALID_DEVICE, $studentId, $scheduleId, 0, $datetime, $lat, $lng, null ); } $status = $this->determineStatus($datetime, $schedule['start_time']); return $this->createResult( $status, $studentId, $scheduleId, $deviceId, $datetime, $lat, $lng, 1.0 ); } /** * Create result and save attendance session * * @param string $status Status * @param int $studentId Student ID * @param int|null $scheduleId Schedule ID * @param int $deviceId Device ID * @param string $datetime Check-in datetime * @param float $lat Latitude * @param float $lng Longitude * @param float|null $confidence Confidence score * @return array Result array */ protected function createResult( string $status, int $studentId, ?int $scheduleId, int $deviceId, string $datetime, float $lat, float $lng, ?float $confidence ): array { // Save attendance session (attendance_date = server date for unique constraint) $attendanceData = [ 'student_id' => $studentId, 'schedule_id' => $scheduleId, 'attendance_date' => date('Y-m-d'), 'device_id' => $deviceId, 'checkin_at' => $datetime, 'latitude' => $lat, 'longitude' => $lng, 'confidence' => $confidence, 'status' => $status, ]; $attendanceId = $this->attendanceModel->insert($attendanceData); $result = [ 'status' => $status, 'attendance_id' => $attendanceId, 'student_id' => $studentId, 'schedule_id' => $scheduleId, 'device_id' => $deviceId, 'checkin_at' => $datetime, 'latitude' => $lat, 'longitude' => $lng, 'confidence' => $confidence, ]; // Notify parents via Telegram when PRESENT or LATE (do not break flow on failure) if ($status === AttendanceSession::STATUS_PRESENT || $status === AttendanceSession::STATUS_LATE) { $this->notifyParentsOfCheckin($studentId, $scheduleId, $datetime, $status); } return $result; } /** * Build result array without inserting (for ALREADY_CHECKED_IN, ABSENCE_WINDOW_CLOSED). */ protected function createResultWithoutInsert( string $status, int $studentId, ?int $scheduleId, int $deviceId, string $datetime, float $lat, float $lng, ?float $confidence ): array { return [ 'status' => $status, 'attendance_id' => null, 'student_id' => $studentId, 'schedule_id' => $scheduleId, 'device_id' => $deviceId, 'checkin_at' => $datetime, 'latitude' => $lat, 'longitude' => $lng, 'confidence' => $confidence, ]; } /** * Send Telegram notification to student's linked parents */ protected function notifyParentsOfCheckin(int $studentId, ?int $scheduleId, string $checkinAt, string $status): void { try { $studentModel = new StudentModel(); $classModel = new ClassModel(); $scheduleModel = new ScheduleModel(); $subjectModel = new SubjectModel(); $telegramAccountModel = new TelegramAccountModel(); $telegramBot = new TelegramBotService(); $student = $studentModel->find($studentId); if (!$student) { return; } $studentName = htmlspecialchars((string) ($student->name ?? '-'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $className = '-'; if ($student->class_id) { $class = $classModel->find($student->class_id); $className = $class ? $class->name : '-'; } $subjectName = '-'; if ($scheduleId) { $schedule = $scheduleModel->find($scheduleId); if ($schedule && $schedule->subject_id) { $subject = $subjectModel->find($schedule->subject_id); $subjectName = $subject ? htmlspecialchars((string) $subject->name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') : '-'; } } $statusLabel = $status === AttendanceSession::STATUS_PRESENT ? 'Hadir' : 'Terlambat'; $timeStr = date('H:i', strtotime($checkinAt)) . ' WIB'; $emojiStatus = $status === AttendanceSession::STATUS_PRESENT ? '✅' : '⏰'; $message = "{$emojiStatus} Absensi SMAN 1 Garut\n\n"; $message .= "Nama: {$studentName}\n"; $message .= "Kelas: " . htmlspecialchars($className, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n"; $message .= "Mapel: " . htmlspecialchars($subjectName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n"; $message .= "Status: {$emojiStatus} {$statusLabel}\n"; $message .= "Waktu: {$timeStr}"; $telegramUserIds = $telegramAccountModel->getTelegramUserIdsByStudentId($studentId); foreach ($telegramUserIds as $telegramUserId) { try { $telegramBot->sendMessage($telegramUserId, $message); } catch (\Throwable $e) { log_message('error', 'AttendanceCheckinService notifyParents: sendMessage failed for telegram_user_id=' . $telegramUserId . ' - ' . $e->getMessage()); } } } catch (\Throwable $e) { log_message('error', 'AttendanceCheckinService notifyParents: ' . $e->getMessage()); } } }