313 lines
11 KiB
PHP
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;
|
|
}
|
|
}
|