A product sold by weight with no unit produces order lines with nothing to print: the receipt would read "4,2" with no idea of what. Until now nothing stopped that — the mistake only surfaced at the cashier. Enforce it in two places, because neither alone sees the whole picture. On create, the validator has everything it needs. On update, the request may omit unit_id for a product that already has one, so the check runs in the processor against the merged product: what is rejected is the end state, a product sold by weight with no unit. Also fixes two things this uncovered: The struct tags on the product contracts are decorative — this validator is hand-written and never calls validator.Struct — so `oneof=unit weight` was never enforced, and an unknown sell_by was silently rewritten to "unit" by the mapper. It is now rejected with a message that names the valid values. The update validator's "at least one field" guard did not list unit_id, sell_by or print_to_checker, so an update carrying only one of those was turned away as an empty request. 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
|