init backend presensi
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user