Merge pull request 'Feature/weight based products' (#29) from feature/weight-based-products into main
Reviewed-on: https://gits.altru.id/apksel-dev/apskel-pos-backend/pulls/29
This commit was merged in pull request #29.
This commit is contained in:
@@ -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`<br>`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": "<id satuan Ons>"
|
||||
}
|
||||
```
|
||||
|
||||
| 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": "<ikan-tude>", "quantity": 1, "weight": 4.2 },
|
||||
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 5.6 },
|
||||
{ "product_id": "<nasi-goreng>", "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": "<baris 4,2 ons>", "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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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 "-"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -524,6 +524,7 @@
|
||||
<th>No.</th>
|
||||
<th>Nama Item</th>
|
||||
<th class="align-center">Qty</th>
|
||||
<th class="align-center">Berat</th>
|
||||
<th class="align-right">Penjualan Kotor</th>
|
||||
<th class="align-right">Diskon</th>
|
||||
<th class="align-right">Penjualan Bersih</th>
|
||||
@@ -537,6 +538,7 @@
|
||||
<td class="align-center number-cell">{{add $i 1}}</td>
|
||||
<td><span class="item-name">{{$item.Name}}</span></td>
|
||||
<td class="align-center number-cell">{{$item.Quantity}}</td>
|
||||
<td class="align-center number-cell">{{$item.Weight}}</td>
|
||||
<td class="align-right number-cell">{{$item.GrossSales}}</td>
|
||||
<td class="align-right number-cell">{{$item.Discount}}</td>
|
||||
<td class="align-right number-cell">{{$item.NetSales}}</td>
|
||||
|
||||
Reference in New Issue
Block a user