get(); if ($kriteria->isEmpty()) { return view('user.preferensi')->with('error', 'Data kriteria belum tersedia. Hubungi administrator.'); } // Ambil ID kontrakan yang sedang di-booking aktif $bookedKontrakanIds = Booking::whereIn('status', [ Booking::STATUS_PENDING, Booking::STATUS_CONFIRMED, Booking::STATUS_CHECKED_IN ])->pluck('kontrakan_id')->toArray(); // Ambil kontrakan yang tersedia (tidak sedang di-booking) $kontrakans = Kontrakan::whereNotIn('id', $bookedKontrakanIds)->get(); // Hitung total dan yang tersedia untuk info $totalKontrakan = Kontrakan::count(); $availableCount = $kontrakans->count(); $bookedCount = count($bookedKontrakanIds); return view('user.preferensi', compact('kriteria', 'kontrakans', 'totalKontrakan', 'availableCount', 'bookedCount')); } catch (Exception $e) { Log::error('Error di User SAW Index: ' . $e->getMessage()); return redirect()->back()->with('error', 'Terjadi kesalahan. Silakan coba lagi.'); } } /** * Proses perhitungan SAW dengan bobot hybrid (10-70%) */ public function calculate(Request $request) { try { // Validasi input bobot (sekarang dalam %) $request->validate([ 'bobot_harga' => 'required|integer|min:10|max:70', 'bobot_jarak' => 'required|integer|min:10|max:70', 'bobot_jumlah_kamar' => 'required|integer|min:10|max:70', 'bobot_fasilitas' => 'required|integer|min:10|max:70', 'mode' => 'required|in:preset,manual', ], [ 'bobot_harga.min' => 'Bobot harga minimal 10%!', 'bobot_harga.max' => 'Bobot harga maksimal 70%!', 'bobot_jarak.min' => 'Bobot jarak minimal 10%!', 'bobot_jarak.max' => 'Bobot jarak maksimal 70%!', 'bobot_jumlah_kamar.min' => 'Bobot jumlah kamar minimal 10%!', 'bobot_jumlah_kamar.max' => 'Bobot jumlah kamar maksimal 70%!', 'bobot_fasilitas.min' => 'Bobot fasilitas minimal 10%!', 'bobot_fasilitas.max' => 'Bobot fasilitas maksimal 70%!', ]); // Validasi total bobot harus 100% $totalBobot = $request->bobot_harga + $request->bobot_jarak + $request->bobot_jumlah_kamar + $request->bobot_fasilitas; if ($totalBobot != 100) { return back()->withErrors([ 'bobot' => "Total bobot harus tepat 100%. Sekarang: {$totalBobot}%" ])->withInput(); } // Gunakan koordinat kampus sebagai referensi (FIXED) $refLat = self::KAMPUS_LAT; $refLng = self::KAMPUS_LNG; $refName = 'Kampus Polije'; // Convert bobot ke decimal (0-1) $bobot = [ 'harga' => $request->bobot_harga / 100, 'jarak' => $request->bobot_jarak / 100, 'jumlah_kamar' => $request->bobot_jumlah_kamar / 100, 'fasilitas' => $request->bobot_fasilitas / 100, ]; Log::info('User SAW Hybrid Input:', [ 'bobot' => $bobot, 'reference_point' => $refName, 'ref_lat' => $refLat, 'ref_lng' => $refLng, ]); // Ambil kriteria kontrakan $kriteria = Kriteria::where('tipe_bisnis', 'kontrakan')->get(); if ($kriteria->isEmpty()) { return redirect()->route('user.search') ->with('error', 'Kriteria belum tersedia!'); } // Ambil ID kontrakan yang sedang di-booking aktif (pending, confirmed, checked_in) $bookedKontrakanIds = Booking::whereIn('status', [ Booking::STATUS_PENDING, Booking::STATUS_CONFIRMED, Booking::STATUS_CHECKED_IN ])->pluck('kontrakan_id')->toArray(); // Ambil semua kontrakan yang TERSEDIA (tidak sedang di-booking) $kontrakan = Kontrakan::whereNotIn('id', $bookedKontrakanIds)->get(); if ($kontrakan->isEmpty()) { return redirect()->route('user.search') ->with('error', 'Semua kontrakan sedang tidak tersedia (sudah di-booking). Silakan coba lagi nanti.'); } // Proses data kontrakan dengan lokasi referensi $kontrakanProcessed = $this->processKontrakan($kontrakan, $refLat, $refLng); // Hitung max/min $maxMin = $this->calculateMaxMin($kontrakanProcessed); // Hitung SAW dengan bobot user $hasil = $this->calculateSAW($kontrakanProcessed, $kriteria, $bobot, $maxMin); if (empty($hasil)) { return redirect()->route('user.search') ->with('error', 'Gagal menghitung hasil. Silakan coba lagi.'); } // Info bobot untuk ditampilkan (TRANSPARANSI!) $bobotInfo = [ 'mode' => $request->mode, 'preset_type' => $request->preset_type ?? null, 'values' => [ 'harga' => $request->bobot_harga, 'jarak' => $request->bobot_jarak, 'kamar' => $request->bobot_jumlah_kamar, 'fasilitas' => $request->bobot_fasilitas, ], 'reference_point' => $refName, 'user_location' => ($request->user_lat && $request->user_lng) ? [ 'lat' => $request->user_lat, 'lng' => $request->user_lng ] : null ]; return view('user.hasil', compact('hasil', 'kriteria', 'bobotInfo')); } catch (\Illuminate\Validation\ValidationException $e) { return redirect()->route('user.search') ->withErrors($e->validator) ->withInput(); } catch (Exception $e) { Log::error('Error di User SAW Calculate: ' . $e->getMessage(), [ 'trace' => $e->getTraceAsString() ]); return redirect()->route('user.search') ->with('error', 'Terjadi kesalahan: ' . $e->getMessage()); } } /** * Proses data kontrakan dengan hitung fasilitas & jarak */ private function processKontrakan($kontrakan, $refLat, $refLng) { return $kontrakan->map(function($item) use ($refLat, $refLng) { try { // Hitung jumlah fasilitas $item->jumlah_fasilitas = $item->fasilitas ? count(explode(',', $item->fasilitas)) : 0; // Set harga $item->harga_value = $item->harga ?? 0; // Hitung jarak dari lokasi referensi if ($item->latitude && $item->longitude) { try { // calculateDistance mengembalikan jarak dalam km, konversi ke meter $item->jarak_value = $item->calculateDistance($refLat, $refLng) * 1000; } catch (Exception $e) { Log::warning("Gagal hitung jarak untuk {$item->nama}: " . $e->getMessage()); $item->jarak_value = $item->jarak ?? 0; } } else { $item->jarak_value = $item->jarak ?? 0; } // Set jumlah_kamar $item->jumlah_kamar_value = $item->jumlah_kamar ?? 0; return $item; } catch (Exception $e) { Log::error("Error processing kontrakan {$item->id}: " . $e->getMessage()); return null; } })->filter(); } /** * Hitung max/min untuk normalisasi */ private function calculateMaxMin($kontrakan) { return [ 'harga' => [ 'max' => $kontrakan->max('harga_value') ?: 1, 'min' => $kontrakan->min('harga_value') ?: 1, ], 'jarak' => [ 'max' => $kontrakan->max('jarak_value') ?: 1, 'min' => $kontrakan->min('jarak_value') ?: 1, ], 'jumlah_kamar' => [ 'max' => $kontrakan->max('jumlah_kamar_value') ?: 1, 'min' => $kontrakan->min('jumlah_kamar_value') ?: 1, ], 'fasilitas' => [ 'max' => $kontrakan->max('jumlah_fasilitas') ?: 1, 'min' => $kontrakan->min('jumlah_fasilitas') ?: 1, ], ]; } /** * Hitung SAW dengan bobot dari user */ private function calculateSAW($kontrakan, $kriteria, $bobot, $maxMin) { $hasil = []; foreach ($kontrakan as $item) { try { $normalisasi = []; $nilaiTotal = 0; // Hitung untuk setiap kriteria $kriteriaMap = [ 'harga' => ['value' => $item->harga_value, 'maxmin' => $maxMin['harga']], 'jarak' => ['value' => $item->jarak_value, 'maxmin' => $maxMin['jarak']], 'jumlah_kamar' => ['value' => $item->jumlah_kamar_value, 'maxmin' => $maxMin['jumlah_kamar']], 'fasilitas' => ['value' => $item->jumlah_fasilitas, 'maxmin' => $maxMin['fasilitas']], ]; foreach ($kriteria as $krit) { $kriteriaKey = $this->getKriteriaKey($krit->nama_kriteria); if (!isset($kriteriaMap[$kriteriaKey]) || !isset($bobot[$kriteriaKey])) { continue; } $nilaiAsli = $kriteriaMap[$kriteriaKey]['value']; $maxMinKrit = $kriteriaMap[$kriteriaKey]['maxmin']; $bobotKrit = $bobot[$kriteriaKey]; $tipeKrit = strtolower($krit->tipe); $nilaiNormalisasi = $this->normalize($nilaiAsli, $maxMinKrit, $tipeKrit); // Debug untuk jumlah kamar if ($kriteriaKey === 'jumlah_kamar') { Log::info("Debug Kamar - {$item->nama}: asli={$nilaiAsli}, max={$maxMinKrit['max']}, tipe={$tipeKrit}, normalisasi={$nilaiNormalisasi}"); } $normalisasi[$krit->nama_kriteria] = [ 'asli' => $nilaiAsli, 'normalisasi' => round($nilaiNormalisasi, 4), 'bobot' => $bobotKrit, 'tipe' => $krit->tipe, ]; $nilaiTotal += ($nilaiNormalisasi * $bobotKrit); } $hasil[] = [ 'id' => $item->id, 'nama' => $item->nama, 'alamat' => $item->alamat, 'harga' => $item->harga, 'jarak' => $item->jarak_value, 'jumlah_kamar' => $item->jumlah_kamar, 'fasilitas' => $item->fasilitas, 'jumlah_fasilitas' => $item->jumlah_fasilitas, 'foto' => $item->foto, 'latitude' => $item->latitude, 'longitude' => $item->longitude, 'no_whatsapp' => $item->no_whatsapp, 'status' => $item->status ?? 'available', // Status ketersediaan 'normalisasi' => $normalisasi, 'nilai' => round($nilaiTotal, 4), ]; } catch (Exception $e) { Log::error("Error calculating SAW for kontrakan {$item->id}: " . $e->getMessage()); continue; } } // Sort berdasarkan nilai tertinggi usort($hasil, fn($a, $b) => $b['nilai'] <=> $a['nilai']); // Tambahkan ranking foreach ($hasil as $key => $value) { $hasil[$key]['ranking'] = $key + 1; } return $hasil; } /** * Normalisasi nilai */ private function normalize($nilai, $maxMin, $tipe) { try { if ($nilai == 0 && $tipe == 'cost') { return 0; } if ($tipe == 'cost') { return $nilai > 0 ? $maxMin['min'] / $nilai : 0; } else { return $maxMin['max'] > 0 ? $nilai / $maxMin['max'] : 0; } } catch (Exception $e) { Log::error('Error normalize: ' . $e->getMessage()); return 0; } } /** * Get bobot info untuk transparansi */ private function getBobotInfo($kriteria, $request) { $bobotInfo = []; foreach ($kriteria as $k) { $bobotInfo[] = [ 'nama' => $k->nama_kriteria, 'bobot' => $request->input('bobot_' . $k->kode_kriteria, 50), ]; } return $bobotInfo; } /** * Helper: Convert nama kriteria ke key */ private function getKriteriaKey($namaKriteria) { $namaLower = strtolower($namaKriteria); if (strpos($namaLower, 'harga') !== false) return 'harga'; if (strpos($namaLower, 'jarak') !== false) return 'jarak'; if (strpos($namaLower, 'jumlah_kamar') !== false || strpos($namaLower, 'jumlah kamar') !== false || strpos($namaLower, 'luas') !== false) return 'jumlah_kamar'; if (strpos($namaLower, 'fasilitas') !== false) return 'fasilitas'; return explode(' ', $namaLower)[0]; } /** * Halaman Preferensi SAW untuk Kontrakan */ public function preferensi() { try { $kriteria = Kriteria::where('tipe_bisnis', 'kontrakan')->get(); if ($kriteria->isEmpty()) { return redirect()->route('welcome') ->with('error', 'Data kriteria belum tersedia. Hubungi administrator.'); } // Ambil ID kontrakan yang sedang di-booking aktif $bookedKontrakanIds = Booking::whereIn('status', [ Booking::STATUS_PENDING, Booking::STATUS_CONFIRMED, Booking::STATUS_CHECKED_IN ])->pluck('kontrakan_id')->toArray(); // Ambil kontrakan yang tersedia (tidak sedang di-booking) $kontrakans = Kontrakan::whereNotIn('id', $bookedKontrakanIds)->get(); // Hitung total dan yang tersedia untuk info $totalKontrakan = Kontrakan::count(); $availableCount = $kontrakans->count(); $bookedCount = count($bookedKontrakanIds); return view('user.preferensi', compact('kriteria', 'kontrakans', 'totalKontrakan', 'availableCount', 'bookedCount')); } catch (Exception $e) { Log::error('Error di User Preferensi: ' . $e->getMessage()); return redirect()->route('welcome')->with('error', 'Terjadi kesalahan.'); } } /** * Halaman Preferensi SAW untuk Laundry */ public function preferensiLaundry() { try { $kriteria = Kriteria::where('tipe_bisnis', 'laundry')->get(); if ($kriteria->isEmpty()) { return redirect()->route('welcome') ->with('error', 'Data kriteria laundry belum tersedia.'); } return view('user.preferensi-laundry', compact('kriteria')); } catch (Exception $e) { Log::error('Error di User Preferensi Laundry: ' . $e->getMessage()); return redirect()->route('welcome')->with('error', 'Terjadi kesalahan.'); } } /** * Halaman Search Laundry */ public function searchLaundry() { try { $kriteria = Kriteria::where('tipe_bisnis', 'laundry')->get(); if ($kriteria->isEmpty()) { return redirect()->route('welcome') ->with('error', 'Data kriteria laundry belum tersedia.'); } return view('user.search-laundry', compact('kriteria')); } catch (Exception $e) { Log::error('Error di User Search Laundry: ' . $e->getMessage()); return redirect()->route('welcome')->with('error', 'Terjadi kesalahan.'); } } /** * Calculate Laundry dengan SAW (Auto - Tanpa Bobot Manual) */ public function calculateLaundry(Request $request) { try { // Ambil kriteria untuk laundry $kriteria = Kriteria::where('tipe_bisnis', 'laundry')->get(); if ($kriteria->isEmpty()) { return redirect()->route('user.search.laundry') ->with('error', 'Data kriteria belum tersedia. Hubungi administrator.'); } // Validasi input hanya kategori dan lokasi $request->validate([ 'kategori_layanan' => 'required|in:reguler,express,kilat,premium', 'user_lat' => 'nullable|numeric', 'user_lng' => 'nullable|numeric', ]); // Ambil semua laundry $laundries = Laundry::all(); if ($laundries->isEmpty()) { return redirect()->route('user.search.laundry') ->with('error', 'Belum ada data laundry yang tersedia.'); } // Tentukan lokasi referensi untuk perhitungan jarak $refLat = $request->user_lat ?: self::KAMPUS_LAT; $refLng = $request->user_lng ?: self::KAMPUS_LNG; $refName = ($request->user_lat && $request->user_lng) ? 'Lokasi Anda' : 'Kampus Polije'; Log::info('Laundry SAW Calculation (Auto):', [ 'kategori_layanan' => $request->kategori_layanan, 'reference_location' => $refName, 'ref_lat' => $refLat, 'ref_lng' => $refLng, ]); // Hitung jarak dari lokasi referensi untuk setiap laundry foreach ($laundries as $laundry) { if (!empty($laundry->latitude) && !empty($laundry->longitude)) { $laundry->jarak_kampus = $this->haversineDistance( $refLat, $refLng, $laundry->latitude, $laundry->longitude ); } else { $laundry->jarak_kampus = 999; // Default jarak besar jika koordinat tidak ada } } // Proses Normalisasi dan Perhitungan SAW dengan filter kategori $hasil = $this->prosesLaundrySAW($laundries, $kriteria, $request); // Simpan info untuk tampilan $bobotInfo = [ 'kategori_layanan' => $request->kategori_layanan, 'reference_point' => $refName, 'user_location' => ($request->user_lat && $request->user_lng) ? [ 'lat' => $request->user_lat, 'lng' => $request->user_lng ] : null, 'mode' => 'auto' ]; return view('user.hasil-laundry', compact('hasil', 'kriteria', 'bobotInfo')); } catch (Exception $e) { Log::error('Error di Calculate Laundry: ' . $e->getMessage()); return redirect()->route('user.search.laundry') ->with('error', 'Terjadi kesalahan dalam perhitungan. Silakan coba lagi.'); } } /** * Proses perhitungan SAW untuk Laundry (Auto - Bobot Seimbang) */ private function prosesLaundrySAW($laundries, $kriteria, $request) { $hasil = []; $normalizedData = []; $kategoriLayanan = $request->kategori_layanan; // Filter laundry yang memiliki layanan sesuai kategori yang dipilih $laundries = $laundries->filter(function($laundry) use ($kategoriLayanan) { return $laundry->layanan() ->where('jenis_layanan', $kategoriLayanan) ->exists(); }); if ($laundries->isEmpty()) { Log::warning('Tidak ada laundry dengan kategori: ' . $kategoriLayanan); return []; } // Step 1: Normalisasi foreach ($kriteria as $k) { $values = []; // Kumpulkan nilai dengan key = laundry->id untuk menghindari undefined index foreach ($laundries as $laundry) { switch ($k->kode_kriteria) { case 'H': // Harga $values[$laundry->id] = $this->getHargaLaundryByKategori($laundry, $kategoriLayanan); break; case 'J': // Jarak $values[$laundry->id] = $laundry->jarak_kampus; break; case 'R': // Rating $values[$laundry->id] = $laundry->rating ?: 3; // Default 3 jika tidak ada rating break; case 'F': // Fasilitas $values[$laundry->id] = $this->getFasilitasScore($laundry); break; } } // Cek apakah array values tidak kosong sebelum memanggil max/min if (empty($values)) { continue; // Skip kriteria ini jika tidak ada nilai } $max = max($values); $min = min($values); foreach ($laundries as $laundry) { $value = $values[$laundry->id]; // Gunakan ID bukan index if ($k->jenis_kriteria === 'benefit') { $normalizedData[$laundry->id][$k->kode_kriteria] = $max != $min ? $value / $max : 1; } else { // cost $normalizedData[$laundry->id][$k->kode_kriteria] = $min != $max ? $min / $value : 1; } } } // Step 2: Perhitungan SAW dengan bobot seimbang otomatis $jumlahKriteria = $kriteria->count(); $bobotSeimbang = $jumlahKriteria > 0 ? (1 / $jumlahKriteria) : 0.25; // Default 25% jika ada 4 kriteria foreach ($laundries as $laundry) { $nilaiSAW = 0; foreach ($kriteria as $k) { // Cek apakah ada data normalisasi untuk kriteria ini $nilaiNormalisasi = $normalizedData[$laundry->id][$k->kode_kriteria] ?? 0; $nilaiSAW += $bobotSeimbang * $nilaiNormalisasi; } // Ambil layanan spesifik untuk kategori yang dipilih $layananSpesifik = $laundry->layanan() ->where('jenis_layanan', $kategoriLayanan) ->first(); $hasil[] = [ 'laundry' => $laundry, 'layanan' => $layananSpesifik, 'nilai' => round($nilaiSAW, 4), 'detail' => $normalizedData[$laundry->id] ?? [] ]; } // Sort by nilai descending usort($hasil, function($a, $b) { return $b['nilai'] <=> $a['nilai']; }); return $hasil; } /** * Get harga rata-rata laundry */ private function getHargaLaundry($laundry) { // Ambil rata-rata harga layanan laundry $layanan = $laundry->layanan; if ($layanan->isEmpty()) { return 5000; // Default harga jika tidak ada layanan } return $layanan->avg('harga') ?: 5000; } /** * Get harga laundry berdasarkan kategori layanan */ private function getHargaLaundryByKategori($laundry, $kategoriLayanan) { // Ambil harga layanan laundry berdasarkan kategori $layanan = $laundry->layanan() ->where('jenis_layanan', $kategoriLayanan) ->first(); if (!$layanan) { return 999999; // Return nilai besar jika tidak ada layanan untuk kategori ini } return $layanan->harga ?: 5000; } /** * Get fasilitas score berdasarkan jumlah fasilitas */ private function getFasilitasScore($laundry) { $fasilitas = $laundry->fasilitas ? explode(',', $laundry->fasilitas) : []; return count($fasilitas); } /** * Calculate distance between two coordinates using Haversine formula * * @param float $lat1 Latitude of first point * @param float $lng1 Longitude of first point * @param float $lat2 Latitude of second point * @param float $lng2 Longitude of second point * @return float Distance in kilometers */ private function haversineDistance($lat1, $lng1, $lat2, $lng2) { $earthRadius = 6371; // Radius bumi dalam kilometer $dLat = deg2rad($lat2 - $lat1); $dLng = deg2rad($lng2 - $lng1); $a = sin($dLat / 2) * sin($dLat / 2) + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) * sin($dLng / 2); $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); $distance = $earthRadius * $c; // Jarak dalam kilometer return round($distance, 2); // Bulatkan 2 desimal } }