diff --git a/docs/integration-weight-based-products.md b/docs/integration-weight-based-products.md
new file mode 100644
index 0000000..1d9d658
--- /dev/null
+++ b/docs/integration-weight-based-products.md
@@ -0,0 +1,279 @@
+# 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`
`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": ""
+}
+```
+
+| 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": "", "quantity": 1, "weight": 4.2 },
+ { "product_id": "", "quantity": 1, "weight": 5.6 },
+ { "product_id": "", "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": "", "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
diff --git a/docs/rfc-weight-based-products.md b/docs/rfc-weight-based-products.md
new file mode 100644
index 0000000..2a0f299
--- /dev/null
+++ b/docs/rfc-weight-based-products.md
@@ -0,0 +1,362 @@
+# 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.
diff --git a/internal/constants/product.go b/internal/constants/product.go
new file mode 100644
index 0000000..cb79eaf
--- /dev/null
+++ b/internal/constants/product.go
@@ -0,0 +1,17 @@
+package constants
+
+// SellBy describes how a product's quantity is expressed on an order line.
+type SellBy = string
+
+const (
+ // SellByUnit is the default: the line counts discrete items (2 portions, 3 bottles).
+ SellByUnit SellBy = "unit"
+ // SellByWeight means the line carries a weighed amount. One weighing is one line,
+ // so quantity stays 1 and the measured value lives in order_items.weight.
+ SellByWeight SellBy = "weight"
+)
+
+// IsValidSellBy reports whether s is a supported sell_by value.
+func IsValidSellBy(s string) bool {
+ return s == SellByUnit || s == SellByWeight
+}
diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go
index 918be35..1d2045f 100644
--- a/internal/contract/analytics_contract.go
+++ b/internal/contract/analytics_contract.go
@@ -198,6 +198,7 @@ type ProductAnalyticsData struct {
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"`
+ WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
@@ -324,6 +325,7 @@ type ParentCategoryAnalyticsProductData struct {
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
QuantitySold int64 `json:"quantity_sold"`
+ WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go
index 237874f..80ea44f 100644
--- a/internal/contract/order_contract.go
+++ b/internal/contract/order_contract.go
@@ -40,13 +40,16 @@ type UpdateOrderRequest struct {
}
type CreateOrderItemRequest struct {
- ProductID uuid.UUID `json:"product_id" validate:"required"`
- ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
- Quantity int `json:"quantity" validate:"required,min=1"`
- UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Optional, will use database price if not provided
- Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
- Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
+ ProductID uuid.UUID `json:"product_id" validate:"required"`
+ ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
+ Quantity int `json:"quantity" validate:"required,min=1"`
+ // Weight is required for products with sell_by = "weight" and must be absent otherwise.
+ // One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price.
+ Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
+ UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Ignored: price always comes from the database
+ Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
+ Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type UpdateOrderItemRequest struct {
@@ -92,26 +95,31 @@ type OrderResponse struct {
}
type OrderItemResponse struct {
- ID uuid.UUID `json:"id"`
- OrderID uuid.UUID `json:"order_id"`
- ProductID uuid.UUID `json:"product_id"`
- ProductName string `json:"product_name"`
- ProductVariantID *uuid.UUID `json:"product_variant_id"`
- ProductVariantName *string `json:"product_variant_name,omitempty"`
- CategoryID *uuid.UUID `json:"category_id,omitempty"`
- CategoryName *string `json:"category_name,omitempty"`
- Quantity int `json:"quantity"`
- UnitPrice float64 `json:"unit_price"`
- TotalPrice float64 `json:"total_price"`
- Modifiers []map[string]interface{} `json:"modifiers"`
- Notes *string `json:"notes,omitempty"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
- Status string `json:"status"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- PrinterType string `json:"printer_type"`
- PrintToChecker bool `json:"print_to_checker"`
- PaidQuantity int `json:"paid_quantity"`
+ ID uuid.UUID `json:"id"`
+ OrderID uuid.UUID `json:"order_id"`
+ ProductID uuid.UUID `json:"product_id"`
+ ProductName string `json:"product_name"`
+ ProductVariantID *uuid.UUID `json:"product_variant_id"`
+ ProductVariantName *string `json:"product_variant_name,omitempty"`
+ CategoryID *uuid.UUID `json:"category_id,omitempty"`
+ CategoryName *string `json:"category_name,omitempty"`
+ Quantity int `json:"quantity"`
+ // Weight is set only for products sold by weight; the line is priced weight * unit_price.
+ Weight *float64 `json:"weight,omitempty"`
+ UnitID *uuid.UUID `json:"unit_id,omitempty"`
+ UnitName *string `json:"unit_name,omitempty"`
+ UnitAbbreviation *string `json:"unit_abbreviation,omitempty"`
+ UnitPrice float64 `json:"unit_price"`
+ TotalPrice float64 `json:"total_price"`
+ Modifiers []map[string]interface{} `json:"modifiers"`
+ Notes *string `json:"notes,omitempty"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
+ Status string `json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ PrinterType string `json:"printer_type"`
+ PrintToChecker bool `json:"print_to_checker"`
+ PaidQuantity int `json:"paid_quantity"`
}
type ListOrdersQuery struct {
diff --git a/internal/contract/product_contract.go b/internal/contract/product_contract.go
index 61c9e90..90a90ea 100644
--- a/internal/contract/product_contract.go
+++ b/internal/contract/product_contract.go
@@ -18,6 +18,8 @@ type CreateProductRequest struct {
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
PrintToChecker *bool `json:"print_to_checker,omitempty"`
+ UnitID *uuid.UUID `json:"unit_id,omitempty"`
+ SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
@@ -38,6 +40,8 @@ type UpdateProductRequest struct {
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
PrintToChecker *bool `json:"print_to_checker,omitempty"`
+ UnitID *uuid.UUID `json:"unit_id,omitempty"`
+ SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
@@ -73,6 +77,8 @@ type ProductResponse struct {
BusinessType string `json:"business_type"`
ImageURL *string `json:"image_url"`
PrinterType string `json:"printer_type"`
+ UnitID *uuid.UUID `json:"unit_id,omitempty"`
+ SellBy string `json:"sell_by"`
PrintToChecker bool `json:"print_to_checker"`
Metadata map[string]interface{} `json:"metadata"`
IsActive bool `json:"is_active"`
diff --git a/internal/contract/self_order_contract.go b/internal/contract/self_order_contract.go
index 8a4e7fd..7e97c7c 100644
--- a/internal/contract/self_order_contract.go
+++ b/internal/contract/self_order_contract.go
@@ -58,6 +58,7 @@ type SelfOrderCreateOrderItem struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
Quantity int `json:"quantity" validate:"required,min=1"`
+ Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
Notes *string `json:"notes,omitempty"`
}
diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go
index fc38018..87b9644 100644
--- a/internal/entities/analytics.go
+++ b/internal/entities/analytics.go
@@ -114,6 +114,7 @@ type ProductAnalytics struct {
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"`
+ WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
diff --git a/internal/entities/order_item.go b/internal/entities/order_item.go
index 72cbf58..189bcc3 100644
--- a/internal/entities/order_item.go
+++ b/internal/entities/order_item.go
@@ -4,6 +4,7 @@ import (
"database/sql/driver"
"encoding/json"
"errors"
+ "math"
"time"
"github.com/google/uuid"
@@ -47,6 +48,8 @@ type OrderItem struct {
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `gorm:"type:uuid;index" json:"product_variant_id"`
Quantity int `gorm:"not null" json:"quantity" validate:"required,min=1"`
+ Weight *float64 `gorm:"type:decimal(12,3)" json:"weight,omitempty"`
+ UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id,omitempty"`
UnitPrice float64 `gorm:"type:decimal(10,2);not null" json:"unit_price" validate:"required,min=0"`
TotalPrice float64 `gorm:"type:decimal(10,2);not null" json:"total_price" validate:"required,min=0"`
UnitCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"unit_cost"`
@@ -68,6 +71,7 @@ type OrderItem struct {
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"`
+ Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
}
func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error {
@@ -81,8 +85,57 @@ func (OrderItem) TableName() string {
return "order_items"
}
+// IsWeighed reports whether this line was sold by weight rather than by count.
+func (oi *OrderItem) IsWeighed() bool {
+ return oi.Weight != nil
+}
+
+// BillableQuantity is the multiplier for unit price and unit cost on this line:
+// the weighed amount for weight-based products, the item count otherwise.
+//
+// This is the ONLY place that decides between the two. Every price or cost
+// calculation must go through it — multiplying by Quantity directly would bill a
+// 4.2 ons fish as a single ons.
+//
+// Weighed lines are pinned to quantity = 1 by chk_order_items_weight_single_line,
+// so there is no ambiguity between counting and weighing.
+func (oi *OrderItem) BillableQuantity() float64 {
+ if oi.Weight != nil {
+ return *oi.Weight
+ }
+ return float64(oi.Quantity)
+}
+
+// BillableQuantityFor is BillableQuantity for a partial action on this line, such as
+// voiding or refunding `units` of it.
+//
+// Weighed lines are always acted on in full — quantity is 1, so any void or refund
+// covers the whole weighing — hence the full weight is returned regardless of units.
+func (oi *OrderItem) BillableQuantityFor(units int) float64 {
+ if oi.Weight != nil {
+ return *oi.Weight
+ }
+ return float64(units)
+}
+
+// RoundMoney rounds to the 2 decimals that every monetary column stores, so the
+// value written back always matches what the database keeps.
+func RoundMoney(amount float64) float64 {
+ return math.Round(amount*100) / 100
+}
+
+// RoundWeight rounds to the 3 decimals of order_items.weight, so a validated value
+// is identical to the stored one.
+func RoundWeight(weight float64) float64 {
+ return math.Round(weight*1000) / 1000
+}
+
func (oi *OrderItem) CalculateTotalPrice() {
- oi.TotalPrice = float64(oi.Quantity) * oi.UnitPrice
+ oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
+}
+
+func (oi *OrderItem) CalculateTotalCost() {
+ oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
}
func (oi *OrderItem) CanBeModified() bool {
diff --git a/internal/entities/order_item_test.go b/internal/entities/order_item_test.go
new file mode 100644
index 0000000..0292693
--- /dev/null
+++ b/internal/entities/order_item_test.go
@@ -0,0 +1,104 @@
+package entities
+
+import "testing"
+
+func weightPtr(v float64) *float64 { return &v }
+
+func TestBillableQuantity(t *testing.T) {
+ tests := []struct {
+ name string
+ item OrderItem
+ want float64
+ }{
+ {"unit line counts items", OrderItem{Quantity: 3}, 3},
+ {"weighed line uses the weight", OrderItem{Quantity: 1, Weight: weightPtr(4.2)}, 4.2},
+ {"weight of zero is still a weighed line", OrderItem{Quantity: 1, Weight: weightPtr(0)}, 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.item.BillableQuantity(); got != tt.want {
+ t.Errorf("BillableQuantity() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+// A weighed line is voided or refunded whole, so the units asked for must not shrink
+// the amount: refunding "1" of a 4.2 ons line refunds all 4.2 ons.
+func TestBillableQuantityForIgnoresUnitsOnWeighedLines(t *testing.T) {
+ weighed := OrderItem{Quantity: 1, Weight: weightPtr(5.6)}
+ if got := weighed.BillableQuantityFor(1); got != 5.6 {
+ t.Errorf("weighed BillableQuantityFor(1) = %v, want 5.6", got)
+ }
+
+ counted := OrderItem{Quantity: 3}
+ if got := counted.BillableQuantityFor(2); got != 2 {
+ t.Errorf("unit BillableQuantityFor(2) = %v, want 2", got)
+ }
+}
+
+// The regression this whole feature exists to prevent: a 4.2 ons fish priced per ons
+// must not be billed as a single ons.
+func TestCalculateTotalPriceForWeighedLine(t *testing.T) {
+ item := OrderItem{Quantity: 1, Weight: weightPtr(4.2), UnitPrice: 4500, UnitCost: 3000}
+ item.CalculateTotalPrice()
+ item.CalculateTotalCost()
+
+ if item.TotalPrice != 18900 {
+ t.Errorf("TotalPrice = %v, want 18900", item.TotalPrice)
+ }
+ if item.TotalCost != 12600 {
+ t.Errorf("TotalCost = %v, want 12600", item.TotalCost)
+ }
+}
+
+func TestCalculateTotalPriceForUnitLine(t *testing.T) {
+ item := OrderItem{Quantity: 3, UnitPrice: 25000, UnitCost: 10000}
+ item.CalculateTotalPrice()
+ item.CalculateTotalCost()
+
+ if item.TotalPrice != 75000 {
+ t.Errorf("TotalPrice = %v, want 75000", item.TotalPrice)
+ }
+ if item.TotalCost != 30000 {
+ t.Errorf("TotalCost = %v, want 30000", item.TotalCost)
+ }
+}
+
+// Sub-rupiah residue must not survive into a decimal(10,2) column.
+func TestCalculateTotalPriceRoundsToStoredPrecision(t *testing.T) {
+ item := OrderItem{Quantity: 1, Weight: weightPtr(4.237), UnitPrice: 4500}
+ item.CalculateTotalPrice()
+
+ if item.TotalPrice != 19066.5 {
+ t.Errorf("TotalPrice = %v, want 19066.5", item.TotalPrice)
+ }
+}
+
+func TestIsWeighed(t *testing.T) {
+ if (&OrderItem{Quantity: 1}).IsWeighed() {
+ t.Error("unit line reported as weighed")
+ }
+ if !(&OrderItem{Quantity: 1, Weight: weightPtr(1.5)}).IsWeighed() {
+ t.Error("weighed line reported as unit")
+ }
+}
+
+func TestRoundWeight(t *testing.T) {
+ tests := []struct {
+ in float64
+ want float64
+ }{
+ {4.2, 4.2},
+ {4.2374, 4.237},
+ {4.2375, 4.238},
+ {0.0004, 0},
+ }
+
+ for _, tt := range tests {
+ if got := RoundWeight(tt.in); got != tt.want {
+ t.Errorf("RoundWeight(%v) = %v, want %v", tt.in, got, tt.want)
+ }
+ }
+}
diff --git a/internal/entities/product.go b/internal/entities/product.go
index dc8c524..22d3f6d 100644
--- a/internal/entities/product.go
+++ b/internal/entities/product.go
@@ -20,6 +20,7 @@ type Product struct {
ImageURL *string `gorm:"size:500" json:"image_url"`
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
+ SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
IsActive bool `gorm:"default:true" json:"is_active"`
diff --git a/internal/handler/self_order_handler.go b/internal/handler/self_order_handler.go
index ebbfdcb..1d7c576 100644
--- a/internal/handler/self_order_handler.go
+++ b/internal/handler/self_order_handler.go
@@ -344,6 +344,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
ProductID: item.ProductID,
ProductVariantID: item.ProductVariantID,
Quantity: item.Quantity,
+ Weight: item.Weight,
Notes: item.Notes,
})
}
diff --git a/internal/mappers/order_mapper.go b/internal/mappers/order_mapper.go
index b4c84da..5f68ce2 100644
--- a/internal/mappers/order_mapper.go
+++ b/internal/mappers/order_mapper.go
@@ -115,12 +115,23 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
}
}
+ // Unit is only meaningful for weighed lines, but carry it whenever it was captured.
+ var unitName, unitAbbreviation *string
+ if item.Unit != nil {
+ unitName = &item.Unit.Name
+ unitAbbreviation = item.Unit.Abbreviation
+ }
+
response := &models.OrderItemResponse{
ID: item.ID,
OrderID: item.OrderID,
ProductID: item.ProductID,
ProductVariantID: item.ProductVariantID,
Quantity: item.Quantity,
+ Weight: item.Weight,
+ UnitID: item.UnitID,
+ UnitName: unitName,
+ UnitAbbreviation: unitAbbreviation,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
UnitCost: item.UnitCost,
diff --git a/internal/mappers/product_mapper.go b/internal/mappers/product_mapper.go
index e91c3ef..c9d1aa4 100644
--- a/internal/mappers/product_mapper.go
+++ b/internal/mappers/product_mapper.go
@@ -25,6 +25,9 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
+ UnitID: entity.UnitID,
+ SellBy: entity.SellBy,
+ HasIngredients: entity.HasIngredients,
Metadata: map[string]interface{}(entity.Metadata),
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
@@ -49,6 +52,9 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
BusinessType: string(model.BusinessType),
ImageURL: model.ImageURL,
PrinterType: model.PrinterType,
+ UnitID: model.UnitID,
+ SellBy: model.SellBy,
+ HasIngredients: model.HasIngredients,
Metadata: entities.Metadata(model.Metadata),
IsActive: model.IsActive,
CreatedAt: model.CreatedAt,
@@ -76,6 +82,11 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
printerType = *req.PrinterType
}
+ sellBy := constants.SellByUnit
+ if constants.IsValidSellBy(req.SellBy) {
+ sellBy = req.SellBy
+ }
+
metadata := entities.Metadata{}
if req.Metadata != nil {
metadata = entities.Metadata(req.Metadata)
@@ -92,6 +103,8 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
BusinessType: businessType,
ImageURL: req.ImageURL,
PrinterType: printerType,
+ UnitID: req.UnitID,
+ SellBy: sellBy,
Metadata: metadata,
IsActive: true, // Default to active
}
@@ -140,6 +153,8 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
+ UnitID: entity.UnitID,
+ SellBy: entity.SellBy,
Metadata: map[string]interface{}(entity.Metadata),
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
@@ -185,6 +200,14 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
entity.PrinterType = *req.PrinterType
}
+ if req.UnitID != nil {
+ entity.UnitID = req.UnitID
+ }
+
+ if req.SellBy != nil && constants.IsValidSellBy(*req.SellBy) {
+ entity.SellBy = *req.SellBy
+ }
+
if req.Metadata != nil {
if entity.Metadata == nil {
entity.Metadata = make(entities.Metadata)
diff --git a/internal/models/analytics.go b/internal/models/analytics.go
index d3aaa07..109a65b 100644
--- a/internal/models/analytics.go
+++ b/internal/models/analytics.go
@@ -233,6 +233,7 @@ type ProductAnalyticsData struct {
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"`
+ WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
@@ -359,6 +360,7 @@ type ParentCategoryAnalyticsProductData struct {
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
QuantitySold int64 `json:"quantity_sold"`
+ WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
diff --git a/internal/models/order.go b/internal/models/order.go
index 313c496..d18e2a9 100644
--- a/internal/models/order.go
+++ b/internal/models/order.go
@@ -44,6 +44,8 @@ type OrderItem struct {
ProductID uuid.UUID
ProductVariantID *uuid.UUID
Quantity int
+ Weight *float64
+ UnitID *uuid.UUID
UnitPrice float64
TotalPrice float64
UnitCost float64
@@ -99,7 +101,8 @@ type CreateOrderItemRequest struct {
ProductID uuid.UUID `validate:"required"`
ProductVariantID *uuid.UUID `validate:"omitempty"`
Quantity int `validate:"required,min=1"`
- UnitPrice *float64 `validate:"omitempty,min=0"` // Optional, will use database price if not provided
+ Weight *float64 `validate:"omitempty,gt=0"`
+ UnitPrice *float64 `validate:"omitempty,min=0"` // Ignored: price always comes from the database
Modifiers []map[string]interface{} `validate:"omitempty"`
Notes *string `validate:"omitempty,max=500"`
Metadata map[string]interface{} `validate:"omitempty"`
@@ -191,6 +194,10 @@ type OrderItemResponse struct {
CategoryID *uuid.UUID
CategoryName *string
Quantity int
+ Weight *float64
+ UnitID *uuid.UUID
+ UnitName *string
+ UnitAbbreviation *string
UnitPrice float64
TotalPrice float64
UnitCost float64
diff --git a/internal/models/product.go b/internal/models/product.go
index 0e0b6a1..1d2142b 100644
--- a/internal/models/product.go
+++ b/internal/models/product.go
@@ -19,6 +19,7 @@ type Product struct {
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
+ SellBy string
UnitID *uuid.UUID
HasIngredients bool
Metadata map[string]interface{}
@@ -52,6 +53,7 @@ type CreateProductRequest struct {
PrinterType *string `validate:"omitempty,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"`
+ SellBy string `validate:"omitempty,oneof=unit weight"`
HasIngredients bool `validate:"omitempty"`
Metadata map[string]interface{}
Variants []CreateProductVariantRequest `validate:"omitempty,dive"`
@@ -73,6 +75,7 @@ type UpdateProductRequest struct {
PrinterType *string `validate:"omitempty,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"`
+ SellBy *string `validate:"omitempty,oneof=unit weight"`
HasIngredients *bool `validate:"omitempty"`
Metadata map[string]interface{}
IsActive *bool
@@ -110,6 +113,7 @@ type ProductResponse struct {
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
+ SellBy string
PrintToChecker bool
UnitID *uuid.UUID
HasIngredients bool
diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go
index 37ad289..1297e90 100644
--- a/internal/processor/analytics_processor.go
+++ b/internal/processor/analytics_processor.go
@@ -322,6 +322,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
CategoryName: data.CategoryName,
CategoryOrder: data.CategoryOrder,
QuantitySold: data.QuantitySold,
+ WeightSold: data.WeightSold,
Revenue: data.Revenue,
AveragePrice: data.AveragePrice,
OrderCount: data.OrderCount,
@@ -447,6 +448,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
ProductSku: product.ProductSku,
ProductPrice: product.ProductPrice,
QuantitySold: product.QuantitySold,
+ WeightSold: product.WeightSold,
Revenue: product.Revenue,
AveragePrice: product.AveragePrice,
OrderCount: product.OrderCount,
diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go
index 6065005..3e14e23 100644
--- a/internal/processor/order_processor.go
+++ b/internal/processor/order_processor.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
+ "apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
@@ -149,6 +150,33 @@ func NewOrderProcessorImpl(
}
}
+// resolveLineQuantity reconciles the requested quantity and weight against how the
+// product is sold, returning the values to store on the order line.
+//
+// For weight-based products a line is exactly one weighing, so quantity is pinned to 1
+// and the weighed amount is what gets priced. This mirrors
+// chk_order_items_weight_single_line in the database.
+func resolveLineQuantity(product *entities.Product, quantity int, weight *float64) (int, *float64, error) {
+ if product.SellBy == constants.SellByWeight {
+ if weight == nil {
+ return 0, nil, fmt.Errorf("product %s is sold by weight and requires a weight", product.Name)
+ }
+ rounded := entities.RoundWeight(*weight)
+ if rounded <= 0 {
+ return 0, nil, fmt.Errorf("weight for product %s must be greater than 0", product.Name)
+ }
+ return 1, &rounded, nil
+ }
+
+ if weight != nil {
+ return 0, nil, fmt.Errorf("product %s is not sold by weight and must not carry a weight", product.Name)
+ }
+ if quantity < 1 {
+ return 0, nil, fmt.Errorf("quantity for product %s must be at least 1", product.Name)
+ }
+ return quantity, nil, nil
+}
+
func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
if err != nil {
@@ -194,25 +222,29 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
}
}
- itemTotalPrice := float64(itemReq.Quantity) * unitPrice
- itemTotalCost := float64(itemReq.Quantity) * unitCost
-
- subtotal += itemTotalPrice
- totalCost += itemTotalCost
+ quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
+ if err != nil {
+ return nil, err
+ }
orderItem := &entities.OrderItem{
ProductID: itemReq.ProductID,
ProductVariantID: itemReq.ProductVariantID,
- Quantity: itemReq.Quantity,
+ Quantity: quantity,
+ Weight: weight,
+ UnitID: product.UnitID,
UnitPrice: unitPrice,
- TotalPrice: itemTotalPrice,
UnitCost: unitCost,
- TotalCost: itemTotalCost,
Modifiers: entities.Modifiers(itemReq.Modifiers),
Notes: itemReq.Notes,
Metadata: entities.Metadata(itemReq.Metadata),
Status: entities.OrderItemStatusPending,
}
+ orderItem.CalculateTotalPrice()
+ orderItem.CalculateTotalCost()
+
+ subtotal += orderItem.TotalPrice
+ totalCost += orderItem.TotalCost
orderItems = append(orderItems, orderItem)
}
@@ -327,26 +359,30 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
}
}
- itemTotalPrice := float64(itemReq.Quantity) * unitPrice
- itemTotalCost := float64(itemReq.Quantity) * unitCost
-
- newSubtotal += itemTotalPrice
- newTotalCost += itemTotalCost
+ quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
+ if err != nil {
+ return nil, err
+ }
orderItem := &entities.OrderItem{
OrderID: orderID,
ProductID: itemReq.ProductID,
ProductVariantID: itemReq.ProductVariantID,
- Quantity: itemReq.Quantity,
+ Quantity: quantity,
+ Weight: weight,
+ UnitID: product.UnitID,
UnitPrice: unitPrice,
- TotalPrice: itemTotalPrice,
UnitCost: unitCost,
- TotalCost: itemTotalCost,
Modifiers: entities.Modifiers(itemReq.Modifiers),
Notes: itemReq.Notes,
Metadata: entities.Metadata(itemReq.Metadata),
Status: entities.OrderItemStatusPending,
}
+ orderItem.CalculateTotalPrice()
+ orderItem.CalculateTotalCost()
+
+ newSubtotal += orderItem.TotalPrice
+ newTotalCost += orderItem.TotalCost
addedOrderItems = append(addedOrderItems, orderItem)
}
@@ -602,8 +638,9 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
}
- voidedAmount := float64(itemVoid.Quantity) * orderItem.UnitPrice
- voidedCost := float64(itemVoid.Quantity) * orderItem.UnitCost
+ billable := orderItem.BillableQuantityFor(itemVoid.Quantity)
+ voidedAmount := entities.RoundMoney(billable * orderItem.UnitPrice)
+ voidedCost := entities.RoundMoney(billable * orderItem.UnitCost)
totalVoidedAmount += voidedAmount
totalVoidedCost += voidedCost
@@ -720,7 +757,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
return fmt.Errorf("refund quantity cannot exceed original quantity")
}
- refundAmount := float64(refundQuantity) * orderItem.UnitPrice
+ refundAmount := entities.RoundMoney(orderItem.BillableQuantityFor(refundQuantity) * orderItem.UnitPrice)
if itemRefund.RefundAmount != nil {
refundAmount = *itemRefund.RefundAmount
}
@@ -1016,7 +1053,7 @@ func (p *OrderProcessorImpl) createInventoryMovement(ctx context.Context, item *
PreviousQuantity: float64(updatedInventory.Quantity + item.Quantity), // Add back the quantity that was subtracted
NewQuantity: float64(updatedInventory.Quantity),
UnitCost: item.UnitCost,
- TotalCost: float64(item.Quantity) * item.UnitCost,
+ TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypePayment
return &t
@@ -1314,7 +1351,7 @@ func (p *OrderProcessorImpl) prepareProductInventoryMovement(item *entities.Orde
PreviousQuantity: float64(previousQuantity),
NewQuantity: float64(updatedInventory.Quantity),
UnitCost: item.UnitCost,
- TotalCost: float64(item.Quantity) * item.UnitCost,
+ TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypePayment
return &t
@@ -1380,7 +1417,7 @@ type ingredientRecipeItem struct {
// prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls
func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeItem, error) {
- totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
+ totalIngredientQuantity := recipe.Quantity * item.BillableQuantity()
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
if err != nil {
@@ -1478,7 +1515,7 @@ func (p *OrderProcessorImpl) prepareRefundProductInventoryMovement(item *entitie
PreviousQuantity: float64(previousQuantity),
NewQuantity: float64(updatedInventory.Quantity),
UnitCost: item.UnitCost,
- TotalCost: float64(refundedQuantity) * item.UnitCost,
+ TotalCost: entities.RoundMoney(item.BillableQuantityFor(refundedQuantity) * item.UnitCost),
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypeRefund
return &t
@@ -1544,7 +1581,7 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeData(ctx context.Con
// prepareRefundedIngredientRecipeItem prepares data for a single ingredient recipe restoration
func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) (*ingredientRecipeItem, error) {
// Calculate total ingredient quantity needed based on order item quantity
- totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
+ totalIngredientQuantity := recipe.Quantity * item.BillableQuantity()
// Get current ingredient details
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
diff --git a/internal/processor/order_weight_test.go b/internal/processor/order_weight_test.go
new file mode 100644
index 0000000..1558707
--- /dev/null
+++ b/internal/processor/order_weight_test.go
@@ -0,0 +1,106 @@
+package processor
+
+import (
+ "testing"
+
+ "apskel-pos-be/internal/constants"
+ "apskel-pos-be/internal/entities"
+)
+
+func fPtr(v float64) *float64 { return &v }
+
+func TestResolveLineQuantity(t *testing.T) {
+ weightProduct := &entities.Product{Name: "Ikan Tude", SellBy: constants.SellByWeight}
+ unitProduct := &entities.Product{Name: "Nasi Goreng", SellBy: constants.SellByUnit}
+
+ tests := []struct {
+ name string
+ product *entities.Product
+ quantity int
+ weight *float64
+ wantQuantity int
+ wantWeight *float64
+ wantErr bool
+ }{
+ {
+ name: "weighed line is pinned to quantity 1",
+ // Mirrors chk_order_items_weight_single_line: even if a client sends 3,
+ // one weighing is one line.
+ product: weightProduct, quantity: 3, weight: fPtr(4.2),
+ wantQuantity: 1, wantWeight: fPtr(4.2),
+ },
+ {
+ name: "weight is rounded to stored precision",
+ product: weightProduct, quantity: 1, weight: fPtr(5.6004),
+ wantQuantity: 1, wantWeight: fPtr(5.6),
+ },
+ {
+ name: "weight product without a weight is rejected",
+ product: weightProduct, quantity: 1, weight: nil,
+ wantErr: true,
+ },
+ {
+ name: "weight that rounds away to zero is rejected",
+ product: weightProduct, quantity: 1, weight: fPtr(0.0004),
+ wantErr: true,
+ },
+ {
+ name: "unit line keeps its quantity and carries no weight",
+ product: unitProduct, quantity: 3, weight: nil,
+ wantQuantity: 3, wantWeight: nil,
+ },
+ {
+ name: "weight on a unit product is rejected",
+ // Otherwise the line would silently be priced by weight.
+ product: unitProduct, quantity: 1, weight: fPtr(4.2),
+ wantErr: true,
+ },
+ {
+ name: "non-positive quantity on a unit product is rejected",
+ product: unitProduct, quantity: 0, weight: nil,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotQty, gotWeight, err := resolveLineQuantity(tt.product, tt.quantity, tt.weight)
+
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("expected an error, got quantity=%v weight=%v", gotQty, gotWeight)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if gotQty != tt.wantQuantity {
+ t.Errorf("quantity = %v, want %v", gotQty, tt.wantQuantity)
+ }
+ switch {
+ case tt.wantWeight == nil && gotWeight != nil:
+ t.Errorf("weight = %v, want nil", *gotWeight)
+ case tt.wantWeight != nil && gotWeight == nil:
+ t.Errorf("weight = nil, want %v", *tt.wantWeight)
+ case tt.wantWeight != nil && *gotWeight != *tt.wantWeight:
+ t.Errorf("weight = %v, want %v", *gotWeight, *tt.wantWeight)
+ }
+ })
+ }
+}
+
+// A product created before this feature has sell_by defaulted to 'unit' by the
+// migration, but a zero-valued struct must behave the same way.
+func TestResolveLineQuantityTreatsEmptySellByAsUnit(t *testing.T) {
+ legacy := &entities.Product{Name: "Legacy"}
+
+ quantity, weight, err := resolveLineQuantity(legacy, 2, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if quantity != 2 || weight != nil {
+ t.Errorf("got quantity=%v weight=%v, want quantity=2 weight=nil", quantity, weight)
+ }
+}
diff --git a/internal/processor/product_processor.go b/internal/processor/product_processor.go
index f778833..e08ea7c 100644
--- a/internal/processor/product_processor.go
+++ b/internal/processor/product_processor.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
+ "apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers"
@@ -191,6 +192,13 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
mappers.UpdateProductEntityFromRequest(existingProduct, req)
+ // Checked after the merge, not on the request: switching a product to sell_by
+ // "weight" is valid when it already carries a unit, and clearing the unit is
+ // invalid when it is already sold by weight. Only the merged product shows either.
+ if existingProduct.SellBy == constants.SellByWeight && existingProduct.UnitID == nil {
+ return nil, fmt.Errorf("product '%s' is sold by weight and requires a unit_id", existingProduct.Name)
+ }
+
if err := p.productRepo.Update(ctx, existingProduct); err != nil {
return nil, fmt.Errorf("failed to update product: %w", err)
}
diff --git a/internal/processor/split_bill_processor.go b/internal/processor/split_bill_processor.go
index 5559125..d527a89 100644
--- a/internal/processor/split_bill_processor.go
+++ b/internal/processor/split_bill_processor.go
@@ -140,7 +140,7 @@ func calculateSplitAmounts(req *models.SplitBillRequest, validation *SplitBillVa
for _, item := range req.Items {
orderItem := validation.OrderItems[item.OrderItemID]
- itemAmount := float64(item.Quantity) * orderItem.UnitPrice
+ itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
totalItemAmount := itemAmount + itemTaxAmount
@@ -186,7 +186,7 @@ func (p *SplitBillProcessorImpl) createSplitPayment(ctx context.Context, req *mo
func (p *SplitBillProcessorImpl) createPaymentOrderItems(ctx context.Context, splitPayment *entities.Payment, req *models.SplitBillRequest, validation *SplitBillValidation) error {
for _, item := range req.Items {
orderItem := validation.OrderItems[item.OrderItemID]
- itemAmount := float64(item.Quantity) * orderItem.UnitPrice
+ itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
totalItemAmount := itemAmount + itemTaxAmount
@@ -228,7 +228,7 @@ func (p *SplitBillProcessorImpl) updateOrderAfterSplit(ctx context.Context, orde
quantityInfo[item.OrderItemID.String()] = map[string]interface{}{
"quantity": item.Quantity,
"unit_price": orderItem.UnitPrice,
- "total_amount": float64(item.Quantity) * orderItem.UnitPrice,
+ "total_amount": entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice),
}
}
order.Metadata[MetadataKeyLastSplitQuantities] = quantityInfo
diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go
index 9d83d16..fe23ebc 100644
--- a/internal/repository/analytics_repository.go
+++ b/internal/repository/analytics_repository.go
@@ -461,6 +461,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
c.name as category_name,
c.order as category_order,
COALESCE(SUM(oi.quantity), 0) as quantity_sold,
+ COALESCE(SUM(oi.weight), 0) as weight_sold,
COALESCE(SUM(oi.total_price), 0) as revenue,
CASE
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity)
@@ -1167,9 +1168,9 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C
}
type purchasingTotals struct {
- Total float64
- RawMaterial float64
- Expense float64
+ Total float64
+ RawMaterial float64
+ Expense float64
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
diff --git a/internal/repository/order_item_repository.go b/internal/repository/order_item_repository.go
index d983b6b..6f31acc 100644
--- a/internal/repository/order_item_repository.go
+++ b/internal/repository/order_item_repository.go
@@ -50,6 +50,7 @@ func (r *OrderItemRepositoryImpl) GetByOrderID(ctx context.Context, orderID uuid
err := r.db.WithContext(ctx).
Preload("Product").
Preload("ProductVariant").
+ Preload("Unit").
Where("order_id = ?", orderID).
Find(&orderItems).Error
return orderItems, err
@@ -109,8 +110,12 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
return err
}
- if voidQuantity >= orderItem.Quantity {
- voidedAmount := float64(voidQuantity) * orderItem.UnitPrice
+ // A weighed line is one weighing, so it is voided whole — never split into a
+ // remainder row. chk_order_items_weight_single_line pins its quantity to 1, which
+ // already sends it down this branch; the explicit check keeps that guarantee
+ // visible and independent of the comparison below.
+ if orderItem.IsWeighed() || voidQuantity >= orderItem.Quantity {
+ voidedAmount := entities.RoundMoney(orderItem.BillableQuantityFor(voidQuantity) * orderItem.UnitPrice)
updates := map[string]interface{}{
"refund_quantity": voidQuantity,
@@ -128,16 +133,18 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
Updates(updates).Error
}
+ // Only unit-priced lines reach here: weighed lines were voided whole above.
voidedOrderItem := entities.OrderItem{
OrderID: orderItem.OrderID,
ProductID: orderItem.ProductID,
ProductVariantID: orderItem.ProductVariantID,
Quantity: voidQuantity,
+ UnitID: orderItem.UnitID,
UnitPrice: orderItem.UnitPrice,
- TotalPrice: float64(voidQuantity) * orderItem.UnitPrice,
+ TotalPrice: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
UnitCost: orderItem.UnitCost,
- TotalCost: float64(voidQuantity) * orderItem.UnitCost,
- RefundAmount: float64(voidQuantity) * orderItem.UnitPrice,
+ TotalCost: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitCost),
+ RefundAmount: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
RefundQuantity: voidQuantity,
IsPartiallyRefunded: false,
IsFullyRefunded: true,
@@ -155,8 +162,8 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
}
remainingQuantity := orderItem.Quantity - voidQuantity
- remainingTotalPrice := float64(remainingQuantity) * orderItem.UnitPrice
- remainingTotalCost := float64(remainingQuantity) * orderItem.UnitCost
+ remainingTotalPrice := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitPrice)
+ remainingTotalCost := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitCost)
updates := map[string]interface{}{
"quantity": remainingQuantity,
diff --git a/internal/repository/order_repository.go b/internal/repository/order_repository.go
index 9d13a48..341ce14 100644
--- a/internal/repository/order_repository.go
+++ b/internal/repository/order_repository.go
@@ -63,6 +63,7 @@ func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID
Preload("OrderItems.Product.Category").
Preload("OrderItems.Product.ProductOutletPrices").
Preload("OrderItems.ProductVariant").
+ Preload("OrderItems.Unit").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems").
@@ -144,6 +145,7 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter
Preload("OrderItems.Product.Category").
Preload("OrderItems.Product.ProductOutletPrices").
Preload("OrderItems.ProductVariant").
+ Preload("OrderItems.Unit").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems").
@@ -162,6 +164,7 @@ func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID str
Preload("OrderItems.Product.Category").
Preload("OrderItems.Product.ProductOutletPrices").
Preload("OrderItems.ProductVariant").
+ Preload("OrderItems.Unit").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems").
diff --git a/internal/service/report_service.go b/internal/service/report_service.go
index 7c113ff..8ba124f 100644
--- a/internal/service/report_service.go
+++ b/internal/service/report_service.go
@@ -64,6 +64,7 @@ type reportSummary struct {
type reportItem struct {
Name string
Quantity int64
+ Weight string
GrossSales string
Discount string
NetSales string
@@ -161,6 +162,7 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org
items = append(items, reportItem{
Name: p.ProductName,
Quantity: p.QuantitySold,
+ Weight: formatWeight(p.WeightSold),
GrossSales: formatCurrency(p.Revenue),
Discount: formatCurrency(0),
NetSales: formatCurrency(p.Revenue),
@@ -489,6 +491,16 @@ func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLoss
return result
}
+// formatWeight renders the weight sold for a product, or a dash when the product is
+// not sold by weight. Trailing zeros are trimmed so 4.200 prints as "4,2".
+func formatWeight(weight float64) string {
+ if weight <= 0 {
+ return "-"
+ }
+ s := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.3f", weight), "0"), ".")
+ return strings.Replace(s, ".", ",", 1)
+}
+
func formatCurrencyOrDash(amount float64) string {
if amount == 0 {
return "-"
diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go
index d9214eb..8590c18 100644
--- a/internal/transformer/analytics_transformer.go
+++ b/internal/transformer/analytics_transformer.go
@@ -291,6 +291,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
CategoryName: item.CategoryName,
CategoryOrder: item.CategoryOrder,
QuantitySold: item.QuantitySold,
+ WeightSold: item.WeightSold,
Revenue: item.Revenue,
AveragePrice: item.AveragePrice,
OrderCount: item.OrderCount,
@@ -506,6 +507,7 @@ func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAna
ProductSku: product.ProductSku,
ProductPrice: product.ProductPrice,
QuantitySold: product.QuantitySold,
+ WeightSold: product.WeightSold,
Revenue: product.Revenue,
AveragePrice: product.AveragePrice,
OrderCount: product.OrderCount,
@@ -592,6 +594,7 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
CategoryID: item.CategoryID,
CategoryName: item.CategoryName,
QuantitySold: item.QuantitySold,
+ WeightSold: item.WeightSold,
Revenue: item.Revenue,
AveragePrice: item.AveragePrice,
OrderCount: item.OrderCount,
diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go
index 88f88d6..92b8ab8 100644
--- a/internal/transformer/order_transformer.go
+++ b/internal/transformer/order_transformer.go
@@ -103,6 +103,10 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse {
CategoryID: item.CategoryID,
CategoryName: item.CategoryName,
Quantity: item.Quantity,
+ Weight: item.Weight,
+ UnitID: item.UnitID,
+ UnitName: item.UnitName,
+ UnitAbbreviation: item.UnitAbbreviation,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
Modifiers: item.Modifiers,
@@ -174,6 +178,10 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO
CategoryID: item.CategoryID,
CategoryName: item.CategoryName,
Quantity: item.Quantity,
+ Weight: item.Weight,
+ UnitID: item.UnitID,
+ UnitName: item.UnitName,
+ UnitAbbreviation: item.UnitAbbreviation,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
Modifiers: item.Modifiers,
diff --git a/internal/transformer/product_transformer.go b/internal/transformer/product_transformer.go
index e6f0321..0a628f6 100644
--- a/internal/transformer/product_transformer.go
+++ b/internal/transformer/product_transformer.go
@@ -20,6 +20,11 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
businessType = constants.BusinessType(*req.BusinessType)
}
+ sellBy := constants.SellByUnit
+ if req.SellBy != nil {
+ sellBy = *req.SellBy
+ }
+
var variants []models.CreateProductVariantRequest
if req.Variants != nil {
variants = make([]models.CreateProductVariantRequest, len(req.Variants))
@@ -58,6 +63,8 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
ImageURL: req.ImageURL,
PrinterType: req.PrinterType,
PrintToChecker: req.PrintToChecker,
+ UnitID: req.UnitID,
+ SellBy: sellBy,
Metadata: metadata,
Variants: variants,
}
@@ -86,6 +93,8 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up
ImageURL: req.ImageURL,
PrinterType: req.PrinterType,
PrintToChecker: req.PrintToChecker,
+ UnitID: req.UnitID,
+ SellBy: req.SellBy,
Metadata: metadata,
IsActive: req.IsActive,
}
@@ -145,6 +154,8 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
ImageURL: prod.ImageURL,
PrinterType: prod.PrinterType,
PrintToChecker: prod.PrintToChecker,
+ UnitID: prod.UnitID,
+ SellBy: prod.SellBy,
Metadata: prod.Metadata,
IsActive: prod.IsActive,
CreatedAt: prod.CreatedAt,
diff --git a/internal/validator/product_validator.go b/internal/validator/product_validator.go
index 82d4e22..a5943bb 100644
--- a/internal/validator/product_validator.go
+++ b/internal/validator/product_validator.go
@@ -63,6 +63,32 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
}
+ if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil {
+ return err, code
+ }
+
+ return nil, ""
+}
+
+// validateSellBy checks how a product is sold and that it carries what that choice
+// needs. A weight-based product without a unit would produce order lines with no unit
+// to print, so the receipt could show "4,2" with no idea of what.
+//
+// unitID is the unit the request would leave on the product: for an update that does
+// not touch unit_id, pass the product's current one.
+func validateSellBy(sellBy *string, unitID *uuid.UUID) (error, string) {
+ if sellBy == nil {
+ return nil, ""
+ }
+
+ if !constants.IsValidSellBy(*sellBy) {
+ return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode
+ }
+
+ if *sellBy == constants.SellByWeight && unitID == nil {
+ return errors.New("unit_id is required when sell_by is 'weight'"), constants.MissingFieldErrorCode
+ }
+
return nil, ""
}
@@ -74,7 +100,8 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
// At least one field should be provided for update
if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil &&
req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil &&
- req.PrinterType == nil && req.Metadata == nil && req.IsActive == nil {
+ req.PrinterType == nil && req.PrintToChecker == nil && req.UnitID == nil &&
+ req.SellBy == nil && req.Metadata == nil && req.IsActive == nil {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
}
@@ -111,6 +138,13 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
}
+ // Only the value is checked here. Whether the product ends up with a unit depends on
+ // what it already has, which this request cannot see — the processor checks that
+ // against the stored product.
+ if req.SellBy != nil && !constants.IsValidSellBy(*req.SellBy) {
+ return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode
+ }
+
return nil, ""
}
diff --git a/internal/validator/product_validator_sell_by_test.go b/internal/validator/product_validator_sell_by_test.go
new file mode 100644
index 0000000..6a6dd56
--- /dev/null
+++ b/internal/validator/product_validator_sell_by_test.go
@@ -0,0 +1,130 @@
+package validator
+
+import (
+ "testing"
+
+ "apskel-pos-be/internal/constants"
+ "apskel-pos-be/internal/contract"
+
+ "github.com/google/uuid"
+)
+
+func strPtr(s string) *string { return &s }
+
+func baseCreateRequest() *contract.CreateProductRequest {
+ return &contract.CreateProductRequest{
+ CategoryID: uuid.New(),
+ Name: "Ikan Tude",
+ Price: 4500,
+ }
+}
+
+func TestValidateCreateProductRequestSellBy(t *testing.T) {
+ unitID := uuid.New()
+
+ tests := []struct {
+ name string
+ sellBy *string
+ unitID *uuid.UUID
+ wantErr bool
+ wantMsg string
+ }{
+ {
+ name: "omitted sell_by is allowed and defaults to unit",
+ sellBy: nil, unitID: nil,
+ },
+ {
+ name: "unit product needs no unit_id",
+ sellBy: strPtr(constants.SellByUnit), unitID: nil,
+ },
+ {
+ name: "weight product with a unit is accepted",
+ sellBy: strPtr(constants.SellByWeight), unitID: &unitID,
+ },
+ {
+ name: "weight product without a unit is rejected",
+ // Otherwise its order lines would have no unit to print on the receipt.
+ sellBy: strPtr(constants.SellByWeight), unitID: nil,
+ wantErr: true,
+ wantMsg: "unit_id is required when sell_by is 'weight'",
+ },
+ {
+ name: "unknown sell_by is rejected rather than silently corrected",
+ // The struct tags on this contract are not enforced — this validator is
+ // hand-written — so the check has to be explicit.
+ sellBy: strPtr("pisang"), unitID: &unitID,
+ wantErr: true,
+ wantMsg: "sell_by must be either 'unit' or 'weight'",
+ },
+ }
+
+ v := NewProductValidator()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := baseCreateRequest()
+ req.SellBy = tt.sellBy
+ req.UnitID = tt.unitID
+
+ err, code := v.ValidateCreateProductRequest(req)
+
+ if !tt.wantErr {
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatal("expected an error, got none")
+ }
+ if err.Error() != tt.wantMsg {
+ t.Errorf("message = %q, want %q", err.Error(), tt.wantMsg)
+ }
+ if code == "" {
+ t.Error("expected an error code")
+ }
+ })
+ }
+}
+
+// An update carrying only sell_by must not be turned away as an empty update.
+func TestValidateUpdateProductRequestAcceptsSellByAlone(t *testing.T) {
+ v := NewProductValidator()
+
+ err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{
+ SellBy: strPtr(constants.SellByWeight),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ err, _ = v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{})
+ if err == nil {
+ t.Error("an update with no fields at all should be rejected")
+ }
+}
+
+func TestValidateUpdateProductRequestRejectsUnknownSellBy(t *testing.T) {
+ v := NewProductValidator()
+
+ err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{
+ SellBy: strPtr("timbangan"),
+ })
+ if err == nil {
+ t.Fatal("expected an error for an unknown sell_by")
+ }
+}
+
+// The update path deliberately does NOT require unit_id on the request: a product that
+// already has a unit can be switched to sell_by "weight" without resending it. That
+// pairing is checked by the processor against the stored product.
+func TestValidateUpdateProductRequestDefersUnitCheck(t *testing.T) {
+ v := NewProductValidator()
+
+ err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{
+ SellBy: strPtr(constants.SellByWeight),
+ })
+ if err != nil {
+ t.Fatalf("update should not require unit_id on the request, got: %v", err)
+ }
+}
diff --git a/migrations/000089_add_weight_to_order_items.down.sql b/migrations/000089_add_weight_to_order_items.down.sql
new file mode 100644
index 0000000..50803d6
--- /dev/null
+++ b/migrations/000089_add_weight_to_order_items.down.sql
@@ -0,0 +1,15 @@
+DROP INDEX IF EXISTS idx_order_items_unit_id;
+
+ALTER TABLE order_items
+ DROP CONSTRAINT IF EXISTS chk_order_items_weight_single_line,
+ DROP CONSTRAINT IF EXISTS chk_order_items_weight_positive;
+
+ALTER TABLE order_items
+ DROP COLUMN IF EXISTS unit_id,
+ DROP COLUMN IF EXISTS weight;
+
+ALTER TABLE products
+ DROP CONSTRAINT IF EXISTS chk_products_sell_by;
+
+ALTER TABLE products
+ DROP COLUMN IF EXISTS sell_by;
diff --git a/migrations/000089_add_weight_to_order_items.up.sql b/migrations/000089_add_weight_to_order_items.up.sql
new file mode 100644
index 0000000..00157a2
--- /dev/null
+++ b/migrations/000089_add_weight_to_order_items.up.sql
@@ -0,0 +1,30 @@
+-- Weight-based products (e.g. fish sold per ons/kg).
+-- One weighing = one order_items row: quantity stays 1, the weight goes in `weight`.
+
+ALTER TABLE products
+ ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit';
+
+ALTER TABLE products
+ ADD CONSTRAINT chk_products_sell_by
+ CHECK (sell_by IN ('unit', 'weight'));
+
+ALTER TABLE order_items
+ ADD COLUMN weight DECIMAL(12,3),
+ ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT;
+
+-- A weighed line always carries a positive weight...
+ALTER TABLE order_items
+ ADD CONSTRAINT chk_order_items_weight_positive
+ CHECK (weight IS NULL OR weight > 0);
+
+-- ...and always represents exactly one weighing, so its quantity is pinned to 1.
+-- This is what makes billable quantity unambiguous and keeps void all-or-nothing.
+ALTER TABLE order_items
+ ADD CONSTRAINT chk_order_items_weight_single_line
+ CHECK (weight IS NULL OR quantity = 1);
+
+CREATE INDEX idx_order_items_unit_id ON order_items(unit_id);
+
+COMMENT ON COLUMN products.sell_by IS 'How the product is sold: unit (discrete count) or weight (weighed per transaction)';
+COMMENT ON COLUMN order_items.weight IS 'Weighed amount in unit_id units; NULL for unit-priced products. Price is weight * unit_price.';
+COMMENT ON COLUMN order_items.unit_id IS 'Snapshot of the product unit at sale time, so historical lines keep their meaning';
diff --git a/templates/daily_transaction.html b/templates/daily_transaction.html
index b9e788e..ed43d76 100644
--- a/templates/daily_transaction.html
+++ b/templates/daily_transaction.html
@@ -524,6 +524,7 @@
No. |
Nama Item |
Qty |
+ Berat |
Penjualan Kotor |
Diskon |
Penjualan Bersih |
@@ -537,6 +538,7 @@
{{add $i 1}} |
{{$item.Name}} |
{{$item.Quantity}} |
+ {{$item.Weight}} |
{{$item.GrossSales}} |
{{$item.Discount}} |
{{$item.NetSales}} |