penjadwalan-ga/app/Services/Scheduling/DatasetLoader.php

269 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Scheduling;
use App\Services\Scheduling\Data\Guru;
use App\Services\Scheduling\Data\Kelas;
use App\Services\Scheduling\Data\SchedulingDataset;
use App\Services\Scheduling\Data\SchedulingSlot;
use Illuminate\Support\Facades\DB;
/**
* Service yang bertanggung jawab membaca data mentah dari database
* dan mengubahnya menjadi SchedulingDataset yang siap dipakai GA.
*
* Ini adalah anti-corruption layer: seluruh dependensi pada struktur
* database ada di sini. GA tidak tahu apa-apa tentang tabel/kolom.
*/
class DatasetLoader
{
// Semester default untuk skripsi ini
public const DEFAULT_SEMESTER = 'Genap';
public const DEFAULT_TAHUN_AJARAN = '2025/2026';
// Konfigurasi operasional Robotku (bisa dipindah ke config file kalau perlu)
public const HARI_LIST = [1, 2, 3, 4, 5, 6]; // Senin-Sabtu
public const SESI_LIST = [1, 2, 3, 4, 5]; // Sesi 1-5
/**
* Method utama: baca database → return SchedulingDataset lengkap.
*
* @param string|null $semester Default: 'Genap'
* @param string|null $tahunAjaran Default: '2025/2026'
* @return SchedulingDataset
* @throws \RuntimeException jika dataset tidak lolos validasi
*/
public function load(
?string $semester = null,
?string $tahunAjaran = null
): SchedulingDataset {
$semester = $semester ?? self::DEFAULT_SEMESTER;
$tahunAjaran = $tahunAjaran ?? self::DEFAULT_TAHUN_AJARAN;
$guruList = $this->loadGuruList();
$kelasList = $this->loadKelasList($semester, $tahunAjaran);
$ruanganRobotkuIds = $this->loadRuanganRobotku();
$allSlots = $this->buildAllSlots();
$dataset = new SchedulingDataset(
guruList: $guruList,
kelasList: $kelasList,
ruanganRobotkuIds: $ruanganRobotkuIds,
allSlots: $allSlots,
);
$this->validate($dataset);
return $dataset;
}
/**
* Load semua guru yang aktif mengajar & punya level_maksimal.
*
* @return array<int, Guru> Map guru_id => Guru
*/
private function loadGuruList(): array
{
// Ambil semua guru aktif yang punya level_maksimal terisi.
// Guru administratif tanpa level_maksimal otomatis dikecualikan.
$guruRows = DB::table('guru')
->select('id', 'nama', 'level_maksimal')
->where('status', 'Aktif')
->whereNotNull('level_maksimal')
->orderBy('id')
->get();
// Ambil semua ketersediaan sekaligus (satu query), lalu di-index per guru_id.
$guruIds = $guruRows->pluck('id')->toArray();
$ketersediaanByGuru = $this->loadKetersediaanByGuru($guruIds);
$result = [];
foreach ($guruRows as $row) {
$result[$row->id] = new Guru(
id: (int) $row->id,
nama: (string) $row->nama,
levelMaksimal: (int) $row->level_maksimal,
ketersediaan: $ketersediaanByGuru[$row->id] ?? [],
);
}
return $result;
}
/**
* Load ketersediaan_mengajars dan grup berdasarkan guru_id.
*
* @param array<int> $guruIds
* @return array<int, array<string, bool>> Map guru_id => (map slot_key => true)
*/
private function loadKetersediaanByGuru(array $guruIds): array
{
if (empty($guruIds)) {
return [];
}
// sesi_id di tabel ketersediaan mengacu ke tabel sesi (id 1-5).
// Kita asumsikan sesi.id === nomor sesi (dikonfirmasi user).
$rows = DB::table('ketersediaan_mengajars')
->select('guru_id', 'hari', 'sesi_id')
->whereIn('guru_id', $guruIds)
->get();
$result = [];
foreach ($rows as $row) {
$slotKey = "{$row->hari}-{$row->sesi_id}";
$result[$row->guru_id][$slotKey] = true;
}
return $result;
}
/**
* Load semua kelas aktif untuk semester tertentu.
*
* @return array<int, Kelas> Map kelas_id => Kelas
*/
private function loadKelasList(string $semester, string $tahunAjaran): array
{
// Join dengan programs untuk ambil level_number kelas.
$rows = DB::table('kelas')
->join('programs', 'kelas.program_id', '=', 'programs.id')
->select(
'kelas.id',
'kelas.nama_kelas',
'programs.level_number',
'kelas.frekuensi_per_minggu',
'kelas.is_ekstrakurikuler',
'kelas.hari_fix',
'kelas.sesi_fix',
'kelas.ruangan_fix_id',
)
->where('kelas.semester', $semester)
->where('kelas.tahun_ajaran', $tahunAjaran)
->where('kelas.status', 'Aktif')
->orderBy('kelas.id')
->get();
$result = [];
foreach ($rows as $row) {
// level_number bisa null jika data programs belum lengkap
if ($row->level_number === null) {
throw new \RuntimeException(
"Kelas '{$row->nama_kelas}' (id={$row->id}) merujuk ke program tanpa level_number. "
. "Isi level_number di tabel programs terlebih dahulu."
);
}
$result[$row->id] = new Kelas(
id: (int) $row->id,
nama: (string) $row->nama_kelas,
level: (int) $row->level_number,
frekuensiPerMinggu: (int) $row->frekuensi_per_minggu,
isEkstrakurikuler: (bool) $row->is_ekstrakurikuler,
hariFix: $row->hari_fix !== null ? (int) $row->hari_fix : null,
sesiFix: $row->sesi_fix !== null ? (int) $row->sesi_fix : null,
ruanganFixId: $row->ruangan_fix_id !== null ? (int) $row->ruangan_fix_id : null,
);
}
return $result;
}
/**
* Load 5 ruangan Robotku (lokasi_tipe='robotku').
* Ruangan sekolah tidak masuk pool ini karena tidak dipakai kelas reguler.
*
* @return array<int> Array id ruangan
*/
private function loadRuanganRobotku(): array
{
return DB::table('ruangan')
->where('lokasi_tipe', 'robotku')
->where('status', 'aktif')
->orderBy('id')
->pluck('id')
->map(fn($id) => (int) $id)
->toArray();
}
/**
* Generate semua slot valid: 6 hari × 5 sesi = 30 slot.
*
* @return array<SchedulingSlot>
*/
private function buildAllSlots(): array
{
$slots = [];
foreach (self::HARI_LIST as $hari) {
foreach (self::SESI_LIST as $sesi) {
$slots[] = new SchedulingSlot($hari, $sesi);
}
}
return $slots;
}
/**
* Validasi dataset: pastikan tidak ada kondisi mustahil sebelum GA jalan.
* Kalau ada masalah, throw exception dengan pesan jelas.
*/
private function validate(SchedulingDataset $dataset): void
{
// 1. Minimal harus ada guru
if (empty($dataset->guruList)) {
throw new \RuntimeException(
"Tidak ada guru dengan status='Aktif' dan level_maksimal terisi. "
. "Sistem tidak bisa menjadwalkan."
);
}
// 2. Minimal harus ada kelas
if (empty($dataset->kelasList)) {
throw new \RuntimeException(
"Tidak ada kelas aktif untuk semester ini. "
. "Cek filter semester & tahun_ajaran."
);
}
// 3. Minimal harus ada ruangan Robotku
if (empty($dataset->ruanganRobotkuIds)) {
throw new \RuntimeException(
"Tidak ada ruangan Robotku (lokasi_tipe='robotku') yang aktif."
);
}
// 4. Untuk tiap kelas ekstrakurikuler, ruangan_fix_id harus valid
// (ruangan tersebut harus benar-benar ada di tabel ruangan)
$ruanganIdsEks = collect($dataset->kelasEkstrakurikuler())
->pluck('ruanganFixId')
->unique()
->toArray();
if (!empty($ruanganIdsEks)) {
$existingRuangan = DB::table('ruangan')
->whereIn('id', $ruanganIdsEks)
->pluck('id')
->toArray();
$missing = array_diff($ruanganIdsEks, $existingRuangan);
if (!empty($missing)) {
throw new \RuntimeException(
"Kelas ekstrakurikuler merujuk ke ruangan_fix_id yang tidak ada di database: "
. implode(', ', $missing)
);
}
}
// 5. Untuk tiap level yang butuh guru, minimal ada 1 guru yang mampu.
// (Feasibility check paling dasar untuk H4)
$levelKelas = collect($dataset->kelasList)->pluck('level')->unique();
foreach ($levelKelas as $level) {
$guruMampu = $dataset->guruMampuLevel($level);
if (empty($guruMampu)) {
throw new \RuntimeException(
"Tidak ada guru dengan level_maksimal >= {$level}. "
. "Kelas level {$level} tidak dapat dijadwalkan."
);
}
}
}
}