Files
presensi/app/Modules/Academic/Services/DapodikSyncService.php
2026-03-05 14:37:36 +07:00

313 lines
11 KiB
PHP

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