init backend presensi

This commit is contained in:
mwpn
2026-03-05 14:37:36 +07:00
commit b4fda6b9c9
319 changed files with 27261 additions and 0 deletions

View File

@@ -0,0 +1,776 @@
<?php
namespace App\Modules\Attendance\Services;
use App\Modules\Academic\Models\ClassModel;
use App\Modules\Academic\Models\ScheduleModel;
use App\Modules\Academic\Models\StudentModel;
use App\Modules\Academic\Models\SubjectModel;
use App\Modules\Academic\Services\ScheduleResolverService;
use App\Modules\Attendance\Entities\AttendanceSession;
use App\Modules\Attendance\Models\AttendanceSessionModel;
use App\Modules\Attendance\Models\QrAttendanceTokenModel;
use App\Modules\Devices\Models\DeviceModel;
use App\Modules\Devices\Services\DeviceAuthService;
use App\Modules\Dashboard\Models\SchoolPresenceSettingsModel;
use App\Modules\Geo\Models\ZoneModel;
use App\Modules\Geo\Services\GeoFenceService;
use App\Modules\Notification\Models\TelegramAccountModel;
use App\Modules\Notification\Services\TelegramBotService;
/**
* Attendance Check-in Service
*
* Handles attendance check-in logic combining device auth, schedule resolution, and geofencing.
* Sends Telegram notification to parents when status is PRESENT or LATE.
*/
class AttendanceCheckinService
{
protected DeviceAuthService $deviceAuthService;
protected ScheduleResolverService $scheduleResolverService;
protected ScheduleModel $scheduleModel;
protected StudentModel $studentModel;
protected GeoFenceService $geoFenceService;
protected ZoneModel $zoneModel;
protected AttendanceSessionModel $attendanceModel;
protected QrAttendanceTokenModel $qrTokenModel;
protected DeviceModel $deviceModel;
protected SchoolPresenceSettingsModel $presenceSettingsModel;
/**
* Device code untuk aplikasi mobile (absen masuk/pulang)
*/
public const MOBILE_APP_DEVICE_CODE = 'MOBILE_APP';
/**
* Late tolerance in minutes (default: 10 minutes)
*/
protected int $lateToleranceMinutes = 10;
/**
* Grace period in seconds after schedule end_time before session is considered closed (default: 30)
*/
protected int $sessionCloseGraceSeconds = 30;
public function __construct()
{
$this->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 = "<b>{$emojiStatus} Absensi SMAN 1 Garut</b>\n\n";
$message .= "Nama: <b>{$studentName}</b>\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} <b>{$statusLabel}</b>\n";
$message .= "Waktu: <i>{$timeStr}</i>";
$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());
}
}
}