init backend presensi
This commit is contained in:
1
app/Modules/Attendance/Services/.gitkeep
Normal file
1
app/Modules/Attendance/Services/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Attendance Module - Services
|
||||
776
app/Modules/Attendance/Services/AttendanceCheckinService.php
Normal file
776
app/Modules/Attendance/Services/AttendanceCheckinService.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
189
app/Modules/Attendance/Services/AttendanceReportService.php
Normal file
189
app/Modules/Attendance/Services/AttendanceReportService.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Attendance\Services;
|
||||
|
||||
use App\Modules\Auth\Entities\Role;
|
||||
use App\Modules\Attendance\Entities\AttendanceSession;
|
||||
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\Attendance\Models\AttendanceSessionModel;
|
||||
|
||||
/**
|
||||
* Attendance Report Service
|
||||
*
|
||||
* On-the-fly computation of schedule attendance report (expected, present, late, absent).
|
||||
* No DB insert for ABSENT. RBAC applied when userContext provided.
|
||||
*/
|
||||
class AttendanceReportService
|
||||
{
|
||||
protected ScheduleModel $scheduleModel;
|
||||
protected StudentModel $studentModel;
|
||||
protected SubjectModel $subjectModel;
|
||||
protected ClassModel $classModel;
|
||||
protected AttendanceSessionModel $attendanceModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->scheduleModel = new ScheduleModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->subjectModel = new SubjectModel();
|
||||
$this->classModel = new ClassModel();
|
||||
$this->attendanceModel = new AttendanceSessionModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schedule attendance report for a given date.
|
||||
* Absent = expected students minus those with PRESENT/LATE record (no insert).
|
||||
*
|
||||
* @param int $scheduleId
|
||||
* @param string $dateYmd Y-m-d
|
||||
* @param array|null $userContext { id, name, email, roles: [ { role_code } ] } for RBAC
|
||||
* @return array|null Report array or null if schedule not found / no access
|
||||
*/
|
||||
public function getScheduleAttendanceReport(int $scheduleId, string $dateYmd, ?array $userContext = null): ?array
|
||||
{
|
||||
$schedule = $this->scheduleModel->getScheduleWithSlot($scheduleId);
|
||||
if ($schedule === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($userContext !== null && !$this->canAccessSchedule($schedule, $userContext)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$classId = (int) $schedule['class_id'];
|
||||
|
||||
$expectedStudents = $this->studentModel->where('class_id', $classId)->findAll();
|
||||
$expectedIds = array_map(fn ($s) => (int) $s->id, $expectedStudents);
|
||||
$expectedTotal = count($expectedIds);
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table('attendance_sessions AS a');
|
||||
$builder->select('a.student_id, s.nisn, s.name, a.status, a.checkin_at');
|
||||
$builder->join('students AS s', 's.id = a.student_id', 'inner');
|
||||
$builder->where('a.schedule_id', $scheduleId);
|
||||
$builder->where('a.attendance_date', $dateYmd);
|
||||
$builder->whereIn('a.status', [AttendanceSession::STATUS_PRESENT, AttendanceSession::STATUS_LATE]);
|
||||
$builder->orderBy('s.name', 'ASC');
|
||||
$rows = $builder->get()->getResultArray();
|
||||
|
||||
$presentList = [];
|
||||
$presentIds = [];
|
||||
$lateTotal = 0;
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int) $row['student_id'];
|
||||
$presentIds[] = $sid;
|
||||
$presentList[] = [
|
||||
'student_id' => $sid,
|
||||
'nisn' => (string) ($row['nisn'] ?? ''),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'status' => (string) $row['status'],
|
||||
'checkin_at' => (string) $row['checkin_at'],
|
||||
];
|
||||
if ($row['status'] === AttendanceSession::STATUS_LATE) {
|
||||
$lateTotal++;
|
||||
}
|
||||
}
|
||||
$presentTotal = count($presentList);
|
||||
|
||||
$absentIds = array_diff($expectedIds, $presentIds);
|
||||
$absentList = [];
|
||||
foreach ($expectedStudents as $s) {
|
||||
if (in_array((int) $s->id, $absentIds, true)) {
|
||||
$absentList[] = [
|
||||
'student_id' => (int) $s->id,
|
||||
'nisn' => (string) ($s->nisn ?? ''),
|
||||
'name' => (string) ($s->name ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($absentList, fn ($a, $b) => strcmp($a['name'], $b['name']));
|
||||
$absentTotal = count($absentList);
|
||||
|
||||
$subject = $this->subjectModel->find($schedule['subject_id']);
|
||||
$subjectName = $subject ? (string) $subject->name : '-';
|
||||
$classEntity = $this->classModel->find($classId);
|
||||
$className = $classEntity ? (string) $classEntity->name : '-';
|
||||
|
||||
$schedulePayload = [
|
||||
'id' => (int) $schedule['id'],
|
||||
'class_id' => (int) $schedule['class_id'],
|
||||
'class_name' => $className,
|
||||
'subject' => $subjectName,
|
||||
'teacher' => (string) ($schedule['teacher_name'] ?? ''),
|
||||
'start_time' => (string) $schedule['start_time'],
|
||||
'end_time' => (string) $schedule['end_time'],
|
||||
'day_of_week' => (int) $schedule['day_of_week'],
|
||||
];
|
||||
|
||||
return [
|
||||
'schedule' => $schedulePayload,
|
||||
'summary' => [
|
||||
'expected_total' => $expectedTotal,
|
||||
'present_total' => $presentTotal,
|
||||
'late_total' => $lateTotal,
|
||||
'absent_total' => $absentTotal,
|
||||
],
|
||||
'present' => $presentList,
|
||||
'absent' => $absentList,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* RBAC: whether user can access this schedule.
|
||||
* @param array|object $schedule Schedule row (array from getScheduleWithSlot) or entity with class_id, teacher_user_id
|
||||
*/
|
||||
protected function canAccessSchedule(array|object $schedule, array $user): bool
|
||||
{
|
||||
$roles = $user['roles'] ?? [];
|
||||
$roleCodes = array_column($roles, 'role_code');
|
||||
|
||||
if (in_array(Role::CODE_ADMIN, $roleCodes, true) || in_array(Role::CODE_GURU_BK, $roleCodes, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$classId = (int) (is_array($schedule) ? $schedule['class_id'] : $schedule->class_id);
|
||||
|
||||
if (in_array(Role::CODE_WALI_KELAS, $roleCodes, true)) {
|
||||
$db = \Config\Database::connect();
|
||||
$row = $db->table('classes')->select('id')->where('id', $classId)->where('wali_user_id', $user['id'])->get()->getRow();
|
||||
return $row !== null;
|
||||
}
|
||||
|
||||
if (in_array(Role::CODE_GURU_MAPEL, $roleCodes, true)) {
|
||||
$teacherUserId = is_array($schedule)
|
||||
? (isset($schedule['teacher_user_id']) ? (int) $schedule['teacher_user_id'] : 0)
|
||||
: (isset($schedule->teacher_user_id) ? (int) $schedule->teacher_user_id : 0);
|
||||
return $teacherUserId === (int) $user['id'];
|
||||
}
|
||||
|
||||
if (in_array(Role::CODE_ORANG_TUA, $roleCodes, true)) {
|
||||
$studentIds = $this->getStudentIdsForParent((int) $user['id']);
|
||||
if ($studentIds === []) {
|
||||
return false;
|
||||
}
|
||||
$db = \Config\Database::connect();
|
||||
$overlap = $db->table('students')
|
||||
->where('class_id', $classId)
|
||||
->whereIn('id', $studentIds)
|
||||
->countAllResults();
|
||||
return $overlap > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function getStudentIdsForParent(int $userId): array
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('student_parents AS sp')
|
||||
->select('sp.student_id')
|
||||
->join('parents AS p', 'p.id = sp.parent_id', 'inner')
|
||||
->where('p.user_id', $userId)
|
||||
->get()
|
||||
->getResultArray();
|
||||
return array_map('intval', array_column($rows, 'student_id'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user