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