# Rancangan Database SaaS Bengkel

## Keputusan Arsitektur

- Laravel menjadi backend tunggal.
- Web Blade hanya untuk Super Admin/platform admin.
- Web backoffice mengelola bengkel, paket, langganan, invoice, user, dan konfigurasi; juga membaca rekap pembelian/detail, perbaikan/detail, serta kas yang diinput Flutter.
- User bengkel menggunakan aplikasi Flutter melalui API Laravel di prefix `/api` (tanpa versioning `v1`).
- Gunakan tabel `users` bawaan yang sudah ada. Jangan membuat tabel `user` atau `bengkel_user` baru.
- Role aplikasi sementara hanya membedakan Super Admin dan Bengkel.
- Satu bengkel hanya memiliki satu user; user Bengkel tidak dapat menambah user lain.
- Relasi user-bengkel disimpan langsung melalui `users.bengkel_id`.
- Seluruh data operasional wajib memiliki `bengkel_id` untuk isolasi data.
- Semua nama tabel bisnis berbentuk singular persis seperti dokumen ini. Jangan menambahkan akhiran `s` bawaan Laravel.
- Setiap model wajib mendefinisikan `protected $table = 'nama_tabel';`.
- Harga dan nilai uang disimpan sebagai integer rupiah atau decimal tetap, tidak memakai float.

## Daftar Tabel Final

### 1. Akun dan Bengkel

#### `users` (existing)

Gunakan tabel Laravel yang sudah ada.

Kolom existing/yang diperlukan:

- `id`
- `bengkel_id` nullable dan unique, foreign key ke `bengkel.id`
- `username`
- `name`
- `email`
- `password`
- `phone`
- `address`
- `avatar`
- `fcm_token` (text, nullable) — token Firebase Cloud Messaging untuk push notification
- `is_aktif` (`Y`/`T`)
- `email_verified_at`
- `remember_token`
- timestamps

Aturan:

- Super Admin dikelola melalui web.
- Saat pendaftaran Flutter berhasil, buat satu user dengan role `Bengkel` lalu buat satu record `bengkel`.
- Super Admin memiliki `bengkel_id = null`; user dengan role Bengkel wajib memiliki `bengkel_id`.
- User dengan role Bengkel hanya boleh mengakses bengkel miliknya.
- User Bengkel tidak boleh membuat user tambahan.
- `users.name` menyimpan nama pemilik bengkel.

#### `bengkel`

- `id`
- `kode` unique
- `nama`
- `phone`
- `email` nullable
- `alamat`
- `logo` nullable
- `is_aktif` (`Y`/`T`)
- timestamps

Relasi:

- `users` belongsTo `bengkel`
- `bengkel` hasOne `users`; unique index `users.bengkel_id` menegakkan satu user per bengkel.

Pemisahan nama:

- `users.name` = nama pemilik/penanggung jawab akun.
- `bengkel.nama` = nama usaha bengkel yang tampil pada aplikasi, invoice, dan transaksi.
- Jangan menambah `nama_pemilik` ke tabel `bengkel` karena akan menduplikasi `users.name`.

### 2. Paket dan Langganan

#### `paket`

Tabel existing untuk master paket SaaS.

- `id`
- `nama` unique
- `keterangan` nullable
- `harga`
- `max_transaksi`
- `is_aktif` (`Y`/`T`), disarankan ditambahkan
- timestamps

`max_transaksi` adalah batas jumlah transaksi bengkel dalam satu periode langganan.

#### `paket_langganan`

- `id`
- `bengkel_id`
- `paket_id`
- `tanggal_mulai`
- `tanggal_selesai`
- `status` (`pending`, `trial`, `aktif`, `expired`, `batal`)
- `harga` sebagai snapshot harga saat berlangganan
- `max_transaksi` sebagai snapshot limit saat berlangganan
- timestamps

Aturan:

- Satu bengkel dapat memiliki histori banyak langganan.
- Hanya satu `paket_langganan` yang boleh aktif pada waktu yang sama untuk satu bengkel. Saat aktivasi, langganan aktif lain pada bengkel yang sama diubah menjadi `expired`.
- Snapshot harga dan limit tidak berubah ketika master `paket` diedit.

#### `paket_pemakaian`

- `id`
- `bengkel_id`
- `paket_langganan_id`
- `periode_mulai`
- `periode_selesai`
- `jumlah_transaksi`
- timestamps

Aturan:

