Merge pull request 'Feature/weight based products' (#29) from feature/weight-based-products into main
Reviewed-on: #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"`
|
CategoryName string `json:"category_name"`
|
||||||
CategoryOrder int `json:"category_order"`
|
CategoryOrder int `json:"category_order"`
|
||||||
QuantitySold int64 `json:"quantity_sold"`
|
QuantitySold int64 `json:"quantity_sold"`
|
||||||
|
WeightSold float64 `json:"weight_sold"`
|
||||||
Revenue float64 `json:"revenue"`
|
Revenue float64 `json:"revenue"`
|
||||||
AveragePrice float64 `json:"average_price"`
|
AveragePrice float64 `json:"average_price"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
@@ -324,6 +325,7 @@ type ParentCategoryAnalyticsProductData struct {
|
|||||||
ProductSku string `json:"product_sku"`
|
ProductSku string `json:"product_sku"`
|
||||||
ProductPrice float64 `json:"product_price"`
|
ProductPrice float64 `json:"product_price"`
|
||||||
QuantitySold int64 `json:"quantity_sold"`
|
QuantitySold int64 `json:"quantity_sold"`
|
||||||
|
WeightSold float64 `json:"weight_sold"`
|
||||||
Revenue float64 `json:"revenue"`
|
Revenue float64 `json:"revenue"`
|
||||||
AveragePrice float64 `json:"average_price"`
|
AveragePrice float64 `json:"average_price"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
|
|||||||
@@ -40,13 +40,16 @@ type UpdateOrderRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateOrderItemRequest struct {
|
type CreateOrderItemRequest struct {
|
||||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
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
|
// Weight is required for products with sell_by = "weight" and must be absent otherwise.
|
||||||
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
|
// One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price.
|
||||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
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 {
|
type UpdateOrderItemRequest struct {
|
||||||
@@ -92,26 +95,31 @@ type OrderResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OrderItemResponse struct {
|
type OrderItemResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
OrderID uuid.UUID `json:"order_id"`
|
OrderID uuid.UUID `json:"order_id"`
|
||||||
ProductID uuid.UUID `json:"product_id"`
|
ProductID uuid.UUID `json:"product_id"`
|
||||||
ProductName string `json:"product_name"`
|
ProductName string `json:"product_name"`
|
||||||
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
||||||
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
||||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||||
CategoryName *string `json:"category_name,omitempty"`
|
CategoryName *string `json:"category_name,omitempty"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
UnitPrice float64 `json:"unit_price"`
|
// Weight is set only for products sold by weight; the line is priced weight * unit_price.
|
||||||
TotalPrice float64 `json:"total_price"`
|
Weight *float64 `json:"weight,omitempty"`
|
||||||
Modifiers []map[string]interface{} `json:"modifiers"`
|
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||||
Notes *string `json:"notes,omitempty"`
|
UnitName *string `json:"unit_name,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
UnitAbbreviation *string `json:"unit_abbreviation,omitempty"`
|
||||||
Status string `json:"status"`
|
UnitPrice float64 `json:"unit_price"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
TotalPrice float64 `json:"total_price"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
Modifiers []map[string]interface{} `json:"modifiers"`
|
||||||
PrinterType string `json:"printer_type"`
|
Notes *string `json:"notes,omitempty"`
|
||||||
PrintToChecker bool `json:"print_to_checker"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
PaidQuantity int `json:"paid_quantity"`
|
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 {
|
type ListOrdersQuery struct {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type CreateProductRequest struct {
|
|||||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
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"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
|
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
|
||||||
@@ -38,6 +40,8 @@ type UpdateProductRequest struct {
|
|||||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
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"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
||||||
@@ -73,6 +77,8 @@ type ProductResponse struct {
|
|||||||
BusinessType string `json:"business_type"`
|
BusinessType string `json:"business_type"`
|
||||||
ImageURL *string `json:"image_url"`
|
ImageURL *string `json:"image_url"`
|
||||||
PrinterType string `json:"printer_type"`
|
PrinterType string `json:"printer_type"`
|
||||||
|
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||||
|
SellBy string `json:"sell_by"`
|
||||||
PrintToChecker bool `json:"print_to_checker"`
|
PrintToChecker bool `json:"print_to_checker"`
|
||||||
Metadata map[string]interface{} `json:"metadata"`
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type SelfOrderCreateOrderItem struct {
|
|||||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||||
|
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
||||||
Notes *string `json:"notes,omitempty"`
|
Notes *string `json:"notes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ type ProductAnalytics struct {
|
|||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
CategoryOrder int `json:"category_order"`
|
CategoryOrder int `json:"category_order"`
|
||||||
QuantitySold int64 `json:"quantity_sold"`
|
QuantitySold int64 `json:"quantity_sold"`
|
||||||
|
WeightSold float64 `json:"weight_sold"`
|
||||||
Revenue float64 `json:"revenue"`
|
Revenue float64 `json:"revenue"`
|
||||||
AveragePrice float64 `json:"average_price"`
|
AveragePrice float64 `json:"average_price"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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"`
|
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"`
|
ProductVariantID *uuid.UUID `gorm:"type:uuid;index" json:"product_variant_id"`
|
||||||
Quantity int `gorm:"not null" json:"quantity" validate:"required,min=1"`
|
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"`
|
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"`
|
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"`
|
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"`
|
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||||
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||||
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,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 {
|
func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error {
|
||||||
@@ -81,8 +85,57 @@ func (OrderItem) TableName() string {
|
|||||||
return "order_items"
|
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() {
|
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 {
|
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"`
|
ImageURL *string `gorm:"size:500" json:"image_url"`
|
||||||
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
|
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
|
||||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
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"`
|
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
|
||||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
|
|||||||
ProductID: item.ProductID,
|
ProductID: item.ProductID,
|
||||||
ProductVariantID: item.ProductVariantID,
|
ProductVariantID: item.ProductVariantID,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
|
Weight: item.Weight,
|
||||||
Notes: item.Notes,
|
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{
|
response := &models.OrderItemResponse{
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
OrderID: item.OrderID,
|
OrderID: item.OrderID,
|
||||||
ProductID: item.ProductID,
|
ProductID: item.ProductID,
|
||||||
ProductVariantID: item.ProductVariantID,
|
ProductVariantID: item.ProductVariantID,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
|
Weight: item.Weight,
|
||||||
|
UnitID: item.UnitID,
|
||||||
|
UnitName: unitName,
|
||||||
|
UnitAbbreviation: unitAbbreviation,
|
||||||
UnitPrice: item.UnitPrice,
|
UnitPrice: item.UnitPrice,
|
||||||
TotalPrice: item.TotalPrice,
|
TotalPrice: item.TotalPrice,
|
||||||
UnitCost: item.UnitCost,
|
UnitCost: item.UnitCost,
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
|
|||||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||||
ImageURL: entity.ImageURL,
|
ImageURL: entity.ImageURL,
|
||||||
PrinterType: entity.PrinterType,
|
PrinterType: entity.PrinterType,
|
||||||
|
UnitID: entity.UnitID,
|
||||||
|
SellBy: entity.SellBy,
|
||||||
|
HasIngredients: entity.HasIngredients,
|
||||||
Metadata: map[string]interface{}(entity.Metadata),
|
Metadata: map[string]interface{}(entity.Metadata),
|
||||||
IsActive: entity.IsActive,
|
IsActive: entity.IsActive,
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
@@ -49,6 +52,9 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
|
|||||||
BusinessType: string(model.BusinessType),
|
BusinessType: string(model.BusinessType),
|
||||||
ImageURL: model.ImageURL,
|
ImageURL: model.ImageURL,
|
||||||
PrinterType: model.PrinterType,
|
PrinterType: model.PrinterType,
|
||||||
|
UnitID: model.UnitID,
|
||||||
|
SellBy: model.SellBy,
|
||||||
|
HasIngredients: model.HasIngredients,
|
||||||
Metadata: entities.Metadata(model.Metadata),
|
Metadata: entities.Metadata(model.Metadata),
|
||||||
IsActive: model.IsActive,
|
IsActive: model.IsActive,
|
||||||
CreatedAt: model.CreatedAt,
|
CreatedAt: model.CreatedAt,
|
||||||
@@ -76,6 +82,11 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
|||||||
printerType = *req.PrinterType
|
printerType = *req.PrinterType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sellBy := constants.SellByUnit
|
||||||
|
if constants.IsValidSellBy(req.SellBy) {
|
||||||
|
sellBy = req.SellBy
|
||||||
|
}
|
||||||
|
|
||||||
metadata := entities.Metadata{}
|
metadata := entities.Metadata{}
|
||||||
if req.Metadata != nil {
|
if req.Metadata != nil {
|
||||||
metadata = entities.Metadata(req.Metadata)
|
metadata = entities.Metadata(req.Metadata)
|
||||||
@@ -92,6 +103,8 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
|||||||
BusinessType: businessType,
|
BusinessType: businessType,
|
||||||
ImageURL: req.ImageURL,
|
ImageURL: req.ImageURL,
|
||||||
PrinterType: printerType,
|
PrinterType: printerType,
|
||||||
|
UnitID: req.UnitID,
|
||||||
|
SellBy: sellBy,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
IsActive: true, // Default to active
|
IsActive: true, // Default to active
|
||||||
}
|
}
|
||||||
@@ -140,6 +153,8 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
|||||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||||
ImageURL: entity.ImageURL,
|
ImageURL: entity.ImageURL,
|
||||||
PrinterType: entity.PrinterType,
|
PrinterType: entity.PrinterType,
|
||||||
|
UnitID: entity.UnitID,
|
||||||
|
SellBy: entity.SellBy,
|
||||||
Metadata: map[string]interface{}(entity.Metadata),
|
Metadata: map[string]interface{}(entity.Metadata),
|
||||||
IsActive: entity.IsActive,
|
IsActive: entity.IsActive,
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
@@ -185,6 +200,14 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
|
|||||||
entity.PrinterType = *req.PrinterType
|
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 req.Metadata != nil {
|
||||||
if entity.Metadata == nil {
|
if entity.Metadata == nil {
|
||||||
entity.Metadata = make(entities.Metadata)
|
entity.Metadata = make(entities.Metadata)
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ type ProductAnalyticsData struct {
|
|||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
CategoryOrder int `json:"category_order"`
|
CategoryOrder int `json:"category_order"`
|
||||||
QuantitySold int64 `json:"quantity_sold"`
|
QuantitySold int64 `json:"quantity_sold"`
|
||||||
|
WeightSold float64 `json:"weight_sold"`
|
||||||
Revenue float64 `json:"revenue"`
|
Revenue float64 `json:"revenue"`
|
||||||
AveragePrice float64 `json:"average_price"`
|
AveragePrice float64 `json:"average_price"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
@@ -359,6 +360,7 @@ type ParentCategoryAnalyticsProductData struct {
|
|||||||
ProductSku string `json:"product_sku"`
|
ProductSku string `json:"product_sku"`
|
||||||
ProductPrice float64 `json:"product_price"`
|
ProductPrice float64 `json:"product_price"`
|
||||||
QuantitySold int64 `json:"quantity_sold"`
|
QuantitySold int64 `json:"quantity_sold"`
|
||||||
|
WeightSold float64 `json:"weight_sold"`
|
||||||
Revenue float64 `json:"revenue"`
|
Revenue float64 `json:"revenue"`
|
||||||
AveragePrice float64 `json:"average_price"`
|
AveragePrice float64 `json:"average_price"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ type OrderItem struct {
|
|||||||
ProductID uuid.UUID
|
ProductID uuid.UUID
|
||||||
ProductVariantID *uuid.UUID
|
ProductVariantID *uuid.UUID
|
||||||
Quantity int
|
Quantity int
|
||||||
|
Weight *float64
|
||||||
|
UnitID *uuid.UUID
|
||||||
UnitPrice float64
|
UnitPrice float64
|
||||||
TotalPrice float64
|
TotalPrice float64
|
||||||
UnitCost float64
|
UnitCost float64
|
||||||
@@ -99,7 +101,8 @@ type CreateOrderItemRequest struct {
|
|||||||
ProductID uuid.UUID `validate:"required"`
|
ProductID uuid.UUID `validate:"required"`
|
||||||
ProductVariantID *uuid.UUID `validate:"omitempty"`
|
ProductVariantID *uuid.UUID `validate:"omitempty"`
|
||||||
Quantity int `validate:"required,min=1"`
|
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"`
|
Modifiers []map[string]interface{} `validate:"omitempty"`
|
||||||
Notes *string `validate:"omitempty,max=500"`
|
Notes *string `validate:"omitempty,max=500"`
|
||||||
Metadata map[string]interface{} `validate:"omitempty"`
|
Metadata map[string]interface{} `validate:"omitempty"`
|
||||||
@@ -191,6 +194,10 @@ type OrderItemResponse struct {
|
|||||||
CategoryID *uuid.UUID
|
CategoryID *uuid.UUID
|
||||||
CategoryName *string
|
CategoryName *string
|
||||||
Quantity int
|
Quantity int
|
||||||
|
Weight *float64
|
||||||
|
UnitID *uuid.UUID
|
||||||
|
UnitName *string
|
||||||
|
UnitAbbreviation *string
|
||||||
UnitPrice float64
|
UnitPrice float64
|
||||||
TotalPrice float64
|
TotalPrice float64
|
||||||
UnitCost float64
|
UnitCost float64
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Product struct {
|
|||||||
BusinessType constants.BusinessType
|
BusinessType constants.BusinessType
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
PrinterType string
|
PrinterType string
|
||||||
|
SellBy string
|
||||||
UnitID *uuid.UUID
|
UnitID *uuid.UUID
|
||||||
HasIngredients bool
|
HasIngredients bool
|
||||||
Metadata map[string]interface{}
|
Metadata map[string]interface{}
|
||||||
@@ -52,6 +53,7 @@ type CreateProductRequest struct {
|
|||||||
PrinterType *string `validate:"omitempty,max=50"`
|
PrinterType *string `validate:"omitempty,max=50"`
|
||||||
PrintToChecker *bool `validate:"omitempty"`
|
PrintToChecker *bool `validate:"omitempty"`
|
||||||
UnitID *uuid.UUID `validate:"omitempty"`
|
UnitID *uuid.UUID `validate:"omitempty"`
|
||||||
|
SellBy string `validate:"omitempty,oneof=unit weight"`
|
||||||
HasIngredients bool `validate:"omitempty"`
|
HasIngredients bool `validate:"omitempty"`
|
||||||
Metadata map[string]interface{}
|
Metadata map[string]interface{}
|
||||||
Variants []CreateProductVariantRequest `validate:"omitempty,dive"`
|
Variants []CreateProductVariantRequest `validate:"omitempty,dive"`
|
||||||
@@ -73,6 +75,7 @@ type UpdateProductRequest struct {
|
|||||||
PrinterType *string `validate:"omitempty,max=50"`
|
PrinterType *string `validate:"omitempty,max=50"`
|
||||||
PrintToChecker *bool `validate:"omitempty"`
|
PrintToChecker *bool `validate:"omitempty"`
|
||||||
UnitID *uuid.UUID `validate:"omitempty"`
|
UnitID *uuid.UUID `validate:"omitempty"`
|
||||||
|
SellBy *string `validate:"omitempty,oneof=unit weight"`
|
||||||
HasIngredients *bool `validate:"omitempty"`
|
HasIngredients *bool `validate:"omitempty"`
|
||||||
Metadata map[string]interface{}
|
Metadata map[string]interface{}
|
||||||
IsActive *bool
|
IsActive *bool
|
||||||
@@ -110,6 +113,7 @@ type ProductResponse struct {
|
|||||||
BusinessType constants.BusinessType
|
BusinessType constants.BusinessType
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
PrinterType string
|
PrinterType string
|
||||||
|
SellBy string
|
||||||
PrintToChecker bool
|
PrintToChecker bool
|
||||||
UnitID *uuid.UUID
|
UnitID *uuid.UUID
|
||||||
HasIngredients bool
|
HasIngredients bool
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
|
|||||||
CategoryName: data.CategoryName,
|
CategoryName: data.CategoryName,
|
||||||
CategoryOrder: data.CategoryOrder,
|
CategoryOrder: data.CategoryOrder,
|
||||||
QuantitySold: data.QuantitySold,
|
QuantitySold: data.QuantitySold,
|
||||||
|
WeightSold: data.WeightSold,
|
||||||
Revenue: data.Revenue,
|
Revenue: data.Revenue,
|
||||||
AveragePrice: data.AveragePrice,
|
AveragePrice: data.AveragePrice,
|
||||||
OrderCount: data.OrderCount,
|
OrderCount: data.OrderCount,
|
||||||
@@ -447,6 +448,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
|||||||
ProductSku: product.ProductSku,
|
ProductSku: product.ProductSku,
|
||||||
ProductPrice: product.ProductPrice,
|
ProductPrice: product.ProductPrice,
|
||||||
QuantitySold: product.QuantitySold,
|
QuantitySold: product.QuantitySold,
|
||||||
|
WeightSold: product.WeightSold,
|
||||||
Revenue: product.Revenue,
|
Revenue: product.Revenue,
|
||||||
AveragePrice: product.AveragePrice,
|
AveragePrice: product.AveragePrice,
|
||||||
OrderCount: product.OrderCount,
|
OrderCount: product.OrderCount,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/mappers"
|
"apskel-pos-be/internal/mappers"
|
||||||
"apskel-pos-be/internal/models"
|
"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) {
|
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)
|
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -194,25 +222,29 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
|
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
|
||||||
itemTotalCost := float64(itemReq.Quantity) * unitCost
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
subtotal += itemTotalPrice
|
}
|
||||||
totalCost += itemTotalCost
|
|
||||||
|
|
||||||
orderItem := &entities.OrderItem{
|
orderItem := &entities.OrderItem{
|
||||||
ProductID: itemReq.ProductID,
|
ProductID: itemReq.ProductID,
|
||||||
ProductVariantID: itemReq.ProductVariantID,
|
ProductVariantID: itemReq.ProductVariantID,
|
||||||
Quantity: itemReq.Quantity,
|
Quantity: quantity,
|
||||||
|
Weight: weight,
|
||||||
|
UnitID: product.UnitID,
|
||||||
UnitPrice: unitPrice,
|
UnitPrice: unitPrice,
|
||||||
TotalPrice: itemTotalPrice,
|
|
||||||
UnitCost: unitCost,
|
UnitCost: unitCost,
|
||||||
TotalCost: itemTotalCost,
|
|
||||||
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
||||||
Notes: itemReq.Notes,
|
Notes: itemReq.Notes,
|
||||||
Metadata: entities.Metadata(itemReq.Metadata),
|
Metadata: entities.Metadata(itemReq.Metadata),
|
||||||
Status: entities.OrderItemStatusPending,
|
Status: entities.OrderItemStatusPending,
|
||||||
}
|
}
|
||||||
|
orderItem.CalculateTotalPrice()
|
||||||
|
orderItem.CalculateTotalCost()
|
||||||
|
|
||||||
|
subtotal += orderItem.TotalPrice
|
||||||
|
totalCost += orderItem.TotalCost
|
||||||
|
|
||||||
orderItems = append(orderItems, orderItem)
|
orderItems = append(orderItems, orderItem)
|
||||||
}
|
}
|
||||||
@@ -327,26 +359,30 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
|
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
|
||||||
itemTotalCost := float64(itemReq.Quantity) * unitCost
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
newSubtotal += itemTotalPrice
|
}
|
||||||
newTotalCost += itemTotalCost
|
|
||||||
|
|
||||||
orderItem := &entities.OrderItem{
|
orderItem := &entities.OrderItem{
|
||||||
OrderID: orderID,
|
OrderID: orderID,
|
||||||
ProductID: itemReq.ProductID,
|
ProductID: itemReq.ProductID,
|
||||||
ProductVariantID: itemReq.ProductVariantID,
|
ProductVariantID: itemReq.ProductVariantID,
|
||||||
Quantity: itemReq.Quantity,
|
Quantity: quantity,
|
||||||
|
Weight: weight,
|
||||||
|
UnitID: product.UnitID,
|
||||||
UnitPrice: unitPrice,
|
UnitPrice: unitPrice,
|
||||||
TotalPrice: itemTotalPrice,
|
|
||||||
UnitCost: unitCost,
|
UnitCost: unitCost,
|
||||||
TotalCost: itemTotalCost,
|
|
||||||
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
||||||
Notes: itemReq.Notes,
|
Notes: itemReq.Notes,
|
||||||
Metadata: entities.Metadata(itemReq.Metadata),
|
Metadata: entities.Metadata(itemReq.Metadata),
|
||||||
Status: entities.OrderItemStatusPending,
|
Status: entities.OrderItemStatusPending,
|
||||||
}
|
}
|
||||||
|
orderItem.CalculateTotalPrice()
|
||||||
|
orderItem.CalculateTotalCost()
|
||||||
|
|
||||||
|
newSubtotal += orderItem.TotalPrice
|
||||||
|
newTotalCost += orderItem.TotalCost
|
||||||
|
|
||||||
addedOrderItems = append(addedOrderItems, orderItem)
|
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)
|
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
voidedAmount := float64(itemVoid.Quantity) * orderItem.UnitPrice
|
billable := orderItem.BillableQuantityFor(itemVoid.Quantity)
|
||||||
voidedCost := float64(itemVoid.Quantity) * orderItem.UnitCost
|
voidedAmount := entities.RoundMoney(billable * orderItem.UnitPrice)
|
||||||
|
voidedCost := entities.RoundMoney(billable * orderItem.UnitCost)
|
||||||
|
|
||||||
totalVoidedAmount += voidedAmount
|
totalVoidedAmount += voidedAmount
|
||||||
totalVoidedCost += voidedCost
|
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")
|
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 {
|
if itemRefund.RefundAmount != nil {
|
||||||
refundAmount = *itemRefund.RefundAmount
|
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
|
PreviousQuantity: float64(updatedInventory.Quantity + item.Quantity), // Add back the quantity that was subtracted
|
||||||
NewQuantity: float64(updatedInventory.Quantity),
|
NewQuantity: float64(updatedInventory.Quantity),
|
||||||
UnitCost: item.UnitCost,
|
UnitCost: item.UnitCost,
|
||||||
TotalCost: float64(item.Quantity) * item.UnitCost,
|
TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
|
||||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||||
t := entities.InventoryMovementReferenceTypePayment
|
t := entities.InventoryMovementReferenceTypePayment
|
||||||
return &t
|
return &t
|
||||||
@@ -1314,7 +1351,7 @@ func (p *OrderProcessorImpl) prepareProductInventoryMovement(item *entities.Orde
|
|||||||
PreviousQuantity: float64(previousQuantity),
|
PreviousQuantity: float64(previousQuantity),
|
||||||
NewQuantity: float64(updatedInventory.Quantity),
|
NewQuantity: float64(updatedInventory.Quantity),
|
||||||
UnitCost: item.UnitCost,
|
UnitCost: item.UnitCost,
|
||||||
TotalCost: float64(item.Quantity) * item.UnitCost,
|
TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
|
||||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||||
t := entities.InventoryMovementReferenceTypePayment
|
t := entities.InventoryMovementReferenceTypePayment
|
||||||
return &t
|
return &t
|
||||||
@@ -1380,7 +1417,7 @@ type ingredientRecipeItem struct {
|
|||||||
|
|
||||||
// prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls
|
// 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) {
|
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)
|
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1478,7 +1515,7 @@ func (p *OrderProcessorImpl) prepareRefundProductInventoryMovement(item *entitie
|
|||||||
PreviousQuantity: float64(previousQuantity),
|
PreviousQuantity: float64(previousQuantity),
|
||||||
NewQuantity: float64(updatedInventory.Quantity),
|
NewQuantity: float64(updatedInventory.Quantity),
|
||||||
UnitCost: item.UnitCost,
|
UnitCost: item.UnitCost,
|
||||||
TotalCost: float64(refundedQuantity) * item.UnitCost,
|
TotalCost: entities.RoundMoney(item.BillableQuantityFor(refundedQuantity) * item.UnitCost),
|
||||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||||
t := entities.InventoryMovementReferenceTypeRefund
|
t := entities.InventoryMovementReferenceTypeRefund
|
||||||
return &t
|
return &t
|
||||||
@@ -1544,7 +1581,7 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeData(ctx context.Con
|
|||||||
// prepareRefundedIngredientRecipeItem prepares data for a single ingredient recipe restoration
|
// 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) {
|
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
|
// 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
|
// Get current ingredient details
|
||||||
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
|
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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/logger"
|
"apskel-pos-be/internal/logger"
|
||||||
"apskel-pos-be/internal/mappers"
|
"apskel-pos-be/internal/mappers"
|
||||||
@@ -191,6 +192,13 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
|
|||||||
|
|
||||||
mappers.UpdateProductEntityFromRequest(existingProduct, req)
|
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 {
|
if err := p.productRepo.Update(ctx, existingProduct); err != nil {
|
||||||
return nil, fmt.Errorf("failed to update product: %w", err)
|
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 {
|
for _, item := range req.Items {
|
||||||
orderItem := validation.OrderItems[item.OrderItemID]
|
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
|
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
|
||||||
totalItemAmount := itemAmount + itemTaxAmount
|
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 {
|
func (p *SplitBillProcessorImpl) createPaymentOrderItems(ctx context.Context, splitPayment *entities.Payment, req *models.SplitBillRequest, validation *SplitBillValidation) error {
|
||||||
for _, item := range req.Items {
|
for _, item := range req.Items {
|
||||||
orderItem := validation.OrderItems[item.OrderItemID]
|
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
|
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
|
||||||
totalItemAmount := itemAmount + itemTaxAmount
|
totalItemAmount := itemAmount + itemTaxAmount
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ func (p *SplitBillProcessorImpl) updateOrderAfterSplit(ctx context.Context, orde
|
|||||||
quantityInfo[item.OrderItemID.String()] = map[string]interface{}{
|
quantityInfo[item.OrderItemID.String()] = map[string]interface{}{
|
||||||
"quantity": item.Quantity,
|
"quantity": item.Quantity,
|
||||||
"unit_price": orderItem.UnitPrice,
|
"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
|
order.Metadata[MetadataKeyLastSplitQuantities] = quantityInfo
|
||||||
|
|||||||
@@ -461,6 +461,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
|
|||||||
c.name as category_name,
|
c.name as category_name,
|
||||||
c.order as category_order,
|
c.order as category_order,
|
||||||
COALESCE(SUM(oi.quantity), 0) as quantity_sold,
|
COALESCE(SUM(oi.quantity), 0) as quantity_sold,
|
||||||
|
COALESCE(SUM(oi.weight), 0) as weight_sold,
|
||||||
COALESCE(SUM(oi.total_price), 0) as revenue,
|
COALESCE(SUM(oi.total_price), 0) as revenue,
|
||||||
CASE
|
CASE
|
||||||
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity)
|
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 {
|
type purchasingTotals struct {
|
||||||
Total float64
|
Total float64
|
||||||
RawMaterial float64
|
RawMaterial float64
|
||||||
Expense float64
|
Expense float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
|
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).
|
err := r.db.WithContext(ctx).
|
||||||
Preload("Product").
|
Preload("Product").
|
||||||
Preload("ProductVariant").
|
Preload("ProductVariant").
|
||||||
|
Preload("Unit").
|
||||||
Where("order_id = ?", orderID).
|
Where("order_id = ?", orderID).
|
||||||
Find(&orderItems).Error
|
Find(&orderItems).Error
|
||||||
return orderItems, err
|
return orderItems, err
|
||||||
@@ -109,8 +110,12 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if voidQuantity >= orderItem.Quantity {
|
// A weighed line is one weighing, so it is voided whole — never split into a
|
||||||
voidedAmount := float64(voidQuantity) * orderItem.UnitPrice
|
// 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{}{
|
updates := map[string]interface{}{
|
||||||
"refund_quantity": voidQuantity,
|
"refund_quantity": voidQuantity,
|
||||||
@@ -128,16 +133,18 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
|
|||||||
Updates(updates).Error
|
Updates(updates).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only unit-priced lines reach here: weighed lines were voided whole above.
|
||||||
voidedOrderItem := entities.OrderItem{
|
voidedOrderItem := entities.OrderItem{
|
||||||
OrderID: orderItem.OrderID,
|
OrderID: orderItem.OrderID,
|
||||||
ProductID: orderItem.ProductID,
|
ProductID: orderItem.ProductID,
|
||||||
ProductVariantID: orderItem.ProductVariantID,
|
ProductVariantID: orderItem.ProductVariantID,
|
||||||
Quantity: voidQuantity,
|
Quantity: voidQuantity,
|
||||||
|
UnitID: orderItem.UnitID,
|
||||||
UnitPrice: orderItem.UnitPrice,
|
UnitPrice: orderItem.UnitPrice,
|
||||||
TotalPrice: float64(voidQuantity) * orderItem.UnitPrice,
|
TotalPrice: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
|
||||||
UnitCost: orderItem.UnitCost,
|
UnitCost: orderItem.UnitCost,
|
||||||
TotalCost: float64(voidQuantity) * orderItem.UnitCost,
|
TotalCost: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitCost),
|
||||||
RefundAmount: float64(voidQuantity) * orderItem.UnitPrice,
|
RefundAmount: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
|
||||||
RefundQuantity: voidQuantity,
|
RefundQuantity: voidQuantity,
|
||||||
IsPartiallyRefunded: false,
|
IsPartiallyRefunded: false,
|
||||||
IsFullyRefunded: true,
|
IsFullyRefunded: true,
|
||||||
@@ -155,8 +162,8 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
|
|||||||
}
|
}
|
||||||
|
|
||||||
remainingQuantity := orderItem.Quantity - voidQuantity
|
remainingQuantity := orderItem.Quantity - voidQuantity
|
||||||
remainingTotalPrice := float64(remainingQuantity) * orderItem.UnitPrice
|
remainingTotalPrice := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitPrice)
|
||||||
remainingTotalCost := float64(remainingQuantity) * orderItem.UnitCost
|
remainingTotalCost := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitCost)
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"quantity": remainingQuantity,
|
"quantity": remainingQuantity,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID
|
|||||||
Preload("OrderItems.Product.Category").
|
Preload("OrderItems.Product.Category").
|
||||||
Preload("OrderItems.Product.ProductOutletPrices").
|
Preload("OrderItems.Product.ProductOutletPrices").
|
||||||
Preload("OrderItems.ProductVariant").
|
Preload("OrderItems.ProductVariant").
|
||||||
|
Preload("OrderItems.Unit").
|
||||||
Preload("Payments").
|
Preload("Payments").
|
||||||
Preload("Payments.PaymentMethod").
|
Preload("Payments.PaymentMethod").
|
||||||
Preload("Payments.PaymentOrderItems").
|
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.Category").
|
||||||
Preload("OrderItems.Product.ProductOutletPrices").
|
Preload("OrderItems.Product.ProductOutletPrices").
|
||||||
Preload("OrderItems.ProductVariant").
|
Preload("OrderItems.ProductVariant").
|
||||||
|
Preload("OrderItems.Unit").
|
||||||
Preload("Payments").
|
Preload("Payments").
|
||||||
Preload("Payments.PaymentMethod").
|
Preload("Payments.PaymentMethod").
|
||||||
Preload("Payments.PaymentOrderItems").
|
Preload("Payments.PaymentOrderItems").
|
||||||
@@ -162,6 +164,7 @@ func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID str
|
|||||||
Preload("OrderItems.Product.Category").
|
Preload("OrderItems.Product.Category").
|
||||||
Preload("OrderItems.Product.ProductOutletPrices").
|
Preload("OrderItems.Product.ProductOutletPrices").
|
||||||
Preload("OrderItems.ProductVariant").
|
Preload("OrderItems.ProductVariant").
|
||||||
|
Preload("OrderItems.Unit").
|
||||||
Preload("Payments").
|
Preload("Payments").
|
||||||
Preload("Payments.PaymentMethod").
|
Preload("Payments.PaymentMethod").
|
||||||
Preload("Payments.PaymentOrderItems").
|
Preload("Payments.PaymentOrderItems").
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ type reportSummary struct {
|
|||||||
type reportItem struct {
|
type reportItem struct {
|
||||||
Name string
|
Name string
|
||||||
Quantity int64
|
Quantity int64
|
||||||
|
Weight string
|
||||||
GrossSales string
|
GrossSales string
|
||||||
Discount string
|
Discount string
|
||||||
NetSales string
|
NetSales string
|
||||||
@@ -161,6 +162,7 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org
|
|||||||
items = append(items, reportItem{
|
items = append(items, reportItem{
|
||||||
Name: p.ProductName,
|
Name: p.ProductName,
|
||||||
Quantity: p.QuantitySold,
|
Quantity: p.QuantitySold,
|
||||||
|
Weight: formatWeight(p.WeightSold),
|
||||||
GrossSales: formatCurrency(p.Revenue),
|
GrossSales: formatCurrency(p.Revenue),
|
||||||
Discount: formatCurrency(0),
|
Discount: formatCurrency(0),
|
||||||
NetSales: formatCurrency(p.Revenue),
|
NetSales: formatCurrency(p.Revenue),
|
||||||
@@ -489,6 +491,16 @@ func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLoss
|
|||||||
return result
|
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 {
|
func formatCurrencyOrDash(amount float64) string {
|
||||||
if amount == 0 {
|
if amount == 0 {
|
||||||
return "-"
|
return "-"
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
|
|||||||
CategoryName: item.CategoryName,
|
CategoryName: item.CategoryName,
|
||||||
CategoryOrder: item.CategoryOrder,
|
CategoryOrder: item.CategoryOrder,
|
||||||
QuantitySold: item.QuantitySold,
|
QuantitySold: item.QuantitySold,
|
||||||
|
WeightSold: item.WeightSold,
|
||||||
Revenue: item.Revenue,
|
Revenue: item.Revenue,
|
||||||
AveragePrice: item.AveragePrice,
|
AveragePrice: item.AveragePrice,
|
||||||
OrderCount: item.OrderCount,
|
OrderCount: item.OrderCount,
|
||||||
@@ -506,6 +507,7 @@ func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAna
|
|||||||
ProductSku: product.ProductSku,
|
ProductSku: product.ProductSku,
|
||||||
ProductPrice: product.ProductPrice,
|
ProductPrice: product.ProductPrice,
|
||||||
QuantitySold: product.QuantitySold,
|
QuantitySold: product.QuantitySold,
|
||||||
|
WeightSold: product.WeightSold,
|
||||||
Revenue: product.Revenue,
|
Revenue: product.Revenue,
|
||||||
AveragePrice: product.AveragePrice,
|
AveragePrice: product.AveragePrice,
|
||||||
OrderCount: product.OrderCount,
|
OrderCount: product.OrderCount,
|
||||||
@@ -592,6 +594,7 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
|
|||||||
CategoryID: item.CategoryID,
|
CategoryID: item.CategoryID,
|
||||||
CategoryName: item.CategoryName,
|
CategoryName: item.CategoryName,
|
||||||
QuantitySold: item.QuantitySold,
|
QuantitySold: item.QuantitySold,
|
||||||
|
WeightSold: item.WeightSold,
|
||||||
Revenue: item.Revenue,
|
Revenue: item.Revenue,
|
||||||
AveragePrice: item.AveragePrice,
|
AveragePrice: item.AveragePrice,
|
||||||
OrderCount: item.OrderCount,
|
OrderCount: item.OrderCount,
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse {
|
|||||||
CategoryID: item.CategoryID,
|
CategoryID: item.CategoryID,
|
||||||
CategoryName: item.CategoryName,
|
CategoryName: item.CategoryName,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
|
Weight: item.Weight,
|
||||||
|
UnitID: item.UnitID,
|
||||||
|
UnitName: item.UnitName,
|
||||||
|
UnitAbbreviation: item.UnitAbbreviation,
|
||||||
UnitPrice: item.UnitPrice,
|
UnitPrice: item.UnitPrice,
|
||||||
TotalPrice: item.TotalPrice,
|
TotalPrice: item.TotalPrice,
|
||||||
Modifiers: item.Modifiers,
|
Modifiers: item.Modifiers,
|
||||||
@@ -174,6 +178,10 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO
|
|||||||
CategoryID: item.CategoryID,
|
CategoryID: item.CategoryID,
|
||||||
CategoryName: item.CategoryName,
|
CategoryName: item.CategoryName,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
|
Weight: item.Weight,
|
||||||
|
UnitID: item.UnitID,
|
||||||
|
UnitName: item.UnitName,
|
||||||
|
UnitAbbreviation: item.UnitAbbreviation,
|
||||||
UnitPrice: item.UnitPrice,
|
UnitPrice: item.UnitPrice,
|
||||||
TotalPrice: item.TotalPrice,
|
TotalPrice: item.TotalPrice,
|
||||||
Modifiers: item.Modifiers,
|
Modifiers: item.Modifiers,
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
|
|||||||
businessType = constants.BusinessType(*req.BusinessType)
|
businessType = constants.BusinessType(*req.BusinessType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sellBy := constants.SellByUnit
|
||||||
|
if req.SellBy != nil {
|
||||||
|
sellBy = *req.SellBy
|
||||||
|
}
|
||||||
|
|
||||||
var variants []models.CreateProductVariantRequest
|
var variants []models.CreateProductVariantRequest
|
||||||
if req.Variants != nil {
|
if req.Variants != nil {
|
||||||
variants = make([]models.CreateProductVariantRequest, len(req.Variants))
|
variants = make([]models.CreateProductVariantRequest, len(req.Variants))
|
||||||
@@ -58,6 +63,8 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
|
|||||||
ImageURL: req.ImageURL,
|
ImageURL: req.ImageURL,
|
||||||
PrinterType: req.PrinterType,
|
PrinterType: req.PrinterType,
|
||||||
PrintToChecker: req.PrintToChecker,
|
PrintToChecker: req.PrintToChecker,
|
||||||
|
UnitID: req.UnitID,
|
||||||
|
SellBy: sellBy,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
Variants: variants,
|
Variants: variants,
|
||||||
}
|
}
|
||||||
@@ -86,6 +93,8 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up
|
|||||||
ImageURL: req.ImageURL,
|
ImageURL: req.ImageURL,
|
||||||
PrinterType: req.PrinterType,
|
PrinterType: req.PrinterType,
|
||||||
PrintToChecker: req.PrintToChecker,
|
PrintToChecker: req.PrintToChecker,
|
||||||
|
UnitID: req.UnitID,
|
||||||
|
SellBy: req.SellBy,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
IsActive: req.IsActive,
|
IsActive: req.IsActive,
|
||||||
}
|
}
|
||||||
@@ -145,6 +154,8 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
|
|||||||
ImageURL: prod.ImageURL,
|
ImageURL: prod.ImageURL,
|
||||||
PrinterType: prod.PrinterType,
|
PrinterType: prod.PrinterType,
|
||||||
PrintToChecker: prod.PrintToChecker,
|
PrintToChecker: prod.PrintToChecker,
|
||||||
|
UnitID: prod.UnitID,
|
||||||
|
SellBy: prod.SellBy,
|
||||||
Metadata: prod.Metadata,
|
Metadata: prod.Metadata,
|
||||||
IsActive: prod.IsActive,
|
IsActive: prod.IsActive,
|
||||||
CreatedAt: prod.CreatedAt,
|
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
|
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, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +100,8 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
|
|||||||
// At least one field should be provided for update
|
// At least one field should be provided for update
|
||||||
if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil &&
|
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.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
|
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
|
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, ""
|
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>No.</th>
|
||||||
<th>Nama Item</th>
|
<th>Nama Item</th>
|
||||||
<th class="align-center">Qty</th>
|
<th class="align-center">Qty</th>
|
||||||
|
<th class="align-center">Berat</th>
|
||||||
<th class="align-right">Penjualan Kotor</th>
|
<th class="align-right">Penjualan Kotor</th>
|
||||||
<th class="align-right">Diskon</th>
|
<th class="align-right">Diskon</th>
|
||||||
<th class="align-right">Penjualan Bersih</th>
|
<th class="align-right">Penjualan Bersih</th>
|
||||||
@@ -537,6 +538,7 @@
|
|||||||
<td class="align-center number-cell">{{add $i 1}}</td>
|
<td class="align-center number-cell">{{add $i 1}}</td>
|
||||||
<td><span class="item-name">{{$item.Name}}</span></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.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.GrossSales}}</td>
|
||||||
<td class="align-right number-cell">{{$item.Discount}}</td>
|
<td class="align-right number-cell">{{$item.Discount}}</td>
|
||||||
<td class="align-right number-cell">{{$item.NetSales}}</td>
|
<td class="align-right number-cell">{{$item.NetSales}}</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user