# 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** dengan pesan jelas. | | `unit_id` | UUID | **Wajib** saat `sell_by: "weight"`, ditolak backend bila kosong. Opsional untuk produk satuan. | | `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. | Pada `PUT /api/v1/products/:id`, `unit_id` **tidak perlu dikirim ulang** bila produknya sudah punya satuan — mengubah `sell_by` menjadi `"weight"` saja sudah cukup. Yang ditolak adalah kondisi akhirnya: produk yang dijual per timbangan tanpa satuan. 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. Backend juga menolaknya, tapi ditangkap di form lebih baik daripada baru gagal saat simpan. - Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*. - Untuk produk satuan, pemilih satuan boleh disembunyikan — `unit_id` opsional dan belum dikonsumsi apa pun di POS. --- ## 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. ### Setup produk (Backoffice) | Pesan (`cause`) | Penyebab | Perbaikan di klien | |---|---|---| | `unit_id is required when sell_by is 'weight'` | Produk timbangan dibuat tanpa satuan | Wajibkan pemilih satuan saat Timbangan dipilih | | `sell_by must be either 'unit' or 'weight'` | Nilai `sell_by` di luar dua itu | Kirim persis `"unit"` atau `"weight"` | | `product '…' is sold by weight and requires a unit_id` | Update membuat produk jadi timbangan tanpa satuan | Kirim `unit_id` bersama perubahan `sell_by` | --- ## 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 - **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