MIF_E31231430/app/Http/Controllers/RekomendasiController.php

656 lines
24 KiB
PHP

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Kamera;
use App\Models\Lensa;
use App\Models\Penyewaan;
use App\Models\User;
use App\Services\CatalogCodeService;
use App\Services\InventoryStockService;
use App\Services\KameraScoringService;
use App\Services\TopsisService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
class RekomendasiController extends Controller
{
private function getFilterGuide(): array
{
$kameras = Kamera::query()
->select(['merek', 'price', 'iso', 'af_point'])
->whereNotNull('price')
->whereNotNull('iso')
->whereNotNull('af_point')
->get();
$allGuide = [
'label' => 'Semua Merek',
'min_price' => (int) $kameras->min('price'),
'max_price' => (int) $kameras->max('price'),
'min_iso' => (int) $kameras->min('iso'),
'max_iso' => (int) $kameras->max('iso'),
'min_af_point' => (int) $kameras->min('af_point'),
'max_af_point' => (int) $kameras->max('af_point'),
];
$brandGuides = $kameras
->groupBy('merek')
->sortKeys()
->mapWithKeys(function (Collection $items, string $merek): array {
return [
$merek => [
'label' => $merek,
'min_price' => (int) $items->min('price'),
'max_price' => (int) $items->max('price'),
'min_iso' => (int) $items->min('iso'),
'max_iso' => (int) $items->max('iso'),
'min_af_point' => (int) $items->min('af_point'),
'max_af_point' => (int) $items->max('af_point'),
],
];
})
->all();
return [
'all' => $allGuide,
'brands' => $brandGuides,
];
}
private function isAllBrands(string $merekInput): bool
{
return $merekInput === '' || in_array(Str::lower($merekInput), ['semua', 'semua merek', 'all'], true);
}
private function buildNoResultMessage(Request $request, Collection $availableForBrand, string $merekLabel): string
{
if ($availableForBrand->isEmpty()) {
return 'Merek kamera yang dipilih belum tersedia di dataset.';
}
$parts = [];
if ($request->filled('budget')) {
$parts[] = 'budget tersedia Rp ' . number_format((int) $availableForBrand->min('price'), 0, ',', '.')
. ' sampai Rp ' . number_format((int) $availableForBrand->max('price'), 0, ',', '.');
}
if ($request->filled('iso_min') || $request->filled('iso_max')) {
$parts[] = 'ISO tersedia ' . number_format((int) $availableForBrand->min('iso'), 0, ',', '.')
. ' sampai ' . number_format((int) $availableForBrand->max('iso'), 0, ',', '.');
}
if ($parts === []) {
$parts[] = 'harga tersedia Rp ' . number_format((int) $availableForBrand->min('price'), 0, ',', '.')
. ' sampai Rp ' . number_format((int) $availableForBrand->max('price'), 0, ',', '.');
$parts[] = 'ISO tersedia ' . number_format((int) $availableForBrand->min('iso'), 0, ',', '.')
. ' sampai ' . number_format((int) $availableForBrand->max('iso'), 0, ',', '.');
}
return 'Tidak ada kamera yang cocok dengan filter yang dipilih untuk ' . $merekLabel . '. '
. 'Data saat ini memiliki ' . implode(' dan ', $parts) . '. '
. 'Coba longgarkan salah satu filter atau kosongkan budget/ISO terlebih dahulu.';
}
// Halaman landing
public function index()
{
$merekList = Kamera::select('merek')->distinct()->orderBy('merek')->pluck('merek');
$filterGuide = $this->getFilterGuide();
return view('landing', compact('merekList', 'filterGuide'));
}
// Form rekomendasi
public function form()
{
$merekList = Kamera::select('merek')->distinct()->orderBy('merek')->pluck('merek');
$filterGuide = $this->getFilterGuide();
return view('form_rekomendasi', compact('merekList', 'filterGuide'));
}
/**
* Menghitung skor kriteria kamera sesuai notebook Jupyter:
* Price (cost), ISO (benefit), AF Point (benefit), Sensor (benefit).
*/
private function hitungSkorKriteria(Collection $kameras): array
{
$minPrice = (float) $kameras->min('price');
$maxPrice = (float) $kameras->max('price');
$maxIso = max((float) $kameras->max('iso'), 1);
$maxAfPoint = max((float) $kameras->max('af_point'), 1);
$skorKriteria = [];
foreach ($kameras as $k) {
$priceScore = ($maxPrice === $minPrice)
? 1.0
: (($maxPrice - (float) $k->price) / ($maxPrice - $minPrice)) * 1.0;
$isoScore = ((float) $k->iso / $maxIso) * 1.0;
$afScore = ((float) $k->af_point / $maxAfPoint) * 1.0;
$skorKriteria[] = [
'price_score' => round($priceScore, 2),
'iso_score' => round($isoScore, 2),
'af_score' => round($afScore, 2),
'sensor_score' => round((float) $k->sensor_score / 10.0, 2),
];
}
return $skorKriteria;
}
/**
* Bobot mengikuti notebook Jupyter.
* Urutan bobot: [Price, ISO, AF Point, Sensor]
*/
private function getBobotByPenggunaan(string $penggunaan): array
{
$bobotMap = [
'outdoor_day' => [0.30, 0.15, 0.25, 0.30],
'outdoor_night' => [0.15, 0.50, 0.15, 0.20],
'indoor_cerah' => [0.35, 0.20, 0.20, 0.25],
'indoor_gelap' => [0.15, 0.55, 0.15, 0.15],
'all' => [0.25, 0.30, 0.20, 0.25],
];
return $bobotMap[$penggunaan] ?? [0.25, 0.25, 0.25, 0.25];
}
// Proses rekomendasi (TOPSIS)
public function proses(Request $request)
{
// Validasi input
$request->validate([
'penggunaan' => 'required|in:outdoor_day,outdoor_night,indoor_cerah,indoor_gelap,all',
'merek' => 'nullable|string',
'budget' => 'nullable|integer|min:0',
'iso_min' => 'nullable|integer',
'iso_max' => 'nullable|integer|gte:iso_min',
]);
// Cek apakah data kamera ada
if (Kamera::all()->isEmpty()) {
return back()->withInput()->with('error', 'Data kamera masih kosong. Silakan isi data kamera terlebih dahulu.');
}
$merekInput = trim((string) $request->input('merek', ''));
$query = Kamera::query();
if (! $this->isAllBrands($merekInput)) {
$query->whereRaw('LOWER(merek) = ?', [Str::lower($merekInput)], 'and');
}
$availableForBrand = (clone $query)->get();
$merekLabel = $this->isAllBrands($merekInput) ? 'semua merek' : $merekInput;
if ($availableForBrand->isEmpty()) {
return back()->withInput()->with('error', 'Merek kamera yang dipilih belum tersedia di dataset.');
}
if ($request->budget && $request->budget > 0) {
$query->where('price', '<=', $request->budget);
}
if ($request->filled('iso_min')) {
$query->where('iso', '>=', $request->integer('iso_min'));
}
if ($request->filled('iso_max')) {
$query->where('iso', '<=', $request->integer('iso_max'));
}
$kameras = $query->get();
if ($kameras->isEmpty()) {
return back()
->withInput()
->with('error', $this->buildNoResultMessage($request, $availableForBrand, $merekLabel));
}
$skorKriteria = $this->hitungSkorKriteria($kameras);
$dataMatrix = [];
foreach ($skorKriteria as $skor) {
$dataMatrix[] = [
$skor['price_score'],
$skor['iso_score'],
$skor['af_score'],
$skor['sensor_score'],
];
}
$bobot = $this->getBobotByPenggunaan($request->penggunaan);
$jenis = ['cost', 'benefit', 'benefit', 'benefit'];
$penggunaanLabels = [
'outdoor_day' => 'Outdoor - Day (Siang)',
'outdoor_night' => 'Outdoor - Night (Malam)',
'indoor_cerah' => 'Indoor - Cerah',
'indoor_gelap' => 'Indoor - Gelap',
'all' => 'Semua Kondisi',
];
$penggunaanLabel = $penggunaanLabels[$request->penggunaan] ?? $request->penggunaan;
try {
$scores = TopsisService::hitung($dataMatrix, $bobot, $jenis);
$hasil = [];
foreach ($kameras as $i => $k) {
$hasil[] = (object)[
'id' => $k->id,
'nama' => $k->nama,
'merek' => $k->merek,
'image_path' => $k->image_path,
'harga' => $k->price,
'iso' => $k->iso,
'af_point' => $k->af_point,
'sensor' => $k->sensor,
'price_score' => $skorKriteria[$i]['price_score'],
'iso_score' => $skorKriteria[$i]['iso_score'],
'af_score' => $skorKriteria[$i]['af_score'],
'sensor_score' => $skorKriteria[$i]['sensor_score'],
'skor' => round($scores[$i], 4),
];
}
usort($hasil, fn($a, $b) => $b->skor <=> $a->skor);
$top3 = array_slice($hasil, 0, 3);
foreach ($top3 as $k) {
$lensa = Lensa::query()
->where('merek', $k->merek)
->orderByDesc('aperture_score')
->first();
$k->lensa_rekomendasi = $lensa ? $lensa->nama : null;
$k->harga_lensa = $lensa ? $lensa->harga_sewa : 0;
$k->total_harga = $k->harga + ($lensa ? $lensa->harga_sewa : 0);
}
} catch (\Throwable $e) {
report($e);
return back()
->withInput()
->with('error', 'Proses rekomendasi gagal: ' . $e->getMessage());
}
return view('hasil_rekomendasi', [
'hasil' => $top3,
'total_kamera_diproses' => count($kameras),
'penggunaan_terpilih' => $penggunaanLabel,
'bobot_yang_digunakan' => $bobot,
]);
}
// Dashboard admin
public function dashboard()
{
$kameras = Kamera::all();
$lensas = Lensa::all();
$penyewaans = Penyewaan::with(['kamera', 'lensa'])
->latest()
->take(15)
->get();
$users = User::query()
->where('role', 'user')
->withCount('penyewaans')
->latest()
->get();
return view('dashboard', compact('kameras', 'lensas', 'penyewaans', 'users'));
}
public function createKameraAdmin(): View
{
return view('admin.kamera_form', [
'kamera' => new Kamera([]),
'formMode' => 'create',
'sensorOptions' => KameraScoringService::sensorOptions(),
]);
}
public function storeKameraAdmin(Request $request): RedirectResponse
{
$validated = $request->validate([
'nama' => ['required', 'string', 'max:255'],
'merek' => ['required', 'string', 'max:255'],
'harga_sewa' => ['required', 'integer', 'min:0'],
'iso' => ['required', 'integer', 'min:0'],
'af_point' => ['required', 'integer', 'min:0'],
'sensor' => ['required', 'string', 'max:255'],
]);
$kamera = Kamera::create(array_merge(
$validated,
KameraScoringService::deriveFromSpecs($validated)
));
$kamera->update([
'kode_kamera' => CatalogCodeService::forKameraId((int) $kamera->id),
]);
return redirect()->route('dashboard')->with('status', 'Data kamera baru berhasil ditambahkan.');
}
public function editKameraAdmin(Kamera $kamera): View
{
return view('admin.kamera_form', [
'kamera' => $kamera,
'formMode' => 'edit',
'sensorOptions' => KameraScoringService::sensorOptions(),
]);
}
public function updateKameraAdmin(Request $request, Kamera $kamera): RedirectResponse
{
$validated = $request->validate([
'nama' => ['required', 'string', 'max:255'],
'merek' => ['required', 'string', 'max:255'],
'harga_sewa' => ['required', 'integer', 'min:0'],
'iso' => ['required', 'integer', 'min:0'],
'af_point' => ['required', 'integer', 'min:0'],
'sensor' => ['required', 'string', 'max:255'],
]);
$kamera->update(array_merge(
$validated,
KameraScoringService::deriveFromSpecs($validated)
));
return redirect()->route('dashboard')->with('status', 'Data kamera berhasil diperbarui.');
}
public function destroyKameraAdmin(Kamera $kamera): RedirectResponse
{
$nama = $kamera->nama;
Kamera::destroy($kamera->getKey());
return redirect()->route('dashboard')->with('status', 'Data kamera ' . $nama . ' berhasil dihapus.');
}
public function createLensaAdmin(): View
{
return view('admin.lensa_form', [
'lensa' => new Lensa([]),
'formMode' => 'create',
]);
}
public function storeLensaAdmin(Request $request): RedirectResponse
{
$validated = $request->validate([
'nama' => ['required', 'string', 'max:255'],
'merek' => ['required', 'string', 'max:255'],
'harga_sewa' => ['required', 'integer', 'min:0'],
'aperture_score' => ['required', 'numeric', 'min:0', 'max:10'],
'stabilisasi' => ['required', 'numeric', 'min:0', 'max:10'],
'fleksibilitas' => ['required', 'numeric', 'min:0', 'max:10'],
]);
$lensa = Lensa::create($validated);
$lensa->update([
'kode_lensa' => CatalogCodeService::forLensaId((int) $lensa->id),
]);
return redirect()->route('dashboard')->with('status', 'Data lensa baru berhasil ditambahkan.');
}
public function editLensaAdmin(Lensa $lensa): View
{
return view('admin.lensa_form', [
'lensa' => $lensa,
'formMode' => 'edit',
]);
}
public function updateLensaAdmin(Request $request, Lensa $lensa): RedirectResponse
{
$validated = $request->validate([
'nama' => ['required', 'string', 'max:255'],
'merek' => ['required', 'string', 'max:255'],
'harga_sewa' => ['required', 'integer', 'min:0'],
'aperture_score' => ['required', 'numeric', 'min:0', 'max:10'],
'stabilisasi' => ['required', 'numeric', 'min:0', 'max:10'],
'fleksibilitas' => ['required', 'numeric', 'min:0', 'max:10'],
]);
$lensa->update($validated);
return redirect()->route('dashboard')->with('status', 'Data lensa berhasil diperbarui.');
}
public function destroyLensaAdmin(Lensa $lensa): RedirectResponse
{
$nama = $lensa->nama;
Lensa::destroy($lensa->getKey());
return redirect()->route('dashboard')->with('status', 'Data lensa ' . $nama . ' berhasil dihapus.');
}
public function verifyUserMember(Request $request, User $user)
{
if (! $user->isUser()) {
return back()->with('error', 'Verifikasi hanya bisa dilakukan untuk akun user.');
}
$validated = $request->validate([
'member_level' => ['required', 'in:tanpa_member,bronze,silver,gold'],
]);
$user->update([
'member_level' => $validated['member_level'],
]);
return back()->with(
'status',
'Level member untuk ' . $user->name . ' berhasil diperbarui menjadi '
. Str::of($validated['member_level'])->replace('_', ' ')->title() . '.'
);
}
public function updatePenyewaanStatus(Request $request, Penyewaan $penyewaan)
{
$validated = $request->validate([
'status' => ['required', 'in:disetujui,ditolak,dikembalikan'],
]);
if ($penyewaan->status === $validated['status']) {
return back()->with('status', 'Status penyewaan sudah sesuai dan tidak ada perubahan.');
}
InventoryStockService::transaction(function () use ($penyewaan, $validated): void {
InventoryStockService::syncPenyewaanStock(
$penyewaan,
(int) $penyewaan->qty,
$validated['status']
);
$penyewaan->update([
'status' => $validated['status'],
]);
});
return back()->with(
'status',
'Pengajuan sewa atas nama ' . $penyewaan->nama_penyewa . ' berhasil '
. match ($validated['status']) {
'disetujui' => 'disetujui',
'dikembalikan' => 'ditandai sudah dikembalikan',
default => 'ditolak',
} . '.'
);
}
public function updatePenyewaanAdminNote(Request $request, Penyewaan $penyewaan): RedirectResponse
{
$validated = $request->validate([
'admin_catatan' => ['nullable', 'string', 'max:2000'],
]);
$catatanBaru = filled($validated['admin_catatan'] ?? null)
? trim((string) $validated['admin_catatan'])
: null;
$updateData = [
'admin_catatan' => $catatanBaru,
];
if ($catatanBaru !== null && $catatanBaru !== $penyewaan->admin_catatan) {
$updateData['admin_catatan_read_at'] = null;
}
if ($catatanBaru === null) {
$updateData['admin_catatan_read_at'] = null;
}
$penyewaan->update($updateData);
return back()->with('status', 'Catatan admin untuk pengajuan sewa berhasil diperbarui.');
}
public function editPenyewaan(Penyewaan $penyewaan): View
{
return view('admin.penyewaan_edit', [
'penyewaan' => $penyewaan->load(['kamera', 'lensa']),
]);
}
public function updatePenyewaan(Request $request, Penyewaan $penyewaan): RedirectResponse
{
$validated = $request->validate([
'nama_penyewa' => ['required', 'string', 'max:100'],
'email' => ['required', 'email'],
'telepon' => ['required', 'string', 'max:30'],
'no_wa' => ['required', 'string', 'max:30'],
'alamat_ktp' => ['required', 'string', 'max:500'],
'alamat_tinggal' => ['required', 'string', 'max:500'],
'instagram' => ['nullable', 'string', 'max:100'],
'jaminan' => ['required', 'string', 'max:255'],
'member_level' => ['required', 'in:tanpa_member,bronze,silver,gold'],
'qty' => ['required', 'integer', 'min:1', 'max:'.max(Kamera::DEFAULT_STOCK, Lensa::DEFAULT_STOCK)],
'tanggal_mulai' => ['required', 'date'],
'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'],
'jam_sewa' => ['required', 'date_format:H:i'],
'jam_kembali' => ['required', 'date_format:H:i'],
'metode_pembayaran' => ['required', 'in:qris,transfer_bca'],
'status' => ['required', 'in:menunggu,disetujui,ditolak,dikembalikan'],
'catatan' => ['nullable', 'string', 'max:500'],
'admin_catatan' => ['nullable', 'string', 'max:2000'],
'foto_jaminan' => ['nullable', 'image', 'mimes:jpg,jpeg,png', 'max:5120'],
'foto_diri' => ['nullable', 'image', 'mimes:jpg,jpeg,png', 'max:5120'],
'bukti_pembayaran' => ['nullable', 'file', 'mimes:jpg,jpeg,png,pdf', 'max:5120'],
]);
$durasiHari = Carbon::parse($validated['tanggal_mulai'])
->diffInDays(Carbon::parse($validated['tanggal_selesai'])) + 1;
$hargaHarian = match ($penyewaan->jenis_sewa) {
'kamera' => (int) ($penyewaan->kamera->harga_sewa ?? 0),
'lensa' => (int) ($penyewaan->lensa->harga_sewa ?? 0),
default => (int) (($penyewaan->kamera->harga_sewa ?? 0) + ($penyewaan->lensa->harga_sewa ?? 0)),
};
$memberDiskon = [
'tanpa_member' => 0.00,
'bronze' => 0.05,
'silver' => 0.10,
'gold' => 0.15,
];
$subTotal = $hargaHarian * $validated['qty'] * $durasiHari;
$diskon = (int) round($subTotal * ($memberDiskon[$validated['member_level']] ?? 0));
$totalHarga = max($subTotal - $diskon, 0);
$updateData = [
'nama_penyewa' => $validated['nama_penyewa'],
'email' => $validated['email'],
'telepon' => $validated['telepon'],
'no_wa' => $validated['no_wa'],
'alamat_ktp' => $validated['alamat_ktp'],
'alamat_tinggal' => $validated['alamat_tinggal'],
'instagram' => $validated['instagram'] ?? null,
'jaminan' => $validated['jaminan'],
'member_level' => $validated['member_level'],
'qty' => $validated['qty'],
'tanggal_mulai' => $validated['tanggal_mulai'],
'jam_sewa' => $validated['jam_sewa'],
'tanggal_selesai' => $validated['tanggal_selesai'],
'jam_kembali' => $validated['jam_kembali'],
'durasi_hari' => $durasiHari,
'total_harga' => $totalHarga,
'metode_pembayaran' => $validated['metode_pembayaran'],
'catatan' => $validated['catatan'] ?? null,
'admin_catatan' => filled($validated['admin_catatan'] ?? null) ? trim((string) $validated['admin_catatan']) : null,
'status' => $validated['status'],
];
if (($updateData['admin_catatan'] ?? null) !== $penyewaan->admin_catatan) {
$updateData['admin_catatan_read_at'] = $updateData['admin_catatan'] ? null : null;
}
if ($request->hasFile('foto_jaminan')) {
if ($penyewaan->foto_jaminan_path) {
Storage::disk('public')->delete($penyewaan->foto_jaminan_path);
}
$updateData['foto_jaminan_path'] = $request->file('foto_jaminan')->store('foto-jaminan', 'public');
}
if ($request->hasFile('foto_diri')) {
if ($penyewaan->foto_diri_path) {
Storage::disk('public')->delete($penyewaan->foto_diri_path);
}
$updateData['foto_diri_path'] = $request->file('foto_diri')->store('foto-diri', 'public');
}
if ($request->hasFile('bukti_pembayaran')) {
if ($penyewaan->bukti_pembayaran_path) {
Storage::disk('public')->delete($penyewaan->bukti_pembayaran_path);
}
$updateData['bukti_pembayaran_path'] = $request->file('bukti_pembayaran')->store('bukti-pembayaran', 'public');
}
InventoryStockService::transaction(function () use ($penyewaan, $validated, $updateData): void {
InventoryStockService::syncPenyewaanStock(
$penyewaan,
(int) $validated['qty'],
$validated['status']
);
$penyewaan->update($updateData);
});
return redirect()->route('dashboard')->with('status', 'Pengajuan sewa berhasil diperbarui.');
}
public function destroyPenyewaan(Penyewaan $penyewaan): RedirectResponse
{
if ($penyewaan->foto_jaminan_path) {
Storage::disk('public')->delete($penyewaan->foto_jaminan_path);
}
if ($penyewaan->foto_diri_path) {
Storage::disk('public')->delete($penyewaan->foto_diri_path);
}
if ($penyewaan->bukti_pembayaran_path) {
Storage::disk('public')->delete($penyewaan->bukti_pembayaran_path);
}
$namaPenyewa = $penyewaan->nama_penyewa;
InventoryStockService::transaction(function () use ($penyewaan): void {
InventoryStockService::restoreForDeletion($penyewaan);
Penyewaan::destroy($penyewaan->getKey());
});
return redirect()->route('dashboard')->with('status', 'Pengajuan sewa atas nama ' . $namaPenyewa . ' berhasil dihapus.');
}
}