request->getJSON(true) ?? []; $limit = (int) ($body['limit'] ?? $this->request->getGet('limit') ?? 100); $maxPages = (int) ($body['max_pages'] ?? $this->request->getGet('max_pages') ?? 50); $limit = max(1, min(500, $limit)); $maxPages = max(1, min(100, $maxPages)); $jobModel = new DapodikSyncJobModel(); // Job locking: if there is already a running students-sync job, reuse it. $existing = $jobModel ->where('type', DapodikSyncJobModel::TYPE_STUDENTS) ->where('status', DapodikSyncJobModel::STATUS_RUNNING) ->orderBy('started_at', 'DESC') ->first(); if ($existing) { return $this->successResponse([ 'job_id' => (int) $existing['id'], 'total_rows' => (int) ($existing['total_rows'] ?? 0), 'status' => $existing['status'], ], 'Sync already running'); } $service = new DapodikSyncService(); $estimate = $service->estimateTotalStudents($limit); $totalRows = $estimate['total']; $now = date('Y-m-d H:i:s'); $jobId = $jobModel->insert([ 'type' => DapodikSyncJobModel::TYPE_STUDENTS, 'total_rows' => $totalRows, 'processed_rows'=> 0, 'status' => DapodikSyncJobModel::STATUS_RUNNING, 'message' => null, 'started_at' => $now, 'finished_at' => null, ]); $payload = [ 'job_id' => (int) $jobId, 'total_rows' => $totalRows, 'status' => 'running', ]; $this->successResponse($payload, 'Sync started')->send(); if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } ignore_user_abort(true); set_time_limit(0); $service->syncStudentsWithJob((int) $jobId, $limit, $maxPages); exit; } /** * GET /api/academic/dapodik/sync/status/{job_id} * Returns job progress for polling. */ public function status(int $jobId): ResponseInterface { $jobModel = new DapodikSyncJobModel(); $job = $jobModel->find($jobId); if (! $job) { return $this->errorResponse('Job not found', null, null, 404); } $total = (int) ($job['total_rows'] ?? 0); $processed = (int) ($job['processed_rows'] ?? 0); $percent = $total > 0 ? min(100, (int) round(($processed / $total) * 100)) : ($processed > 0 ? 1 : 0); return $this->successResponse([ 'total_rows' => $total, 'processed_rows' => $processed, 'percent' => $percent, 'status' => $job['status'], 'message' => $job['message'] ?? null, ]); } /** * GET /api/academic/dapodik/rombels * Query: unmapped_only=1 to filter. */ public function rombels(): ResponseInterface { $unmappedOnly = $this->request->getGet('unmapped_only') === '1' || $this->request->getGet('unmapped_only') === 'true'; $db = \Config\Database::connect(); $builder = $db->table('dapodik_rombel_mappings AS m') ->select('m.id, m.dapodik_rombel, m.class_id, m.last_seen_at, m.updated_at, c.grade, c.major, c.name AS class_name') ->join('classes AS c', 'c.id = m.class_id', 'left') ->orderBy('m.dapodik_rombel', 'ASC'); if ($unmappedOnly) { $builder->where('m.class_id', null); } $rows = $builder->get()->getResultArray(); $data = array_map(static function ($r) { $classLabel = null; if ($r['class_id'] !== null && isset($r['grade'])) { $classLabel = trim(($r['grade'] ?? '') . ' ' . ($r['major'] ?? '') . ' ' . ($r['class_name'] ?? '')); } return [ 'id' => (int) $r['id'], 'dapodik_rombel' => (string) $r['dapodik_rombel'], 'class_id' => $r['class_id'] !== null ? (int) $r['class_id'] : null, 'class_label' => $classLabel, 'last_seen_at' => $r['last_seen_at'], 'updated_at' => $r['updated_at'], ]; }, $rows); return $this->successResponse($data, 'Rombel mappings'); } /** * PUT /api/academic/dapodik/rombels/{id} * Body: class_id (nullable) */ public function updateRombel(int $id): ResponseInterface { $payload = $this->request->getJSON(true) ?? $this->request->getPost(); $classId = null; if (isset($payload['class_id']) && $payload['class_id'] !== '' && $payload['class_id'] !== null) { $classId = (int) $payload['class_id']; $db = \Config\Database::connect(); $exists = $db->table('classes')->where('id', $classId)->countAllResults(); if ($exists < 1) { return $this->errorResponse('class_id must exist in classes', null, null, 422); } } $model = new DapodikRombelMappingModel(); $row = $model->find($id); if (! $row) { return $this->errorResponse('Mapping not found', null, null, 404); } $model->update($id, ['class_id' => $classId]); $updated = $model->find($id); $classLabel = null; if ($updated['class_id']) { $db = \Config\Database::connect(); $c = $db->table('classes')->select('grade, major, name')->where('id', $updated['class_id'])->get()->getRowArray(); if ($c) { $classLabel = trim(($c['grade'] ?? '') . ' ' . ($c['major'] ?? '') . ' ' . ($c['name'] ?? '')); } } return $this->successResponse([ 'id' => (int) $updated['id'], 'dapodik_rombel' => (string) $updated['dapodik_rombel'], 'class_id' => $updated['class_id'] !== null ? (int) $updated['class_id'] : null, 'class_label' => $classLabel, ], 'Mapping updated'); } }