- Unique gabungan `paket_langganan_id`, `periode_mulai`, `periode_selesai`.
- Record dibuat otomatis oleh `PaketLangganan::ensurePemakaian()` saat aktivasi langganan. `TransaksiService` juga membuatnya jika langganan aktif belum punya kuota (data lama).
- `jumlah_transaksi` ditambah secara atomik saat transaksi perbaikan selesai.
- API menolak transaksi baru jika pemakaian mencapai `max_transaksi`.

#### `invoice`

Satu tabel untuk tagihan dan pembayaran langganan.

- `id`
- `bengkel_id`
- `paket_langganan_id`
- `nomor` unique
- `tanggal`
- `jatuh_tempo`
- `jumlah`
- `metode_pembayaran` nullable
- `referensi_pembayaran` nullable
- `status` (`pending`, `dibayar`, `expired`, `batal`)
- `dibayar_at` nullable
- `catatan` nullable
- timestamps

Aturan:

- Pembayaran cukup dicatat di `invoice`; jangan membuat tabel pembayaran langganan terpisah.
- Callback payment gateway harus idempotent berdasarkan `referensi_pembayaran`.

### 3. Pelanggan, Kendaraan, Produk, dan Jasa

#### `pelanggan`

Master bersama antar bengkel. Nomor HP yang sama (`phone_norm`, digit saja) = pelanggan yang sama.

- `id`
- `uuid` nullable, unique (untuk sinkronisasi offline)
- `bengkel_id` nullable — pembuat pertama (audit), bukan pemilik eksklusif; `nullOnDelete`
- `kode`
- `nama`
- `phone` nullable
- `phone_norm` nullable, unique
- `email` nullable
- `alamat` nullable
- timestamps
- soft delete

Index/constraint:

- Unique `phone_norm` (banyak `NULL` diizinkan).
- Index gabungan `bengkel_id`, `kode` (tidak unique).
- Index gabungan `bengkel_id`, `phone`.
- Model **tidak** memakai `BelongsToBengkel`.

#### `kendaraan`

Master bersama antar bengkel. Nomor polisi yang sama (`nomor_polisi_norm`, huruf/angka uppercase) = kendaraan yang sama.

- `id`
- `uuid` nullable, unique (untuk sinkronisasi offline)
- `bengkel_id` nullable — pembuat pertama (audit), bukan pemilik eksklusif; `nullOnDelete`
- `pelanggan_id`
- `nomor_polisi`
- `nomor_polisi_norm` unique
- `merk`
- `model` nullable
- `tahun` nullable
- `warna` nullable
- timestamps
- soft delete

Index/constraint:

- Unique `nomor_polisi_norm`.
- Index gabungan `bengkel_id`, `nomor_polisi` (tidak unique).
- Model **tidak** memakai `BelongsToBengkel`.
- Flutter lookup nopol/HP; sync pull hanya record relevan bagi bengkel.

#### `kategori_produk`

Gunakan tabel existing.

- `id`
- `uuid` nullable, unique (untuk sinkronisasi offline)
- `bengkel_id`
- `nama`
- `is_aktif` (`Y`/`T`)
- timestamps
- soft delete

Constraint:

- Unique gabungan `bengkel_id`, `nama`.

Catatan: tabel existing perlu disesuaikan agar kategori produk terisolasi per bengkel.

#### `produk`

- `id`
- `uuid` nullable, unique (untuk sinkronisasi offline)
- `bengkel_id`
- `kategori_produk_id`
- `kode`
- `barcode` nullable
- `nama`
- `satuan_id`, foreign key ke master global `satuan`
- `harga_beli`
- `harga_jual`
- `stok`
- `stok_minimum` nullable
- `is_aktif` (`Y`/`T`)
- timestamps
- soft delete

Constraint:

- Unique gabungan `bengkel_id`, `kode`.
- Kategori dan produk wajib berasal dari bengkel yang sama.

#### `satuan`

Master global satuan produk yang dikelola platform dan dipakai seluruh bengkel.

- `id`
- `kode` unique
- `nama` unique
- `is_aktif` (`Y`/`T`)
- timestamps

Data awal: Pcs, Unit, Set, Pack, Dus, Botol, Kaleng, Liter, Meter, dan Kilogram.

`pembelian_detail.satuan` serta `perbaikan_detail.satuan` tetap berupa string snapshot. Histori transaksi tidak boleh bergantung pada perubahan master `satuan`.

#### `produk_log`

Ledger mutasi stok produk.

- `id`
- `uuid` nullable, unique (untuk sinkronisasi offline)
- `bengkel_id`
- `produk_id`
- `tipe` (`stok_awal`, `pembelian`, `perbaikan`, `koreksi`, `retur_beli`, `retur_jual`)
- `qty_masuk`
- `qty_keluar`
- `saldo`
- `referensi_tabel` nullable
- `referensi_id` nullable
- `keterangan` nullable
- `created_by`
- `created_at`

