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,120 @@
<?php
namespace App\Modules\Academic\Services;
/**
* Dapodik WebService API client.
* Uses env: DAPODIK_BASE_URL, DAPODIK_TOKEN, DAPODIK_NPSN.
* Do not log or expose token.
*/
class DapodikClient
{
protected string $baseUrl;
protected string $token;
protected string $npsn;
public function __construct(?string $baseUrl = null, ?string $token = null, ?string $npsn = null)
{
$this->baseUrl = rtrim($baseUrl ?? (string) env('DAPODIK_BASE_URL', ''), '/');
$this->token = $token ?? (string) env('DAPODIK_TOKEN', '');
$this->npsn = $npsn ?? (string) env('DAPODIK_NPSN', '');
}
/**
* GET getSekolah
*
* @return array{success: bool, data?: array, error?: string}
*/
public function getSekolah(): array
{
$url = $this->baseUrl . '/getSekolah';
if ($this->npsn !== '') {
$url .= '?npsn=' . rawurlencode($this->npsn);
}
return $this->request('GET', $url);
}
/**
* GET getPesertaDidik with pagination
*
* @param int $start
* @param int $limit
* @return array{success: bool, data?: array, rows?: array, id?: mixed, start?: int, limit?: int, results?: int, error?: string}
*/
public function getPesertaDidik(int $start = 0, int $limit = 200): array
{
$params = [];
if ($this->npsn !== '') {
$params['npsn'] = $this->npsn;
}
$params['start'] = $start;
$params['limit'] = $limit;
$url = $this->baseUrl . '/getPesertaDidik?' . http_build_query($params);
return $this->request('GET', $url);
}
/**
* Execute HTTP request. Returns decoded JSON with success flag and error message on failure.
*/
protected function request(string $method, string $url): array
{
$ch = curl_init();
if ($ch === false) {
return ['success' => false, 'error' => 'cURL init failed'];
}
$headers = [
'Accept: application/json',
'Authorization: Bearer ' . $this->token,
];
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CUSTOMREQUEST => $method,
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
return ['success' => false, 'error' => 'Network error: ' . ($errno === CURLE_OPERATION_TIMEDOUT ? 'timeout' : 'curl ' . $errno)];
}
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['success' => false, 'error' => 'Invalid JSON response', 'http_code' => $httpCode];
}
if ($httpCode < 200 || $httpCode >= 300) {
$msg = is_array($decoded) && isset($decoded['message']) ? $decoded['message'] : 'HTTP ' . $httpCode;
return ['success' => false, 'error' => $msg, 'http_code' => $httpCode, 'data' => $decoded];
}
return array_merge(['success' => true], $decoded);
}
/**
* Normalize Dapodik response to a list of rows.
* Dapodik returns { results, id, start, limit, rows: [...] }
*
* @param array $response Response from getPesertaDidik
* @return array<int, array<string, mixed>>
*/
public static function normalizePesertaDidikRows(array $response): array
{
if (! isset($response['rows']) || ! is_array($response['rows'])) {
return [];
}
$rows = $response['rows'];
$out = [];
foreach ($rows as $i => $row) {
$out[] = is_array($row) ? $row : (array) $row;
}
return $out;
}
}

View File

