Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the order line could not represent: quantity is INTEGER and prices are always computed as quantity * unit_price. Model one weighing as one order line. quantity stays INTEGER and keeps meaning "how many items"; the measured amount goes into a new nullable order_items.weight, and the line is priced weight * unit_price. Two weighings of the same product are two lines, never merged into one. Keeping quantity integral avoids float comparisons in void, refund and split bill, where accumulated rounding error would silently misbehave — "1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid split-bill item marked unpaid. BillableQuantity() is now the single place that decides between weight and count; every price and cost calculation goes through it. Missing one would bill a 4.2 ons fish as a single ons — wrong money, no error. Two database constraints back the design: a weighed line always carries a positive weight, and its quantity is pinned to 1. The latter also makes void all-or-nothing for weighed lines, so the row-splitting branch can never produce a zero-weight remainder row. Also wires product.unit_id through the API, which was previously not settable at all, and corrects the misleading comment on the request's unit_price field — that value has never been used; price always comes from the database. Design notes and the audit of every price multiplication site are in docs/rfc-weight-based-products.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
15 KiB
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_idyang merujuk tabelunits— 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:
- Menghapus jejak barang.
4.2dan5.6yang digabung jadi9.8kehilangan informasi bahwa ada dua ikan. Tidak bisa direkonstruksi. - 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. - Membawa masalah presisi float ke seluruh sistem. Perbandingan quantity dipakai
di void, refund, dan split bill. Dengan float,
1,4 + 1,4 + 1,4tidak sama dengan4,2— split bill "bagi rata bertiga" akan gagal menandai item lunas meski uang sudah diterima penuh. Semua itu tidak terjadi bilaquantitytetap 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
-- 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:
weightnullable.NULLberarti produk satuan biasa — seluruh data lama valid tanpa backfill, dan perilakunya tidak berubah sama sekali.chk_order_items_weight_single_linemenegakkan keputusan §2 di level database: baris berbobot selaluquantity = 1. Ini yang membuatBillableQuantity()tidak ambigu dan membuat void otomatis bersifat utuh (§6).quantitytidak berubah tipe.CHECK (quantity > 0)yang sudah ada tetap berlaku.DECIMAL(12,3)konsisten denganinventory_movements.quantityyang 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):
// 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
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:129danprocessor/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 dijagaorderItem.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)sebagaitotal_itemstetap bermakna dan tetap konsisten lintas produk — 2 ikan tetap dihitung 2, bukan 9,8 ons.QuantitySoldtetapint64. 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):
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, bukanquantity— kalau tidak, menjual ikan 4,2 ons hanya mengurangi stok sebanyak 1. inventory.quantityperlu menjadiDECIMAL(12,3)lebih dulu. Biayanya hampir nol sekarang (3 call site, tanpa data historis); jauh lebih mahal setelah berjalan.order_processor.go:946berisideltaInt := 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
- Migrasi skema (§4). Aman: semua kolom nullable atau ber-default, data lama tidak tersentuh.
BillableQuantity()+CalculateTotalPrice()/CalculateTotalCost()(§5).- Ganti 8 titik perkalian harga (§5) lalu jalankan dua
grepverifikasi. - Field kontrak:
weightpada request order & self-order,weight+unitpada response. - Validasi
sell_by(§7.1). - Template & tampilan struk (§7.2).
- (Opsional)
weight_soldpada 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
-
Pembulatan uang — diputuskan sementara, perlu konfirmasi.
RoundMoneymembulatkan ke 2 desimal, mengikuti presisi kolomdecimal(10,2)yang sudah dipakai semua nilai uang. Jadi4,237 ons × Rp 4.500tersimpanRp 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
RoundMoneydientities/order_item.go— satu tempat, dan lakukan sebelum ada transaksi timbangan, karena setelahnya data lama dan baru akan mengikuti aturan berbeda. -
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, tambahkanproducts.min_weight_increment. -
Sumber angka timbangan — kasir mengetik manual atau timbangan tersambung? Bila tersambung, ada urusan tara dan pembacaan stabil yang berada di luar RFC ini.