492 lines
16 KiB
PHP
492 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Siswa;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Siswa;
|
||
use App\Models\Tes;
|
||
use App\Models\SoalMinat;
|
||
use App\Models\JawabanMinat;
|
||
use App\Models\Jurusan;
|
||
use App\Models\HasilSaw;
|
||
use App\Models\Upload;
|
||
use App\Models\TesPdf;
|
||
use App\Models\Penyakit;
|
||
use App\Services\ActivityLogger;
|
||
use App\Services\TesPdfService;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Storage;
|
||
|
||
class SpkController extends Controller
|
||
{
|
||
/**
|
||
* Tampilkan halaman tes siswa
|
||
*/
|
||
public function index()
|
||
{
|
||
$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.');
|
||
}
|
||
|
||
$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'
|
||
);
|
||
}
|
||
}
|