# RFC: Produk Timbangan (Weight-Based Products) **Status:** Diimplementasikan (migrasi `000089`) **Tanggal:** 2026-09-05, diperbarui 2026-09-06 **Scope:** Product, Order, Void/Refund, Report **Out of scope:** Inventory / pengurangan stok otomatis (lihat §8) --- ## 1. Masalah Sistem mengasumsikan setiap produk dijual dalam satuan diskrit. `order_items.quantity` bertipe `INTEGER` dengan `CHECK (quantity > 0)`, dan harga dihitung `quantity × unit_price` di seluruh jalur order, void, refund, dan split bill. Produk seperti Ikan Tude dijual per timbangan. Pelanggan memesan Ikan Tude 4,2 ons, lalu memesan Ikan Tude lagi 5,6 ons. Keduanya adalah **dua ikan berbeda yang ditimbang terpisah** — bukan satu baris berisi 9,8. Angka 4,2 itu **berat**, bukan cacah. Sistem belum punya tempat untuk menyimpannya. > **Catatan satuan.** RFC ini tidak mengasumsikan satuan tertentu. Satuan produk > ditentukan `products.unit_id` yang merujuk tabel `units` — bisa ons, kg, gram, atau > apa pun yang didefinisikan organisasi. Contoh memakai **ons** karena itu kasus yang > sedang dikerjakan; tidak ada bagian desain ini yang bergantung padanya. --- ## 2. Keputusan Inti **Satu penimbangan = satu baris `order_items`.** | | Baris 1 | Baris 2 | |---|---|---| | Ikan Tude 4,2 ons | `quantity = 1`, `weight = 4.2` | | | Ikan Tude 5,6 ons | | `quantity = 1`, `weight = 5.6` | `quantity` tetap `INTEGER` dan tetap berarti "berapa banyak barang". Berat masuk ke kolom baru. Dua baris tidak pernah digabung menjadi `9.8`, karena keduanya memang dua ikan yang berbeda. ### Kenapa bukan `quantity = 4.2` Alternatif yang sempat dipertimbangkan adalah mengubah `quantity` menjadi `DECIMAL(12,3)`. Model itu ditolak karena tiga alasan: 1. **Menghapus jejak barang.** `4.2` dan `5.6` yang digabung jadi `9.8` kehilangan informasi bahwa ada dua ikan. Tidak bisa direkonstruksi. 2. **Merusak agregasi lintas produk.** `SUM(quantity)` untuk laporan "total item terjual" akan menjumlahkan ons dengan porsi — angka tanpa arti, yang bahkan berubah nilainya bila satuan produk diganti dari ons ke kg tanpa ada apa pun yang berubah di dunia nyata. 3. **Membawa masalah presisi float ke seluruh sistem.** Perbandingan quantity dipakai di void, refund, dan split bill. Dengan float, `1,4 + 1,4 + 1,4` tidak sama dengan `4,2` — split bill "bagi rata bertiga" akan gagal menandai item lunas meski uang sudah diterima penuh. Semua itu tidak terjadi bila `quantity` tetap integer. Konsekuensi langsung dari keputusan ini: **tidak diperlukan helper perbandingan epsilon.** Berat tidak pernah dibandingkan, hanya dikalikan. --- ## 3. Prinsip **P1 — Baris transaksi adalah snapshot yang beku.** `order_items` sudah menyimpan `unit_price` dan `unit_cost` sebagai salinan, bukan join ke `products`. Satuan mendapat perlakuan sama: mengubah master data tidak boleh mengubah arti transaksi yang sudah terjadi. **P2 — Perhitungan harga baris hanya ada di satu tempat.** Setelah RFC ini ada dua rumus (`quantity × harga` dan `weight × harga`). Tidak boleh ada perkalian harga yang tersebar; semuanya memanggil satu fungsi. **P3 — Harga tetap otoritas backend.** Klien tidak pernah mengirim harga. Backend membacanya dari `products` / `product_outlet_prices` seperti sekarang. **P4 — Berat boleh dijumlahkan dalam satu produk, tidak boleh antar produk.** `SUM(weight)` untuk satu produk bermakna ("terjual 47,3 ons"). Lintas produk dengan satuan berbeda tidak bermakna. --- ## 4. Perubahan Skema ```sql -- Products: cara jual ALTER TABLE products ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit' CHECK (sell_by IN ('unit', 'weight')); -- Order items: berat + snapshot satuan ALTER TABLE order_items ADD COLUMN weight DECIMAL(12,3), ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT; ALTER TABLE order_items ADD CONSTRAINT chk_order_items_weight_positive CHECK (weight IS NULL OR weight > 0), ADD CONSTRAINT chk_order_items_weight_single_line CHECK (weight IS NULL OR quantity = 1); ``` **Catatan:** - `weight` **nullable**. `NULL` berarti produk satuan biasa — seluruh data lama valid tanpa backfill, dan perilakunya tidak berubah sama sekali. - `chk_order_items_weight_single_line` menegakkan keputusan §2 di level database: baris berbobot selalu `quantity = 1`. Ini yang membuat `BillableQuantity()` tidak ambigu dan membuat void otomatis bersifat utuh (§6). - `quantity` **tidak berubah tipe**. `CHECK (quantity > 0)` yang sudah ada tetap berlaku. - `DECIMAL(12,3)` konsisten dengan `inventory_movements.quantity` yang sudah memakai presisi sama. - Tidak ada `weighed_unit`. Karena satu baris memang satu barang, "ikan curah" dan "ikan per ekor" berperilaku identik — pembedaan itu tidak punya konsekuensi. --- ## 5. Perhitungan Harga Satu-satunya tempat yang boleh mengalikan harga (P2): ```go // BillableQuantity mengembalikan pengali harga untuk baris ini: // berat bila produk dijual per timbangan, jumlah bila dijual per satuan. // Baris berbobot dijamin quantity = 1 oleh constraint DB. func (oi *OrderItem) BillableQuantity() float64 { if oi.Weight != nil { return *oi.Weight } return float64(oi.Quantity) } func (oi *OrderItem) CalculateTotalPrice() { oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice) } func (oi *OrderItem) CalculateTotalCost() { oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost) } ``` `unit_price` tetap berarti **harga per satu satuan produk** (per ons). Tidak ada faktor konversi yang menyelinap ke perhitungan uang. ### Titik yang harus diganti Ini bagian paling berisiko dari RFC. Setiap perkalian harga yang terlewat akan menghitung `1 × harga_per_ons` — ikan 4,2 ons ditagih seharga 1 ons. **Salah uang, bukan salah tampilan**, dan tidak memicu error apa pun. | Lokasi | Sekarang | |---|---| | `processor/order_processor.go:197-198` | buat order | | `processor/order_processor.go:330-331` | tambah item ke order | | `processor/order_processor.go:605-606` | jumlah & HPP yang di-void | | `processor/order_processor.go:723` | jumlah refund | | `processor/split_bill_processor.go:143` | hitung jumlah split | | `processor/split_bill_processor.go:189` | catat pembayaran | | `processor/split_bill_processor.go:231` | metadata pembayaran | | `repository/order_item_repository.go:113` | jumlah void penuh | Implementasi menemukan **lima titik tambahan** di luar daftar di atas, semuanya di jalur inventory movement dan resep bahan yang tidak terlihat saat RFC ini ditulis: | Lokasi | Status | |---|---| | `order_processor.go:1056` `createInventoryMovement` | mati (0 pemanggil), tetap diperbaiki | | `order_processor.go:1354` `prepareProductInventoryMovement` | **hidup** | | `order_processor.go:1420` `prepareIngredientRecipeItem` | **hidup** | | `order_processor.go:1518` `prepareRefundProductInventoryMovement` | mati (0 pemanggil), tetap diperbaiki | | `order_processor.go:1584` `prepareRefundedIngredientRecipeItem` | **hidup** | Tiga yang hidup penting: tanpa perbaikan, konsumsi bahan untuk ikan 4,2 ons akan dihitung sebagai 1 satuan resep. ### Verifikasi ```bash grep -rn "Quantity) \* \|Quantity \* " --include=*.go internal/ \ | grep -iE "price|cost" | grep -v BillableQuantity | grep -v totalIngredientQuantity ``` Hasilnya **tidak kosong** — tersisa tujuh baris, semuanya sudah diperiksa dan aman: - `mappers/inventory_movement_mapper.go:129` dan `processor/inventory_movement_processor.go:69` — penyesuaian stok manual, bukan baris order. - `repository/order_item_repository.go:144,146,147,165,166` — cabang void sebagian, yang baris berbobot tidak pernah jangkau karena dijaga `orderItem.IsWeighed()`. Bila daftar ini bertambah di kemudian hari, baris barunya harus diperiksa satu per satu. --- ## 6. Void, Refund, Split Bill **Tidak ada perubahan logika.** Ini konsekuensi menyenangkan dari `quantity` yang tetap integer. **Void.** `VoidOrderItem` (`repository/order_item_repository.go:104`) bercabang pada `voidQuantity >= orderItem.Quantity`. Untuk baris berbobot, `quantity` dijamin `1` dan `voidQuantity` minimal `1`, sehingga **selalu** masuk cabang void penuh. Cabang pemecahan baris tidak pernah tersentuh, sehingga tidak mungkin lahir baris sisa berbobot nol. Yang berubah hanya perhitungan `voidedAmount` di baris 113 (§5). **Refund.** Sama — refund baris berbobot bersifat utuh. Hanya `refundAmount` di `order_processor.go:723` yang perlu memakai `BillableQuantity()`. **Split bill.** `payment_order_items.quantity` tetap `INTEGER`. Untuk baris berbobot nilainya `0` atau `1` — bayar penuh atau tidak sama sekali. Seluruh perbandingan di `split_bill_processor.go` tetap aritmatika bilangan bulat, sehingga masalah presisi float tidak pernah muncul. Hanya perhitungan `itemAmount` (baris 143 dan 189) yang berubah. **Batasan yang diterima:** refund atau void **sebagian berat** (mengembalikan 1 ons dari baris 4,2 ons) tidak didukung. Untuk barang yang sudah ditimbang dan diserahkan, koreksi sebagian pada praktiknya berarti salah timbang — yang penanganan benarnya adalah void baris itu lalu input ulang, bukan mengubah berat baris yang sudah tercatat. Ini menjaga jejak audit tetap jujur. --- ## 7. Validasi & Tampilan ### 7.1 Aturan validasi Divalidasi di processor saat membuat / menambah item, di mana produk sudah dimuat: | `products.sell_by` | Aturan | |---|---| | `unit` | `weight` harus kosong. Bila dikirim → tolak. | | `weight` | `weight` wajib ada dan `> 0`. `quantity` dipaksa `1`. | `unit_id` di `order_items` diisi dari `products.unit_id` saat baris dibuat (P1) — bukan dibaca lewat join saat ditampilkan. Berat dibulatkan ke 3 desimal saat masuk, agar nilai tersimpan selalu sama dengan nilai yang divalidasi. ### 7.2 Tampilan `templates/daily_transaction.html:539` mencetak `{{$item.Quantity}}`. Untuk baris berbobot ini akan menampilkan `1`, bukan `4,2 ons`. Perlu bercabang pada `weight`. Response API menambah `weight` dan `unit` pada item, agar frontend dan struk dapat menampilkan `4,2 ons × Rp 4.500` alih-alih `1 × Rp 4.500`. --- ## 8. Report **Tidak ada perubahan yang wajib.** Karena `quantity` tetap integer dan tetap berarti "berapa banyak barang": - `SUM(oi.quantity)` sebagai `total_items` tetap bermakna dan tetap konsisten lintas produk — 2 ikan tetap dihitung 2, bukan 9,8 ons. - `QuantitySold` tetap `int64`. Tidak ada pemotongan pecahan. - `average_price = SUM(total_price) / SUM(quantity)` menjadi "rata-rata harga per ekor", yang tetap merupakan angka bermakna. **Tambahan opsional** — melaporkan berat terjual, hanya pada laporan **per produk** (P4): ```sql COALESCE(SUM(oi.weight), 0) AS weight_sold ``` Tidak boleh dipakai pada agregat lintas produk, karena akan menjumlahkan satuan yang berbeda. --- ## 9. Di Luar Scope **Pengurangan stok otomatis.** `adjustInventoryWithTransaction` (`order_processor.go:1177`) dan `adjustIngredientInventoryWithTransaction` (`order_processor.go:920`) terdefinisi tetapi **tidak pernah dipanggil dari mana pun** — sudah diverifikasi se-repo. Endpoint CRUD inventory berfungsi; pengurangan stok saat penjualan tidak tersambung. Konsekuensi untuk RFC ini: `inventory.quantity` yang masih `int` tidak menghalangi apa pun. Catatan untuk nanti bila jalur stok disambungkan: - Stok produk timbangan harus berkurang sebesar `weight`, bukan `quantity` — kalau tidak, menjual ikan 4,2 ons hanya mengurangi stok sebanyak 1. - `inventory.quantity` perlu menjadi `DECIMAL(12,3)` lebih dulu. Biayanya hampir nol sekarang (3 call site, tanpa data historis); jauh lebih mahal setelah berjalan. - `order_processor.go:946` berisi `deltaInt := int(delta)` yang memotong pecahan. Kode ini mati, jadi bukan kebocoran aktif — tetapi bila disambungkan tanpa diperbaiki, konsumsi bahan di bawah 1 unit akan hilang diam-diam. Kedua fungsi mati itu sebaiknya **dihapus atau disambungkan**, jangan dibiarkan menggantung — komentar di dalamnya ditulis seolah-olah aktif. --- ## 10. Temuan Sampingan: `unit_price` pada request diabaikan `CreateOrderItemRequest.UnitPrice` (`contract/order_contract.go:46`) berkomentar *"Optional, will use database price if not provided"*. Kenyataannya field ini **tidak pernah dipakai** — satu-satunya yang menyentuhnya adalah validasi `< 0` di `service/order_service.go:431` dan `:474`. Processor selalu membaca harga dari `products` / `product_outlet_prices`. Perilaku sekarang sudah benar dan sesuai P3. Yang salah hanya komentarnya, yang menyiratkan klien bisa mengirim harga. Sebaiknya field itu **dihapus** dari contract, atau komentarnya dikoreksi menjadi keterangan bahwa harga selalu diambil dari database. Dibiarkan seperti sekarang, ini mengundang frontend mengirim harga dan menyangka berhasil, padahal diabaikan diam-diam. --- ## 11. Urutan Implementasi 1. **Migrasi skema** (§4). Aman: semua kolom nullable atau ber-default, data lama tidak tersentuh. 2. **`BillableQuantity()` + `CalculateTotalPrice()` / `CalculateTotalCost()`** (§5). 3. **Ganti 8 titik perkalian harga** (§5) lalu jalankan dua `grep` verifikasi. 4. **Field kontrak**: `weight` pada request order & self-order, `weight` + `unit` pada response. 5. **Validasi `sell_by`** (§7.1). 6. **Template & tampilan struk** (§7.2). 7. *(Opsional)* `weight_sold` pada laporan per produk (§8). Langkah 1-4 membuat produk timbangan dapat dijual dengan harga yang benar. Langkah 5 mencegah data tidak konsisten masuk. Langkah 6 membuat struk terbaca benar. --- ## 12. Risiko | Risiko | Dampak | Mitigasi | |---|---|---| | Satu titik perkalian harga terlewat | Ikan 4,2 ons ditagih seharga 1 ons — salah uang, tanpa error | Dua `grep` verifikasi di §5; uji satu order timbangan lewat setiap jalur (create, tambah item, void, refund, split bill) | | `weight` dikirim untuk produk `unit` | Harga baris salah total | Validasi §7.1 + constraint DB | | `quantity > 1` pada baris berbobot | `BillableQuantity()` ambigu | Dicegah `chk_order_items_weight_single_line` di level DB | | Klien lama tidak mengirim `weight` | Produk timbangan ditagih 1 satuan | Validasi §7.1 menolak, bukan mendiamkan | | Struk menampilkan `1` alih-alih `4,2 ons` | Pelanggan bingung, kasir kehilangan kepercayaan | §7.2 | --- ## 13. Pertanyaan Terbuka 1. **Pembulatan uang — diputuskan sementara, perlu konfirmasi.** `RoundMoney` membulatkan ke **2 desimal**, mengikuti presisi kolom `decimal(10,2)` yang sudah dipakai semua nilai uang. Jadi `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`. Ini pilihan paling tidak mengejutkan dan konsisten dengan data lama, tetapi **bukan** pembulatan ke rupiah utuh. Bila kasir harus menerima uang dalam rupiah penuh (atau kelipatan Rp 100/500), ubah `RoundMoney` di `entities/order_item.go` — satu tempat, dan lakukan **sebelum** ada transaksi timbangan, karena setelahnya data lama dan baru akan mengikuti aturan berbeda. 2. **Presisi input berat.** Apakah `4,237 ons` (resolusi 0,1 gram) valid, atau input harus dibatasi ke kelipatan tertentu sesuai resolusi timbangan? Bila perlu dibatasi, tambahkan `products.min_weight_increment`. 3. **Sumber angka timbangan** — kasir mengetik manual atau timbangan tersambung? Bila tersambung, ada urusan tara dan pembacaan stabil yang berada di luar RFC ini.