Aturan:

- Jangan update/hapus histori `produk_log`.
- Setiap perubahan stok wajib membuat record `produk_log`.
- Update stok dan insert log dilakukan dalam satu DB transaction.

#### `service`

Tabel master jasa servis bengkel, bukan konten marketing.

- `id`
- `uuid` nullable, unique (untuk sinkronisasi offline)
- `bengkel_id`
- `kode`
- `nama`
- `harga`
- `estimasi_menit` nullable
- `keterangan` nullable
- `is_aktif` (`Y`/`T`)
- timestamps
- soft delete

Constraint:

- Unique gabungan `bengkel_id`, `kode`.

Catatan penting: model/controller/view `service` existing saat ini masih berupa konten dengan `slug`, `foto`, dan `keterangan`. Sebelum modul bengkel dibuat, CRUD existing tersebut harus diubah menjadi master jasa sesuai skema ini.

### 4. Transaksi

#### `pembelian`

Header transaksi pembelian produk.

- `id`
- `uuid` nullable, unique (untuk sinkronisasi offline)
- `bengkel_id`
- `nomor`
- `tanggal`
- `supplier` nullable
- `subtotal`
- `diskon`
- `total`
- `dibayar`
- `sisa`
- `status` (`draft`, `selesai`, `batal`)
- `posted_at` (timestamp, nullable) — idempotency marker finalisasi; diisi oleh `TransaksiService::finalizePembelian()`
- `catatan` nullable
- `created_by`
- timestamps
- soft delete

Constraint:

- Unique gabungan `bengkel_id`, `nomor`.

#### `pembelian_detail`

- `id`
- `uuid` nullable, unique (untuk sinkronisasi offline)
- `bengkel_id`
- `pembelian_id`
- `produk_id`
- Snapshot: `kode_produk`, `nama_produk`, `satuan`
- `qty`
- `harga`
- `diskon`
- `subtotal`
- timestamps
- soft delete

Aturan:

- Ketika pembelian berstatus selesai: tambah stok + buat `produk_log` tipe `pembelian`.
- Pembatalan harus menghasilkan mutasi balik; jangan menghapus log lama.

#### `perbaikan`

Header transaksi servis bengkel (nota servis).

- `id`
- `uuid` nullable, unique (untuk sinkronisasi offline)
- `bengkel_id`
- `pelanggan_id` nullable
- `kendaraan_id` nullable
- `nomor`
- `tanggal`
- `keluhan` nullable
- `diagnosa` nullable
- `subtotal`
- `diskon`
- `total`
- `dibayar`
- `kembalian`
- `metode_pembayaran` nullable
- `status` (`draft`, `proses`, `selesai`, `batal`)
- `posted_at` (timestamp, nullable) — idempotency marker finalisasi; diisi oleh `TransaksiService::finalizePerbaikan()`
- `catatan` nullable
- `created_by`
- timestamps
- soft delete

Constraint:

- Unique gabungan `bengkel_id`, `nomor`.
- Index gabungan `bengkel_id`, `status`, `tanggal`.

Aturan:

- Ketika selesai: kurangi stok produk, buat `produk_log` tipe `perbaikan`, tambah `paket_pemakaian`, dan catat kas dalam satu DB transaction.
- Pelanggan dan kendaraan adalah master bersama; FK merujuk record global, bukan salinan per bengkel.
- Histori `perbaikan` (status `proses`/`selesai`) ditampilkan di halaman publik cek kendaraan berdasarkan `nomor_polisi`.

#### `perbaikan_detail`

Satu detail dapat berisi produk atau jasa.

- `id`
- `uuid` nullable, unique (untuk sinkronisasi offline)
- `bengkel_id`
- `perbaikan_id`
- `tipe` (`produk`, `service`)
- `produk_id` nullable
- `service_id` nullable
- Snapshot: `kode`, `nama`, `satuan` nullable
- `qty`
- `harga`
- `diskon`
- `subtotal`
- timestamps
- soft delete

Aturan:

- Jika `tipe=produk`, `produk_id` wajib dan `service_id` null.
- Jika `tipe=service`, `service_id` wajib dan `produk_id` null.
- Snapshot wajib disimpan agar histori tidak berubah saat master diedit.

#### `kas`

Ledger seluruh arus uang bengkel.

