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..57f92ba 100644 --- a/internal/mappers/product_mapper.go +++ b/internal/mappers/product_mapper.go @@ -25,6 +25,7 @@ func ProductEntityToModel(entity *entities.Product) *models.Product { BusinessType: constants.BusinessType(entity.BusinessType), ImageURL: entity.ImageURL, PrinterType: entity.PrinterType, + SellBy: entity.SellBy, Metadata: map[string]interface{}(entity.Metadata), IsActive: entity.IsActive, CreatedAt: entity.CreatedAt, @@ -76,6 +77,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 +98,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 +148,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 +195,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/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/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}}