269 lines
9.1 KiB
PHP
269 lines
9.1 KiB
PHP
<?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."
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} |