init backend presensi
This commit is contained in:
120
app/Modules/Academic/Services/DapodikClient.php
Normal file
120
app/Modules/Academic/Services/DapodikClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
312
app/Modules/Academic/Services/DapodikSyncService.php
Normal file
312
app/Modules/Academic/Services/DapodikSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
78
app/Modules/Academic/Services/ScheduleResolverService.php
Normal file
78
app/Modules/Academic/Services/ScheduleResolverService.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user