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