diff --git a/docs/integration-weight-based-products.md b/docs/integration-weight-based-products.md new file mode 100644 index 0000000..61b5b16 --- /dev/null +++ b/docs/integration-weight-based-products.md @@ -0,0 +1,269 @@ +# Integrasi Produk Timbangan — POS Mobile & Backoffice + +**Migrasi:** `000089` · **Base URL:** `/api/v1` · **Kompatibilitas:** mundur penuh + +Panduan untuk menjual produk per timbangan (ikan, daging, buah) dari sisi klien. +Alasan di balik setiap keputusan desain ada di [`rfc-weight-based-products.md`](./rfc-weight-based-products.md). + +--- + +## 1. Konsep inti + +**Satu penimbangan = satu baris order.** + +Pelanggan memesan Ikan Tude 4,2 ons, lalu memesan Ikan Tude lagi 5,6 ons. Itu **dua +baris terpisah**, karena keduanya dua ikan berbeda yang ditimbang sendiri-sendiri. + +| Bukan begini | Melainkan begini | +|---|---| +| `quantity: 9.8` | `quantity: 1, weight: 4.2`
`quantity: 1, weight: 5.6` | +| Dua ikan hilang jejaknya, dan `quantity` bertipe integer sehingga pecahan ditolak | Tiap penimbangan berdiri sendiri, bisa di-void atau dibayar terpisah | + +Empat aturan yang berlaku di seluruh dokumen ini: + +1. `quantity` untuk produk timbangan **selalu 1**. Backend memaksanya, dan database + menolak nilai lain lewat constraint `chk_order_items_weight_single_line`. +2. `weight` menyimpan angka timbangan, dalam satuan produk itu sendiri (ons, kg, gram — + apa pun yang dipilih saat setup). +3. Harga baris dihitung `weight × unit_price`, bukan `quantity × unit_price`. + `unit_price` tetap berarti harga per satu satuan (per ons). +4. **Jangan pernah menggabungkan dua baris** produk timbangan menjadi satu, meski + produknya sama. + +Untuk produk biasa tidak ada yang berubah: `weight` tidak dikirim, `quantity` tetap +cacah seperti sekarang. + +--- + +## 2. Backoffice — setup produk + +### 2.1 Pastikan satuannya ada + +Satuan disimpan per organisasi. Buat sekali, pakai ulang untuk semua produk timbangan. + +`POST /api/v1/units` + +```json +{ + "name": "Ons", + "abbreviation": "ons", + "is_active": true +} +``` + +`abbreviation` yang dipakai POS untuk mencetak `4,2 ons` di struk — isi dengan bentuk +pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/v1/units`. + +### 2.2 Buat produk sebagai produk timbangan + +`POST /api/v1/products` + +```json +{ + "category_id": "…", + "name": "Ikan Tude", + "price": 4500, + "cost": 3000, + "sell_by": "weight", + "unit_id": "" +} +``` + +| Field | Tipe | Keterangan | +|---|---|---| +| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain ditolak validator. | +| `unit_id` | UUID | Opsional di backend, tapi **wajib secara praktik** untuk `sell_by: "weight"` — lihat §8. | +| `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. | + +Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan +ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`, +`/products/:id`). + +### 2.3 Catatan UI + +- Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit` + ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per + cacah — tapi akan membingungkan pengguna yang melihat riwayatnya. +- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. +- Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*. + +--- + +## 3. POS Mobile — transaksi + +### 3.1 Bentuk input mengikuti `sell_by` + +| `sell_by` | Input di POS | Yang dikirim | +|---|---|---| +| `"unit"` | Stepper − / + seperti sekarang | `quantity: n`, tanpa `weight` | +| `"weight"` | Papan angka desimal, satuan dari `unit` produk | `quantity: 1` + `weight: 4.2` | + +### 3.2 Mengirim order + +`POST /api/v1/orders` + +```json +{ + "outlet_id": "…", + "user_id": "…", + "order_type": "dine_in", + "order_items": [ + { "product_id": "", "quantity": 1, "weight": 4.2 }, + { "product_id": "", "quantity": 1, "weight": 5.6 }, + { "product_id": "", "quantity": 2 } + ] +} +``` + +Bentuk yang sama berlaku untuk `POST /api/v1/orders/:id/add-items` dan untuk pemesanan +mandiri `POST /api/v1/self-order/orders`. + +**Presisi.** Berat dibulatkan backend ke 3 desimal. Kirim `4.2` atau `4.237`; angka di +bawah `0.001` membulat ke nol dan ditolak. + +**Harga.** Field `unit_price` pada request **diabaikan** — harga selalu diambil backend +dari master produk. Jangan mengirim harga hasil hitungan sendiri. + +--- + +## 4. Menampilkan baris + +Setiap `order_items[]` di response membawa empat field tambahan: + +```json +{ + "product_name": "Ikan Tude", + "quantity": 1, + "weight": 4.2, + "unit_id": "…", + "unit_name": "Ons", + "unit_abbreviation": "ons", + "unit_price": 4500, + "total_price": 18900 +} +``` + +Semuanya `null` atau absen untuk produk biasa, jadi cabangkan tampilan pada `weight`: + +| Kondisi | Tampilkan | +|---|---| +| `weight == null` | `2 × Rp 25.000` | +| `weight != null` | `4,2 ons × Rp 4.500` | + +**Jangan menampilkan `quantity` untuk baris berbobot** — nilainya selalu 1 dan akan +terbaca seperti "satu ons". Gunakan `weight` dengan `unit_abbreviation`, dan pakai koma +desimal sesuai format Indonesia. + +--- + +## 5. Void, refund, split bill + +Baris berbobot bersifat **utuh**: dibatalkan seluruhnya atau tidak sama sekali. Karena +`quantity`-nya 1, semua endpoint cukup dikirimi `1`, dan backend menghitung nilai +rupiahnya dari `weight`. + +| Aksi | Endpoint | Field untuk baris berbobot | +|---|---|---| +| Void per item | `POST /orders/void` | `items[].quantity: 1` | +| Refund per item | `POST /orders/:id/refund` | `order_items[].refund_quantity: 1` (atau kosongkan) | +| Split bill per item | `POST /orders/split-bill` | `items[].quantity: 1` = bayar baris itu penuh | + +```json +{ + "order_id": "…", + "reason": "Salah timbang", + "type": "ITEM", + "items": [ + { "order_item_id": "", "quantity": 1 } + ] +} +``` + +Untuk split bill, baris berbobot hanya bisa berstatus belum dibayar atau lunas — tidak +ada nilai di antaranya. Sembunyikan stepper jumlah pada baris berbobot, ganti dengan +tombol pilih baris. + +**Batasan yang disengaja.** Mengembalikan *sebagian berat* — 1 ons dari baris 4,2 ons — +tidak didukung. Koreksi salah timbang ditangani dengan void baris itu lalu input ulang, +sehingga jejak auditnya tetap jujur. + +--- + +## 6. Referensi error + +Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `900`: + +```json +{ + "success": false, + "data": null, + "errors": [ + { "code": "900", "entity": "ORDER", + "cause": "product Ikan Tude is sold by weight and requires a weight" } + ] +} +``` + +| Pesan (`cause`) | Penyebab | Perbaikan di klien | +|---|---|---| +| `… is sold by weight and requires a weight` | Produk `sell_by: "weight"` dikirim tanpa `weight` | Wajibkan input timbangan sebelum item masuk keranjang | +| `… is not sold by weight and must not carry a weight` | `weight` dikirim untuk produk satuan | Kirim `weight` hanya bila `sell_by == "weight"` | +| `weight for … must be greater than 0` | Berat nol, negatif, atau membulat ke nol | Validasi minimal `0.001` di keypad | +| `quantity for … must be at least 1` | Produk satuan dengan `quantity` ≤ 0 | Perilaku lama, tidak berubah | + +Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir. + +--- + +## 7. Kompatibilitas mundur + +- Semua produk lama otomatis `sell_by: "unit"`. Perilakunya identik dengan sebelumnya. +- `weight` opsional di request. Klien yang tidak mengenalnya tetap berfungsi penuh untuk + produk satuan. +- Field baru di response semuanya `omitempty` — tidak muncul untuk baris biasa, jadi + parser lama tidak terganggu. +- `quantity` tetap **integer** di seluruh API. Tidak ada field yang berubah tipe. + +Yang tidak berfungsi di klien lama hanyalah menjual produk timbangan itu sendiri — +permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam. + +--- + +## 8. Batasan yang diketahui + +> **Perlu ditangani di frontend.** Backend **belum** memaksa `unit_id` terisi saat +> `sell_by: "weight"`. Produk timbangan tanpa satuan akan tersimpan, tapi baris ordernya +> keluar dengan `unit_abbreviation: null` — struk tidak bisa mencetak "ons". Sampai +> validasi itu ditambahkan di backend, **Backoffice wajib mewajibkannya di form**. + +- **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`, + bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu + diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat). +- **Stok belum otomatis berkurang** saat penjualan — untuk produk timbangan maupun + produk biasa. Pengurangan stok belum tersambung di backend, jadi jangan menampilkan + sisa stok yang mengandaikan itu berjalan. + +--- + +## 9. Checklist per tim + +**Backoffice Website** + +- [ ] CRUD satuan tersedia di menu master data +- [ ] Form produk punya pilihan cara jual: Satuan / Timbangan +- [ ] Pemilih satuan menjadi wajib saat Timbangan dipilih +- [ ] Label harga ikut satuan — "Harga per ons" +- [ ] `sell_by` dikunci untuk produk yang sudah bertransaksi +- [ ] Daftar produk menandai mana yang dijual per timbangan +- [ ] Laporan harian menampilkan kolom Berat + +**POS Mobile** + +- [ ] Menu membaca `sell_by` tiap produk +- [ ] Papan angka desimal untuk produk timbangan +- [ ] Kirim `quantity: 1` + `weight` +- [ ] Dua penimbangan menjadi dua baris, tidak digabung +- [ ] Keranjang & struk menampilkan `4,2 ons × Rp 4.500` +- [ ] Void & refund baris berbobot bersifat utuh +- [ ] Split bill: pilih baris, bukan stepper jumlah +- [ ] Pesan error validasi ditampilkan ke kasir