- `id`
- `uuid` nullable, unique (untuk sinkronisasi offline)
- `bengkel_id`
- `tanggal`
- `tipe` (`masuk`, `keluar`)
- `kategori` (`perbaikan`, `pembelian`, `operasional`, `koreksi`, `lainnya`)
- `jumlah`
- `referensi_tabel` nullable
- `referensi_id` nullable
- `keterangan` nullable
- `created_by`
- timestamps
- soft delete

Aturan:

- `kas` bersifat ledger; jangan hard delete histori.
- Perbaikan selesai membuat kas masuk.
- Pembayaran pembelian membuat kas keluar.
- Koreksi dibuat sebagai record baru, bukan mengedit histori lama.

#### `invoice_log`

Audit trail seluruh panggilan API Pakasir (payment gateway) — baik yang berhasil maupun gagal.

- `id`
- `invoice_id`
- `user_id` nullable (null untuk webhook callback dari Pakasir)
- `endpoint` (contoh: `/transactioncreate/qris`, `/transactiondetail`, `callback`)
- `method` (`GET`, `POST`, dll.)
- `status_code` nullable (null jika koneksi gagal sebelum respons HTTP diterima)
- `is_success` (boolean) — true jika HTTP 2xx
- `duration_ms` (integer) — durasi panggilan API dalam milidetik
- `request` (json) — payload yang dikirim, `api_key` di-redact menjadi `***REDACTED***`
- `response` (json, nullable) — body respons dari Pakasir
- `error` (text, nullable) — pesan error jika gagal (mis. `ConnectionException` atau `message` dari Pakasir)
- `ip_address` nullable
- timestamps

Index:

- Foreign key `invoice_id` → `invoices.id` (cascade delete).
- Foreign key `user_id` → `users.id` (null on delete).
- Index `is_success` untuk filter cepat log yang gagal.

Aturan:

- Setiap panggilan `PakasirService::createTransaction()` dan `transactionStatus()` wajib membuat satu record `invoice_log`.
- Webhook callback dari Pakasir juga dicatat sebagai record `invoice_log` dengan `endpoint=callback` dan `user_id=null`.
- Jika `ConnectionException` terjadi (timeout / koneksi gagal), record tetap dibuat dengan `status_code=null` dan `is_success=false` sebelum exception di-rethrow.
- `api_key` tidak boleh disimpan di log; gunakan `PakasirService::sanitizePayload()` untuk redact.

## Daftar Ringkas

Tabel bisnis yang digunakan:

```text
users                    existing
bengkel
paket                    existing
paket_langganan
paket_pemakaian
invoice
pelanggan
kendaraan
kategori_produk          existing, perlu tenant
satuan
produk
produk_log
service                  existing, ubah menjadi master jasa
pembelian
pembelian_detail
perbaikan
perbaikan_detail
kas
invoice_log
```

Tidak dibuat pada tahap ini:

```text
bengkel_user
device_token
pembayaran_langganan
stok
mutasi_stok
service_order
service_order_detail
pembayaran_service
status_service_log
supplier
booking
cabang
promo
notifikasi
audit_log
```

## Relasi Utama

```text
users 1---1 bengkel (`users.bengkel_id` unique; Super Admin boleh null)
bengkel 1---n paket_langganan n---1 paket
paket_langganan 1---n paket_pemakaian
paket_langganan 1---n invoice
bengkel 1---n perbaikan n---1 pelanggan
bengkel 1---n perbaikan n---1 kendaraan
pelanggan 1---n kendaraan
bengkel 1---n kategori_produk 1---n produk 1---n produk_log
bengkel 1---n service
pembelian 1---n pembelian_detail n---1 produk
perbaikan 1---n perbaikan_detail
perbaikan_detail n---1 produk atau service
perbaikan/pembelian ---> kas
```

## Aturan API dan Multi-Tenant

- Resolve `bengkel` dari `auth()->user()->bengkel_id` / relasi `auth()->user()->bengkel`.
- Jangan menerima atau mempercayai `bengkel_id` dari body Flutter.
- Controller mengisi `bengkel_id` dari user terautentikasi.
- Semua model tenant wajib memakai trait `App\Models\Concerns\BelongsToBengkel`, kecuali `Pelanggan` dan `Kendaraan` (master bersama).
- Trait tersebut memberi global scope `bengkel`, mengisi `bengkel_id` otomatis saat create, dan mencegah record dipindahkan ke tenant lain saat update.
- Super Admin dengan `users.bengkel_id = null` dapat melihat seluruh tenant; user Bengkel hanya melihat data sesuai `users.bengkel_id`.
- Query tanpa isolasi hanya boleh dilakukan Super Admin secara eksplisit dengan `withoutGlobalScope('bengkel')` / `forBengkel($id)`.
- Hindari `DB::table()` untuk data tenant karena melewati global scope model.
- Foreign key produk/service/transaksi harus divalidasi berada pada bengkel yang sama. `pelanggan_id` dan `kendaraan_id` valid secara global.
- Gunakan API response konsisten: `success`, `message`, `data`, `meta`, `errors`.
- Endpoint store/finalisasi transaksi wajib memakai idempotency key agar retry Flutter tidak membuat transaksi ganda.
- Pembelian selesai, perbaikan selesai, perubahan stok, pemakaian paket, dan kas wajib dibungkus DB transaction.

