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>
9.8 KiB
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.
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.2quantity: 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:
quantityuntuk produk timbangan selalu 1. Backend memaksanya, dan database menolak nilai lain lewat constraintchk_order_items_weight_single_line.weightmenyimpan angka timbangan, dalam satuan produk itu sendiri (ons, kg, gram — apa pun yang dipilih saat setup).- Harga baris dihitung
weight × unit_price, bukanquantity × unit_price.unit_pricetetap berarti harga per satu satuan (per ons). - 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
{
"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
{
"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_bysetelah produk punya transaksi. Mengubah produk lama dariunitkeweighttidak mengubah baris order yang sudah ada — baris lama tetap dihitung per cacah — tapi akan membingungkan pengguna yang melihat riwayatnya. - Saat
weightdipilih, 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_idopsional 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
{
"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:
{
"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 |
{
"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:
{
"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. weightopsional 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. quantitytetap 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.500tersimpanRp 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_bydikunci untuk produk yang sudah bertransaksi- Daftar produk menandai mana yang dijual per timbangan
- Laporan harian menampilkan kolom Berat
POS Mobile
- Menu membaca
sell_bytiap 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