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 @@
# Attendance Module - Controllers

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Modules\Attendance\Controllers;
use App\Core\BaseApiController;
use App\Modules\Attendance\Services\AttendanceCheckinService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Attendance Controller
*
* Handles attendance check-in endpoints.
*/
class AttendanceController extends BaseApiController
{
protected AttendanceCheckinService $checkinService;
public function __construct()
{
$this->checkinService = new AttendanceCheckinService();
}
/**
* Attendance check-in endpoint
*
* POST /api/attendance/checkin
* Body: {
* "device_code": "",
* "api_key": "",
* "student_id": 0,
* "datetime": "Y-m-d H:i:s",
* "lat": 0.0,
* "lng": 0.0,
* "confidence": 0.0 (optional)
* }
*
* @return ResponseInterface
*/
public function checkin(): ResponseInterface
{
// Get JSON input
$input = $this->request->getJSON(true);
// Validate required fields
$requiredFields = ['device_code', 'api_key', 'student_id', 'datetime', 'lat', 'lng'];
foreach ($requiredFields as $field) {
if (!isset($input[$field])) {
return $this->errorResponse(
"Field '{$field}' is required",
null,
null,
400
);
}
}
// Process check-in
try {
$result = $this->checkinService->checkin($input);
// Determine message based on status
$messages = [
'PRESENT' => 'Attendance recorded successfully',
'LATE' => 'Attendance recorded but marked as late',
'OUTSIDE_ZONE' => 'Check-in failed: Location outside school zone',
'NO_SCHEDULE' => 'Check-in failed: No active schedule found',
'INVALID_DEVICE' => 'Check-in failed: Invalid device credentials',
'ALREADY_CHECKED_IN' => 'Already checked in for this schedule today',
'ABSENCE_WINDOW_CLOSED' => 'Check-in failed: Outside attendance window',
'SESSION_CLOSED' => 'Attendance session closed',
];
$message = $messages[$result['status']] ?? 'Attendance check-in processed';
// Return success response (even for failures, as the record is saved)
return $this->successResponse(
$result,
$message
);
} catch (\Exception $e) {
return $this->errorResponse(
'An error occurred while processing check-in',
['error' => $e->getMessage()],
null,
500
);
}
}
}

View File

