Files
apskel-pos-backend/docs/integration-weight-based-products.md
T
efrilmandClaude Opus 5 d3987c7114 docs(order): add weight-based product integration guide
Client-facing companion to the RFC, aimed at the POS Mobile and Backoffice
teams: endpoints and payloads for setting up a weight product, placing an
order, rendering the line, and voiding, refunding or splitting it.

Documents two gaps the teams have to work around rather than discover:
unit_id is not yet enforced when sell_by is "weight", so Backoffice must
require it in the form; and money rounds to 2 decimals rather than whole
rupiah, which is still an open decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:20:22 +07:00

9.1 KiB
Raw Blame History

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.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

{
  "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 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

{
  "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.


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