Produk kini punya cara jual `unit` (cacah) atau `weight` (timbangan), mengikuti kontrak backend di docs/integration-weight-based-products.md. - tipe SellBy, domain/infrastructure unit baru, dan endpoint /api/v1/units - dialog input berat saat produk timbangan ditambahkan ke keranjang - tiap penimbangan jadi baris keranjang sendiri; total baris dihitung berat x harga satuan, sedangkan produk cacah tetap digabung - request ke backend mengirim quantity 1 dan membawa berat - migrasi DB v3: kolom sell_by, unit_id, unit_name, unit_abbreviation di tabel products supaya satuan tetap tampil saat offline - tampilan keranjang, struk, split bill, void, dan refund memakai berat - test/weight_based_products_test.dart menutup perilaku di atas (9 test) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
280 lines
9.8 KiB
Markdown
280 lines
9.8 KiB
Markdown
# 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`<br>`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": "<id satuan Ons>"
|
||
}
|
||
```
|
||
|
||
| 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": "<ikan-tude>", "quantity": 1, "weight": 4.2 },
|
||
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 5.6 },
|
||
{ "product_id": "<nasi-goreng>", "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": "<baris 4,2 ons>", "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
|