parent
40cc6d7128
commit
28a9d9c1d5
|
|
@ -0,0 +1,18 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[docker-compose.yml]
|
||||||
|
indent_size = 4
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
APP_NAME=Laravel
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=laravel
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
BROADCAST_DRIVER=log
|
||||||
|
CACHE_DRIVER=file
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=sync
|
||||||
|
SESSION_DRIVER=file
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=smtp
|
||||||
|
MAIL_HOST=mailpit
|
||||||
|
MAIL_PORT=1025
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_ENCRYPTION=null
|
||||||
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
PUSHER_APP_ID=
|
||||||
|
PUSHER_APP_KEY=
|
||||||
|
PUSHER_APP_SECRET=
|
||||||
|
PUSHER_HOST=
|
||||||
|
PUSHER_PORT=443
|
||||||
|
PUSHER_SCHEME=https
|
||||||
|
PUSHER_APP_CLUSTER=mt1
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
CAR_RECOMMENDATION_API_URL=http://127.0.0.1:5000
|
||||||
|
CAR_RECOMMENDATION_API_TIMEOUT=3
|
||||||
|
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||||
|
VITE_PUSHER_HOST="${PUSHER_HOST}"
|
||||||
|
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
||||||
|
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
||||||
|
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||||
|
|
||||||
|
WHATSAPP_API_BASE_URL=https://graph.facebook.com
|
||||||
|
WHATSAPP_API_VERSION=v23.0
|
||||||
|
WHATSAPP_ACCESS_TOKEN=
|
||||||
|
WHATSAPP_PHONE_NUMBER_ID=
|
||||||
|
WHATSAPP_ADMIN_RECIPIENT=
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
/.phpunit.cache
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/vendor
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpunit.result.cache
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
auth.json
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
/.fleet
|
||||||
|
/.idea
|
||||||
|
/.vscode
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
<IfModule mod_negotiation.c>
|
||||||
|
Options -MultiViews -Indexes
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Deny direct access to Blade templates and sensitive files/folders
|
||||||
|
RewriteRule (^|/).*\.blade\.php$ - [F,L]
|
||||||
|
RewriteRule (^|/)\.env($|/) - [F,L]
|
||||||
|
RewriteRule (^|/)\.git - [F,L]
|
||||||
|
RewriteRule (^|/)composer\.(json|lock)$ - [F,L]
|
||||||
|
RewriteRule ^package(-lock)?\.json$ - [F,L]
|
||||||
|
RewriteRule ^vite\.config\.js$ - [F,L]
|
||||||
|
RewriteRule ^phpunit\.xml$ - [F,L]
|
||||||
|
RewriteRule ^artisan$ - [F,L]
|
||||||
|
RewriteRule ^tests/ - [F,L]
|
||||||
|
RewriteRule ^node_modules/ - [F,L]
|
||||||
|
RewriteRule ^database/ - [F,L]
|
||||||
|
RewriteRule ^storage/(app|framework|logs)/ - [F,L]
|
||||||
|
RewriteRule ^resources/ - [F,L]
|
||||||
|
RewriteRule ^app/ - [F,L]
|
||||||
|
RewriteRule ^bootstrap/ - [F,L]
|
||||||
|
RewriteRule ^vendor/ - [F,L]
|
||||||
|
RewriteRule ^public/ - [F,L]
|
||||||
|
|
||||||
|
# Handle Authorization Header
|
||||||
|
RewriteCond %{HTTP:Authorization} .
|
||||||
|
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||||
|
|
||||||
|
# Redirect Trailing Slashes If Not A Folder...
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_URI} (.+)/$
|
||||||
|
RewriteRule ^ %1 [L,R=301]
|
||||||
|
|
||||||
|
# Serve static assets from public/ if file exists
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{DOCUMENT_ROOT}/E31231762/public/$1 -f
|
||||||
|
RewriteRule ^(.*)$ public/$1 [L]
|
||||||
|
|
||||||
|
# Send Requests To Front Controller (index.php in root)
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
|
</IfModule>
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
# Admin Showroom WhatsApp Configuration
|
||||||
|
|
||||||
|
Untuk sistem showroom pribadi dengan satu admin, masukkan nomor WhatsApp admin Anda di file `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ADMIN_WHATSAPP_NUMBER=081234567890
|
||||||
|
ADMIN_NAME=JMW Showroom
|
||||||
|
```
|
||||||
|
|
||||||
|
## Langkah Setup
|
||||||
|
|
||||||
|
### 1. Update `.env` File
|
||||||
|
|
||||||
|
Edit file `.env` di root folder project:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Showroom Admin WhatsApp
|
||||||
|
ADMIN_WHATSAPP_NUMBER=081234567890 # Ganti dengan nomor WhatsApp admin Anda
|
||||||
|
ADMIN_NAME=JMW Showroom # Ganti dengan nama showroom Anda
|
||||||
|
```
|
||||||
|
|
||||||
|
**Format Nomor Telepon yang Diterima:**
|
||||||
|
- ✅ `081234567890` (Indonesia mobile)
|
||||||
|
- ✅ `08-1234-567890` (dengan separator)
|
||||||
|
- ✅ `+6281234567890` (international)
|
||||||
|
- ✅ `6281234567890` (country code tanpa +)
|
||||||
|
|
||||||
|
Semua format akan otomatis dikonversi ke `62xxxxxxxxxx` untuk WhatsApp.
|
||||||
|
|
||||||
|
### 2. Jalankan Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Selesai! ✅
|
||||||
|
|
||||||
|
Tombol "💬 Chat Admin via WhatsApp" akan otomatis tampil di setiap halaman detail mobil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cara Kerja
|
||||||
|
|
||||||
|
**Ketika customer klik tombol "Chat Admin via WhatsApp":**
|
||||||
|
|
||||||
|
1. ✅ API mengambil nomor WhatsApp dari `.env` (`ADMIN_WHATSAPP_NUMBER`)
|
||||||
|
2. ✅ API mengambil nama mobil dari kategori
|
||||||
|
3. ✅ Membuat pesan otomatis dengan nama mobil
|
||||||
|
4. ✅ Redirect ke WhatsApp Web/App dengan pesan:
|
||||||
|
|
||||||
|
```
|
||||||
|
Halo, saya tertarik dengan kendaraan bekas - [Nama Mobil]. Apakah masih tersedia?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints (Admin Showroom)
|
||||||
|
|
||||||
|
### 1. Dapatkan Link WhatsApp Admin
|
||||||
|
```
|
||||||
|
GET /api/chat/admin-whatsapp/{categoryId?}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Contoh:**
|
||||||
|
```
|
||||||
|
GET /api/chat/admin-whatsapp/5
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Link WhatsApp berhasil dibuat",
|
||||||
|
"data": {
|
||||||
|
"admin_name": "JMW Showroom",
|
||||||
|
"phone_number": "081234567890",
|
||||||
|
"whatsapp_link": "https://wa.me/6281234567890?text=...",
|
||||||
|
"car_name": "Toyota Avanza 2015"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Direct Redirect ke WhatsApp Admin
|
||||||
|
```
|
||||||
|
GET /api/contact/admin-whatsapp/{categoryId?}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Contoh:**
|
||||||
|
```
|
||||||
|
GET /api/contact/admin-whatsapp/5
|
||||||
|
```
|
||||||
|
|
||||||
|
*Secara otomatis membuka WhatsApp tanpa response JSON*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tidak Perlu Melakukan Ini
|
||||||
|
|
||||||
|
❌ Tidak perlu menambahkan `seller_username` ke mobil
|
||||||
|
❌ Tidak perlu menyimpan nomor telepon untuk setiap user
|
||||||
|
❌ Tidak perlu update data mobil - semua langsung ke admin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### ❌ "Nomor WhatsApp admin tidak dikonfigurasi"
|
||||||
|
**Penyebab:** `ADMIN_WHATSAPP_NUMBER` tidak ada atau kosong di `.env`
|
||||||
|
**Solusi:** Pastikan sudah menambahkan nomor di `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADMIN_WHATSAPP_NUMBER=081234567890
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ WhatsApp tidak terbuka
|
||||||
|
**Penyebab:**
|
||||||
|
- Browser block popup
|
||||||
|
- WhatsApp tidak terinstall/login
|
||||||
|
- Format nomor tidak valid
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
- Pastikan format nomor benar: `08xxxxxxxxxx` atau `+628xxxxxxxxxx`
|
||||||
|
- Pastikan WhatsApp Web sudah login
|
||||||
|
- Coba di browser lain atau incognito mode
|
||||||
|
|
||||||
|
### ❌ Error 422
|
||||||
|
**Penyebab:** Nomor WhatsApp admin tidak valid
|
||||||
|
**Solusi:** Check kembali format nomor telepon di `.env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test dengan curl:
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8000/api/chat/admin-whatsapp/5"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Link WhatsApp berhasil dibuat",
|
||||||
|
"data": {
|
||||||
|
"admin_name": "JMW Showroom",
|
||||||
|
"phone_number": "081234567890",
|
||||||
|
"whatsapp_link": "https://wa.me/6281234567890?text=Halo%2C%20saya%20tertarik%20dengan%20kendaraan%20bekas%20-%20Toyota%20Avanza%202015.%20Apakah%20masih%20tersedia%3F",
|
||||||
|
"car_name": "Toyota Avanza 2015"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom Integration
|
||||||
|
|
||||||
|
Jika ingin membuat tombol WhatsApp di tempat lain:
|
||||||
|
|
||||||
|
### HTML Button:
|
||||||
|
```html
|
||||||
|
<button onclick="contactAdminWhatsApp(5)" class="btn btn-primary">
|
||||||
|
💬 Chat Admin
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Function:
|
||||||
|
```javascript
|
||||||
|
function contactAdminWhatsApp(categoryId) {
|
||||||
|
window.location.href = `/api/contact/admin-whatsapp/${categoryId}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Function:
|
||||||
|
```javascript
|
||||||
|
async function getAdminWhatsAppLink(categoryId) {
|
||||||
|
const response = await fetch(`/api/chat/admin-whatsapp/${categoryId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
window.open(data.data.whatsapp_link, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Catatan Penting
|
||||||
|
|
||||||
|
- ✅ Sistem sepenuhnya otomatis - tidak perlu update data per mobil
|
||||||
|
- ✅ Tombol selalu tersedia untuk semua mobil
|
||||||
|
- ✅ Nomor WhatsApp admin terpusat di `.env`
|
||||||
|
- ✅ Support WhatsApp Web dan WhatsApp App
|
||||||
|
- ✅ Pesan otomatis disesuaikan dengan nama mobil
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
Jika ada pertanyaan, lihat [WHATSAPP_API_DOCUMENTATION.md](WHATSAPP_API_DOCUMENTATION.md)
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
# 🚀 WhatsApp Integration - Quick Start Guide
|
||||||
|
|
||||||
|
## Apa yang Sudah Diintegrasikan?
|
||||||
|
|
||||||
|
✅ **API WhatsApp** - Endpoint untuk mengarahkan ke WhatsApp
|
||||||
|
✅ **Car Detail Page** - Tombol "Chat via WhatsApp" pada halaman detail mobil
|
||||||
|
✅ **Database Fields** - Kolom untuk nomor telepon penjual dan username
|
||||||
|
✅ **JavaScript Handling** - Script untuk membuka WhatsApp dengan pesan otomatis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Jalankan Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Ini akan menambahkan 2 kolom baru:
|
||||||
|
- `phone_number` di tabel `user`
|
||||||
|
- `seller_username` di tabel `categories`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Update User Phone Numbers
|
||||||
|
|
||||||
|
Pastikan setiap penjual memiliki nomor telepon. Buka database atau gunakan Tinker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan tinker
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Update seller dengan nomor telepon
|
||||||
|
$user = User::find('username_penjual');
|
||||||
|
$user->update(['phone_number' => '081234567890']);
|
||||||
|
|
||||||
|
// Atau bulk update
|
||||||
|
User::where('role', 'seller')->update(['phone_number' => '081234567890']);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Tentukan Seller untuk Setiap Mobil
|
||||||
|
|
||||||
|
Ketika menambah/edit mobil di admin panel, masukkan **seller_username** dari user yang menjual mobil tersebut.
|
||||||
|
|
||||||
|
**Format Field Input:**
|
||||||
|
```
|
||||||
|
Nama Mobil: Toyota Avanza 2015
|
||||||
|
Harga: 120000000
|
||||||
|
Tahun: 2015
|
||||||
|
Penjual: john_seller ← Username dari tabel user
|
||||||
|
Gambar: [upload image]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: Test di Frontend
|
||||||
|
|
||||||
|
1. Buka halaman detail mobil: `/mobil-bekas/{car-id}`
|
||||||
|
2. Scroll ke bagian kanan (price section)
|
||||||
|
3. Klik tombol **"💬 Chat via WhatsApp"**
|
||||||
|
4. Secara otomatis akan membuka WhatsApp dengan pesan:
|
||||||
|
|
||||||
|
```
|
||||||
|
Halo, saya tertarik dengan kendaraan bekas - [Nama Mobil]. Apakah masih tersedia?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints (Dokumentasi Lengkap)
|
||||||
|
|
||||||
|
### 1. Dapatkan Link WhatsApp (Return JSON)
|
||||||
|
```
|
||||||
|
GET /api/chat/whatsapp/{username}/{categoryId}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Contoh:**
|
||||||
|
```
|
||||||
|
GET /api/chat/whatsapp/john_seller/5
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"whatsapp_link": "https://wa.me/6281234567890?text=...",
|
||||||
|
"seller_username": "john_seller",
|
||||||
|
"phone_number": "+6281234567890"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Redirect Langsung ke WhatsApp
|
||||||
|
```
|
||||||
|
GET /api/contact/whatsapp/{username}/{categoryId}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Contoh:**
|
||||||
|
```
|
||||||
|
GET /api/contact/whatsapp/john_seller/5
|
||||||
|
```
|
||||||
|
|
||||||
|
*Secara otomatis membuka WhatsApp tanpa response JSON*
|
||||||
|
|
||||||
|
### 3. Dapatkan Daftar Semua Penjual
|
||||||
|
```
|
||||||
|
GET /api/chat/sellers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"username": "john_seller",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"phone_number": "+6281234567890"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integrasi Custom (Frontend)
|
||||||
|
|
||||||
|
Jika ingin membuat tombol WhatsApp di tempat lain:
|
||||||
|
|
||||||
|
### Option 1: Link Langsung
|
||||||
|
```html
|
||||||
|
<a href="/api/contact/whatsapp/username_penjual/car_id">
|
||||||
|
Hubungi via WhatsApp
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Button dengan JavaScript
|
||||||
|
```html
|
||||||
|
<button onclick="contactSellerViaWhatsApp('username_penjual', 'car_id')">
|
||||||
|
Chat via WhatsApp
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Fungsi sudah tersedia di `car-detail.blade.php`.
|
||||||
|
|
||||||
|
### Option 3: Custom Handler
|
||||||
|
```javascript
|
||||||
|
// Dapatkan link dari API
|
||||||
|
async function getWhatsAppLink(username, categoryId) {
|
||||||
|
const response = await fetch(`/api/chat/whatsapp/${username}/${categoryId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
window.open(data.data.whatsapp_link, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### ❌ "Informasi penjual tidak tersedia"
|
||||||
|
**Penyebab:** `seller_username` tidak diset pada kategori/mobil
|
||||||
|
**Solusi:** Edit mobil dan masukkan seller_username yang valid
|
||||||
|
|
||||||
|
### ❌ "Nomor telepon penjual tidak tersedia"
|
||||||
|
**Penyebab:** User tidak memiliki `phone_number`
|
||||||
|
**Solusi:** Update field phone_number untuk user tersebut
|
||||||
|
|
||||||
|
### ❌ WhatsApp tidak terbuka
|
||||||
|
**Penyebab:**
|
||||||
|
- Browser block popup
|
||||||
|
- WhatsApp tidak terinstall/login
|
||||||
|
- Format nomor tidak valid
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
- Gunakan format: `08xxxxxxxxxx` atau `+628xxxxxxxxxx`
|
||||||
|
- Pastikan WhatsApp Web sudah login
|
||||||
|
- Coba di browser lain atau incognito mode
|
||||||
|
|
||||||
|
### ❌ Error 404 pada API
|
||||||
|
**Penyebab:** User tidak ditemukan
|
||||||
|
**Solusi:** Pastikan username benar dan user ada di database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### User Table (New Fields)
|
||||||
|
```sql
|
||||||
|
ALTER TABLE user ADD COLUMN phone_number VARCHAR(255) NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Categories Table (New Fields)
|
||||||
|
```sql
|
||||||
|
ALTER TABLE categories ADD COLUMN seller_username VARCHAR(255) NULL;
|
||||||
|
ALTER TABLE categories ADD INDEX idx_seller_username (seller_username);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- ✅ Nomor telepon otomatis diformat ke format WhatsApp (`62xxxxxxxxxx`)
|
||||||
|
- ✅ Pesan otomatis disesuaikan dengan nama mobil
|
||||||
|
- ✅ Responsive design untuk mobile
|
||||||
|
- ✅ Error handling untuk missing data
|
||||||
|
- ✅ Support untuk berbagai format nomor telepon
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dokumentasi Lengkap
|
||||||
|
|
||||||
|
Lihat: [WHATSAPP_API_DOCUMENTATION.md](WHATSAPP_API_DOCUMENTATION.md)
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## About Laravel
|
||||||
|
|
||||||
|
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||||
|
|
||||||
|
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||||
|
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||||
|
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||||
|
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||||
|
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||||
|
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||||
|
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||||
|
|
||||||
|
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||||
|
|
||||||
|
## Learning Laravel
|
||||||
|
|
||||||
|
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||||
|
|
||||||
|
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
|
||||||
|
|
||||||
|
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||||
|
|
||||||
|
## Laravel Sponsors
|
||||||
|
|
||||||
|
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||||
|
|
||||||
|
### Premium Partners
|
||||||
|
|
||||||
|
- **[Vehikl](https://vehikl.com/)**
|
||||||
|
- **[Tighten Co.](https://tighten.co)**
|
||||||
|
- **[WebReinvent](https://webreinvent.com/)**
|
||||||
|
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||||
|
- **[64 Robots](https://64robots.com)**
|
||||||
|
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
|
||||||
|
- **[Cyber-Duck](https://cyber-duck.co.uk)**
|
||||||
|
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||||
|
- **[Jump24](https://jump24.co.uk)**
|
||||||
|
- **[Redberry](https://redberry.international/laravel/)**
|
||||||
|
- **[Active Logic](https://activelogic.com)**
|
||||||
|
- **[byte5](https://byte5.de)**
|
||||||
|
- **[OP.GG](https://op.gg)**
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||||
|
|
||||||
|
## Security Vulnerabilities
|
||||||
|
|
||||||
|
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
# WhatsApp Chat API Documentation
|
||||||
|
|
||||||
|
## Daftar API Endpoints
|
||||||
|
|
||||||
|
### 1. **Dapatkan Link WhatsApp Penjual**
|
||||||
|
**Endpoint:** `GET /api/chat/whatsapp/{username}/{categoryId?}`
|
||||||
|
|
||||||
|
**Deskripsi:** Mendapatkan link WhatsApp untuk menghubungi penjual
|
||||||
|
|
||||||
|
**Parameter:**
|
||||||
|
- `username` (required) - Username penjual
|
||||||
|
- `categoryId` (optional) - ID mobil/kategori yang diminati
|
||||||
|
|
||||||
|
**Response Sukses (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Link WhatsApp berhasil dibuat",
|
||||||
|
"data": {
|
||||||
|
"seller_username": "john_seller",
|
||||||
|
"seller_name": "john_seller",
|
||||||
|
"phone_number": "+6281234567890",
|
||||||
|
"whatsapp_link": "https://wa.me/6281234567890?text=Halo...",
|
||||||
|
"car_name": "Toyota Avanza 2015"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Error (404):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Penjual tidak ditemukan"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Error (422):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Nomor telepon penjual tidak tersedia"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Contoh Request:**
|
||||||
|
```
|
||||||
|
GET http://localhost:8000/api/chat/whatsapp/john_seller/5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Dapatkan Daftar Semua Penjual**
|
||||||
|
**Endpoint:** `GET /api/chat/sellers`
|
||||||
|
|
||||||
|
**Deskripsi:** Mendapatkan list semua penjual dengan informasi kontak
|
||||||
|
|
||||||
|
**Response Sukses (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Daftar penjual berhasil diambil",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"username": "john_seller",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"phone_number": "+6281234567890"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "maria_seller",
|
||||||
|
"email": "maria@example.com",
|
||||||
|
"phone_number": "+6282345678901"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Contoh Request:**
|
||||||
|
```
|
||||||
|
GET http://localhost:8000/api/chat/sellers
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Redirect Langsung ke WhatsApp**
|
||||||
|
**Endpoint:** `GET /contact/whatsapp/{username}/{categoryId?}`
|
||||||
|
|
||||||
|
**Deskripsi:** Redirect langsung ke WhatsApp Web/App dengan pesan otomatis
|
||||||
|
|
||||||
|
**Parameter:**
|
||||||
|
- `username` (required) - Username penjual
|
||||||
|
- `categoryId` (optional) - ID mobil/kategori
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Secara otomatis membuka WhatsApp Web atau aplikasi WhatsApp yang terinstall
|
||||||
|
- Pesan otomatis dikirim ke chat window
|
||||||
|
|
||||||
|
**Contoh Request:**
|
||||||
|
```
|
||||||
|
GET http://localhost:8000/api/contact/whatsapp/john_seller/5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cara Menggunakan
|
||||||
|
|
||||||
|
### Option 1: Dari Frontend (HTML Button)
|
||||||
|
```html
|
||||||
|
<!-- Button untuk membuka WhatsApp -->
|
||||||
|
<a href="/api/contact/whatsapp/john_seller/5" class="btn btn-primary">
|
||||||
|
Hubungi Penjual via WhatsApp
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Dari Frontend (JavaScript)
|
||||||
|
```javascript
|
||||||
|
// Dapatkan link WhatsApp dari API
|
||||||
|
fetch('/api/chat/whatsapp/john_seller/5')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Buka link di tab baru
|
||||||
|
window.open(data.data.whatsapp_link, '_blank');
|
||||||
|
} else {
|
||||||
|
alert(data.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Mobile App
|
||||||
|
```javascript
|
||||||
|
// Untuk membuka WhatsApp App di mobile
|
||||||
|
const whatsappLink = "whatsapp://send?phone=6281234567890&text=Halo";
|
||||||
|
window.location.href = whatsappLink;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup & Migration
|
||||||
|
|
||||||
|
### 1. Jalankan Migration untuk menambah field phone_number
|
||||||
|
```bash
|
||||||
|
php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Ini akan menambahkan kolom `phone_number` ke tabel `user`.
|
||||||
|
|
||||||
|
### 2. Update data penjual dengan nomor telepon
|
||||||
|
```bash
|
||||||
|
php artisan tinker
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$user = User::find('john_seller');
|
||||||
|
$user->update(['phone_number' => '+6281234567890']);
|
||||||
|
```
|
||||||
|
|
||||||
|
Atau melalui database secara langsung:
|
||||||
|
```sql
|
||||||
|
UPDATE user SET phone_number = '+6281234567890' WHERE username = 'john_seller';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Format Nomor Telepon
|
||||||
|
|
||||||
|
API secara otomatis memformat nomor telepon. Nomor dapat diterima dalam format:
|
||||||
|
- ✅ `081234567890` (Indonesian mobile)
|
||||||
|
- ✅ `08-1234-567890` (dengan separator)
|
||||||
|
- ✅ `+6281234567890` (international)
|
||||||
|
- ✅ `6281234567890` (country code tanpa +)
|
||||||
|
|
||||||
|
Semua format akan dikonversi menjadi: `6281234567890` untuk link WhatsApp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pesan Otomatis
|
||||||
|
|
||||||
|
Pesan default yang dikirim:
|
||||||
|
```
|
||||||
|
Halo, saya tertarik dengan kendaraan bekas - [Nama Mobil]. Apakah masih tersedia?
|
||||||
|
```
|
||||||
|
|
||||||
|
Contoh pesan dengan car_name:
|
||||||
|
```
|
||||||
|
Halo, saya tertarik dengan kendaraan bekas - Toyota Avanza 2015. Apakah masih tersedia?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integrasi pada Car Detail Page
|
||||||
|
|
||||||
|
Tambahkan tombol "Hubungi Penjual" pada halaman detail mobil:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<!-- resources/views/user/car-detail.blade.php -->
|
||||||
|
<button onclick="contactSeller('{{ $seller->username }}', '{{ $car->id }}')">
|
||||||
|
Hubungi Penjual via WhatsApp
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function contactSeller(username, categoryId) {
|
||||||
|
window.location.href = `/api/contact/whatsapp/${username}/${categoryId}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Error 404 - Penjual tidak ditemukan:**
|
||||||
|
- Pastikan username penjual correct/exists di database
|
||||||
|
|
||||||
|
**Error 422 - Nomor telepon tidak tersedia:**
|
||||||
|
- Update field `phone_number` untuk user tersebut
|
||||||
|
- Pastikan nomor telepon dalam format yang valid
|
||||||
|
|
||||||
|
**Error 500 - Server error:**
|
||||||
|
- Cek log file di `storage/logs/`
|
||||||
|
- Pastikan semua field exists di model
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Catatan Penting
|
||||||
|
|
||||||
|
1. **Nomor Telepon Wajib:**
|
||||||
|
- User/Penjual harus memiliki nomor telepon di field `phone_number`
|
||||||
|
|
||||||
|
2. **Role Seller:**
|
||||||
|
- Untuk endpoint `/api/chat/sellers`, hanya users dengan role 'seller' yang ditampilkan
|
||||||
|
|
||||||
|
3. **WhatsApp Web:**
|
||||||
|
- Pengguna harus sudah login di WhatsApp Web
|
||||||
|
- Atau memiliki aplikasi WhatsApp yang terinstall
|
||||||
|
|
||||||
|
4. **Character Encoding:**
|
||||||
|
- Pesan otomatis di-encode dengan `urlencode()` untuk mendukung karakter khusus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Link WhatsApp tidak buka
|
||||||
|
- Pastikan WhatsApp Web sudah login atau aplikasi terinstall
|
||||||
|
- Coba akses link langsung: `https://wa.me/6281234567890`
|
||||||
|
|
||||||
|
### Nomor telepon tidak valid
|
||||||
|
- Check format nomor di database
|
||||||
|
- Gunakan format: `+62xxxxxxxxxx` atau `08xxxxxxxxxx`
|
||||||
|
|
||||||
|
### Pesan tidak muncul
|
||||||
|
- Pastikan browser memperbolehkan popup/redirect
|
||||||
|
- Coba buka di incognito mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contact untuk Support
|
||||||
|
Jika ada pertanyaan atau issue, hubungi team development.
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
|
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||||
|
|
||||||
|
class Kernel extends ConsoleKernel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the application's command schedule.
|
||||||
|
*/
|
||||||
|
protected function schedule(Schedule $schedule): void
|
||||||
|
{
|
||||||
|
$schedule->call(fn () => \App\Models\Category::releaseExpiredReservations())
|
||||||
|
->everyMinute()
|
||||||
|
->name('release-expired-car-reservations')
|
||||||
|
->withoutOverlapping();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the commands for the application.
|
||||||
|
*/
|
||||||
|
protected function commands(): void
|
||||||
|
{
|
||||||
|
$this->load(__DIR__.'/Commands');
|
||||||
|
|
||||||
|
require base_path('routes/console.php');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||||
|
use Illuminate\Session\TokenMismatchException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class Handler extends ExceptionHandler
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The list of the inputs that are never flashed to the session on validation exceptions.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $dontFlash = [
|
||||||
|
'current_password',
|
||||||
|
'password',
|
||||||
|
'password_confirmation',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the exception handling callbacks for the application.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->reportable(function (Throwable $e) {
|
||||||
|
//
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->renderable(function (TokenMismatchException $e) {
|
||||||
|
return back()
|
||||||
|
->withInput()
|
||||||
|
->with('error', 'Sesi halaman sudah berubah atau kadaluarsa. Silakan coba kirim ulang bukti transaksi.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Tampilkan halaman login.
|
||||||
|
*/
|
||||||
|
public function showLogin()
|
||||||
|
{
|
||||||
|
if (Auth::guard('admin')->check()) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Auth::check()) {
|
||||||
|
return redirect()->route('user.dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proses login.
|
||||||
|
*/
|
||||||
|
public function login(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'username' => 'required|string',
|
||||||
|
'password' => 'required|string',
|
||||||
|
], [
|
||||||
|
'username.required' => 'Username wajib diisi.',
|
||||||
|
'password.required' => 'Password wajib diisi.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$credentials = [
|
||||||
|
'username' => $request->username,
|
||||||
|
'password' => $request->password,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (Auth::guard('admin')->attempt($credentials)) {
|
||||||
|
$request->session()->regenerate();
|
||||||
|
return redirect()->intended(route('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Auth::attempt($credentials)) {
|
||||||
|
$request->session()->regenerate();
|
||||||
|
return redirect()->intended(route('user.dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()
|
||||||
|
->withInput($request->only('username'))
|
||||||
|
->with('error', 'Username atau password salah.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tampilkan halaman register.
|
||||||
|
*/
|
||||||
|
public function showRegister()
|
||||||
|
{
|
||||||
|
return view('register');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proses registrasi user baru.
|
||||||
|
*/
|
||||||
|
public function register(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'username' => 'required|string|max:255|unique:user,username',
|
||||||
|
'email' => 'required|email|max:255|unique:user,email',
|
||||||
|
'password' => 'required|string|min:6|confirmed',
|
||||||
|
], [
|
||||||
|
'username.required' => 'Username wajib diisi.',
|
||||||
|
'username.unique' => 'Username sudah digunakan.',
|
||||||
|
'email.required' => 'Email wajib diisi.',
|
||||||
|
'email.unique' => 'Email sudah terdaftar.',
|
||||||
|
'password.required' => 'Password wajib diisi.',
|
||||||
|
'password.min' => 'Password minimal 6 karakter.',
|
||||||
|
'password.confirmed' => 'Konfirmasi password tidak cocok.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'username' => $request->username,
|
||||||
|
'email' => $request->email,
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
'role' => 'user',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('login')->with('success', 'Registrasi berhasil. Silakan login dengan akun Anda.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tampilkan halaman lupa password.
|
||||||
|
*/
|
||||||
|
public function showForgotPassword()
|
||||||
|
{
|
||||||
|
return view('forgot-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kirim link reset password ke email user.
|
||||||
|
*/
|
||||||
|
public function sendResetPasswordLink(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'email' => 'required|email|exists:user,email',
|
||||||
|
], [
|
||||||
|
'email.required' => 'Email wajib diisi.',
|
||||||
|
'email.email' => 'Format email tidak valid.',
|
||||||
|
'email.exists' => 'Email tidak terdaftar.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = Password::sendResetLink($request->only('email'));
|
||||||
|
|
||||||
|
if ($status === Password::RESET_LINK_SENT) {
|
||||||
|
return back()->with('success', 'Link verifikasi reset password sudah dikirim ke email Anda.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()
|
||||||
|
->withInput($request->only('email'))
|
||||||
|
->with('error', 'Link reset password gagal dikirim. Silakan coba lagi.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tampilkan halaman password baru dari link email.
|
||||||
|
*/
|
||||||
|
public function showResetPassword(Request $request, string $token)
|
||||||
|
{
|
||||||
|
return view('reset-password', [
|
||||||
|
'token' => $token,
|
||||||
|
'email' => $request->email,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simpan password baru.
|
||||||
|
*/
|
||||||
|
public function resetPassword(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'token' => 'required',
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required|string|min:6|confirmed',
|
||||||
|
], [
|
||||||
|
'password.required' => 'Password baru wajib diisi.',
|
||||||
|
'password.min' => 'Password minimal 6 karakter.',
|
||||||
|
'password.confirmed' => 'Konfirmasi password tidak cocok.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = Password::reset(
|
||||||
|
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||||
|
function (User $user, string $password) {
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($password),
|
||||||
|
'login_otp' => null,
|
||||||
|
'login_otp_expires_at' => null,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($status === Password::PASSWORD_RESET) {
|
||||||
|
return redirect()->route('login')->with('success', 'Password berhasil diubah. Silakan login.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()
|
||||||
|
->withInput($request->only('email'))
|
||||||
|
->with('error', 'Link reset password tidak valid atau sudah kedaluwarsa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proses logout.
|
||||||
|
*/
|
||||||
|
public function logout(Request $request)
|
||||||
|
{
|
||||||
|
if (Auth::guard('admin')->check()) {
|
||||||
|
Auth::guard('admin')->logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Auth::check()) {
|
||||||
|
Auth::logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Favorite;
|
||||||
|
use App\Services\CarRecommendationService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class CategoryController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
|
||||||
|
$categories = Category::orderBy('created_at', 'desc')->get();
|
||||||
|
|
||||||
|
return view('admin.kategori', compact('categories'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'description' => 'required|string|max:1000',
|
||||||
|
'merk' => 'nullable|string|max:255',
|
||||||
|
'model' => 'nullable|string|max:255',
|
||||||
|
'kategori' => 'nullable|string|max:255',
|
||||||
|
'price' => 'required|numeric|min:0',
|
||||||
|
'year' => 'required|integer|min:1900|max:' . date('Y'),
|
||||||
|
'mesin_cc' => 'nullable|integer|min:0',
|
||||||
|
'transmisi' => 'nullable|string|max:255',
|
||||||
|
'bahan_bakar' => 'nullable|string|max:255',
|
||||||
|
'status' => 'required|in:available,reserved,sold',
|
||||||
|
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||||
|
], [
|
||||||
|
'name.required' => 'Nama kategori wajib diisi.',
|
||||||
|
'description.required' => 'Deskripsi kategori wajib diisi.',
|
||||||
|
'price.required' => 'Harga mobil wajib diisi.',
|
||||||
|
'price.numeric' => 'Harga mobil harus berupa angka.',
|
||||||
|
'price.min' => 'Harga mobil tidak boleh negatif.',
|
||||||
|
'year.required' => 'Tahun mobil wajib diisi.',
|
||||||
|
'year.integer' => 'Tahun mobil harus berupa angka.',
|
||||||
|
'year.min' => 'Tahun mobil tidak valid.',
|
||||||
|
'year.max' => 'Tahun mobil tidak valid.',
|
||||||
|
'status.required' => 'Status mobil wajib dipilih.',
|
||||||
|
'status.in' => 'Status mobil tidak valid.',
|
||||||
|
'image.image' => 'File harus berupa gambar.',
|
||||||
|
'image.mimes' => 'Format gambar harus jpeg, png, jpg, atau gif.',
|
||||||
|
'image.max' => 'Ukuran gambar maksimal 2MB.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->hasFile('image')) {
|
||||||
|
$validated['image'] = $request->file('image')->store('categories', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
Category::create($validated);
|
||||||
|
|
||||||
|
return redirect()->route('kategori')->with('success', 'Kategori berhasil ditambahkan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Category $category)
|
||||||
|
{
|
||||||
|
return view('admin.kategori-edit', compact('category'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Category $category)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'description' => 'required|string|max:1000',
|
||||||
|
'merk' => 'nullable|string|max:255',
|
||||||
|
'model' => 'nullable|string|max:255',
|
||||||
|
'kategori' => 'nullable|string|max:255',
|
||||||
|
'price' => 'required|numeric|min:0',
|
||||||
|
'year' => 'required|integer|min:1900|max:' . date('Y'),
|
||||||
|
'mesin_cc' => 'nullable|integer|min:0',
|
||||||
|
'transmisi' => 'nullable|string|max:255',
|
||||||
|
'bahan_bakar' => 'nullable|string|max:255',
|
||||||
|
'status' => 'required|in:available,reserved,sold',
|
||||||
|
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||||
|
], [
|
||||||
|
'name.required' => 'Nama kategori wajib diisi.',
|
||||||
|
'description.required' => 'Deskripsi kategori wajib diisi.',
|
||||||
|
'price.required' => 'Harga mobil wajib diisi.',
|
||||||
|
'price.numeric' => 'Harga mobil harus berupa angka.',
|
||||||
|
'price.min' => 'Harga mobil tidak boleh negatif.',
|
||||||
|
'year.required' => 'Tahun mobil wajib diisi.',
|
||||||
|
'year.integer' => 'Tahun mobil harus berupa angka.',
|
||||||
|
'year.min' => 'Tahun mobil tidak valid.',
|
||||||
|
'year.max' => 'Tahun mobil tidak valid.',
|
||||||
|
'status.required' => 'Status mobil wajib dipilih.',
|
||||||
|
'status.in' => 'Status mobil tidak valid.',
|
||||||
|
'image.image' => 'File harus berupa gambar.',
|
||||||
|
'image.mimes' => 'Format gambar harus jpeg, png, jpg, atau gif.',
|
||||||
|
'image.max' => 'Ukuran gambar maksimal 2MB.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->hasFile('image')) {
|
||||||
|
if ($category->image) {
|
||||||
|
Storage::disk('public')->delete($category->image);
|
||||||
|
}
|
||||||
|
$validated['image'] = $request->file('image')->store('categories', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
$category->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('kategori')->with('success', 'Kategori berhasil diperbarui.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Category $category)
|
||||||
|
{
|
||||||
|
if ($category->image) {
|
||||||
|
Storage::disk('public')->delete($category->image);
|
||||||
|
}
|
||||||
|
|
||||||
|
$category->delete();
|
||||||
|
|
||||||
|
return redirect()->route('kategori')->with('success', 'Kategori berhasil dihapus.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userIndex(Request $request, CarRecommendationService $recommendationService)
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
|
||||||
|
$budget = $request->query('budget');
|
||||||
|
$budgetValue = is_numeric($budget) ? (float) $budget : null;
|
||||||
|
$year = $request->query('year');
|
||||||
|
$yearValue = is_numeric($year) ? (int) $year : null;
|
||||||
|
$mesinCc = $request->query('mesin_cc');
|
||||||
|
$mesinCcValue = is_numeric($mesinCc) ? (int) $mesinCc : null;
|
||||||
|
|
||||||
|
$categories = Category::where('status', 'available')->orderBy('created_at', 'desc')->get();
|
||||||
|
$hasExactBudgetMatch = false;
|
||||||
|
$recommendationSource = null;
|
||||||
|
$recommendationMeta = null;
|
||||||
|
|
||||||
|
if ($budgetValue) {
|
||||||
|
$recommendation = $recommendationService->recommendForBudget($budgetValue, 50, [
|
||||||
|
'year' => $yearValue,
|
||||||
|
'mesin_cc' => $mesinCcValue,
|
||||||
|
]);
|
||||||
|
$recommended = $recommendation['cars'];
|
||||||
|
$hasExactBudgetMatch = $recommendation['has_exact_budget_match'];
|
||||||
|
$recommendationSource = $recommendation['source'];
|
||||||
|
$recommendationMeta = $recommendation['meta'];
|
||||||
|
} else {
|
||||||
|
$recommended = Category::where('status', 'available')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('user.cars', compact(
|
||||||
|
'categories',
|
||||||
|
'recommended',
|
||||||
|
'budget',
|
||||||
|
'year',
|
||||||
|
'mesinCc',
|
||||||
|
'hasExactBudgetMatch',
|
||||||
|
'recommendationSource',
|
||||||
|
'recommendationMeta'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allCars()
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
|
||||||
|
$categories = Category::where('status', 'available')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(12);
|
||||||
|
|
||||||
|
return view('user.all-cars', compact('categories'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function budgetCars(Request $request, CarRecommendationService $recommendationService)
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
|
||||||
|
$budget = $request->query('budget');
|
||||||
|
$budgetValue = is_numeric($budget) ? (float) $budget : null;
|
||||||
|
$year = $request->query('year');
|
||||||
|
$yearValue = is_numeric($year) ? (int) $year : null;
|
||||||
|
$mesinCc = $request->query('mesin_cc');
|
||||||
|
$mesinCcValue = is_numeric($mesinCc) ? (int) $mesinCc : null;
|
||||||
|
$availableCount = Category::where('status', 'available')->count();
|
||||||
|
$hasExactBudgetMatch = false;
|
||||||
|
$recommendationSource = null;
|
||||||
|
$recommendationMeta = null;
|
||||||
|
$recommended = collect();
|
||||||
|
|
||||||
|
if ($budgetValue) {
|
||||||
|
$recommendation = $recommendationService->recommendForBudget($budgetValue, 50, [
|
||||||
|
'year' => $yearValue,
|
||||||
|
'mesin_cc' => $mesinCcValue,
|
||||||
|
]);
|
||||||
|
$recommended = $recommendation['cars'];
|
||||||
|
$hasExactBudgetMatch = $recommendation['has_exact_budget_match'];
|
||||||
|
$recommendationSource = $recommendation['source'];
|
||||||
|
$recommendationMeta = $recommendation['meta'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('user.budget-cars', compact(
|
||||||
|
'budget',
|
||||||
|
'year',
|
||||||
|
'mesinCc',
|
||||||
|
'availableCount',
|
||||||
|
'recommended',
|
||||||
|
'hasExactBudgetMatch',
|
||||||
|
'recommendationSource',
|
||||||
|
'recommendationMeta'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Category $category)
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
$category->refresh();
|
||||||
|
|
||||||
|
return view('user.car-detail', compact('category'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addToFavorites(Category $category)
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
$category->refresh();
|
||||||
|
|
||||||
|
$username = auth()->user()->username;
|
||||||
|
|
||||||
|
if ($category->status !== 'available') {
|
||||||
|
return response()->json(['message' => 'Mobil ini sedang tidak tersedia.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already favorited
|
||||||
|
if (Favorite::where('username', $username)->where('category_id', $category->id)->exists()) {
|
||||||
|
return response()->json(['message' => 'Already in favorites'], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
Favorite::create([
|
||||||
|
'username' => $username,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Added to favorites']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeFromFavorites(Category $category)
|
||||||
|
{
|
||||||
|
$username = auth()->user()->username;
|
||||||
|
|
||||||
|
Favorite::where('username', $username)
|
||||||
|
->where('category_id', $category->id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Removed from favorites']);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Services\WhatsAppBusinessService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ChatController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var \App\Services\WhatsAppBusinessService
|
||||||
|
*/
|
||||||
|
private $whatsAppBusinessService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \App\Services\WhatsAppBusinessService $whatsAppBusinessService
|
||||||
|
*/
|
||||||
|
public function __construct(WhatsAppBusinessService $whatsAppBusinessService)
|
||||||
|
{
|
||||||
|
$this->whatsAppBusinessService = $whatsAppBusinessService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get WhatsApp link for admin showroom
|
||||||
|
*
|
||||||
|
* @param int $categoryId - The category/car ID (optional)
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function getAdminWhatsAppLink($categoryId = null)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Get admin phone number from centralized service configuration.
|
||||||
|
$phoneNumber = config('services.whatsapp.admin_recipient');
|
||||||
|
$adminName = config('services.whatsapp.admin_name', 'Showroom');
|
||||||
|
|
||||||
|
if (!$phoneNumber) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Nomor WhatsApp admin tidak dikonfigurasi',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$carName = null;
|
||||||
|
$carImageUrl = null;
|
||||||
|
$carDetailUrl = null;
|
||||||
|
$category = null;
|
||||||
|
if ($categoryId) {
|
||||||
|
$category = Category::find($categoryId);
|
||||||
|
if ($category) {
|
||||||
|
$carName = $category->name;
|
||||||
|
$carImageUrl = $this->getCarImageUrl($category);
|
||||||
|
$carDetailUrl = $this->getCarDetailUrl($category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format phone number
|
||||||
|
$formattedPhone = $this->formatPhoneNumber($phoneNumber);
|
||||||
|
|
||||||
|
// Create WhatsApp message
|
||||||
|
$message = $this->buildWhatsAppMessage($category, $carImageUrl, $carDetailUrl);
|
||||||
|
$encodedMessage = urlencode($message);
|
||||||
|
|
||||||
|
// WhatsApp Web link format
|
||||||
|
$whatsappLink = "https://wa.me/{$formattedPhone}?text={$encodedMessage}";
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Link WhatsApp berhasil dibuat',
|
||||||
|
'data' => [
|
||||||
|
'admin_name' => $adminName,
|
||||||
|
'phone_number' => $phoneNumber,
|
||||||
|
'whatsapp_link' => $whatsappLink,
|
||||||
|
'car_name' => $carName,
|
||||||
|
'car_image_url' => $carImageUrl,
|
||||||
|
'car_detail_url' => $carDetailUrl,
|
||||||
|
]
|
||||||
|
], 200);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Terjadi kesalahan: ' . $e->getMessage(),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct redirect to WhatsApp admin.
|
||||||
|
*
|
||||||
|
* @param int $categoryId - The category/car ID (optional)
|
||||||
|
* @return \Illuminate\Http\RedirectResponse
|
||||||
|
*/
|
||||||
|
public function redirectAdminWhatsApp($categoryId = null)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$phoneNumber = config('services.whatsapp.admin_recipient');
|
||||||
|
|
||||||
|
if (!$phoneNumber) {
|
||||||
|
return redirect()->back()->with('error', 'Nomor WhatsApp admin tidak dikonfigurasi');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format phone number
|
||||||
|
$formattedPhone = $this->formatPhoneNumber($phoneNumber);
|
||||||
|
|
||||||
|
$carImageUrl = null;
|
||||||
|
$carDetailUrl = null;
|
||||||
|
$category = null;
|
||||||
|
if ($categoryId) {
|
||||||
|
$category = Category::find($categoryId);
|
||||||
|
if ($category) {
|
||||||
|
$carImageUrl = $this->getCarImageUrl($category);
|
||||||
|
$carDetailUrl = $this->getCarDetailUrl($category);
|
||||||
|
$this->sendBusinessImageNotification($category, auth()->user());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create message
|
||||||
|
$message = $this->buildWhatsAppMessage($category, $carImageUrl, $carDetailUrl);
|
||||||
|
$encodedMessage = urlencode($message);
|
||||||
|
|
||||||
|
// Redirect to WhatsApp, but keep catalog/detail URLs based on this local app host.
|
||||||
|
$whatsappLink = "https://wa.me/{$formattedPhone}?text={$encodedMessage}";
|
||||||
|
return redirect($whatsappLink);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return redirect()->back()->with('error', 'Terjadi kesalahan saat membuka WhatsApp');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a WhatsApp Business notification with the car image to admin.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
* @param int $categoryId
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function notifyAdminWithCarImage(Request $request, $categoryId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$category = Category::find($categoryId);
|
||||||
|
|
||||||
|
if (!$category) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Mobil tidak ditemukan.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->whatsAppBusinessService->sendCarNotificationToAdmin(
|
||||||
|
$category,
|
||||||
|
$request->user()
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($result, $result['success'] ? 200 : 422);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Terjadi kesalahan saat mengirim notifikasi ke admin.',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format phone number to WhatsApp format
|
||||||
|
* Converts various formats to 62xxxxxxxxxx
|
||||||
|
*
|
||||||
|
* @param string $phoneNumber
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function formatPhoneNumber($phoneNumber)
|
||||||
|
{
|
||||||
|
// Remove all non-numeric characters
|
||||||
|
$phoneNumber = preg_replace('/[^0-9]/', '', $phoneNumber);
|
||||||
|
|
||||||
|
// Handle Indonesian phone numbers
|
||||||
|
// 081234567890 -> 6281234567890
|
||||||
|
if (strlen($phoneNumber) == 10 && substr($phoneNumber, 0, 1) == '8') {
|
||||||
|
$phoneNumber = '62' . $phoneNumber;
|
||||||
|
}
|
||||||
|
// 08xxxxxxxxxx -> 62xxxxxxxxxx
|
||||||
|
elseif (strlen($phoneNumber) >= 11 && substr($phoneNumber, 0, 2) == '08') {
|
||||||
|
$phoneNumber = '62' . substr($phoneNumber, 1);
|
||||||
|
}
|
||||||
|
// If doesn't start with 62, prepend it
|
||||||
|
elseif (substr($phoneNumber, 0, 2) != '62') {
|
||||||
|
$phoneNumber = '62' . $phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build WhatsApp message with optional car image link.
|
||||||
|
*
|
||||||
|
* @param \App\Models\Category|null $category
|
||||||
|
* @param string|null $carImageUrl
|
||||||
|
* @param string|null $carDetailUrl
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function buildWhatsAppMessage(?Category $category = null, $carImageUrl = null, $carDetailUrl = null)
|
||||||
|
{
|
||||||
|
if (!$category) {
|
||||||
|
return "Halo admin, saya tertarik dengan salah satu mobil bekas yang ada di website. Apakah masih tersedia?";
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = [
|
||||||
|
'Halo admin, saya tertarik dengan mobil katalog ini.',
|
||||||
|
'',
|
||||||
|
'Nama mobil: ' . $category->name,
|
||||||
|
'Harga: Rp ' . number_format((float) $category->price, 0, ',', '.'),
|
||||||
|
'Tahun: ' . ($category->year ?: '-'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($carImageUrl) {
|
||||||
|
$lines[] = 'Foto katalog: ' . $carImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($carDetailUrl) {
|
||||||
|
$lines[] = 'Link detail: ' . $carDetailUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = 'Apakah mobil ini masih tersedia?';
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get absolute public URL for the car image if available.
|
||||||
|
*
|
||||||
|
* @param \App\Models\Category $category
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private function getCarImageUrl(Category $category)
|
||||||
|
{
|
||||||
|
if (!$category->image) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->publicUrl(Storage::disk('public')->url($category->image), $this->getClickableBaseUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get absolute detail page URL for the car.
|
||||||
|
*
|
||||||
|
* @param \App\Models\Category $category
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getCarDetailUrl(Category $category)
|
||||||
|
{
|
||||||
|
return $this->publicUrl('/mobil-bekas/' . $category->id, $this->getClickableBaseUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an absolute URL from the current local app host.
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function publicUrl($path, $baseUrl = null)
|
||||||
|
{
|
||||||
|
if (filter_var($path, FILTER_VALIDATE_URL)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = $baseUrl ?: url('/');
|
||||||
|
|
||||||
|
return rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer a public local tunnel URL for WhatsApp, because WhatsApp often
|
||||||
|
* does not turn 127.0.0.1/localhost links into clickable links.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getClickableBaseUrl()
|
||||||
|
{
|
||||||
|
$requestUrl = rtrim(request()->getSchemeAndHttpHost(), '/');
|
||||||
|
|
||||||
|
if ($requestUrl) {
|
||||||
|
return $requestUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
$configuredUrl = rtrim((string) config('app.url'), '/');
|
||||||
|
|
||||||
|
if ($configuredUrl && ! preg_match('/^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/i', $configuredUrl)) {
|
||||||
|
return $configuredUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send selected catalog image through WhatsApp Business Cloud API.
|
||||||
|
* The direct WhatsApp redirect still works if the API send fails.
|
||||||
|
*/
|
||||||
|
private function sendBusinessImageNotification(Category $category, $customer = null)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($this->whatsAppBusinessService->isConfigured()) {
|
||||||
|
$this->whatsAppBusinessService->sendCarNotificationToAdmin($category, $customer);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||||
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
|
||||||
|
class Controller extends BaseController
|
||||||
|
{
|
||||||
|
use AuthorizesRequests, ValidatesRequests;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class TransactionController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
|
||||||
|
$transactions = Transaction::with('category')->latest()->get();
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'total' => $transactions->count(),
|
||||||
|
'paid' => $transactions->where('payment_status', 'paid')->count(),
|
||||||
|
'sales' => $transactions->where('payment_status', 'paid')->sum('total_amount'),
|
||||||
|
'today' => $transactions->where('created_at', '>=', today())->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('admin.riwayat', compact('transactions', 'summary'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
|
||||||
|
$categories = Category::where('status', 'available')->orderBy('name')->get();
|
||||||
|
$transactionCode = $this->generateTransactionCode();
|
||||||
|
|
||||||
|
return view('admin.input', compact('categories', 'transactionCode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'customer_name' => 'required|string|max:255',
|
||||||
|
'customer_phone' => 'nullable|string|max:30',
|
||||||
|
'customer_email' => 'nullable|email|max:255',
|
||||||
|
'transaction_code' => 'nullable|string|max:30|unique:transactions,transaction_code',
|
||||||
|
'category_id' => 'required|exists:categories,id',
|
||||||
|
'quantity' => 'required|integer|min:1',
|
||||||
|
'payment_method' => 'required|in:cash,transfer',
|
||||||
|
'payment_status' => 'required|in:pending,paid,cancelled',
|
||||||
|
'payment_proof' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:2048',
|
||||||
|
'note' => 'nullable|string|max:1000',
|
||||||
|
], [
|
||||||
|
'customer_name.required' => 'Nama customer wajib diisi.',
|
||||||
|
'category_id.required' => 'Produk wajib dipilih.',
|
||||||
|
'category_id.exists' => 'Produk yang dipilih tidak tersedia.',
|
||||||
|
'quantity.required' => 'Jumlah wajib diisi.',
|
||||||
|
'quantity.integer' => 'Jumlah harus berupa angka.',
|
||||||
|
'quantity.min' => 'Jumlah minimal 1.',
|
||||||
|
'payment_method.required' => 'Metode pembayaran wajib dipilih.',
|
||||||
|
'payment_status.required' => 'Status pembayaran wajib dipilih.',
|
||||||
|
'customer_email.email' => 'Format email tidak valid.',
|
||||||
|
'payment_proof.mimes' => 'Bukti pembayaran harus berupa JPG, PNG, atau PDF.',
|
||||||
|
'payment_proof.max' => 'Ukuran bukti pembayaran maksimal 2 MB.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$category = Category::findOrFail($validated['category_id']);
|
||||||
|
|
||||||
|
if ($category->status !== 'available') {
|
||||||
|
return back()
|
||||||
|
->withErrors(['category_id' => 'Mobil ini sedang tidak tersedia dan tidak bisa dibuatkan transaksi baru.'])
|
||||||
|
->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
$quantity = (int) $validated['quantity'];
|
||||||
|
$unitPrice = $category->price;
|
||||||
|
$paymentProofPath = $request->hasFile('payment_proof')
|
||||||
|
? $request->file('payment_proof')->store('payment-proofs', 'public')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$expiresAt = $validated['payment_status'] === 'pending'
|
||||||
|
? now()->addMinutes(Category::RESERVATION_MINUTES)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
Transaction::create($this->transactionPayload([
|
||||||
|
'transaction_code' => $validated['transaction_code'] ?? $this->generateTransactionCode(),
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'admin_username' => auth('admin')->user()->username ?? null,
|
||||||
|
'customer_name' => $validated['customer_name'],
|
||||||
|
'customer_phone' => $validated['customer_phone'] ?? null,
|
||||||
|
'customer_email' => $validated['customer_email'] ?? null,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'unit_price' => $unitPrice,
|
||||||
|
'total_amount' => $unitPrice * $quantity,
|
||||||
|
'payment_method' => $validated['payment_method'],
|
||||||
|
'payment_status' => $validated['payment_status'],
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'reserved_by' => $validated['payment_status'] === 'pending'
|
||||||
|
? (auth('admin')->user()->username ?? 'admin')
|
||||||
|
: null,
|
||||||
|
'payment_proof' => $paymentProofPath,
|
||||||
|
'note' => $validated['note'] ?? null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ($validated['payment_status'] === 'paid') {
|
||||||
|
$category->update([
|
||||||
|
'status' => 'sold',
|
||||||
|
]);
|
||||||
|
} elseif ($validated['payment_status'] === 'pending') {
|
||||||
|
$category->update([
|
||||||
|
'status' => 'reserved',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('riwayat')->with('success', 'Transaksi berhasil disimpan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beginPayment(Category $category)
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
|
||||||
|
$transaction = DB::transaction(function () use ($category) {
|
||||||
|
$lockedCategory = Category::whereKey($category->id)->lockForUpdate()->firstOrFail();
|
||||||
|
|
||||||
|
if ($lockedCategory->status !== 'available') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = now()->addMinutes(Category::RESERVATION_MINUTES);
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
$transaction = Transaction::create($this->transactionPayload([
|
||||||
|
'transaction_code' => $this->generateTransactionCode(),
|
||||||
|
'category_id' => $lockedCategory->id,
|
||||||
|
'customer_name' => $user->username,
|
||||||
|
'customer_phone' => $user->phone_number,
|
||||||
|
'customer_email' => $user->email,
|
||||||
|
'quantity' => 1,
|
||||||
|
'unit_price' => $lockedCategory->price,
|
||||||
|
'total_amount' => $lockedCategory->price,
|
||||||
|
'payment_method' => 'transfer',
|
||||||
|
'payment_status' => 'pending',
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'reserved_by' => $user->username,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$lockedCategory->update([
|
||||||
|
'status' => 'reserved',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $transaction;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (! $transaction) {
|
||||||
|
return redirect()->route('mobil-bekas.detail', $category)
|
||||||
|
->with('error', 'Maaf, mobil ini baru saja dipesan pengguna lain atau sudah terjual.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('transactions.payment', $transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showPayment(Transaction $transaction)
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
$transaction->refresh();
|
||||||
|
|
||||||
|
if ($transaction->payment_status === 'cancelled' || ! $transaction->category) {
|
||||||
|
return redirect()->route('semua-mobil-bekas')
|
||||||
|
->with('error', 'Waktu pembayaran telah habis. Mobil kembali tersedia.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->isTransactionOwner($transaction)) {
|
||||||
|
abort(403, 'Anda tidak memiliki akses ke pembayaran ini.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('user.payment', compact('transaction'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadProof(Request $request, Transaction $transaction)
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
$transaction->refresh();
|
||||||
|
|
||||||
|
if ($transaction->payment_status !== 'pending' || ! $transaction->expires_at || $transaction->expires_at->isPast()) {
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
|
||||||
|
return redirect()->route('semua-mobil-bekas')
|
||||||
|
->with('error', 'Waktu pembayaran telah habis. Mobil kembali tersedia.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->isTransactionOwner($transaction)) {
|
||||||
|
abort(403, 'Anda tidak memiliki akses ke pembayaran ini.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'customer_name' => 'required|string|max:255',
|
||||||
|
'customer_phone' => 'nullable|string|max:30',
|
||||||
|
'customer_email' => 'nullable|email|max:255',
|
||||||
|
'payment_method' => 'required|in:cash,transfer',
|
||||||
|
'payment_proof' => 'required|file|mimes:jpg,jpeg,png,pdf|max:2048',
|
||||||
|
'note' => 'nullable|string|max:1000',
|
||||||
|
], [
|
||||||
|
'customer_name.required' => 'Nama pembeli wajib diisi.',
|
||||||
|
'payment_method.required' => 'Metode pembayaran wajib dipilih.',
|
||||||
|
'payment_proof.required' => 'Bukti pembayaran wajib diupload.',
|
||||||
|
'payment_proof.mimes' => 'Bukti pembayaran harus berupa JPG, PNG, atau PDF.',
|
||||||
|
'payment_proof.max' => 'Ukuran bukti pembayaran maksimal 2 MB.',
|
||||||
|
'customer_email.email' => 'Format email tidak valid.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$paymentProofPath = $request->file('payment_proof')->store('payment-proofs', 'public');
|
||||||
|
|
||||||
|
$updated = DB::transaction(function () use ($transaction, $validated, $paymentProofPath) {
|
||||||
|
$lockedTransaction = Transaction::whereKey($transaction->id)->lockForUpdate()->firstOrFail();
|
||||||
|
$lockedCategory = $lockedTransaction->category()->lockForUpdate()->first();
|
||||||
|
|
||||||
|
if (! $lockedCategory
|
||||||
|
|| $lockedTransaction->payment_status !== 'pending'
|
||||||
|
|| ! $lockedTransaction->expires_at
|
||||||
|
|| $lockedTransaction->expires_at->isPast()
|
||||||
|
|| $lockedTransaction->reserved_by !== auth()->user()->username) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockedTransaction->update([
|
||||||
|
'customer_name' => $validated['customer_name'],
|
||||||
|
'customer_phone' => $validated['customer_phone'] ?? null,
|
||||||
|
'customer_email' => $validated['customer_email'] ?? null,
|
||||||
|
'payment_method' => $validated['payment_method'],
|
||||||
|
'payment_proof' => $paymentProofPath,
|
||||||
|
'note' => $validated['note'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (! $updated) {
|
||||||
|
Storage::disk('public')->delete($paymentProofPath);
|
||||||
|
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
return redirect()->route('semua-mobil-bekas')->with('error', 'Waktu pembayaran telah habis. Mobil kembali tersedia.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('mobil-bekas.detail', $transaction->category_id)
|
||||||
|
->with('success', 'Bukti pembayaran berhasil dikirim dan sedang menunggu konfirmasi admin.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmPayment(Transaction $transaction)
|
||||||
|
{
|
||||||
|
Category::releaseExpiredReservations();
|
||||||
|
$transaction->refresh();
|
||||||
|
|
||||||
|
if ($transaction->payment_status === 'cancelled') {
|
||||||
|
return back()->with('error', 'Transaksi ini sudah dibatalkan karena melewati batas waktu pembayaran.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$confirmed = DB::transaction(function () use ($transaction) {
|
||||||
|
$lockedTransaction = Transaction::whereKey($transaction->id)->lockForUpdate()->firstOrFail();
|
||||||
|
$lockedCategory = $lockedTransaction->category()
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $lockedCategory || $lockedCategory->status === 'sold') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockedTransaction->update($this->transactionPayload([
|
||||||
|
'payment_status' => 'paid',
|
||||||
|
'admin_username' => auth('admin')->user()->username ?? null,
|
||||||
|
'expires_at' => null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$lockedCategory->update([
|
||||||
|
'status' => 'sold',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (! $confirmed) {
|
||||||
|
return back()->with('error', 'Mobil ini sudah terjual, transaksi pending lain tidak bisa dikonfirmasi.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('riwayat')->with('success', 'Pembayaran dikonfirmasi dan mobil ditandai terjual.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateTransactionCode()
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$code = 'TRX' . now()->format('YmdHis') . random_int(10, 99);
|
||||||
|
} while (Transaction::where('transaction_code', $code)->exists());
|
||||||
|
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function transactionPayload(array $payload): array
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('transactions', 'expires_at')) {
|
||||||
|
unset($payload['expires_at']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Schema::hasColumn('transactions', 'reserved_by')) {
|
||||||
|
unset($payload['reserved_by']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isTransactionOwner(Transaction $transaction): bool
|
||||||
|
{
|
||||||
|
return auth()->check()
|
||||||
|
&& $transaction->reserved_by === auth()->user()->username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||||
|
|
||||||
|
class Kernel extends HttpKernel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The application's global HTTP middleware stack.
|
||||||
|
*
|
||||||
|
* These middleware are run during every request to your application.
|
||||||
|
*
|
||||||
|
* @var array<int, class-string|string>
|
||||||
|
*/
|
||||||
|
protected $middleware = [
|
||||||
|
// \App\Http\Middleware\TrustHosts::class,
|
||||||
|
\App\Http\Middleware\TrustProxies::class,
|
||||||
|
\Illuminate\Http\Middleware\HandleCors::class,
|
||||||
|
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
||||||
|
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||||
|
\App\Http\Middleware\TrimStrings::class,
|
||||||
|
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application's route middleware groups.
|
||||||
|
*
|
||||||
|
* @var array<string, array<int, class-string|string>>
|
||||||
|
*/
|
||||||
|
protected $middlewareGroups = [
|
||||||
|
'web' => [
|
||||||
|
\App\Http\Middleware\EncryptCookies::class,
|
||||||
|
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||||
|
\Illuminate\Session\Middleware\StartSession::class,
|
||||||
|
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||||
|
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'api' => [
|
||||||
|
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||||
|
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
|
||||||
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application's middleware aliases.
|
||||||
|
*
|
||||||
|
* Aliases may be used instead of class names to conveniently assign middleware to routes and groups.
|
||||||
|
*
|
||||||
|
* @var array<string, class-string|string>
|
||||||
|
*/
|
||||||
|
protected $middlewareAliases = [
|
||||||
|
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||||
|
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||||
|
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||||
|
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
||||||
|
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
|
||||||
|
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
|
||||||
|
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
|
||||||
|
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||||
|
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||||
|
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Middleware\Authenticate as Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class Authenticate extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the path the user should be redirected to when they are not authenticated.
|
||||||
|
*/
|
||||||
|
protected function redirectTo(Request $request): ?string
|
||||||
|
{
|
||||||
|
return $request->expectsJson() ? null : route('login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
|
||||||
|
|
||||||
|
class EncryptCookies extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The names of the cookies that should not be encrypted.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $except = [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
|
||||||
|
|
||||||
|
class PreventRequestsDuringMaintenance extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The URIs that should be reachable while maintenance mode is enabled.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $except = [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Providers\RouteServiceProvider;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RedirectIfAuthenticated
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||||
|
{
|
||||||
|
$guards = empty($guards) ? [null] : $guards;
|
||||||
|
|
||||||
|
foreach ($guards as $guard) {
|
||||||
|
if (Auth::guard($guard)->check()) {
|
||||||
|
return redirect(RouteServiceProvider::HOME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
|
||||||
|
|
||||||
|
class TrimStrings extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The names of the attributes that should not be trimmed.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $except = [
|
||||||
|
'current_password',
|
||||||
|
'password',
|
||||||
|
'password_confirmation',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Middleware\TrustHosts as Middleware;
|
||||||
|
|
||||||
|
class TrustHosts extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the host patterns that should be trusted.
|
||||||
|
*
|
||||||
|
* @return array<int, string|null>
|
||||||
|
*/
|
||||||
|
public function hosts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->allSubdomainsOfApplicationUrl(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Middleware\TrustProxies as Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TrustProxies extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The trusted proxies for this application.
|
||||||
|
*
|
||||||
|
* @var array<int, string>|string|null
|
||||||
|
*/
|
||||||
|
protected $proxies;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The headers that should be used to detect proxies.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
protected $headers =
|
||||||
|
Request::HEADER_X_FORWARDED_FOR |
|
||||||
|
Request::HEADER_X_FORWARDED_HOST |
|
||||||
|
Request::HEADER_X_FORWARDED_PORT |
|
||||||
|
Request::HEADER_X_FORWARDED_PROTO |
|
||||||
|
Request::HEADER_X_FORWARDED_AWS_ELB;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Routing\Middleware\ValidateSignature as Middleware;
|
||||||
|
|
||||||
|
class ValidateSignature extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The names of the query string parameters that should be ignored.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $except = [
|
||||||
|
// 'fbclid',
|
||||||
|
// 'utm_campaign',
|
||||||
|
// 'utm_content',
|
||||||
|
// 'utm_medium',
|
||||||
|
// 'utm_source',
|
||||||
|
// 'utm_term',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
|
||||||
|
|
||||||
|
class VerifyCsrfToken extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The URIs that should be excluded from CSRF verification.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $except = [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
|
class Admin extends Authenticatable
|
||||||
|
{
|
||||||
|
use HasApiTokens, Notifiable;
|
||||||
|
|
||||||
|
protected $table = 'admin';
|
||||||
|
protected $primaryKey = 'username';
|
||||||
|
public $incrementing = false;
|
||||||
|
protected $keyType = 'string';
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class Category extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const RESERVATION_MINUTES = 10;
|
||||||
|
|
||||||
|
protected $table = 'categories';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'merk',
|
||||||
|
'model',
|
||||||
|
'kategori',
|
||||||
|
'price',
|
||||||
|
'year',
|
||||||
|
'mesin_cc',
|
||||||
|
'transmisi',
|
||||||
|
'bahan_bakar',
|
||||||
|
'image',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = [
|
||||||
|
'image_url',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getImageUrlAttribute()
|
||||||
|
{
|
||||||
|
if ($this->image) {
|
||||||
|
return asset('storage/' . $this->image);
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset('images/default-car.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusLabelAttribute()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'available' => 'Tersedia',
|
||||||
|
'reserved' => 'Sedang Dipesan',
|
||||||
|
'sold' => 'Terjual',
|
||||||
|
][$this->status] ?? ucfirst($this->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function releaseExpiredReservations(): int
|
||||||
|
{
|
||||||
|
if (!Schema::hasColumn('transactions', 'expires_at')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiredCategoryIds = Transaction::where('payment_status', 'pending')
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', now())
|
||||||
|
->pluck('category_id')
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$expiredCount = Transaction::where('payment_status', 'pending')
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', now())
|
||||||
|
->update(['payment_status' => 'cancelled']);
|
||||||
|
|
||||||
|
static::where('status', 'reserved')
|
||||||
|
->whereIn('id', $expiredCategoryIds)
|
||||||
|
->whereDoesntHave('transactions', function ($query) {
|
||||||
|
$query->where('payment_status', 'pending')
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->update(['status' => 'available']);
|
||||||
|
|
||||||
|
return $expiredCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isReserved(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 'reserved'
|
||||||
|
&& (bool) $this->activeReservation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activeReservation(): ?Transaction
|
||||||
|
{
|
||||||
|
return $this->transactions()
|
||||||
|
->where('payment_status', 'pending')
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '>', now())
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the users who have favorited this category
|
||||||
|
*/
|
||||||
|
public function favoritedBy()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'favorites', 'category_id', 'username', 'id', 'username');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transactions()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Transaction::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the category is favorited by a specific user
|
||||||
|
*/
|
||||||
|
public function isFavoritedBy($username)
|
||||||
|
{
|
||||||
|
return $this->favoritedBy()->where('favorites.username', $username)->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Favorite extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'username',
|
||||||
|
'category_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relationship with User model
|
||||||
|
*/
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'username', 'username');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relationship with Category model
|
||||||
|
*/
|
||||||
|
public function category()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class, 'category_id', 'id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Transaction extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'transaction_code',
|
||||||
|
'category_id',
|
||||||
|
'admin_username',
|
||||||
|
'customer_name',
|
||||||
|
'customer_phone',
|
||||||
|
'customer_email',
|
||||||
|
'quantity',
|
||||||
|
'unit_price',
|
||||||
|
'total_amount',
|
||||||
|
'payment_method',
|
||||||
|
'payment_status',
|
||||||
|
'payment_proof',
|
||||||
|
'note',
|
||||||
|
'expires_at',
|
||||||
|
'reserved_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function category()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPaymentMethodLabelAttribute()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'cash' => 'Tunai',
|
||||||
|
'transfer' => 'Transfer Bank',
|
||||||
|
][$this->payment_method] ?? ucfirst($this->payment_method);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPaymentStatusLabelAttribute()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => 'Pending',
|
||||||
|
'paid' => 'Lunas',
|
||||||
|
'cancelled' => 'Dibatalkan',
|
||||||
|
][$this->payment_status] ?? ucfirst($this->payment_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
|
protected $table = 'user';
|
||||||
|
protected $primaryKey = 'username';
|
||||||
|
public $incrementing = false;
|
||||||
|
protected $keyType = 'string';
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'username',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
'phone_number',
|
||||||
|
'role',
|
||||||
|
'email_verified_at',
|
||||||
|
'login_otp',
|
||||||
|
'login_otp_expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be hidden for serialization.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'login_otp_expires_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's favorite cars
|
||||||
|
*/
|
||||||
|
public function favorites()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Favorite::class, 'username', 'username');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the categories that the user has favorited
|
||||||
|
*/
|
||||||
|
public function favoriteCategories()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Category::class, 'favorites', 'username', 'category_id', 'username', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class LoginOtpNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(private readonly string $otp)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('Kode OTP Login JMW Showroom')
|
||||||
|
->greeting('Halo ' . $notifiable->username . ',')
|
||||||
|
->line('Gunakan kode OTP berikut untuk menyelesaikan login Anda:')
|
||||||
|
->line($this->otp)
|
||||||
|
->line('Kode ini berlaku selama 5 menit.')
|
||||||
|
->line('Jika Anda tidak mencoba login, abaikan email ini.')
|
||||||
|
->salutation('Salam, Tim JMW Showroom');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
class VerifyEmailNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$verificationUrl = $this->verificationUrl($notifiable);
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('Verifikasi Email Anda')
|
||||||
|
->greeting('Halo ' . $notifiable->name . ',')
|
||||||
|
->line('Terima kasih telah mendaftar di Marketplace Mobil Bekas!')
|
||||||
|
->line('Silakan klik tombol di bawah untuk memverifikasi email Anda:')
|
||||||
|
->action('Verifikasi Email', $verificationUrl)
|
||||||
|
->line('Jika Anda tidak melakukan registrasi, abaikan email ini.')
|
||||||
|
->salutation('Salam, Tim Marketplace Mobil Bekas');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the verification URL for the user.
|
||||||
|
*/
|
||||||
|
protected function verificationUrl($notifiable): string
|
||||||
|
{
|
||||||
|
return URL::temporarySignedRoute(
|
||||||
|
'verification.verify',
|
||||||
|
Carbon::now()->addMinutes(60),
|
||||||
|
[
|
||||||
|
'id' => $notifiable->getKey(),
|
||||||
|
'hash' => sha1($notifiable->getEmailForVerification()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
if (config('app.env') === 'production' || str_contains(config('app.url'), 'https://')) {
|
||||||
|
URL::forceScheme('https');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config('app.url') && str_contains(config('app.url'), 'http')) {
|
||||||
|
URL::forceRootUrl(config('app.url'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
// use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
|
|
||||||
|
class AuthServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The model to policy mappings for the application.
|
||||||
|
*
|
||||||
|
* @var array<class-string, class-string>
|
||||||
|
*/
|
||||||
|
protected $policies = [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register any authentication / authorization services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Broadcast;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class BroadcastServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Broadcast::routes();
|
||||||
|
|
||||||
|
require base_path('routes/channels.php');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Events\Registered;
|
||||||
|
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||||
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
|
||||||
|
class EventServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The event to listener mappings for the application.
|
||||||
|
*
|
||||||
|
* @var array<class-string, array<int, class-string>>
|
||||||
|
*/
|
||||||
|
protected $listen = [
|
||||||
|
Registered::class => [
|
||||||
|
SendEmailVerificationNotification::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register any events for your application.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if events and listeners should be automatically discovered.
|
||||||
|
*/
|
||||||
|
public function shouldDiscoverEvents(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
class RouteServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The path to your application's "home" route.
|
||||||
|
*
|
||||||
|
* Typically, users are redirected here after authentication.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public const HOME = '/dashboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define your route model bindings, pattern filters, and other route configuration.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
RateLimiter::for('api', function (Request $request) {
|
||||||
|
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->routes(function () {
|
||||||
|
Route::middleware('api')
|
||||||
|
->prefix('api')
|
||||||
|
->group(base_path('routes/api.php'));
|
||||||
|
|
||||||
|
Route::middleware('web')
|
||||||
|
->group(base_path('routes/web.php'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class CarRecommendationService
|
||||||
|
{
|
||||||
|
private const PRICE_TOLERANCE_PERCENT = 10;
|
||||||
|
|
||||||
|
public function recommendForBudget(float $budget, int $limit = 50, array $criteria = []): array
|
||||||
|
{
|
||||||
|
$year = $this->positiveInt($criteria['year'] ?? null);
|
||||||
|
$mesinCc = $this->positiveInt($criteria['mesin_cc'] ?? null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$filters = [
|
||||||
|
'toleransi_harga_percent' => self::PRICE_TOLERANCE_PERCENT,
|
||||||
|
'jumlah_cluster' => 4,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($year) {
|
||||||
|
$filters['tahun_min'] = $year;
|
||||||
|
}
|
||||||
|
|
||||||
|
$preferences = [];
|
||||||
|
if ($mesinCc) {
|
||||||
|
$preferences['mesin_cc'] = $mesinCc;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::timeout(config('services.car_recommendation.timeout', 3))
|
||||||
|
->post(rtrim(config('services.car_recommendation.base_url'), '/') . '/recommend', [
|
||||||
|
'budget' => $budget,
|
||||||
|
'limit' => $limit,
|
||||||
|
'strategi' => 'harga_pas',
|
||||||
|
'preferences' => $preferences,
|
||||||
|
'filter' => $filters,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return $this->fallback($budget, $criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $response->json();
|
||||||
|
$cars = $this->mapRecommendationsToCategories($payload['recommendations'] ?? []);
|
||||||
|
|
||||||
|
if ($cars->isEmpty()) {
|
||||||
|
return $this->fallback($budget, $criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cars' => $cars,
|
||||||
|
'source' => 'python_kmeans',
|
||||||
|
'meta' => $payload['model'] ?? null,
|
||||||
|
'has_exact_budget_match' => (bool) data_get($payload, 'model.exact_budget_match', false),
|
||||||
|
];
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
return $this->fallback($budget, $criteria);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fallback(float $budget, array $criteria = []): array
|
||||||
|
{
|
||||||
|
$year = $this->positiveInt($criteria['year'] ?? null);
|
||||||
|
$mesinCc = $this->positiveInt($criteria['mesin_cc'] ?? null);
|
||||||
|
|
||||||
|
$baseQuery = Category::where('status', 'available');
|
||||||
|
|
||||||
|
if ($year) {
|
||||||
|
$baseQuery->where('year', '>=', $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mesinCc) {
|
||||||
|
$engineTolerance = 250;
|
||||||
|
$baseQuery->whereBetween('mesin_cc', [$mesinCc - $engineTolerance, $mesinCc + $engineTolerance]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$exactMatches = (clone $baseQuery)
|
||||||
|
->where('price', $budget)
|
||||||
|
->orderBy('year', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($exactMatches->isNotEmpty()) {
|
||||||
|
return [
|
||||||
|
'cars' => $exactMatches,
|
||||||
|
'source' => 'laravel_fallback',
|
||||||
|
'meta' => null,
|
||||||
|
'has_exact_budget_match' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$minimumPrice = $budget * (1 - (self::PRICE_TOLERANCE_PERCENT / 100));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cars' => (clone $baseQuery)
|
||||||
|
->where('price', '>', 0)
|
||||||
|
->where('price', '>=', $minimumPrice)
|
||||||
|
->where('price', '<=', $budget)
|
||||||
|
->orderByRaw('ABS(price - ?) ASC', [$budget])
|
||||||
|
->when($mesinCc, fn ($query) => $query->orderByRaw('ABS(mesin_cc - ?) ASC', [$mesinCc]))
|
||||||
|
->orderBy('year', 'desc')
|
||||||
|
->get(),
|
||||||
|
'source' => 'laravel_fallback',
|
||||||
|
'meta' => null,
|
||||||
|
'has_exact_budget_match' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapRecommendationsToCategories(array $recommendations): Collection
|
||||||
|
{
|
||||||
|
$categories = Category::where('status', 'available')->get();
|
||||||
|
$matched = collect();
|
||||||
|
|
||||||
|
foreach ($recommendations as $recommendation) {
|
||||||
|
$category = $this->findMatchingCategory($categories, $recommendation, $matched->pluck('id')->all());
|
||||||
|
|
||||||
|
if ($category) {
|
||||||
|
$matched->push($category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findMatchingCategory(Collection $categories, array $recommendation, array $usedIds): ?Category
|
||||||
|
{
|
||||||
|
$name = $this->normalize($recommendation['nama'] ?? '');
|
||||||
|
$year = (int) ($recommendation['tahun'] ?? 0);
|
||||||
|
$price = (float) ($recommendation['harga'] ?? 0);
|
||||||
|
|
||||||
|
return $categories->first(function (Category $category) use ($name, $year, $price, $usedIds) {
|
||||||
|
return ! in_array($category->id, $usedIds, true)
|
||||||
|
&& $this->normalize($category->name) === $name
|
||||||
|
&& (int) $category->year === $year
|
||||||
|
&& (float) $category->price === $price;
|
||||||
|
}) ?? $categories->first(function (Category $category) use ($name, $year, $usedIds) {
|
||||||
|
return ! in_array($category->id, $usedIds, true)
|
||||||
|
&& $this->normalize($category->name) === $name
|
||||||
|
&& (int) $category->year === $year;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalize(string $value): string
|
||||||
|
{
|
||||||
|
return preg_replace('/\s+/', ' ', strtolower(trim($value))) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function positiveInt(mixed $value): ?int
|
||||||
|
{
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = (int) $value;
|
||||||
|
|
||||||
|
return $value > 0 ? $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,285 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class WhatsAppBusinessService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check whether WhatsApp Business API is configured.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isConfigured()
|
||||||
|
{
|
||||||
|
return filled(config('services.whatsapp.token'))
|
||||||
|
&& filled(config('services.whatsapp.phone_number_id'))
|
||||||
|
&& filled($this->adminRecipient());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a car notification to the admin via WhatsApp Cloud API.
|
||||||
|
*
|
||||||
|
* @param \App\Models\Category $category
|
||||||
|
* @param \App\Models\User|null $customer
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function sendCarNotificationToAdmin(Category $category, ?User $customer = null)
|
||||||
|
{
|
||||||
|
if (!$this->isConfigured()) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'WhatsApp Business API belum dikonfigurasi.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipient = $this->formatPhoneNumber($this->adminRecipient());
|
||||||
|
$endpoint = $this->graphEndpoint('messages');
|
||||||
|
|
||||||
|
$imageUrl = $this->getCarImageUrl($category);
|
||||||
|
$mediaId = $this->uploadCarImage($category);
|
||||||
|
$canSendImage = filled($mediaId) || $this->isPublicMediaUrl($imageUrl);
|
||||||
|
$caption = $this->buildCaption($category, $customer, $canSendImage);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'messaging_product' => 'whatsapp',
|
||||||
|
'recipient_type' => 'individual',
|
||||||
|
'to' => $recipient,
|
||||||
|
'type' => $canSendImage ? 'image' : 'text',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($mediaId) {
|
||||||
|
$payload['image'] = [
|
||||||
|
'id' => $mediaId,
|
||||||
|
'caption' => $caption,
|
||||||
|
];
|
||||||
|
} elseif ($canSendImage) {
|
||||||
|
$payload['image'] = [
|
||||||
|
'link' => $imageUrl,
|
||||||
|
'caption' => $caption,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$payload['text'] = [
|
||||||
|
'preview_url' => false,
|
||||||
|
'body' => $caption,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withToken(config('services.whatsapp.token'))
|
||||||
|
->acceptJson()
|
||||||
|
->post($endpoint, $payload);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => $canSendImage
|
||||||
|
? 'Notifikasi gambar katalog mobil berhasil dikirim ke admin.'
|
||||||
|
: 'Notifikasi teks berhasil dikirim ke admin.',
|
||||||
|
'data' => $response->json(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('Gagal mengirim WhatsApp Business notification.', [
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'response' => $response->json() ?: $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal mengirim notifikasi WhatsApp Business ke admin.',
|
||||||
|
'error' => $response->json() ?: $response->body(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build message caption sent to admin.
|
||||||
|
*
|
||||||
|
* @param \App\Models\Category $category
|
||||||
|
* @param \App\Models\User|null $customer
|
||||||
|
* @param bool $withImage
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function buildCaption(Category $category, ?User $customer = null, $withImage = true)
|
||||||
|
{
|
||||||
|
$lines = [
|
||||||
|
'Customer tertarik dengan mobil katalog ini.',
|
||||||
|
'Mobil: ' . $category->name,
|
||||||
|
'Harga: Rp ' . number_format((float) $category->price, 0, ',', '.'),
|
||||||
|
'Tahun: ' . ($category->year ?: '-'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($customer) {
|
||||||
|
$lines[] = 'Username customer: ' . $customer->username;
|
||||||
|
$lines[] = 'Email customer: ' . $customer->email;
|
||||||
|
if ($customer->phone_number) {
|
||||||
|
$lines[] = 'No. HP customer: ' . $customer->phone_number;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$lines[] = 'Customer: pengunjung belum login';
|
||||||
|
}
|
||||||
|
|
||||||
|
$detailUrl = route('mobil-bekas.detail', ['category' => $category->id]);
|
||||||
|
$lines[] = 'Link detail: ' . $detailUrl;
|
||||||
|
if (!$withImage) {
|
||||||
|
$lines[] = 'Link foto katalog: ' . ($this->getCarImageUrl($category) ?: '-');
|
||||||
|
$lines[] = 'Catatan: gambar dikirim sebagai teks karena file katalog belum bisa dikirim sebagai media.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload the local catalog image to WhatsApp and return its media ID.
|
||||||
|
*
|
||||||
|
* @param \App\Models\Category $category
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private function uploadCarImage(Category $category)
|
||||||
|
{
|
||||||
|
if (!$category->image || !Storage::disk('public')->exists($category->image)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = Storage::disk('public')->path($category->image);
|
||||||
|
$mimeType = Storage::disk('public')->mimeType($category->image) ?: 'image/jpeg';
|
||||||
|
|
||||||
|
if (!str_starts_with($mimeType, 'image/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = fopen($path, 'r');
|
||||||
|
|
||||||
|
if (!$handle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withToken(config('services.whatsapp.token'))
|
||||||
|
->acceptJson()
|
||||||
|
->attach('file', $handle, basename($path), ['Content-Type' => $mimeType])
|
||||||
|
->post($this->graphEndpoint('media'), [
|
||||||
|
'messaging_product' => 'whatsapp',
|
||||||
|
'type' => $mimeType,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return $response->json('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('Gagal upload media WhatsApp Business.', [
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'response' => $response->json() ?: $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get admin WhatsApp recipient from the dedicated WhatsApp setting,
|
||||||
|
* with fallback to the existing direct chat number.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private function adminRecipient()
|
||||||
|
{
|
||||||
|
return config('services.whatsapp.admin_recipient');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a public absolute image URL if the car has an image.
|
||||||
|
*
|
||||||
|
* @param \App\Models\Category $category
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private function getCarImageUrl(Category $category)
|
||||||
|
{
|
||||||
|
if (!$category->image) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->publicUrl(Storage::disk('public')->url($category->image));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the media URL is public enough for WhatsApp to fetch.
|
||||||
|
*
|
||||||
|
* @param string|null $url
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function isPublicMediaUrl($url)
|
||||||
|
{
|
||||||
|
if (!$url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !str_contains($url, 'localhost')
|
||||||
|
&& !str_contains($url, '127.0.0.1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an absolute URL from APP_URL so the Cloud API can fetch media.
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function publicUrl($path)
|
||||||
|
{
|
||||||
|
if (filter_var($path, FILTER_VALIDATE_URL)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(config('app.url'), '/') . '/' . ltrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build WhatsApp Graph API endpoint for the configured phone number.
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function graphEndpoint($path)
|
||||||
|
{
|
||||||
|
return rtrim(config('services.whatsapp.base_url'), '/')
|
||||||
|
. '/'
|
||||||
|
. config('services.whatsapp.api_version')
|
||||||
|
. '/'
|
||||||
|
. config('services.whatsapp.phone_number_id')
|
||||||
|
. '/'
|
||||||
|
. ltrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize phone number to WhatsApp format.
|
||||||
|
*
|
||||||
|
* @param string $phoneNumber
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function formatPhoneNumber($phoneNumber)
|
||||||
|
{
|
||||||
|
$phoneNumber = preg_replace('/[^0-9]/', '', $phoneNumber);
|
||||||
|
|
||||||
|
if (strlen($phoneNumber) === 10 && substr($phoneNumber, 0, 1) === '8') {
|
||||||
|
return '62' . $phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($phoneNumber) >= 11 && substr($phoneNumber, 0, 2) === '08') {
|
||||||
|
return '62' . substr($phoneNumber, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (substr($phoneNumber, 0, 2) !== '62') {
|
||||||
|
return '62' . $phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phoneNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Register The Auto Loader
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Composer provides a convenient, automatically generated class loader
|
||||||
|
| for our application. We just need to utilize it! We'll require it
|
||||||
|
| into the script here so that we do not have to worry about the
|
||||||
|
| loading of any of our classes manually. It's great to relax.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Run The Artisan Application
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When we run the console application, the current CLI command will be
|
||||||
|
| executed in this console and the response sent back to a terminal
|
||||||
|
| or another output device for the developers. Here goes nothing!
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||||
|
|
||||||
|
$status = $kernel->handle(
|
||||||
|
$input = new Symfony\Component\Console\Input\ArgvInput,
|
||||||
|
new Symfony\Component\Console\Output\ConsoleOutput
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Shutdown The Application
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Once Artisan has finished running, we will fire off the shutdown events
|
||||||
|
| so that any final work may be done by the application before we shut
|
||||||
|
| down the process. This is the last thing to happen to the request.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
$kernel->terminate($input, $status);
|
||||||
|
|
||||||
|
exit($status);
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Create The Application
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The first thing we will do is create a new Laravel application instance
|
||||||
|
| which serves as the "glue" for all the components of Laravel, and is
|
||||||
|
| the IoC container for the system binding all of the various parts.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
$app = new Illuminate\Foundation\Application(
|
||||||
|
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Bind Important Interfaces
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Next, we need to bind some important interfaces into the container so
|
||||||
|
| we will be able to resolve them when needed. The kernels serve the
|
||||||
|
| incoming requests to this application from both the web and CLI.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
$app->singleton(
|
||||||
|
Illuminate\Contracts\Http\Kernel::class,
|
||||||
|
App\Http\Kernel::class
|
||||||
|
);
|
||||||
|
|
||||||
|
$app->singleton(
|
||||||
|
Illuminate\Contracts\Console\Kernel::class,
|
||||||
|
App\Console\Kernel::class
|
||||||
|
);
|
||||||
|
|
||||||
|
$app->singleton(
|
||||||
|
Illuminate\Contracts\Debug\ExceptionHandler::class,
|
||||||
|
App\Exceptions\Handler::class
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Return The Application
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This script returns the application instance. The instance is given to
|
||||||
|
| the calling script so we can separate the building of the instances
|
||||||
|
| from the actual running of the application and sending responses.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return $app;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Build Assets & Upload to FTP
|
||||||
|
* 1. npm install (if needed)
|
||||||
|
* 2. npm run build
|
||||||
|
* 3. Upload public/ to remote server
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
|
||||||
|
const PROJECT_DIR = __dirname;
|
||||||
|
const BUILD_DIR = path.join(PROJECT_DIR, 'public');
|
||||||
|
|
||||||
|
console.log('=== ASSET BUILD & UPLOAD SCRIPT ===\n');
|
||||||
|
|
||||||
|
// Check if node_modules exists
|
||||||
|
if (!fs.existsSync(path.join(PROJECT_DIR, 'node_modules'))) {
|
||||||
|
console.log('📦 Installing dependencies...');
|
||||||
|
exec('npm install', { cwd: PROJECT_DIR }, (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ npm install failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✅ Dependencies installed\n');
|
||||||
|
runBuild();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
runBuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runBuild() {
|
||||||
|
console.log('🔨 Building assets with Vite...');
|
||||||
|
exec('npm run build', { cwd: PROJECT_DIR }, (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Build failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(stdout);
|
||||||
|
console.log('✅ Assets built successfully!\n');
|
||||||
|
console.log('📁 Built files are in:', BUILD_DIR);
|
||||||
|
console.log('\n📋 Next steps:');
|
||||||
|
console.log('1. Upload public/ folder to remote server via FTP');
|
||||||
|
console.log('2. Import tamyhost_E31231762.sql via phpMyAdmin');
|
||||||
|
console.log('3. Test site at https://ta.myhost.id/E31231762/\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
{
|
||||||
|
"name": "laravel/laravel",
|
||||||
|
"type": "project",
|
||||||
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
|
"keywords": ["laravel", "framework"],
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.1",
|
||||||
|
"guzzlehttp/guzzle": "^7.2",
|
||||||
|
"laravel/framework": "^10.10",
|
||||||
|
"laravel/sanctum": "^3.3",
|
||||||
|
"laravel/tinker": "^2.8"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.9.1",
|
||||||
|
"laravel/pint": "^1.0",
|
||||||
|
"laravel/sail": "^1.18",
|
||||||
|
"mockery/mockery": "^1.4.4",
|
||||||
|
"nunomaduro/collision": "^7.0",
|
||||||
|
"phpunit/phpunit": "^10.1",
|
||||||
|
"spatie/laravel-ignition": "^2.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
"@php artisan package:discover --ansi"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||||
|
],
|
||||||
|
"post-root-package-install": [
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||||
|
],
|
||||||
|
"post-create-project-cmd": [
|
||||||
|
"@php artisan key:generate --ansi"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"dont-discover": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true,
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,188 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Facade;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value is the name of your application. This value is used when the
|
||||||
|
| framework needs to place the application's name in a notification or
|
||||||
|
| any other location as required by the application or its packages.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'name' => env('APP_NAME', 'Laravel'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Environment
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the "environment" your application is currently
|
||||||
|
| running in. This may determine how you prefer to configure various
|
||||||
|
| services the application utilizes. Set this in your ".env" file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'env' => env('APP_ENV', 'production'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Debug Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When your application is in debug mode, detailed error messages with
|
||||||
|
| stack traces will be shown on every error that occurs within your
|
||||||
|
| application. If disabled, a simple generic error page is shown.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application URL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This URL is used by the console to properly generate URLs when using
|
||||||
|
| the Artisan command line tool. You should set this to the root of
|
||||||
|
| your application so that it is used when running Artisan tasks.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
|
'asset_url' => env('ASSET_URL'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Timezone
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default timezone for your application, which
|
||||||
|
| will be used by the PHP date and date-time functions. We have gone
|
||||||
|
| ahead and set this to a sensible default for you out of the box.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Locale Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The application locale determines the default locale that will be used
|
||||||
|
| by the translation service provider. You are free to set this value
|
||||||
|
| to any of the locales which will be supported by the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'locale' => 'en',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Fallback Locale
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The fallback locale determines the locale to use when the current one
|
||||||
|
| is not available. You may change the value to correspond to any of
|
||||||
|
| the language folders that are provided through your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'fallback_locale' => 'en',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Faker Locale
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This locale will be used by the Faker PHP library when generating fake
|
||||||
|
| data for your database seeds. For example, this will be used to get
|
||||||
|
| localized telephone numbers, street address information and more.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'faker_locale' => 'en_US',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Encryption Key
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This key is used by the Illuminate encrypter service and should be set
|
||||||
|
| to a random, 32 character string, otherwise these encrypted strings
|
||||||
|
| will not be safe. Please do this before deploying an application!
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'key' => env('APP_KEY'),
|
||||||
|
|
||||||
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Maintenance Mode Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options determine the driver used to determine and
|
||||||
|
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||||
|
| allow maintenance mode to be controlled across multiple machines.
|
||||||
|
|
|
||||||
|
| Supported drivers: "file", "cache"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'maintenance' => [
|
||||||
|
'driver' => 'file',
|
||||||
|
// 'store' => 'redis',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Autoloaded Service Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The service providers listed here will be automatically loaded on the
|
||||||
|
| request to your application. Feel free to add your own services to
|
||||||
|
| this array to grant expanded functionality to your applications.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => ServiceProvider::defaultProviders()->merge([
|
||||||
|
/*
|
||||||
|
* Package Service Providers...
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Application Service Providers...
|
||||||
|
*/
|
||||||
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\AuthServiceProvider::class,
|
||||||
|
// App\Providers\BroadcastServiceProvider::class,
|
||||||
|
App\Providers\EventServiceProvider::class,
|
||||||
|
App\Providers\RouteServiceProvider::class,
|
||||||
|
])->toArray(),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Class Aliases
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This array of class aliases will be registered when this application
|
||||||
|
| is started. However, feel free to register as many as you wish as
|
||||||
|
| the aliases are "lazy" loaded so they don't hinder performance.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'aliases' => Facade::defaultAliases()->merge([
|
||||||
|
// 'Example' => App\Facades\Example::class,
|
||||||
|
])->toArray(),
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Defaults
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default authentication "guard" and password
|
||||||
|
| reset options for your application. You may change these defaults
|
||||||
|
| as required, but they're a perfect start for most applications.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'guard' => 'web',
|
||||||
|
'passwords' => 'users',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Next, you may define every authentication guard for your application.
|
||||||
|
| Of course, a great default configuration has been defined for you
|
||||||
|
| here which uses session storage and the Eloquent user provider.
|
||||||
|
|
|
||||||
|
| All authentication drivers have a user provider. This defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| mechanisms used by this application to persist your user's data.
|
||||||
|
|
|
||||||
|
| Supported: "session"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guards' => [
|
||||||
|
'web' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
|
||||||
|
'admin' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'admins',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| User Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| All authentication drivers have a user provider. This defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| mechanisms used by this application to persist your user's data.
|
||||||
|
|
|
||||||
|
| If you have multiple user tables or models you may configure multiple
|
||||||
|
| sources which represent each model / table. These sources may then
|
||||||
|
| be assigned to any extra authentication guards you have defined.
|
||||||
|
|
|
||||||
|
| Supported: "database", "eloquent"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
'users' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => App\Models\User::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'admins' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => App\Models\Admin::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
// 'users' => [
|
||||||
|
// 'driver' => 'database',
|
||||||
|
// 'table' => 'users',
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Resetting Passwords
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| You may specify multiple password reset configurations if you have more
|
||||||
|
| than one user table or model in the application and you want to have
|
||||||
|
| separate password reset settings based on the specific user types.
|
||||||
|
|
|
||||||
|
| The expiry time is the number of minutes that each reset token will be
|
||||||
|
| considered valid. This security feature keeps tokens short-lived so
|
||||||
|
| they have less time to be guessed. You may change this as needed.
|
||||||
|
|
|
||||||
|
| The throttle setting is the number of seconds a user must wait before
|
||||||
|
| generating more password reset tokens. This prevents the user from
|
||||||
|
| quickly generating a very large amount of password reset tokens.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => [
|
||||||
|
'users' => [
|
||||||
|
'provider' => 'users',
|
||||||
|
'table' => 'password_reset_tokens',
|
||||||
|
'expire' => 60,
|
||||||
|
'throttle' => 60,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Confirmation Timeout
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the amount of seconds before a password confirmation
|
||||||
|
| times out and the user is prompted to re-enter their password via the
|
||||||
|
| confirmation screen. By default, the timeout lasts for three hours.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'password_timeout' => 10800,
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Broadcaster
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default broadcaster that will be used by the
|
||||||
|
| framework when an event needs to be broadcast. You may set this to
|
||||||
|
| any of the connections defined in the "connections" array below.
|
||||||
|
|
|
||||||
|
| Supported: "pusher", "ably", "redis", "log", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('BROADCAST_DRIVER', 'null'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Broadcast Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the broadcast connections that will be used
|
||||||
|
| to broadcast events to other systems or over websockets. Samples of
|
||||||
|
| each available type of connection are provided inside this array.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'pusher' => [
|
||||||
|
'driver' => 'pusher',
|
||||||
|
'key' => env('PUSHER_APP_KEY'),
|
||||||
|
'secret' => env('PUSHER_APP_SECRET'),
|
||||||
|
'app_id' => env('PUSHER_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||||
|
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||||
|
'port' => env('PUSHER_PORT', 443),
|
||||||
|
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||||
|
'encrypted' => true,
|
||||||
|
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'client_options' => [
|
||||||
|
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'ably' => [
|
||||||
|
'driver' => 'ably',
|
||||||
|
'key' => env('ABLY_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => 'default',
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'driver' => 'log',
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'null',
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default cache connection that gets used while
|
||||||
|
| using this caching library. This connection is used when another is
|
||||||
|
| not explicitly specified when executing a given caching function.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('CACHE_DRIVER', 'file'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Stores
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the cache "stores" for your application as
|
||||||
|
| well as their drivers. You may even define multiple stores for the
|
||||||
|
| same cache driver to group types of items stored in your caches.
|
||||||
|
|
|
||||||
|
| Supported drivers: "apc", "array", "database", "file",
|
||||||
|
| "memcached", "redis", "dynamodb", "octane", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stores' => [
|
||||||
|
|
||||||
|
'apc' => [
|
||||||
|
'driver' => 'apc',
|
||||||
|
],
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'driver' => 'array',
|
||||||
|
'serialize' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'table' => 'cache',
|
||||||
|
'connection' => null,
|
||||||
|
'lock_connection' => null,
|
||||||
|
],
|
||||||
|
|
||||||
|
'file' => [
|
||||||
|
'driver' => 'file',
|
||||||
|
'path' => storage_path('framework/cache/data'),
|
||||||
|
'lock_path' => storage_path('framework/cache/data'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'memcached' => [
|
||||||
|
'driver' => 'memcached',
|
||||||
|
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||||
|
'sasl' => [
|
||||||
|
env('MEMCACHED_USERNAME'),
|
||||||
|
env('MEMCACHED_PASSWORD'),
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||||
|
],
|
||||||
|
'servers' => [
|
||||||
|
[
|
||||||
|
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MEMCACHED_PORT', 11211),
|
||||||
|
'weight' => 100,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => 'cache',
|
||||||
|
'lock_connection' => 'default',
|
||||||
|
],
|
||||||
|
|
||||||
|
'dynamodb' => [
|
||||||
|
'driver' => 'dynamodb',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||||
|
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'octane' => [
|
||||||
|
'driver' => 'octane',
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Key Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the APC, database, memcached, Redis, or DynamoDB cache
|
||||||
|
| stores there might be other applications using the same cache. For
|
||||||
|
| that reason, you may prefix every cache key to avoid collisions.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your settings for cross-origin resource sharing
|
||||||
|
| or "CORS". This determines what cross-origin operations may execute
|
||||||
|
| in web browsers. You are free to adjust these settings as needed.
|
||||||
|
|
|
||||||
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||||
|
|
||||||
|
'allowed_methods' => ['*'],
|
||||||
|
|
||||||
|
'allowed_origins' => ['*'],
|
||||||
|
|
||||||
|
'allowed_origins_patterns' => [],
|
||||||
|
|
||||||
|
'allowed_headers' => ['*'],
|
||||||
|
|
||||||
|
'exposed_headers' => [],
|
||||||
|
|
||||||
|
'max_age' => 0,
|
||||||
|
|
||||||
|
'supports_credentials' => false,
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Database Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which of the database connections below you wish
|
||||||
|
| to use as your default connection for all database work. Of course
|
||||||
|
| you may use many connections at once using the Database library.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('DB_CONNECTION', 'mysql'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Database Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here are each of the database connections setup for your application.
|
||||||
|
| Of course, examples of configuring each database platform that is
|
||||||
|
| supported by Laravel is shown below to make development simple.
|
||||||
|
|
|
||||||
|
|
|
||||||
|
| All database work in Laravel is done through the PHP PDO facilities
|
||||||
|
| so make sure you have the driver for your particular database of
|
||||||
|
| choice installed on your machine before you begin development.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sqlite' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'url' => env('DATABASE_URL'),
|
||||||
|
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
'mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DATABASE_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'forge'),
|
||||||
|
'username' => env('DB_USERNAME', 'forge'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
'collation' => 'utf8mb4_unicode_ci',
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pgsql' => [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'url' => env('DATABASE_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '5432'),
|
||||||
|
'database' => env('DB_DATABASE', 'forge'),
|
||||||
|
'username' => env('DB_USERNAME', 'forge'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => 'utf8',
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'search_path' => 'public',
|
||||||
|
'sslmode' => 'prefer',
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqlsrv' => [
|
||||||
|
'driver' => 'sqlsrv',
|
||||||
|
'url' => env('DATABASE_URL'),
|
||||||
|
'host' => env('DB_HOST', 'localhost'),
|
||||||
|
'port' => env('DB_PORT', '1433'),
|
||||||
|
'database' => env('DB_DATABASE', 'forge'),
|
||||||
|
'username' => env('DB_USERNAME', 'forge'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => 'utf8',
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||||
|
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Migration Repository Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This table keeps track of all the migrations that have already run for
|
||||||
|
| your application. Using this information, we can determine which of
|
||||||
|
| the migrations on disk haven't actually been run in the database.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'migrations' => 'migrations',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Redis Databases
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Redis is an open source, fast, and advanced key-value store that also
|
||||||
|
| provides a richer body of commands than a typical key-value system
|
||||||
|
| such as APC or Memcached. Laravel makes it easy to dig right in.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
|
||||||
|
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||||
|
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'default' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_CACHE_DB', '1'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Filesystem Disk
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default filesystem disk that should be used
|
||||||
|
| by the framework. The "local" disk, as well as a variety of cloud
|
||||||
|
| based disks are available to your application. Just store away!
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Filesystem Disks
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure as many filesystem "disks" as you wish, and you
|
||||||
|
| may even configure multiple disks of the same driver. Defaults have
|
||||||
|
| been set up for each driver as an example of the required values.
|
||||||
|
|
|
||||||
|
| Supported Drivers: "local", "ftp", "sftp", "s3"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'disks' => [
|
||||||
|
|
||||||
|
'local' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app'),
|
||||||
|
'throw' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'public' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/public'),
|
||||||
|
'url' => env('APP_URL').'/storage',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'throw' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
's3' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION'),
|
||||||
|
'bucket' => env('AWS_BUCKET'),
|
||||||
|
'url' => env('AWS_URL'),
|
||||||
|
'endpoint' => env('AWS_ENDPOINT'),
|
||||||
|
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
|
'throw' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Symbolic Links
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the symbolic links that will be created when the
|
||||||
|
| `storage:link` Artisan command is executed. The array keys should be
|
||||||
|
| the locations of the links and the values should be their targets.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'links' => [
|
||||||
|
public_path('storage') => storage_path('app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Hash Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default hash driver that will be used to hash
|
||||||
|
| passwords for your application. By default, the bcrypt algorithm is
|
||||||
|
| used; however, you remain free to modify this option if you wish.
|
||||||
|
|
|
||||||
|
| Supported: "bcrypt", "argon", "argon2id"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => 'bcrypt',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Bcrypt Options
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the configuration options that should be used when
|
||||||
|
| passwords are hashed using the Bcrypt algorithm. This will allow you
|
||||||
|
| to control the amount of time it takes to hash the given password.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'bcrypt' => [
|
||||||
|
'rounds' => env('BCRYPT_ROUNDS', 12),
|
||||||
|
'verify' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Argon Options
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the configuration options that should be used when
|
||||||
|
| passwords are hashed using the Argon algorithm. These will allow you
|
||||||
|
| to control the amount of time it takes to hash the given password.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'argon' => [
|
||||||
|
'memory' => 65536,
|
||||||
|
'threads' => 1,
|
||||||
|
'time' => 4,
|
||||||
|
'verify' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Monolog\Handler\NullHandler;
|
||||||
|
use Monolog\Handler\StreamHandler;
|
||||||
|
use Monolog\Handler\SyslogUdpHandler;
|
||||||
|
use Monolog\Processor\PsrLogMessageProcessor;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default log channel that gets used when writing
|
||||||
|
| messages to the logs. The name specified in this option should match
|
||||||
|
| one of the channels defined in the "channels" configuration array.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('LOG_CHANNEL', 'stack'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Deprecations Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the log channel that should be used to log warnings
|
||||||
|
| regarding deprecated PHP and library features. This allows you to get
|
||||||
|
| your application ready for upcoming major versions of dependencies.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'deprecations' => [
|
||||||
|
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||||
|
'trace' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Log Channels
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the log channels for your application. Out of
|
||||||
|
| the box, Laravel uses the Monolog PHP logging library. This gives
|
||||||
|
| you a variety of powerful log handlers / formatters to utilize.
|
||||||
|
|
|
||||||
|
| Available Drivers: "single", "daily", "slack", "syslog",
|
||||||
|
| "errorlog", "monolog",
|
||||||
|
| "custom", "stack"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'channels' => [
|
||||||
|
'stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => ['single'],
|
||||||
|
'ignore_exceptions' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'single' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'daily' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'days' => 14,
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'driver' => 'slack',
|
||||||
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
'username' => 'Laravel Log',
|
||||||
|
'emoji' => ':boom:',
|
||||||
|
'level' => env('LOG_LEVEL', 'critical'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'papertrail' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||||
|
'handler_with' => [
|
||||||
|
'host' => env('PAPERTRAIL_URL'),
|
||||||
|
'port' => env('PAPERTRAIL_PORT'),
|
||||||
|
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||||
|
],
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'stderr' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => StreamHandler::class,
|
||||||
|
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||||
|
'with' => [
|
||||||
|
'stream' => 'php://stderr',
|
||||||
|
],
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'syslog' => [
|
||||||
|
'driver' => 'syslog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'facility' => LOG_USER,
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'errorlog' => [
|
||||||
|
'driver' => 'errorlog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'handler' => NullHandler::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'emergency' => [
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Mailer
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default mailer that is used to send any email
|
||||||
|
| messages sent by your application. Alternative mailers may be setup
|
||||||
|
| and used as needed; however, this mailer will be used by default.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('MAIL_MAILER', 'smtp'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Mailer Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure all of the mailers used by your application plus
|
||||||
|
| their respective settings. Several examples have been configured for
|
||||||
|
| you and you are free to add your own as your application requires.
|
||||||
|
|
|
||||||
|
| Laravel supports a variety of mail "transport" drivers to be used while
|
||||||
|
| sending an e-mail. You will specify which one you are using for your
|
||||||
|
| mailers below. You are free to add additional mailers as required.
|
||||||
|
|
|
||||||
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||||
|
| "postmark", "log", "array", "failover", "roundrobin"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'mailers' => [
|
||||||
|
'smtp' => [
|
||||||
|
'transport' => 'smtp',
|
||||||
|
'url' => env('MAIL_URL'),
|
||||||
|
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
|
||||||
|
'port' => env('MAIL_PORT', 587),
|
||||||
|
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
|
||||||
|
'username' => env('MAIL_USERNAME'),
|
||||||
|
'password' => env('MAIL_PASSWORD'),
|
||||||
|
'timeout' => null,
|
||||||
|
'local_domain' => env('MAIL_EHLO_DOMAIN'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'transport' => 'ses',
|
||||||
|
],
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'transport' => 'postmark',
|
||||||
|
// 'message_stream_id' => null,
|
||||||
|
// 'client' => [
|
||||||
|
// 'timeout' => 5,
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
'mailgun' => [
|
||||||
|
'transport' => 'mailgun',
|
||||||
|
// 'client' => [
|
||||||
|
// 'timeout' => 5,
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
'sendmail' => [
|
||||||
|
'transport' => 'sendmail',
|
||||||
|
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'transport' => 'log',
|
||||||
|
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'transport' => 'array',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'transport' => 'failover',
|
||||||
|
'mailers' => [
|
||||||
|
'smtp',
|
||||||
|
'log',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'roundrobin' => [
|
||||||
|
'transport' => 'roundrobin',
|
||||||
|
'mailers' => [
|
||||||
|
'ses',
|
||||||
|
'postmark',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Global "From" Address
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| You may wish for all e-mails sent by your application to be sent from
|
||||||
|
| the same address. Here, you may specify a name and address that is
|
||||||
|
| used globally for all e-mails that are sent by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'from' => [
|
||||||
|
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||||
|
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Markdown Mail Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| If you are using Markdown based email rendering, you may configure your
|
||||||
|
| theme and component paths here, allowing you to customize the design
|
||||||
|
| of the emails. Or, you may simply stick with the Laravel defaults!
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'markdown' => [
|
||||||
|
'theme' => 'default',
|
||||||
|
|
||||||
|
'paths' => [
|
||||||
|
resource_path('views/vendor/mail'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Queue Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Laravel's queue API supports an assortment of back-ends via a single
|
||||||
|
| API, giving you convenient access to each back-end using the same
|
||||||
|
| syntax for every one. Here you may define a default connection.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('QUEUE_CONNECTION', 'sync'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the connection information for each server that
|
||||||
|
| is used by your application. A default configuration has been added
|
||||||
|
| for each back-end shipped with Laravel. You are free to add more.
|
||||||
|
|
|
||||||
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sync' => [
|
||||||
|
'driver' => 'sync',
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'table' => 'jobs',
|
||||||
|
'queue' => 'default',
|
||||||
|
'retry_after' => 90,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'beanstalkd' => [
|
||||||
|
'driver' => 'beanstalkd',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'queue' => 'default',
|
||||||
|
'retry_after' => 90,
|
||||||
|
'block_for' => 0,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqs' => [
|
||||||
|
'driver' => 'sqs',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||||
|
'queue' => env('SQS_QUEUE', 'default'),
|
||||||
|
'suffix' => env('SQS_SUFFIX'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => 'default',
|
||||||
|
'queue' => env('REDIS_QUEUE', 'default'),
|
||||||
|
'retry_after' => 90,
|
||||||
|
'block_for' => null,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Job Batching
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following options configure the database and table that store job
|
||||||
|
| batching information. These options can be updated to any database
|
||||||
|
| connection and table which has been defined by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'batching' => [
|
||||||
|
'database' => env('DB_CONNECTION', 'mysql'),
|
||||||
|
'table' => 'job_batches',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Failed Queue Jobs
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These options configure the behavior of failed queue job logging so you
|
||||||
|
| can control which database and table are used to store the jobs that
|
||||||
|
| have failed. You may change them to any database / table you wish.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'failed' => [
|
||||||
|
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||||
|
'database' => env('DB_CONNECTION', 'mysql'),
|
||||||
|
'table' => 'failed_jobs',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stateful Domains
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Requests from the following domains / hosts will receive stateful API
|
||||||
|
| authentication cookies. Typically, these should include your local
|
||||||
|
| and production domains which access your API via a frontend SPA.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||||
|
'%s%s',
|
||||||
|
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||||
|
Sanctum::currentApplicationUrlWithPort()
|
||||||
|
))),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This array contains the authentication guards that will be checked when
|
||||||
|
| Sanctum is trying to authenticate a request. If none of these guards
|
||||||
|
| are able to authenticate the request, Sanctum will use the bearer
|
||||||
|
| token that's present on an incoming request for authentication.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guard' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Expiration Minutes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value controls the number of minutes until an issued token will be
|
||||||
|
| considered expired. This will override any values set in the token's
|
||||||
|
| "expires_at" attribute, but first-party sessions are not affected.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Token Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||||
|
| security scanning initiatives maintained by open source platforms
|
||||||
|
| that notify developers if they commit tokens into repositories.
|
||||||
|
|
|
||||||
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When authenticating your first-party SPA with Sanctum you may need to
|
||||||
|
| customize some of the middleware Sanctum uses while processing the
|
||||||
|
| request. You may change the middleware listed below as required.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => [
|
||||||
|
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
||||||
|
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
|
||||||
|
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Third Party Services
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This file is for storing the credentials for third party services such
|
||||||
|
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||||
|
| location for this type of information, allowing packages to have
|
||||||
|
| a conventional file to locate the various service credentials.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'mailgun' => [
|
||||||
|
'domain' => env('MAILGUN_DOMAIN'),
|
||||||
|
'secret' => env('MAILGUN_SECRET'),
|
||||||
|
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
|
||||||
|
'scheme' => 'https',
|
||||||
|
],
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'token' => env('POSTMARK_TOKEN'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'whatsapp' => [
|
||||||
|
'base_url' => env('WHATSAPP_API_BASE_URL', 'https://graph.facebook.com'),
|
||||||
|
'api_version' => env('WHATSAPP_API_VERSION', 'v23.0'),
|
||||||
|
'token' => env('WHATSAPP_ACCESS_TOKEN'),
|
||||||
|
'phone_number_id' => env('WHATSAPP_PHONE_NUMBER_ID'),
|
||||||
|
'admin_recipient' => env('WHATSAPP_ADMIN_RECIPIENT', env('ADMIN_WHATSAPP_NUMBER')),
|
||||||
|
'admin_name' => env('ADMIN_NAME', 'Showroom'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'car_recommendation' => [
|
||||||
|
'base_url' => env('CAR_RECOMMENDATION_API_URL', 'http://127.0.0.1:5000'),
|
||||||
|
'timeout' => env('CAR_RECOMMENDATION_API_TIMEOUT', 3),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Session Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default session "driver" that will be used on
|
||||||
|
| requests. By default, we will use the lightweight native driver but
|
||||||
|
| you may specify any of the other wonderful drivers provided here.
|
||||||
|
|
|
||||||
|
| Supported: "file", "cookie", "database", "apc",
|
||||||
|
| "memcached", "redis", "dynamodb", "array"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => env('SESSION_DRIVER', 'file'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Lifetime
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the number of minutes that you wish the session
|
||||||
|
| to be allowed to remain idle before it expires. If you want them
|
||||||
|
| to immediately expire on the browser closing, set that option.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lifetime' => env('SESSION_LIFETIME', 120),
|
||||||
|
|
||||||
|
'expire_on_close' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Encryption
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to easily specify that all of your session data
|
||||||
|
| should be encrypted before it is stored. All encryption will be run
|
||||||
|
| automatically by Laravel and you can use the Session like normal.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'encrypt' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session File Location
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the native session driver, we need a location where session
|
||||||
|
| files may be stored. A default has been set for you but a different
|
||||||
|
| location may be specified. This is only needed for file sessions.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'files' => storage_path('framework/sessions'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Connection
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" or "redis" session drivers, you may specify a
|
||||||
|
| connection that should be used to manage these sessions. This should
|
||||||
|
| correspond to a connection in your database configuration options.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connection' => env('SESSION_CONNECTION'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" session driver, you may specify the table we
|
||||||
|
| should use to manage the sessions. Of course, a sensible default is
|
||||||
|
| provided for you; however, you are free to change this as needed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'table' => 'sessions',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| While using one of the framework's cache driven session backends you may
|
||||||
|
| list a cache store that should be used for these sessions. This value
|
||||||
|
| must match with one of the application's configured cache "stores".
|
||||||
|
|
|
||||||
|
| Affects: "apc", "dynamodb", "memcached", "redis"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'store' => env('SESSION_STORE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Sweeping Lottery
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Some session drivers must manually sweep their storage location to get
|
||||||
|
| rid of old sessions from storage. Here are the chances that it will
|
||||||
|
| happen on a given request. By default, the odds are 2 out of 100.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lottery' => [2, 100],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may change the name of the cookie used to identify a session
|
||||||
|
| instance by ID. The name specified here will get used every time a
|
||||||
|
| new session cookie is created by the framework for every driver.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cookie' => env(
|
||||||
|
'SESSION_COOKIE',
|
||||||
|
Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
|
||||||
|
),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The session cookie path determines the path for which the cookie will
|
||||||
|
| be regarded as available. Typically, this will be the root path of
|
||||||
|
| your application but you are free to change this when necessary.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => '/',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Domain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may change the domain of the cookie used to identify a session
|
||||||
|
| in your application. This will determine which domains the cookie is
|
||||||
|
| available to in your application. A sensible default has been set.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'domain' => env('SESSION_DOMAIN'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTPS Only Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By setting this option to true, session cookies will only be sent back
|
||||||
|
| to the server if the browser has a HTTPS connection. This will keep
|
||||||
|
| the cookie from being sent to you when it can't be done securely.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTP Access Only
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will prevent JavaScript from accessing the
|
||||||
|
| value of the cookie and the cookie will only be accessible through
|
||||||
|
| the HTTP protocol. You are free to modify this option if needed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'http_only' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Same-Site Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines how your cookies behave when cross-site requests
|
||||||
|
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||||
|
| will set this value to "lax" since this is a secure default value.
|
||||||
|
|
|
||||||
|
| Supported: "lax", "strict", "none", null
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'same_site' => 'lax',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Partitioned Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will tie the cookie to the top-level site for
|
||||||
|
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||||
|
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'partitioned' => false,
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| View Storage Paths
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Most templating systems load templates from disk. Here you may specify
|
||||||
|
| an array of paths that should be checked for your views. Of course
|
||||||
|
| the usual Laravel view path has already been registered for you.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'paths' => [
|
||||||
|
resource_path('views'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Compiled View Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines where all the compiled Blade templates will be
|
||||||
|
| stored for your application. Typically, this is within the storage
|
||||||
|
| directory. However, as usual, you are free to change this value.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'compiled' => env(
|
||||||
|
'VIEW_COMPILED_PATH',
|
||||||
|
realpath(storage_path('framework/views'))
|
||||||
|
),
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
*.sqlite*
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
merk,model,kategori,tahun,mesin_cc,transmisi,bahan_bakar,Harga,cluster
|
||||||
|
Toyota,Kijang Innova,mpv,2018,1998,manual,bensin,179000000.0,3
|
||||||
|
Daihatsu,Ayla,city car,2011,998,manual,diesel,88000000.0,2
|
||||||
|
Honda,Jazz,hatchback,2022,1497,automatic,diesel,186000000.0,2
|
||||||
|
Honda,Brio,city car,2014,1199,automatic,bensin,114000000.0,0
|
||||||
|
Honda,Brio,city car,2008,1199,manual,diesel,86000000.0,2
|
||||||
|
Hyundai,i10,city car,2015,1086,automatic,bensin,115000000.0,0
|
||||||
|
Toyota,Rush,suv,2020,1496,manual,bensin,269000000.0,3
|
||||||
|
Suzuki,Ertiga,mpv,2013,1462,manual,bensin,173000000.0,3
|
||||||
|
KIA,Picanto,city car,2023,1248,automatic,bensin,141000000.0,0
|
||||||
|
Toyota,Kijang Innova,mpv,2020,1998,manual,bensin,200000000.0,3
|
||||||
|
Mitsubishi,Xpander,mpv,2023,1499,manual,diesel,208000000.0,2
|
||||||
|
Toyota,Innova Reborn,mpv,2021,2393,automatic,bensin,185000000.0,0
|
||||||
|
Honda,Mobilio,mpv,2015,1497,automatic,bensin,170000000.0,0
|
||||||
|
Nissan,Grand Livina,mpv,2011,1498,manual,bensin,158000000.0,3
|
||||||
|
Suzuki,Ertiga,mpv,2012,1462,automatic,bensin,141000000.0,0
|
||||||
|
Hyundai,i10,city car,2010,1086,automatic,bensin,99000000.0,0
|
||||||
|
Honda,Jazz,hatchback,2019,1497,automatic,diesel,164000000.0,2
|
||||||
|
Daihatsu,Terios,suv,2016,1496,manual,bensin,253000000.0,3
|
||||||
|
Suzuki,Karimun Wagon R,city car,2021,998,automatic,bensin,150000000.0,0
|
||||||
|
Daihatsu,Terios,suv,2010,1496,manual,bensin,240000000.0,3
|
||||||
|
Suzuki,Karimun Wagon R,city car,2021,998,manual,diesel,143000000.0,2
|
||||||
|
Honda,Mobilio,mpv,2022,1497,automatic,bensin,211000000.0,0
|
||||||
|
Daihatsu,Terios,suv,2015,1496,manual,bensin,236000000.0,3
|
||||||
|
Mitsubishi,Xpander,mpv,2022,1499,automatic,diesel,189000000.0,2
|
||||||
|
Toyota,Rush,suv,2019,1496,automatic,bensin,254000000.0,0
|
||||||
|
Mitsubishi,Xpander,mpv,2014,1499,automatic,bensin,169000000.0,0
|
||||||
|
Honda,Jazz,hatchback,2012,1497,automatic,diesel,144000000.0,2
|
||||||
|
Mitsubishi,Pajero Sport,suv,2011,2442,automatic,diesel,223000000.0,2
|
||||||
|
Suzuki,Ertiga,mpv,2012,1462,automatic,diesel,148000000.0,2
|
||||||
|
Honda,Jazz,hatchback,2011,1497,automatic,bensin,124000000.0,0
|
||||||
|
KIA,Picanto,city car,2008,1248,manual,bensin,78000000.0,3
|
||||||
|
Suzuki,Karimun Wagon R,city car,2020,998,manual,bensin,132000000.0,3
|
||||||
|
Suzuki,Karimun Wagon R,city car,2016,998,manual,diesel,107000000.0,2
|
||||||
|
Honda,Jazz,hatchback,2022,1497,automatic,diesel,165000000.0,2
|
||||||
|
Toyota,Calya,mpv,2014,1197,manual,bensin,163000000.0,3
|
||||||
|
Toyota,Calya,mpv,2012,1197,manual,diesel,154000000.0,2
|
||||||
|
Honda,Brio,city car,2019,1199,automatic,bensin,138000000.0,0
|
||||||
|
Toyota,Agya,city car,2010,1197,automatic,diesel,101000000.0,2
|
||||||
|
Suzuki,Karimun Wagon R,city car,2015,998,manual,diesel,124000000.0,2
|
||||||
|
Honda,Brio,city car,2008,1199,automatic,bensin,78000000.0,0
|
||||||
|
KIA,Picanto,city car,2022,1248,automatic,diesel,151000000.0,2
|
||||||
|
Toyota,Avanza,mpv,2019,1496,manual,diesel,178000000.0,2
|
||||||
|
Daihatsu,Terios,suv,2015,1496,automatic,bensin,234000000.0,0
|
||||||
|
Suzuki,Karimun Wagon R,city car,2009,998,automatic,bensin,87000000.0,0
|
||||||
|
Toyota,Innova Reborn,mpv,2012,2393,automatic,bensin,162000000.0,0
|
||||||
|
Suzuki,Ertiga,mpv,2021,1462,automatic,bensin,193000000.0,0
|
||||||
|
Nissan,Grand Livina,mpv,2019,1498,automatic,diesel,199000000.0,2
|
||||||
|
Daihatsu,Terios,suv,2023,1496,manual,bensin,265000000.0,3
|
||||||
|
Toyota,Kijang Innova,mpv,2018,1998,manual,diesel,192000000.0,2
|
||||||
|
Mitsubishi,Pajero Sport,suv,2013,2442,automatic,bensin,254000000.0,0
|
||||||
|
Hyundai,i10,city car,2011,1086,manual,bensin,114000000.0,3
|
||||||
|
Toyota,Avanza,mpv,2008,1496,manual,diesel,130000000.0,2
|
||||||
|
Toyota,Kijang Innova,mpv,2019,1998,manual,diesel,174000000.0,2
|
||||||
|
Suzuki,XL7,suv,2019,1462,automatic,diesel,274000000.0,2
|
||||||
|
Suzuki,XL7,suv,2013,1462,automatic,diesel,249000000.0,2
|
||||||
|
Daihatsu,Ayla,city car,2012,998,automatic,bensin,97000000.0,0
|
||||||
|
KIA,Picanto,city car,2012,1248,automatic,bensin,99000000.0,0
|
||||||
|
Suzuki,Ertiga,mpv,2010,1462,automatic,bensin,135000000.0,0
|
||||||
|
Daihatsu,Ayla,city car,2017,998,manual,diesel,128000000.0,2
|
||||||
|
Toyota,Calya,mpv,2013,1197,automatic,bensin,164000000.0,0
|
||||||
|
Toyota,Agya,city car,2014,1197,automatic,diesel,129000000.0,2
|
||||||
|
Daihatsu,Xenia,mpv,2023,1329,automatic,bensin,206000000.0,0
|
||||||
|
Suzuki,Karimun Wagon R,city car,2011,998,manual,diesel,91000000.0,2
|
||||||
|
Mitsubishi,Pajero Sport,suv,2011,2442,automatic,diesel,229000000.0,2
|
||||||
|
Mitsubishi,Pajero Sport,suv,2020,2442,manual,bensin,259000000.0,3
|
||||||
|
Toyota,Avanza,mpv,2008,1496,automatic,diesel,149000000.0,2
|
||||||
|
Honda,Brio,city car,2012,1199,manual,diesel,99000000.0,2
|
||||||
|
Toyota,Calya,mpv,2013,1197,automatic,diesel,147000000.0,2
|
||||||
|
Toyota,Avanza,mpv,2008,1496,manual,diesel,126000000.0,2
|
||||||
|
Toyota,Innova Reborn,mpv,2023,2393,manual,diesel,195000000.0,2
|
||||||
|
Toyota,Innova Reborn,mpv,2021,2393,manual,bensin,188000000.0,3
|
||||||
|
Toyota,Calya,mpv,2010,1197,manual,diesel,160000000.0,2
|
||||||
|
Honda,Jazz,hatchback,2020,1497,automatic,diesel,168000000.0,2
|
||||||
|
Toyota,Kijang Innova,mpv,2010,1998,manual,bensin,154000000.0,3
|
||||||
|
Daihatsu,Xenia,mpv,2016,1329,manual,bensin,158000000.0,3
|
||||||
|
Toyota,Rush,suv,2013,1496,manual,bensin,236000000.0,3
|
||||||
|
Suzuki,Ertiga,mpv,2015,1462,automatic,bensin,169000000.0,0
|
||||||
|
Toyota,Avanza,mpv,2021,1496,automatic,diesel,205000000.0,2
|
||||||
|
Suzuki,Ertiga,mpv,2018,1462,automatic,diesel,190000000.0,2
|
||||||
|
Toyota,Avanza,mpv,2022,1496,automatic,bensin,200000000.0,0
|
||||||
|
Toyota,Calya,mpv,2013,1197,manual,bensin,169000000.0,3
|
||||||
|
Nissan,Grand Livina,mpv,2023,1498,automatic,diesel,212000000.0,2
|
||||||
|
Toyota,Agya,city car,2011,1197,automatic,bensin,99000000.0,0
|
||||||
|
Suzuki,XL7,suv,2013,1462,manual,diesel,230000000.0,2
|
||||||
|
Suzuki,Ertiga,mpv,2023,1462,automatic,diesel,188000000.0,2
|
||||||
|
Toyota,Calya,mpv,2023,1197,automatic,diesel,202000000.0,2
|
||||||
|
Honda,Mobilio,mpv,2008,1497,manual,bensin,149000000.0,3
|
||||||
|
Suzuki,Karimun Wagon R,city car,2014,998,automatic,diesel,112000000.0,2
|
||||||
|
KIA,Picanto,city car,2009,1248,automatic,diesel,99000000.0,2
|
||||||
|
Mitsubishi,Pajero Sport,suv,2010,2442,automatic,bensin,228000000.0,0
|
||||||
|
Suzuki,Karimun Wagon R,city car,2012,998,manual,bensin,98000000.0,3
|
||||||
|
Nissan,Grand Livina,mpv,2008,1498,manual,diesel,132000000.0,2
|
||||||
|
Daihatsu,Terios,suv,2023,1496,manual,diesel,269000000.0,2
|
||||||
|
Honda,Jazz,hatchback,2020,1497,manual,bensin,180000000.0,3
|
||||||
|
Toyota,Innova Reborn,mpv,2013,2393,automatic,diesel,155000000.0,2
|
||||||
|
Suzuki,Ertiga,mpv,2019,1462,automatic,diesel,199000000.0,2
|
||||||
|
Honda,Mobilio,mpv,2016,1497,manual,bensin,164000000.0,3
|
||||||
|
Honda,Jazz,hatchback,2013,1497,manual,bensin,148000000.0,3
|
||||||
|
Toyota,Rush,suv,2013,1496,automatic,bensin,246000000.0,0
|
||||||
|
Honda,Brio,city car,2014,1199,automatic,diesel,120000000.0,2
|
||||||
|
Suzuki,XL7,suv,2021,1462,manual,bensin,266000000.0,3
|
||||||
|
Mitsubishi,Pajero Sport,suv,2019,2442,manual,bensin,255000000.0,3
|
||||||
|
Toyota,Avanza,mpv,2020,1496,manual,bensin,194000000.0,3
|
||||||
|
Suzuki,XL7,suv,2019,1462,manual,diesel,262000000.0,2
|
||||||
|
Honda,Brio,city car,2012,1199,manual,diesel,94000000.0,2
|
||||||
|
Toyota,Kijang Innova,mpv,2016,1998,manual,bensin,170000000.0,3
|
||||||
|
KIA,Picanto,city car,2023,1248,automatic,bensin,165000000.0,0
|
||||||
|
Toyota,Innova Reborn,mpv,2011,2393,automatic,diesel,156000000.0,2
|
||||||
|
Daihatsu,Ayla,city car,2009,998,automatic,diesel,104000000.0,2
|
||||||
|
Daihatsu,Ayla,city car,2019,998,manual,bensin,148000000.0,3
|
||||||
|
Toyota,Agya,city car,2011,1197,manual,diesel,92000000.0,2
|
||||||
|
Mitsubishi,Pajero Sport,suv,2008,2442,automatic,diesel,230000000.0,2
|
||||||
|
Suzuki,Karimun Wagon R,city car,2012,998,manual,diesel,95000000.0,2
|
||||||
|
Mitsubishi,Pajero Sport,suv,2022,2442,manual,bensin,283000000.0,3
|
||||||
|
Toyota,Kijang Innova,mpv,2009,1998,automatic,diesel,133000000.0,2
|
||||||
|
Daihatsu,Terios,suv,2010,1496,automatic,bensin,235000000.0,0
|
||||||
|
Daihatsu,Terios,suv,2013,1496,manual,diesel,248000000.0,2
|
||||||
|
Toyota,Kijang Innova,mpv,2013,1998,automatic,diesel,155000000.0,2
|
||||||
|
Toyota,Agya,city car,2023,1197,automatic,bensin,159000000.0,0
|
||||||
|
Suzuki,XL7,suv,2015,1462,manual,bensin,236000000.0,3
|
||||||
|
Mitsubishi,Xpander,mpv,2017,1499,manual,diesel,185000000.0,2
|
||||||
|
Mitsubishi,Xpander,mpv,2012,1499,automatic,diesel,144000000.0,2
|
||||||
|
Mitsubishi,Xpander,mpv,2016,1499,manual,bensin,158000000.0,3
|
||||||
|
KIA,Picanto,city car,2010,1248,manual,bensin,109000000.0,3
|
||||||
|
Toyota,Agya,city car,2013,1197,automatic,diesel,96000000.0,2
|
||||||
|
Honda,Jazz,hatchback,2020,1497,automatic,bensin,159000000.0,0
|
||||||
|
Daihatsu,Terios,suv,2018,1496,automatic,bensin,263000000.0,0
|
||||||
|
Honda,Jazz,hatchback,2014,1497,manual,bensin,149000000.0,3
|
||||||
|
Toyota,Avanza,mpv,2018,1496,automatic,bensin,182000000.0,0
|
||||||
|
Toyota,Kijang Innova,mpv,2022,1998,manual,diesel,192000000.0,2
|
||||||
|
Mitsubishi,Xpander,mpv,2023,1499,automatic,bensin,204000000.0,0
|
||||||
|
Honda,Jazz,hatchback,2012,1497,manual,bensin,135000000.0,3
|
||||||
|
Suzuki,XL7,suv,2010,1462,manual,diesel,218000000.0,2
|
||||||
|
Suzuki,XL7,suv,2008,1462,manual,diesel,227000000.0,2
|
||||||
|
KIA,Picanto,city car,2023,1248,automatic,bensin,147000000.0,0
|
||||||
|
Honda,Jazz,hatchback,2008,1497,manual,bensin,132000000.0,3
|
||||||
|
Toyota,Avanza,mpv,2014,1496,automatic,diesel,178000000.0,2
|
||||||
|
KIA,Picanto,city car,2012,1248,manual,bensin,108000000.0,3
|
||||||
|
Honda,Jazz,hatchback,2008,1497,automatic,bensin,126000000.0,0
|
||||||
|
Toyota,Kijang Innova,mpv,2011,1998,manual,bensin,149000000.0,3
|
||||||
|
Toyota,Kijang Innova,mpv,2015,1998,automatic,bensin,170000000.0,0
|
||||||
|
Toyota,Agya,city car,2019,1197,manual,diesel,149000000.0,2
|
||||||
|
Daihatsu,Terios,suv,2012,1496,automatic,diesel,236000000.0,2
|
||||||
|
Honda,Brio,city car,2012,1199,manual,diesel,101000000.0,2
|
||||||
|
Hyundai,i10,city car,2017,1086,manual,bensin,126000000.0,3
|
||||||
|
Mitsubishi,Xpander,mpv,2019,1499,automatic,diesel,197000000.0,2
|
||||||
|
Toyota,Innova Reborn,mpv,2009,2393,manual,bensin,141000000.0,3
|
||||||
|
Toyota,Avanza,mpv,2023,1496,automatic,bensin,205000000.0,0
|
||||||
|
Toyota,Calya,mpv,2019,1197,manual,bensin,183000000.0,3
|
||||||
|
Suzuki,Karimun Wagon R,city car,2020,998,automatic,diesel,144000000.0,2
|
||||||
|
Datsun,Go+,hatchback,2021,799,manual,bensin,53000000.0,3
|
||||||
|
Datsun,Go,hatchback,2018,799,automatic,bensin,67000000.0,0
|
||||||
|
Datsun,Redi-Go,hatchback,2022,799,automatic,bensin,63000000.0,0
|
||||||
|
Datsun,Go,hatchback,2019,799,manual,bensin,69000000.0,3
|
||||||
|
Datsun,Go,hatchback,2021,999,manual,bensin,74000000.0,3
|
||||||
|
Datsun,Go,hatchback,2017,999,manual,bensin,83000000.0,3
|
||||||
|
Hyundai,Creta,hatchback,2021,799,manual,bensin,67000000.0,3
|
||||||
|
Wuling,Cortez,hatchback,2022,799,automatic,bensin,54000000.0,0
|
||||||
|
Isuzu,D-Max,hatchback,2019,799,automatic,bensin,73000000.0,0
|
||||||
|
Hyundai,Creta,hatchback,2018,799,automatic,bensin,79000000.0,0
|
||||||
|
Toyota,Innova,hatchback,2022,999,manual,bensin,82000000.0,3
|
||||||
|
Toyota,Avanza,hatchback,2022,999,automatic,bensin,86000000.0,0
|
||||||
|
Honda,HR-V,hatchback,2017,999,automatic,bensin,88000000.0,0
|
||||||
|
Mazda,Mazda3,hatchback,2020,999,manual,bensin,92000000.0,3
|
||||||
|
Kia,Seltos,hatchback,2019,999,manual,bensin,84000000.0,3
|
||||||
|
Isuzu,D-Max,hatchback,2016,999,manual,bensin,89000000.0,3
|
||||||
|
Suzuki,Ignis,hatchback,2022,999,manual,bensin,88000000.0,3
|
||||||
|
Nissan,Livina,hatchback,2020,999,manual,bensin,94000000.0,3
|
||||||
|
Datsun,Redi-Go,hatchback,2016,1198,manual,bensin,71000000.0,3
|
||||||
|
Datsun,Redi-Go,hatchback,2019,1198,manual,bensin,89000000.0,3
|
||||||
|
Datsun,Redi-Go,hatchback,2015,1198,automatic,bensin,95000000.0,0
|
||||||
|
Datsun,Go,hatchback,2020,1198,manual,bensin,95000000.0,3
|
||||||
|
Datsun,Go+,hatchback,2023,1198,manual,bensin,89000000.0,3
|
||||||
|
Datsun,Redi-Go,hatchback,2015,1198,manual,bensin,100000000.0,3
|
||||||
|
Datsun,Redi-Go,hatchback,2017,1198,manual,bensin,105000000.0,3
|
||||||
|
Datsun,Redi-Go,hatchback,2021,1198,automatic,bensin,113000000.0,0
|
||||||
|
Datsun,Redi-Go,hatchback,2018,1198,manual,bensin,117000000.0,3
|
||||||
|
Hyundai,Ioniq,hatchback,2023,1086,manual,bensin,82000000.0,3
|
||||||
|
Hyundai,Ioniq,hatchback,2017,1086,manual,bensin,96000000.0,3
|
||||||
|
Hyundai,Santa Fe,hatchback,2017,1086,manual,bensin,101000000.0,3
|
||||||
|
Hyundai,Ioniq,hatchback,2021,1086,automatic,bensin,97000000.0,0
|
||||||
|
Hyundai,Stargazer,hatchback,2023,1086,manual,bensin,108000000.0,3
|
||||||
|
Hyundai,Santa Fe,hatchback,2021,1086,manual,bensin,106000000.0,3
|
||||||
|
Hyundai,Creta,muv,2016,999,automatic,bensin,94000000.0,0
|
||||||
|
Suzuki,Ertiga,muv,2018,999,automatic,bensin,106000000.0,0
|
||||||
|
Nissan,Magnite,muv,2023,999,automatic,bensin,116000000.0,0
|
||||||
|
Datsun,Go+,muv,2016,999,manual,bensin,126000000.0,3
|
||||||
|
Toyota,Raize,hatchback,2023,1197,manual,bensin,99000000.0,3
|
||||||
|
Toyota,Innova,hatchback,2019,1197,automatic,bensin,110000000.0,0
|
||||||
|
Toyota,Rush,hatchback,2016,1364,manual,solar,123000000.0,1
|
||||||
|
Toyota,Innova,hatchback,2018,1197,manual,bensin,120000000.0,3
|
||||||
|
Toyota,Raize,hatchback,2021,1364,manual,solar,142000000.0,1
|
||||||
|
Toyota,Yaris,hatchback,2022,1364,manual,solar,134000000.0,1
|
||||||
|
Toyota,Rush,hatchback,2017,1197,automatic,bensin,114000000.0,0
|
||||||
|
Toyota,Avanza,hatchback,2015,1364,manual,solar,137000000.0,1
|
||||||
|
Toyota,Raize,hatchback,2018,1197,automatic,bensin,122000000.0,0
|
||||||
|
Toyota,Avanza,hatchback,2016,1364,manual,solar,144000000.0,1
|
||||||
|
Toyota,Innova,hatchback,2022,1197,manual,bensin,106000000.0,3
|
||||||
|
Toyota,Raize,hatchback,2018,1364,manual,solar,130000000.0,1
|
||||||
|
Toyota,Avanza,hatchback,2016,1197,manual,bensin,126000000.0,3
|
||||||
|
Toyota,Innova,hatchback,2020,1364,automatic,solar,148000000.0,1
|
||||||
|
Nissan,Serena,hatchback,2020,1198,automatic,bensin,100000000.0,0
|
||||||
|
Nissan,X-Trail,hatchback,2018,1198,automatic,bensin,114000000.0,0
|
||||||
|
Nissan,X-Trail,hatchback,2020,1198,automatic,bensin,107000000.0,0
|
||||||
|
Hyundai,Santa Fe,sedan,2016,1197,manual,cng + petrol,106000000.0,1
|
||||||
|
Hyundai,Stargazer,sedan,2016,1197,automatic,bensin,102000000.0,0
|
||||||
|
Hyundai,Creta,sedan,2018,1197,manual,bensin,106000000.0,3
|
||||||
|
Hyundai,Creta,sedan,2022,1197,manual,cng + petrol,102000000.0,1
|
||||||
|
Hyundai,Ioniq,sedan,2021,1197,manual,solar,122000000.0,1
|
||||||
|
Hyundai,Santa Fe,sedan,2023,1197,manual,solar,118000000.0,1
|
||||||
|
Hyundai,Stargazer,hatchback,2022,1197,manual,bensin,106000000.0,3
|
||||||
|
Hyundai,Creta,hatchback,2019,1396,manual,solar,133000000.0,1
|
||||||
|
Hyundai,Ioniq,hatchback,2016,1197,automatic,bensin,121000000.0,0
|
||||||
|
Hyundai,Ioniq,hatchback,2019,1396,manual,solar,146000000.0,1
|
||||||
|
Hyundai,Creta,hatchback,2022,1197,manual,bensin,137000000.0,3
|
||||||
|
Hyundai,Ioniq,hatchback,2023,1396,manual,solar,166000000.0,1
|
||||||
|
Hyundai,Stargazer,hatchback,2023,1197,manual,bensin,154000000.0,3
|
||||||
|
Hyundai,Santa Fe,hatchback,2015,1396,automatic,solar,177000000.0,1
|
||||||
|
Hyundai,Ioniq,hatchback,2023,1197,manual,bensin,175000000.0,3
|
||||||
|
Hyundai,Stargazer,hatchback,2021,1197,automatic,bensin,143000000.0,0
|
||||||
|
Hyundai,Stargazer,hatchback,2023,1197,automatic,bensin,158000000.0,0
|
||||||
|
Hyundai,Creta,hatchback,2022,1396,manual,solar,161000000.0,1
|
||||||
|
Hyundai,Stargazer,sedan,2015,1197,manual,bensin,110000000.0,3
|
||||||
|
Hyundai,Creta,sedan,2022,1197,automatic,bensin,125000000.0,0
|
||||||
|
Hyundai,Stargazer,sedan,2022,1197,automatic,bensin,134000000.0,0
|
||||||
|
Hyundai,Creta,sedan,2017,1197,automatic,bensin,139000000.0,0
|
||||||
|
Hyundai,Stargazer,sedan,2015,1197,automatic,bensin,153000000.0,0
|
||||||
|
Hyundai,Ioniq,sedan,2022,1197,manual,bensin,149000000.0,3
|
||||||
|
Hyundai,Stargazer,sedan,2017,1197,manual,cng + petrol,138000000.0,1
|
||||||
|
Hyundai,Stargazer,sedan,2017,998,manual,bensin,162000000.0,3
|
||||||
|
Hyundai,Ioniq,sedan,2015,1186,automatic,solar,147000000.0,1
|
||||||
|
Hyundai,Stargazer,sedan,2019,1186,automatic,solar,157000000.0,1
|
||||||
|
Hyundai,Creta,sedan,2021,1186,manual,solar,175000000.0,1
|
||||||
|
Hyundai,Santa Fe,sedan,2023,1186,manual,solar,172000000.0,1
|
||||||
|
Suzuki,Ertiga,hatchback,2021,999,manual,bensin,111000000.0,3
|
||||||
|
Nissan,Livina,hatchback,2023,999,manual,bensin,129000000.0,3
|
||||||
|
Mazda,Mazda2,hatchback,2022,999,automatic,bensin,148000000.0,0
|
||||||
|
Mazda,CX-5,hatchback,2016,1498,automatic,solar,140000000.0,1
|
||||||
|
Isuzu,Panther,hatchback,2015,1498,automatic,solar,162000000.0,1
|
||||||
|
Honda,HR-V,hatchback,2021,1498,manual,solar,177000000.0,1
|
||||||
|
Mazda,Mazda2,hatchback,2021,1197,manual,bensin,185000000.0,3
|
||||||
|
Toyota,Avanza,hatchback,2022,1498,automatic,solar,188000000.0,1
|
||||||
|
Kia,Seltos,crossover,2019,1498,automatic,solar,129000000.0,1
|
||||||
|
Isuzu,MU-X,hatchback,2017,1498,manual,solar,150000000.0,1
|
||||||
|
Isuzu,MU-X,hatchback,2022,1498,automatic,solar,159000000.0,1
|
||||||
|
Mazda,CX-5,hatchback,2020,1194,manual,bensin,112000000.0,3
|
||||||
|
Kia,Sportage,hatchback,2017,1194,manual,bensin,137000000.0,3
|
||||||
|
Isuzu,Panther,hatchback,2015,1194,manual,bensin,144000000.0,3
|
||||||
|
Honda,CR-V,crossover,2017,1194,manual,bensin,129000000.0,3
|
||||||
|
Toyota,Avanza,crossover,2019,1498,manual,solar,142000000.0,1
|
||||||
|
Honda,Brio,sedan,2017,1498,manual,solar,135000000.0,1
|
||||||
|
Wuling,Cortez,sedan,2015,1498,automatic,solar,154000000.0,1
|
||||||
|
Wuling,Almaz,sedan,2019,1498,manual,solar,190000000.0,1
|
||||||
|
Mitsubishi,Triton,sedan,2021,1498,manual,solar,176000000.0,1
|
||||||
|
Datsun,Redi-Go,sedan,2021,999,automatic,bensin,113000000.0,0
|
||||||
|
Isuzu,Panther,sedan,2023,999,manual,bensin,126000000.0,3
|
||||||
|
Wuling,Confero,sedan,2017,999,manual,bensin,152000000.0,3
|
||||||
|
Mazda,Mazda2,sedan,2021,1498,automatic,solar,190000000.0,1
|
||||||
|
Mitsubishi,Xpander,sedan,2015,1194,manual,bensin,114000000.0,3
|
||||||
|
Kia,Seltos,sedan,2018,1194,manual,bensin,133000000.0,3
|
||||||
|
Hyundai,Ioniq,sedan,2018,1194,manual,bensin,126000000.0,3
|
||||||
|
Honda,Brio,sedan,2019,1498,manual,solar,133000000.0,1
|
||||||
|
Isuzu,Panther,sedan,2021,1498,automatic,solar,140000000.0,1
|
||||||
|
Mitsubishi,Xpander,sedan,2021,1498,automatic,solar,148000000.0,1
|
||||||
|
Nissan,X-Trail,sedan,2018,1498,automatic,solar,155000000.0,1
|
||||||
|
Wuling,Confero,sedan,2021,1498,automatic,solar,164000000.0,1
|
||||||
|
Honda,Jazz,sedan,2017,1194,automatic,bensin,149000000.0,0
|
||||||
|
Datsun,Go,sedan,2020,1194,manual,bensin,140000000.0,3
|
||||||
|
Toyota,Fortuner,sedan,2023,1194,automatic,bensin,145000000.0,0
|
||||||
|
Mitsubishi,Xpander,sedan,2019,1498,manual,solar,160000000.0,1
|
||||||
|
Toyota,Innova,sedan,2020,1496,automatic,bensin,119000000.0,0
|
||||||
|
Toyota,Rush,sedan,2018,1496,automatic,bensin,137000000.0,0
|
||||||
|
Toyota,Yaris,sedan,2021,1364,manual,solar,140000000.0,1
|
||||||
|
Toyota,Yaris,sedan,2023,1364,manual,solar,157000000.0,1
|
||||||
|
Toyota,Fortuner,sedan,2021,1364,manual,solar,169000000.0,1
|
||||||
|
Toyota,Fortuner,sedan,2015,1496,manual,bensin,148000000.0,3
|
||||||
|
Toyota,Fortuner,sedan,2015,1496,automatic,bensin,131000000.0,0
|
||||||
|
Toyota,Fortuner,sedan,2023,1364,automatic,solar,152000000.0,1
|
||||||
|
Toyota,Rush,sedan,2023,1364,manual,solar,174000000.0,1
|
||||||
|
Toyota,Fortuner,sedan,2018,1496,manual,bensin,153000000.0,3
|
||||||
|
Toyota,Fortuner,hatchback,2023,1496,manual,bensin,152000000.0,3
|
||||||
|
Toyota,Raize,hatchback,2017,1197,manual,bensin,124000000.0,3
|
||||||
|
Toyota,Yaris,hatchback,2021,1364,manual,solar,152000000.0,1
|
||||||
|
Toyota,Fortuner,hatchback,2020,1364,manual,solar,146000000.0,1
|
||||||
|
Toyota,Rush,hatchback,2022,1197,automatic,bensin,127000000.0,0
|
||||||
|
Toyota,Yaris,hatchback,2023,1364,manual,solar,155000000.0,1
|
||||||
|
Toyota,Yaris,hatchback,2019,1197,manual,bensin,137000000.0,3
|
||||||
|
Toyota,Avanza,hatchback,2015,1197,automatic,bensin,144000000.0,0
|
||||||
|
Toyota,Fortuner,hatchback,2017,1197,manual,bensin,158000000.0,3
|
||||||
|
Toyota,Fortuner,hatchback,2022,1197,automatic,bensin,169000000.0,0
|
||||||
|
Toyota,Avanza,hatchback,2020,1197,automatic,bensin,133000000.0,0
|
||||||
|
Honda,CR-V,hatchback,2022,1199,manual,bensin,142000000.0,3
|
||||||
|
Honda,City,hatchback,2023,1199,manual,bensin,173000000.0,3
|
||||||
|
Honda,City,hatchback,2018,1199,manual,bensin,150000000.0,3
|
||||||
|
Honda,Jazz,hatchback,2015,1199,manual,bensin,164000000.0,3
|
||||||
|
Honda,Brio,hatchback,2015,1498,manual,solar,155000000.0,1
|
||||||
|
Honda,HR-V,hatchback,2018,1498,automatic,solar,170000000.0,1
|
||||||
|
Honda,HR-V,hatchback,2021,1498,manual,solar,179000000.0,1
|
||||||
|
Honda,HR-V,hatchback,2016,1199,manual,bensin,176000000.0,3
|
||||||
|
Hyundai,Ioniq,hatchback,2020,1120,manual,solar,105000000.0,1
|
||||||
|
Hyundai,Ioniq,hatchback,2021,1120,automatic,solar,108000000.0,1
|
||||||
|
Hyundai,Stargazer,hatchback,2019,1197,manual,bensin,87000000.0,3
|
||||||
|
Hyundai,Creta,hatchback,2015,1197,automatic,bensin,91000000.0,0
|
||||||
|
Honda,Jazz,sedan,2016,1199,automatic,bensin,145000000.0,0
|
||||||
|
Honda,HR-V,sedan,2019,1498,manual,solar,134000000.0,1
|
||||||
|
Honda,City,sedan,2020,1199,manual,bensin,113000000.0,3
|
||||||
|
Honda,Jazz,sedan,2021,1498,automatic,solar,164000000.0,1
|
||||||
|
Honda,Brio,sedan,2018,1199,manual,bensin,156000000.0,3
|
||||||
|
Honda,Brio,sedan,2021,1498,manual,solar,176000000.0,1
|
||||||
|
Honda,HR-V,sedan,2022,1498,manual,solar,149000000.0,1
|
||||||
|
Honda,Mobilio,sedan,2015,1498,manual,solar,170000000.0,1
|
||||||
|
Honda,Jazz,sedan,2020,1498,automatic,solar,161000000.0,1
|
||||||
|
Honda,Brio,sedan,2022,1199,manual,bensin,148000000.0,3
|
||||||
|
Honda,Brio,sedan,2019,1199,manual,bensin,139000000.0,3
|
||||||
|
Honda,CR-V,sedan,2018,1198,manual,bensin,128000000.0,3
|
||||||
|
Honda,Jazz,sedan,2016,1498,automatic,solar,172000000.0,1
|
||||||
|
Honda,Brio,sedan,2020,1199,manual,bensin,151000000.0,3
|
||||||
|
Honda,Mobilio,sedan,2020,1498,automatic,solar,184000000.0,1
|
||||||
|
Honda,CR-V,sedan,2015,1199,manual,bensin,164000000.0,3
|
||||||
|
Honda,HR-V,sedan,2023,1498,automatic,solar,186000000.0,1
|
||||||
|
Honda,CR-V,sedan,2020,1199,manual,bensin,167000000.0,3
|
||||||
|
Honda,Mobilio,sedan,2017,1498,manual,solar,172000000.0,1
|
||||||
|
Honda,City,sedan,2018,1199,manual,bensin,151000000.0,3
|
||||||
|
Hyundai,Santa Fe,suv,2018,1197,automatic,bensin,124000000.0,0
|
||||||
|
Hyundai,Creta,suv,2017,998,automatic,bensin,157000000.0,0
|
||||||
|
Hyundai,Ioniq,suv,2017,1397,manual,solar,148000000.0,1
|
||||||
|
Hyundai,Creta,suv,2017,998,automatic,bensin,182000000.0,0
|
||||||
|
Hyundai,Creta,suv,2018,998,automatic,bensin,185000000.0,0
|
||||||
|
Hyundai,Creta,suv,2021,998,manual,bensin,202000000.0,3
|
||||||
|
Hyundai,Santa Fe,suv,2018,998,manual,bensin,179000000.0,3
|
||||||
|
Hyundai,Santa Fe,suv,2023,998,manual,bensin,212000000.0,3
|
||||||
|
Hyundai,Ioniq,suv,2015,1197,manual,bensin,138000000.0,3
|
||||||
|
Hyundai,Ioniq,suv,2022,1397,automatic,solar,162000000.0,1
|
||||||
|
Hyundai,Creta,suv,2021,1397,automatic,solar,187000000.0,1
|
||||||
|
Hyundai,Santa Fe,suv,2016,1397,automatic,solar,190000000.0,1
|
||||||
|
Hyundai,Stargazer,suv,2022,1397,manual,solar,207000000.0,1
|
||||||
|
Hyundai,Ioniq,hatchback,2015,1197,manual,bensin,147000000.0,3
|
||||||
|
Hyundai,Stargazer,hatchback,2023,1197,automatic,bensin,163000000.0,0
|
||||||
|
Hyundai,Stargazer,hatchback,2023,1197,automatic,bensin,168000000.0,0
|
||||||
|
Hyundai,Creta,hatchback,2016,1396,automatic,solar,189000000.0,1
|
||||||
|
Nissan,X-Trail,suv,2021,1497,automatic,bensin,150000000.0,0
|
||||||
|
Isuzu,D-Max,suv,2017,1497,automatic,bensin,165000000.0,0
|
||||||
|
Suzuki,Swift,suv,2021,1497,manual,bensin,180000000.0,3
|
||||||
|
Mazda,Mazda2,suv,2023,1497,manual,bensin,215000000.0,3
|
||||||
|
Mazda,CX-5,suv,2018,1498,automatic,solar,160000000.0,1
|
||||||
|
Datsun,Go,suv,2015,1498,automatic,solar,175000000.0,1
|
||||||
|
Isuzu,MU-X,suv,2016,1498,manual,solar,190000000.0,1
|
||||||
|
Wuling,Confero,suv,2015,1498,manual,solar,207000000.0,1
|
||||||
|
Kia,Sportage,suv,2019,1497,automatic,bensin,190000000.0,0
|
||||||
|
Isuzu,Panther,suv,2019,1498,manual,solar,218000000.0,1
|
||||||
|
Mitsubishi,Xpander,suv,2021,1497,manual,bensin,190000000.0,3
|
||||||
|
Mazda,CX-3,suv,2023,1498,manual,solar,207000000.0,1
|
||||||
|
Mazda,Mazda3,suv,2023,1498,automatic,bensin,152000000.0,0
|
||||||
|
Nissan,Magnite,suv,2017,1498,automatic,bensin,175000000.0,0
|
||||||
|
Hyundai,Creta,suv,2017,1498,manual,bensin,190000000.0,3
|
||||||
|
Isuzu,MU-X,suv,2017,1461,automatic,solar,177000000.0,1
|
||||||
|
Mitsubishi,Triton,suv,2018,1461,manual,solar,190000000.0,1
|
||||||
|
Kia,Sonet,suv,2022,1461,manual,solar,213000000.0,1
|
||||||
|
Wuling,Confero,suv,2020,1461,automatic,solar,230000000.0,1
|
||||||
|
Toyota,Fortuner,suv,2022,1461,automatic,solar,237000000.0,1
|
||||||
|
Suzuki,Ertiga,suv,2015,1461,automatic,solar,237000000.0,1
|
||||||
|
Hyundai,Stargazer,sedan,2022,1591,automatic,bensin,190000000.0,0
|
||||||
|
Hyundai,Stargazer,sedan,2018,1582,manual,solar,223000000.0,1
|
||||||
|
Hyundai,Santa Fe,sedan,2015,1582,manual,solar,247000000.0,1
|
||||||
|
Hyundai,Stargazer,sedan,2022,1591,manual,bensin,223000000.0,3
|
||||||
|
Hyundai,Ioniq,sedan,2018,1582,manual,solar,252000000.0,1
|
||||||
|
Hyundai,Santa Fe,sedan,2020,1591,manual,bensin,245000000.0,3
|
||||||
|
Hyundai,Creta,sedan,2022,1396,automatic,bensin,177000000.0,0
|
||||||
|
Hyundai,Stargazer,sedan,2018,1396,manual,bensin,155000000.0,3
|
||||||
|
Hyundai,Stargazer,sedan,2017,1591,automatic,bensin,224000000.0,0
|
||||||
|
Hyundai,Creta,sedan,2023,1591,automatic,bensin,221000000.0,0
|
||||||
|
Hyundai,Stargazer,sedan,2017,1582,automatic,solar,267000000.0,1
|
||||||
|
Hyundai,Ioniq,sedan,2023,1396,manual,solar,179000000.0,1
|
||||||
|
Hyundai,Ioniq,sedan,2016,1396,manual,solar,190000000.0,1
|
||||||
|
Datsun,Go+,muv,2016,1461,manual,solar,164000000.0,1
|
||||||
|
Mazda,Mazda2,muv,2016,1461,manual,solar,183000000.0,1
|
||||||
|
Mazda,Mazda3,muv,2020,1461,automatic,solar,230000000.0,1
|
||||||
|
Isuzu,D-Max,muv,2017,1461,manual,solar,230000000.0,1
|
||||||
|
Mazda,CX-3,muv,2023,1461,manual,solar,200000000.0,1
|
||||||
|
Mitsubishi,Outlander,muv,2018,1461,automatic,solar,215000000.0,1
|
||||||
|
Datsun,Go,muv,2015,1461,manual,solar,183000000.0,1
|
||||||
|
Toyota,Avanza,sedan,2018,1598,automatic,bensin,167000000.0,0
|
||||||
|
Wuling,Confero,sedan,2015,1598,automatic,bensin,190000000.0,0
|
||||||
|
Mazda,Mazda2,sedan,2019,1598,automatic,bensin,190000000.0,0
|
||||||
|
Mazda,CX-3,sedan,2018,1197,manual,bensin,228000000.0,3
|
||||||
|
Honda,CR-V,sedan,2022,1197,manual,bensin,275000000.0,3
|
||||||
|
Honda,Mobilio,sedan,2022,1498,automatic,solar,182000000.0,1
|
||||||
|
Mitsubishi,Triton,sedan,2021,1498,manual,solar,190000000.0,1
|
||||||
|
Toyota,Fortuner,sedan,2017,1498,automatic,solar,230000000.0,1
|
||||||
|
Wuling,Air EV,sedan,2015,1498,automatic,solar,254000000.0,1
|
||||||
|
Kia,Sonet,sedan,2015,1498,automatic,solar,250000000.0,1
|
||||||
|
Nissan,X-Trail,hatchback,2017,1498,manual,solar,275000000.0,1
|
||||||
|
Hyundai,Santa Fe,hatchback,2020,1197,manual,bensin,250000000.0,3
|
||||||
|
Honda,City,suv,2021,1497,automatic,bensin,181000000.0,0
|
||||||
|
Honda,Mobilio,suv,2020,1497,manual,bensin,200000000.0,3
|
||||||
|
Honda,HR-V,suv,2020,1497,manual,bensin,222000000.0,3
|
||||||
|
Honda,HR-V,suv,2020,1497,manual,bensin,242000000.0,3
|
||||||
|
Honda,Mobilio,suv,2017,1497,automatic,bensin,244000000.0,0
|
||||||
|
Honda,Jazz,suv,2020,1498,automatic,solar,226000000.0,1
|
||||||
|
Honda,HR-V,suv,2022,1498,manual,solar,242000000.0,1
|
||||||
|
Honda,City,suv,2016,1498,manual,solar,263000000.0,1
|
||||||
|
Toyota,Rush,muv,2019,2755,manual,bensin,284000000.0,3
|
||||||
|
Toyota,Fortuner,muv,2020,2755,automatic,bensin,285000000.0,0
|
||||||
|
Mazda,Mazda2,hatchback,2016,1194,automatic,bensin,99000000.0,0
|
||||||
|
Wuling,Almaz,hatchback,2023,1498,automatic,solar,118000000.0,1
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||||
|
*/
|
||||||
|
class UserFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The current password being used by the factory.
|
||||||
|
*/
|
||||||
|
protected static ?string $password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => fake()->name(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
'remember_token' => Str::random(10),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the model's email address should be unverified.
|
||||||
|
*/
|
||||||
|
public function unverified(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('users', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
|
$table->string('password');
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||||
|
$table->string('email')->primary();
|
||||||
|
$table->string('token');
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('password_reset_tokens');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('uuid')->unique();
|
||||||
|
$table->text('connection');
|
||||||
|
$table->text('queue');
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->longText('exception');
|
||||||
|
$table->timestamp('failed_at')->useCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('failed_jobs');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->morphs('tokenable');
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('token', 64)->unique();
|
||||||
|
$table->text('abilities')->nullable();
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('personal_access_tokens');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::create('categories', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->text('description');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('categories');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->decimal('price', 14, 2)->default(0)->after('description');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('price');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->string('image')->nullable()->after('price');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('image');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->integer('year')->nullable()->after('price');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('year');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('favorites', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('username'); // Reference to user.username
|
||||||
|
$table->unsignedBigInteger('category_id'); // Reference to categories.id
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Index for performance
|
||||||
|
$table->index(['username', 'category_id']);
|
||||||
|
|
||||||
|
// Unique constraint to prevent duplicate favorites
|
||||||
|
$table->unique(['username', 'category_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('favorites');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
\DB::statement("ALTER TABLE favorites MODIFY username VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
\DB::statement("ALTER TABLE favorites MODIFY username VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('user', function (Blueprint $table) {
|
||||||
|
$table->string('phone_number')->nullable()->after('email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('user', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('phone_number');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->string('seller_username')->nullable()->after('image');
|
||||||
|
$table->index('seller_username');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['seller_username']);
|
||||||
|
$table->dropColumn('seller_username');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('user', function (Blueprint $table) {
|
||||||
|
if (!Schema::hasColumn('user', 'email_verified_at')) {
|
||||||
|
$table->timestamp('email_verified_at')->nullable()->after('email');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('user', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('user', 'email_verified_at')) {
|
||||||
|
$table->dropColumn('email_verified_at');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::create('transactions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('transaction_code')->unique();
|
||||||
|
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
|
||||||
|
$table->string('admin_username')->nullable();
|
||||||
|
$table->string('customer_name');
|
||||||
|
$table->string('customer_phone')->nullable();
|
||||||
|
$table->string('customer_email')->nullable();
|
||||||
|
$table->unsignedInteger('quantity')->default(1);
|
||||||
|
$table->decimal('unit_price', 14, 2)->default(0);
|
||||||
|
$table->decimal('total_amount', 14, 2)->default(0);
|
||||||
|
$table->string('payment_method')->default('cash');
|
||||||
|
$table->string('payment_status')->default('pending');
|
||||||
|
$table->text('note')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('transactions');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('user', function (Blueprint $table) {
|
||||||
|
if (!Schema::hasColumn('user', 'login_otp')) {
|
||||||
|
$table->string('login_otp')->nullable()->after('email_verified_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Schema::hasColumn('user', 'login_otp_expires_at')) {
|
||||||
|
$table->timestamp('login_otp_expires_at')->nullable()->after('login_otp');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('user', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('user', 'login_otp_expires_at')) {
|
||||||
|
$table->dropColumn('login_otp_expires_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('user', 'login_otp')) {
|
||||||
|
$table->dropColumn('login_otp');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::table('transactions', function (Blueprint $table) {
|
||||||
|
$table->string('payment_proof')->nullable()->after('payment_status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('transactions', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('payment_proof');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->string('status')->default('available')->after('image');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::create('users', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
|
$table->string('password');
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('categories', 'seller_username')) {
|
||||||
|
$table->dropIndex(['seller_username']);
|
||||||
|
$table->dropColumn('seller_username');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('user', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('user', 'konfirmasi_password')) {
|
||||||
|
$table->dropColumn('konfirmasi_password');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
if (!Schema::hasColumn('categories', 'seller_username')) {
|
||||||
|
$table->string('seller_username')->nullable()->after('status');
|
||||||
|
$table->index('seller_username');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('user', function (Blueprint $table) {
|
||||||
|
if (!Schema::hasColumn('user', 'konfirmasi_password')) {
|
||||||
|
$table->string('konfirmasi_password')->nullable()->after('phone_number');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('dataset_row_id')->nullable()->after('id');
|
||||||
|
$table->string('dataset_hash', 64)->nullable()->after('dataset_row_id');
|
||||||
|
$table->string('merk')->nullable()->after('description');
|
||||||
|
$table->string('model')->nullable()->after('merk');
|
||||||
|
$table->string('kategori')->nullable()->after('model');
|
||||||
|
$table->unsignedInteger('mesin_cc')->nullable()->after('year');
|
||||||
|
$table->string('transmisi')->nullable()->after('mesin_cc');
|
||||||
|
$table->string('bahan_bakar')->nullable()->after('transmisi');
|
||||||
|
$table->integer('cluster')->nullable()->after('bahan_bakar');
|
||||||
|
|
||||||
|
$table->unique('dataset_hash');
|
||||||
|
$table->index('dataset_row_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['dataset_hash']);
|
||||||
|
$table->dropIndex(['dataset_row_id']);
|
||||||
|
$table->dropColumn([
|
||||||
|
'dataset_row_id',
|
||||||
|
'dataset_hash',
|
||||||
|
'merk',
|
||||||
|
'model',
|
||||||
|
'kategori',
|
||||||
|
'mesin_cc',
|
||||||
|
'transmisi',
|
||||||
|
'bahan_bakar',
|
||||||
|
'cluster',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::statement('ALTER TABLE favorites ADD CONSTRAINT favorites_username_foreign FOREIGN KEY (username) REFERENCES user(username) ON DELETE CASCADE ON UPDATE CASCADE');
|
||||||
|
DB::statement('ALTER TABLE favorites ADD CONSTRAINT favorites_category_id_foreign FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE ON UPDATE CASCADE');
|
||||||
|
|
||||||
|
DB::statement('ALTER TABLE transactions MODIFY admin_username VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL');
|
||||||
|
DB::statement('ALTER TABLE transactions ADD CONSTRAINT transactions_admin_username_foreign FOREIGN KEY (admin_username) REFERENCES admin(username) ON DELETE SET NULL ON UPDATE CASCADE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('transactions', function ($table) {
|
||||||
|
$table->dropForeign('transactions_admin_username_foreign');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::statement('ALTER TABLE transactions MODIFY admin_username VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL');
|
||||||
|
|
||||||
|
Schema::table('favorites', function ($table) {
|
||||||
|
$table->dropForeign('favorites_category_id_foreign');
|
||||||
|
$table->dropForeign('favorites_username_foreign');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->timestamp('reserved_until')->nullable()->after('status');
|
||||||
|
$table->string('reserved_by')->nullable()->after('reserved_until');
|
||||||
|
$table->index(['status', 'reserved_until']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('transactions', function (Blueprint $table) {
|
||||||
|
$table->timestamp('expires_at')->nullable()->after('payment_status');
|
||||||
|
$table->index(['payment_status', 'expires_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('transactions', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['payment_status', 'expires_at']);
|
||||||
|
$table->dropColumn('expires_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['status', 'reserved_until']);
|
||||||
|
$table->dropColumn(['reserved_until', 'reserved_by']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['dataset_hash']);
|
||||||
|
$table->dropIndex(['dataset_row_id']);
|
||||||
|
$table->dropColumn([
|
||||||
|
'dataset_row_id',
|
||||||
|
'dataset_hash',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('dataset_row_id')->nullable()->after('id');
|
||||||
|
$table->string('dataset_hash', 64)->nullable()->after('dataset_row_id');
|
||||||
|
|
||||||
|
$table->unique('dataset_hash');
|
||||||
|
$table->index('dataset_row_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('transactions', 'reserved_by')) {
|
||||||
|
Schema::table('transactions', function (Blueprint $table) {
|
||||||
|
$table->string('reserved_by')->nullable()->after('expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('categories', 'reserved_by')) {
|
||||||
|
DB::table('categories')
|
||||||
|
->whereNotNull('reserved_by')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->each(function ($category) {
|
||||||
|
DB::table('transactions')
|
||||||
|
->where('category_id', $category->id)
|
||||||
|
->where('payment_status', 'pending')
|
||||||
|
->whereNull('reserved_by')
|
||||||
|
->update(['reserved_by' => $category->reserved_by]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('categories', 'reserved_until')) {
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['status', 'reserved_until']);
|
||||||
|
$table->dropColumn(['reserved_until', 'reserved_by']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('categories', 'reserved_until')) {
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->timestamp('reserved_until')->nullable()->after('status');
|
||||||
|
$table->string('reserved_by')->nullable()->after('reserved_until');
|
||||||
|
$table->index(['status', 'reserved_until']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('transactions', 'reserved_by')) {
|
||||||
|
DB::table('transactions')
|
||||||
|
->where('payment_status', 'pending')
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '>', now())
|
||||||
|
->whereNotNull('reserved_by')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->each(function ($transaction) {
|
||||||
|
DB::table('categories')
|
||||||
|
->where('id', $transaction->category_id)
|
||||||
|
->update([
|
||||||
|
'reserved_until' => $transaction->expires_at,
|
||||||
|
'reserved_by' => $transaction->reserved_by,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('transactions', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('reserved_by');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('categories', 'cluster')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('cluster');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('categories', 'cluster')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('categories', function (Blueprint $table) {
|
||||||
|
$table->integer('cluster')->nullable()->after('bahan_bakar');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('categories', 'status')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('categories')
|
||||||
|
->whereNull('status')
|
||||||
|
->orWhere('status', '')
|
||||||
|
->update(['status' => 'available']);
|
||||||
|
|
||||||
|
DB::statement("ALTER TABLE categories MODIFY status VARCHAR(255) NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('categories', 'status')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::statement("ALTER TABLE categories MODIFY status VARCHAR(255) NOT NULL DEFAULT 'available'");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class CategorySeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$csvPath = collect([
|
||||||
|
storage_path('app/dataset_final.csv'),
|
||||||
|
database_path('dataset_final.csv'),
|
||||||
|
database_path('dataset_final.xls'),
|
||||||
|
storage_path('app/dataset_mobil.csv'),
|
||||||
|
])->first(fn ($path) => file_exists($path));
|
||||||
|
|
||||||
|
if (! $csvPath) {
|
||||||
|
$this->command->warn('File CSV dataset mobil tidak ditemukan.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command->info("Menggunakan dataset: {$csvPath}");
|
||||||
|
|
||||||
|
$file = fopen($csvPath, 'r');
|
||||||
|
fgetcsv($file);
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$batchSize = 50;
|
||||||
|
|
||||||
|
while (($data = fgetcsv($file)) !== false) {
|
||||||
|
if (count($data) < 8) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
$this->insertCategory($data);
|
||||||
|
|
||||||
|
if ($count % $batchSize === 0) {
|
||||||
|
$this->command->info("Batch processed: {$count} records...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($file);
|
||||||
|
|
||||||
|
$this->command->info("Successfully imported {$count} vehicles.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function insertCategory(array $data): void
|
||||||
|
{
|
||||||
|
$merk = trim($data[0] ?? '');
|
||||||
|
$model = trim($data[1] ?? '');
|
||||||
|
$kategori = trim($data[2] ?? '');
|
||||||
|
$tahun = (int) ($data[3] ?? 2023);
|
||||||
|
$mesinCc = (int) ($data[4] ?? 0);
|
||||||
|
$transmisi = trim($data[5] ?? '');
|
||||||
|
$bahanBakar = trim($data[6] ?? '');
|
||||||
|
$harga = (float) ($data[7] ?? 0);
|
||||||
|
if ($merk === '' || $model === '' || $harga <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = "{$merk} {$model}";
|
||||||
|
$description = "{$kategori} | {$mesinCc}cc | {$transmisi} | {$bahanBakar}";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$category = Category::where('name', $name)
|
||||||
|
->where('year', $tahun)
|
||||||
|
->where('price', $harga)
|
||||||
|
->where('description', $description)
|
||||||
|
->first()
|
||||||
|
?? new Category();
|
||||||
|
|
||||||
|
$category->fill([
|
||||||
|
'name' => $name,
|
||||||
|
'description' => $description,
|
||||||
|
'merk' => $merk,
|
||||||
|
'model' => $model,
|
||||||
|
'kategori' => $kategori,
|
||||||
|
'price' => $harga,
|
||||||
|
'year' => $tahun,
|
||||||
|
'mesin_cc' => $mesinCc,
|
||||||
|
'transmisi' => $transmisi,
|
||||||
|
'bahan_bakar' => $bahanBakar,
|
||||||
|
'status' => $category->exists ? $category->status : 'available',
|
||||||
|
])->save();
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
$this->command->warn("Error inserting {$name}: {$exception->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class DatabaseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Seed the application's database.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Seed default Admin
|
||||||
|
DB::table('admin')->updateOrInsert(
|
||||||
|
['username' => 'admin'],
|
||||||
|
[
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed default User
|
||||||
|
DB::table('user')->updateOrInsert(
|
||||||
|
['username' => 'user'],
|
||||||
|
[
|
||||||
|
'email' => 'user@example.com',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
'phone_number' => '081234567890',
|
||||||
|
'role' => 'user',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed sample categories if CategorySeeder exists
|
||||||
|
if (class_exists(CategorySeeder::class)) {
|
||||||
|
$this->call(CategorySeeder::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
SET @database_name = DATABASE();
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @database_name
|
||||||
|
AND TABLE_NAME = 'categories'
|
||||||
|
AND COLUMN_NAME = 'reserved_until'
|
||||||
|
) = 0,
|
||||||
|
'ALTER TABLE `categories` ADD COLUMN `reserved_until` timestamp NULL DEFAULT NULL AFTER `status`',
|
||||||
|
'SELECT ''categories.reserved_until already exists'' AS message'
|
||||||
|
);
|
||||||
|
PREPARE statement FROM @sql;
|
||||||
|
EXECUTE statement;
|
||||||
|
DEALLOCATE PREPARE statement;
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @database_name
|
||||||
|
AND TABLE_NAME = 'categories'
|
||||||
|
AND COLUMN_NAME = 'reserved_by'
|
||||||
|
) = 0,
|
||||||
|
'ALTER TABLE `categories` ADD COLUMN `reserved_by` varchar(255) DEFAULT NULL AFTER `reserved_until`',
|
||||||
|
'SELECT ''categories.reserved_by already exists'' AS message'
|
||||||
|
);
|
||||||
|
PREPARE statement FROM @sql;
|
||||||
|
EXECUTE statement;
|
||||||
|
DEALLOCATE PREPARE statement;
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @database_name
|
||||||
|
AND TABLE_NAME = 'categories'
|
||||||
|
AND INDEX_NAME = 'categories_status_reserved_until_index'
|
||||||
|
) = 0,
|
||||||
|
'ALTER TABLE `categories` ADD INDEX `categories_status_reserved_until_index` (`status`, `reserved_until`)',
|
||||||
|
'SELECT ''categories_status_reserved_until_index already exists'' AS message'
|
||||||
|
);
|
||||||
|
PREPARE statement FROM @sql;
|
||||||
|
EXECUTE statement;
|
||||||
|
DEALLOCATE PREPARE statement;
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = @database_name
|
||||||
|
AND TABLE_NAME = 'transactions'
|
||||||
|
) > 0
|
||||||
|
AND (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @database_name
|
||||||
|
AND TABLE_NAME = 'transactions'
|
||||||
|
AND COLUMN_NAME = 'expires_at'
|
||||||
|
) = 0,
|
||||||
|
'ALTER TABLE `transactions` ADD COLUMN `expires_at` timestamp NULL DEFAULT NULL AFTER `payment_status`',
|
||||||
|
'SELECT ''transactions.expires_at already exists or transactions table not found'' AS message'
|
||||||
|
);
|
||||||
|
PREPARE statement FROM @sql;
|
||||||
|
EXECUTE statement;
|
||||||
|
DEALLOCATE PREPARE statement;
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = @database_name
|
||||||
|
AND TABLE_NAME = 'transactions'
|
||||||
|
) > 0
|
||||||
|
AND (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @database_name
|
||||||
|
AND TABLE_NAME = 'transactions'
|
||||||
|
AND INDEX_NAME = 'transactions_payment_status_expires_at_index'
|
||||||
|
) = 0,
|
||||||
|
'ALTER TABLE `transactions` ADD INDEX `transactions_payment_status_expires_at_index` (`payment_status`, `expires_at`)',
|
||||||
|
'SELECT ''transactions_payment_status_expires_at_index already exists or transactions table not found'' AS message'
|
||||||
|
);
|
||||||
|
PREPARE statement FROM @sql;
|
||||||
|
EXECUTE statement;
|
||||||
|
DEALLOCATE PREPARE statement;
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
-- phpMyAdmin SQL Dump
|
||||||
|
-- Database: `tamyhost_E31231762`
|
||||||
|
-- Server version: 8.0.30 / MariaDB 10.4+
|
||||||
|
-- PHP Version: 8.2
|
||||||
|
|
||||||
|
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||||
|
START TRANSACTION;
|
||||||
|
SET time_zone = "+00:00";
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||||
|
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||||
|
/*!40101 SET NAMES utf8mb4 */;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `admin`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `admin` (
|
||||||
|
`username` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
|
||||||
|
`password` varchar(255) NOT NULL,
|
||||||
|
PRIMARY KEY (`username`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Dumping data for table `admin` (username: admin, password: password)
|
||||||
|
--
|
||||||
|
|
||||||
|
INSERT INTO `admin` (`username`, `password`) VALUES
|
||||||
|
('admin', '$2y$12$0B05s6Jg4jP.O3PzBUpb2O3yQ0LvhxW92C9yB0Z5uX.R71E7K1Fny')
|
||||||
|
ON DUPLICATE KEY UPDATE `password` = VALUES(`password`);
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `user`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `user` (
|
||||||
|
`username` varchar(255) NOT NULL,
|
||||||
|
`email` varchar(255) NOT NULL,
|
||||||
|
`password` varchar(255) NOT NULL,
|
||||||
|
`phone_number` varchar(255) DEFAULT NULL,
|
||||||
|
`role` varchar(50) DEFAULT 'user',
|
||||||
|
`email_verified_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`login_otp` varchar(10) DEFAULT NULL,
|
||||||
|
`login_otp_expires_at` timestamp NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`username`),
|
||||||
|
UNIQUE KEY `user_email_unique` (`email`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Dumping data for table `user` (username: user, password: password)
|
||||||
|
--
|
||||||
|
|
||||||
|
INSERT INTO `user` (`username`, `email`, `password`, `phone_number`, `role`) VALUES
|
||||||
|
('user', 'user@example.com', '$2y$12$0B05s6Jg4jP.O3PzBUpb2O3yQ0LvhxW92C9yB0Z5uX.R71E7K1Fny', '081234567890', 'user')
|
||||||
|
ON DUPLICATE KEY UPDATE `password` = VALUES(`password`);
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `categories`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `categories` (
|
||||||
|
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`description` text NOT NULL,
|
||||||
|
`merk` varchar(255) DEFAULT NULL,
|
||||||
|
`model` varchar(255) DEFAULT NULL,
|
||||||
|
`kategori` varchar(255) DEFAULT NULL,
|
||||||
|
`price` decimal(15,2) DEFAULT NULL,
|
||||||
|
`year` int(11) DEFAULT NULL,
|
||||||
|
`mesin_cc` int(11) DEFAULT NULL,
|
||||||
|
`transmisi` varchar(255) DEFAULT NULL,
|
||||||
|
`bahan_bakar` varchar(255) DEFAULT NULL,
|
||||||
|
`image` varchar(255) DEFAULT NULL,
|
||||||
|
`status` varchar(255) NOT NULL DEFAULT 'available',
|
||||||
|
`seller_username` varchar(255) DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`updated_at` timestamp NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Sample data for table `categories`
|
||||||
|
--
|
||||||
|
|
||||||
|
INSERT INTO `categories` (`id`, `name`, `description`, `merk`, `model`, `kategori`, `price`, `year`, `mesin_cc`, `transmisi`, `bahan_bakar`, `image`, `status`, `seller_username`, `created_at`, `updated_at`) VALUES
|
||||||
|
(1, 'Toyota Avanza 1.3 G', 'MPV | 1300cc | Manual | Bensin', 'Toyota', 'Avanza', 'MPV', 145000000.00, 2018, 1300, 'Manual', 'Bensin', NULL, 'available', 'admin', NOW(), NOW()),
|
||||||
|
(2, 'Honda Brio Satya E', 'Hatchback | 1200cc | Otomatis | Bensin', 'Honda', 'Brio', 'Hatchback', 135000000.00, 2019, 1200, 'Otomatis', 'Bensin', NULL, 'available', 'admin', NOW(), NOW()),
|
||||||
|
(3, 'Mitsubishi Pajero Sport Dakar', 'SUV | 2400cc | Otomatis | Diesel', 'Mitsubishi', 'Pajero Sport', 'SUV', 450000000.00, 2021, 2400, 'Otomatis', 'Diesel', NULL, 'available', 'admin', NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `transactions`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `transactions` (
|
||||||
|
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`transaction_code` varchar(255) NOT NULL,
|
||||||
|
`category_id` bigint(20) UNSIGNED DEFAULT NULL,
|
||||||
|
`admin_username` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||||
|
`customer_name` varchar(255) NOT NULL,
|
||||||
|
`customer_phone` varchar(255) DEFAULT NULL,
|
||||||
|
`customer_email` varchar(255) DEFAULT NULL,
|
||||||
|
`quantity` int(10) UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
`unit_price` decimal(14,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`total_amount` decimal(14,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`payment_method` varchar(255) NOT NULL DEFAULT 'cash',
|
||||||
|
`payment_status` varchar(255) NOT NULL DEFAULT 'pending',
|
||||||
|
`expires_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`payment_proof` varchar(255) DEFAULT NULL,
|
||||||
|
`note` text DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`updated_at` timestamp NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `transactions_transaction_code_unique` (`transaction_code`),
|
||||||
|
KEY `transactions_category_id_foreign` (`category_id`),
|
||||||
|
KEY `transactions_admin_username_foreign` (`admin_username`),
|
||||||
|
CONSTRAINT `transactions_category_id_foreign` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT `transactions_admin_username_foreign` FOREIGN KEY (`admin_username`) REFERENCES `admin` (`username`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `favorites`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `favorites` (
|
||||||
|
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(255) NOT NULL,
|
||||||
|
`category_id` bigint(20) UNSIGNED NOT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`updated_at` timestamp NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `favorites_username_category_id_unique` (`username`, `category_id`),
|
||||||
|
KEY `favorites_username_category_id_index` (`username`, `category_id`),
|
||||||
|
KEY `favorites_category_id_foreign` (`category_id`),
|
||||||
|
CONSTRAINT `favorites_username_foreign` FOREIGN KEY (`username`) REFERENCES `user` (`username`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT `favorites_category_id_foreign` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `password_reset_tokens`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `password_reset_tokens` (
|
||||||
|
`email` varchar(255) NOT NULL,
|
||||||
|
`token` varchar(255) NOT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`email`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `failed_jobs`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `failed_jobs` (
|
||||||
|
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`uuid` varchar(255) NOT NULL,
|
||||||
|
`connection` text NOT NULL,
|
||||||
|
`queue` text NOT NULL,
|
||||||
|
`payload` longtext NOT NULL,
|
||||||
|
`exception` longtext NOT NULL,
|
||||||
|
`failed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `failed_jobs_uuid_unique` (`uuid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `personal_access_tokens`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `personal_access_tokens` (
|
||||||
|
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`tokenable_type` varchar(255) NOT NULL,
|
||||||
|
`tokenable_id` bigint(20) UNSIGNED NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`token` varchar(64) NOT NULL,
|
||||||
|
`abilities` text DEFAULT NULL,
|
||||||
|
`last_used_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`expires_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`updated_at` timestamp NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `personal_access_tokens_token_unique` (`token`),
|
||||||
|
KEY `personal_access_tokens_tokenable_type_tokenable_id_index` (`tokenable_type`, `tokenable_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
COMMIT;
|
||||||
|
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||||
|
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
Penjelasan Rinci: Proses K-Means dan Hubungannya dengan Sistem Rekomendasi
|
||||||
|
Dokumen ini disusun untuk memberikan penjelasan secara mendalam mengenai proses penerapan algoritma machine learning (K-Means Clustering) pada data asli di database, serta bagaimana hasil pengelompokan tersebut digunakan untuk memberikan rekomendasi mobil yang akurat berdasarkan budget konsumen pada Sistem Informasi Penjualan Mobil di Showroom JMW.1. Tahapan Proses K-Means pada Data Asli di Database
|
||||||
|
Algoritma K-Means tidak dapat langsung memproses data mentah (raw data) yang ada di database. Mesin membutuhkan data yang telah diseragamkan dalam bentuk angka. Berdasarkan laporan, terdapat 405 data mobil dengan 9 atribut. Berikut adalah proses detail dari database hingga menjadi cluster:a. Pengambilan & Pembersihan Data (Data Fetching & Cleaning)Sistem akan mengambil data dari tabel kategori (mobil) di database MySQL. Data ini mencakup atribut seperti Merk, Kategori, Transmisi, Bahan Bakar, Tahun, Kapasitas Mesin (CC), dan Harga. Pada tahap ini, sistem memastikan tidak ada data yang kosong (missing value).b. Pra-pemrosesan Data (Preprocessing)Karena algoritma K-Means menghitung "jarak" antar data menggunakan rumus matematika, data berformat teks (kategorikal) harus diubah menjadi angka:- One-Hot Encoding: Atribut teks seperti Merk (Toyota, Honda), Transmisi (Manual, Matic), dan Bahan Bakar diubah menjadi representasi biner (0 dan 1). Proses ini memperluas jumlah fitur dari 7 atribut menjadi 29 fitur analisis.- Normalisasi (Z-Score/StandardScaler): Atribut angka memiliki rentang yang sangat timpang. Harga bisa mencapai ratusan juta (contoh: 285.000.000), sedangkan tahun hanya puluhan (contoh: 2022). Jika tidak dinormalisasi, atribut harga akan mendominasi dan merusak perhitungan. Dengan Z-Score, semua atribut angka disetarakan skalanya agar memiliki rata-rata 0 dan standar deviasi 1.c. Penentuan Jumlah Cluster (Metode Elbow & Silhouette Score)Sistem harus menentukan jumlah kelompok (k) terbaik. Menggunakan metode Silhouette Score, sistem mengevaluasi kedekatan antar mobil di dalam kelompok yang sama (cohesion) dan jaraknya dengan kelompok lain (separation). Pada studi kasus JMW, didapatkan nilai K=6 (Silhouette Score tertinggi: 0.257) sebagai jumlah pengelompokan yang paling optimal.d. Perhitungan Jarak dan Pengelompokan (Euclidean Distance)Sistem menentukan 6 titik pusat (centroid) secara acak. Kemudian, jarak dari setiap mobil di database ke 6 titik pusat tersebut dihitung menggunakan rumus Euclidean Distance. Mobil akan dimasukkan ke dalam cluster dengan jarak centroid paling dekat dengannya.e. Pembaruan Centroid (Iterasi Konvergen)Setelah 405 mobil terbagi ke dalam 6 kelompok, sistem akan menghitung ulang lokasi rata-rata dari masing-masing kelompok untuk menjadi centroid baru. Proses hitung jarak dan pembaruan centroid ini diulang terus-menerus hingga letak titik pusat stabil dan tidak ada lagi mobil yang berpindah kelompok. Data cluster (0-5) inilah yang kemudian disimpan kembali ke dalam database.2. Hubungan Data yang Sudah di-Clustering dengan Sistem Rekomendasi
|
||||||
|
Banyak yang bertanya, "Mengapa harus repot menggunakan Machine Learning jika kita bisa sekadar menggunakan filter harga di SQL (misal: WHERE harga <= budget)?". Jawabannya terletak pada "Relevansi dan Value for Money". Hubungan antara hasil K-Means dengan rekomendasi dapat dijelaskan sebagai berikut:a. Menghindari Rekomendasi "Buta" (Blind Filtering)Jika hanya menggunakan query database biasa berdasarkan budget Rp120.000.000, sistem mungkin akan memunculkan mobil city car baru berukuran kecil, sekaligus memunculkan SUV tua bangka bermesin besar yang boros secara bersamaan. Rekomendasi ini tidak terarah karena hanya bertumpu pada harga, bukan spesifikasi keseluruhan.b. Pemetaan Profil (Profiling) Berdasarkan ClusterData yang sudah di-clustering telah memiliki "DNA" atau profilnya masing-masing, misalnya:- Cluster 0: Hatchback, manual, bensin (rata-rata harga Rp132 Juta)- Cluster 1: SUV, manual, bensin cc besar (rata-rata harga Rp219 Juta)- Cluster 2: City car, manual, diesel tua (rata-rata harga Rp117 Juta)Artinya, mobil di dalam kelompok yang sama memiliki karakteristik nilai penyusutan, kenyamanan, dan performa yang sangat mirip.c. Mekanisme Rekomendasi CerdasKetika konsumen menginputkan budget maksimalnya, machine learning sistem tidak hanya mencari mobil yang lebih murah dari angka tersebut. Sistem akan mengidentifikasi budget tersebut paling mendekati karakteristik profil (centroid) cluster yang mana. Setelah menemukan cluster yang paling sesuai dengan profil finansial konsumen, sistem akan merekomendasikan daftar unit dari dalam cluster tersebut.Kesimpulan: K-Means berfungsi sebagai "Otak Pengkategorian" yang sangat cerdas. Hubungannya dengan rekomendasi adalah memastikan bahwa unit mobil yang disarankan kepada konsumen bukan sekadar pas di kantong, melainkan juga memiliki spesifikasi yang setara, wajar, dan sesuai dengan tren harga pasar di kelompoknya. Hal ini pada akhirnya akan sangat membantu efektivitas pengambilan keputusan transaksi konsumen di Showroom JMW.Penjelasan Lengkap Metode K-Means pada Fitur Rekomendasi Mobil Berdasarkan Budget
|
||||||
|
Dokumen ini menjelaskan tahapan metode K-Means yang digunakan pada fitur rekomendasi mobil sesuai budget. Penjelasan disesuaikan dengan implementasi sistem, yaitu aplikasi Laravel sebagai sistem utama dan Flask API sebagai layanan pengolahan rekomendasi.1. Gambaran Umum Fitur Rekomendasi
|
||||||
|
Fitur rekomendasi budget bekerja ketika pengguna memasukkan nominal budget maksimal. Laravel menerima input tersebut, lalu mengirim request ke Flask API. Flask API membaca dataset mobil, menjalankan proses K-Means, memilih cluster yang paling sesuai dengan budget pengguna, kemudian mengembalikan daftar mobil rekomendasi kepada Laravel.Alur sistem secara ringkas:
|
||||||
|
- Pengguna memasukkan budget pada halaman rekomendasi.- Laravel memanggil endpoint Flask API /recommend.- Flask membaca dataset mobil.- Flask membentuk cluster dengan K-Means berdasarkan harga, tahun, dan mesin_cc.- Sistem menentukan cluster yang paling dekat dengan budget pengguna.- Mobil dalam cluster tersebut difilter agar tidak melebihi budget.- Hasil akhir diurutkan menggunakan scoring, lalu ditampilkan oleh Laravel.2. Atribut yang Digunakan dalam K-Means
|
||||||
|
K-Means pada sistem ini menggunakan tiga atribut numerik. Ketiga atribut tersebut diambil dari data mobil dan digunakan sebagai vector fitur untuk proses clustering.AtributNama di KodeFungsi dalam RekomendasiHarga mobilhargaMenentukan kedekatan mobil dengan budget pengguna.Tahun mobiltahunMenunjukkan usia kendaraan; mobil lebih muda biasanya lebih menarik.Kapasitas mesinmesin_ccMenunjukkan karakteristik performa dan spesifikasi kendaraan.Potongan kode atribut yang digunakan:
|
||||||
|
def car_features(car):
|
||||||
|
return [car["harga"], car["tahun"], car["mesin_cc"]]
|
||||||
|
Kode tersebut menunjukkan bahwa setiap mobil diubah menjadi vector berisi tiga nilai, yaitu harga, tahun, dan kapasitas mesin. Vector inilah yang kemudian diproses dalam K-Means.3. Membaca dan Menyiapkan Dataset
|
||||||
|
Flask API membaca dataset mobil dari file yang tersedia di project. Setiap baris dataset diubah menjadi data mobil yang memiliki atribut merk, model, kategori, tahun, mesin_cc, transmisi, bahan_bakar, harga, dan cluster. Untuk proses K-Means, atribut yang digunakan hanya harga, tahun, dan mesin_cc.Potongan kode pembacaan data:
|
||||||
|
cars.append(
|
||||||
|
{
|
||||||
|
"merk": row.get("merk", "").strip(),
|
||||||
|
"model": row.get("model", "").strip(),
|
||||||
|
"nama": f"{row.get('merk', '').strip()} {row.get('model', '').strip()}".strip(),
|
||||||
|
"tahun": to_int(row.get("tahun")),
|
||||||
|
"mesin_cc": to_int(row.get("mesin_cc")),
|
||||||
|
"harga": price,
|
||||||
|
"cluster": to_int(row.get("cluster"), default=None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Data dengan harga tidak valid tidak digunakan. Pada pembentukan model K-Means, sistem juga hanya memakai data yang memiliki harga, tahun, dan mesin_cc lebih dari 0.4. Normalisasi Data
|
||||||
|
Normalisasi adalah proses mengubah nilai atribut ke skala yang sama. Pada sistem ini digunakan Min-Max Normalization agar nilai berada pada rentang 0 sampai 1. Normalisasi diperlukan karena harga memiliki skala ratusan juta, sedangkan tahun sekitar 2019-2025 dan mesin sekitar 1000-2500 cc. Tanpa normalisasi, atribut harga akan terlalu mendominasi perhitungan jarak.Rumus normalisasi:
|
||||||
|
nilai_normal = (nilai - nilai_minimum) / (nilai_maksimum - nilai_minimum)
|
||||||
|
Potongan kode normalisasi:
|
||||||
|
def scaled_value(value, minimum, maximum):
|
||||||
|
if maximum == minimum:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return (value - minimum) / (maximum - minimum)
|
||||||
|
Kode pembentukan vector normalisasi:
|
||||||
|
feature_rows = [car_features(car) for car in usable_cars]
|
||||||
|
columns = list(zip(*feature_rows))
|
||||||
|
ranges = [(min(column), max(column)) for column in columns]
|
||||||
|
vectors = [
|
||||||
|
[scaled_value(value, ranges[index][0], ranges[index][1]) for index, value in enumerate(row)]
|
||||||
|
for row in feature_rows
|
||||||
|
]
|
||||||
|
5. Contoh Data dan Perhitungan Normalisasi
|
||||||
|
Misalkan terdapat data mobil berikut:MobilHargaTahunMesin CCToyota Avanza125.000.00020191500Hyundai Ioniq122.000.00020211197Toyota Fortuner200.000.00020202500Toyota Innova250.000.00020202000Fortuner Baru500.000.00020212500Nilai minimum dan maksimum dari contoh data:
|
||||||
|
AtributMinimumMaksimumHarga122.000.000500.000.000Tahun20192021Mesin CC11972500Contoh normalisasi Toyota Avanza:
|
||||||
|
Harga:
|
||||||
|
(125.000.000 - 122.000.000) / (500.000.000 - 122.000.000)
|
||||||
|
= 3.000.000 / 378.000.000
|
||||||
|
= 0,008
|
||||||
|
|
||||||
|
Tahun:
|
||||||
|
(2019 - 2019) / (2021 - 2019)
|
||||||
|
= 0 / 2
|
||||||
|
= 0
|
||||||
|
|
||||||
|
Mesin:
|
||||||
|
(1500 - 1197) / (2500 - 1197)
|
||||||
|
= 303 / 1303
|
||||||
|
= 0,233
|
||||||
|
Jadi vector normalisasi Toyota Avanza adalah:
|
||||||
|
[0,008, 0,000, 0,233]
|
||||||
|
Contoh normalisasi Toyota Fortuner harga 200 juta:
|
||||||
|
Harga:
|
||||||
|
(200.000.000 - 122.000.000) / (500.000.000 - 122.000.000)
|
||||||
|
= 78.000.000 / 378.000.000
|
||||||
|
= 0,206
|
||||||
|
|
||||||
|
Tahun:
|
||||||
|
(2020 - 2019) / (2021 - 2019)
|
||||||
|
= 1 / 2
|
||||||
|
= 0,500
|
||||||
|
|
||||||
|
Mesin:
|
||||||
|
(2500 - 1197) / (2500 - 1197)
|
||||||
|
= 1303 / 1303
|
||||||
|
= 1,000
|
||||||
|
Jadi vector normalisasi Toyota Fortuner adalah:
|
||||||
|
[0,206, 0,500, 1,000]
|
||||||
|
6. Menentukan Centroid Awal
|
||||||
|
Centroid adalah titik pusat cluster. Pada implementasi sistem, centroid awal dipilih berdasarkan data yang sudah diurutkan menurut harga. Cara ini membuat centroid awal tersebar dari data harga rendah sampai harga tinggi. Jumlah cluster default yang digunakan adalah 4 cluster.Potongan kode centroid awal:
|
||||||
|
cluster_count = max(1, min(cluster_count, len(vectors)))
|
||||||
|
sorted_indexes = sorted(range(len(usable_cars)), key=lambda index: usable_cars[index]["harga"])
|
||||||
|
centroids = []
|
||||||
|
|
||||||
|
for cluster_index in range(cluster_count):
|
||||||
|
sample_position = round(cluster_index * (len(sorted_indexes) - 1) / max(cluster_count - 1, 1))
|
||||||
|
centroids.append(vectors[sorted_indexes[sample_position]][:])
|
||||||
|
Secara teori, centroid awal dapat ditentukan secara acak atau menggunakan strategi tertentu. Pada sistem ini, centroid awal tidak dipilih acak, melainkan berdasarkan persebaran harga agar cluster awal lebih stabil.7. Menghitung Jarak dengan Euclidean Distance
|
||||||
|
Setelah centroid awal ditentukan, sistem menghitung jarak setiap mobil terhadap seluruh centroid. Jarak yang digunakan adalah Euclidean Distance.Rumus Euclidean Distance:
|
||||||
|
d = sqrt((x1 - c1)^2 + (x2 - c2)^2 + (x3 - c3)^2)
|
||||||
|
Keterangan:
|
||||||
|
- x1, x2, x3 adalah nilai normalisasi mobil untuk harga, tahun, dan mesin_cc.- c1, c2, c3 adalah nilai centroid untuk harga, tahun, dan mesin_cc.- d adalah jarak antara mobil dengan centroid.Potongan kode jarak:
|
||||||
|
def distance(point, centroid):
|
||||||
|
return math.sqrt(sum((point[index] - centroid[index]) ** 2 for index in range(len(point))))
|
||||||
|
Contoh perhitungan jarak:
|
||||||
|
Misalnya vector normalisasi Toyota Avanza adalah [0,008, 0,000, 0,233] dan centroid C1 adalah [0,000, 1,000, 0,000]. Maka jaraknya:d = sqrt((0,008 - 0,000)^2 + (0,000 - 1,000)^2 + (0,233 - 0,000)^2)
|
||||||
|
d = sqrt(0,000064 + 1,000000 + 0,054289)
|
||||||
|
d = sqrt(1,054353)
|
||||||
|
d = 1,027
|
||||||
|
Jarak yang lebih kecil menunjukkan bahwa data mobil lebih dekat dengan centroid tersebut. Mobil akan masuk ke cluster dengan centroid yang jaraknya paling kecil.8. Menentukan Cluster Terdekat
|
||||||
|
Setelah jarak dihitung, sistem memasukkan setiap mobil ke cluster dengan centroid terdekat. Tahap ini disebut assignment step pada K-Means.Potongan kode assignment cluster:
|
||||||
|
for index, vector in enumerate(vectors):
|
||||||
|
nearest_cluster = min(range(cluster_count), key=lambda cluster: distance(vector, centroids[cluster]))
|
||||||
|
if labels[index] != nearest_cluster:
|
||||||
|
changed = True
|
||||||
|
labels[index] = nearest_cluster
|
||||||
|
Variabel labels menyimpan nomor cluster untuk setiap mobil. Jika cluster sebuah mobil berubah, variabel changed bernilai True, sehingga proses iterasi akan berlanjut.9. Menghitung Ulang Centroid
|
||||||
|
Setelah semua mobil ditempatkan pada cluster terdekat, sistem menghitung ulang centroid. Centroid baru didapatkan dari rata-rata seluruh anggota dalam cluster tersebut.Potongan kode update centroid:
|
||||||
|
new_centroids = []
|
||||||
|
for cluster in range(cluster_count):
|
||||||
|
members = [vectors[index] for index, label in enumerate(labels) if label == cluster]
|
||||||
|
if not members:
|
||||||
|
new_centroids.append(centroids[cluster])
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_centroids.append(
|
||||||
|
[sum(member[column] for member in members) / len(members) for column in range(len(vectors[0]))]
|
||||||
|
)
|
||||||
|
|
||||||
|
centroids = new_centroids
|
||||||
|
Contoh perhitungan centroid baru:
|
||||||
|
Misalnya Cluster 0 memiliki dua anggota dengan vector normalisasi berikut:Mobil A = [0,008, 0,000, 0,233]
|
||||||
|
Mobil B = [0,000, 1,000, 0,000]
|
||||||
|
Centroid baru Cluster 0 dihitung dengan rata-rata setiap atribut:
|
||||||
|
Harga centroid = (0,008 + 0,000) / 2 = 0,004
|
||||||
|
Tahun centroid = (0,000 + 1,000) / 2 = 0,500
|
||||||
|
Mesin centroid = (0,233 + 0,000) / 2 = 0,1165
|
||||||
|
|
||||||
|
Centroid baru = [0,004, 0,500, 0,1165]
|
||||||
|
10. Iterasi Sampai Stabil
|
||||||
|
Proses menghitung jarak, menentukan cluster, dan menghitung ulang centroid dilakukan berulang. Iterasi akan berhenti jika anggota cluster tidak berubah lagi atau jika sudah mencapai batas iterasi. Dalam sistem ini, batas iterasi default adalah 30.Potongan kode iterasi:
|
||||||
|
for _ in range(iterations):
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# assignment step
|
||||||
|
# update centroid step
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
break
|
||||||
|
11. Menentukan Cluster yang Sesuai dengan Budget User
|
||||||
|
Setelah model K-Means terbentuk, sistem harus menentukan cluster mana yang paling cocok dengan budget pengguna. Untuk itu, sistem membuat data target dari budget pengguna. Nilai harga target adalah budget yang dimasukkan user, sedangkan tahun dan mesin target diambil dari median mobil yang harganya kurang dari atau sama dengan budget.Potongan kode penentuan cluster budget:
|
||||||
|
def predict_budget_cluster(model, budget):
|
||||||
|
model_cars = model["cars"]
|
||||||
|
target_year = median([car["tahun"] for car in model_cars if car["harga"] <= budget] or [car["tahun"] for car in model_cars])
|
||||||
|
target_engine = median([car["mesin_cc"] for car in model_cars if car["harga"] <= budget] or [car["mesin_cc"] for car in model_cars])
|
||||||
|
target_features = [budget, target_year, target_engine]
|
||||||
|
target_vector = [
|
||||||
|
scaled_value(value, model["ranges"][index][0], model["ranges"][index][1])
|
||||||
|
for index, value in enumerate(target_features)
|
||||||
|
]
|
||||||
|
|
||||||
|
return min(
|
||||||
|
range(model["cluster_count"]),
|
||||||
|
key=lambda cluster: distance(target_vector, model["centroids"][cluster]),
|
||||||
|
)
|
||||||
|
Contoh:
|
||||||
|
Jika pengguna memasukkan budget Rp200.000.000, sistem membuat target harga 200.000.000. Tahun dan mesin target dihitung dari median data mobil yang harganya <= 200.000.000. Setelah itu target dinormalisasi dan dibandingkan dengan semua centroid. Cluster dengan jarak paling kecil menjadi cluster rekomendasi.12. Filter Mobil Berdasarkan Cluster dan Budget
|
||||||
|
Setelah cluster target ditemukan, sistem mengambil mobil dari cluster tersebut dengan syarat harga tidak melebihi budget. Jika ada mobil dengan harga sama persis dengan budget, mobil tersebut diprioritaskan.Potongan kode filter budget:
|
||||||
|
same_cluster = [
|
||||||
|
car for car in cars
|
||||||
|
if model["labels"].get(id(car)) == target_cluster and car["harga"] <= budget
|
||||||
|
]
|
||||||
|
|
||||||
|
exact_matches = [car for car in same_cluster if car["harga"] == budget]
|
||||||
|
selected = exact_matches or same_cluster or [car for car in cars if car["harga"] <= budget]
|
||||||
|
Contoh jika budget Rp200.000.000:
|
||||||
|
MobilHargaKeteranganToyota Avanza125.000.000Lolos karena di bawah budgetHyundai Ioniq122.000.000Lolos karena di bawah budgetToyota Fortuner200.000.000Diprioritaskan karena sama dengan budgetToyota Innova250.000.000Tidak lolos karena melebihi budgetFortuner Baru500.000.000Tidak lolos karena melebihi budget13. Scoring Setelah K-Means
|
||||||
|
K-Means digunakan untuk membentuk kelompok kandidat mobil. Setelah kandidat diperoleh, sistem masih melakukan scoring untuk mengurutkan hasil rekomendasi. Scoring ini bukan proses K-Means, melainkan proses tambahan agar hasil rekomendasi lebih rapi dan relevan.Potongan kode bobot strategi harga_pas:
|
||||||
|
if normalize_text(strategy) in ["harga_pas", "harga pas", "spesifik", "specific"]:
|
||||||
|
return {
|
||||||
|
"budget": 0.70,
|
||||||
|
"preference": 0.15,
|
||||||
|
"year": 0.10,
|
||||||
|
"engine": 0.05,
|
||||||
|
"cluster": 0.00,
|
||||||
|
}
|
||||||
|
Pada strategi harga_pas, bobot harga adalah 70%. Artinya hasil rekomendasi lebih mengutamakan mobil yang harganya paling mendekati budget pengguna. Tahun dan mesin tetap dihitung, tetapi pengaruhnya lebih kecil.14. Kesimpulan Tahapan K-Means pada Sistem
|
||||||
|
Tahapan K-Means pada sistem rekomendasi mobil sudah sesuai dengan konsep dasar K-Means. Sistem menentukan atribut, melakukan normalisasi, menentukan centroid awal, menghitung jarak, membentuk cluster, memperbarui centroid, dan mengulang proses sampai stabil. Setelah itu, hasil clustering digunakan untuk memilih mobil yang paling sesuai dengan budget pengguna.Kalimat yang dapat digunakan dalam laporan:
|
||||||
|
Pada sistem ini, metode K-Means digunakan untuk mengelompokkan data mobil berdasarkan tiga atribut, yaitu harga, tahun, dan kapasitas mesin. Data terlebih dahulu dinormalisasi menggunakan Min-Max Normalization agar setiap atribut berada pada skala yang sama. Setelah itu, sistem membentuk cluster menggunakan perhitungan jarak Euclidean. Cluster yang paling dekat dengan budget pengguna dipilih sebagai dasar rekomendasi. Mobil dalam cluster tersebut kemudian disaring agar tidak melebihi budget dan diurutkan menggunakan scoring.15. Catatan Penting untuk Demo dan Sidang
|
||||||
|
K-Means pada sistem dijalankan oleh Flask API melalui file flask_api/app.py. Laravel hanya memanggil hasil rekomendasi dari API tersebut. Jika Flask API tidak aktif, Laravel akan memakai fallback filter harga biasa. Oleh karena itu, saat demo atau sidang, Flask API harus dijalankan agar fitur rekomendasi benar-benar memakai K-Means.16. Kode Program Clustering dengan 3 Atribut
|
||||||
|
Berikut adalah kode program Python untuk proses clustering K-Means yang disesuaikan dengan tiga atribut utama pada sistem rekomendasi, yaitu harga, tahun, dan mesin_cc. Kode ini dapat digunakan sebagai contoh implementasi pada laporan karena hanya memakai atribut yang relevan dengan fitur rekomendasi budget.# Import library
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from sklearn.metrics import silhouette_score
|
||||||
|
|
||||||
|
|
||||||
|
# Membaca dataset
|
||||||
|
df = pd.read_csv("dataset_final.csv")
|
||||||
|
|
||||||
|
|
||||||
|
# Jika masih ada kolom cluster lama, hapus terlebih dahulu
|
||||||
|
if "cluster" in df.columns:
|
||||||
|
df = df.drop(columns=["cluster"])
|
||||||
|
|
||||||
|
|
||||||
|
# Samakan nama kolom harga
|
||||||
|
if "Harga" in df.columns:
|
||||||
|
df = df.rename(columns={"Harga": "harga"})
|
||||||
|
|
||||||
|
|
||||||
|
# Pilih 3 atribut utama untuk clustering
|
||||||
|
fitur = df[
|
||||||
|
[
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Standarisasi data
|
||||||
|
scaler = StandardScaler()
|
||||||
|
fitur_scaled = scaler.fit_transform(fitur)
|
||||||
|
|
||||||
|
|
||||||
|
# Mencari jumlah cluster terbaik menggunakan silhouette score
|
||||||
|
for k in range(2, 7):
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=k,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
|
||||||
|
labels = kmeans.fit_predict(fitur_scaled)
|
||||||
|
score = silhouette_score(fitur_scaled, labels)
|
||||||
|
|
||||||
|
print(f"Jumlah Cluster: {k}, Silhouette Score: {score}")
|
||||||
|
|
||||||
|
|
||||||
|
# Membuat model K-Means akhir
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=4,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Menambahkan hasil cluster ke dataset
|
||||||
|
df["cluster"] = kmeans.fit_predict(fitur_scaled)
|
||||||
|
|
||||||
|
|
||||||
|
# Menampilkan hasil clustering
|
||||||
|
print(
|
||||||
|
df[
|
||||||
|
[
|
||||||
|
"merk",
|
||||||
|
"model",
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc",
|
||||||
|
"cluster"
|
||||||
|
]
|
||||||
|
].head(20)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Menyimpan dataset yang sudah memiliki cluster
|
||||||
|
df.to_csv("dataset_final_clustered.csv", index=False)
|
||||||
|
Pada kode tersebut, data terlebih dahulu dibaca dari file dataset_final.csv. Jika terdapat kolom cluster lama, kolom tersebut dihapus agar hasil clustering dihitung ulang dari awal. Setelah itu, sistem memilih tiga atribut utama, yaitu harga, tahun, dan mesin_cc.Tahap standarisasi dilakukan menggunakan StandardScaler agar ketiga atribut berada pada skala yang seimbang. Hal ini penting karena harga memiliki nilai yang jauh lebih besar dibandingkan tahun dan mesin_cc. Setelah data distandarisasi, K-Means dijalankan untuk membentuk cluster.Bagian silhouette score digunakan untuk melihat kualitas beberapa jumlah cluster. Setelah itu, sistem membuat model akhir. Pada contoh ini digunakan 4 cluster agar sesuai dengan konfigurasi sistem rekomendasi yang mengirim parameter jumlah_cluster = 4 ke Flask API.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
Penjelasan Rinci: Proses K-Means dan Hubungannya dengan Sistem Rekomendasi
|
||||||
|
Dokumen ini disusun untuk memberikan penjelasan secara mendalam mengenai proses penerapan algoritma machine learning (K-Means Clustering) pada data asli di database, serta bagaimana hasil pengelompokan tersebut digunakan untuk memberikan rekomendasi mobil yang akurat berdasarkan budget konsumen pada Sistem Informasi Penjualan Mobil di Showroom JMW.1. Tahapan Proses K-Means pada Data Asli di Database
|
||||||
|
Algoritma K-Means tidak dapat langsung memproses data mentah (raw data) yang ada di database. Mesin membutuhkan data yang telah diseragamkan dalam bentuk angka. Berdasarkan laporan, terdapat 405 data mobil dengan 9 atribut. Berikut adalah proses detail dari database hingga menjadi cluster:a. Pengambilan & Pembersihan Data (Data Fetching & Cleaning)Sistem akan mengambil data dari tabel kategori (mobil) di database MySQL. Data ini mencakup atribut seperti Merk, Kategori, Transmisi, Bahan Bakar, Tahun, Kapasitas Mesin (CC), dan Harga. Pada tahap ini, sistem memastikan tidak ada data yang kosong (missing value).b. Pra-pemrosesan Data (Preprocessing)Karena algoritma K-Means menghitung "jarak" antar data menggunakan rumus matematika, data berformat teks (kategorikal) harus diubah menjadi angka:- One-Hot Encoding: Atribut teks seperti Merk (Toyota, Honda), Transmisi (Manual, Matic), dan Bahan Bakar diubah menjadi representasi biner (0 dan 1). Proses ini memperluas jumlah fitur dari 7 atribut menjadi 29 fitur analisis.- Normalisasi (Z-Score/StandardScaler): Atribut angka memiliki rentang yang sangat timpang. Harga bisa mencapai ratusan juta (contoh: 285.000.000), sedangkan tahun hanya puluhan (contoh: 2022). Jika tidak dinormalisasi, atribut harga akan mendominasi dan merusak perhitungan. Dengan Z-Score, semua atribut angka disetarakan skalanya agar memiliki rata-rata 0 dan standar deviasi 1.c. Penentuan Jumlah Cluster (Metode Elbow & Silhouette Score)Sistem harus menentukan jumlah kelompok (k) terbaik. Menggunakan metode Silhouette Score, sistem mengevaluasi kedekatan antar mobil di dalam kelompok yang sama (cohesion) dan jaraknya dengan kelompok lain (separation). Pada studi kasus JMW, didapatkan nilai K=6 (Silhouette Score tertinggi: 0.257) sebagai jumlah pengelompokan yang paling optimal.d. Perhitungan Jarak dan Pengelompokan (Euclidean Distance)Sistem menentukan 6 titik pusat (centroid) secara acak. Kemudian, jarak dari setiap mobil di database ke 6 titik pusat tersebut dihitung menggunakan rumus Euclidean Distance. Mobil akan dimasukkan ke dalam cluster dengan jarak centroid paling dekat dengannya.e. Pembaruan Centroid (Iterasi Konvergen)Setelah 405 mobil terbagi ke dalam 6 kelompok, sistem akan menghitung ulang lokasi rata-rata dari masing-masing kelompok untuk menjadi centroid baru. Proses hitung jarak dan pembaruan centroid ini diulang terus-menerus hingga letak titik pusat stabil dan tidak ada lagi mobil yang berpindah kelompok. Data cluster (0-5) inilah yang kemudian disimpan kembali ke dalam database.2. Hubungan Data yang Sudah di-Clustering dengan Sistem Rekomendasi
|
||||||
|
Banyak yang bertanya, "Mengapa harus repot menggunakan Machine Learning jika kita bisa sekadar menggunakan filter harga di SQL (misal: WHERE harga <= budget)?". Jawabannya terletak pada "Relevansi dan Value for Money". Hubungan antara hasil K-Means dengan rekomendasi dapat dijelaskan sebagai berikut:a. Menghindari Rekomendasi "Buta" (Blind Filtering)Jika hanya menggunakan query database biasa berdasarkan budget Rp120.000.000, sistem mungkin akan memunculkan mobil city car baru berukuran kecil, sekaligus memunculkan SUV tua bangka bermesin besar yang boros secara bersamaan. Rekomendasi ini tidak terarah karena hanya bertumpu pada harga, bukan spesifikasi keseluruhan.b. Pemetaan Profil (Profiling) Berdasarkan ClusterData yang sudah di-clustering telah memiliki "DNA" atau profilnya masing-masing, misalnya:- Cluster 0: Hatchback, manual, bensin (rata-rata harga Rp132 Juta)- Cluster 1: SUV, manual, bensin cc besar (rata-rata harga Rp219 Juta)- Cluster 2: City car, manual, diesel tua (rata-rata harga Rp117 Juta)Artinya, mobil di dalam kelompok yang sama memiliki karakteristik nilai penyusutan, kenyamanan, dan performa yang sangat mirip.c. Mekanisme Rekomendasi CerdasKetika konsumen menginputkan budget maksimalnya, machine learning sistem tidak hanya mencari mobil yang lebih murah dari angka tersebut. Sistem akan mengidentifikasi budget tersebut paling mendekati karakteristik profil (centroid) cluster yang mana. Setelah menemukan cluster yang paling sesuai dengan profil finansial konsumen, sistem akan merekomendasikan daftar unit dari dalam cluster tersebut.Kesimpulan: K-Means berfungsi sebagai "Otak Pengkategorian" yang sangat cerdas. Hubungannya dengan rekomendasi adalah memastikan bahwa unit mobil yang disarankan kepada konsumen bukan sekadar pas di kantong, melainkan juga memiliki spesifikasi yang setara, wajar, dan sesuai dengan tren harga pasar di kelompoknya. Hal ini pada akhirnya akan sangat membantu efektivitas pengambilan keputusan transaksi konsumen di Showroom JMW.Penjelasan Lengkap Metode K-Means pada Fitur Rekomendasi Mobil Berdasarkan Budget
|
||||||
|
Dokumen ini menjelaskan tahapan metode K-Means yang digunakan pada fitur rekomendasi mobil sesuai budget. Penjelasan disesuaikan dengan implementasi sistem, yaitu aplikasi Laravel sebagai sistem utama dan Flask API sebagai layanan pengolahan rekomendasi.1. Gambaran Umum Fitur Rekomendasi
|
||||||
|
Fitur rekomendasi budget bekerja ketika pengguna memasukkan nominal budget maksimal. Laravel menerima input tersebut, lalu mengirim request ke Flask API. Flask API membaca dataset mobil, menjalankan proses K-Means, memilih cluster yang paling sesuai dengan budget pengguna, kemudian mengembalikan daftar mobil rekomendasi kepada Laravel.Alur sistem secara ringkas:
|
||||||
|
- Pengguna memasukkan budget pada halaman rekomendasi.- Laravel memanggil endpoint Flask API /recommend.- Flask membaca dataset mobil.- Flask membentuk cluster dengan K-Means berdasarkan harga, tahun, dan mesin_cc.- Sistem menentukan cluster yang paling dekat dengan budget pengguna.- Mobil dalam cluster tersebut difilter agar tidak melebihi budget.- Hasil akhir diurutkan menggunakan scoring, lalu ditampilkan oleh Laravel.2. Atribut yang Digunakan dalam K-Means
|
||||||
|
K-Means pada sistem ini menggunakan tiga atribut numerik. Ketiga atribut tersebut diambil dari data mobil dan digunakan sebagai vector fitur untuk proses clustering.AtributNama di KodeFungsi dalam RekomendasiHarga mobilhargaMenentukan kedekatan mobil dengan budget pengguna.Tahun mobiltahunMenunjukkan usia kendaraan; mobil lebih muda biasanya lebih menarik.Kapasitas mesinmesin_ccMenunjukkan karakteristik performa dan spesifikasi kendaraan.Potongan kode atribut yang digunakan:
|
||||||
|
def car_features(car):
|
||||||
|
return [car["harga"], car["tahun"], car["mesin_cc"]]
|
||||||
|
Kode tersebut menunjukkan bahwa setiap mobil diubah menjadi vector berisi tiga nilai, yaitu harga, tahun, dan kapasitas mesin. Vector inilah yang kemudian diproses dalam K-Means.3. Membaca dan Menyiapkan Dataset
|
||||||
|
Flask API membaca dataset mobil dari file yang tersedia di project. Setiap baris dataset diubah menjadi data mobil yang memiliki atribut merk, model, kategori, tahun, mesin_cc, transmisi, bahan_bakar, harga, dan cluster. Untuk proses K-Means, atribut yang digunakan hanya harga, tahun, dan mesin_cc.Potongan kode pembacaan data:
|
||||||
|
cars.append(
|
||||||
|
{
|
||||||
|
"merk": row.get("merk", "").strip(),
|
||||||
|
"model": row.get("model", "").strip(),
|
||||||
|
"nama": f"{row.get('merk', '').strip()} {row.get('model', '').strip()}".strip(),
|
||||||
|
"tahun": to_int(row.get("tahun")),
|
||||||
|
"mesin_cc": to_int(row.get("mesin_cc")),
|
||||||
|
"harga": price,
|
||||||
|
"cluster": to_int(row.get("cluster"), default=None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Data dengan harga tidak valid tidak digunakan. Pada pembentukan model K-Means, sistem juga hanya memakai data yang memiliki harga, tahun, dan mesin_cc lebih dari 0.4. Normalisasi Data
|
||||||
|
Normalisasi adalah proses mengubah nilai atribut ke skala yang sama. Pada sistem ini digunakan Min-Max Normalization agar nilai berada pada rentang 0 sampai 1. Normalisasi diperlukan karena harga memiliki skala ratusan juta, sedangkan tahun sekitar 2019-2025 dan mesin sekitar 1000-2500 cc. Tanpa normalisasi, atribut harga akan terlalu mendominasi perhitungan jarak.Rumus normalisasi:
|
||||||
|
nilai_normal = (nilai - nilai_minimum) / (nilai_maksimum - nilai_minimum)
|
||||||
|
Potongan kode normalisasi:
|
||||||
|
def scaled_value(value, minimum, maximum):
|
||||||
|
if maximum == minimum:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return (value - minimum) / (maximum - minimum)
|
||||||
|
Kode pembentukan vector normalisasi:
|
||||||
|
feature_rows = [car_features(car) for car in usable_cars]
|
||||||
|
columns = list(zip(*feature_rows))
|
||||||
|
ranges = [(min(column), max(column)) for column in columns]
|
||||||
|
vectors = [
|
||||||
|
[scaled_value(value, ranges[index][0], ranges[index][1]) for index, value in enumerate(row)]
|
||||||
|
for row in feature_rows
|
||||||
|
]
|
||||||
|
5. Contoh Data dan Perhitungan Normalisasi
|
||||||
|
Misalkan terdapat data mobil berikut:MobilHargaTahunMesin CCToyota Avanza125.000.00020191500Hyundai Ioniq122.000.00020211197Toyota Fortuner200.000.00020202500Toyota Innova250.000.00020202000Fortuner Baru500.000.00020212500Nilai minimum dan maksimum dari contoh data:
|
||||||
|
AtributMinimumMaksimumHarga122.000.000500.000.000Tahun20192021Mesin CC11972500Contoh normalisasi Toyota Avanza:
|
||||||
|
Harga:
|
||||||
|
(125.000.000 - 122.000.000) / (500.000.000 - 122.000.000)
|
||||||
|
= 3.000.000 / 378.000.000
|
||||||
|
= 0,008
|
||||||
|
|
||||||
|
Tahun:
|
||||||
|
(2019 - 2019) / (2021 - 2019)
|
||||||
|
= 0 / 2
|
||||||
|
= 0
|
||||||
|
|
||||||
|
Mesin:
|
||||||
|
(1500 - 1197) / (2500 - 1197)
|
||||||
|
= 303 / 1303
|
||||||
|
= 0,233
|
||||||
|
Jadi vector normalisasi Toyota Avanza adalah:
|
||||||
|
[0,008, 0,000, 0,233]
|
||||||
|
Contoh normalisasi Toyota Fortuner harga 200 juta:
|
||||||
|
Harga:
|
||||||
|
(200.000.000 - 122.000.000) / (500.000.000 - 122.000.000)
|
||||||
|
= 78.000.000 / 378.000.000
|
||||||
|
= 0,206
|
||||||
|
|
||||||
|
Tahun:
|
||||||
|
(2020 - 2019) / (2021 - 2019)
|
||||||
|
= 1 / 2
|
||||||
|
= 0,500
|
||||||
|
|
||||||
|
Mesin:
|
||||||
|
(2500 - 1197) / (2500 - 1197)
|
||||||
|
= 1303 / 1303
|
||||||
|
= 1,000
|
||||||
|
Jadi vector normalisasi Toyota Fortuner adalah:
|
||||||
|
[0,206, 0,500, 1,000]
|
||||||
|
6. Menentukan Centroid Awal
|
||||||
|
Centroid adalah titik pusat cluster. Pada implementasi sistem, centroid awal dipilih berdasarkan data yang sudah diurutkan menurut harga. Cara ini membuat centroid awal tersebar dari data harga rendah sampai harga tinggi. Jumlah cluster default yang digunakan adalah 4 cluster.Potongan kode centroid awal:
|
||||||
|
cluster_count = max(1, min(cluster_count, len(vectors)))
|
||||||
|
sorted_indexes = sorted(range(len(usable_cars)), key=lambda index: usable_cars[index]["harga"])
|
||||||
|
centroids = []
|
||||||
|
|
||||||
|
for cluster_index in range(cluster_count):
|
||||||
|
sample_position = round(cluster_index * (len(sorted_indexes) - 1) / max(cluster_count - 1, 1))
|
||||||
|
centroids.append(vectors[sorted_indexes[sample_position]][:])
|
||||||
|
Secara teori, centroid awal dapat ditentukan secara acak atau menggunakan strategi tertentu. Pada sistem ini, centroid awal tidak dipilih acak, melainkan berdasarkan persebaran harga agar cluster awal lebih stabil.7. Menghitung Jarak dengan Euclidean Distance
|
||||||
|
Setelah centroid awal ditentukan, sistem menghitung jarak setiap mobil terhadap seluruh centroid. Jarak yang digunakan adalah Euclidean Distance.Rumus Euclidean Distance:
|
||||||
|
d = sqrt((x1 - c1)^2 + (x2 - c2)^2 + (x3 - c3)^2)
|
||||||
|
Keterangan:
|
||||||
|
- x1, x2, x3 adalah nilai normalisasi mobil untuk harga, tahun, dan mesin_cc.- c1, c2, c3 adalah nilai centroid untuk harga, tahun, dan mesin_cc.- d adalah jarak antara mobil dengan centroid.Potongan kode jarak:
|
||||||
|
def distance(point, centroid):
|
||||||
|
return math.sqrt(sum((point[index] - centroid[index]) ** 2 for index in range(len(point))))
|
||||||
|
Contoh perhitungan jarak:
|
||||||
|
Misalnya vector normalisasi Toyota Avanza adalah [0,008, 0,000, 0,233] dan centroid C1 adalah [0,000, 1,000, 0,000]. Maka jaraknya:d = sqrt((0,008 - 0,000)^2 + (0,000 - 1,000)^2 + (0,233 - 0,000)^2)
|
||||||
|
d = sqrt(0,000064 + 1,000000 + 0,054289)
|
||||||
|
d = sqrt(1,054353)
|
||||||
|
d = 1,027
|
||||||
|
Jarak yang lebih kecil menunjukkan bahwa data mobil lebih dekat dengan centroid tersebut. Mobil akan masuk ke cluster dengan centroid yang jaraknya paling kecil.8. Menentukan Cluster Terdekat
|
||||||
|
Setelah jarak dihitung, sistem memasukkan setiap mobil ke cluster dengan centroid terdekat. Tahap ini disebut assignment step pada K-Means.Potongan kode assignment cluster:
|
||||||
|
for index, vector in enumerate(vectors):
|
||||||
|
nearest_cluster = min(range(cluster_count), key=lambda cluster: distance(vector, centroids[cluster]))
|
||||||
|
if labels[index] != nearest_cluster:
|
||||||
|
changed = True
|
||||||
|
labels[index] = nearest_cluster
|
||||||
|
Variabel labels menyimpan nomor cluster untuk setiap mobil. Jika cluster sebuah mobil berubah, variabel changed bernilai True, sehingga proses iterasi akan berlanjut.9. Menghitung Ulang Centroid
|
||||||
|
Setelah semua mobil ditempatkan pada cluster terdekat, sistem menghitung ulang centroid. Centroid baru didapatkan dari rata-rata seluruh anggota dalam cluster tersebut.Potongan kode update centroid:
|
||||||
|
new_centroids = []
|
||||||
|
for cluster in range(cluster_count):
|
||||||
|
members = [vectors[index] for index, label in enumerate(labels) if label == cluster]
|
||||||
|
if not members:
|
||||||
|
new_centroids.append(centroids[cluster])
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_centroids.append(
|
||||||
|
[sum(member[column] for member in members) / len(members) for column in range(len(vectors[0]))]
|
||||||
|
)
|
||||||
|
|
||||||
|
centroids = new_centroids
|
||||||
|
Contoh perhitungan centroid baru:
|
||||||
|
Misalnya Cluster 0 memiliki dua anggota dengan vector normalisasi berikut:Mobil A = [0,008, 0,000, 0,233]
|
||||||
|
Mobil B = [0,000, 1,000, 0,000]
|
||||||
|
Centroid baru Cluster 0 dihitung dengan rata-rata setiap atribut:
|
||||||
|
Harga centroid = (0,008 + 0,000) / 2 = 0,004
|
||||||
|
Tahun centroid = (0,000 + 1,000) / 2 = 0,500
|
||||||
|
Mesin centroid = (0,233 + 0,000) / 2 = 0,1165
|
||||||
|
|
||||||
|
Centroid baru = [0,004, 0,500, 0,1165]
|
||||||
|
10. Iterasi Sampai Stabil
|
||||||
|
Proses menghitung jarak, menentukan cluster, dan menghitung ulang centroid dilakukan berulang. Iterasi akan berhenti jika anggota cluster tidak berubah lagi atau jika sudah mencapai batas iterasi. Dalam sistem ini, batas iterasi default adalah 30.Potongan kode iterasi:
|
||||||
|
for _ in range(iterations):
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# assignment step
|
||||||
|
# update centroid step
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
break
|
||||||
|
11. Menentukan Cluster yang Sesuai dengan Budget User
|
||||||
|
Setelah model K-Means terbentuk, sistem harus menentukan cluster mana yang paling cocok dengan budget pengguna. Untuk itu, sistem membuat data target dari budget pengguna. Nilai harga target adalah budget yang dimasukkan user, sedangkan tahun dan mesin target diambil dari median mobil yang harganya kurang dari atau sama dengan budget.Potongan kode penentuan cluster budget:
|
||||||
|
def predict_budget_cluster(model, budget):
|
||||||
|
model_cars = model["cars"]
|
||||||
|
target_year = median([car["tahun"] for car in model_cars if car["harga"] <= budget] or [car["tahun"] for car in model_cars])
|
||||||
|
target_engine = median([car["mesin_cc"] for car in model_cars if car["harga"] <= budget] or [car["mesin_cc"] for car in model_cars])
|
||||||
|
target_features = [budget, target_year, target_engine]
|
||||||
|
target_vector = [
|
||||||
|
scaled_value(value, model["ranges"][index][0], model["ranges"][index][1])
|
||||||
|
for index, value in enumerate(target_features)
|
||||||
|
]
|
||||||
|
|
||||||
|
return min(
|
||||||
|
range(model["cluster_count"]),
|
||||||
|
key=lambda cluster: distance(target_vector, model["centroids"][cluster]),
|
||||||
|
)
|
||||||
|
Contoh:
|
||||||
|
Jika pengguna memasukkan budget Rp200.000.000, sistem membuat target harga 200.000.000. Tahun dan mesin target dihitung dari median data mobil yang harganya <= 200.000.000. Setelah itu target dinormalisasi dan dibandingkan dengan semua centroid. Cluster dengan jarak paling kecil menjadi cluster rekomendasi.12. Filter Mobil Berdasarkan Cluster dan Budget
|
||||||
|
Setelah cluster target ditemukan, sistem mengambil mobil dari cluster tersebut dengan syarat harga tidak melebihi budget. Jika ada mobil dengan harga sama persis dengan budget, mobil tersebut diprioritaskan.Potongan kode filter budget:
|
||||||
|
same_cluster = [
|
||||||
|
car for car in cars
|
||||||
|
if model["labels"].get(id(car)) == target_cluster and car["harga"] <= budget
|
||||||
|
]
|
||||||
|
|
||||||
|
exact_matches = [car for car in same_cluster if car["harga"] == budget]
|
||||||
|
selected = exact_matches or same_cluster or [car for car in cars if car["harga"] <= budget]
|
||||||
|
Contoh jika budget Rp200.000.000:
|
||||||
|
MobilHargaKeteranganToyota Avanza125.000.000Lolos karena di bawah budgetHyundai Ioniq122.000.000Lolos karena di bawah budgetToyota Fortuner200.000.000Diprioritaskan karena sama dengan budgetToyota Innova250.000.000Tidak lolos karena melebihi budgetFortuner Baru500.000.000Tidak lolos karena melebihi budget13. Scoring Setelah K-Means
|
||||||
|
K-Means digunakan untuk membentuk kelompok kandidat mobil. Setelah kandidat diperoleh, sistem masih melakukan scoring untuk mengurutkan hasil rekomendasi. Scoring ini bukan proses K-Means, melainkan proses tambahan agar hasil rekomendasi lebih rapi dan relevan.Potongan kode bobot strategi harga_pas:
|
||||||
|
if normalize_text(strategy) in ["harga_pas", "harga pas", "spesifik", "specific"]:
|
||||||
|
return {
|
||||||
|
"budget": 0.70,
|
||||||
|
"preference": 0.15,
|
||||||
|
"year": 0.10,
|
||||||
|
"engine": 0.05,
|
||||||
|
"cluster": 0.00,
|
||||||
|
}
|
||||||
|
Pada strategi harga_pas, bobot harga adalah 70%. Artinya hasil rekomendasi lebih mengutamakan mobil yang harganya paling mendekati budget pengguna. Tahun dan mesin tetap dihitung, tetapi pengaruhnya lebih kecil.14. Kesimpulan Tahapan K-Means pada Sistem
|
||||||
|
Tahapan K-Means pada sistem rekomendasi mobil sudah sesuai dengan konsep dasar K-Means. Sistem menentukan atribut, melakukan normalisasi, menentukan centroid awal, menghitung jarak, membentuk cluster, memperbarui centroid, dan mengulang proses sampai stabil. Setelah itu, hasil clustering digunakan untuk memilih mobil yang paling sesuai dengan budget pengguna.Kalimat yang dapat digunakan dalam laporan:
|
||||||
|
Pada sistem ini, metode K-Means digunakan untuk mengelompokkan data mobil berdasarkan tiga atribut, yaitu harga, tahun, dan kapasitas mesin. Data terlebih dahulu dinormalisasi menggunakan Min-Max Normalization agar setiap atribut berada pada skala yang sama. Setelah itu, sistem membentuk cluster menggunakan perhitungan jarak Euclidean. Cluster yang paling dekat dengan budget pengguna dipilih sebagai dasar rekomendasi. Mobil dalam cluster tersebut kemudian disaring agar tidak melebihi budget dan diurutkan menggunakan scoring.15. Catatan Penting untuk Demo dan Sidang
|
||||||
|
K-Means pada sistem dijalankan oleh Flask API melalui file flask_api/app.py. Laravel hanya memanggil hasil rekomendasi dari API tersebut. Jika Flask API tidak aktif, Laravel akan memakai fallback filter harga biasa. Oleh karena itu, saat demo atau sidang, Flask API harus dijalankan agar fitur rekomendasi benar-benar memakai K-Means.16. Kode Program Clustering dengan 3 Atribut
|
||||||
|
Berikut adalah kode program Python untuk proses clustering K-Means yang disesuaikan dengan tiga atribut utama pada sistem rekomendasi, yaitu harga, tahun, dan mesin_cc. Kode ini dapat digunakan sebagai contoh implementasi pada laporan karena hanya memakai atribut yang relevan dengan fitur rekomendasi budget.# Import library
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from sklearn.metrics import silhouette_score
|
||||||
|
|
||||||
|
|
||||||
|
# Membaca dataset
|
||||||
|
df = pd.read_csv("dataset_final.csv")
|
||||||
|
|
||||||
|
|
||||||
|
# Jika masih ada kolom cluster lama, hapus terlebih dahulu
|
||||||
|
if "cluster" in df.columns:
|
||||||
|
df = df.drop(columns=["cluster"])
|
||||||
|
|
||||||
|
|
||||||
|
# Samakan nama kolom harga
|
||||||
|
if "Harga" in df.columns:
|
||||||
|
df = df.rename(columns={"Harga": "harga"})
|
||||||
|
|
||||||
|
|
||||||
|
# Pilih 3 atribut utama untuk clustering
|
||||||
|
fitur = df[
|
||||||
|
[
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Standarisasi data
|
||||||
|
scaler = StandardScaler()
|
||||||
|
fitur_scaled = scaler.fit_transform(fitur)
|
||||||
|
|
||||||
|
|
||||||
|
# Mencari jumlah cluster terbaik menggunakan silhouette score
|
||||||
|
for k in range(2, 7):
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=k,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
|
||||||
|
labels = kmeans.fit_predict(fitur_scaled)
|
||||||
|
score = silhouette_score(fitur_scaled, labels)
|
||||||
|
|
||||||
|
print(f"Jumlah Cluster: {k}, Silhouette Score: {score}")
|
||||||
|
|
||||||
|
|
||||||
|
# Membuat model K-Means akhir
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=4,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Menambahkan hasil cluster ke dataset
|
||||||
|
df["cluster"] = kmeans.fit_predict(fitur_scaled)
|
||||||
|
|
||||||
|
|
||||||
|
# Menampilkan hasil clustering
|
||||||
|
print(
|
||||||
|
df[
|
||||||
|
[
|
||||||
|
"merk",
|
||||||
|
"model",
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc",
|
||||||
|
"cluster"
|
||||||
|
]
|
||||||
|
].head(20)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Menyimpan dataset yang sudah memiliki cluster
|
||||||
|
df.to_csv("dataset_final_clustered.csv", index=False)
|
||||||
|
Pada kode tersebut, data terlebih dahulu dibaca dari file dataset_final.csv. Jika terdapat kolom cluster lama, kolom tersebut dihapus agar hasil clustering dihitung ulang dari awal. Setelah itu, sistem memilih tiga atribut utama, yaitu harga, tahun, dan mesin_cc.Tahap standarisasi dilakukan menggunakan StandardScaler agar ketiga atribut berada pada skala yang seimbang. Hal ini penting karena harga memiliki nilai yang jauh lebih besar dibandingkan tahun dan mesin_cc. Setelah data distandarisasi, K-Means dijalankan untuk membentuk cluster.Bagian silhouette score digunakan untuk melihat kualitas beberapa jumlah cluster. Setelah itu, sistem membuat model akhir. Pada contoh ini digunakan 4 cluster agar sesuai dengan konfigurasi sistem rekomendasi yang mengirim parameter jumlah_cluster = 4 ke Flask API.
|
||||||
|
17. Penjelasan Kode Clustering dan Kegunaannya dalam Sistem
|
||||||
|
Bagian ini menjelaskan fungsi setiap blok kode clustering K-Means dan hubungannya dengan fitur rekomendasi mobil berdasarkan budget pada sistem JMW Showroom. Kode clustering digunakan untuk membentuk kelompok mobil berdasarkan kemiripan harga, tahun, dan kapasitas mesin.17.1 Import Library
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from sklearn.metrics import silhouette_score
|
||||||
|
Kode ini digunakan untuk memanggil library yang diperlukan. pandas dipakai untuk membaca dan mengolah dataset. StandardScaler digunakan untuk menstandarkan nilai atribut agar skala harga, tahun, dan mesin_cc menjadi seimbang. KMeans digunakan untuk melakukan proses clustering, sedangkan silhouette_score digunakan untuk menilai kualitas pembentukan cluster.Kegunaannya dalam sistem adalah menyiapkan alat utama untuk mengolah data mobil sebelum data tersebut dipakai sebagai dasar rekomendasi.17.2 Membaca Dataset
|
||||||
|
df = pd.read_csv("dataset_final.csv")
|
||||||
|
Kode ini membaca file dataset_final.csv yang berisi data mobil. Dataset tersebut memuat informasi seperti merk, model, harga, tahun, mesin_cc, transmisi, bahan bakar, dan atribut lainnya.Dalam sistem, dataset ini menjadi sumber data utama untuk membentuk cluster. Jika data mobil berubah atau ditambah, proses clustering dapat dijalankan kembali agar hasil rekomendasi mengikuti data terbaru.17.3 Menghapus Cluster Lama
|
||||||
|
if "cluster" in df.columns:
|
||||||
|
df = df.drop(columns=["cluster"])
|
||||||
|
Kode ini mengecek apakah dataset sudah memiliki kolom cluster. Jika ada, kolom tersebut dihapus terlebih dahulu. Tujuannya agar proses clustering tidak memakai hasil lama, tetapi menghitung ulang cluster dari awal.Kegunaannya dalam sistem adalah menjaga agar hasil cluster selalu sesuai dengan data dan proses perhitungan terbaru, bukan hasil clustering sebelumnya.17.4 Menyamakan Nama Kolom Harga
|
||||||
|
if "Harga" in df.columns:
|
||||||
|
df = df.rename(columns={"Harga": "harga"})
|
||||||
|
Kode ini menyamakan nama kolom Harga menjadi harga. Penyamaan nama kolom penting karena kode clustering mengambil atribut dengan nama harga, tahun, dan mesin_cc.Dalam sistem, hal ini membantu mencegah error ketika dataset menggunakan nama kolom dengan huruf besar, misalnya Harga, sedangkan kode membaca kolom harga.17.5 Memilih Tiga Atribut Utama
|
||||||
|
fitur = df[
|
||||||
|
[
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
Kode ini memilih tiga atribut yang digunakan untuk clustering, yaitu harga, tahun, dan mesin_cc. Ketiga atribut tersebut dipakai karena berhubungan langsung dengan rekomendasi mobil bekas. Harga menentukan kesesuaian dengan budget, tahun menunjukkan usia kendaraan, dan mesin_cc menunjukkan kapasitas mesin.Kegunaannya dalam sistem adalah menentukan dasar pengelompokan mobil. Mobil dengan harga, tahun, dan mesin yang mirip akan cenderung masuk ke cluster yang sama.17.6 Standarisasi Data
|
||||||
|
scaler = StandardScaler()
|
||||||
|
fitur_scaled = scaler.fit_transform(fitur)
|
||||||
|
Kode ini melakukan standarisasi data. Standarisasi diperlukan karena nilai harga sangat besar dibandingkan tahun dan mesin_cc. Jika tidak distandarisasi, perhitungan jarak pada K-Means akan terlalu dipengaruhi oleh harga, sehingga atribut tahun dan mesin_cc menjadi kurang berpengaruh.Dalam sistem, standarisasi membuat proses clustering lebih adil karena semua atribut berada pada skala yang seimbang sebelum dihitung jaraknya.17.7 Mencari Jumlah Cluster Terbaik
|
||||||
|
for k in range(2, 7):
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=k,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
|
||||||
|
labels = kmeans.fit_predict(fitur_scaled)
|
||||||
|
score = silhouette_score(fitur_scaled, labels)
|
||||||
|
|
||||||
|
print(f"Jumlah Cluster: {k}, Silhouette Score: {score}")
|
||||||
|
Kode ini mencoba beberapa jumlah cluster, mulai dari 2 sampai 6. Setiap nilai k diuji dengan K-Means, lalu hasilnya dinilai menggunakan silhouette_score. Nilai silhouette digunakan untuk melihat seberapa baik data terpisah ke dalam cluster.Kegunaannya dalam sistem adalah membantu menentukan jumlah cluster yang paling sesuai. Dengan cara ini, jumlah cluster tidak dipilih secara asal, tetapi berdasarkan hasil evaluasi.17.8 Membuat Model K-Means Akhir
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=4,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
Kode ini membuat model K-Means akhir dengan jumlah cluster sebanyak 4. Angka 4 disesuaikan dengan konfigurasi pada sistem rekomendasi yang mengirim parameter jumlah_cluster = 4 ke Flask API.Dalam sistem, model ini berfungsi untuk membagi data mobil ke dalam empat kelompok. Setiap kelompok berisi mobil yang memiliki karakteristik harga, tahun, dan mesin_cc yang relatif mirip.17.9 Menambahkan Hasil Cluster ke Dataset
|
||||||
|
df["cluster"] = kmeans.fit_predict(fitur_scaled)
|
||||||
|
Kode ini menjalankan K-Means pada data yang sudah distandarisasi, lalu menyimpan hasil cluster ke kolom baru bernama cluster. Setiap mobil akan mendapatkan label cluster, misalnya 0, 1, 2, atau 3.Kegunaannya dalam sistem adalah memberikan identitas kelompok pada setiap mobil. Label cluster ini dapat dipakai untuk memilih kandidat mobil yang paling sesuai dengan budget pengguna.17.10 Menampilkan dan Menyimpan Hasil
|
||||||
|
print(
|
||||||
|
df[
|
||||||
|
[
|
||||||
|
"merk",
|
||||||
|
"model",
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc",
|
||||||
|
"cluster"
|
||||||
|
]
|
||||||
|
].head(20)
|
||||||
|
)
|
||||||
|
|
||||||
|
df.to_csv("dataset_final_clustered.csv", index=False)
|
||||||
|
Kode ini menampilkan sebagian hasil clustering agar peneliti dapat melihat apakah setiap mobil sudah memiliki label cluster. Setelah itu, dataset disimpan ke file baru bernama dataset_final_clustered.csv.Dalam sistem, file hasil clustering dapat digunakan sebagai dataset yang sudah siap dipakai untuk proses rekomendasi. Data tersebut berisi informasi mobil beserta cluster-nya.17.11 Hubungan Kode dengan Fitur Rekomendasi Budget
|
||||||
|
Secara keseluruhan, kode clustering ini berfungsi untuk mengelompokkan mobil sebelum proses rekomendasi dilakukan. Ketika pengguna memasukkan budget, sistem dapat mencari kelompok mobil yang karakteristiknya paling mendekati budget tersebut. Setelah cluster yang sesuai ditemukan, sistem menyaring mobil yang harganya tidak melebihi budget pengguna dan mengurutkan hasil rekomendasi.Dengan demikian, K-Means tidak hanya menampilkan mobil berdasarkan harga terdekat, tetapi membantu sistem mempertimbangkan kemiripan karakteristik kendaraan berdasarkan harga, tahun, dan kapasitas mesin.Kalimat Ringkas untuk Laporan
|
||||||
|
Kode clustering digunakan untuk mengelompokkan data mobil berdasarkan atribut harga, tahun, dan mesin_cc. Sebelum dilakukan clustering, data distandarisasi agar setiap atribut memiliki skala yang seimbang. Setelah model K-Means dijalankan, setiap mobil memperoleh label cluster. Label cluster tersebut digunakan sistem sebagai dasar dalam memilih kandidat rekomendasi mobil yang sesuai dengan budget pengguna.
|
||||||
|
|
@ -0,0 +1,324 @@
|
||||||
|
Penjelasan Rinci: Proses K-Means dan Hubungannya dengan Sistem Rekomendasi
|
||||||
|
Dokumen ini disusun untuk memberikan penjelasan secara mendalam mengenai proses penerapan algoritma machine learning (K-Means Clustering) pada data asli di database, serta bagaimana hasil pengelompokan tersebut digunakan untuk memberikan rekomendasi mobil yang akurat berdasarkan budget konsumen pada Sistem Informasi Penjualan Mobil di Showroom JMW.1. Tahapan Proses K-Means pada Data Asli di Database
|
||||||
|
Algoritma K-Means tidak dapat langsung memproses data mentah (raw data) yang ada di database. Mesin membutuhkan data yang telah diseragamkan dalam bentuk angka. Berdasarkan laporan, terdapat 405 data mobil dengan 9 atribut. Berikut adalah proses detail dari database hingga menjadi cluster:a. Pengambilan & Pembersihan Data (Data Fetching & Cleaning)Sistem akan mengambil data dari tabel kategori (mobil) di database MySQL. Data ini mencakup atribut seperti Merk, Kategori, Transmisi, Bahan Bakar, Tahun, Kapasitas Mesin (CC), dan Harga. Pada tahap ini, sistem memastikan tidak ada data yang kosong (missing value).b. Pra-pemrosesan Data (Preprocessing)Karena algoritma K-Means menghitung "jarak" antar data menggunakan rumus matematika, data berformat teks (kategorikal) harus diubah menjadi angka:- One-Hot Encoding: Atribut teks seperti Merk (Toyota, Honda), Transmisi (Manual, Matic), dan Bahan Bakar diubah menjadi representasi biner (0 dan 1). Proses ini memperluas jumlah fitur dari 7 atribut menjadi 29 fitur analisis.- Normalisasi (Z-Score/StandardScaler): Atribut angka memiliki rentang yang sangat timpang. Harga bisa mencapai ratusan juta (contoh: 285.000.000), sedangkan tahun hanya puluhan (contoh: 2022). Jika tidak dinormalisasi, atribut harga akan mendominasi dan merusak perhitungan. Dengan Z-Score, semua atribut angka disetarakan skalanya agar memiliki rata-rata 0 dan standar deviasi 1.c. Penentuan Jumlah Cluster (Metode Elbow & Silhouette Score)Sistem harus menentukan jumlah kelompok (k) terbaik. Menggunakan metode Silhouette Score, sistem mengevaluasi kedekatan antar mobil di dalam kelompok yang sama (cohesion) dan jaraknya dengan kelompok lain (separation). Pada studi kasus JMW, didapatkan nilai K=6 (Silhouette Score tertinggi: 0.257) sebagai jumlah pengelompokan yang paling optimal.d. Perhitungan Jarak dan Pengelompokan (Euclidean Distance)Sistem menentukan 6 titik pusat (centroid) secara acak. Kemudian, jarak dari setiap mobil di database ke 6 titik pusat tersebut dihitung menggunakan rumus Euclidean Distance. Mobil akan dimasukkan ke dalam cluster dengan jarak centroid paling dekat dengannya.e. Pembaruan Centroid (Iterasi Konvergen)Setelah 405 mobil terbagi ke dalam 6 kelompok, sistem akan menghitung ulang lokasi rata-rata dari masing-masing kelompok untuk menjadi centroid baru. Proses hitung jarak dan pembaruan centroid ini diulang terus-menerus hingga letak titik pusat stabil dan tidak ada lagi mobil yang berpindah kelompok. Data cluster (0-5) inilah yang kemudian disimpan kembali ke dalam database.2. Hubungan Data yang Sudah di-Clustering dengan Sistem Rekomendasi
|
||||||
|
Banyak yang bertanya, "Mengapa harus repot menggunakan Machine Learning jika kita bisa sekadar menggunakan filter harga di SQL (misal: WHERE harga <= budget)?". Jawabannya terletak pada "Relevansi dan Value for Money". Hubungan antara hasil K-Means dengan rekomendasi dapat dijelaskan sebagai berikut:a. Menghindari Rekomendasi "Buta" (Blind Filtering)Jika hanya menggunakan query database biasa berdasarkan budget Rp120.000.000, sistem mungkin akan memunculkan mobil city car baru berukuran kecil, sekaligus memunculkan SUV tua bangka bermesin besar yang boros secara bersamaan. Rekomendasi ini tidak terarah karena hanya bertumpu pada harga, bukan spesifikasi keseluruhan.b. Pemetaan Profil (Profiling) Berdasarkan ClusterData yang sudah di-clustering telah memiliki "DNA" atau profilnya masing-masing, misalnya:- Cluster 0: Hatchback, manual, bensin (rata-rata harga Rp132 Juta)- Cluster 1: SUV, manual, bensin cc besar (rata-rata harga Rp219 Juta)- Cluster 2: City car, manual, diesel tua (rata-rata harga Rp117 Juta)Artinya, mobil di dalam kelompok yang sama memiliki karakteristik nilai penyusutan, kenyamanan, dan performa yang sangat mirip.c. Mekanisme Rekomendasi CerdasKetika konsumen menginputkan budget maksimalnya, machine learning sistem tidak hanya mencari mobil yang lebih murah dari angka tersebut. Sistem akan mengidentifikasi budget tersebut paling mendekati karakteristik profil (centroid) cluster yang mana. Setelah menemukan cluster yang paling sesuai dengan profil finansial konsumen, sistem akan merekomendasikan daftar unit dari dalam cluster tersebut.Kesimpulan: K-Means berfungsi sebagai "Otak Pengkategorian" yang sangat cerdas. Hubungannya dengan rekomendasi adalah memastikan bahwa unit mobil yang disarankan kepada konsumen bukan sekadar pas di kantong, melainkan juga memiliki spesifikasi yang setara, wajar, dan sesuai dengan tren harga pasar di kelompoknya. Hal ini pada akhirnya akan sangat membantu efektivitas pengambilan keputusan transaksi konsumen di Showroom JMW.Penjelasan Lengkap Metode K-Means pada Fitur Rekomendasi Mobil Berdasarkan Budget
|
||||||
|
Dokumen ini menjelaskan tahapan metode K-Means yang digunakan pada fitur rekomendasi mobil sesuai budget. Penjelasan disesuaikan dengan implementasi sistem, yaitu aplikasi Laravel sebagai sistem utama dan Flask API sebagai layanan pengolahan rekomendasi.1. Gambaran Umum Fitur Rekomendasi
|
||||||
|
Fitur rekomendasi budget bekerja ketika pengguna memasukkan nominal budget maksimal. Laravel menerima input tersebut, lalu mengirim request ke Flask API. Flask API membaca dataset mobil, menjalankan proses K-Means, memilih cluster yang paling sesuai dengan budget pengguna, kemudian mengembalikan daftar mobil rekomendasi kepada Laravel.Alur sistem secara ringkas:
|
||||||
|
- Pengguna memasukkan budget pada halaman rekomendasi.- Laravel memanggil endpoint Flask API /recommend.- Flask membaca dataset mobil.- Flask membentuk cluster dengan K-Means berdasarkan harga, tahun, dan mesin_cc.- Sistem menentukan cluster yang paling dekat dengan budget pengguna.- Mobil dalam cluster tersebut difilter agar tidak melebihi budget.- Hasil akhir diurutkan menggunakan scoring, lalu ditampilkan oleh Laravel.2. Atribut yang Digunakan dalam K-Means
|
||||||
|
K-Means pada sistem ini menggunakan tiga atribut numerik. Ketiga atribut tersebut diambil dari data mobil dan digunakan sebagai vector fitur untuk proses clustering.AtributNama di KodeFungsi dalam RekomendasiHarga mobilhargaMenentukan kedekatan mobil dengan budget pengguna.Tahun mobiltahunMenunjukkan usia kendaraan; mobil lebih muda biasanya lebih menarik.Kapasitas mesinmesin_ccMenunjukkan karakteristik performa dan spesifikasi kendaraan.Potongan kode atribut yang digunakan:
|
||||||
|
def car_features(car):
|
||||||
|
return [car["harga"], car["tahun"], car["mesin_cc"]]
|
||||||
|
Kode tersebut menunjukkan bahwa setiap mobil diubah menjadi vector berisi tiga nilai, yaitu harga, tahun, dan kapasitas mesin. Vector inilah yang kemudian diproses dalam K-Means.3. Membaca dan Menyiapkan Dataset
|
||||||
|
Flask API membaca dataset mobil dari file yang tersedia di project. Setiap baris dataset diubah menjadi data mobil yang memiliki atribut merk, model, kategori, tahun, mesin_cc, transmisi, bahan_bakar, harga, dan cluster. Untuk proses K-Means, atribut yang digunakan hanya harga, tahun, dan mesin_cc.Potongan kode pembacaan data:
|
||||||
|
cars.append(
|
||||||
|
{
|
||||||
|
"merk": row.get("merk", "").strip(),
|
||||||
|
"model": row.get("model", "").strip(),
|
||||||
|
"nama": f"{row.get('merk', '').strip()} {row.get('model', '').strip()}".strip(),
|
||||||
|
"tahun": to_int(row.get("tahun")),
|
||||||
|
"mesin_cc": to_int(row.get("mesin_cc")),
|
||||||
|
"harga": price,
|
||||||
|
"cluster": to_int(row.get("cluster"), default=None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Data dengan harga tidak valid tidak digunakan. Pada pembentukan model K-Means, sistem juga hanya memakai data yang memiliki harga, tahun, dan mesin_cc lebih dari 0.4. Normalisasi Data
|
||||||
|
Normalisasi adalah proses mengubah nilai atribut ke skala yang sama. Pada sistem ini digunakan Min-Max Normalization agar nilai berada pada rentang 0 sampai 1. Normalisasi diperlukan karena harga memiliki skala ratusan juta, sedangkan tahun sekitar 2019-2025 dan mesin sekitar 1000-2500 cc. Tanpa normalisasi, atribut harga akan terlalu mendominasi perhitungan jarak.Rumus normalisasi:
|
||||||
|
nilai_normal = (nilai - nilai_minimum) / (nilai_maksimum - nilai_minimum)
|
||||||
|
Potongan kode normalisasi:
|
||||||
|
def scaled_value(value, minimum, maximum):
|
||||||
|
if maximum == minimum:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return (value - minimum) / (maximum - minimum)
|
||||||
|
Kode pembentukan vector normalisasi:
|
||||||
|
feature_rows = [car_features(car) for car in usable_cars]
|
||||||
|
columns = list(zip(*feature_rows))
|
||||||
|
ranges = [(min(column), max(column)) for column in columns]
|
||||||
|
vectors = [
|
||||||
|
[scaled_value(value, ranges[index][0], ranges[index][1]) for index, value in enumerate(row)]
|
||||||
|
for row in feature_rows
|
||||||
|
]
|
||||||
|
5. Contoh Data dan Perhitungan Normalisasi
|
||||||
|
Misalkan terdapat data mobil berikut:MobilHargaTahunMesin CCToyota Avanza125.000.00020191500Hyundai Ioniq122.000.00020211197Toyota Fortuner200.000.00020202500Toyota Innova250.000.00020202000Fortuner Baru500.000.00020212500Nilai minimum dan maksimum dari contoh data:
|
||||||
|
AtributMinimumMaksimumHarga122.000.000500.000.000Tahun20192021Mesin CC11972500Contoh normalisasi Toyota Avanza:
|
||||||
|
Harga:
|
||||||
|
(125.000.000 - 122.000.000) / (500.000.000 - 122.000.000)
|
||||||
|
= 3.000.000 / 378.000.000
|
||||||
|
= 0,008
|
||||||
|
|
||||||
|
Tahun:
|
||||||
|
(2019 - 2019) / (2021 - 2019)
|
||||||
|
= 0 / 2
|
||||||
|
= 0
|
||||||
|
|
||||||
|
Mesin:
|
||||||
|
(1500 - 1197) / (2500 - 1197)
|
||||||
|
= 303 / 1303
|
||||||
|
= 0,233
|
||||||
|
Jadi vector normalisasi Toyota Avanza adalah:
|
||||||
|
[0,008, 0,000, 0,233]
|
||||||
|
Contoh normalisasi Toyota Fortuner harga 200 juta:
|
||||||
|
Harga:
|
||||||
|
(200.000.000 - 122.000.000) / (500.000.000 - 122.000.000)
|
||||||
|
= 78.000.000 / 378.000.000
|
||||||
|
= 0,206
|
||||||
|
|
||||||
|
Tahun:
|
||||||
|
(2020 - 2019) / (2021 - 2019)
|
||||||
|
= 1 / 2
|
||||||
|
= 0,500
|
||||||
|
|
||||||
|
Mesin:
|
||||||
|
(2500 - 1197) / (2500 - 1197)
|
||||||
|
= 1303 / 1303
|
||||||
|
= 1,000
|
||||||
|
Jadi vector normalisasi Toyota Fortuner adalah:
|
||||||
|
[0,206, 0,500, 1,000]
|
||||||
|
6. Menentukan Centroid Awal
|
||||||
|
Centroid adalah titik pusat cluster. Pada implementasi sistem, centroid awal dipilih berdasarkan data yang sudah diurutkan menurut harga. Cara ini membuat centroid awal tersebar dari data harga rendah sampai harga tinggi. Jumlah cluster default yang digunakan adalah 4 cluster.Potongan kode centroid awal:
|
||||||
|
cluster_count = max(1, min(cluster_count, len(vectors)))
|
||||||
|
sorted_indexes = sorted(range(len(usable_cars)), key=lambda index: usable_cars[index]["harga"])
|
||||||
|
centroids = []
|
||||||
|
|
||||||
|
for cluster_index in range(cluster_count):
|
||||||
|
sample_position = round(cluster_index * (len(sorted_indexes) - 1) / max(cluster_count - 1, 1))
|
||||||
|
centroids.append(vectors[sorted_indexes[sample_position]][:])
|
||||||
|
Secara teori, centroid awal dapat ditentukan secara acak atau menggunakan strategi tertentu. Pada sistem ini, centroid awal tidak dipilih acak, melainkan berdasarkan persebaran harga agar cluster awal lebih stabil.7. Menghitung Jarak dengan Euclidean Distance
|
||||||
|
Setelah centroid awal ditentukan, sistem menghitung jarak setiap mobil terhadap seluruh centroid. Jarak yang digunakan adalah Euclidean Distance.Rumus Euclidean Distance:
|
||||||
|
d = sqrt((x1 - c1)^2 + (x2 - c2)^2 + (x3 - c3)^2)
|
||||||
|
Keterangan:
|
||||||
|
- x1, x2, x3 adalah nilai normalisasi mobil untuk harga, tahun, dan mesin_cc.- c1, c2, c3 adalah nilai centroid untuk harga, tahun, dan mesin_cc.- d adalah jarak antara mobil dengan centroid.Potongan kode jarak:
|
||||||
|
def distance(point, centroid):
|
||||||
|
return math.sqrt(sum((point[index] - centroid[index]) ** 2 for index in range(len(point))))
|
||||||
|
Contoh perhitungan jarak:
|
||||||
|
Misalnya vector normalisasi Toyota Avanza adalah [0,008, 0,000, 0,233] dan centroid C1 adalah [0,000, 1,000, 0,000]. Maka jaraknya:d = sqrt((0,008 - 0,000)^2 + (0,000 - 1,000)^2 + (0,233 - 0,000)^2)
|
||||||
|
d = sqrt(0,000064 + 1,000000 + 0,054289)
|
||||||
|
d = sqrt(1,054353)
|
||||||
|
d = 1,027
|
||||||
|
Jarak yang lebih kecil menunjukkan bahwa data mobil lebih dekat dengan centroid tersebut. Mobil akan masuk ke cluster dengan centroid yang jaraknya paling kecil.8. Menentukan Cluster Terdekat
|
||||||
|
Setelah jarak dihitung, sistem memasukkan setiap mobil ke cluster dengan centroid terdekat. Tahap ini disebut assignment step pada K-Means.Potongan kode assignment cluster:
|
||||||
|
for index, vector in enumerate(vectors):
|
||||||
|
nearest_cluster = min(range(cluster_count), key=lambda cluster: distance(vector, centroids[cluster]))
|
||||||
|
if labels[index] != nearest_cluster:
|
||||||
|
changed = True
|
||||||
|
labels[index] = nearest_cluster
|
||||||
|
Variabel labels menyimpan nomor cluster untuk setiap mobil. Jika cluster sebuah mobil berubah, variabel changed bernilai True, sehingga proses iterasi akan berlanjut.9. Menghitung Ulang Centroid
|
||||||
|
Setelah semua mobil ditempatkan pada cluster terdekat, sistem menghitung ulang centroid. Centroid baru didapatkan dari rata-rata seluruh anggota dalam cluster tersebut.Potongan kode update centroid:
|
||||||
|
new_centroids = []
|
||||||
|
for cluster in range(cluster_count):
|
||||||
|
members = [vectors[index] for index, label in enumerate(labels) if label == cluster]
|
||||||
|
if not members:
|
||||||
|
new_centroids.append(centroids[cluster])
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_centroids.append(
|
||||||
|
[sum(member[column] for member in members) / len(members) for column in range(len(vectors[0]))]
|
||||||
|
)
|
||||||
|
|
||||||
|
centroids = new_centroids
|
||||||
|
Contoh perhitungan centroid baru:
|
||||||
|
Misalnya Cluster 0 memiliki dua anggota dengan vector normalisasi berikut:Mobil A = [0,008, 0,000, 0,233]
|
||||||
|
Mobil B = [0,000, 1,000, 0,000]
|
||||||
|
Centroid baru Cluster 0 dihitung dengan rata-rata setiap atribut:
|
||||||
|
Harga centroid = (0,008 + 0,000) / 2 = 0,004
|
||||||
|
Tahun centroid = (0,000 + 1,000) / 2 = 0,500
|
||||||
|
Mesin centroid = (0,233 + 0,000) / 2 = 0,1165
|
||||||
|
|
||||||
|
Centroid baru = [0,004, 0,500, 0,1165]
|
||||||
|
10. Iterasi Sampai Stabil
|
||||||
|
Proses menghitung jarak, menentukan cluster, dan menghitung ulang centroid dilakukan berulang. Iterasi akan berhenti jika anggota cluster tidak berubah lagi atau jika sudah mencapai batas iterasi. Dalam sistem ini, batas iterasi default adalah 30.Potongan kode iterasi:
|
||||||
|
for _ in range(iterations):
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# assignment step
|
||||||
|
# update centroid step
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
break
|
||||||
|
11. Menentukan Cluster yang Sesuai dengan Budget User
|
||||||
|
Setelah model K-Means terbentuk, sistem harus menentukan cluster mana yang paling cocok dengan budget pengguna. Untuk itu, sistem membuat data target dari budget pengguna. Nilai harga target adalah budget yang dimasukkan user, sedangkan tahun dan mesin target diambil dari median mobil yang harganya kurang dari atau sama dengan budget.Potongan kode penentuan cluster budget:
|
||||||
|
def predict_budget_cluster(model, budget):
|
||||||
|
model_cars = model["cars"]
|
||||||
|
target_year = median([car["tahun"] for car in model_cars if car["harga"] <= budget] or [car["tahun"] for car in model_cars])
|
||||||
|
target_engine = median([car["mesin_cc"] for car in model_cars if car["harga"] <= budget] or [car["mesin_cc"] for car in model_cars])
|
||||||
|
target_features = [budget, target_year, target_engine]
|
||||||
|
target_vector = [
|
||||||
|
scaled_value(value, model["ranges"][index][0], model["ranges"][index][1])
|
||||||
|
for index, value in enumerate(target_features)
|
||||||
|
]
|
||||||
|
|
||||||
|
return min(
|
||||||
|
range(model["cluster_count"]),
|
||||||
|
key=lambda cluster: distance(target_vector, model["centroids"][cluster]),
|
||||||
|
)
|
||||||
|
Contoh:
|
||||||
|
Jika pengguna memasukkan budget Rp200.000.000, sistem membuat target harga 200.000.000. Tahun dan mesin target dihitung dari median data mobil yang harganya <= 200.000.000. Setelah itu target dinormalisasi dan dibandingkan dengan semua centroid. Cluster dengan jarak paling kecil menjadi cluster rekomendasi.12. Filter Mobil Berdasarkan Cluster dan Budget
|
||||||
|
Setelah cluster target ditemukan, sistem mengambil mobil dari cluster tersebut dengan syarat harga tidak melebihi budget. Jika ada mobil dengan harga sama persis dengan budget, mobil tersebut diprioritaskan.Potongan kode filter budget:
|
||||||
|
same_cluster = [
|
||||||
|
car for car in cars
|
||||||
|
if model["labels"].get(id(car)) == target_cluster and car["harga"] <= budget
|
||||||
|
]
|
||||||
|
|
||||||
|
exact_matches = [car for car in same_cluster if car["harga"] == budget]
|
||||||
|
selected = exact_matches or same_cluster or [car for car in cars if car["harga"] <= budget]
|
||||||
|
Contoh jika budget Rp200.000.000:
|
||||||
|
MobilHargaKeteranganToyota Avanza125.000.000Lolos karena di bawah budgetHyundai Ioniq122.000.000Lolos karena di bawah budgetToyota Fortuner200.000.000Diprioritaskan karena sama dengan budgetToyota Innova250.000.000Tidak lolos karena melebihi budgetFortuner Baru500.000.000Tidak lolos karena melebihi budget13. Scoring Setelah K-Means
|
||||||
|
K-Means digunakan untuk membentuk kelompok kandidat mobil. Setelah kandidat diperoleh, sistem masih melakukan scoring untuk mengurutkan hasil rekomendasi. Scoring ini bukan proses K-Means, melainkan proses tambahan agar hasil rekomendasi lebih rapi dan relevan.Potongan kode bobot strategi harga_pas:
|
||||||
|
if normalize_text(strategy) in ["harga_pas", "harga pas", "spesifik", "specific"]:
|
||||||
|
return {
|
||||||
|
"budget": 0.70,
|
||||||
|
"preference": 0.15,
|
||||||
|
"year": 0.10,
|
||||||
|
"engine": 0.05,
|
||||||
|
"cluster": 0.00,
|
||||||
|
}
|
||||||
|
Pada strategi harga_pas, bobot harga adalah 70%. Artinya hasil rekomendasi lebih mengutamakan mobil yang harganya paling mendekati budget pengguna. Tahun dan mesin tetap dihitung, tetapi pengaruhnya lebih kecil.14. Kesimpulan Tahapan K-Means pada Sistem
|
||||||
|
Tahapan K-Means pada sistem rekomendasi mobil sudah sesuai dengan konsep dasar K-Means. Sistem menentukan atribut, melakukan normalisasi, menentukan centroid awal, menghitung jarak, membentuk cluster, memperbarui centroid, dan mengulang proses sampai stabil. Setelah itu, hasil clustering digunakan untuk memilih mobil yang paling sesuai dengan budget pengguna.Kalimat yang dapat digunakan dalam laporan:
|
||||||
|
Pada sistem ini, metode K-Means digunakan untuk mengelompokkan data mobil berdasarkan tiga atribut, yaitu harga, tahun, dan kapasitas mesin. Data terlebih dahulu dinormalisasi menggunakan Min-Max Normalization agar setiap atribut berada pada skala yang sama. Setelah itu, sistem membentuk cluster menggunakan perhitungan jarak Euclidean. Cluster yang paling dekat dengan budget pengguna dipilih sebagai dasar rekomendasi. Mobil dalam cluster tersebut kemudian disaring agar tidak melebihi budget dan diurutkan menggunakan scoring.15. Catatan Penting untuk Demo dan Sidang
|
||||||
|
K-Means pada sistem dijalankan oleh Flask API melalui file flask_api/app.py. Laravel hanya memanggil hasil rekomendasi dari API tersebut. Jika Flask API tidak aktif, Laravel akan memakai fallback filter harga biasa. Oleh karena itu, saat demo atau sidang, Flask API harus dijalankan agar fitur rekomendasi benar-benar memakai K-Means.16. Kode Program Clustering dengan 3 Atribut
|
||||||
|
Berikut adalah kode program Python untuk proses clustering K-Means yang disesuaikan dengan tiga atribut utama pada sistem rekomendasi, yaitu harga, tahun, dan mesin_cc. Kode ini dapat digunakan sebagai contoh implementasi pada laporan karena hanya memakai atribut yang relevan dengan fitur rekomendasi budget.# Import library
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from sklearn.metrics import silhouette_score
|
||||||
|
|
||||||
|
|
||||||
|
# Membaca dataset
|
||||||
|
df = pd.read_csv("dataset_final.csv")
|
||||||
|
|
||||||
|
|
||||||
|
# Jika masih ada kolom cluster lama, hapus terlebih dahulu
|
||||||
|
if "cluster" in df.columns:
|
||||||
|
df = df.drop(columns=["cluster"])
|
||||||
|
|
||||||
|
|
||||||
|
# Samakan nama kolom harga
|
||||||
|
if "Harga" in df.columns:
|
||||||
|
df = df.rename(columns={"Harga": "harga"})
|
||||||
|
|
||||||
|
|
||||||
|
# Pilih 3 atribut utama untuk clustering
|
||||||
|
fitur = df[
|
||||||
|
[
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Standarisasi data
|
||||||
|
scaler = StandardScaler()
|
||||||
|
fitur_scaled = scaler.fit_transform(fitur)
|
||||||
|
|
||||||
|
|
||||||
|
# Mencari jumlah cluster terbaik menggunakan silhouette score
|
||||||
|
for k in range(2, 7):
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=k,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
|
||||||
|
labels = kmeans.fit_predict(fitur_scaled)
|
||||||
|
score = silhouette_score(fitur_scaled, labels)
|
||||||
|
|
||||||
|
print(f"Jumlah Cluster: {k}, Silhouette Score: {score}")
|
||||||
|
|
||||||
|
|
||||||
|
# Membuat model K-Means akhir
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=4,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Menambahkan hasil cluster ke dataset
|
||||||
|
df["cluster"] = kmeans.fit_predict(fitur_scaled)
|
||||||
|
|
||||||
|
|
||||||
|
# Menampilkan hasil clustering
|
||||||
|
print(
|
||||||
|
df[
|
||||||
|
[
|
||||||
|
"merk",
|
||||||
|
"model",
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc",
|
||||||
|
"cluster"
|
||||||
|
]
|
||||||
|
].head(20)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Menyimpan dataset yang sudah memiliki cluster
|
||||||
|
df.to_csv("dataset_final_clustered.csv", index=False)
|
||||||
|
Pada kode tersebut, data terlebih dahulu dibaca dari file dataset_final.csv. Jika terdapat kolom cluster lama, kolom tersebut dihapus agar hasil clustering dihitung ulang dari awal. Setelah itu, sistem memilih tiga atribut utama, yaitu harga, tahun, dan mesin_cc.Tahap standarisasi dilakukan menggunakan StandardScaler agar ketiga atribut berada pada skala yang seimbang. Hal ini penting karena harga memiliki nilai yang jauh lebih besar dibandingkan tahun dan mesin_cc. Setelah data distandarisasi, K-Means dijalankan untuk membentuk cluster.Bagian silhouette score digunakan untuk melihat kualitas beberapa jumlah cluster. Setelah itu, sistem membuat model akhir. Pada contoh ini digunakan 4 cluster agar sesuai dengan konfigurasi sistem rekomendasi yang mengirim parameter jumlah_cluster = 4 ke Flask API.
|
||||||
|
17. Penjelasan Kode Clustering dan Kegunaannya dalam Sistem
|
||||||
|
Bagian ini menjelaskan fungsi setiap blok kode clustering K-Means dan hubungannya dengan fitur rekomendasi mobil berdasarkan budget pada sistem JMW Showroom. Kode clustering digunakan untuk membentuk kelompok mobil berdasarkan kemiripan harga, tahun, dan kapasitas mesin.17.1 Import Library
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from sklearn.metrics import silhouette_score
|
||||||
|
Kode ini digunakan untuk memanggil library yang diperlukan. pandas dipakai untuk membaca dan mengolah dataset. StandardScaler digunakan untuk menstandarkan nilai atribut agar skala harga, tahun, dan mesin_cc menjadi seimbang. KMeans digunakan untuk melakukan proses clustering, sedangkan silhouette_score digunakan untuk menilai kualitas pembentukan cluster.Kegunaannya dalam sistem adalah menyiapkan alat utama untuk mengolah data mobil sebelum data tersebut dipakai sebagai dasar rekomendasi.17.2 Membaca Dataset
|
||||||
|
df = pd.read_csv("dataset_final.csv")
|
||||||
|
Kode ini membaca file dataset_final.csv yang berisi data mobil. Dataset tersebut memuat informasi seperti merk, model, harga, tahun, mesin_cc, transmisi, bahan bakar, dan atribut lainnya.Dalam sistem, dataset ini menjadi sumber data utama untuk membentuk cluster. Jika data mobil berubah atau ditambah, proses clustering dapat dijalankan kembali agar hasil rekomendasi mengikuti data terbaru.17.3 Menghapus Cluster Lama
|
||||||
|
if "cluster" in df.columns:
|
||||||
|
df = df.drop(columns=["cluster"])
|
||||||
|
Kode ini mengecek apakah dataset sudah memiliki kolom cluster. Jika ada, kolom tersebut dihapus terlebih dahulu. Tujuannya agar proses clustering tidak memakai hasil lama, tetapi menghitung ulang cluster dari awal.Kegunaannya dalam sistem adalah menjaga agar hasil cluster selalu sesuai dengan data dan proses perhitungan terbaru, bukan hasil clustering sebelumnya.17.4 Menyamakan Nama Kolom Harga
|
||||||
|
if "Harga" in df.columns:
|
||||||
|
df = df.rename(columns={"Harga": "harga"})
|
||||||
|
Kode ini menyamakan nama kolom Harga menjadi harga. Penyamaan nama kolom penting karena kode clustering mengambil atribut dengan nama harga, tahun, dan mesin_cc.Dalam sistem, hal ini membantu mencegah error ketika dataset menggunakan nama kolom dengan huruf besar, misalnya Harga, sedangkan kode membaca kolom harga.17.5 Memilih Tiga Atribut Utama
|
||||||
|
fitur = df[
|
||||||
|
[
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
Kode ini memilih tiga atribut yang digunakan untuk clustering, yaitu harga, tahun, dan mesin_cc. Ketiga atribut tersebut dipakai karena berhubungan langsung dengan rekomendasi mobil bekas. Harga menentukan kesesuaian dengan budget, tahun menunjukkan usia kendaraan, dan mesin_cc menunjukkan kapasitas mesin.Kegunaannya dalam sistem adalah menentukan dasar pengelompokan mobil. Mobil dengan harga, tahun, dan mesin yang mirip akan cenderung masuk ke cluster yang sama.17.6 Standarisasi Data
|
||||||
|
scaler = StandardScaler()
|
||||||
|
fitur_scaled = scaler.fit_transform(fitur)
|
||||||
|
Kode ini melakukan standarisasi data. Standarisasi diperlukan karena nilai harga sangat besar dibandingkan tahun dan mesin_cc. Jika tidak distandarisasi, perhitungan jarak pada K-Means akan terlalu dipengaruhi oleh harga, sehingga atribut tahun dan mesin_cc menjadi kurang berpengaruh.Dalam sistem, standarisasi membuat proses clustering lebih adil karena semua atribut berada pada skala yang seimbang sebelum dihitung jaraknya.17.7 Mencari Jumlah Cluster Terbaik
|
||||||
|
for k in range(2, 7):
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=k,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
|
||||||
|
labels = kmeans.fit_predict(fitur_scaled)
|
||||||
|
score = silhouette_score(fitur_scaled, labels)
|
||||||
|
|
||||||
|
print(f"Jumlah Cluster: {k}, Silhouette Score: {score}")
|
||||||
|
Kode ini mencoba beberapa jumlah cluster, mulai dari 2 sampai 6. Setiap nilai k diuji dengan K-Means, lalu hasilnya dinilai menggunakan silhouette_score. Nilai silhouette digunakan untuk melihat seberapa baik data terpisah ke dalam cluster.Kegunaannya dalam sistem adalah membantu menentukan jumlah cluster yang paling sesuai. Dengan cara ini, jumlah cluster tidak dipilih secara asal, tetapi berdasarkan hasil evaluasi.17.8 Membuat Model K-Means Akhir
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=4,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
Kode ini membuat model K-Means akhir dengan jumlah cluster sebanyak 4. Angka 4 disesuaikan dengan konfigurasi pada sistem rekomendasi yang mengirim parameter jumlah_cluster = 4 ke Flask API.Dalam sistem, model ini berfungsi untuk membagi data mobil ke dalam empat kelompok. Setiap kelompok berisi mobil yang memiliki karakteristik harga, tahun, dan mesin_cc yang relatif mirip.17.9 Menambahkan Hasil Cluster ke Dataset
|
||||||
|
df["cluster"] = kmeans.fit_predict(fitur_scaled)
|
||||||
|
Kode ini menjalankan K-Means pada data yang sudah distandarisasi, lalu menyimpan hasil cluster ke kolom baru bernama cluster. Setiap mobil akan mendapatkan label cluster, misalnya 0, 1, 2, atau 3.Kegunaannya dalam sistem adalah memberikan identitas kelompok pada setiap mobil. Label cluster ini dapat dipakai untuk memilih kandidat mobil yang paling sesuai dengan budget pengguna.17.10 Menampilkan dan Menyimpan Hasil
|
||||||
|
print(
|
||||||
|
df[
|
||||||
|
[
|
||||||
|
"merk",
|
||||||
|
"model",
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc",
|
||||||
|
"cluster"
|
||||||
|
]
|
||||||
|
].head(20)
|
||||||
|
)
|
||||||
|
|
||||||
|
df.to_csv("dataset_final_clustered.csv", index=False)
|
||||||
|
Kode ini menampilkan sebagian hasil clustering agar peneliti dapat melihat apakah setiap mobil sudah memiliki label cluster. Setelah itu, dataset disimpan ke file baru bernama dataset_final_clustered.csv.Dalam sistem, file hasil clustering dapat digunakan sebagai dataset yang sudah siap dipakai untuk proses rekomendasi. Data tersebut berisi informasi mobil beserta cluster-nya.17.11 Hubungan Kode dengan Fitur Rekomendasi Budget
|
||||||
|
Secara keseluruhan, kode clustering ini berfungsi untuk mengelompokkan mobil sebelum proses rekomendasi dilakukan. Ketika pengguna memasukkan budget, sistem dapat mencari kelompok mobil yang karakteristiknya paling mendekati budget tersebut. Setelah cluster yang sesuai ditemukan, sistem menyaring mobil yang harganya tidak melebihi budget pengguna dan mengurutkan hasil rekomendasi.Dengan demikian, K-Means tidak hanya menampilkan mobil berdasarkan harga terdekat, tetapi membantu sistem mempertimbangkan kemiripan karakteristik kendaraan berdasarkan harga, tahun, dan kapasitas mesin.Kalimat Ringkas untuk Laporan
|
||||||
|
Kode clustering digunakan untuk mengelompokkan data mobil berdasarkan atribut harga, tahun, dan mesin_cc. Sebelum dilakukan clustering, data distandarisasi agar setiap atribut memiliki skala yang seimbang. Setelah model K-Means dijalankan, setiap mobil memperoleh label cluster. Label cluster tersebut digunakan sistem sebagai dasar dalam memilih kandidat rekomendasi mobil yang sesuai dengan budget pengguna.18. Perhitungan Manual Centroid Awal Sesuai Sistem
|
||||||
|
Pada sistem rekomendasi mobil JMW, centroid awal tidak dipilih secara acak. Kode pada Flask API terlebih dahulu mengurutkan data mobil berdasarkan harga, kemudian memilih beberapa data yang posisinya tersebar dari harga terendah sampai harga tertinggi. Data yang dipilih tersebut dijadikan centroid awal dalam bentuk vector normalisasi.Atribut yang digunakan dalam proses clustering pada sistem adalah harga, tahun, dan mesin_cc. Sebelum menjadi centroid, setiap nilai atribut dinormalisasi terlebih dahulu menggunakan Min-Max Normalization agar semua atribut berada pada rentang 0 sampai 1.Rumus normalisasi yang digunakan:X' = (X - nilai minimum) / (nilai maksimum - nilai minimum)Rumus pemilihan posisi centroid awal pada sistem:posisi = round(cluster_index * (jumlah_data - 1) / (jumlah_cluster - 1))Contoh berikut menggunakan jumlah cluster k=4 sesuai nilai yang saat ini dikirim oleh Laravel ke Flask API. Jika sistem diubah menjadi k=6, rumusnya tetap sama, tetapi centroid awal yang dipilih berjumlah 6 data.Contoh data mobil yang sudah layak dipakai clustering:MobilHargaTahunMesin CCHyundai Ioniq122.000.00020211197Toyota Avanza125.000.00020191500Toyota Innova250.000.00020202000Toyota Fortuner500.000.00020212500Dari contoh data tersebut, nilai minimum dan maksimum setiap atribut adalah: harga minimum 122.000.000 dan harga maksimum 500.000.000; tahun minimum 2019 dan tahun maksimum 2021; mesin minimum 1197 cc dan mesin maksimum 2500 cc.Hasil normalisasi setiap data:MobilHarga NormalisasiTahun NormalisasiMesin NormalisasiVector NormalisasiHyundai Ioniq0,0001,0000,000[0,000; 1,000; 0,000]Toyota Avanza0,0080,0000,233[0,008; 0,000; 0,233]Toyota Innova0,3390,5000,616[0,339; 0,500; 0,616]Toyota Fortuner1,0001,0001,000[1,000; 1,000; 1,000]Contoh perhitungan normalisasi:Harga Toyota Avanza = (125.000.000 - 122.000.000) / (500.000.000 - 122.000.000) = 3.000.000 / 378.000.000 = 0,008.Tahun Toyota Avanza = (2019 - 2019) / (2021 - 2019) = 0 / 2 = 0,000.Mesin Toyota Avanza = (1500 - 1197) / (2500 - 1197) = 303 / 1303 = 0,233.Maka vector normalisasi Toyota Avanza adalah [0,008; 0,000; 0,233]. Vector inilah yang digunakan sistem untuk menghitung jarak ke centroid.Perhitungan posisi centroid awal untuk k=4:Jumlah data = 4, jumlah cluster = 4.Clustercluster_indexPerhitungan PosisiPosisiCentroid AwalC10round(0 * (4 - 1) / (4 - 1))0Hyundai IoniqC21round(1 * (4 - 1) / (4 - 1))1Toyota AvanzaC32round(2 * (4 - 1) / (4 - 1))2Toyota InnovaC43round(3 * (4 - 1) / (4 - 1))3Toyota FortunerBerdasarkan perhitungan tersebut, centroid awal yang digunakan adalah vector normalisasi dari data pada posisi 0, 1, 2, dan 3.CentroidVector Centroid AwalC1[0,000; 1,000; 0,000]C2[0,008; 0,000; 0,233]C3[0,339; 0,500; 0,616]C4[1,000; 1,000; 1,000]Setelah centroid awal terbentuk, sistem menghitung jarak setiap mobil ke semua centroid menggunakan Euclidean Distance. Mobil akan masuk ke cluster yang memiliki jarak paling dekat. Setelah semua data masuk ke cluster, sistem menghitung ulang centroid baru berdasarkan rata-rata vector anggota cluster. Proses ini diulang sampai jumlah iterasi selesai atau posisi cluster tidak banyak berubah.Dengan demikian, centroid awal pada sistem bukan angka yang dibuat manual oleh pengguna, tetapi berasal dari data mobil yang sudah dinormalisasi. Pemilihan awalnya mengikuti urutan harga agar centroid mewakili sebaran harga dari rendah sampai tinggi, lalu atribut tahun dan mesin tetap ikut mempengaruhi jarak dan pembentukan cluster.
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,305 @@
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZIP_DEFLATED, ZipFile
|
||||||
|
import tempfile
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_DOC = Path("docs/Penjelasan_Rinci_KMeans_Rekomendasi_JMW_Gabungan.docx")
|
||||||
|
OUT_DOC = Path("docs/Penjelasan_Rinci_KMeans_Rekomendasi_JMW_Gabungan_Update.docx")
|
||||||
|
|
||||||
|
WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||||
|
ET.register_namespace("w", WORD_NS)
|
||||||
|
|
||||||
|
|
||||||
|
def qn(name):
|
||||||
|
return f"{{{WORD_NS}}}{name}"
|
||||||
|
|
||||||
|
|
||||||
|
def paragraph(text="", style=None):
|
||||||
|
p = ET.Element(qn("p"))
|
||||||
|
if style:
|
||||||
|
p_pr = ET.SubElement(p, qn("pPr"))
|
||||||
|
p_style = ET.SubElement(p_pr, qn("pStyle"))
|
||||||
|
p_style.set(qn("val"), style)
|
||||||
|
|
||||||
|
run = ET.SubElement(p, qn("r"))
|
||||||
|
t = ET.SubElement(run, qn("t"))
|
||||||
|
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
||||||
|
t.text = text
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def code_paragraph(text):
|
||||||
|
p = ET.Element(qn("p"))
|
||||||
|
p_pr = ET.SubElement(p, qn("pPr"))
|
||||||
|
p_style = ET.SubElement(p_pr, qn("pStyle"))
|
||||||
|
p_style.set(qn("val"), "Code")
|
||||||
|
|
||||||
|
run = ET.SubElement(p, qn("r"))
|
||||||
|
t = ET.SubElement(run, qn("t"))
|
||||||
|
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
||||||
|
t.text = text
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def page_break():
|
||||||
|
p = ET.Element(qn("p"))
|
||||||
|
r = ET.SubElement(p, qn("r"))
|
||||||
|
br = ET.SubElement(r, qn("br"))
|
||||||
|
br.set(qn("type"), "page")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def code_block(text):
|
||||||
|
return [code_paragraph(line) for line in text.strip("\n").splitlines()]
|
||||||
|
|
||||||
|
|
||||||
|
def new_section():
|
||||||
|
items = []
|
||||||
|
items.append(page_break())
|
||||||
|
items.append(paragraph("17. Penjelasan Kode Clustering dan Kegunaannya dalam Sistem", "Heading1"))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Bagian ini menjelaskan fungsi setiap blok kode clustering K-Means dan hubungannya dengan fitur rekomendasi "
|
||||||
|
"mobil berdasarkan budget pada sistem JMW Showroom. Kode clustering digunakan untuk membentuk kelompok mobil "
|
||||||
|
"berdasarkan kemiripan harga, tahun, dan kapasitas mesin."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("17.1 Import Library", "Heading2"))
|
||||||
|
items.extend(code_block("""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from sklearn.metrics import silhouette_score
|
||||||
|
"""))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kode ini digunakan untuk memanggil library yang diperlukan. pandas dipakai untuk membaca dan mengolah dataset. "
|
||||||
|
"StandardScaler digunakan untuk menstandarkan nilai atribut agar skala harga, tahun, dan mesin_cc menjadi "
|
||||||
|
"seimbang. KMeans digunakan untuk melakukan proses clustering, sedangkan silhouette_score digunakan untuk "
|
||||||
|
"menilai kualitas pembentukan cluster."
|
||||||
|
))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kegunaannya dalam sistem adalah menyiapkan alat utama untuk mengolah data mobil sebelum data tersebut dipakai "
|
||||||
|
"sebagai dasar rekomendasi."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("17.2 Membaca Dataset", "Heading2"))
|
||||||
|
items.extend(code_block("""
|
||||||
|
df = pd.read_csv("dataset_final.csv")
|
||||||
|
"""))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kode ini membaca file dataset_final.csv yang berisi data mobil. Dataset tersebut memuat informasi seperti "
|
||||||
|
"merk, model, harga, tahun, mesin_cc, transmisi, bahan bakar, dan atribut lainnya."
|
||||||
|
))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Dalam sistem, dataset ini menjadi sumber data utama untuk membentuk cluster. Jika data mobil berubah atau "
|
||||||
|
"ditambah, proses clustering dapat dijalankan kembali agar hasil rekomendasi mengikuti data terbaru."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("17.3 Menghapus Cluster Lama", "Heading2"))
|
||||||
|
items.extend(code_block("""
|
||||||
|
if "cluster" in df.columns:
|
||||||
|
df = df.drop(columns=["cluster"])
|
||||||
|
"""))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kode ini mengecek apakah dataset sudah memiliki kolom cluster. Jika ada, kolom tersebut dihapus terlebih "
|
||||||
|
"dahulu. Tujuannya agar proses clustering tidak memakai hasil lama, tetapi menghitung ulang cluster dari awal."
|
||||||
|
))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kegunaannya dalam sistem adalah menjaga agar hasil cluster selalu sesuai dengan data dan proses perhitungan "
|
||||||
|
"terbaru, bukan hasil clustering sebelumnya."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("17.4 Menyamakan Nama Kolom Harga", "Heading2"))
|
||||||
|
items.extend(code_block("""
|
||||||
|
if "Harga" in df.columns:
|
||||||
|
df = df.rename(columns={"Harga": "harga"})
|
||||||
|
"""))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kode ini menyamakan nama kolom Harga menjadi harga. Penyamaan nama kolom penting karena kode clustering "
|
||||||
|
"mengambil atribut dengan nama harga, tahun, dan mesin_cc."
|
||||||
|
))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Dalam sistem, hal ini membantu mencegah error ketika dataset menggunakan nama kolom dengan huruf besar, "
|
||||||
|
"misalnya Harga, sedangkan kode membaca kolom harga."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("17.5 Memilih Tiga Atribut Utama", "Heading2"))
|
||||||
|
items.extend(code_block("""
|
||||||
|
fitur = df[
|
||||||
|
[
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
"""))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kode ini memilih tiga atribut yang digunakan untuk clustering, yaitu harga, tahun, dan mesin_cc. Ketiga atribut "
|
||||||
|
"tersebut dipakai karena berhubungan langsung dengan rekomendasi mobil bekas. Harga menentukan kesesuaian "
|
||||||
|
"dengan budget, tahun menunjukkan usia kendaraan, dan mesin_cc menunjukkan kapasitas mesin."
|
||||||
|
))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kegunaannya dalam sistem adalah menentukan dasar pengelompokan mobil. Mobil dengan harga, tahun, dan mesin "
|
||||||
|
"yang mirip akan cenderung masuk ke cluster yang sama."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("17.6 Standarisasi Data", "Heading2"))
|
||||||
|
items.extend(code_block("""
|
||||||
|
scaler = StandardScaler()
|
||||||
|
fitur_scaled = scaler.fit_transform(fitur)
|
||||||
|
"""))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kode ini melakukan standarisasi data. Standarisasi diperlukan karena nilai harga sangat besar dibandingkan "
|
||||||
|
"tahun dan mesin_cc. Jika tidak distandarisasi, perhitungan jarak pada K-Means akan terlalu dipengaruhi oleh "
|
||||||
|
"harga, sehingga atribut tahun dan mesin_cc menjadi kurang berpengaruh."
|
||||||
|
))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Dalam sistem, standarisasi membuat proses clustering lebih adil karena semua atribut berada pada skala yang "
|
||||||
|
"seimbang sebelum dihitung jaraknya."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("17.7 Mencari Jumlah Cluster Terbaik", "Heading2"))
|
||||||
|
items.extend(code_block("""
|
||||||
|
for k in range(2, 7):
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=k,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
|
||||||
|
labels = kmeans.fit_predict(fitur_scaled)
|
||||||
|
score = silhouette_score(fitur_scaled, labels)
|
||||||
|
|
||||||
|
print(f"Jumlah Cluster: {k}, Silhouette Score: {score}")
|
||||||
|
"""))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kode ini mencoba beberapa jumlah cluster, mulai dari 2 sampai 6. Setiap nilai k diuji dengan K-Means, lalu "
|
||||||
|
"hasilnya dinilai menggunakan silhouette_score. Nilai silhouette digunakan untuk melihat seberapa baik data "
|
||||||
|
"terpisah ke dalam cluster."
|
||||||
|
))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kegunaannya dalam sistem adalah membantu menentukan jumlah cluster yang paling sesuai. Dengan cara ini, jumlah "
|
||||||
|
"cluster tidak dipilih secara asal, tetapi berdasarkan hasil evaluasi."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("17.8 Membuat Model K-Means Akhir", "Heading2"))
|
||||||
|
items.extend(code_block("""
|
||||||
|
kmeans = KMeans(
|
||||||
|
n_clusters=4,
|
||||||
|
random_state=42,
|
||||||
|
n_init=10
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kode ini membuat model K-Means akhir dengan jumlah cluster sebanyak 4. Angka 4 disesuaikan dengan konfigurasi "
|
||||||
|
"pada sistem rekomendasi yang mengirim parameter jumlah_cluster = 4 ke Flask API."
|
||||||
|
))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Dalam sistem, model ini berfungsi untuk membagi data mobil ke dalam empat kelompok. Setiap kelompok berisi "
|
||||||
|
"mobil yang memiliki karakteristik harga, tahun, dan mesin_cc yang relatif mirip."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("17.9 Menambahkan Hasil Cluster ke Dataset", "Heading2"))
|
||||||
|
items.extend(code_block("""
|
||||||
|
df["cluster"] = kmeans.fit_predict(fitur_scaled)
|
||||||
|
"""))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kode ini menjalankan K-Means pada data yang sudah distandarisasi, lalu menyimpan hasil cluster ke kolom baru "
|
||||||
|
"bernama cluster. Setiap mobil akan mendapatkan label cluster, misalnya 0, 1, 2, atau 3."
|
||||||
|
))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kegunaannya dalam sistem adalah memberikan identitas kelompok pada setiap mobil. Label cluster ini dapat "
|
||||||
|
"dipakai untuk memilih kandidat mobil yang paling sesuai dengan budget pengguna."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("17.10 Menampilkan dan Menyimpan Hasil", "Heading2"))
|
||||||
|
items.extend(code_block("""
|
||||||
|
print(
|
||||||
|
df[
|
||||||
|
[
|
||||||
|
"merk",
|
||||||
|
"model",
|
||||||
|
"harga",
|
||||||
|
"tahun",
|
||||||
|
"mesin_cc",
|
||||||
|
"cluster"
|
||||||
|
]
|
||||||
|
].head(20)
|
||||||
|
)
|
||||||
|
|
||||||
|
df.to_csv("dataset_final_clustered.csv", index=False)
|
||||||
|
"""))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kode ini menampilkan sebagian hasil clustering agar peneliti dapat melihat apakah setiap mobil sudah memiliki "
|
||||||
|
"label cluster. Setelah itu, dataset disimpan ke file baru bernama dataset_final_clustered.csv."
|
||||||
|
))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Dalam sistem, file hasil clustering dapat digunakan sebagai dataset yang sudah siap dipakai untuk proses "
|
||||||
|
"rekomendasi. Data tersebut berisi informasi mobil beserta cluster-nya."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("17.11 Hubungan Kode dengan Fitur Rekomendasi Budget", "Heading2"))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Secara keseluruhan, kode clustering ini berfungsi untuk mengelompokkan mobil sebelum proses rekomendasi "
|
||||||
|
"dilakukan. Ketika pengguna memasukkan budget, sistem dapat mencari kelompok mobil yang karakteristiknya paling "
|
||||||
|
"mendekati budget tersebut. Setelah cluster yang sesuai ditemukan, sistem menyaring mobil yang harganya tidak "
|
||||||
|
"melebihi budget pengguna dan mengurutkan hasil rekomendasi."
|
||||||
|
))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Dengan demikian, K-Means tidak hanya menampilkan mobil berdasarkan harga terdekat, tetapi membantu sistem "
|
||||||
|
"mempertimbangkan kemiripan karakteristik kendaraan berdasarkan harga, tahun, dan kapasitas mesin."
|
||||||
|
))
|
||||||
|
|
||||||
|
items.append(paragraph("Kalimat Ringkas untuk Laporan", "Heading2"))
|
||||||
|
items.append(paragraph(
|
||||||
|
"Kode clustering digunakan untuk mengelompokkan data mobil berdasarkan atribut harga, tahun, dan mesin_cc. "
|
||||||
|
"Sebelum dilakukan clustering, data distandarisasi agar setiap atribut memiliki skala yang seimbang. Setelah "
|
||||||
|
"model K-Means dijalankan, setiap mobil memperoleh label cluster. Label cluster tersebut digunakan sistem "
|
||||||
|
"sebagai dasar dalam memilih kandidat rekomendasi mobil yang sesuai dengan budget pengguna."
|
||||||
|
))
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def append_section():
|
||||||
|
OUT_DOC.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_dir = Path(tmp)
|
||||||
|
|
||||||
|
with ZipFile(SOURCE_DOC, "r") as archive:
|
||||||
|
archive.extractall(tmp_dir)
|
||||||
|
|
||||||
|
document_path = tmp_dir / "word" / "document.xml"
|
||||||
|
root = ET.parse(document_path).getroot()
|
||||||
|
body = root.find(qn("body"))
|
||||||
|
|
||||||
|
sect_pr = None
|
||||||
|
for child in list(body):
|
||||||
|
if child.tag == qn("sectPr"):
|
||||||
|
sect_pr = child
|
||||||
|
body.remove(child)
|
||||||
|
break
|
||||||
|
|
||||||
|
for item in new_section():
|
||||||
|
body.append(item)
|
||||||
|
|
||||||
|
if sect_pr is not None:
|
||||||
|
body.append(sect_pr)
|
||||||
|
|
||||||
|
ET.ElementTree(root).write(document_path, encoding="utf-8", xml_declaration=True)
|
||||||
|
|
||||||
|
if OUT_DOC.exists():
|
||||||
|
OUT_DOC.unlink()
|
||||||
|
|
||||||
|
with ZipFile(OUT_DOC, "w", ZIP_DEFLATED) as archive:
|
||||||
|
for path in tmp_dir.rglob("*"):
|
||||||
|
if path.is_file():
|
||||||
|
archive.write(path, path.relative_to(tmp_dir).as_posix())
|
||||||
|
|
||||||
|
print(OUT_DOC.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
append_section()
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZipFile, ZIP_DEFLATED
|
||||||
|
import html
|
||||||
|
import shutil
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
INPUT_DOCX = BASE_DIR / "Penjelasan_Rinci_KMeans_Rekomendasi_JMW_Gabungan_Update.docx"
|
||||||
|
OUTPUT_DOCX = BASE_DIR / "Penjelasan_Rinci_KMeans_Rekomendasi_JMW_Gabungan_Update_Centroid.docx"
|
||||||
|
|
||||||
|
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||||
|
ET.register_namespace("w", W_NS)
|
||||||
|
|
||||||
|
|
||||||
|
def qn(tag):
|
||||||
|
return f"{{{W_NS}}}{tag}"
|
||||||
|
|
||||||
|
|
||||||
|
def paragraph(text, style=None, bold=False):
|
||||||
|
p = ET.Element(qn("p"))
|
||||||
|
if style:
|
||||||
|
p_pr = ET.SubElement(p, qn("pPr"))
|
||||||
|
p_style = ET.SubElement(p_pr, qn("pStyle"))
|
||||||
|
p_style.set(qn("val"), style)
|
||||||
|
r = ET.SubElement(p, qn("r"))
|
||||||
|
if bold:
|
||||||
|
r_pr = ET.SubElement(r, qn("rPr"))
|
||||||
|
ET.SubElement(r_pr, qn("b"))
|
||||||
|
t = ET.SubElement(r, qn("t"))
|
||||||
|
t.text = text
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def table(rows):
|
||||||
|
tbl = ET.Element(qn("tbl"))
|
||||||
|
tbl_pr = ET.SubElement(tbl, qn("tblPr"))
|
||||||
|
tbl_style = ET.SubElement(tbl_pr, qn("tblStyle"))
|
||||||
|
tbl_style.set(qn("val"), "TableGrid")
|
||||||
|
tbl_w = ET.SubElement(tbl_pr, qn("tblW"))
|
||||||
|
tbl_w.set(qn("w"), "0")
|
||||||
|
tbl_w.set(qn("type"), "auto")
|
||||||
|
|
||||||
|
for row_index, row in enumerate(rows):
|
||||||
|
tr = ET.SubElement(tbl, qn("tr"))
|
||||||
|
for cell in row:
|
||||||
|
tc = ET.SubElement(tr, qn("tc"))
|
||||||
|
tc_pr = ET.SubElement(tc, qn("tcPr"))
|
||||||
|
tc_w = ET.SubElement(tc_pr, qn("tcW"))
|
||||||
|
tc_w.set(qn("w"), "2400")
|
||||||
|
tc_w.set(qn("type"), "dxa")
|
||||||
|
tc.append(paragraph(str(cell), bold=(row_index == 0)))
|
||||||
|
return tbl
|
||||||
|
|
||||||
|
|
||||||
|
def append_to_docx():
|
||||||
|
if not INPUT_DOCX.exists():
|
||||||
|
raise FileNotFoundError(f"Dokumen sumber tidak ditemukan: {INPUT_DOCX}")
|
||||||
|
|
||||||
|
shutil.copyfile(INPUT_DOCX, OUTPUT_DOCX)
|
||||||
|
|
||||||
|
with ZipFile(INPUT_DOCX, "r") as zin:
|
||||||
|
document_xml = zin.read("word/document.xml")
|
||||||
|
files = {name: zin.read(name) for name in zin.namelist() if name != "word/document.xml"}
|
||||||
|
|
||||||
|
root = ET.fromstring(document_xml)
|
||||||
|
body = root.find(qn("body"))
|
||||||
|
sect_pr = body.find(qn("sectPr"))
|
||||||
|
insert_index = list(body).index(sect_pr) if sect_pr is not None else len(list(body))
|
||||||
|
|
||||||
|
content = []
|
||||||
|
content.append(paragraph("18. Perhitungan Manual Centroid Awal Sesuai Sistem", style="Heading1"))
|
||||||
|
content.append(paragraph(
|
||||||
|
"Pada sistem rekomendasi mobil JMW, centroid awal tidak dipilih secara acak. "
|
||||||
|
"Kode pada Flask API terlebih dahulu mengurutkan data mobil berdasarkan harga, "
|
||||||
|
"kemudian memilih beberapa data yang posisinya tersebar dari harga terendah sampai harga tertinggi. "
|
||||||
|
"Data yang dipilih tersebut dijadikan centroid awal dalam bentuk vector normalisasi."
|
||||||
|
))
|
||||||
|
content.append(paragraph(
|
||||||
|
"Atribut yang digunakan dalam proses clustering pada sistem adalah harga, tahun, dan mesin_cc. "
|
||||||
|
"Sebelum menjadi centroid, setiap nilai atribut dinormalisasi terlebih dahulu menggunakan Min-Max Normalization "
|
||||||
|
"agar semua atribut berada pada rentang 0 sampai 1."
|
||||||
|
))
|
||||||
|
content.append(paragraph("Rumus normalisasi yang digunakan:", bold=True))
|
||||||
|
content.append(paragraph("X' = (X - nilai minimum) / (nilai maksimum - nilai minimum)"))
|
||||||
|
content.append(paragraph("Rumus pemilihan posisi centroid awal pada sistem:", bold=True))
|
||||||
|
content.append(paragraph("posisi = round(cluster_index * (jumlah_data - 1) / (jumlah_cluster - 1))"))
|
||||||
|
content.append(paragraph(
|
||||||
|
"Contoh berikut menggunakan jumlah cluster k=4 sesuai nilai yang saat ini dikirim oleh Laravel ke Flask API. "
|
||||||
|
"Jika sistem diubah menjadi k=6, rumusnya tetap sama, tetapi centroid awal yang dipilih berjumlah 6 data."
|
||||||
|
))
|
||||||
|
|
||||||
|
content.append(paragraph("Contoh data mobil yang sudah layak dipakai clustering:", bold=True))
|
||||||
|
raw_rows = [
|
||||||
|
["Mobil", "Harga", "Tahun", "Mesin CC"],
|
||||||
|
["Hyundai Ioniq", "122.000.000", "2021", "1197"],
|
||||||
|
["Toyota Avanza", "125.000.000", "2019", "1500"],
|
||||||
|
["Toyota Innova", "250.000.000", "2020", "2000"],
|
||||||
|
["Toyota Fortuner", "500.000.000", "2021", "2500"],
|
||||||
|
]
|
||||||
|
content.append(table(raw_rows))
|
||||||
|
|
||||||
|
content.append(paragraph(
|
||||||
|
"Dari contoh data tersebut, nilai minimum dan maksimum setiap atribut adalah: "
|
||||||
|
"harga minimum 122.000.000 dan harga maksimum 500.000.000; tahun minimum 2019 dan tahun maksimum 2021; "
|
||||||
|
"mesin minimum 1197 cc dan mesin maksimum 2500 cc."
|
||||||
|
))
|
||||||
|
|
||||||
|
content.append(paragraph("Hasil normalisasi setiap data:", bold=True))
|
||||||
|
norm_rows = [
|
||||||
|
["Mobil", "Harga Normalisasi", "Tahun Normalisasi", "Mesin Normalisasi", "Vector Normalisasi"],
|
||||||
|
["Hyundai Ioniq", "0,000", "1,000", "0,000", "[0,000; 1,000; 0,000]"],
|
||||||
|
["Toyota Avanza", "0,008", "0,000", "0,233", "[0,008; 0,000; 0,233]"],
|
||||||
|
["Toyota Innova", "0,339", "0,500", "0,616", "[0,339; 0,500; 0,616]"],
|
||||||
|
["Toyota Fortuner", "1,000", "1,000", "1,000", "[1,000; 1,000; 1,000]"],
|
||||||
|
]
|
||||||
|
content.append(table(norm_rows))
|
||||||
|
|
||||||
|
content.append(paragraph("Contoh perhitungan normalisasi:", bold=True))
|
||||||
|
content.append(paragraph(
|
||||||
|
"Harga Toyota Avanza = (125.000.000 - 122.000.000) / (500.000.000 - 122.000.000) "
|
||||||
|
"= 3.000.000 / 378.000.000 = 0,008."
|
||||||
|
))
|
||||||
|
content.append(paragraph(
|
||||||
|
"Tahun Toyota Avanza = (2019 - 2019) / (2021 - 2019) = 0 / 2 = 0,000."
|
||||||
|
))
|
||||||
|
content.append(paragraph(
|
||||||
|
"Mesin Toyota Avanza = (1500 - 1197) / (2500 - 1197) = 303 / 1303 = 0,233."
|
||||||
|
))
|
||||||
|
content.append(paragraph(
|
||||||
|
"Maka vector normalisasi Toyota Avanza adalah [0,008; 0,000; 0,233]. "
|
||||||
|
"Vector inilah yang digunakan sistem untuk menghitung jarak ke centroid."
|
||||||
|
))
|
||||||
|
|
||||||
|
content.append(paragraph("Perhitungan posisi centroid awal untuk k=4:", bold=True))
|
||||||
|
content.append(paragraph("Jumlah data = 4, jumlah cluster = 4."))
|
||||||
|
centroid_rows = [
|
||||||
|
["Cluster", "cluster_index", "Perhitungan Posisi", "Posisi", "Centroid Awal"],
|
||||||
|
["C1", "0", "round(0 * (4 - 1) / (4 - 1))", "0", "Hyundai Ioniq"],
|
||||||
|
["C2", "1", "round(1 * (4 - 1) / (4 - 1))", "1", "Toyota Avanza"],
|
||||||
|
["C3", "2", "round(2 * (4 - 1) / (4 - 1))", "2", "Toyota Innova"],
|
||||||
|
["C4", "3", "round(3 * (4 - 1) / (4 - 1))", "3", "Toyota Fortuner"],
|
||||||
|
]
|
||||||
|
content.append(table(centroid_rows))
|
||||||
|
|
||||||
|
content.append(paragraph(
|
||||||
|
"Berdasarkan perhitungan tersebut, centroid awal yang digunakan adalah vector normalisasi dari data pada posisi 0, 1, 2, dan 3."
|
||||||
|
))
|
||||||
|
final_rows = [
|
||||||
|
["Centroid", "Vector Centroid Awal"],
|
||||||
|
["C1", "[0,000; 1,000; 0,000]"],
|
||||||
|
["C2", "[0,008; 0,000; 0,233]"],
|
||||||
|
["C3", "[0,339; 0,500; 0,616]"],
|
||||||
|
["C4", "[1,000; 1,000; 1,000]"],
|
||||||
|
]
|
||||||
|
content.append(table(final_rows))
|
||||||
|
|
||||||
|
content.append(paragraph(
|
||||||
|
"Setelah centroid awal terbentuk, sistem menghitung jarak setiap mobil ke semua centroid menggunakan Euclidean Distance. "
|
||||||
|
"Mobil akan masuk ke cluster yang memiliki jarak paling dekat. Setelah semua data masuk ke cluster, sistem menghitung ulang centroid baru "
|
||||||
|
"berdasarkan rata-rata vector anggota cluster. Proses ini diulang sampai jumlah iterasi selesai atau posisi cluster tidak banyak berubah."
|
||||||
|
))
|
||||||
|
content.append(paragraph(
|
||||||
|
"Dengan demikian, centroid awal pada sistem bukan angka yang dibuat manual oleh pengguna, tetapi berasal dari data mobil yang sudah dinormalisasi. "
|
||||||
|
"Pemilihan awalnya mengikuti urutan harga agar centroid mewakili sebaran harga dari rendah sampai tinggi, lalu atribut tahun dan mesin tetap ikut mempengaruhi "
|
||||||
|
"jarak dan pembentukan cluster."
|
||||||
|
))
|
||||||
|
|
||||||
|
for offset, element in enumerate(content):
|
||||||
|
body.insert(insert_index + offset, element)
|
||||||
|
|
||||||
|
new_xml = ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
||||||
|
|
||||||
|
with ZipFile(OUTPUT_DOCX, "w", ZIP_DEFLATED) as zout:
|
||||||
|
for name, data in files.items():
|
||||||
|
zout.writestr(name, data)
|
||||||
|
zout.writestr("word/document.xml", new_xml)
|
||||||
|
|
||||||
|
print(OUTPUT_DOCX)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
append_to_docx()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue