# Business Rules

Dokumen ini merangkum aturan bisnis yang diimplementasi di kode dan aturan yang direncanakan tetapi belum ada.

---

## Prinsip umum

1. **Prototype, bukan ERP lengkap** — validasi alur Excel lebih penting daripada fitur komersial.
2. **Bahasa Indonesia** — istilah admin (PBF, DPB, OTC, Medis) harus dipertahankan.
3. **Stok berbasis batch** — stok aktual tidak disimpan di master obat.
4. **Harga beli per transaksi** — tidak ada harga modal tetap di master obat.
5. **Transaksi atomik** — simpan pembelian/penjualan/koreksi dalam `DB::transaction()`.

---

## Master data

### Cabang

- Setiap transaksi (pembelian, penjualan, stok) terikat ke satu `cabang_id`.
- Seeder default: Apotek Siaga 24 dan 25.

### Obat

**Pemisahan identitas vs harga (V0.2a)**

| Aspek | Lokasi | Keterangan |
|-------|--------|------------|
| Identitas obat | `master_obat` | Kode, nama, golongan, kategori, satuan, isi kemasan |
| Harga jual | `harga_jual_obat` | OTC, REG, VIP — satu record aktif per obat (opsional per cabang) |
| Arsip harga | `histori_harga_jual_obat` | Snapshot saat harga diganti |
| Stok | `stok_batch_obat` | Agregat per cabang + batch |

**Field `master_obat`**

| Field | Aturan |
|-------|--------|
| `kode_obat` | Wajib, unique. Auto-generate saat create dari aplikasi (lihat [Auto-generate kode obat](#auto-generate-kode-obat)). Import Excel mempertahankan kode dari file. |
| `nama_obat` | Wajib |
| `golongan_obat_id` | Opsional, FK → `master_golongan_obat` |
| `kategori_obat_id` | Opsional, FK → `master_kategori_obat` |
| `satuan_obat_id` | Opsional, FK → `master_satuan_obat` (label UI: **Kemasan Terkecil**) |
| `isi_kemasan` | Wajib, integer ≥ 1 — jumlah isi dalam satu satuan |
| `keterangan` | Opsional |

**Dictionary obat**

- Golongan, kategori, dan satuan via FK ke tabel `master_*`.
- `master_satuan_obat` memuat satuan/kemasan terkecil (STRIP, BOTOL, PCS, dll.).
- Nilai dictionary baru dari Excel dapat dibuat otomatis saat seeding (`MasterDictionaryResolver`).

**Seeding dari Excel**

1. Admin mengisi `database/data/seederObat.xlsx`
2. `python scripts/generate-obat-data.py` → `database/data/obat.json`
3. `ObatSeeder` memetakan `KEMASAN TERKECIL` → `satuan_obat_id`
4. Kolom `KODE OBAT` dari Excel **dipertahankan apa adanya** — tidak diganti oleh auto-generate

#### Auto-generate kode obat

Saat ini semua data obat dari Excel sudah di-import ke database, dan kolom `kode_obat` mengikuti pola prefix huruf awal nama obat (contoh: `A-001`, `A-100`, `B-057`).

Ketika admin menambahkan obat baru dari aplikasi, sistem mengecek kode existing di database sebelum membuat kode baru.

**Aturan utama**

1. Sistem mengambil huruf pertama dari `nama_obat`.
2. Huruf pertama digunakan sebagai prefix kode.
3. Sistem mencari kode obat existing dengan prefix yang sama.
4. Sistem mencari nomor urut **terbesar** dari prefix tersebut.
5. Sistem membuat kode baru dengan nomor urut terbesar + 1.
6. Sistem tidak boleh membuat kode yang sudah ada.
7. `kode_obat` wajib unique di database.
8. Auto-generate **hanya** berlaku untuk obat baru yang dibuat dari aplikasi.
9. Saat import dari Excel, kode obat dari Excel tetap dipertahankan dan tidak diganti otomatis.

**Contoh**

| Kode existing | Nama obat baru | Kode baru |
|---------------|----------------|-----------|
| `A-001`, `A-002`, `A-100` | `AMLODIPINE 10 MG` | `A-101` |
| `B-001`, `B-057` | `BETADINE SOLUTION` | `B-058` |
| (belum ada prefix `C`) | `CETIRIZINE` | `C-001` |

**Catatan teknis**

- Jangan hanya mengambil data terakhir berdasarkan `id` atau `created_at` — cari angka terbesar berdasarkan pola kode.
- Parsing nomor dilakukan dari bagian setelah tanda `-`.
- Hanya kode yang cocok pola `{PREFIX}-{ANGKA}` yang dihitung (mis. `AB-999` tidak mempengaruhi prefix `A`).
- Validasi unique pada kolom `kode_obat` (form edit + constraint database).
- Generate dan insert obat baru dibungkus `DB::transaction` dengan `lockForUpdate()`; jika terjadi duplicate karena race condition, sistem retry generate.
- Jika `nama_obat` diawali angka/simbol/karakter tidak valid, gunakan fallback prefix `X-`.
- Format nomor minimal 3 digit: `A-001`, `A-057`, `A-100`. Jika sudah melewati 999, jangan dipotong: `A-1001`.

**Implementasi:** `App\Services\KodeObatGenerator` — dipanggil dari `ObatController::store()`. Form create tidak menampilkan field `kode_obat`; form edit tetap bisa mengubah kode secara manual.

**Harga jual**

- Disimpan via `HargaJualService::simpanHarga()` — bukan langsung di `master_obat`.
- **Harga jual selalu global** — satu baris master per obat dengan `cabang_id = null` (berlaku S24 & S25). `berlaku_selesai` selalu `null` pada baris master.
- Mode `versi`: snapshot harga lama ke `histori_harga_jual_obat`, lalu **UPDATE** baris master (bukan insert baru).
- Mode `koreksi`: UPDATE baris master tanpa menulis histori (salah ketik).
- DPB / stok / tagihan tetap **per cabang**; selector cabang di Manajemen Harga hanya acuan modal/stok.
- `Obat::getHargaByJenis('OTC'|'REG'|'VIP')` membaca harga aktif dari `harga_jual_obat`.
- Accessor `satuan` menampilkan nama dari relasi `satuanObat`.
- Cleanup data cabang-spesifik lama: `php artisan harga:cleanup-global` (opsional `--dry-run`).

**Yang tidak ada di master obat**

- Stok (`stok_batch_obat`)
- Harga beli (`detail_pembelian`, `stok_batch_obat`)
- Harga jual OTC/REG/VIP (`harga_jual_obat`)

---

## Penentuan harga obat

> **Sumber kebenaran istilah, rumus, dan relasi DB:** [`pricing-dpb-rules.md`](pricing-dpb-rules.md)  
> Contoh Excel: [`contoh_tabel_harga.xlsx`](contoh_tabel_harga.xlsx)

### Kamus singkat (jangan dicampur)

| Istilah | Arti | Level |
|---------|------|-------|
| Harga beli / netto | Dari faktur PBF | Per kemasan |
| Harga modal | Netto × PPN 11% | Per kemasan |
| Harga modal satuan (eceran) | `(modal × 1.015) / isi_kemasan` | Per satuan ecer |
| Harga OTC | Harga jual ke pelanggan umum | **Ecer** |
| Harga REG / VIP | Harga jual ke pelanggan medis | **Kemasan utuh** |
| Total faktur / tagihan | Σ subtotal baris + PPN 11% | Dokumen hutang ke PBF |

### Rumus inti

```
harga_beli_netto   = harga_beli × (1 − diskon_persen / 100)
harga_modal        = harga_beli_netto × 1.11
harga_modal_satuan = (harga_modal_agregat × 1.015) / isi_kemasan

total_faktur = Σ(subtotal_baris) + (Σ subtotal × 0.11)   // DPP + PPN tagihan

harga_otc_satuan = harga_otc                    // jangan bagi isi
harga_reg_satuan = harga_reg / isi_kemasan
margin_*         = (harga_*_satuan − harga_modal_satuan) / harga_modal_satuan × 100
```

- Modal agregat: `rata_rata` (default) atau `tertinggi` dari batch berstok (`HargaModalService`).
- Preview konfirmasi harga: JS client-side; simpan final: PHP (`HargaJualService`).
- Margin VIP &lt; 11% → warning saja.
- Relasi tabel & diagram: lihat `pricing-dpb-rules.md` §2–§3.

### Histori harga jual

- Tabel `histori_harga_jual_obat` diisi otomatis saat `HargaJualService::simpanHarga()` mode `versi` (bukan mode `koreksi`).
- UI untuk melihat histori **belum ada**.

### Pelanggan

- `jenis_harga` menentukan tier harga saat penjualan Medis:
  - `OTC` → `harga_otc`
  - `REG` → `harga_reg`
  - `VIP` → `harga_vip`
- `top` = term of payment dalam hari; dipakai sebagai **default saran** tanggal jatuh tempo saat pelanggan dipilih di form penjualan Medis (admin boleh ubah per faktur).
- `nama_faktur` = nama yang tercetak di faktur (bisa berbeda dari `nama_pelanggan`).
- `area` = wilayah pelanggan (contoh: BARAT, TIMUR, APT).

**Seeding dari Excel**

1. Admin mengisi sheet `PELANGGAN` di `database/data/seeder.xlsx`
2. `python scripts/generate-pbf-pelanggan-data.py` → `database/data/pelanggan.json`
3. `PelangganSeeder` upsert berdasarkan `kode_pelanggan`

**Data seed saat ini:** 62 pelanggan — 40 REG, 22 VIP (kode format `20P-xxx`, mis. `20P-001` AAN, `20P-005` ALIS VIP).

### PBF

- Supplier farmasi; satu PBF bisa memasok banyak pembelian.
- Satu obat bisa dibeli dari PBF berbeda dengan harga beli berbeda.

**Seeding dari Excel**

1. Admin mengisi sheet `PBF` di `database/data/seeder.xlsx`
2. `python scripts/generate-pbf-pelanggan-data.py` → `database/data/pbf.json`
3. `PbfSeeder` upsert berdasarkan `nama_pbf`

**Data seed saat ini:** 23 supplier (mis. SERASI DARMA PUTERA, HARUM SENTOSA, COMBI PUTRA) — wilayah utama Bandung, Bekasi, Jakarta, Cirebon.

---

## Pembelian / DPB

> Spesifikasi lengkap modul DPB + penentuan harga: [`pricing-dpb-rules.md`](pricing-dpb-rules.md)

### Definisi

**DPB** = pencatatan barang masuk dari PBF. Satu DPB = satu faktur pembelian. Edit/hapus **terbatas** setelah simpan (lihat § Edit & hapus DPB di bawah) — bukan immutable penuh.

### Input

| Field | Aturan |
|-------|--------|
| `tanggal_terima` | Wajib |
| `tanggal_faktur` | Wajib |
| `pbf_id`, `cabang_id` | Wajib, harus exist di master |
| `nomor_faktur` | Wajib, max 100 karakter |
| Detail min 1 baris | Wajib |
| `nomor_batch` | **Wajib** di validasi |
| `tanggal_expired` | **Wajib** di validasi |
| `harga_beli` | Wajib per baris — harga di faktur PBF ini (bukan harga modal terhitung) |
| `diskon_persen` | Opsional 0–100 |

### Perhitungan subtotal detail & total tagihan

```
bruto          = jumlah × harga_beli
subtotal (DPP) = bruto − (bruto × diskon_persen / 100)
subtotal_dpp   = Σ subtotal
nilai_ppn      = subtotal_dpp × 0.11
total_faktur   = subtotal_dpp + nilai_ppn   // nilai tagihan PBF
```

Form DPB menampilkan ringkasan DPP + PPN + total, lalu modal konfirmasi sebelum simpan.

### Efek sistem saat simpan (`PembelianService::simpan`)

Semua dalam `DB::transaction()`:

1. Buat record `pembelian` dengan `status_tagihan = 'Belum Lunas'`.
2. Untuk setiap detail:
   - Simpan `detail_pembelian` (snapshot kode/nama/SK + `harga_beli`).
   - Tambah/merge `stok_batch_obat` dengan `harga_beli` batch.
   - Catat `mutasi_stok` jenis `Pembelian`, jumlah positif.
3. Buat `tagihan_pbf` (1:1 per DPB):
   - `nilai_faktur` = `total_faktur`
   - `jumlah_bayar` = 0
   - `sisa_tagihan` = `nilai_faktur`
   - `tanggal_jatuh_tempo` = `tanggal_faktur + 30 hari`
   - `status_tagihan` = `Belum Lunas`

Setelah simpan/update, admin diarahkan ke halaman konfirmasi harga jual.

### Edit & hapus DPB

| Kondisi | Boleh edit/hapus? |
|---------|-------------------|
| Tagihan masih belum dibayar (`jumlah_bayar = 0`) | Ya |
| Tagihan sudah ada pembayaran | Tidak |
| Stok batch dari DPB sudah terpakai (sisa &lt; jumlah DPB / sudah ada penjualan) | Tidak |

Saat edit/hapus yang diizinkan:
1. Kurangi stok batch sesuai detail lama + catat mutasi koreksi.
2. Hapus detail & mutasi pembelian lama.
3. Edit: tulis ulang detail + stok + sinkronkan `tagihan_pbf`.
4. Hapus: hapus `tagihan_pbf` lalu header `pembelian`.

### Merge batch

Kunci: `cabang_id + obat_id + nomor_batch + tanggal_expired`.

- Jika sudah ada → `jumlah_stok` di-increment.
- `harga_beli` batch existing **tidak diubah** saat merge.
- Batch baru → record baru dengan `harga_beli` dari detail DPB.

### Hubungan DPB dengan harga

| Data | Disimpan di | Kapan |
|------|-------------|-------|
| Harga beli / netto / modal unit | `detail_pembelian`, `stok_batch_obat` | Saat DPB disimpan |
| Total tagihan (DPP + PPN) | `pembelian.total_faktur`, `tagihan_pbf.nilai_faktur` | Saat DPB disimpan |
| Modal agregat + harga jual | `harga_jual_obat` | Saat konfirmasi harga (Step 2) |
| Jejak batch modal | `harga_modal_perhitungan` | Saat simpan harga dari DPB |

DPB **tidak** otomatis mengubah harga jual — admin wajib konfirmasi. Kamus lengkap: [`pricing-dpb-rules.md`](pricing-dpb-rules.md).

---

## Penjualan

### Jenis

| Jenis | Pelanggan | Harga default |
|-------|-----------|---------------|
| `OTC` | Tidak dipakai | `harga_otc` (readonly dari `harga_jual_obat`) |
| `Medis` | Wajib | Berdasarkan `pelanggan.jenis_harga` (`REG` / `VIP`), readonly |

### Input

| Field | Aturan |
|-------|--------|
| `nomor_faktur` | Medis: auto faktur `{YYMMDD}F-{NNN}` (contoh `260703F-003`). OTC: kode internal `OTC-{YYMMDD}-{NNN}` (contoh `OTC-260714-001`) untuk rekonsiliasi admin — antrian terpisah per jenis. |
| `metode_pembayaran` | Wajib: `CASH` atau `TRANSFER` |
| `sumber_kas` | Force dari metode + jenis: CASH Medis → `Kas Medis`, CASH OTC → `Kas OTC`, TRANSFER (Medis/OTC) → `BCA` |
| `jenis_penjualan` | Wajib: `Medis` atau `OTC` |
| `tanggal_jatuh_tempo` | Medis saja; **wajib, diisi manual** per faktur (boleh beda walau pelanggan sama). Default UI dari `pelanggan.top` saat pelanggan dipilih. |
| Detail min 1 baris | Wajib |
| `harga_satuan` | Readonly; resolve server-side dari tier harga (bukan override client) |
| `diskon` | Nominal Rupiah (integer) per baris; `subtotal = max(0, jumlah×harga − diskon)` |
| `satuan` | Snapshot dari master obat |
| Dokumen cetak | Medis: **faktur** (tampil nomor + jatuh tempo) setelah simpan. OTC: **tidak ada faktur**; **kwitansi** on-demand dari halaman detail (tanpa nomor & jatuh tempo). |
| Edit / hapus | Diizinkan. Edit: kembalikan alokasi batch (`detail_penjualan_batch`) + kompensasi kas keluar, lalu tulis ulang detail (FEFO ulang) + kas masuk baru. Hapus: kembalikan stok + kas keluar kompensasi, hapus header. Nomor faktur/kode internal **tidak berubah** saat edit. |

### FEFO (First Expired First Out)

Saat penjualan disimpan:

1. Cek `getStokTotal(cabang, obat) >= jumlah`.
2. Ambil batch dengan `jumlah_stok > 0`, urut `tanggal_expired` ASC.
3. Gunakan `lockForUpdate()` untuk mencegah race condition.
4. Kurangi stok dari batch expired terdekat dulu.
5. Jika satu batch tidak cukup, lanjut ke batch berikutnya.
6. Catat `detail_penjualan_batch` untuk setiap batch yang terpakai.
7. Catat `mutasi_stok` jenis `Penjualan`, jumlah **negatif**.

**Tidak ada** pemilihan batch manual di UI saat ini.

### Kas masuk otomatis

Jika `sumber_kas` terisi dan `total_penjualan > 0`:

- `KasService::catatMasuk()` dengan kategori `Penjualan {Medis|OTC}`.

---

## Stok

### Status agregat (per cabang + obat)

| Status | Kondisi |
|--------|---------|
| `Habis` | `total_stok <= 0` |
| `Menipis` | `total_stok <= stok_minimum` (dan > 0) |
| `Aman` | `total_stok > stok_minimum` |

`stok_minimum` diambil dari `MAX(stok_minimum)` di batch-batch terkait (default 10 per batch baru).

### Mendekati expired

Batch dengan:
- `jumlah_stok > 0`
- `tanggal_expired` antara hari ini dan hari ini + 90 hari

Ditampilkan di dashboard dan laporan stok batch.

### Koreksi stok

- Hanya via form koreksi per batch (`stok/batch/{id}/koreksi`).
- Menghitung selisih `jumlah_baru - jumlah_lama`.
- Catat `mutasi_stok` jenis `Koreksi` dengan selisih tersebut.
- Tidak membuat/b menghapus batch.

### Jenis mutasi `Retur`

Ada di enum/skema, tetapi **belum ada flow UI** untuk retur.

### Pengiriman antar cabang

**Status:** Belum diimplementasi (backlog). Lihat [`BRIEF_Stok_UI_Hub.md`](BRIEF_Stok_UI_Hub.md) dan `pending-work` §6b.

Keputusan yang sudah disepakati:

- Alur 2 langkah: **Kirim** (cabang asal) → **Terima** (cabang tujuan); status `Dikirim` / `Diterima` / `Batal`.
- Detail per **batch** (bukan hanya kode obat + qty seperti Excel lapangan).
- Di tujuan: merge/create batch identitas sama + salin harga beli/modal.
- Jenis mutasi baru nanti: `Transfer Keluar` / `Transfer Masuk`.

---

## Tagihan PBF

### Status

| Status | Kondisi |
|--------|---------|
| `Belum Lunas` | `jumlah_bayar = 0` |
| `Sebagian` | `0 < jumlah_bayar < nilai_faktur` |
| `Lunas` | `sisa_tagihan <= 0` |

### Pembayaran

- Input `jumlah_bayar_baru` (bukan total kumulatif) — ditambahkan ke `jumlah_bayar` existing.
- Max pembayaran = `sisa_tagihan` saat ini.
- Jika `sumber_dana` diisi, catat `mutasi_kas` keluar kategori `Pembayaran PBF`.
- Jejak cabang pada mutasi: `sumber_dana = BCA` → `cabang_id` null; `Kas Medis` / `Kas OTC` → `cabang_id` = cabang aktif saat bayar.
- Setiap pembayaran (nominal > 0) menulis baris `pembayaran_tagihan_pbf` (tanggal, jumlah, sumber, `sisa_setelah`, opsional `mutasi_kas_id`).

### Daftar tagihan (prioritas bayar)

- Default filter status: `Belum Lunas` + `Sebagian` (`status=aktif`). `status=semua` untuk semua; status tunggal tetap didukung.
- Urutan: `tanggal_jatuh_tempo` ASC (null di akhir), lalu `sisa_tagihan` ASC — memudahkan bagi kas ke beberapa tagihan kecil/terdekat.

### Histori pembayaran

- Halaman `/tagihan/histori` + blok di form bayar per faktur.
- Sumber kebenaran: `pembayaran_tagihan_pbf` (bukan `rencana_pembayaran_pbf`).
- Kolom tampil: PBF, no. faktur, jumlah dibayar, tanggal dibayar, sisa setelah bayar (atau Lunas).

---

## Rencana pembayaran PBF

- CRUD standalone; **tidak** otomatis mengubah `tagihan_pbf`.
- Status: `Direncanakan`, `Dibayar`, `Batal`.
- `sumber_dana`: `Kas Medis`, `Kas OTC`, `BCA`.
- Merupakan estimasi/perencanaan, bukan pembayaran final.

---

## Cash flow (mutasi kas)

### Sumber kas

Tiga sumber terpisah; kolom `mutasi_kas.saldo` = running **per `sumber_kas` (global)**:

- `Kas Medis` — laci tunai arus Medis (UI saldo = **neto per cabang aktif**)
- `Kas OTC` — laci tunai arus OTC (UI saldo = **neto per cabang aktif**)
- `BCA` — rekening bank bersama owner (UI saldo = running global)

### Jejak cabang (`mutasi_kas.cabang_id`)

| Peristiwa | `cabang_id` |
|-----------|-------------|
| Penjualan | Dari penjualan |
| Mutasi manual | Cabang aktif (terkunci) |
| Bayar PBF dari BCA | `null` |
| Bayar PBF dari Kas Tunai | Cabang aktif |

### Navigasi UI

| Menu | Query | Angka |
|------|-------|-------|
| Kas Tunai | `rekening=tunai&arus=Medis\|OTC` | Neto laci cabang aktif (`cabang_id` exact) |
| Rekening BCA | `rekening=bca` (+ opsional `arus`) | Saldo running BCA (global) |
| Omzet Medis/OTC | `rekening=omzet&arus=Medis\|OTC` | Total masuk/keluar/neto lintas sumber (bukan saldo gabungan) |
| Dashboard | — | Kartu BCA global + Kas Tunai cabang aktif |

Detail kontrak: [`BRIEF_Kas_Tunai_BCA.md`](BRIEF_Kas_Tunai_BCA.md).

### Jenis mutasi

- `masuk` — `pemasukan` > 0, `pengeluaran` = 0
- `keluar` — sebaliknya

### Pemicu otomatis

| Peristiwa | Arah kas |
|-----------|----------|
| Penjualan CASH | Masuk ke `Kas Medis` / `Kas OTC` |
| Penjualan TRANSFER | Masuk ke `BCA` (kategori tetap `Penjualan {jenis}`) |
| Pembayaran tagihan PBF | Keluar (jika sumber_dana diisi) |
| Input manual di `/kas/create` | Masuk atau keluar |

### Kategori umum

`Penjualan Medis`, `Penjualan OTC`, `Batal Penjualan Medis`, `Batal Penjualan OTC`, `Pembayaran PBF`, `Setor Tunai`, `Operasional`, `Lain-lain`

---

## Aturan bisnis terencana (belum diimplementasi)

| Item | Status |
|------|--------|
| Perbandingan harga beli lama vs baru di form DPB | Belum |
| UI histori harga jual | Belum |
| Warning margin VIP di edit master obat / penjualan | Belum |
| Import Excel harga bulanan | V0.3 |

---

## Laporan

Semua laporan mendukung filter tanggal (`dari`, `sampai`) kecuali stok (snapshot saat ini).

| Laporan | Data utama |
|---------|------------|
| Penjualan | Header + detail penjualan |
| Pembelian | Header + detail pembelian |
| Tagihan PBF | Hutang supplier |
| Cash flow | Mutasi kas |
| Stok obat | Agregat per cabang+obat |
| Stok batch | Detail batch + expired |

---

## Autentikasi & otorisasi

- Semua route kecuali login memerlukan auth.
- Role minimal di `users.role`:
  - `owner` — boleh switch cabang aktif; boleh CRUD Data Cabang
  - `admin_cabang` — terkunci ke `users.cabang_id`; tidak ada switcher
- Konteks operasional memakai session `cabang_aktif_id` (middleware `cabang.aktif`).
- Form DPB/penjualan/rencana mengikuti cabang aktif; admin cabang tidak bisa menulis ke cabang lain.
- Index DPB / penjualan / tagihan: admin cabang terkunci ke cabangnya (tanpa dropdown); owner filter per cabang atau `cabang_id=all` (Semua Cabang). Sentinel `all` diperlukan karena live-search membuang query kosong.
- Index penjualan: admin tidak bisa show/edit/hapus penjualan cabang lain (403).
- Permission per modul (kasir vs admin) belum ada — lihat pending-work.