@@ -0,0 +1,312 @@
<?php
namespace App\Modules\Academic\Services;
use App\Modules\Academic\Models\DapodikRombelMappingModel;
use App\Modules\Academic\Models\DapodikSyncJobModel;
use App\Modules\Academic\Models\StudentModel;
/**
* Syncs Dapodik peserta didik into local students with rombel -> class mapping.
*/
class DapodikSyncService
{
protected DapodikClient $client;
protected DapodikRombelMappingModel $mappingModel;
protected StudentModel $studentModel;
protected DapodikSyncJobModel $jobModel;
public function __construct(?DapodikClient $client = null, ?DapodikSyncJobModel $jobModel = null)
{
$this->client = $client ?? new DapodikClient();
$this->mappingModel = new DapodikRombelMappingModel();
$this->studentModel = new StudentModel();
$this->jobModel = $jobModel ?? new DapodikSyncJobModel();
}
/**
* Sync students from Dapodik with job tracking. Paginates getPesertaDidik, upserts per batch.
*
* @param int $jobId Job row id for progress updates
* @param int $limit Per-page limit
* @param int $maxPages Max pages to fetch
* @return void
*/
public function syncStudentsWithJob(int $jobId, int $limit = 100, int $maxPages = 50): void
{
$start = 0;
$page = 0;
$now = date('Y-m-d H:i:s');
$processedTotal = 0;
$totalRows = 0;
$knownTotal = false;
$db = \Config\Database::connect();
try {
while ($page < $maxPages) {
$response = $this->client->getPesertaDidik($start, $limit);
if (! ($response['success'] ?? false)) {
$err = $response['error'] ?? 'Unknown error';
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_FAILED,
'message' => $err,
'finished_at' => $now,
]);
return;
}
$rows = DapodikClient::normalizePesertaDidikRows($response);
if ($rows === []) {
break;
}
if (! $knownTotal) {
$results = $response['results'] ?? null;
if (is_numeric($results) && (int) $results > 0) {
$totalRows = (int) $results;
$knownTotal = true;
$this->jobModel->update($jobId, ['total_rows' => $totalRows]);
}
}
$db->transStart();
try {
$batchCount = 0;
foreach ($rows as $row) {
$dapodikId = $this->extractDapodikId($row);
$nisn = $this->extractString($row, ['nisn']);
$nama = $this->extractString($row, ['nama', 'name']);
$gender = $this->extractGender($row);
$rombel = $this->extractString($row, ['rombel', 'nama_rombel', 'rombongan_belajar']);
// Require at least one stable identifier
if ($dapodikId === null && $nisn === '') {
continue;
}
$mapping = $this->mappingModel->getByRombel($rombel);
if ($mapping === null) {
$this->mappingModel->insert([
'dapodik_rombel' => $rombel,
'class_id' => null,
'last_seen_at' => $now,
]);
$classId = null;
} else {
$this->mappingModel->update($mapping['id'], ['last_seen_at' => $now]);
$classId = ! empty($mapping['class_id']) ? (int) $mapping['class_id'] : null;
}
$data = [
'dapodik_id' => $dapodikId,
'nisn' => $nisn,
'name' => $nama !== '' ? $nama : ($nisn !== '' ? 'Siswa ' . $nisn : 'Siswa Dapodik'),
'gender' => $gender,
'class_id' => $classId,
'is_active' => 1,
];
// Use upsert so repeated syncs update instead of inserting duplicates
$this->studentModel->skipValidation(true);
$this->studentModel->upsert($data);
$batchCount++;
}
$db->transComplete();
} catch (\Throwable $e) {
$db->transRollback();
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_FAILED,
'message' => $e->getMessage(),
'finished_at' => $now,
]);
return;
}
$processedTotal += $batchCount;
$this->jobModel->update($jobId, [
'processed_rows' => $processedTotal,
]);
if (count($rows) < $limit) {
if (! $knownTotal) {
$this->jobModel->update($jobId, ['total_rows' => $processedTotal]);
}
break;
}
$start += $limit;
$page++;
}
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_COMPLETED,
'finished_at' => $now,
]);
} catch (\Throwable $e) {
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_FAILED,
'message' => $e->getMessage(),
'finished_at' => date('Y-m-d H:i:s'),
]);
}
}
/**
* Extract Dapodik stable identifier for a student row.
*
* @param array $row
* @return string|null
*/
private function extractDapodikId(array $row): ?string
{
foreach (['peserta_didik_id', 'id', 'pd_id'] as $key) {
if (isset($row[$key]) && is_scalar($row[$key])) {
$v = trim((string) $row[$key]);
if ($v !== '') {
return $v;
}
}
}
return null;
}
/**
* Estimate total rows from first Dapodik response (if available).
*
* @param int $limit Per-page limit
* @return array{total: int, from_api: bool}
*/
public function estimateTotalStudents(int $limit = 100): array
{
$response = $this->client->getPesertaDidik(0, $limit);
if (! ($response['success'] ?? false)) {
return ['total' => 0, 'from_api' => false];
}
$results = $response['results'] ?? null;
if (is_numeric($results) && (int) $results > 0) {
return ['total' => (int) $results, 'from_api' => true];
}
$rows = DapodikClient::normalizePesertaDidikRows($response);
return ['total' => count($rows) > 0 ? count($rows) : 0, 'from_api' => false];
}
/**
* Legacy sync (no job). Kept for backwards compatibility.
*
* @param int $limit Per-page limit
* @param int $maxPages Max pages to fetch
* @return array{fetched_total: int, inserted_students: int, updated_students: int, unmapped_students_count: int, mappings_created: int, mappings_seen_updated: int, errors: array<int, string>}
*/
public function syncStudents(int $limit = 200, int $maxPages = 50): array
{
$summary = [
'fetched_total' => 0,
'inserted_students' => 0,
'updated_students' => 0,
'unmapped_students_count' => 0,
'mappings_created' => 0,
'mappings_seen_updated' => 0,
'errors' => [],
];
$start = 0;
$page = 0;
$now = date('Y-m-d H:i:s');
while ($page < $maxPages) {
$response = $this->client->getPesertaDidik($start, $limit);
if (! ($response['success'] ?? false)) {
$summary['errors'][] = $response['error'] ?? 'Unknown error';
break;
}
$rows = DapodikClient::normalizePesertaDidikRows($response);
if ($rows === []) {
break;
}
$summary['fetched_total'] += count($rows);
foreach ($rows as $row) {
$nisn = $this->extractString($row, ['nisn']);
$nama = $this->extractString($row, ['nama', 'name']);
$gender = $this->extractGender($row);
$rombel = $this->extractString($row, ['rombel', 'nama_rombel', 'rombongan_belajar']);
if ($nisn === '') {
continue;
}
$mapping = $this->mappingModel->getByRombel($rombel);
if ($mapping === null) {
$this->mappingModel->insert([
'dapodik_rombel' => $rombel,
'class_id' => null,
'last_seen_at' => $now,
]);
$summary['mappings_created']++;
$classId = null;
} else {
$this->mappingModel->update($mapping['id'], ['last_seen_at' => $now]);
$summary['mappings_seen_updated']++;
$classId = ! empty($mapping['class_id']) ? (int) $mapping['class_id'] : null;
}
if ($classId === null) {
$summary['unmapped_students_count']++;
}
$existing = $this->studentModel->findByNisn($nisn);
$data = [
'nisn' => $nisn,
'name' => $nama !== '' ? $nama : 'Siswa ' . $nisn,
'gender' => $gender,
'class_id' => $classId,
'is_active'=> 1,
];
$this->studentModel->skipValidation(false);
if ($existing) {
$this->studentModel->update($existing->id, $data);
$summary['updated_students']++;
} else {
$this->studentModel->insert($data);
$summary['inserted_students']++;
}
}
if (count($rows) < $limit) {
break;
}
$start += $limit;
$page++;
}
return $summary;
}
private function extractString(array $row, array $keys): string
{
foreach ($keys as $k) {
if (isset($row[$k]) && is_scalar($row[$k])) {
return trim((string) $row[$k]);
}
}
return '';
}
private function extractGender(array $row): ?string
{
$v = $row['jenis_kelamin'] ?? $row['gender'] ?? $row['jk'] ?? null;
if ($v === null || $v === '') {
return null;
}
$v = strtoupper(trim((string) $v));
if ($v === 'L' || $v === 'LAKI-LAKI' || $v === 'Laki-laki') {
return 'L';
}
if ($v === 'P' || $v === 'PEREMPUAN' || $v === 'Perempuan') {
return 'P';
}
return null;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Modules\Academic\Services;
use App\Modules\Academic\Models\ScheduleModel;
use App\Modules\Academic\Models\StudentModel;
/**
* Schedule Resolver Service
*
* Handles schedule resolution logic for students.
*/
class ScheduleResolverService
{
protected StudentModel $studentModel;
protected ScheduleModel $scheduleModel;
public function __construct()
{
$this->studentModel = new StudentModel();
$this->scheduleModel = new ScheduleModel();
}
/**
* Get active schedule for a student at a specific datetime
*
* Rules:
* - Find student's class_id
* - Determine day_of_week from datetime (1=Monday, 7=Sunday)
* - Find schedule where start_time <= time < end_time
*
* @param int $studentId Student ID
* @param string $datetime Datetime string (Y-m-d H:i:s format)
* @return array|null Returns schedule detail or null if not found
*/
public function getActiveSchedule(int $studentId, string $datetime): ?array
{
// Find student
$student = $this->studentModel->find($studentId);
if (!$student) {
return null;
}
// Get class_id from student
$classId = $student->class_id;
// Parse datetime to get day of week and time
$timestamp = strtotime($datetime);
if ($timestamp === false) {
return null;
}
// Get day of week (1=Monday, 7=Sunday)
// PHP date('N') returns 1-7 (Monday-Sunday)
$dayOfWeek = (int) date('N', $timestamp);
// Get time in H:i:s format
$time = date('H:i:s', $timestamp);
// Find active schedule (lesson_slots/users as source of truth with fallback to schedule columns)
$row = $this->scheduleModel->getActiveScheduleRow($classId, $dayOfWeek, $time);
if ($row === null) {
return null;
}
return [
'schedule_id' => $row['id'],
'class_id' => $row['class_id'],
'subject_id' => $row['subject_id'],
'teacher_name' => $row['teacher_name'],
'room' => $row['room'],
'start_time' => $row['start_time'],
'end_time' => $row['end_time'],
];
}
}