@@ -0,0 +1,260 @@
<?php
namespace App\Modules\Attendance\Controllers;
use App\Core\BaseApiController;
use App\Modules\Auth\Entities\Role;
use App\Modules\Auth\Services\AuthService;
use App\Modules\Attendance\Services\AttendanceReportService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Attendance Report Controller
*
* On-the-fly schedule attendance reports (expected, present, late, absent).
*/
class AttendanceReportController extends BaseApiController
{
protected AttendanceReportService $reportService;
protected AuthService $authService;
public function __construct()
{
$this->reportService = new AttendanceReportService();
$this->authService = new AuthService();
}
/**
* GET /api/attendance/reports
*
* Query:
* - from_date (YYYY-MM-DD, optional, default: today if both empty)
* - to_date (YYYY-MM-DD, optional, default: today if both empty)
* - class_id (int, optional)
* - student_id (int, optional)
* - status (PRESENT,LATE,OUTSIDE_ZONE,NO_SCHEDULE,INVALID_DEVICE; optional)
*
* Returns recap per hari/per kelas + daftar detail kehadiran.
*/
public function index(): ResponseInterface
{
$user = $this->authService->currentUser();
if (! $user) {
return $this->errorResponse('Unauthorized', null, null, ResponseInterface::HTTP_UNAUTHORIZED);
}
$roles = $user['roles'] ?? [];
$roleCodes = array_column($roles, 'role_code');
$isAdmin = in_array(Role::CODE_ADMIN, $roleCodes, true);
$isGuruBk = in_array(Role::CODE_GURU_BK, $roleCodes, true);
$isWali = in_array(Role::CODE_WALI_KELAS, $roleCodes, true);
$isGuruMap = in_array(Role::CODE_GURU_MAPEL, $roleCodes, true);
// ORANG_TUA pakai Portal Orang Tua, bukan endpoint ini
if (! $isAdmin && ! $isGuruBk && ! $isWali && ! $isGuruMap) {
return $this->errorResponse('Forbidden', null, null, ResponseInterface::HTTP_FORBIDDEN);
}
$from = $this->request->getGet('from_date');
$to = $this->request->getGet('to_date');
$today = date('Y-m-d');
if (($from === null || $from === '') && ($to === null || $to === '')) {
$from = $today;
$to = $today;
}
if ($from !== null && $from !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
return $this->errorResponse('from_date must be YYYY-MM-DD', null, null, ResponseInterface::HTTP_BAD_REQUEST);
}
if ($to !== null && $to !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
return $this->errorResponse('to_date must be YYYY-MM-DD', null, null, ResponseInterface::HTTP_BAD_REQUEST);
}
$classId = (int) $this->request->getGet('class_id');
$studentId = (int) $this->request->getGet('student_id');
$status = $this->request->getGet('status');
if ($status !== null && $status !== '') {
$allowedStatus = ['PRESENT', 'LATE', 'OUTSIDE_ZONE', 'NO_SCHEDULE', 'INVALID_DEVICE'];
if (! in_array($status, $allowedStatus, true)) {
return $this->errorResponse('Invalid status value', null, null, ResponseInterface::HTTP_BAD_REQUEST);
}
}
$db = \Config\Database::connect();
$builder = $db->table('attendance_sessions AS att')
->select(
'att.id, att.attendance_date, att.status, att.checkin_at, ' .
's.id AS student_id, s.nisn, s.name AS student_name, ' .
'c.id AS class_id, c.grade, c.major, c.name AS class_name, ' .
'sch.id AS schedule_id, sub.id AS subject_id, sub.name AS subject_name, ' .
'u.id AS teacher_user_id, u.name AS teacher_name'
)
->join('students AS s', 's.id = att.student_id', 'inner')
->join('classes AS c', 'c.id = s.class_id', 'left')
->join('schedules AS sch', 'sch.id = att.schedule_id', 'left')
->join('subjects AS sub', 'sub.id = sch.subject_id', 'left')
->join('users AS u', 'u.id = sch.teacher_user_id', 'left');
if ($from) {
$builder->where('att.attendance_date >=', $from);
}
if ($to) {
$builder->where('att.attendance_date <=', $to);
}
if ($classId > 0) {
$builder->where('c.id', $classId);
}
if ($studentId > 0) {
$builder->where('s.id', $studentId);
}
if ($status) {
$builder->where('att.status', $status);
}
// RBAC batasan data
if ($isWali) {
$builder->where('c.wali_user_id', (int) $user['id']);
} elseif ($isGuruMap) {
$builder->where('sch.teacher_user_id', (int) $user['id']);
} elseif (! $isAdmin && ! $isGuruBk) {
// Should not reach here, tetapi jaga-jaga
return $this->errorResponse('Forbidden', null, null, ResponseInterface::HTTP_FORBIDDEN);
}
$builder->orderBy('att.attendance_date', 'DESC')
->orderBy('c.grade', 'ASC')
->orderBy('c.major', 'ASC')
->orderBy('c.name', 'ASC')
->orderBy('s.name', 'ASC');
$rows = $builder->get()->getResultArray();
// Detail records
$records = array_map(static function (array $r): array {
$classLabel = null;
if ($r['grade'] !== null || $r['major'] !== null || $r['class_name'] !== null) {
$parts = array_filter([
trim((string) ($r['grade'] ?? '')),
trim((string) ($r['major'] ?? '')),
trim((string) ($r['class_name'] ?? '')),
]);
$classLabel = implode(' ', $parts);
}
return [
'id' => (int) $r['id'],
'attendance_date' => $r['attendance_date'],
'status' => $r['status'],
'checkin_at' => $r['checkin_at'],
'student_id' => (int) $r['student_id'],
'student_name' => $r['student_name'],
'nisn' => $r['nisn'],
'class_id' => $r['class_id'] !== null ? (int) $r['class_id'] : null,
'class_label' => $classLabel,
'schedule_id' => $r['schedule_id'] !== null ? (int) $r['schedule_id'] : null,
'subject_id' => $r['subject_id'] !== null ? (int) $r['subject_id'] : null,
'subject_name' => $r['subject_name'],
'teacher_user_id' => $r['teacher_user_id'] !== null ? (int) $r['teacher_user_id'] : null,
'teacher_name' => $r['teacher_name'],
];
}, $rows);
// Rekap per hari & kelas
$summaryMap = [];
foreach ($records as $rec) {
$date = $rec['attendance_date'];
$cid = $rec['class_id'] ?? 0;
$label = $rec['class_label'] ?? '-';
$key = $date . '|' . $cid;
if (! isset($summaryMap[$key])) {
$summaryMap[$key] = [
'attendance_date' => $date,
'class_id' => $cid,
'class_label' => $label,
'total' => 0,
'present' => 0,
'late' => 0,
'outside_zone' => 0,
'no_schedule' => 0,
'invalid_device' => 0,
];
}
$summaryMap[$key]['total']++;
switch ($rec['status']) {
case 'PRESENT':
$summaryMap[$key]['present']++;
break;
case 'LATE':
$summaryMap[$key]['late']++;
break;
case 'OUTSIDE_ZONE':
$summaryMap[$key]['outside_zone']++;
break;
case 'NO_SCHEDULE':
$summaryMap[$key]['no_schedule']++;
break;
case 'INVALID_DEVICE':
$summaryMap[$key]['invalid_device']++;
break;
}
}
$summary = array_values($summaryMap);
usort($summary, static function (array $a, array $b): int {
if ($a['attendance_date'] === $b['attendance_date']) {
return strcmp((string) $a['class_label'], (string) $b['class_label']);
}
return strcmp($a['attendance_date'], $b['attendance_date']);
});
$payload = [
'filters' => [
'from_date' => $from,
'to_date' => $to,
'class_id' => $classId > 0 ? $classId : null,
'student_id' => $studentId > 0 ? $studentId : null,
'status' => $status ?: null,
],
'summary' => $summary,
'records' => $records,
];
return $this->successResponse($payload, 'Attendance reports');
}
/**
* GET /api/attendance/report/schedule/{scheduleId}?date=YYYY-MM-DD
*
* @param int|string $scheduleId From route (:num)
* @return ResponseInterface
*/
public function scheduleReport($scheduleId): ResponseInterface
{
$scheduleId = (int) $scheduleId;
$date = $this->request->getGet('date');
if ($date === null || $date === '') {
return $this->errorResponse('Query parameter date (YYYY-MM-DD) is required', null, null, 400);
}
$date = (string) $date;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $this->errorResponse('Parameter date must be YYYY-MM-DD', null, null, 400);
}
$userContext = $this->authService->currentUser();
$report = $this->reportService->getScheduleAttendanceReport($scheduleId, $date, $userContext);
if ($report === null) {
return $this->errorResponse('Schedule not found or access denied', null, null, 404);
}
return $this->successResponse($report, 'Schedule attendance report');
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Modules\Attendance\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\StudentModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Face link API: menghubungkan ID wajah eksternal dengan siswa.
*
* Catatan:
* - Engine AI / OpenCV bertugas menghasilkan face_external_id.
* - Backend hanya menyimpan mapping face_external_id -> student_id.
*/
class FaceLinkController extends BaseApiController
{
/**
* POST /api/attendance/face/enroll
* Body: { student_id: int, face_external_id: string }
*/
public function enroll(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$studentId = (int) ($payload['student_id'] ?? 0);
$faceId = trim((string) ($payload['face_external_id'] ?? ''));
if ($studentId <= 0 || $faceId === '') {
return $this->errorResponse(
'student_id dan face_external_id wajib diisi',
null,
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
$studentModel = new StudentModel();
$student = $studentModel->find($studentId);
if (! $student) {
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
// Pastikan face_external_id belum dipakai siswa lain
$existing = $studentModel
->where('face_external_id', $faceId)
->where('id !=', $studentId)
->first();
if ($existing) {
return $this->errorResponse('face_external_id sudah terpakai siswa lain', null, null, ResponseInterface::HTTP_CONFLICT);
}
$studentModel->skipValidation(true);
$studentModel->update($studentId, ['face_external_id' => $faceId]);
return $this->successResponse([
'student_id' => $studentId,
'face_external_id' => $faceId,
], 'Face ID berhasil dihubungkan dengan siswa');
}
/**
* POST /api/attendance/face/resolve
* Body: { face_external_id: string }
* Return: { student_id, name, class_id } atau 404.
*/
public function resolve(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$faceId = trim((string) ($payload['face_external_id'] ?? ''));
if ($faceId === '') {
return $this->errorResponse(
'face_external_id wajib diisi',
null,
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
$studentModel = new StudentModel();
$student = $studentModel->where('face_external_id', $faceId)->first();
if (! $student) {
return $this->errorResponse('Siswa untuk face_external_id ini tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
return $this->successResponse([
'student_id' => (int) $student->id,
'name' => (string) $student->name,
'class_id' => $student->class_id !== null ? (int) $student->class_id : null,
], 'Face ID berhasil dikenali');
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Modules\Attendance\Controllers;
use App\Core\BaseApiController;
use App\Modules\Face\Models\StudentFaceModel;
use App\Modules\Face\Services\FaceService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* FaceVerifyController
*
* POST /api/attendance/verify-face
* Body: { "student_id": 123, "image": "data:image/jpeg;base64,..." }
*
* Strategi: Option 1 — bandingkan probe hanya dengan embedding milik student_id kandidat.
*/
class FaceVerifyController extends BaseApiController
{
public function verify(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$studentId = (int) ($payload['student_id'] ?? 0);
$imageData = (string) ($payload['image'] ?? '');
if ($studentId < 1) {
return $this->errorResponse('student_id wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
if ($imageData === '') {
return $this->errorResponse('image (base64) wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
}
$tmpFile = tempnam(sys_get_temp_dir(), 'probe_');
try {
$raw = $this->decodeBase64Image($imageData);
if ($raw === null) {
throw new \RuntimeException('Format gambar tidak valid (harus base64)');
}
file_put_contents($tmpFile, $raw);
$faceService = new FaceService();
$probe = $faceService->extractEmbeddingWithQuality($tmpFile)['embedding'];
$faceModel = new StudentFaceModel();
$rows = $faceModel->where('student_id', $studentId)->findAll();
if (empty($rows)) {
return $this->errorResponse('Belum ada embedding wajah untuk siswa ini', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$bestSim = -1.0;
$bestSource = null;
foreach ($rows as $row) {
$embedding = json_decode($row['embedding'] ?? '[]', true);
if (! is_array($embedding) || $embedding === []) {
continue;
}
$sim = $faceService->cosineSimilarity($probe, array_map('floatval', $embedding));
if ($sim > $bestSim) {
$bestSim = $sim;
$bestSource = $row['source'] ?? null;
}
}
if ($bestSim < 0) {
return $this->errorResponse('Tidak ada embedding valid untuk siswa ini', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$threshold = $faceService->getDefaultThreshold();
$status = $bestSim >= $threshold ? 'match' : 'no_match';
$data = [
'student_id' => $studentId,
'similarity' => $bestSim,
'threshold' => $threshold,
'matched_source'=> $bestSource,
'status' => $status,
];
return $this->successResponse($data, 'Face verification processed');
} catch (\Throwable $e) {
return $this->errorResponse('Gagal verifikasi wajah: ' . $e->getMessage(), null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
} finally {
if (is_file($tmpFile)) {
@unlink($tmpFile);
}
}
}
protected function decodeBase64Image(string $input): ?string
{
$input = trim($input);
if ($input === '') {
return null;
}
if (strpos($input, 'base64,') !== false) {
$parts = explode('base64,', $input, 2);
$input = $parts[1];
}
$data = base64_decode($input, true);
return $data === false ? null : $data;
}
}

View File

@@ -0,0 +1 @@
# Attendance Module - Entities

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Modules\Attendance\Entities;
use CodeIgniter\Entity\Entity;
/**
* Attendance Session Entity
*
* Represents an attendance check-in session.
*/
class AttendanceSession extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'student_id',
'schedule_id',
'checkin_type',
'attendance_date',
'device_id',
'checkin_at',
'latitude',
'longitude',
'confidence',
'status',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'student_id' => 'integer',
'schedule_id' => 'integer',
'checkin_type' => 'string',
'attendance_date' => 'date',
'device_id' => 'integer',
'checkin_at' => 'datetime',
'latitude' => 'float',
'longitude' => 'float',
'confidence' => 'float',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Status constants
*/
public const STATUS_PRESENT = 'PRESENT';
public const STATUS_LATE = 'LATE';
public const STATUS_OUTSIDE_ZONE = 'OUTSIDE_ZONE';
public const STATUS_NO_SCHEDULE = 'NO_SCHEDULE';
public const STATUS_INVALID_DEVICE = 'INVALID_DEVICE';
public const STATUS_ALREADY_CHECKED_IN = 'ALREADY_CHECKED_IN';
public const STATUS_ABSENCE_WINDOW_CLOSED = 'ABSENCE_WINDOW_CLOSED';
public const STATUS_SESSION_CLOSED = 'SESSION_CLOSED';
}

View File

@@ -0,0 +1 @@
# Attendance Module - Models

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Modules\Attendance\Models;
use App\Modules\Attendance\Entities\AttendanceSession;
use CodeIgniter\Model;
/**
* Attendance Session Model
*
* Handles database operations for attendance sessions.
*/
class AttendanceSessionModel extends Model
{
protected $table = 'attendance_sessions';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = AttendanceSession::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'student_id',
'schedule_id',
'checkin_type',
'attendance_date',
'device_id',
'checkin_at',
'latitude',
'longitude',
'confidence',
'status',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [
'student_id' => 'required|integer|is_not_unique[students.id]',
'schedule_id' => 'permit_empty|integer|is_not_unique[schedules.id]',
'checkin_type' => 'permit_empty|in_list[mapel,masuk,pulang]',
'attendance_date' => 'required|valid_date[Y-m-d]',
'device_id' => 'required|integer|is_not_unique[devices.id]',
'checkin_at' => 'required|valid_date[Y-m-d H:i:s]',
'latitude' => 'required|decimal',
'longitude' => 'required|decimal',
'confidence' => 'permit_empty|decimal',
'status' => 'required|in_list[PRESENT,LATE,OUTSIDE_ZONE,NO_SCHEDULE,INVALID_DEVICE]',
];
/**
* Check if attendance already exists for student + schedule on the given date (server date).
* Used for duplicate protection: one attendance per (student_id, schedule_id, attendance_date).
*
* @param int $studentId
* @param int $scheduleId
* @param string $attendanceDate Date in Y-m-d format (use server date)
* @return bool
*/
public function hasAttendanceFor(int $studentId, int $scheduleId, string $attendanceDate): bool
{
$row = $this->where('student_id', $studentId)
->where('schedule_id', $scheduleId)
->where('attendance_date', $attendanceDate)
->first();
return $row !== null;
}
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Modules\Attendance\Models;
use CodeIgniter\Model;
/**
* Token QR untuk absen mapel: guru generate, siswa scan.
*/
class QrAttendanceTokenModel extends Model
{
protected $table = 'qr_attendance_tokens';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $allowedFields = [
'schedule_id',
'token',
'expires_at',
'created_by_user_id',
];
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
/** Token valid (default) 15 menit */
public const VALID_MINUTES = 15;
/**
* Generate token untuk schedule_id. Returns token string or null on failure.
*/
public function generateForSchedule(int $scheduleId, ?int $createdByUserId = null): ?string
{
$token = bin2hex(random_bytes(16));
$expires = date('Y-m-d H:i:s', strtotime('+' . self::VALID_MINUTES . ' minutes'));
$id = $this->insert([
'schedule_id' => $scheduleId,
'token' => $token,
'expires_at' => $expires,
'created_by_user_id' => $createdByUserId,
]);
return $id ? $token : null;
}
/**
* Validate token: return row (schedule_id, expires_at) if valid and not expired; null otherwise.
*/
public function validateToken(string $token): ?array
{
$row = $this->where('token', $token)->first();
if (!$row || !is_array($row)) {
return null;
}
$expiresAt = $row['expires_at'] ?? null;
if (!$expiresAt || strtotime($expiresAt) < time()) {
return null;
}
return [
'schedule_id' => (int) $row['schedule_id'],
'expires_at' => $row['expires_at'],
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
/**
* Attendance Module Routes
*
* This file is automatically loaded by ModuleLoader.
* Define your attendance management routes here.
*
* @var RouteCollection $routes
*/
// Attendance routes
$routes->group('api/attendance', ['namespace' => 'App\Modules\Attendance\Controllers'], function ($routes) {
$routes->post('checkin', 'AttendanceController::checkin');
$routes->post('face/enroll', 'FaceLinkController::enroll');
$routes->post('face/resolve', 'FaceLinkController::resolve');
$routes->post('verify-face', 'FaceVerifyController::verify');
$routes->get('reports', 'AttendanceReportController::index');
$routes->get('report/schedule/(:num)', 'AttendanceReportController::scheduleReport/$1');
});

View File

@@ -0,0 +1 @@
# Attendance Module - Services

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

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