get(); // Tangkap ID kriteria induk yang sedang dipilih oleh user di layar $selectedKriteriaId = $request->input('kriteria_id'); $subKriterias = []; $pairs = []; $allData = []; // Jika user sudah memilih kriteria induk di dropdown: if ($selectedKriteriaId) { // Ambil semua anak-anaknya (Sub-Kriteria) yang berinduk pada kriteria tersebut $subKriterias = SubKriteria::where('kriteria_id', $selectedKriteriaId) ->orderBy('kode', 'asc') ->get(); // 2. Tahap pembuatan pasangan Pairwise (Algoritma Kombinasi) // Sistem akan memasangkan setiap Sub-Kriteria secara otomatis. // Contoh: Sub S1 vs S2, S1 vs S3, S2 vs S3. $n = count($subKriterias); for ($i = 0; $i < $n; $i++) { for ($j = $i + 1; $j < $n; $j++) { $pairs[] = [ 'kiri' => $subKriterias[$i], 'kanan' => $subKriterias[$j], ]; } } // Ambil semua data jawaban kuesioner yang sudah tersimpan untuk sub-kriteria ini $allData = PerbandinganSub::with(['subKiri', 'subKanan']) ->where('kriteria_id', $selectedKriteriaId) ->get(); } // 3. Deteksi urutan pakar $existingPakar = PerbandinganSub::where('kriteria_id', $selectedKriteriaId) ->distinct() ->pluck('pakar_ke') ->toArray(); if (empty($existingPakar)) { $existingPakar = [1]; // Jika belum ada sama sekali, set default Pakar 1 } sort($existingPakar); // 4. Ambil PIN keamanan dari database $pengaturan = Pengaturan::where('nama_pengaturan', 'pin_pakar')->first(); $pinSistem = $pengaturan ? $pengaturan->nilai_pengaturan : '123456'; return view('perbandingan_sub.index', compact( 'kriterias', 'selectedKriteriaId', 'subKriterias', 'pairs', 'existingPakar', 'allData', 'pinSistem' )); } /** * ============================================================================== * FUNGSI 2: SIMPAN() * Menerima kiriman form kuesioner, menyimpannya, lalu memicu perhitungan Fuzzy AHP. * ============================================================================== */ public function simpan(Request $request) { $pengaturan = Pengaturan::where('nama_pengaturan', 'pin_pakar')->first(); $pinSistem = $pengaturan ? $pengaturan->nilai_pengaturan : '123456'; // Validasi PIN dulu if ($request->input('pin_rahasia') !== $pinSistem) { return redirect()->back()->with('error', 'Akses Ditolak! PIN Keamanan salah.'); } $pakarKe = $request->input('pakar_ke'); $kriteriaId = $request->input('kriteria_id'); $kiriIds = $request->input('sub_kiri_id'); $kananIds = $request->input('sub_kanan_id'); $nilaiSkalas= $request->input('nilai_skala'); if (! $kiriIds) { return redirect()->back()->with('error', 'Tidak ada data disimpan.'); } // CEK KONSISTENSI (CR) SEBELUM SIMPAN $subKriteriaIds = SubKriteria::where('kriteria_id', $kriteriaId)->orderBy('kode', 'asc')->pluck('id')->toArray(); $n = count($subKriteriaIds); // Buat matriks bayangan untuk dicek $matrixTemp = array_fill(0, $n, array_fill(0, $n, 1.0)); $idToIndex = array_flip($subKriteriaIds); foreach ($kiriIds as $index => $kiriId) { $i = $idToIndex[$kiriId]; $j = $idToIndex[$kananIds[$index]]; $val = (float) $nilaiSkalas[$index]; $matrixTemp[$i][$j] = $val; $matrixTemp[$j][$i] = 1.0 / $val; } $nilaiCR = $this->checkConsistency($matrixTemp, $n); // Jika tidak konsisten (> 10%), tolak data! if ($nilaiCR > 0.10) { $crPersen = round($nilaiCR * 100, 2); return redirect()->back()->with('error', "Gagal! Data Pakar $pakarKe Tidak Konsisten (CR = $crPersen%). Silakan perbaiki perbandingan."); } // selesai cek lanjut ke database // Gunakan Transaction agar kalau error di tengah jalan, database batal menyimpan (Rollback) DB::transaction(function () use ($pakarKe, $kriteriaId, $kiriIds, $kananIds, $nilaiSkalas) { foreach ($kiriIds as $index => $kiriId) { PerbandinganSub::updateOrCreate( [ 'pakar_ke' => $pakarKe, 'kriteria_id' => $kriteriaId, 'sub_kiri_id' => $kiriId, 'sub_kanan_id' => $kananIds[$index], ], ['nilai_skala' => $nilaiSkalas[$index]] ); } }); // Pemicu perhitungan matematis Fuzzy AHP setelah data berhasil masuk $this->updateLocalWeights($kriteriaId); return redirect()->back()->with('success', "Data Pakar $pakarKe berhasil disimpan."); } /** * ============================================================================== * FUNGSI 3: UPDATE LOCAL WEIGHTS (CORE FUZZY AHP) * TIPS SIDANG: Sistem menjaga akurasi dengan tidak melakukan pembulatan round() * selama proses menengah, untuk menghindari Compounding Error (Penumpukan Galat). * ============================================================================== */ private function updateLocalWeights($kriteriaId) { $subs = SubKriteria::where('kriteria_id', $kriteriaId)->get(); $ids = $subs->pluck('id')->toArray(); $n = count($ids); $allData = PerbandinganSub::where('kriteria_id', $kriteriaId)->get(); // if ($allData->isEmpty() || count($ids) < 2) { // return; // } if ($n < 2 || $allData->isEmpty()) { SubKriteria::where('kriteria_id', $kriteriaId) ->update(['bobot_lokal' => ($n == 1 ? 1.0 : 0)]); return; } // TAHAP 1: KONSENSUS MULTI-PAKAR (GEOMETRIC MEAN) $matrixConsensus = []; foreach ($ids as $i) { foreach ($ids as $j) { if ($i == $j) { $matrixConsensus[$i][$j] = [1.0, 1.0, 1.0]; continue; } $scores = $allData->where('sub_kiri_id', $i)->where('sub_kanan_id', $j)->pluck('nilai_skala')->toArray(); if (count($scores) > 0) { // Rumus Rata-rata Geometrik (Akar pangkat n) $gm = pow(array_product($scores), 1 / count($scores)); $matrixConsensus[$i][$j] = $this->mapToTfn($gm); } else { // Jika data ada di sisi sebaliknya (kanan vs kiri) $revScores = $allData->where('sub_kiri_id', $j)->where('sub_kanan_id', $i)->pluck('nilai_skala')->toArray(); if (count($revScores) > 0) { $gmRev = pow(array_product($revScores), 1 / count($revScores)); $tfnRev = $this->mapToTfn($gmRev); // INVERS FUZZY: Dibalik urutannya (1/Upper, 1/Medium, 1/Lower). // Dibiarkan float murni tanpa di round()! $matrixConsensus[$i][$j] = [ 1 / $tfnRev[2], 1 / $tfnRev[1], 1 / $tfnRev[0] ]; } else { $matrixConsensus[$i][$j] = [1.0, 1.0, 1.0]; } } } } // TAHAP 2: NILAI SINTESIS FUZZY (Si) $rowSums = []; $totalSum = [0.0, 0.0, 0.0]; // 2A: Hitung total Baris (Row) dan Grand Total Matriks foreach ($ids as $i) { $rSum = [0.0, 0.0, 0.0]; foreach ($ids as $j) { $rSum[0] += $matrixConsensus[$i][$j][0]; $rSum[1] += $matrixConsensus[$i][$j][1]; $rSum[2] += $matrixConsensus[$i][$j][2]; } $rowSums[$i] = $rSum; $totalSum[0] += $rSum[0]; $totalSum[1] += $rSum[1]; $totalSum[2] += $rSum[2]; } // 2B: Hitung Si (Baris dikali Invers Grand Total) $si = []; foreach ($ids as $i) { // Dibiarkan presisi tinggi (float) tanpa round() $si[$i] = [ $rowSums[$i][0] / $totalSum[2], $rowSums[$i][1] / $totalSum[1], $rowSums[$i][2] / $totalSum[0], ]; } // TAHAP 3: DERAJAT KEMUNGKINAN (V Matrix) $vMatrix = []; foreach ($ids as $i) { foreach ($ids as $j) { // Menghitung probabilitas perpotongan area segitiga antar Fuzzy $vMatrix[$i][$j] = ($i == $j) ? 1.0 : $this->calculateV($si[$i], $si[$j]); } } // TAHAP 4: MINIMUM DEGREE & NORMALISASI $dPrime = []; foreach ($ids as $i) { $vals = []; foreach ($ids as $j) { if ($j !== $i) { $vals[] = $vMatrix[$i][$j]; } } // Mencari irisan terkecil $dPrime[$i] = count($vals) > 0 ? min($vals) : 0.0; } $sumDPrime = array_sum($dPrime); // Tahap Normalisasi agar total seluruh bobot bernilai 1.0 foreach ($ids as $id) { $finalWeight = ($sumDPrime > 0) ? ($dPrime[$id] / $sumDPrime) : 0.0; // Simpan hasil akhir ke database dengan presisi tinggi (6 angka belakang koma) aslinya 6 diganti 4 SubKriteria::where('id', $id)->update(['bobot_lokal' => round($finalWeight, 4)]); } } /** * ============================================================================== * FUNGSI RUMUS V (PERPOTONGAN ORDINAT METODE CHANG) * ============================================================================== */ private function calculateV(array $si_baris, array $si_kolom): float { [$l1, $m1, $u1] = $si_kolom; // Segitiga Kolom [$l2, $m2, $u2] = $si_baris; // Segitiga Baris // Jika nilai Puncak baris >= kolom, kemungkinan = 100% (1.0) if ($m2 >= $m1) return 1.0; // Jika dasar kolom lebih tinggi dari ujung atas baris = 0% if ($l1 >= $u2) return 0.0; $num = $l1 - $u2; // Pembilang $den = ($m2 - $u2) - ($m1 - $l1); // Penyebut // Dibiarkan tanpa round() agar presisi matematis terjaga return ($den == 0) ? 0.0 : ($num / $den); } /** * ============================================================================== * FUNGSI MAPPING TFN (SKALA SAATY KE FUZZY) * ============================================================================== */ private function mapToTfn($val): array { $baseTFN = [ 1 => [1.0, 1.0, 1.0], 2 => [0.5, 1.0, 1.5], 3 => [1.0, 1.5, 2.0], 4 => [1.5, 2.0, 2.5], 5 => [2.0, 2.5, 3.0], 6 => [2.5, 3.0, 3.5], 7 => [3.0, 3.5, 4.0], 8 => [3.5, 4.0, 4.5], 9 => [4.0, 4.5, 4.5], ]; if ($val >= 0.99 && $val <= 1.01) return $baseTFN[1]; if ($val > 1) { $key = min(9, (int) round($val)); return $baseTFN[$key]; } else { $originalKey = max(1, min(9, (int) round(1 / $val))); $tfn = $baseTFN[$originalKey]; // Dibiarkan presisi tinggi (float) tanpa round() return [1 / $tfn[2], 1 / $tfn[1], 1 / $tfn[0]]; } } /** * ============================================================================== * FUNGSI ALGORITMA SKALA 1000 (LARGEST REMAINDER METHOD) * ============================================================================== * TIPS SIDANG: Ini digunakan untuk menahan agar parameter dengan kepentingan * terendah sekalipun tetap memiliki bobot > 0, dan total akhir pasti pas 1000. */ private function toBobot1000(array $weights): array { $keys = array_keys($weights); $float = array_map(fn($w) => $w * 1000, $weights); $result = []; // Pembulatan ke bawah dengan penahan Minimum 1 foreach ($float as $key => $val) { $result[$key] = max(1, floor($val)); } // Cari selisih kurangnya $remainder = 1000 - (int) array_sum($result); if ($remainder > 0) { $sortedKeys = $keys; usort($sortedKeys, function ($a, $b) use ($float) { $decA = $float[$a] - floor($float[$a]); $decB = $float[$b] - floor($float[$b]); return $decB <=> $decA; // Urutkan sisa desimal terbesar ke terkecil }); // Berikan sisa +1 ke kriteria yang pecahannya paling besar for ($k = 0; $k < $remainder; $k++) { $result[$sortedKeys[$k]] += 1; } } return array_map('intval', $result); } /** * ============================================================================== * FUNGSI DETAIL (UNTUK HALAMAN LAPORAN / CETAK HTML & PDF) * ============================================================================== * TIPS SIDANG: Fungsi ini isinya adalah replika persis (copy-paste logika) dari * fungsi updateLocalWeights(). Bedanya, updateLocalWeights() memproses rahasia di background. * Sedangkan fungsi detail() menangkap setiap langkah prosesnya untuk dicetak ke layar (transparansi). */ public function detail(Request $request) { // 1. Menangkap ID Kriteria Induk dari URL (jika ada) $selectedKriteriaId = $request->input('kriteria_id'); $kriterias = Kriteria::orderBy('kode', 'asc')->get(); // Inisialisasi collection kosong agar View tidak error (Undefined Variable) saat kriteria belum dipilih $allData = collect([]); // Jika user belum memilih Kriteria Induk, kembalikan ke layar pemilihan if (! $selectedKriteriaId) { return view('perbandingan_sub.detail', compact('kriterias', 'selectedKriteriaId', 'allData')); } // 2. Ambil data Induk dan Anak-anaknya (Sub-Kriteria) $kriteriaInduk = Kriteria::findOrFail($selectedKriteriaId); $subs = SubKriteria::where('kriteria_id', $selectedKriteriaId)->orderBy('kode', 'asc')->get(); // Pluck berfungsi mengekstrak 1 kolom spesifik jadi array tunggal. Contoh: ['S1', 'S2', 'S3'] $kodes = $subs->pluck('kode')->toArray(); // 3. Ambil data mentah kuesioner yang sudah diisi oleh semua pakar $allData = PerbandinganSub::with(['subKiri', 'subKanan']) ->where('kriteria_id', $selectedKriteriaId)->get(); if ($allData->isEmpty()) { return redirect()->route('perbandingansub.index', ['kriteria_id' => $selectedKriteriaId]) ->with('error', 'Data perbandingan untuk kriteria ini masih kosong!'); } // 4. MAP TO TFN (Fuzzifikasi Awal) // Menyuntikkan array TFN (l, m, u) ke dalam setiap jawaban pakar agar bisa ditampillkan di tabel ringkasan $allData->map(function ($item) { $item->tfn = $this->mapToTfn($item->nilai_skala); return $item; }); // 5. GENERATE DYNAMIC PAIRS & GROUPING (Untuk Tabel Ringkasan Multi-Pakar) // Menggabungkan sub-kriteria (S1 vs S2, S1 vs S3) khusus untuk ditampilkan di tabel View. $dynamicPairs = []; $n = count($subs); for ($i = 0; $i < $n; $i++) { for ($j = $i + 1; $j < $n; $j++) { $dynamicPairs[] = [ 'kiri_kode' => $subs[$i]->kode, 'kiri_nama' => $subs[$i]->nama_sub ?? $subs[$i]->nama, 'kanan_kode' => $subs[$j]->kode, 'kanan_nama' => $subs[$j]->nama_sub ?? $subs[$j]->nama, ]; } } // Mengelompokkan data kuesioner berdasarkan siapa yang mengisinya (Pakar 1, Pakar 2, dst) $groupedKuesioner = $allData->groupBy('pakar_ke'); $pakarList = $groupedKuesioner->keys()->sort(); // ======================================================================== // TAHAP 1: CRISP MATRIX (GEOMETRIC MEAN - KONSENSUS PAKAR) // ======================================================================== $crispMatrix = []; foreach ($kodes as $iKode) { foreach ($kodes as $jKode) { // Konversi kode menjadi ID asli database $iId = $subs->where('kode', $iKode)->first()->id; $jId = $subs->where('kode', $jKode)->first()->id; if ($iId == $jId) { $crispMatrix[$iKode][$jKode] = 1.0; continue; } // Cari penilaian searah $scores = $allData->where('sub_kiri_id', $iId)->where('sub_kanan_id', $jId)->pluck('nilai_skala')->toArray(); if (count($scores) > 0) { $crispMatrix[$iKode][$jKode] = pow(array_product($scores), 1 / count($scores)); } else { // Cari penilaian berlawanan (Invers) $revScores = $allData->where('sub_kiri_id', $jId)->where('sub_kanan_id', $iId)->pluck('nilai_skala')->toArray(); $crispMatrix[$iKode][$jKode] = count($revScores) > 0 ? (1 / pow(array_product($revScores), 1 / count($revScores))) : 1.0; } } } // ======================================================================== // ---> TAMBAHAN BARU: UJI KONSISTENSI AHP (EIGEN, CI, CR) <--- // ======================================================================== $nKriteria = count($kodes); $colSums = []; $priorityVector = []; $lambdaMax = 0; $ci = 0; $cr = 0; $riBaku = [1=>0.00, 2=>0.00, 3=>0.58, 4=>0.90, 5=>1.12, 6=>1.24, 7=>1.32, 8=>1.41, 9=>1.45]; $ri = $riBaku[$nKriteria] ?? 1.45; // 1. Hitung total per kolom foreach ($kodes as $j) { $sum = 0; foreach ($kodes as $i) { $sum += $crispMatrix[$i][$j]; } $colSums[$j] = $sum; } // 2. Normalisasi & Cari Nilai Eigen foreach ($kodes as $i) { $rowSum = 0; foreach ($kodes as $j) { $normalizedVal = ($colSums[$j] == 0) ? 0 : ($crispMatrix[$i][$j] / $colSums[$j]); $rowSum += $normalizedVal; } $priorityVector[$i] = $rowSum / $nKriteria; } // 3. Cari Lambda Max foreach ($kodes as $j) { $lambdaMax += ($colSums[$j] * $priorityVector[$j]); } // 4. Hitung CI dan CR if ($nKriteria > 1) { $ci = ($lambdaMax - $nKriteria) / ($nKriteria - 1); $cr = ($ri > 0) ? ($ci / $ri) : 0; } // selesai // ======================================================================== // TAHAP 2: FUZZY MATRIX (TFN) // ======================================================================== $matrix = []; foreach ($kodes as $i) { foreach ($kodes as $j) { // array_search digunakan untuk membedakan area diagonal atas dan bawah matriks if (array_search($i, $kodes) <= array_search($j, $kodes)) { $matrix[$i][$j] = $this->mapToTfn($crispMatrix[$i][$j]); } else { $tfnAtas = $matrix[$j][$i]; // Invers TFN tanpa fungsi round() agar presisi $matrix[$i][$j] = [1 / $tfnAtas[2], 1 / $tfnAtas[1], 1 / $tfnAtas[0]]; } } } // ======================================================================== // TAHAP 3: ROW SUMS & NILAI SINTESIS (Si) // ======================================================================== $rowSums = []; $totalSum = [0.0, 0.0, 0.0]; foreach ($kodes as $i) { $rSum = [0.0, 0.0, 0.0]; foreach ($kodes as $j) { $rSum[0] += $matrix[$i][$j][0]; $rSum[1] += $matrix[$i][$j][1]; $rSum[2] += $matrix[$i][$j][2]; } $rowSums[$i] = $rSum; $totalSum[0] += $rSum[0]; $totalSum[1] += $rSum[1]; $totalSum[2] += $rSum[2]; } $si = []; foreach ($kodes as $i) { // Formula Si: Baris L / Total U , Baris M / Total M , Baris U / Total L $si[$i] = [ $rowSums[$i][0] / $totalSum[2], $rowSums[$i][1] / $totalSum[1], $rowSums[$i][2] / $totalSum[0], ]; } // ======================================================================== // TAHAP 4: V MATRIX (Derajat Kemungkinan) // ======================================================================== $vMatrix = []; foreach ($kodes as $i) { foreach ($kodes as $j) { $vMatrix[$i][$j] = ($i == $j) ? 1.0 : $this->calculateV($si[$i], $si[$j]); } } // ======================================================================== // TAHAP 5: d'(Ai) & NORMALISASI (W) // ======================================================================== $dPrime = []; foreach ($kodes as $i) { $vals = []; foreach ($kodes as $j) { if ($j !== $i) { $vals[] = $vMatrix[$i][$j]; } } // Cari nilai Minimum $dPrime[$i] = count($vals) > 0 ? min($vals) : 0.0; } $sumDPrime = array_sum($dPrime); $finalWeights = []; foreach ($kodes as $i) { $finalWeights[$i] = ($sumDPrime > 0) ? ($dPrime[$i] / $sumDPrime) : 0.0; } // ======================================================================== // TAHAP AKHIR: KONVERSI KE SKALA 1000 UNTUK VIEW (Anti-Nol) // ======================================================================== $bobot1000 = $this->toBobot1000($finalWeights); // ======================================================================== // OUTPUT: EXPORT PDF ATAU TAMPILAN WEB BIASA // ======================================================================== if ($request->has('export') && $request->export == 'pdf') { // Pastikan use Barryvdh\DomPDF\Facade\Pdf; sudah ada di header file $pdf = Pdf::loadView('perbandingan_sub.pdf', compact( 'kriterias', 'selectedKriteriaId', 'kriteriaInduk', 'crispMatrix', 'matrix', 'si', 'vMatrix', 'dPrime', 'kodes', 'subs', 'allData', 'finalWeights', 'sumDPrime', 'bobot1000', 'pakarList', 'dynamicPairs', 'groupedKuesioner', 'colSums', 'priorityVector', 'lambdaMax', 'ci', 'cr', 'ri', 'nKriteria' ))->setPaper('A4', 'landscape'); // Pakai landscape agar matriksnya muat return $pdf->stream('Laporan_FAHP_SubKriteria_'.$kriteriaInduk->kode.'.pdf'); } return view('perbandingan_sub.detail', compact( 'kriterias', 'selectedKriteriaId', 'kriteriaInduk', 'crispMatrix', 'matrix', 'si', 'vMatrix', 'dPrime', 'kodes', 'subs', 'allData', 'finalWeights', 'sumDPrime', 'bobot1000', 'pakarList', 'dynamicPairs', 'groupedKuesioner', 'colSums', 'priorityVector', 'lambdaMax', 'ci', 'cr', 'ri', 'nKriteria' )); } /** * ============================================================================== * FUNGSI HELPER: UJI KONSISTENSI AHP (SATUAN) * ============================================================================== */ private function checkConsistency($matrix, $n) { $RI = [1 => 0, 2 => 0, 3 => 0.58, 4 => 0.90, 5 => 1.12, 6 => 1.24, 7 => 1.32, 8 => 1.41, 9 => 1.45]; if ($n < 3) return 0.0; // Jika kriteria cuma 1 atau 2, otomatis konsisten // Hitung Total Kolom $colSum = array_fill(0, $n, 0); foreach ($matrix as $row) { foreach ($row as $j => $val) { $colSum[$j] += $val; } } // Cari Eigen Vector $priorityVector = []; foreach ($matrix as $i => $row) { $rowSum = 0; foreach ($row as $j => $val) { // Jaring pengaman cegah Division by Zero $normalizedVal = ($colSum[$j] == 0) ? 0 : ($val / $colSum[$j]); $rowSum += $normalizedVal; } $priorityVector[$i] = $rowSum / $n; } // Cari Lambda Max $lambdaMax = 0; foreach ($colSum as $j => $sum) { $lambdaMax += ($sum * $priorityVector[$j]); } $ci = ($lambdaMax - $n) / ($n - 1); return $ci / $RI[$n]; } public function hapusPakar(Request $request) { if ($request->pakar_dihapus == 1) { return redirect()->back()->with('error', 'Pakar 1 adalah pakar utama dan tidak dapat dihapus. Silakan lakukan Update data jika ada perubahan.'); } PerbandinganSub::where('kriteria_id', $request->kriteria_id) ->where('pakar_ke', $request->pakar_dihapus)->delete(); $this->updateLocalWeights($request->kriteria_id); return redirect()->back()->with('success', "Data Pakar $request->pakar_dihapus dihapus."); } }