init backend presensi
This commit is contained in:
158
app/Modules/Academic/Controllers/ClassController.php
Normal file
158
app/Modules/Academic/Controllers/ClassController.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Academic\Models\ClassModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Class CRUD API (ADMIN only).
|
||||
*/
|
||||
class ClassController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* GET /api/academic/classes
|
||||
* Response: id, grade, major, name, wali_user_id, wali_name, full_label (e.g. "10 IPA A")
|
||||
*/
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('classes')
|
||||
->select('classes.id, classes.name, classes.grade, classes.major, classes.wali_user_id, users.name AS wali_user_name')
|
||||
->join('users', 'users.id = classes.wali_user_id', 'left')
|
||||
->orderBy('classes.grade', 'ASC')
|
||||
->orderBy('classes.major', 'ASC')
|
||||
->orderBy('classes.name', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$data = array_map(static function ($r) {
|
||||
$grade = $r['grade'] !== null ? trim((string) $r['grade']) : '';
|
||||
$major = $r['major'] !== null ? trim((string) $r['major']) : '';
|
||||
$name = $r['name'] !== null ? trim((string) $r['name']) : '';
|
||||
$parts = array_filter([$grade, $major, $name], static fn ($v) => $v !== '');
|
||||
$fullLabel = implode(' ', $parts) !== '' ? implode(' ', $parts) : (string) $r['name'];
|
||||
|
||||
return [
|
||||
'id' => (int) $r['id'],
|
||||
'grade' => $grade,
|
||||
'major' => $major,
|
||||
'name' => $name,
|
||||
'wali_user_id' => $r['wali_user_id'] !== null ? (int) $r['wali_user_id'] : null,
|
||||
'wali_name' => $r['wali_user_name'] !== null ? (string) $r['wali_user_name'] : null,
|
||||
'full_label' => $fullLabel,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->successResponse($data, 'Classes');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/academic/classes
|
||||
* Requires: grade, major, name (rombel). Optional: wali_user_id.
|
||||
*/
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$data = [
|
||||
'name' => $payload['name'] ?? '',
|
||||
'grade' => $payload['grade'] ?? '',
|
||||
'major' => $payload['major'] ?? '',
|
||||
'wali_user_id' => isset($payload['wali_user_id']) && $payload['wali_user_id'] !== '' && $payload['wali_user_id'] !== null
|
||||
? (int) $payload['wali_user_id']
|
||||
: null,
|
||||
];
|
||||
|
||||
$model = new ClassModel();
|
||||
if (!$model->validate($data)) {
|
||||
return $this->errorResponse(
|
||||
implode(' ', $model->errors()),
|
||||
$model->errors(),
|
||||
null,
|
||||
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
$id = $model->insert($data);
|
||||
if ($id === false) {
|
||||
return $this->errorResponse('Gagal menyimpan kelas', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$row = $model->find($id);
|
||||
$fullLabel = trim($row->grade . ' ' . $row->major . ' ' . $row->name) ?: (string) $row->name;
|
||||
$out = [
|
||||
'id' => (int) $row->id,
|
||||
'grade' => (string) $row->grade,
|
||||
'major' => (string) $row->major,
|
||||
'name' => (string) $row->name,
|
||||
'wali_user_id' => $row->wali_user_id !== null ? (int) $row->wali_user_id : null,
|
||||
'full_label' => $fullLabel,
|
||||
];
|
||||
return $this->successResponse($out, 'Kelas berhasil ditambahkan', null, ResponseInterface::HTTP_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/academic/classes/{id}
|
||||
*/
|
||||
public function update(int $id): ResponseInterface
|
||||
{
|
||||
$model = new ClassModel();
|
||||
$row = $model->find($id);
|
||||
if (!$row) {
|
||||
return $this->errorResponse('Kelas tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$data = [
|
||||
'id' => $id,
|
||||
'name' => $payload['name'] ?? $row->name,
|
||||
'grade' => $payload['grade'] ?? $row->grade,
|
||||
'major' => $payload['major'] ?? $row->major,
|
||||
'wali_user_id' => isset($payload['wali_user_id']) && $payload['wali_user_id'] !== '' && $payload['wali_user_id'] !== null
|
||||
? (int) $payload['wali_user_id']
|
||||
: null,
|
||||
];
|
||||
|
||||
if (!$model->validate($data)) {
|
||||
return $this->errorResponse(
|
||||
implode(' ', $model->errors()),
|
||||
$model->errors(),
|
||||
null,
|
||||
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
unset($data['id']);
|
||||
if ($model->update($id, $data) === false) {
|
||||
return $this->errorResponse('Gagal mengubah kelas', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$updated = $model->find($id);
|
||||
$fullLabel = trim($updated->grade . ' ' . $updated->major . ' ' . $updated->name) ?: (string) $updated->name;
|
||||
$out = [
|
||||
'id' => (int) $updated->id,
|
||||
'grade' => (string) $updated->grade,
|
||||
'major' => (string) $updated->major,
|
||||
'name' => (string) $updated->name,
|
||||
'wali_user_id' => $updated->wali_user_id !== null ? (int) $updated->wali_user_id : null,
|
||||
'full_label' => $fullLabel,
|
||||
];
|
||||
return $this->successResponse($out, 'Kelas berhasil diubah');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/academic/classes/{id}
|
||||
*/
|
||||
public function delete(int $id): ResponseInterface
|
||||
{
|
||||
$model = new ClassModel();
|
||||
if (!$model->find($id)) {
|
||||
return $this->errorResponse('Kelas tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
|
||||
}
|
||||
if ($model->delete($id) === false) {
|
||||
return $this->errorResponse('Gagal menghapus kelas', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
return $this->successResponse(null, 'Kelas berhasil dihapus');
|
||||
}
|
||||
}
|
||||
185
app/Modules/Academic/Controllers/DapodikSyncController.php
Normal file
185
app/Modules/Academic/Controllers/DapodikSyncController.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Academic\Models\DapodikSyncJobModel;
|
||||
use App\Modules\Academic\Models\DapodikRombelMappingModel;
|
||||
use App\Modules\Academic\Services\DapodikSyncService;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Dapodik sync and rombel mapping API (ADMIN only).
|
||||
*/
|
||||
class DapodikSyncController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* POST /api/academic/dapodik/sync/students
|
||||
* Starts sync job, returns job_id immediately, processes in background (same request after response sent).
|
||||
* If a students-sync job is already running, returns existing job_id (job locking).
|
||||
* Body/Query: limit, max_pages (optional).
|
||||
*/
|
||||
public function syncStudents(): ResponseInterface
|
||||
{
|
||||
$body = $this->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');
|
||||
}
|
||||
}
|
||||
129
app/Modules/Academic/Controllers/LessonSlotController.php
Normal file
129
app/Modules/Academic/Controllers/LessonSlotController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Academic\Models\LessonSlotModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Lesson Slot CRUD API (ADMIN only).
|
||||
*/
|
||||
class LessonSlotController extends BaseApiController
|
||||
{
|
||||
protected LessonSlotModel $model;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->model = new LessonSlotModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/academic/lesson-slots
|
||||
*/
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
$slots = $this->model->orderBy('slot_number', 'ASC')->findAll();
|
||||
$data = array_map([$this, 'slotToArray'], $slots);
|
||||
return $this->successResponse($data, 'Lesson slots');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/academic/lesson-slots
|
||||
*/
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
$input = $this->request->getJSON(true) ?? [];
|
||||
$data = [
|
||||
'slot_number' => (int) ($input['slot_number'] ?? 0),
|
||||
'start_time' => (string) ($input['start_time'] ?? ''),
|
||||
'end_time' => (string) ($input['end_time'] ?? ''),
|
||||
'is_active' => isset($input['is_active']) ? (int) (bool) $input['is_active'] : 1,
|
||||
];
|
||||
|
||||
if (!$this->model->validateStartBeforeEnd($data)) {
|
||||
return $this->errorResponse('End time must be after start time', null, null, 422);
|
||||
}
|
||||
|
||||
$id = $this->model->insert($data);
|
||||
if ($id === false) {
|
||||
$msg = $this->model->errors()['end_time'] ?? implode(' ', $this->model->errors());
|
||||
return $this->errorResponse($msg ?: 'Validation failed', null, null, 422);
|
||||
}
|
||||
|
||||
$row = $this->model->find($id);
|
||||
return $this->successResponse($this->slotToArray($row), 'Lesson slot created', null, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/academic/lesson-slots/{id}
|
||||
*/
|
||||
public function update($id): ResponseInterface
|
||||
{
|
||||
$id = (int) $id;
|
||||
$slot = $this->model->find($id);
|
||||
if (!$slot) {
|
||||
return $this->errorResponse('Lesson slot not found', null, null, 404);
|
||||
}
|
||||
|
||||
$input = $this->request->getJSON(true) ?? [];
|
||||
$data = [];
|
||||
if (array_key_exists('slot_number', $input)) {
|
||||
$data['slot_number'] = (int) $input['slot_number'];
|
||||
}
|
||||
if (array_key_exists('start_time', $input)) {
|
||||
$data['start_time'] = (string) $input['start_time'];
|
||||
}
|
||||
if (array_key_exists('end_time', $input)) {
|
||||
$data['end_time'] = (string) $input['end_time'];
|
||||
}
|
||||
if (array_key_exists('is_active', $input)) {
|
||||
$data['is_active'] = (int) (bool) $input['is_active'];
|
||||
}
|
||||
|
||||
if ($data !== [] && !$this->model->validateStartBeforeEnd(array_merge(
|
||||
['start_time' => $data['start_time'] ?? $slot->start_time, 'end_time' => $data['end_time'] ?? $slot->end_time]
|
||||
))) {
|
||||
return $this->errorResponse('End time must be after start time', null, null, 422);
|
||||
}
|
||||
|
||||
$this->model->update($id, $data);
|
||||
if ($this->model->errors()) {
|
||||
return $this->errorResponse(implode(' ', $this->model->errors()), null, null, 422);
|
||||
}
|
||||
|
||||
$row = $this->model->find($id);
|
||||
return $this->successResponse($this->slotToArray($row), 'Lesson slot updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/academic/lesson-slots/{id}
|
||||
*/
|
||||
public function delete($id): ResponseInterface
|
||||
{
|
||||
$id = (int) $id;
|
||||
if (!$this->model->find($id)) {
|
||||
return $this->errorResponse('Lesson slot not found', null, null, 404);
|
||||
}
|
||||
$this->model->delete($id);
|
||||
return $this->successResponse(null, 'Lesson slot deleted');
|
||||
}
|
||||
|
||||
protected function slotToArray($slot): array
|
||||
{
|
||||
if (is_array($slot)) {
|
||||
return [
|
||||
'id' => (int) ($slot['id'] ?? 0),
|
||||
'slot_number' => (int) ($slot['slot_number'] ?? 0),
|
||||
'start_time' => (string) ($slot['start_time'] ?? ''),
|
||||
'end_time' => (string) ($slot['end_time'] ?? ''),
|
||||
];
|
||||
}
|
||||
return [
|
||||
'id' => (int) $slot->id,
|
||||
'slot_number' => (int) $slot->slot_number,
|
||||
'start_time' => (string) $slot->start_time,
|
||||
'end_time' => (string) $slot->end_time,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Academic\Models\LessonSlotModel;
|
||||
use App\Modules\Academic\Models\ScheduleModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Weekly Schedule Management API (ADMIN only).
|
||||
*/
|
||||
class ScheduleManagementController extends BaseApiController
|
||||
{
|
||||
protected ScheduleModel $scheduleModel;
|
||||
protected LessonSlotModel $lessonSlotModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->scheduleModel = new ScheduleModel();
|
||||
$this->lessonSlotModel = new LessonSlotModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/academic/schedules/class/{classId}
|
||||
* Returns weekly schedule grid: slots with days 1–5, each cell schedule or null.
|
||||
*/
|
||||
public function getByClass($classId): ResponseInterface
|
||||
{
|
||||
$classId = (int) $classId;
|
||||
$slots = $this->lessonSlotModel->where('is_active', 1)->orderBy('slot_number', 'ASC')->findAll();
|
||||
$rows = $this->scheduleModel->where('class_id', $classId)->findAll();
|
||||
|
||||
$bySlotDay = [];
|
||||
foreach ($rows as $row) {
|
||||
$slotId = $row->lesson_slot_id ?? 0;
|
||||
$day = (int) $row->day_of_week;
|
||||
if ($slotId && $day >= 1 && $day <= 7) {
|
||||
$bySlotDay[$slotId][$day] = $this->scheduleToArray($row);
|
||||
}
|
||||
}
|
||||
|
||||
$grid = [];
|
||||
foreach ($slots as $slot) {
|
||||
$slotId = (int) $slot->id;
|
||||
$days = [];
|
||||
for ($d = 1; $d <= 5; $d++) {
|
||||
$days[$d] = $bySlotDay[$slotId][$d] ?? null;
|
||||
}
|
||||
$grid[] = [
|
||||
'lesson_slot_id' => $slotId,
|
||||
'slot_number' => (int) $slot->slot_number,
|
||||
'start_time' => (string) $slot->start_time,
|
||||
'end_time' => (string) $slot->end_time,
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->successResponse($grid, 'Weekly schedule');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/academic/schedules/bulk-save
|
||||
* Body: { class_id, schedules: [ { day_of_week, lesson_slot_id, subject_id, teacher_user_id [, room] }, ... ] }
|
||||
* Deletes existing schedules for class then inserts new ones in a transaction.
|
||||
*/
|
||||
public function bulkSave(): ResponseInterface
|
||||
{
|
||||
$input = $this->request->getJSON(true) ?? [];
|
||||
$classId = (int) ($input['class_id'] ?? 0);
|
||||
$items = $input['schedules'] ?? [];
|
||||
|
||||
if ($classId <= 0) {
|
||||
return $this->errorResponse('class_id is required and must be positive', null, null, 422);
|
||||
}
|
||||
|
||||
if (!is_array($items)) {
|
||||
return $this->errorResponse('schedules must be an array', null, null, 422);
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$db->transStart();
|
||||
|
||||
try {
|
||||
$this->scheduleModel->where('class_id', $classId)->delete();
|
||||
$this->scheduleModel->skipValidation(true);
|
||||
foreach ($items as $item) {
|
||||
$day = (int) ($item['day_of_week'] ?? 0);
|
||||
$slot = (int) ($item['lesson_slot_id'] ?? 0);
|
||||
$subj = (int) ($item['subject_id'] ?? 0);
|
||||
$teacher = (int) ($item['teacher_user_id'] ?? 0);
|
||||
if ($day < 1 || $day > 7 || $slot <= 0 || $subj <= 0) {
|
||||
$db->transRollback();
|
||||
return $this->errorResponse('Each schedule must have day_of_week (1-7), lesson_slot_id, subject_id', null, null, 422);
|
||||
}
|
||||
$data = [
|
||||
'class_id' => $classId,
|
||||
'subject_id' => $subj,
|
||||
'teacher_user_id' => $teacher ?: null,
|
||||
'lesson_slot_id' => $slot,
|
||||
'day_of_week' => $day,
|
||||
'room' => isset($item['room']) ? (string) $item['room'] : null,
|
||||
'is_active' => 1,
|
||||
];
|
||||
if ($this->scheduleModel->insert($data) === false) {
|
||||
$db->transRollback();
|
||||
return $this->errorResponse('Failed to insert schedule', null, null, 422);
|
||||
}
|
||||
}
|
||||
$this->scheduleModel->skipValidation(false);
|
||||
$db->transComplete();
|
||||
} catch (\Throwable $e) {
|
||||
if ($db->transStatus() === false) {
|
||||
$db->transRollback();
|
||||
}
|
||||
return $this->errorResponse($e->getMessage(), null, null, 500);
|
||||
}
|
||||
|
||||
if ($db->transStatus() === false) {
|
||||
return $this->errorResponse('Database transaction failed', null, null, 500);
|
||||
}
|
||||
|
||||
return $this->successResponse(null, 'Schedules saved');
|
||||
}
|
||||
|
||||
protected function scheduleToArray($schedule): array
|
||||
{
|
||||
if (is_array($schedule)) {
|
||||
return [
|
||||
'id' => (int) ($schedule['id'] ?? 0),
|
||||
'class_id' => (int) ($schedule['class_id'] ?? 0),
|
||||
'subject_id' => (int) ($schedule['subject_id'] ?? 0),
|
||||
'teacher_user_id' => isset($schedule['teacher_user_id']) ? (int) $schedule['teacher_user_id'] : null,
|
||||
'lesson_slot_id' => (int) ($schedule['lesson_slot_id'] ?? 0),
|
||||
'day_of_week' => (int) ($schedule['day_of_week'] ?? 0),
|
||||
'room' => isset($schedule['room']) ? (string) $schedule['room'] : null,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'id' => (int) $schedule->id,
|
||||
'class_id' => (int) $schedule->class_id,
|
||||
'subject_id' => (int) $schedule->subject_id,
|
||||
'teacher_user_id' => $schedule->teacher_user_id !== null ? (int) $schedule->teacher_user_id : null,
|
||||
'lesson_slot_id' => (int) $schedule->lesson_slot_id,
|
||||
'day_of_week' => (int) $schedule->day_of_week,
|
||||
'room' => $schedule->room !== null ? (string) $schedule->room : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
195
app/Modules/Academic/Controllers/StudentController.php
Normal file
195
app/Modules/Academic/Controllers/StudentController.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Academic\Models\StudentModel;
|
||||
use App\Modules\Academic\Models\ClassModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Student CRUD API (ADMIN only).
|
||||
*/
|
||||
class StudentController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* GET /api/academic/students
|
||||
* Query: class_id (optional), search (optional), unmapped_only (optional), page (default 1), per_page (default 25).
|
||||
*/
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
$classId = $this->request->getGet('class_id');
|
||||
$search = $this->request->getGet('search');
|
||||
$search = is_string($search) ? trim($search) : '';
|
||||
$unmappedOnly = $this->request->getGet('unmapped_only') === '1' || $this->request->getGet('unmapped_only') === 'true';
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = max(5, min(100, (int) ($this->request->getGet('per_page') ?? 20)));
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table('students')
|
||||
->select('students.id, students.nisn, students.name, students.gender, students.class_id, students.is_active,
|
||||
classes.grade, classes.major, classes.name AS class_name')
|
||||
->join('classes', 'classes.id = students.class_id', 'left')
|
||||
->orderBy('students.name', 'ASC');
|
||||
|
||||
if ($unmappedOnly) {
|
||||
$builder->where('students.class_id', null);
|
||||
} elseif ($classId !== null && $classId !== '') {
|
||||
$builder->where('students.class_id', (int) $classId);
|
||||
}
|
||||
if ($search !== '') {
|
||||
$builder->groupStart()
|
||||
->like('students.name', $search)
|
||||
->orLike('students.nisn', $search)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$total = $builder->countAllResults(false);
|
||||
$rows = $builder->limit($perPage, ($page - 1) * $perPage)->get()->getResultArray();
|
||||
|
||||
$data = array_map(static function ($r) {
|
||||
$grade = $r['grade'] !== null ? trim((string) $r['grade']) : '';
|
||||
$major = $r['major'] !== null ? trim((string) $r['major']) : '';
|
||||
$cName = $r['class_name'] !== null ? trim((string) $r['class_name']) : '';
|
||||
$parts = array_filter([$grade, $major, $cName], static fn ($v) => $v !== '');
|
||||
$fullLabel = $parts !== [] ? implode(' ', $parts) : null;
|
||||
|
||||
return [
|
||||
'id' => (int) $r['id'],
|
||||
'nisn' => (string) $r['nisn'],
|
||||
'name' => (string) $r['name'],
|
||||
'gender' => $r['gender'] !== null ? (string) $r['gender'] : null,
|
||||
'class_id' => $r['class_id'] !== null ? (int) $r['class_id'] : null,
|
||||
'class_label' => $fullLabel,
|
||||
'is_active' => (int) ($r['is_active'] ?? 1),
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
$totalPages = $total > 0 ? (int) ceil($total / $perPage) : 0;
|
||||
$meta = [
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total_pages' => $totalPages,
|
||||
];
|
||||
|
||||
return $this->successResponse($data, 'Students', $meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/academic/students
|
||||
*/
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$data = $this->payloadToStudentData($payload);
|
||||
|
||||
$model = new StudentModel();
|
||||
if (! $model->validate($data)) {
|
||||
return $this->errorResponse(
|
||||
implode(' ', $model->errors()),
|
||||
$model->errors(),
|
||||
null,
|
||||
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
$id = $model->insert($data);
|
||||
if ($id === false) {
|
||||
return $this->errorResponse('Gagal menyimpan siswa', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$row = $model->find($id);
|
||||
return $this->successResponse($this->rowToResponse($row), 'Siswa berhasil ditambahkan', null, ResponseInterface::HTTP_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/academic/students/{id}
|
||||
*/
|
||||
public function update(int $id): ResponseInterface
|
||||
{
|
||||
$model = new StudentModel();
|
||||
$row = $model->find($id);
|
||||
if (! $row) {
|
||||
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$data = $this->payloadToStudentData($payload, $row);
|
||||
|
||||
if (! $model->validate(array_merge(['id' => $id], $data))) {
|
||||
return $this->errorResponse(
|
||||
implode(' ', $model->errors()),
|
||||
$model->errors(),
|
||||
null,
|
||||
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
if ($model->update($id, $data) === false) {
|
||||
return $this->errorResponse('Gagal mengubah siswa', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$updated = $model->find($id);
|
||||
return $this->successResponse($this->rowToResponse($updated), 'Siswa berhasil diubah');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/academic/students/{id}
|
||||
*/
|
||||
public function delete(int $id): ResponseInterface
|
||||
{
|
||||
$model = new StudentModel();
|
||||
if (! $model->find($id)) {
|
||||
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
|
||||
}
|
||||
if ($model->delete($id) === false) {
|
||||
return $this->errorResponse('Gagal menghapus siswa', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
return $this->successResponse(null, 'Siswa berhasil dihapus');
|
||||
}
|
||||
|
||||
private function payloadToStudentData(array $payload, $existing = null): array
|
||||
{
|
||||
$classId = isset($payload['class_id']) && $payload['class_id'] !== '' && $payload['class_id'] !== null
|
||||
? (int) $payload['class_id']
|
||||
: null;
|
||||
$gender = isset($payload['gender']) && in_array($payload['gender'], ['L', 'P'], true)
|
||||
? $payload['gender']
|
||||
: ($existing && isset($existing->gender) ? $existing->gender : null);
|
||||
$isActive = array_key_exists('is_active', $payload)
|
||||
? (int) (bool) $payload['is_active']
|
||||
: ($existing && isset($existing->is_active) ? (int) $existing->is_active : 1);
|
||||
|
||||
return [
|
||||
'nisn' => trim($payload['nisn'] ?? $existing->nisn ?? ''),
|
||||
'name' => trim($payload['name'] ?? $existing->name ?? ''),
|
||||
'gender' => $gender,
|
||||
'class_id' => $classId,
|
||||
'is_active'=> $isActive,
|
||||
];
|
||||
}
|
||||
|
||||
private function rowToResponse($row): array
|
||||
{
|
||||
$classId = $row->class_id ?? null;
|
||||
$classLabel = null;
|
||||
if ($classId !== null) {
|
||||
$classModel = new ClassModel();
|
||||
$c = $classModel->find($classId);
|
||||
if ($c) {
|
||||
$classLabel = trim($c->grade . ' ' . $c->major . ' ' . $c->name) ?: (string) $c->name;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'nisn' => (string) $row->nisn,
|
||||
'name' => (string) $row->name,
|
||||
'gender' => $row->gender !== null ? (string) $row->gender : null,
|
||||
'class_id' => $classId !== null ? (int) $classId : null,
|
||||
'class_label' => $classLabel,
|
||||
'is_active' => (int) ($row->is_active ?? 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
121
app/Modules/Academic/Controllers/SubjectController.php
Normal file
121
app/Modules/Academic/Controllers/SubjectController.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Academic\Models\SubjectModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Subject CRUD API (ADMIN only).
|
||||
*/
|
||||
class SubjectController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* GET /api/academic/subjects
|
||||
*/
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
$model = new SubjectModel();
|
||||
$rows = $model->orderBy('name', 'ASC')->findAll();
|
||||
$data = array_map(static function ($s) {
|
||||
return [
|
||||
'id' => (int) $s->id,
|
||||
'name' => (string) $s->name,
|
||||
'code' => $s->code !== null ? (string) $s->code : null,
|
||||
];
|
||||
}, $rows);
|
||||
return $this->successResponse($data, 'Subjects');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/academic/subjects
|
||||
*/
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$data = [
|
||||
'name' => trim($payload['name'] ?? ''),
|
||||
'code' => isset($payload['code']) && $payload['code'] !== '' ? trim($payload['code']) : null,
|
||||
];
|
||||
|
||||
$model = new SubjectModel();
|
||||
if (!$model->validate($data)) {
|
||||
return $this->errorResponse(
|
||||
implode(' ', $model->errors()),
|
||||
$model->errors(),
|
||||
null,
|
||||
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
$id = $model->insert($data);
|
||||
if ($id === false) {
|
||||
return $this->errorResponse('Gagal menyimpan mata pelajaran', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$row = $model->find($id);
|
||||
$out = [
|
||||
'id' => (int) $row->id,
|
||||
'name' => (string) $row->name,
|
||||
'code' => $row->code !== null ? (string) $row->code : null,
|
||||
];
|
||||
return $this->successResponse($out, 'Mata pelajaran berhasil ditambahkan', null, ResponseInterface::HTTP_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/academic/subjects/{id}
|
||||
*/
|
||||
public function update(int $id): ResponseInterface
|
||||
{
|
||||
$model = new SubjectModel();
|
||||
$row = $model->find($id);
|
||||
if (!$row) {
|
||||
return $this->errorResponse('Mata pelajaran tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$data = [
|
||||
'id' => $id,
|
||||
'name' => trim($payload['name'] ?? $row->name),
|
||||
'code' => isset($payload['code']) && $payload['code'] !== '' ? trim($payload['code']) : null,
|
||||
];
|
||||
|
||||
if (!$model->validate($data)) {
|
||||
return $this->errorResponse(
|
||||
implode(' ', $model->errors()),
|
||||
$model->errors(),
|
||||
null,
|
||||
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
unset($data['id']);
|
||||
if ($model->update($id, $data) === false) {
|
||||
return $this->errorResponse('Gagal mengubah mata pelajaran', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$updated = $model->find($id);
|
||||
$out = [
|
||||
'id' => (int) $updated->id,
|
||||
'name' => (string) $updated->name,
|
||||
'code' => $updated->code !== null ? (string) $updated->code : null,
|
||||
];
|
||||
return $this->successResponse($out, 'Mata pelajaran berhasil diubah');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/academic/subjects/{id}
|
||||
*/
|
||||
public function delete(int $id): ResponseInterface
|
||||
{
|
||||
$model = new SubjectModel();
|
||||
if (!$model->find($id)) {
|
||||
return $this->errorResponse('Mata pelajaran tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
|
||||
}
|
||||
if ($model->delete($id) === false) {
|
||||
return $this->errorResponse('Gagal menghapus mata pelajaran', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
return $this->successResponse(null, 'Mata pelajaran berhasil dihapus');
|
||||
}
|
||||
}
|
||||
63
app/Modules/Academic/Controllers/TeacherController.php
Normal file
63
app/Modules/Academic/Controllers/TeacherController.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Auth\Models\RoleModel;
|
||||
use App\Modules\Auth\Models\UserModel;
|
||||
use App\Modules\Auth\Models\UserRoleModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Teachers list API (ADMIN only). Returns users with role GURU_MAPEL or WALI_KELAS.
|
||||
*/
|
||||
class TeacherController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* GET /api/academic/teachers
|
||||
*/
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
$roleModel = new RoleModel();
|
||||
$waliRole = $roleModel->findByCode('WALI_KELAS');
|
||||
$guruRole = $roleModel->findByCode('GURU_MAPEL');
|
||||
if (!$waliRole || !$guruRole) {
|
||||
return $this->successResponse([], 'Teachers');
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$userIdList = $db->table('user_roles')
|
||||
->select('user_id')
|
||||
->whereIn('role_id', [$waliRole->id, $guruRole->id])
|
||||
->get()
|
||||
->getResultArray();
|
||||
$userIdList = array_values(array_unique(array_map(static fn ($r) => (int) $r['user_id'], $userIdList)));
|
||||
if ($userIdList === []) {
|
||||
return $this->successResponse([], 'Teachers');
|
||||
}
|
||||
|
||||
$userModel = new UserModel();
|
||||
$userRoleModel = new UserRoleModel();
|
||||
$users = $userModel->whereIn('id', $userIdList)->findAll();
|
||||
$data = [];
|
||||
foreach ($users as $u) {
|
||||
$roleIds = $userRoleModel->getRoleIdsForUser((int) $u->id);
|
||||
$roles = [];
|
||||
foreach ($roleIds as $rid) {
|
||||
$r = $roleModel->find($rid);
|
||||
if ($r && in_array($r->role_code, ['GURU_MAPEL', 'WALI_KELAS'], true)) {
|
||||
$roles[] = ['role_code' => $r->role_code, 'role_name' => $r->role_name];
|
||||
}
|
||||
}
|
||||
$data[] = [
|
||||
'id' => (int) $u->id,
|
||||
'name' => (string) $u->name,
|
||||
'email' => (string) $u->email,
|
||||
'roles' => $roles,
|
||||
];
|
||||
}
|
||||
usort($data, static fn ($a, $b) => strcasecmp($a['name'], $b['name']));
|
||||
|
||||
return $this->successResponse($data, 'Teachers');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Academic\Models\TeacherSubjectModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Assign mapel per guru (ADMIN only).
|
||||
*/
|
||||
class TeacherSubjectController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* GET /api/academic/teacher-subjects/{teacherId}
|
||||
* Return: { teacher_user_id, subject_ids: [...] }
|
||||
*/
|
||||
public function getByTeacher(int $teacherId): ResponseInterface
|
||||
{
|
||||
$model = new TeacherSubjectModel();
|
||||
$ids = $model->getSubjectIdsForTeacher($teacherId);
|
||||
|
||||
return $this->successResponse([
|
||||
'teacher_user_id' => $teacherId,
|
||||
'subject_ids' => $ids,
|
||||
], 'Teacher subjects');
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/academic/teacher-subjects/{teacherId}
|
||||
* Body: { subject_ids: [1,2,3] }
|
||||
*/
|
||||
public function updateForTeacher(int $teacherId): ResponseInterface
|
||||
{
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$ids = $payload['subject_ids'] ?? [];
|
||||
|
||||
if (! is_array($ids)) {
|
||||
return $this->errorResponse('subject_ids must be an array', null, null, 422);
|
||||
}
|
||||
|
||||
$cleanIds = [];
|
||||
foreach ($ids as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$cleanIds[$id] = true;
|
||||
}
|
||||
}
|
||||
$subjectIds = array_keys($cleanIds);
|
||||
|
||||
$model = new TeacherSubjectModel();
|
||||
$db = \Config\Database::connect();
|
||||
$db->transStart();
|
||||
|
||||
try {
|
||||
$model->where('teacher_user_id', $teacherId)->delete();
|
||||
foreach ($subjectIds as $sid) {
|
||||
$model->insert([
|
||||
'teacher_user_id' => $teacherId,
|
||||
'subject_id' => $sid,
|
||||
]);
|
||||
}
|
||||
$db->transComplete();
|
||||
} catch (\Throwable $e) {
|
||||
$db->transRollback();
|
||||
return $this->errorResponse($e->getMessage(), null, null, 500);
|
||||
}
|
||||
|
||||
if ($db->transStatus() === false) {
|
||||
return $this->errorResponse('Database transaction failed', null, null, 500);
|
||||
}
|
||||
|
||||
return $this->successResponse([
|
||||
'teacher_user_id' => $teacherId,
|
||||
'subject_ids' => $subjectIds,
|
||||
], 'Teacher subjects updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/academic/teacher-subjects/map
|
||||
* Return: { [subject_id]: [teacher_user_id, ...], ... }
|
||||
*/
|
||||
public function map(): ResponseInterface
|
||||
{
|
||||
$model = new TeacherSubjectModel();
|
||||
$map = $model->getMapSubjectToTeacherIds();
|
||||
|
||||
return $this->successResponse($map, 'Subject to teacher map');
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Modules/Academic/Entities/ClassEntity.php
Normal file
37
app/Modules/Academic/Entities/ClassEntity.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Entities;
|
||||
|
||||
use CodeIgniter\Entity\Entity;
|
||||
|
||||
/**
|
||||
* Class Entity
|
||||
*
|
||||
* Represents a class/kelas in the system.
|
||||
*/
|
||||
class ClassEntity extends Entity
|
||||
{
|
||||
/**
|
||||
* Attributes that can be mass assigned
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected $allowedFields = [
|
||||
'name',
|
||||
'grade',
|
||||
'major',
|
||||
'wali_user_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attributes that should be cast to specific types
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
'wali_user_id' => '?integer',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
24
app/Modules/Academic/Entities/LessonSlot.php
Normal file
24
app/Modules/Academic/Entities/LessonSlot.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Entities;
|
||||
|
||||
use CodeIgniter\Entity\Entity;
|
||||
|
||||
/**
|
||||
* Lesson Slot Entity
|
||||
*/
|
||||
class LessonSlot extends Entity
|
||||
{
|
||||
protected $allowedFields = [
|
||||
'slot_number',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
'slot_number' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
50
app/Modules/Academic/Entities/Schedule.php
Normal file
50
app/Modules/Academic/Entities/Schedule.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Entities;
|
||||
|
||||
use CodeIgniter\Entity\Entity;
|
||||
|
||||
/**
|
||||
* Schedule Entity
|
||||
*
|
||||
* Represents a class schedule in the system.
|
||||
*/
|
||||
class Schedule extends Entity
|
||||
{
|
||||
/**
|
||||
* Attributes that can be mass assigned
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected $allowedFields = [
|
||||
'class_id',
|
||||
'subject_id',
|
||||
'teacher_user_id',
|
||||
'teacher_name',
|
||||
'lesson_slot_id',
|
||||
'day_of_week',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'room',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attributes that should be cast to specific types
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
'class_id' => 'integer',
|
||||
'subject_id' => 'integer',
|
||||
'teacher_user_id' => 'integer',
|
||||
'lesson_slot_id' => 'integer',
|
||||
'day_of_week' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'start_time' => 'string',
|
||||
'end_time' => 'string',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
44
app/Modules/Academic/Entities/Student.php
Normal file
44
app/Modules/Academic/Entities/Student.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Entities;
|
||||
|
||||
use CodeIgniter\Entity\Entity;
|
||||
|
||||
/**
|
||||
* Student Entity
|
||||
*
|
||||
* Represents a student in the system.
|
||||
*/
|
||||
class Student extends Entity
|
||||
{
|
||||
/**
|
||||
* Attributes that can be mass assigned
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected $allowedFields = [
|
||||
'nisn',
|
||||
'name',
|
||||
'gender',
|
||||
'class_id',
|
||||
'is_active',
|
||||
'face_external_id',
|
||||
'face_hash',
|
||||
'dapodik_id',
|
||||
'parent_link_code',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attributes that should be cast to specific types
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
'class_id' => '?integer',
|
||||
'is_active' => 'integer',
|
||||
'face_hash' => '?string',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
34
app/Modules/Academic/Entities/Subject.php
Normal file
34
app/Modules/Academic/Entities/Subject.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Entities;
|
||||
|
||||
use CodeIgniter\Entity\Entity;
|
||||
|
||||
/**
|
||||
* Subject Entity
|
||||
*
|
||||
* Represents a subject/mata pelajaran in the system.
|
||||
*/
|
||||
class Subject extends Entity
|
||||
{
|
||||
/**
|
||||
* Attributes that can be mass assigned
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected $allowedFields = [
|
||||
'name',
|
||||
'code',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attributes that should be cast to specific types
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
57
app/Modules/Academic/Models/ClassModel.php
Normal file
57
app/Modules/Academic/Models/ClassModel.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Models;
|
||||
|
||||
use App\Modules\Academic\Entities\ClassEntity;
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* Class Model
|
||||
*
|
||||
* Handles database operations for classes.
|
||||
*/
|
||||
class ClassModel extends Model
|
||||
{
|
||||
protected $table = 'classes';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = ClassEntity::class;
|
||||
protected $useSoftDeletes = false;
|
||||
protected $protectFields = true;
|
||||
protected $allowedFields = [
|
||||
'name',
|
||||
'grade',
|
||||
'major',
|
||||
'wali_user_id',
|
||||
];
|
||||
|
||||
// Dates
|
||||
protected $useTimestamps = true;
|
||||
protected $dateFormat = 'datetime';
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $deletedField = 'deleted_at';
|
||||
|
||||
// Validation
|
||||
protected $validationRules = [
|
||||
'name' => 'required|max_length[100]',
|
||||
'grade' => 'required|max_length[50]',
|
||||
'major' => 'required|max_length[50]',
|
||||
'wali_user_id' => 'permit_empty|integer|is_not_unique[users.id]',
|
||||
];
|
||||
|
||||
protected $validationMessages = [];
|
||||
protected $skipValidation = false;
|
||||
protected $cleanValidationRules = true;
|
||||
|
||||
// Callbacks
|
||||
protected $allowCallbacks = true;
|
||||
protected $beforeInsert = [];
|
||||
protected $afterInsert = [];
|
||||
protected $beforeUpdate = [];
|
||||
protected $afterUpdate = [];
|
||||
protected $beforeFind = [];
|
||||
protected $afterFind = [];
|
||||
protected $beforeDelete = [];
|
||||
protected $afterDelete = [];
|
||||
}
|
||||
30
app/Modules/Academic/Models/DapodikRombelMappingModel.php
Normal file
30
app/Modules/Academic/Models/DapodikRombelMappingModel.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* Dapodik rombel string -> internal class_id mapping.
|
||||
*/
|
||||
class DapodikRombelMappingModel extends Model
|
||||
{
|
||||
protected $table = 'dapodik_rombel_mappings';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $allowedFields = [
|
||||
'dapodik_rombel',
|
||||
'class_id',
|
||||
'last_seen_at',
|
||||
];
|
||||
|
||||
public function getByRombel(string $rombel): ?array
|
||||
{
|
||||
$row = $this->where('dapodik_rombel', $rombel)->first();
|
||||
return $row !== null ? $row : null;
|
||||
}
|
||||
}
|
||||
36
app/Modules/Academic/Models/DapodikSyncJobModel.php
Normal file
36
app/Modules/Academic/Models/DapodikSyncJobModel.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* Dapodik sync job tracker for progress reporting.
|
||||
*/
|
||||
class DapodikSyncJobModel extends Model
|
||||
{
|
||||
protected $table = 'dapodik_sync_jobs';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $allowedFields = [
|
||||
'type',
|
||||
'total_rows',
|
||||
'processed_rows',
|
||||
'status',
|
||||
'message',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
];
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_RUNNING = 'running';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const TYPE_STUDENTS = 'students';
|
||||
public const TYPE_CLASSES = 'classes';
|
||||
}
|
||||
91
app/Modules/Academic/Models/LessonSlotModel.php
Normal file
91
app/Modules/Academic/Models/LessonSlotModel.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Models;
|
||||
|
||||
use App\Modules\Academic\Entities\LessonSlot;
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* Lesson Slot Model
|
||||
*/
|
||||
class LessonSlotModel extends Model
|
||||
{
|
||||
protected $table = 'lesson_slots';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = LessonSlot::class;
|
||||
protected $allowedFields = [
|
||||
'slot_number',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $useTimestamps = false;
|
||||
|
||||
protected $validationRules = [
|
||||
'slot_number' => 'required|integer|greater_than[0]|is_unique[lesson_slots.slot_number,id,{id}]',
|
||||
'start_time' => 'required|valid_date[H:i:s]',
|
||||
'end_time' => 'required|valid_date[H:i:s]',
|
||||
'is_active' => 'permit_empty|in_list[0,1]',
|
||||
];
|
||||
|
||||
protected $validationMessages = [
|
||||
'end_time' => [
|
||||
'required' => 'End time is required.',
|
||||
],
|
||||
];
|
||||
|
||||
protected function getSlotStartBeforeEndRule(): array
|
||||
{
|
||||
return [
|
||||
'end_time' => 'required|valid_date[H:i:s]|greater_than_field[start_time]',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that start_time < end_time (custom rule or after validation).
|
||||
*/
|
||||
public function validateStartBeforeEnd(array $data): bool
|
||||
{
|
||||
$start = $data['start_time'] ?? '';
|
||||
$end = $data['end_time'] ?? '';
|
||||
if ($start === '' || $end === '') {
|
||||
return true;
|
||||
}
|
||||
return strtotime($start) < strtotime($end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override insert to check start < end.
|
||||
*/
|
||||
public function insert($data = null, bool $returnID = true)
|
||||
{
|
||||
if (is_array($data) && !$this->validateStartBeforeEnd($data)) {
|
||||
$this->errors['end_time'] = 'End time must be after start time.';
|
||||
return false;
|
||||
}
|
||||
return parent::insert($data, $returnID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override update to check start < end (merge with existing when partial).
|
||||
*/
|
||||
public function update($id = null, $data = null): bool
|
||||
{
|
||||
if (is_array($data) && (array_key_exists('start_time', $data) || array_key_exists('end_time', $data))) {
|
||||
$existing = $this->find($id);
|
||||
$exStart = $existing && is_object($existing) ? $existing->start_time : ($existing['start_time'] ?? '');
|
||||
$exEnd = $existing && is_object($existing) ? $existing->end_time : ($existing['end_time'] ?? '');
|
||||
$merged = [
|
||||
'start_time' => $data['start_time'] ?? $exStart,
|
||||
'end_time' => $data['end_time'] ?? $exEnd,
|
||||
];
|
||||
if (!$this->validateStartBeforeEnd($merged)) {
|
||||
$this->errors['end_time'] = 'End time must be after start time.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return parent::update($id, $data);
|
||||
}
|
||||
}
|
||||
195
app/Modules/Academic/Models/ScheduleModel.php
Normal file
195
app/Modules/Academic/Models/ScheduleModel.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Models;
|
||||
|
||||
use App\Modules\Academic\Entities\Schedule;
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* Schedule Model
|
||||
*
|
||||
* Handles database operations for schedules.
|
||||
*/
|
||||
class ScheduleModel extends Model
|
||||
{
|
||||
protected $table = 'schedules';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = Schedule::class;
|
||||
protected $useSoftDeletes = false;
|
||||
protected $protectFields = true;
|
||||
protected $allowedFields = [
|
||||
'class_id',
|
||||
'subject_id',
|
||||
'teacher_user_id',
|
||||
'teacher_name',
|
||||
'lesson_slot_id',
|
||||
'day_of_week',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'room',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
// Dates
|
||||
protected $useTimestamps = true;
|
||||
protected $dateFormat = 'datetime';
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $deletedField = 'deleted_at';
|
||||
|
||||
// Validation
|
||||
protected $validationRules = [
|
||||
'class_id' => 'required|integer|is_not_unique[classes.id]',
|
||||
'subject_id' => 'required|integer|is_not_unique[subjects.id]',
|
||||
'teacher_name' => 'required|max_length[255]',
|
||||
'day_of_week' => 'required|integer|greater_than_equal_to[1]|less_than_equal_to[7]',
|
||||
'start_time' => 'required|valid_date[H:i:s]',
|
||||
'end_time' => 'required|valid_date[H:i:s]',
|
||||
'room' => 'permit_empty|max_length[100]',
|
||||
];
|
||||
|
||||
protected $validationMessages = [];
|
||||
protected $skipValidation = false;
|
||||
protected $cleanValidationRules = true;
|
||||
|
||||
// Callbacks
|
||||
protected $allowCallbacks = true;
|
||||
protected $beforeInsert = [];
|
||||
protected $afterInsert = [];
|
||||
protected $beforeUpdate = [];
|
||||
protected $afterUpdate = [];
|
||||
protected $beforeFind = [];
|
||||
protected $afterFind = [];
|
||||
protected $beforeDelete = [];
|
||||
protected $afterDelete = [];
|
||||
|
||||
/**
|
||||
* Find active schedule for class on specific day and time (uses schedule columns only).
|
||||
*
|
||||
* @deprecated Prefer getActiveScheduleRow() for slot/user as source of truth with fallback.
|
||||
* @param int $classId
|
||||
* @param int $dayOfWeek
|
||||
* @param string $time Time in H:i:s format
|
||||
* @return Schedule|null
|
||||
*/
|
||||
public function findActiveSchedule(int $classId, int $dayOfWeek, string $time): ?Schedule
|
||||
{
|
||||
return $this->where('class_id', $classId)
|
||||
->where('day_of_week', $dayOfWeek)
|
||||
->where('start_time <=', $time)
|
||||
->where('end_time >', $time)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active schedule row with lesson_slots and users as source of truth.
|
||||
* start_time, end_time from lesson_slots; teacher_name from users; fallback to schedule columns when NULL.
|
||||
*
|
||||
* @param int $classId
|
||||
* @param int $dayOfWeek
|
||||
* @param string $time Time in H:i:s format
|
||||
* @return array{id: int, class_id: int, subject_id: int, teacher_user_id: int|null, teacher_name: string, room: string|null, start_time: string, end_time: string, day_of_week: int}|null
|
||||
*/
|
||||
public function getActiveScheduleRow(int $classId, int $dayOfWeek, string $time): ?array
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$sql = 'SELECT sch.id AS id, sch.class_id, sch.subject_id, sch.teacher_user_id, '
|
||||
. 'COALESCE(u.name, sch.teacher_name) AS teacher_name, sch.room, '
|
||||
. 'COALESCE(ls.start_time, sch.start_time) AS start_time, COALESCE(ls.end_time, sch.end_time) AS end_time, sch.day_of_week '
|
||||
. 'FROM schedules sch '
|
||||
. 'LEFT JOIN lesson_slots ls ON ls.id = sch.lesson_slot_id '
|
||||
. 'LEFT JOIN users u ON u.id = sch.teacher_user_id '
|
||||
. 'WHERE sch.class_id = ? AND sch.day_of_week = ? '
|
||||
. 'AND ( COALESCE(ls.start_time, sch.start_time) ) <= ? AND ( COALESCE(ls.end_time, sch.end_time) ) > ? '
|
||||
. 'LIMIT 1';
|
||||
$row = $db->query($sql, [$classId, $dayOfWeek, $time, $time])->getRowArray();
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'class_id' => (int) $row['class_id'],
|
||||
'subject_id' => (int) $row['subject_id'],
|
||||
'teacher_user_id' => isset($row['teacher_user_id']) ? (int) $row['teacher_user_id'] : null,
|
||||
'teacher_name' => (string) ($row['teacher_name'] ?? ''),
|
||||
'room' => isset($row['room']) ? (string) $row['room'] : null,
|
||||
'start_time' => (string) $row['start_time'],
|
||||
'end_time' => (string) $row['end_time'],
|
||||
'day_of_week' => (int) $row['day_of_week'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest schedule for class on given day where start_time <= time (may already be ended).
|
||||
* Uses lesson_slots/users as source of truth. ORDER BY start_time DESC, LIMIT 1.
|
||||
*
|
||||
* @param int $classId
|
||||
* @param int $dayOfWeek
|
||||
* @param string $time Time in H:i:s format
|
||||
* @return array{id: int, class_id: int, subject_id: int, teacher_user_id: int|null, teacher_name: string, room: string|null, start_time: string, end_time: string, day_of_week: int}|null
|
||||
*/
|
||||
public function getLatestScheduleForClassToday(int $classId, int $dayOfWeek, string $time): ?array
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$sql = 'SELECT sch.id AS id, sch.class_id, sch.subject_id, sch.teacher_user_id, '
|
||||
. 'COALESCE(u.name, sch.teacher_name) AS teacher_name, sch.room, '
|
||||
. 'COALESCE(ls.start_time, sch.start_time) AS start_time, COALESCE(ls.end_time, sch.end_time) AS end_time, sch.day_of_week '
|
||||
. 'FROM schedules sch '
|
||||
. 'LEFT JOIN lesson_slots ls ON ls.id = sch.lesson_slot_id '
|
||||
. 'LEFT JOIN users u ON u.id = sch.teacher_user_id '
|
||||
. 'WHERE sch.class_id = ? AND sch.day_of_week = ? '
|
||||
. 'AND ( COALESCE(ls.start_time, sch.start_time) ) <= ? '
|
||||
. 'ORDER BY ( COALESCE(ls.start_time, sch.start_time) ) DESC '
|
||||
. 'LIMIT 1';
|
||||
$row = $db->query($sql, [$classId, $dayOfWeek, $time])->getRowArray();
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'class_id' => (int) $row['class_id'],
|
||||
'subject_id' => (int) $row['subject_id'],
|
||||
'teacher_user_id' => isset($row['teacher_user_id']) ? (int) $row['teacher_user_id'] : null,
|
||||
'teacher_name' => (string) ($row['teacher_name'] ?? ''),
|
||||
'room' => isset($row['room']) ? (string) $row['room'] : null,
|
||||
'start_time' => (string) $row['start_time'],
|
||||
'end_time' => (string) $row['end_time'],
|
||||
'day_of_week' => (int) $row['day_of_week'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one schedule row by id with lesson_slots and users as source of truth.
|
||||
*
|
||||
* @param int $scheduleId
|
||||
* @return array{id: int, class_id: int, subject_id: int, teacher_user_id: int|null, teacher_name: string, start_time: string, end_time: string, day_of_week: int, room: string|null}|null
|
||||
*/
|
||||
public function getScheduleWithSlot(int $scheduleId): ?array
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$sql = 'SELECT sch.id AS id, sch.class_id, sch.subject_id, sch.teacher_user_id, '
|
||||
. 'COALESCE(u.name, sch.teacher_name) AS teacher_name, '
|
||||
. 'COALESCE(ls.start_time, sch.start_time) AS start_time, COALESCE(ls.end_time, sch.end_time) AS end_time, '
|
||||
. 'sch.day_of_week, sch.room '
|
||||
. 'FROM schedules sch '
|
||||
. 'LEFT JOIN lesson_slots ls ON ls.id = sch.lesson_slot_id '
|
||||
. 'LEFT JOIN users u ON u.id = sch.teacher_user_id '
|
||||
. 'WHERE sch.id = ? LIMIT 1';
|
||||
$row = $db->query($sql, [$scheduleId])->getRowArray();
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'class_id' => (int) $row['class_id'],
|
||||
'subject_id' => (int) $row['subject_id'],
|
||||
'teacher_user_id' => isset($row['teacher_user_id']) ? (int) $row['teacher_user_id'] : null,
|
||||
'teacher_name' => (string) ($row['teacher_name'] ?? ''),
|
||||
'start_time' => (string) $row['start_time'],
|
||||
'end_time' => (string) $row['end_time'],
|
||||
'day_of_week' => (int) $row['day_of_week'],
|
||||
'room' => isset($row['room']) ? (string) $row['room'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
77
app/Modules/Academic/Models/StudentModel.php
Normal file
77
app/Modules/Academic/Models/StudentModel.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Models;
|
||||
|
||||
use App\Modules\Academic\Entities\Student;
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* Student Model
|
||||
*
|
||||
* Handles database operations for students.
|
||||
*/
|
||||
class StudentModel extends Model
|
||||
{
|
||||
protected $table = 'students';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = Student::class;
|
||||
protected $useSoftDeletes = false;
|
||||
protected $protectFields = true;
|
||||
protected $allowedFields = [
|
||||
'dapodik_id',
|
||||
'face_external_id',
|
||||
'face_hash',
|
||||
'nisn',
|
||||
'name',
|
||||
'gender',
|
||||
'class_id',
|
||||
'is_active',
|
||||
'parent_link_code',
|
||||
];
|
||||
|
||||
// Dates
|
||||
protected $useTimestamps = true;
|
||||
protected $dateFormat = 'datetime';
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $deletedField = 'deleted_at';
|
||||
|
||||
// Validation (class_id nullable for unmapped/Dapodik)
|
||||
protected $validationRules = [
|
||||
'dapodik_id' => 'permit_empty|max_length[64]|is_unique[students.dapodik_id,id,{id}]',
|
||||
'face_external_id' => 'permit_empty|max_length[100]|is_unique[students.face_external_id,id,{id}]',
|
||||
'face_hash' => 'permit_empty|max_length[32]',
|
||||
'nisn' => 'required|max_length[50]|is_unique[students.nisn,id,{id}]',
|
||||
'name' => 'required|max_length[255]',
|
||||
'gender' => 'permit_empty|in_list[L,P]',
|
||||
'class_id' => 'permit_empty|integer|is_not_unique[classes.id]',
|
||||
'is_active' => 'permit_empty|in_list[0,1]',
|
||||
];
|
||||
|
||||
protected $validationMessages = [];
|
||||
protected $skipValidation = false;
|
||||
protected $cleanValidationRules = true;
|
||||
|
||||
// Callbacks
|
||||
protected $allowCallbacks = true;
|
||||
protected $beforeInsert = [];
|
||||
protected $afterInsert = [];
|
||||
protected $beforeUpdate = [];
|
||||
protected $afterUpdate = [];
|
||||
protected $beforeFind = [];
|
||||
protected $afterFind = [];
|
||||
protected $beforeDelete = [];
|
||||
protected $afterDelete = [];
|
||||
|
||||
/**
|
||||
* Find student by NISN
|
||||
*
|
||||
* @param string $nisn
|
||||
* @return Student|null
|
||||
*/
|
||||
public function findByNisn(string $nisn): ?Student
|
||||
{
|
||||
return $this->where('nisn', $nisn)->first();
|
||||
}
|
||||
}
|
||||
53
app/Modules/Academic/Models/SubjectModel.php
Normal file
53
app/Modules/Academic/Models/SubjectModel.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Models;
|
||||
|
||||
use App\Modules\Academic\Entities\Subject;
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* Subject Model
|
||||
*
|
||||
* Handles database operations for subjects.
|
||||
*/
|
||||
class SubjectModel extends Model
|
||||
{
|
||||
protected $table = 'subjects';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = Subject::class;
|
||||
protected $useSoftDeletes = false;
|
||||
protected $protectFields = true;
|
||||
protected $allowedFields = [
|
||||
'name',
|
||||
'code',
|
||||
];
|
||||
|
||||
// Dates
|
||||
protected $useTimestamps = true;
|
||||
protected $dateFormat = 'datetime';
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $deletedField = 'deleted_at';
|
||||
|
||||
// Validation
|
||||
protected $validationRules = [
|
||||
'name' => 'required|max_length[255]',
|
||||
'code' => 'permit_empty|max_length[50]',
|
||||
];
|
||||
|
||||
protected $validationMessages = [];
|
||||
protected $skipValidation = false;
|
||||
protected $cleanValidationRules = true;
|
||||
|
||||
// Callbacks
|
||||
protected $allowCallbacks = true;
|
||||
protected $beforeInsert = [];
|
||||
protected $afterInsert = [];
|
||||
protected $beforeUpdate = [];
|
||||
protected $afterUpdate = [];
|
||||
protected $beforeFind = [];
|
||||
protected $afterFind = [];
|
||||
protected $beforeDelete = [];
|
||||
protected $afterDelete = [];
|
||||
}
|
||||
56
app/Modules/Academic/Models/TeacherSubjectModel.php
Normal file
56
app/Modules/Academic/Models/TeacherSubjectModel.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class TeacherSubjectModel extends Model
|
||||
{
|
||||
protected $table = 'teacher_subjects';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $protectFields = true;
|
||||
protected $allowedFields = [
|
||||
'teacher_user_id',
|
||||
'subject_id',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
|
||||
/**
|
||||
* Get subject IDs for a teacher.
|
||||
*
|
||||
* @param int $teacherId
|
||||
* @return array<int>
|
||||
*/
|
||||
public function getSubjectIdsForTeacher(int $teacherId): array
|
||||
{
|
||||
$rows = $this->select('subject_id')
|
||||
->where('teacher_user_id', $teacherId)
|
||||
->findAll();
|
||||
|
||||
return array_map(static fn ($row) => (int) $row['subject_id'], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping: subject_id => [teacher_user_id, ...].
|
||||
*
|
||||
* @return array<int, array<int>>
|
||||
*/
|
||||
public function getMapSubjectToTeacherIds(): array
|
||||
{
|
||||
$rows = $this->select('teacher_user_id, subject_id')->findAll();
|
||||
$map = [];
|
||||
foreach ($rows as $r) {
|
||||
$s = (int) $r['subject_id'];
|
||||
$t = (int) $r['teacher_user_id'];
|
||||
$map[$s][] = $t;
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Modules/Academic/Routes.php
Normal file
45
app/Modules/Academic/Routes.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Academic Module Routes
|
||||
*
|
||||
* This file is automatically loaded by ModuleLoader.
|
||||
* Define your academic management routes here.
|
||||
*
|
||||
* @var \CodeIgniter\Router\RouteCollection $routes
|
||||
*/
|
||||
|
||||
$routes->group('api/academic', [
|
||||
'namespace' => 'App\Modules\Academic\Controllers',
|
||||
'filter' => 'admin_only',
|
||||
], function ($routes) {
|
||||
$routes->get('lesson-slots', 'LessonSlotController::index');
|
||||
$routes->post('lesson-slots', 'LessonSlotController::create');
|
||||
$routes->put('lesson-slots/(:num)', 'LessonSlotController::update/$1');
|
||||
$routes->delete('lesson-slots/(:num)', 'LessonSlotController::delete/$1');
|
||||
|
||||
$routes->get('schedules/class/(:num)', 'ScheduleManagementController::getByClass/$1');
|
||||
$routes->post('schedules/bulk-save', 'ScheduleManagementController::bulkSave');
|
||||
|
||||
$routes->get('teachers', 'TeacherController::index');
|
||||
$routes->get('teacher-subjects/(:num)', 'TeacherSubjectController::getByTeacher/$1');
|
||||
$routes->put('teacher-subjects/(:num)', 'TeacherSubjectController::updateForTeacher/$1');
|
||||
$routes->get('teacher-subjects/map', 'TeacherSubjectController::map');
|
||||
$routes->get('subjects', 'SubjectController::index');
|
||||
$routes->post('subjects', 'SubjectController::create');
|
||||
$routes->put('subjects/(:num)', 'SubjectController::update/$1');
|
||||
$routes->delete('subjects/(:num)', 'SubjectController::delete/$1');
|
||||
$routes->get('classes', 'ClassController::index');
|
||||
$routes->post('classes', 'ClassController::create');
|
||||
$routes->put('classes/(:num)', 'ClassController::update/$1');
|
||||
$routes->delete('classes/(:num)', 'ClassController::delete/$1');
|
||||
$routes->get('students', 'StudentController::index');
|
||||
$routes->post('students', 'StudentController::create');
|
||||
$routes->put('students/(:num)', 'StudentController::update/$1');
|
||||
$routes->delete('students/(:num)', 'StudentController::delete/$1');
|
||||
|
||||
$routes->post('dapodik/sync/students', 'DapodikSyncController::syncStudents');
|
||||
$routes->get('dapodik/sync/status/(:num)', 'DapodikSyncController::status/$1');
|
||||
$routes->get('dapodik/rombels', 'DapodikSyncController::rombels');
|
||||
$routes->put('dapodik/rombels/(:num)', 'DapodikSyncController::updateRombel/$1');
|
||||
});
|
||||
120
app/Modules/Academic/Services/DapodikClient.php
Normal file
120
app/Modules/Academic/Services/DapodikClient.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Services;
|
||||
|
||||
/**
|
||||
* Dapodik WebService API client.
|
||||
* Uses env: DAPODIK_BASE_URL, DAPODIK_TOKEN, DAPODIK_NPSN.
|
||||
* Do not log or expose token.
|
||||
*/
|
||||
class DapodikClient
|
||||
{
|
||||
protected string $baseUrl;
|
||||
protected string $token;
|
||||
protected string $npsn;
|
||||
|
||||
public function __construct(?string $baseUrl = null, ?string $token = null, ?string $npsn = null)
|
||||
{
|
||||
$this->baseUrl = rtrim($baseUrl ?? (string) env('DAPODIK_BASE_URL', ''), '/');
|
||||
$this->token = $token ?? (string) env('DAPODIK_TOKEN', '');
|
||||
$this->npsn = $npsn ?? (string) env('DAPODIK_NPSN', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET getSekolah
|
||||
*
|
||||
* @return array{success: bool, data?: array, error?: string}
|
||||
*/
|
||||
public function getSekolah(): array
|
||||
{
|
||||
$url = $this->baseUrl . '/getSekolah';
|
||||
if ($this->npsn !== '') {
|
||||
$url .= '?npsn=' . rawurlencode($this->npsn);
|
||||
}
|
||||
return $this->request('GET', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET getPesertaDidik with pagination
|
||||
*
|
||||
* @param int $start
|
||||
* @param int $limit
|
||||
* @return array{success: bool, data?: array, rows?: array, id?: mixed, start?: int, limit?: int, results?: int, error?: string}
|
||||
*/
|
||||
public function getPesertaDidik(int $start = 0, int $limit = 200): array
|
||||
{
|
||||
$params = [];
|
||||
if ($this->npsn !== '') {
|
||||
$params['npsn'] = $this->npsn;
|
||||
}
|
||||
$params['start'] = $start;
|
||||
$params['limit'] = $limit;
|
||||
$url = $this->baseUrl . '/getPesertaDidik?' . http_build_query($params);
|
||||
return $this->request('GET', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute HTTP request. Returns decoded JSON with success flag and error message on failure.
|
||||
*/
|
||||
protected function request(string $method, string $url): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
return ['success' => false, 'error' => 'cURL init failed'];
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Accept: application/json',
|
||||
'Authorization: Bearer ' . $this->token,
|
||||
];
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno) {
|
||||
return ['success' => false, 'error' => 'Network error: ' . ($errno === CURLE_OPERATION_TIMEDOUT ? 'timeout' : 'curl ' . $errno)];
|
||||
}
|
||||
|
||||
$decoded = json_decode($body, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ['success' => false, 'error' => 'Invalid JSON response', 'http_code' => $httpCode];
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$msg = is_array($decoded) && isset($decoded['message']) ? $decoded['message'] : 'HTTP ' . $httpCode;
|
||||
return ['success' => false, 'error' => $msg, 'http_code' => $httpCode, 'data' => $decoded];
|
||||
}
|
||||
|
||||
return array_merge(['success' => true], $decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Dapodik response to a list of rows.
|
||||
* Dapodik returns { results, id, start, limit, rows: [...] }
|
||||
*
|
||||
* @param array $response Response from getPesertaDidik
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function normalizePesertaDidikRows(array $response): array
|
||||
{
|
||||
if (! isset($response['rows']) || ! is_array($response['rows'])) {
|
||||
return [];
|
||||
}
|
||||
$rows = $response['rows'];
|
||||
$out = [];
|
||||
foreach ($rows as $i => $row) {
|
||||
$out[] = is_array($row) ? $row : (array) $row;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
78
app/Modules/Academic/Services/ScheduleResolverService.php
Normal file
78
app/Modules/Academic/Services/ScheduleResolverService.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Academic\Services;
|
||||
|
||||
use App\Modules\Academic\Models\ScheduleModel;
|
||||
use App\Modules\Academic\Models\StudentModel;
|
||||
|
||||
/**
|
||||
* Schedule Resolver Service
|
||||
*
|
||||
* Handles schedule resolution logic for students.
|
||||
*/
|
||||
class ScheduleResolverService
|
||||
{
|
||||
protected StudentModel $studentModel;
|
||||
protected ScheduleModel $scheduleModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->scheduleModel = new ScheduleModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active schedule for a student at a specific datetime
|
||||
*
|
||||
* Rules:
|
||||
* - Find student's class_id
|
||||
* - Determine day_of_week from datetime (1=Monday, 7=Sunday)
|
||||
* - Find schedule where start_time <= time < end_time
|
||||
*
|
||||
* @param int $studentId Student ID
|
||||
* @param string $datetime Datetime string (Y-m-d H:i:s format)
|
||||
* @return array|null Returns schedule detail or null if not found
|
||||
*/
|
||||
public function getActiveSchedule(int $studentId, string $datetime): ?array
|
||||
{
|
||||
// Find student
|
||||
$student = $this->studentModel->find($studentId);
|
||||
|
||||
if (!$student) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get class_id from student
|
||||
$classId = $student->class_id;
|
||||
|
||||
// Parse datetime to get day of week and time
|
||||
$timestamp = strtotime($datetime);
|
||||
if ($timestamp === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get day of week (1=Monday, 7=Sunday)
|
||||
// PHP date('N') returns 1-7 (Monday-Sunday)
|
||||
$dayOfWeek = (int) date('N', $timestamp);
|
||||
|
||||
// Get time in H:i:s format
|
||||
$time = date('H:i:s', $timestamp);
|
||||
|
||||
// Find active schedule (lesson_slots/users as source of truth with fallback to schedule columns)
|
||||
$row = $this->scheduleModel->getActiveScheduleRow($classId, $dayOfWeek, $time);
|
||||
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'schedule_id' => $row['id'],
|
||||
'class_id' => $row['class_id'],
|
||||
'subject_id' => $row['subject_id'],
|
||||
'teacher_name' => $row['teacher_name'],
|
||||
'room' => $row['room'],
|
||||
'start_time' => $row['start_time'],
|
||||
'end_time' => $row['end_time'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user