whereExists(function ($q) { $q->select(DB::raw(1)) ->from('ulasan as u') ->whereColumn('u.periode_id', 'p.id'); }) ->orWhereExists(function ($q) { $q->select(DB::raw(1)) ->from('hasil_analisis as h') ->whereColumn('h.periode_id', 'p.id'); }) ->orderBy('p.tahun', 'desc') ->orderBy('p.bulan', 'desc') ->select('p.*') ->get(); $availablePeriodeIds = $periodeList->pluck('id')->map(fn($id) => (string) $id); $requestedPeriodeId = $request->filled('periode_id') ? (string) $request->periode_id : null; // cari periode bulan & tahun sekarang $currentMonth = now()->month; $currentYear = now()->year; $currentPeriode = $periodeList->first(function ($periode) use ($currentMonth, $currentYear) { return $periode->bulan == $currentMonth && $periode->tahun == $currentYear; }); // kalau bulan sekarang tidak ada → ambil periode terakhir $defaultPeriodeId = $currentPeriode->id ?? $periodeList->sortByDesc('id')->first()->id ?? null; // prioritas: // 1. request manual dari dropdown // 2. periode bulan sekarang // 3. periode terakhir $periodeId = $request->filled('periode_id') && $availablePeriodeIds->contains($requestedPeriodeId) ? $requestedPeriodeId : $defaultPeriodeId; $periodeAktif = $periodeId ? $periodeList->firstWhere('id', (int) $periodeId) : null; $query = Ulasan::query(); // 🔍 SEARCH (ulasan + wisata) if ($request->filled('search')) { $search = $request->search; $query->where(function ($q) use ($search) { $q->where('ulasan', 'LIKE', "%$search%") ->orWhere('wisata', 'LIKE', "%$search%"); }); } // 🔍 FILTER WISATA if ($request->filled('wisata')) { $query->where('wisata', 'LIKE', '%' . $request->wisata . '%'); } // ← default/reset menampilkan periode terbaru if ($periodeId) { $query->where('periode_id', $periodeId); } $filteredQuery = clone $query; $totalUlasan = (clone $filteredQuery)->count(); $totalWisata = (clone $filteredQuery)->distinct('wisata')->count('wisata'); $lastUpdate = (clone $filteredQuery)->latest()->value('created_at'); $mentah = $query->orderBy('tanggal', 'desc')->paginate(20)->withQueryString(); return view('ulasan.index', compact( 'mentah', 'totalUlasan', 'totalWisata', 'lastUpdate', 'periodeList', 'periodeId', 'periodeAktif', 'defaultPeriodeId' ))->with([ 'scraperDestinations' => $this->scraperDestinations(), ]); } public function riwayat() { $riwayat = DB::table('periode_analisis as p') ->select( 'p.id', 'p.nama', 'p.bulan', 'p.tahun', 'p.created_at', DB::raw('(SELECT COUNT(*) FROM ulasan u WHERE u.periode_id = p.id) as total_ulasan'), DB::raw('(SELECT COUNT(*) FROM hasil_analisis h WHERE h.periode_id = p.id) as total_hasil'), DB::raw('(SELECT COUNT(DISTINCT h.wisata) FROM hasil_analisis h WHERE h.periode_id = p.id) as total_wisata'), DB::raw('(SELECT MAX(h.created_at) FROM hasil_analisis h WHERE h.periode_id = p.id) as terakhir_analisis') ) ->whereExists(function ($q) { $q->select(DB::raw(1)) ->from('ulasan as u') ->whereColumn('u.periode_id', 'p.id'); }) ->orWhereExists(function ($q) { $q->select(DB::raw(1)) ->from('hasil_analisis as h') ->whereColumn('h.periode_id', 'p.id'); }) ->orderBy('p.tahun', 'desc') ->orderBy('p.bulan', 'desc') ->paginate(12); return view('ulasan.riwayat', compact('riwayat')); } public function importRiwayat(Request $request) { $this->allowLongRunningRequest(); $validated = $request->validate([ 'periode_bulan' => ['required', 'date_format:Y-m'], 'file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'], ]); $periodeBulan = Carbon::createFromFormat('Y-m', $validated['periode_bulan']); $rows = $this->readCsvRows($request->file('file')->getRealPath()); if (empty($rows)) { return redirect()->route('riwayat.index') ->with('error', 'Import gagal: CSV kosong atau header tidak terbaca.'); } $requiredColumns = ['wisata', 'rating', 'ulasan', 'tanggal']; $headerColumns = array_keys($rows[0]); $missingColumns = array_values(array_diff($requiredColumns, $headerColumns)); if (!empty($missingColumns)) { return redirect()->route('riwayat.index') ->with('error', 'Import gagal: kolom wajib tidak ada: ' . implode(', ', $missingColumns) . '.'); } $inserted = 0; $skipped = 0; $periodeIds = []; DB::transaction(function () use ($rows, $periodeBulan, &$inserted, &$skipped, &$periodeIds) { foreach ($rows as $index => $row) { $wisata = trim((string) ($row['wisata'] ?? '')); $ulasan = trim((string) ($row['ulasan'] ?? '')); $tanggal = $this->normalizeCsvDate($row['tanggal'] ?? null); $rating = $this->normalizeRating($row['rating'] ?? null); $isRatingOnly = false; if ($ulasan === '' && $rating !== null) { $ulasan = '[Tanpa teks]'; $isRatingOnly = true; } if ($wisata === '' || $ulasan === '' || !$tanggal) { $skipped++; continue; } $reviewer = trim((string) ($row['reviewer'] ?? 'anonymous')) ?: 'anonymous'; if ($isRatingOnly && $reviewer === 'anonymous') { $reviewer = 'anonymous-csv-' . ($index + 1); } $periodeTanggal = Carbon::parse($tanggal)->startOfMonth(); $periodeId = $this->getOrCreatePeriode($periodeTanggal ?: $periodeBulan); $exists = DB::table('ulasan') ->where('wisata', $wisata) ->where('reviewer', $reviewer) ->where('ulasan', $ulasan) ->where('tanggal', $tanggal) ->exists(); if ($exists) { $skipped++; continue; } DB::table('ulasan')->insert([ 'wisata' => $wisata, 'reviewer' => $reviewer, 'rating' => $rating, 'ulasan' => $ulasan, 'tanggal' => $tanggal, 'scraping_date' => now(), 'periode_id' => $periodeId, 'sentimen' => null, 'created_at' => now(), 'updated_at' => now(), ]); $periodeIds[$periodeId] = $periodeId; $inserted++; } }); if ($inserted === 0) { return redirect()->route('riwayat.index') ->with('error', 'Import selesai, tetapi tidak ada data baru yang masuk. Baris kosong/duplikat dilewati: ' . $skipped . '.'); } foreach ($periodeIds as $periodeId) { $analisis = $this->runPythonScript(base_path('scraper/analisis.py'), 900, [ '--periode-id', (string) $periodeId, ]); if (!$analisis['success']) { return redirect()->route('riwayat.index') ->with('error', 'Import berhasil, tetapi analisis gagal: ' . $analisis['output']); } } $periodeLabels = DB::table('periode_analisis') ->whereIn('id', array_values($periodeIds)) ->orderBy('tahun') ->orderBy('bulan') ->pluck('nama') ->implode(', '); return redirect()->route('riwayat.index') ->with('success', 'Import CSV historis berhasil. Data masuk: ' . $inserted . ', dilewati: ' . $skipped . ', periode: ' . $periodeLabels . '.'); } public function ambilData(Request $request) { $this->allowLongRunningRequest(); $validated = $request->validate([ 'periode_bulan' => ['nullable', 'date_format:Y-m'], 'wisata' => ['nullable', 'string'], 'redirect_to' => ['nullable', 'in:dashboard,ulasan'], ]); $periodeBulan = \Carbon\Carbon::createFromFormat( 'Y-m', $validated['periode_bulan'] ?? now()->format('Y-m') ); $startDate = $periodeBulan->copy()->startOfMonth()->toDateString(); $endDate = $periodeBulan->copy()->endOfMonth()->toDateString(); $scrapingArguments = [ '--start-date', $startDate, '--end-date', $endDate, ]; if (!empty($validated['wisata'])) { $scrapingArguments[] = '--wisata'; $scrapingArguments[] = $validated['wisata']; } $scraping = $this->runPythonScript(base_path('scraper/scraping_pipeline.py'), 1800, $scrapingArguments); if (!$scraping['success']) { return redirect()->route('ulasan.index') ->with('error', 'Scraping gagal: ' . $scraping['output']); } $lokasiLabel = empty($validated['wisata']) ? 'semua lokasi' : $validated['wisata']; $redirectParameters = [ 'periode_id' => $periode->id ?? null, ]; if (!empty($validated['wisata'])) { $redirectParameters['wisata'] = $validated['wisata']; } if (($validated['redirect_to'] ?? 'dashboard') === 'ulasan') { return redirect()->route('ulasan.index', array_filter($redirectParameters)) ->with('success', 'Data berhasil diambil dan dianalisis untuk ' . $lokasiLabel . ' periode ' . $periodeBulan->translatedFormat('F Y') . '.'); } if (!empty($validated['wisata'])) { $redirectParameters['tab'] = 'analisis'; } return redirect()->route('ulasan.index', array_filter($redirectParameters)) ->with('success', 'Scraping berhasil dilakukan untuk ' . $lokasiLabel . ' periode ' . $periodeBulan->translatedFormat('F Y') . '. Silakan jalankan proses analisis.'); } public function kosongkanPeriode(Request $request) { $periodeId = $request->validate([ 'periode_id' => ['required', 'integer', 'exists:periode_analisis,id'], ])['periode_id']; DB::transaction(function () use ($periodeId) { DB::table('hasil_analisis')->where('periode_id', $periodeId)->delete(); DB::table('evaluasi_model')->where('periode_id', $periodeId)->delete(); DB::table('ulasan')->where('periode_id', $periodeId)->delete(); }); return redirect()->route('ulasan.index', ['periode_id' => $periodeId]) ->with('success', 'Data ulasan, hasil analisis, dan evaluasi untuk periode ini sudah dikosongkan.'); } public function analisisData(Request $request) { $this->allowLongRunningRequest(); $periodeId = $request->input('periode_id') ?: DB::table('ulasan')->max('periode_id'); if (!$periodeId) { return redirect()->route('ulasan.index') ->with('error', 'Analisis gagal: belum ada ulasan. Jalankan Ambil Data terlebih dahulu.'); } $totalUlasan = DB::table('ulasan')->where('periode_id', $periodeId)->count(); if ($totalUlasan === 0) { return redirect()->route('ulasan.index', ['periode_id' => $periodeId]) ->with('error', 'Analisis gagal: belum ada ulasan pada periode yang dipilih.'); } $analisis = $this->runPythonScript(base_path('scraper/analisis.py'), 900, [ '--periode-id', (string) $periodeId, ]); if (!$analisis['success']) { return redirect()->route('ulasan.index') ->with('error', 'Analisis gagal: ' . $analisis['output']); } return redirect()->route('dashboard', ['periode_id' => $periodeId, 'tab' => 'analisis']) ->with('success', 'Analisis berhasil dijalankan.'); } // private function runPythonScript(string $scriptPath, int $timeout, array $arguments = []): array // { // $this->allowLongRunningRequest(); // $pythonPath = env('PYTHON_PATH', 'python3'); // $process = new Process(array_merge([$pythonPath, $scriptPath], $arguments), base_path()); // $process->setTimeout($timeout); // try { // $process->run(); // $output = trim($process->getOutput() . PHP_EOL . $process->getErrorOutput()); // return [ // 'success' => $process->isSuccessful(), // 'output' => $output !== '' ? $output : 'Tidak ada output dari proses Python.', // ]; // } catch (Throwable $exception) { // return [ // 'success' => false, // 'output' => $exception->getMessage(), // ]; // } // } private function runPythonScript(string $scriptPath, int $timeout, array $arguments = []): array { $this->allowLongRunningRequest(); // 1. Pastikan pakai 'python' bukan 'python3' untuk Windows $pythonPath = env('PYTHON_PATH', 'python'); // 2. Suntikkan variabel sistem Windows agar Python tidak eror 10106 saat dijalankan dari web // $envVars = array_merge($_SERVER, $_ENV, [ // 'PATH' => getenv('PATH') ?: 'C:\\Windows\\System32;C:\\Windows', // 'SystemRoot' => getenv('SystemRoot') ?: 'C:\\Windows', // 'windir' => getenv('windir') ?: 'C:\\Windows', // ]); // 2. Suntikkan variabel sistem Windows secara lengkap $envVars = array_merge($_SERVER, $_ENV, [ 'PATH' => getenv('PATH') ?: 'C:\\Windows\\System32;C:\\Windows', 'SystemRoot' => getenv('SystemRoot') ?: 'C:\\Windows', 'windir' => getenv('windir') ?: 'C:\\Windows', 'USERPROFILE' => getenv('USERPROFILE') ?: 'C:\\Users\\Mufrida Farah', 'LOCALAPPDATA' => getenv('LOCALAPPDATA') ?: 'C:\\Users\\Mufrida Farah\\AppData\\Local', 'TEMP' => getenv('TEMP') ?: 'C:\\Users\\Mufrida Farah\\AppData\\Local\\Temp', 'TMP' => getenv('TMP') ?: 'C:\\Users\\Mufrida Farah\\AppData\\Local\\Temp', ]); // 3. Masukkan $envVars ke parameter ketiga Process $process = new Process(array_merge([$pythonPath, $scriptPath], $arguments), base_path(), $envVars); $process->setTimeout($timeout); try { $process->run(); $output = trim($process->getOutput() . PHP_EOL . $process->getErrorOutput()); return [ 'success' => $process->isSuccessful(), 'output' => $output !== '' ? $output : 'Tidak ada output dari proses Python.', ]; } catch (Throwable $exception) { return [ 'success' => false, 'output' => $exception->getMessage(), ]; } } private function allowLongRunningRequest(): void { ignore_user_abort(true); @set_time_limit(0); @ini_set('max_execution_time', '0'); @ini_set('default_socket_timeout', '1800'); } private function getOrCreatePeriode(Carbon $periodeBulan): int { $periode = DB::table('periode_analisis') ->where('bulan', $periodeBulan->month) ->where('tahun', $periodeBulan->year) ->first(); if ($periode) { return (int) $periode->id; } return DB::table('periode_analisis')->insertGetId([ 'nama' => $periodeBulan->translatedFormat('F Y'), 'bulan' => $periodeBulan->month, 'tahun' => $periodeBulan->year, 'created_at' => now(), 'updated_at' => now(), ]); } private function readCsvRows(string $path): array { $handle = fopen($path, 'r'); if (!$handle) { return []; } $firstLine = fgets($handle); if ($firstLine === false) { fclose($handle); return []; } $delimiter = substr_count($firstLine, ';') > substr_count($firstLine, ',') ? ';' : ','; rewind($handle); $header = fgetcsv($handle, 0, $delimiter); if (!$header) { fclose($handle); return []; } $header = array_map(function ($value) { $column = strtolower(trim((string) $value)); $column = preg_replace('/^\xEF\xBB\xBF/', '', $column); return match ($column) { 'destinasi', 'nama_wisata', 'nama wisata' => 'wisata', 'review', 'komentar' => 'ulasan', 'nama', 'user', 'author' => 'reviewer', default => $column, }; }, $header); $rows = []; while (($data = fgetcsv($handle, 0, $delimiter)) !== false) { if (count(array_filter($data, fn($value) => trim((string) $value) !== '')) === 0) { continue; } $row = []; foreach ($header as $index => $column) { $row[$column] = $data[$index] ?? null; } $rows[] = $row; } fclose($handle); return $rows; } private function normalizeCsvDate($value): ?string { if ($value === null || trim((string) $value) === '') { return null; } try { return Carbon::parse((string) $value)->toDateString(); } catch (Throwable) { return null; } } private function normalizeRating($value): ?int { if ($value === null || trim((string) $value) === '') { return null; } preg_match('/[1-5]/', (string) $value, $matches); return isset($matches[0]) ? (int) $matches[0] : null; } private function scraperDestinations(): array { return [ 'Pantai Papuma', 'Pantai Watu Ulo', 'Teluk Love', 'Kebun Teh Gunung Gambir', ]; } }