Files
apskel-pos-backend/docs/rfc-weight-based-products.md
T
efrilmandClaude Opus 5 992bb04816 feat(order): support weight-based products
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>
2026-09-06 17:14:42 +07:00

15 KiB
Raw Blame History

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

-- 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):

// 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: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):

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.