Tabel tenant yang wajib memiliki `bengkel_id`:

```text
paket_langganan
paket_pemakaian
invoice
pelanggan
kendaraan
kategori_produk
produk
produk_log
service
pembelian
pembelian_detail
perbaikan
perbaikan_detail
kas
```

Tabel global yang tidak memakai `bengkel_id`: `paket` karena merupakan katalog paket milik Super Admin. Tabel `bengkel` adalah induk tenant. Tabel `users` memakai `bengkel_id` nullable sebagai konteks tenant user.

## Urutan Implementasi

1. Siapkan API `/api` (tanpa prefix `v1`), auth mobile Sanctum, role Bengkel, dan relasi one-to-one `users` ke `bengkel`.
2. Selesaikan paket: `paket`, `paket_langganan`, `paket_pemakaian`, `invoice`.
3. Buat master: `pelanggan`, `kendaraan`, `kategori_produk`, `produk`, `produk_log`, `service`.
4. Buat pembelian + detail dan posting stok.
5. Buat perbaikan + detail, pemakaian paket, stok, dan kas.
6. Buat dashboard Super Admin untuk bengkel, paket, langganan, invoice, suspend/aktivasi.
7. Integrasikan seluruh proses bengkel ke Flutter.

## Halaman Publik

- `/` adalah halaman cek histori kendaraan berdasarkan nomor polisi.
- Histori bersumber dari relasi `kendaraan` ke `perbaikan` dan `perbaikan_detail`. Nama bengkel diambil dari tiap `perbaikan`, bukan dari `kendaraan.bengkel_id`.
- Hanya `perbaikan` dengan status `proses`/`selesai` yang ditampilkan; `draft` dan `batal` disembunyikan.
- Informasi publik dibatasi pada kendaraan, nama bengkel, tanggal/status servis, keluhan, diagnosa, dan item pekerjaan.
- Data pelanggan, kontak, nilai transaksi, pembayaran, dan kas tidak boleh ditampilkan.
- Implementasi: `HistoriKendaraanController` (route `/` dan `/cek-histori`) dengan view `resources/views/histori-kendaraan/index.blade.php`.

## Catatan Implementasi

Dokumen ini sudah diselaraskan dengan migration `database/schema/saas_bengkel.php` dan model di `app/Models/`. Status per tabel:

1. **`kategori_produk`**: ✅ migration `softDeletes()` + model `SoftDeletes` sudah sinkron.
2. **`pembelian_detail`**: ✅ migration `softDeletes()` + model `SoftDeletes` sudah sinkron.
3. **`perbaikan_detail`**: ✅ migration `softDeletes()` + model `SoftDeletes` sudah sinkron.
4. **`kas`**: ✅ migration `softDeletes()` + `timestamps()` + model `SoftDeletes` sudah sinkron. `updated_at` diisi otomatis agar sync pull bisa deteksi perubahan.
5. **`produk_log`**: ✅ migration hanya `created_at` (ledger immutable, tidak ada `updated_at`). Model set `UPDATED_AT = null` agar Eloquent tidak mencoba insert/update kolom yang tidak ada. Sync pull fallback ke `created_at` via logic `SyncController`.
6. **`invoice`**: ✅ migration + model + database sudah punya kolom `catatan`.
7. **`pelanggan`**: ✅ kolom `catatan` dihapus dari migration dan tidak ada di model `fillable`. Jangan tambahkan lagi.

## Cara Menjalankan Schema

Skrip `database/schema/saas_bengkel.php` bersifat **idempotent** (pakai `Schema::hasTable` / `Schema::hasColumn`) dan tidak ter-load otomatis oleh Laravel. Jalankan via Tinker saat ada perubahan struktur:

```bash
php artisan tinker
>>> require database_path('schema/saas_bengkel.php');
```

Atau via command line (bootstrap manual):

```bash
php -r "require 'vendor/autoload.php'; \$app = require 'bootstrap/app.php'; \$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); require database_path('schema/saas_bengkel.php'); echo 'Done.'.PHP_EOL;"
```
