where('user_id', $user->id)->first(); if (!$siswa) { return redirect()->route('landing.home') ->withErrors('Akses ditolak: akun ini bukan siswa.'); } $soal = SoalMinat::where('is_active', true) ->orderBy('id') ->get(); if ($soal->count() === 0) { session()->flash('info', 'Belum ada soal minat aktif. Hubungi admin untuk melengkapi data soal.'); } $tesTerakhir = Tes::with('penyakit')->where('siswa_id', $siswa->id)->latest()->first(); $riwayatHasil = $tesTerakhir ? HasilSaw::where('tes_id', $tesTerakhir->id)->orderBy('peringkat')->get() : collect(); $jurusan = Jurusan::where('is_active', true) ->orderBy('nama_jurusan') ->get(); $penyakits = Penyakit::where('is_active', true) ->orderBy('nama_penyakit') ->get(); return view('pages.siswa.tes', compact( 'siswa', 'soal', 'tesTerakhir', 'riwayatHasil', 'jurusan', 'penyakits' )); } /** * Simpan tes siswa, jawaban minat, hitung SAW, dan generate PDF */ public function store(Request $request) { $user = Auth::user(); $siswa = Siswa::with('user')->where('user_id', $user->id)->first(); if (!$siswa) { return redirect()->route('landing.home') ->withErrors('Akses ditolak: akun ini bukan siswa.'); } $soalAktif = SoalMinat::where('is_active', true) ->orderBy('id') ->get(); if ($soalAktif->count() === 0) { return back() ->withErrors('Belum ada soal minat aktif. Hubungi admin.') ->withInput(); } $rules = [ 'jurusan_pilihan_1' => 'required|exists:jurusan,id', 'jurusan_pilihan_2' => 'required|exists:jurusan,id|different:jurusan_pilihan_1', 'tinggi_badan' => 'required|numeric|min:100|max:220', 'berat_badan' => 'required|numeric|min:20|max:200', 'buta_warna' => 'required|in:ya,tidak', 'penyakit_id' => 'nullable|exists:penyakit,id', 'nilai_matematika' => 'required|numeric|min:0|max:100', 'nilai_bahasa_indonesia' => 'required|numeric|min:0|max:100', 'nilai_bahasa_inggris' => 'required|numeric|min:0|max:100', 'nilai_ipa' => 'required|numeric|min:0|max:100', 'setuju' => 'required|in:1', ]; foreach ($soalAktif as $index => $item) { $nomor = $index + 1; $rules["bakat_q{$nomor}"] = 'required|integer|min:1|max:4'; } $validated = $request->validate($rules); $jawabanBakat = []; foreach ($soalAktif as $index => $item) { $nomor = $index + 1; $jawabanBakat[] = (int) $validated["bakat_q{$nomor}"]; } $sumSkor = array_sum($jawabanBakat); $jumlahSoal = count($jawabanBakat); $skorMinatBakat = (int) round(($sumSkor / ($jumlahSoal * 4)) * 100); DB::transaction(function () use ($validated, $siswa, $skorMinatBakat, $jawabanBakat, $soalAktif) { $tes = Tes::create([ 'siswa_id' => $siswa->id, 'nilai_matematika' => $validated['nilai_matematika'], 'nilai_bahasa_indonesia' => $validated['nilai_bahasa_indonesia'], 'nilai_bahasa_inggris' => $validated['nilai_bahasa_inggris'], 'nilai_ipa' => $validated['nilai_ipa'], 'skor_minat_bakat' => $skorMinatBakat, 'tinggi_badan' => $validated['tinggi_badan'], 'berat_badan' => $validated['berat_badan'], 'buta_warna' => ($validated['buta_warna'] === 'ya'), 'penyakit_id' => $validated['penyakit_id'] ?? null, 'minat_jurusan_1_id' => $validated['jurusan_pilihan_1'], 'minat_jurusan_2_id' => $validated['jurusan_pilihan_2'], ]); foreach ($soalAktif as $index => $soal) { JawabanMinat::create([ 'tes_id' => $tes->id, 'soal_minat_id' => $soal->id, 'skor' => $jawabanBakat[$index], ]); } $this->prosesSaw($tes); $ok = app(TesPdfService::class)->generateAndStore($tes); if (!$ok) { Log::warning('PDF gagal dibuat saat penyimpanan tes', [ 'tes_id' => $tes->id, ]); } }); ActivityLogger::log("Siswa cek minat bakat: skor {$skorMinatBakat}"); return redirect()->route('siswa.tes.hasil'); } /** * Proses SAW: * - Alternatif = jurusan * - Kriteria = 8 kriteria final * - Aturan wajib_lolos diperlakukan sebagai aturan bisnis tambahan */ private function prosesSaw(Tes $tes): void { $jurusans = Jurusan::with(['jurusanKriteria.kriteria', 'penyakit']) ->where('is_active', true) ->get(); // ambil nilai siswa (0–1) $nilaiDasar = $this->getNilaiKriteriaSiswa($tes); $rows = []; foreach ($jurusans as $jurusan) { // 🔵 mapping kriteria biar rapi $map = $this->mapJurusanKriteriaByKode($jurusan); $nilai = $nilaiDasar; $nilai['C8'] = $this->getSkorPenyakitUntukJurusan($tes, $jurusan); // 🔴 1. WAJIB LOLOS (HARD CONSTRAINT) if ($this->isJurusanDiskualifikasi($map, $nilai)) { $rows[] = [ 'jurusan_id' => $jurusan->id, 'nilai_preferensi' => 0, ]; continue; } $nilaiPreferensi = 0; // 🔵 2. LOOP KRITERIA foreach ($map as $kode => $jk) { if (!isset($nilai[$kode])) continue; $nilaiSiswa = $nilai[$kode]; $bobot = (float) $jk->bobot; // 🟡 3. PENYESUAIAN MIN & MAX $nilaiFix = $this->hitungNilaiKecocokan( $nilaiSiswa, $jk->nilai_min, $jk->nilai_max, $kode ); // 🟢 4. RUMUS SAW (TETAP) $nilaiPreferensi += $bobot * $nilaiFix; } $rows[] = [ 'jurusan_id' => $jurusan->id, 'nilai_preferensi' => round($nilaiPreferensi, 6), ]; } // 🔽 SORTING usort($rows, fn($a, $b) => $b['nilai_preferensi'] <=> $a['nilai_preferensi']); // 🔄 SIMPAN ULANG HasilSaw::where('tes_id', $tes->id)->delete(); foreach ($rows as $i => $row) { HasilSaw::create([ 'tes_id' => $tes->id, 'jurusan_id' => $row['jurusan_id'], 'nilai_preferensi' => $row['nilai_preferensi'], 'peringkat' => $i + 1, ]); } } /** * Nilai final 8 kriteria siswa (Normalisasi 0-1) */ private function getNilaiKriteriaSiswa(Tes $tes): array { return [ 'C1' => $tes->nilai_matematika / 100, 'C2' => $tes->nilai_bahasa_indonesia / 100, 'C3' => $tes->nilai_ipa / 100, 'C4' => $tes->nilai_bahasa_inggris / 100, 'C5' => $this->getSkorFisikDariBmi($tes->tinggi_badan, $tes->berat_badan) / 100, 'C6' => $tes->buta_warna ? 0 : 1, 'C7' => $tes->skor_minat_bakat / 100, ]; } private function getSkorPenyakitUntukJurusan(Tes $tes, Jurusan $jurusan): int { if (!$tes->penyakit_id) { return 1; } return $jurusan->penyakit->contains('id', $tes->penyakit_id) ? 0 : 1; } /** * Hitung BMI siswa */ private function hitungBmi(float $tinggiBadan, float $beratBadan): float { $tinggiMeter = $tinggiBadan / 100; if ($tinggiMeter <= 0) return 0; return round($beratBadan / ($tinggiMeter * $tinggiMeter), 2); } /** * Konversi BMI menjadi skor fisik */ private function getSkorFisikDariBmi(float $tinggiBadan, float $beratBadan): int { $bmi = $this->hitungBmi($tinggiBadan, $beratBadan); if ($bmi >= 18.5 && $bmi <= 24.9) { return 100; } if ($bmi >= 25 && $bmi <= 29.9) { return 80; } return 70; } /** * Ubah koleksi jurusanKriteria menjadi map berdasarkan kode kriteria */ private function mapJurusanKriteriaByKode($jurusan): array { $map = []; foreach ($jurusan->jurusanKriteria as $jurusanKriteria) { $kode = $jurusanKriteria->kriteria?->kode_kriteria; if ($kode) { $map[$kode] = $jurusanKriteria; } } return $map; } /** * Hitung nilai kecocokan siswa terhadap aturan jurusan */ private function hitungNilaiKecocokan(float $nilaiSiswa, $nilaiMin = null, $nilaiMax = null, $kodeKriteria = null): float { // Khusus C6 & C8 adalah biner (0/1), kriteria lain 0-100 di database $isBinary = in_array($kodeKriteria, ['C6', 'C8']); $divider = $isBinary ? 1 : 100; $nilaiMin = is_null($nilaiMin) ? null : $nilaiMin / $divider; $nilaiMax = is_null($nilaiMax) ? null : $nilaiMax / $divider; $score = $nilaiSiswa; // 🔻 Jika di bawah minimum → penalti if (!is_null($nilaiMin) && $nilaiMin > 0 && $nilaiSiswa < $nilaiMin) { $score = ($nilaiSiswa / $nilaiMin) * $nilaiSiswa; } // 🔺 Jika di atas maksimum → penalti if (!is_null($nilaiMax) && $nilaiSiswa > 0 && $nilaiSiswa > $nilaiMax) { $score = ($nilaiMax / $nilaiSiswa) * $score; } return round(max($score, 0), 6); } /** * Aturan bisnis tambahan: * Jika kriteria wajib_lolos tidak terpenuhi, jurusan didiskualifikasi. */ private function isJurusanDiskualifikasi(array $mapJurusanKriteria, array $nilaiSiswa): bool { foreach ($mapJurusanKriteria as $kode => $jurusanKriteria) { if (!(bool) $jurusanKriteria->wajib_lolos) { continue; } $nilai = $nilaiSiswa[$kode] ?? null; if (is_null($nilai)) continue; // C6 & C8 biner (0/1), sisanya 0-100 di database $isBinary = in_array($kode, ['C6', 'C8']); $divider = $isBinary ? 1 : 100; $nilaiMin = is_null($jurusanKriteria->nilai_min) ? null : $jurusanKriteria->nilai_min / $divider; $nilaiMax = is_null($jurusanKriteria->nilai_max) ? null : $jurusanKriteria->nilai_max / $divider; if (!is_null($nilaiMin) && $nilai < $nilaiMin) return true; if (!is_null($nilaiMax) && $nilai > $nilaiMax) return true; } return false; } /** * Tampilkan hasil tes terakhir */ public function hasil() { $user = Auth::user(); $siswa = Siswa::with('user')->where('user_id', $user->id)->first(); if (!$siswa) { return redirect()->route('landing.home') ->withErrors('Akses ditolak: akun ini bukan siswa.'); } $tesTerakhir = Tes::where('siswa_id', $siswa->id)->latest()->first(); if (!$tesTerakhir) { return redirect()->route('siswa.tes.index'); } $hasilList = HasilSaw::where('tes_id', $tesTerakhir->id) ->with('jurusan') ->orderBy('peringkat') ->get(); if ($hasilList->isEmpty()) { return redirect()->route('siswa.tes.index'); } $jurusanPilihan1 = Jurusan::find($tesTerakhir->minat_jurusan_1_id); $jurusanPilihan2 = Jurusan::find($tesTerakhir->minat_jurusan_2_id); $skorPilihan1 = $tesTerakhir->minat_jurusan_1_id ? $hasilList->firstWhere('jurusan_id', $tesTerakhir->minat_jurusan_1_id) : null; $skorPilihan2 = $tesTerakhir->minat_jurusan_2_id ? $hasilList->firstWhere('jurusan_id', $tesTerakhir->minat_jurusan_2_id) : null; return view('pages.siswa.hasil', compact( 'siswa', 'tesTerakhir', 'hasilList', 'jurusanPilihan1', 'jurusanPilihan2', 'skorPilihan1', 'skorPilihan2' )); } /** * Download PDF hasil tes terakhir */ public function cetakPdf() { $user = Auth::user(); $siswa = Siswa::where('user_id', $user->id)->first(); if (!$siswa) abort(403); $tes = Tes::with('penyakit')->where('siswa_id', $siswa->id)->latest()->first(); if (!$tes) abort(404); $tesPdf = app(TesPdfService::class)->ensureAvailable($tes); if (!$tesPdf?->upload || !$tesPdf->upload->existsOnPublicDisk()) { return redirect()->route('siswa.tes.hasil')->withErrors('File PDF tidak ditemukan.'); } return Storage::disk('public')->download( $tesPdf->upload->normalizedStoragePath(), 'hasil_tes_' . $tes->id . '.pdf' ); } /** * Riwayat tes siswa */ public function history() { $siswa = Siswa::where('user_id', Auth::id())->first(); if (!$siswa) return view('pages.siswa.history', ['histories' => collect()]); $histories = Tes::where('siswa_id', $siswa->id) ->with(['hasilSaw.jurusan', 'tesPDF.upload', 'penyakit']) ->latest() ->paginate(10); return view('pages.siswa.history', compact('histories')); } public function hasilByTes(Tes $tes) { $user = Auth::user(); $siswa = Siswa::where('user_id', $user->id)->first(); if (!$siswa || $tes->siswa_id != $siswa->id) abort(403); $tes->loadMissing('penyakit'); $hasilList = HasilSaw::where('tes_id', $tes->id)->with('jurusan')->orderBy('peringkat')->get(); if ($hasilList->isEmpty()) return redirect()->route('siswa.tes.index'); $jurusanPilihan1 = Jurusan::find($tes->minat_jurusan_1_id); $jurusanPilihan2 = Jurusan::find($tes->minat_jurusan_2_id); $skorPilihan1 = $hasilList->firstWhere('jurusan_id', $tes->minat_jurusan_1_id); $skorPilihan2 = $hasilList->firstWhere('jurusan_id', $tes->minat_jurusan_2_id); return view('pages.siswa.hasil', compact('siswa', 'tes', 'hasilList', 'jurusanPilihan1', 'jurusanPilihan2', 'skorPilihan1', 'skorPilihan2'))->with('tesTerakhir', $tes); } public function cetakPdfByTes(Tes $tes) { $siswa = Siswa::where('user_id', Auth::id())->first(); if (!$siswa || $tes->siswa_id != $siswa->id) abort(403); $tesPdf = app(TesPdfService::class)->ensureAvailable($tes); if (!$tesPdf?->upload || !$tesPdf->upload->existsOnPublicDisk()) { return back()->withErrors('File PDF tidak tersedia.'); } return Storage::disk('public')->download( $tesPdf->upload->normalizedStoragePath(), 'hasil_tes_' . $tes->id . '.pdf' ); } }