feat(order): support weight-based products
Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the order line could not represent: quantity is INTEGER and prices are always computed as quantity * unit_price. Model one weighing as one order line. quantity stays INTEGER and keeps meaning "how many items"; the measured amount goes into a new nullable order_items.weight, and the line is priced weight * unit_price. Two weighings of the same product are two lines, never merged into one. Keeping quantity integral avoids float comparisons in void, refund and split bill, where accumulated rounding error would silently misbehave — "1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid split-bill item marked unpaid. BillableQuantity() is now the single place that decides between weight and count; every price and cost calculation goes through it. Missing one would bill a 4.2 ons fish as a single ons — wrong money, no error. Two database constraints back the design: a weighed line always carries a positive weight, and its quantity is pinned to 1. The latter also makes void all-or-nothing for weighed lines, so the row-splitting branch can never produce a zero-weight remainder row. Also wires product.unit_id through the API, which was previously not settable at all, and corrects the misleading comment on the request's unit_price field — that value has never been used; price always comes from the database. Design notes and the audit of every price multiplication site are in docs/rfc-weight-based-products.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
# RFC: Produk Timbangan (Weight-Based Products)
|
||||
|
||||
**Status:** Diimplementasikan (migrasi `000089`)
|
||||
**Tanggal:** 2026-09-05, diperbarui 2026-09-06
|
||||
**Scope:** Product, Order, Void/Refund, Report
|
||||
**Out of scope:** Inventory / pengurangan stok otomatis (lihat §8)
|
||||
|
||||
---
|
||||
|
||||
## 1. Masalah
|
||||
|
||||
Sistem mengasumsikan setiap produk dijual dalam satuan diskrit. `order_items.quantity`
|
||||
bertipe `INTEGER` dengan `CHECK (quantity > 0)`, dan harga dihitung
|
||||
`quantity × unit_price` di seluruh jalur order, void, refund, dan split bill.
|
||||
|
||||
Produk seperti Ikan Tude dijual per timbangan. Pelanggan memesan Ikan Tude 4,2 ons,
|
||||
lalu memesan Ikan Tude lagi 5,6 ons. Keduanya adalah **dua ikan berbeda yang ditimbang
|
||||
terpisah** — bukan satu baris berisi 9,8.
|
||||
|
||||
Angka 4,2 itu **berat**, bukan cacah. Sistem belum punya tempat untuk menyimpannya.
|
||||
|
||||
> **Catatan satuan.** RFC ini tidak mengasumsikan satuan tertentu. Satuan produk
|
||||
> ditentukan `products.unit_id` yang merujuk tabel `units` — bisa ons, kg, gram, atau
|
||||
> apa pun yang didefinisikan organisasi. Contoh memakai **ons** karena itu kasus yang
|
||||
> sedang dikerjakan; tidak ada bagian desain ini yang bergantung padanya.
|
||||
|
||||
---
|
||||
|
||||
## 2. Keputusan Inti
|
||||
|
||||
**Satu penimbangan = satu baris `order_items`.**
|
||||
|
||||
| | Baris 1 | Baris 2 |
|
||||
|---|---|---|
|
||||
| Ikan Tude 4,2 ons | `quantity = 1`, `weight = 4.2` | |
|
||||
| Ikan Tude 5,6 ons | | `quantity = 1`, `weight = 5.6` |
|
||||
|
||||
`quantity` tetap `INTEGER` dan tetap berarti "berapa banyak barang". Berat masuk ke
|
||||
kolom baru. Dua baris tidak pernah digabung menjadi `9.8`, karena keduanya memang dua
|
||||
ikan yang berbeda.
|
||||
|
||||
### Kenapa bukan `quantity = 4.2`
|
||||
|
||||
Alternatif yang sempat dipertimbangkan adalah mengubah `quantity` menjadi
|
||||
`DECIMAL(12,3)`. Model itu ditolak karena tiga alasan:
|
||||
|
||||
1. **Menghapus jejak barang.** `4.2` dan `5.6` yang digabung jadi `9.8` kehilangan
|
||||
informasi bahwa ada dua ikan. Tidak bisa direkonstruksi.
|
||||
2. **Merusak agregasi lintas produk.** `SUM(quantity)` untuk laporan
|
||||
"total item terjual" akan menjumlahkan ons dengan porsi — angka tanpa arti, yang
|
||||
bahkan berubah nilainya bila satuan produk diganti dari ons ke kg tanpa ada apa pun
|
||||
yang berubah di dunia nyata.
|
||||
3. **Membawa masalah presisi float ke seluruh sistem.** Perbandingan quantity dipakai
|
||||
di void, refund, dan split bill. Dengan float, `1,4 + 1,4 + 1,4` tidak sama dengan
|
||||
`4,2` — split bill "bagi rata bertiga" akan gagal menandai item lunas meski uang
|
||||
sudah diterima penuh. Semua itu tidak terjadi bila `quantity` tetap integer.
|
||||
|
||||
Konsekuensi langsung dari keputusan ini: **tidak diperlukan helper perbandingan
|
||||
epsilon.** Berat tidak pernah dibandingkan, hanya dikalikan.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prinsip
|
||||
|
||||
**P1 — Baris transaksi adalah snapshot yang beku.**
|
||||
`order_items` sudah menyimpan `unit_price` dan `unit_cost` sebagai salinan, bukan join
|
||||
ke `products`. Satuan mendapat perlakuan sama: mengubah master data tidak boleh
|
||||
mengubah arti transaksi yang sudah terjadi.
|
||||
|
||||
**P2 — Perhitungan harga baris hanya ada di satu tempat.**
|
||||
Setelah RFC ini ada dua rumus (`quantity × harga` dan `weight × harga`). Tidak boleh
|
||||
ada perkalian harga yang tersebar; semuanya memanggil satu fungsi.
|
||||
|
||||
**P3 — Harga tetap otoritas backend.**
|
||||
Klien tidak pernah mengirim harga. Backend membacanya dari `products` /
|
||||
`product_outlet_prices` seperti sekarang.
|
||||
|
||||
**P4 — Berat boleh dijumlahkan dalam satu produk, tidak boleh antar produk.**
|
||||
`SUM(weight)` untuk satu produk bermakna ("terjual 47,3 ons"). Lintas produk dengan
|
||||
satuan berbeda tidak bermakna.
|
||||
|
||||
---
|
||||
|
||||
## 4. Perubahan Skema
|
||||
|
||||
```sql
|
||||
-- Products: cara jual
|
||||
ALTER TABLE products
|
||||
ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit'
|
||||
CHECK (sell_by IN ('unit', 'weight'));
|
||||
|
||||
-- Order items: berat + snapshot satuan
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN weight DECIMAL(12,3),
|
||||
ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_weight_positive
|
||||
CHECK (weight IS NULL OR weight > 0),
|
||||
ADD CONSTRAINT chk_order_items_weight_single_line
|
||||
CHECK (weight IS NULL OR quantity = 1);
|
||||
```
|
||||
|
||||
**Catatan:**
|
||||
|
||||
- `weight` **nullable**. `NULL` berarti produk satuan biasa — seluruh data lama valid
|
||||
tanpa backfill, dan perilakunya tidak berubah sama sekali.
|
||||
- `chk_order_items_weight_single_line` menegakkan keputusan §2 di level database:
|
||||
baris berbobot selalu `quantity = 1`. Ini yang membuat `BillableQuantity()` tidak
|
||||
ambigu dan membuat void otomatis bersifat utuh (§6).
|
||||
- `quantity` **tidak berubah tipe**. `CHECK (quantity > 0)` yang sudah ada tetap
|
||||
berlaku.
|
||||
- `DECIMAL(12,3)` konsisten dengan `inventory_movements.quantity` yang sudah memakai
|
||||
presisi sama.
|
||||
- Tidak ada `weighed_unit`. Karena satu baris memang satu barang, "ikan curah" dan
|
||||
"ikan per ekor" berperilaku identik — pembedaan itu tidak punya konsekuensi.
|
||||
|
||||
---
|
||||
|
||||
## 5. Perhitungan Harga
|
||||
|
||||
Satu-satunya tempat yang boleh mengalikan harga (P2):
|
||||
|
||||
```go
|
||||
// BillableQuantity mengembalikan pengali harga untuk baris ini:
|
||||
// berat bila produk dijual per timbangan, jumlah bila dijual per satuan.
|
||||
// Baris berbobot dijamin quantity = 1 oleh constraint DB.
|
||||
func (oi *OrderItem) BillableQuantity() float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(oi.Quantity)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalPrice() {
|
||||
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalCost() {
|
||||
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
|
||||
}
|
||||
```
|
||||
|
||||
`unit_price` tetap berarti **harga per satu satuan produk** (per ons). Tidak ada faktor
|
||||
konversi yang menyelinap ke perhitungan uang.
|
||||
|
||||
### Titik yang harus diganti
|
||||
|
||||
Ini bagian paling berisiko dari RFC. Setiap perkalian harga yang terlewat akan
|
||||
menghitung `1 × harga_per_ons` — ikan 4,2 ons ditagih seharga 1 ons. **Salah uang,
|
||||
bukan salah tampilan**, dan tidak memicu error apa pun.
|
||||
|
||||
| Lokasi | Sekarang |
|
||||
|---|---|
|
||||
| `processor/order_processor.go:197-198` | buat order |
|
||||
| `processor/order_processor.go:330-331` | tambah item ke order |
|
||||
| `processor/order_processor.go:605-606` | jumlah & HPP yang di-void |
|
||||
| `processor/order_processor.go:723` | jumlah refund |
|
||||
| `processor/split_bill_processor.go:143` | hitung jumlah split |
|
||||
| `processor/split_bill_processor.go:189` | catat pembayaran |
|
||||
| `processor/split_bill_processor.go:231` | metadata pembayaran |
|
||||
| `repository/order_item_repository.go:113` | jumlah void penuh |
|
||||
|
||||
Implementasi menemukan **lima titik tambahan** di luar daftar di atas, semuanya di jalur
|
||||
inventory movement dan resep bahan yang tidak terlihat saat RFC ini ditulis:
|
||||
|
||||
| Lokasi | Status |
|
||||
|---|---|
|
||||
| `order_processor.go:1056` `createInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
|
||||
| `order_processor.go:1354` `prepareProductInventoryMovement` | **hidup** |
|
||||
| `order_processor.go:1420` `prepareIngredientRecipeItem` | **hidup** |
|
||||
| `order_processor.go:1518` `prepareRefundProductInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
|
||||
| `order_processor.go:1584` `prepareRefundedIngredientRecipeItem` | **hidup** |
|
||||
|
||||
Tiga yang hidup penting: tanpa perbaikan, konsumsi bahan untuk ikan 4,2 ons akan
|
||||
dihitung sebagai 1 satuan resep.
|
||||
|
||||
### Verifikasi
|
||||
|
||||
```bash
|
||||
grep -rn "Quantity) \* \|Quantity \* " --include=*.go internal/ \
|
||||
| grep -iE "price|cost" | grep -v BillableQuantity | grep -v totalIngredientQuantity
|
||||
```
|
||||
|
||||
Hasilnya **tidak kosong** — tersisa tujuh baris, semuanya sudah diperiksa dan aman:
|
||||
|
||||
- `mappers/inventory_movement_mapper.go:129` dan
|
||||
`processor/inventory_movement_processor.go:69` — penyesuaian stok manual, bukan
|
||||
baris order.
|
||||
- `repository/order_item_repository.go:144,146,147,165,166` — cabang void sebagian,
|
||||
yang baris berbobot tidak pernah jangkau karena dijaga `orderItem.IsWeighed()`.
|
||||
|
||||
Bila daftar ini bertambah di kemudian hari, baris barunya harus diperiksa satu per satu.
|
||||
|
||||
---
|
||||
|
||||
## 6. Void, Refund, Split Bill
|
||||
|
||||
**Tidak ada perubahan logika.** Ini konsekuensi menyenangkan dari `quantity` yang tetap
|
||||
integer.
|
||||
|
||||
**Void.** `VoidOrderItem` (`repository/order_item_repository.go:104`) bercabang pada
|
||||
`voidQuantity >= orderItem.Quantity`. Untuk baris berbobot, `quantity` dijamin `1` dan
|
||||
`voidQuantity` minimal `1`, sehingga **selalu** masuk cabang void penuh. Cabang
|
||||
pemecahan baris tidak pernah tersentuh, sehingga tidak mungkin lahir baris sisa
|
||||
berbobot nol. Yang berubah hanya perhitungan `voidedAmount` di baris 113 (§5).
|
||||
|
||||
**Refund.** Sama — refund baris berbobot bersifat utuh. Hanya `refundAmount` di
|
||||
`order_processor.go:723` yang perlu memakai `BillableQuantity()`.
|
||||
|
||||
**Split bill.** `payment_order_items.quantity` tetap `INTEGER`. Untuk baris berbobot
|
||||
nilainya `0` atau `1` — bayar penuh atau tidak sama sekali. Seluruh perbandingan di
|
||||
`split_bill_processor.go` tetap aritmatika bilangan bulat, sehingga masalah presisi
|
||||
float tidak pernah muncul. Hanya perhitungan `itemAmount` (baris 143 dan 189) yang
|
||||
berubah.
|
||||
|
||||
**Batasan yang diterima:** refund atau void **sebagian berat** (mengembalikan 1 ons
|
||||
dari baris 4,2 ons) tidak didukung. Untuk barang yang sudah ditimbang dan diserahkan,
|
||||
koreksi sebagian pada praktiknya berarti salah timbang — yang penanganan benarnya
|
||||
adalah void baris itu lalu input ulang, bukan mengubah berat baris yang sudah tercatat.
|
||||
Ini menjaga jejak audit tetap jujur.
|
||||
|
||||
---
|
||||
|
||||
## 7. Validasi & Tampilan
|
||||
|
||||
### 7.1 Aturan validasi
|
||||
|
||||
Divalidasi di processor saat membuat / menambah item, di mana produk sudah dimuat:
|
||||
|
||||
| `products.sell_by` | Aturan |
|
||||
|---|---|
|
||||
| `unit` | `weight` harus kosong. Bila dikirim → tolak. |
|
||||
| `weight` | `weight` wajib ada dan `> 0`. `quantity` dipaksa `1`. |
|
||||
|
||||
`unit_id` di `order_items` diisi dari `products.unit_id` saat baris dibuat (P1) —
|
||||
bukan dibaca lewat join saat ditampilkan.
|
||||
|
||||
Berat dibulatkan ke 3 desimal saat masuk, agar nilai tersimpan selalu sama dengan
|
||||
nilai yang divalidasi.
|
||||
|
||||
### 7.2 Tampilan
|
||||
|
||||
`templates/daily_transaction.html:539` mencetak `{{$item.Quantity}}`. Untuk baris
|
||||
berbobot ini akan menampilkan `1`, bukan `4,2 ons`. Perlu bercabang pada `weight`.
|
||||
|
||||
Response API menambah `weight` dan `unit` pada item, agar frontend dan struk dapat
|
||||
menampilkan `4,2 ons × Rp 4.500` alih-alih `1 × Rp 4.500`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Report
|
||||
|
||||
**Tidak ada perubahan yang wajib.** Karena `quantity` tetap integer dan tetap berarti
|
||||
"berapa banyak barang":
|
||||
|
||||
- `SUM(oi.quantity)` sebagai `total_items` tetap bermakna dan tetap konsisten lintas
|
||||
produk — 2 ikan tetap dihitung 2, bukan 9,8 ons.
|
||||
- `QuantitySold` tetap `int64`. Tidak ada pemotongan pecahan.
|
||||
- `average_price = SUM(total_price) / SUM(quantity)` menjadi "rata-rata harga per
|
||||
ekor", yang tetap merupakan angka bermakna.
|
||||
|
||||
**Tambahan opsional** — melaporkan berat terjual, hanya pada laporan **per produk**
|
||||
(P4):
|
||||
|
||||
```sql
|
||||
COALESCE(SUM(oi.weight), 0) AS weight_sold
|
||||
```
|
||||
|
||||
Tidak boleh dipakai pada agregat lintas produk, karena akan menjumlahkan satuan yang
|
||||
berbeda.
|
||||
|
||||
---
|
||||
|
||||
## 9. Di Luar Scope
|
||||
|
||||
**Pengurangan stok otomatis.** `adjustInventoryWithTransaction`
|
||||
(`order_processor.go:1177`) dan `adjustIngredientInventoryWithTransaction`
|
||||
(`order_processor.go:920`) terdefinisi tetapi **tidak pernah dipanggil dari mana pun** —
|
||||
sudah diverifikasi se-repo. Endpoint CRUD inventory berfungsi; pengurangan stok saat
|
||||
penjualan tidak tersambung.
|
||||
|
||||
Konsekuensi untuk RFC ini: `inventory.quantity` yang masih `int` tidak menghalangi
|
||||
apa pun.
|
||||
|
||||
Catatan untuk nanti bila jalur stok disambungkan:
|
||||
|
||||
- Stok produk timbangan harus berkurang sebesar `weight`, bukan `quantity` — kalau
|
||||
tidak, menjual ikan 4,2 ons hanya mengurangi stok sebanyak 1.
|
||||
- `inventory.quantity` perlu menjadi `DECIMAL(12,3)` lebih dulu. Biayanya hampir nol
|
||||
sekarang (3 call site, tanpa data historis); jauh lebih mahal setelah berjalan.
|
||||
- `order_processor.go:946` berisi `deltaInt := int(delta)` yang memotong pecahan.
|
||||
Kode ini mati, jadi bukan kebocoran aktif — tetapi bila disambungkan tanpa
|
||||
diperbaiki, konsumsi bahan di bawah 1 unit akan hilang diam-diam.
|
||||
|
||||
Kedua fungsi mati itu sebaiknya **dihapus atau disambungkan**, jangan dibiarkan
|
||||
menggantung — komentar di dalamnya ditulis seolah-olah aktif.
|
||||
|
||||
---
|
||||
|
||||
## 10. Temuan Sampingan: `unit_price` pada request diabaikan
|
||||
|
||||
`CreateOrderItemRequest.UnitPrice` (`contract/order_contract.go:46`) berkomentar
|
||||
*"Optional, will use database price if not provided"*. Kenyataannya field ini **tidak
|
||||
pernah dipakai** — satu-satunya yang menyentuhnya adalah validasi `< 0` di
|
||||
`service/order_service.go:431` dan `:474`. Processor selalu membaca harga dari
|
||||
`products` / `product_outlet_prices`.
|
||||
|
||||
Perilaku sekarang sudah benar dan sesuai P3. Yang salah hanya komentarnya, yang
|
||||
menyiratkan klien bisa mengirim harga. Sebaiknya field itu **dihapus** dari contract,
|
||||
atau komentarnya dikoreksi menjadi keterangan bahwa harga selalu diambil dari database.
|
||||
|
||||
Dibiarkan seperti sekarang, ini mengundang frontend mengirim harga dan menyangka
|
||||
berhasil, padahal diabaikan diam-diam.
|
||||
|
||||
---
|
||||
|
||||
## 11. Urutan Implementasi
|
||||
|
||||
1. **Migrasi skema** (§4). Aman: semua kolom nullable atau ber-default, data lama
|
||||
tidak tersentuh.
|
||||
2. **`BillableQuantity()` + `CalculateTotalPrice()` / `CalculateTotalCost()`** (§5).
|
||||
3. **Ganti 8 titik perkalian harga** (§5) lalu jalankan dua `grep` verifikasi.
|
||||
4. **Field kontrak**: `weight` pada request order & self-order, `weight` + `unit` pada
|
||||
response.
|
||||
5. **Validasi `sell_by`** (§7.1).
|
||||
6. **Template & tampilan struk** (§7.2).
|
||||
7. *(Opsional)* `weight_sold` pada laporan per produk (§8).
|
||||
|
||||
Langkah 1-4 membuat produk timbangan dapat dijual dengan harga yang benar. Langkah 5
|
||||
mencegah data tidak konsisten masuk. Langkah 6 membuat struk terbaca benar.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risiko
|
||||
|
||||
| Risiko | Dampak | Mitigasi |
|
||||
|---|---|---|
|
||||
| Satu titik perkalian harga terlewat | Ikan 4,2 ons ditagih seharga 1 ons — salah uang, tanpa error | Dua `grep` verifikasi di §5; uji satu order timbangan lewat setiap jalur (create, tambah item, void, refund, split bill) |
|
||||
| `weight` dikirim untuk produk `unit` | Harga baris salah total | Validasi §7.1 + constraint DB |
|
||||
| `quantity > 1` pada baris berbobot | `BillableQuantity()` ambigu | Dicegah `chk_order_items_weight_single_line` di level DB |
|
||||
| Klien lama tidak mengirim `weight` | Produk timbangan ditagih 1 satuan | Validasi §7.1 menolak, bukan mendiamkan |
|
||||
| Struk menampilkan `1` alih-alih `4,2 ons` | Pelanggan bingung, kasir kehilangan kepercayaan | §7.2 |
|
||||
|
||||
---
|
||||
|
||||
## 13. Pertanyaan Terbuka
|
||||
|
||||
1. **Pembulatan uang — diputuskan sementara, perlu konfirmasi.** `RoundMoney`
|
||||
membulatkan ke **2 desimal**, mengikuti presisi kolom `decimal(10,2)` yang sudah
|
||||
dipakai semua nilai uang. Jadi `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`.
|
||||
|
||||
Ini pilihan paling tidak mengejutkan dan konsisten dengan data lama, tetapi **bukan**
|
||||
pembulatan ke rupiah utuh. Bila kasir harus menerima uang dalam rupiah penuh (atau
|
||||
kelipatan Rp 100/500), ubah `RoundMoney` di `entities/order_item.go` — satu tempat,
|
||||
dan lakukan **sebelum** ada transaksi timbangan, karena setelahnya data lama dan
|
||||
baru akan mengikuti aturan berbeda.
|
||||
2. **Presisi input berat.** Apakah `4,237 ons` (resolusi 0,1 gram) valid, atau input
|
||||
harus dibatasi ke kelipatan tertentu sesuai resolusi timbangan? Bila perlu dibatasi,
|
||||
tambahkan `products.min_weight_increment`.
|
||||
3. **Sumber angka timbangan** — kasir mengetik manual atau timbangan tersambung? Bila
|
||||
tersambung, ada urusan tara dan pembacaan stabil yang berada di luar RFC ini.
|
||||
@@ -0,0 +1,17 @@
|
||||
package constants
|
||||
|
||||
// SellBy describes how a product's quantity is expressed on an order line.
|
||||
type SellBy = string
|
||||
|
||||
const (
|
||||
// SellByUnit is the default: the line counts discrete items (2 portions, 3 bottles).
|
||||
SellByUnit SellBy = "unit"
|
||||
// SellByWeight means the line carries a weighed amount. One weighing is one line,
|
||||
// so quantity stays 1 and the measured value lives in order_items.weight.
|
||||
SellByWeight SellBy = "weight"
|
||||
)
|
||||
|
||||
// IsValidSellBy reports whether s is a supported sell_by value.
|
||||
func IsValidSellBy(s string) bool {
|
||||
return s == SellByUnit || s == SellByWeight
|
||||
}
|
||||
@@ -198,6 +198,7 @@ type ProductAnalyticsData struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
@@ -324,6 +325,7 @@ type ParentCategoryAnalyticsProductData struct {
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
|
||||
@@ -43,7 +43,10 @@ type CreateOrderItemRequest struct {
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Optional, will use database price if not provided
|
||||
// Weight is required for products with sell_by = "weight" and must be absent otherwise.
|
||||
// One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price.
|
||||
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Ignored: price always comes from the database
|
||||
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
@@ -101,6 +104,11 @@ type OrderItemResponse struct {
|
||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||
CategoryName *string `json:"category_name,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
// Weight is set only for products sold by weight; the line is priced weight * unit_price.
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
UnitName *string `json:"unit_name,omitempty"`
|
||||
UnitAbbreviation *string `json:"unit_abbreviation,omitempty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
Modifiers []map[string]interface{} `json:"modifiers"`
|
||||
|
||||
@@ -18,6 +18,8 @@ type CreateProductRequest struct {
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
|
||||
@@ -38,6 +40,8 @@ type UpdateProductRequest struct {
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
||||
@@ -73,6 +77,8 @@ type ProductResponse struct {
|
||||
BusinessType string `json:"business_type"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy string `json:"sell_by"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
IsActive bool `json:"is_active"`
|
||||
|
||||
@@ -58,6 +58,7 @@ type SelfOrderCreateOrderItem struct {
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ type ProductAnalytics struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -47,6 +48,8 @@ type OrderItem struct {
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `gorm:"type:uuid;index" json:"product_variant_id"`
|
||||
Quantity int `gorm:"not null" json:"quantity" validate:"required,min=1"`
|
||||
Weight *float64 `gorm:"type:decimal(12,3)" json:"weight,omitempty"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id,omitempty"`
|
||||
UnitPrice float64 `gorm:"type:decimal(10,2);not null" json:"unit_price" validate:"required,min=0"`
|
||||
TotalPrice float64 `gorm:"type:decimal(10,2);not null" json:"total_price" validate:"required,min=0"`
|
||||
UnitCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"unit_cost"`
|
||||
@@ -68,6 +71,7 @@ type OrderItem struct {
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"`
|
||||
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error {
|
||||
@@ -81,8 +85,57 @@ func (OrderItem) TableName() string {
|
||||
return "order_items"
|
||||
}
|
||||
|
||||
// IsWeighed reports whether this line was sold by weight rather than by count.
|
||||
func (oi *OrderItem) IsWeighed() bool {
|
||||
return oi.Weight != nil
|
||||
}
|
||||
|
||||
// BillableQuantity is the multiplier for unit price and unit cost on this line:
|
||||
// the weighed amount for weight-based products, the item count otherwise.
|
||||
//
|
||||
// This is the ONLY place that decides between the two. Every price or cost
|
||||
// calculation must go through it — multiplying by Quantity directly would bill a
|
||||
// 4.2 ons fish as a single ons.
|
||||
//
|
||||
// Weighed lines are pinned to quantity = 1 by chk_order_items_weight_single_line,
|
||||
// so there is no ambiguity between counting and weighing.
|
||||
func (oi *OrderItem) BillableQuantity() float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(oi.Quantity)
|
||||
}
|
||||
|
||||
// BillableQuantityFor is BillableQuantity for a partial action on this line, such as
|
||||
// voiding or refunding `units` of it.
|
||||
//
|
||||
// Weighed lines are always acted on in full — quantity is 1, so any void or refund
|
||||
// covers the whole weighing — hence the full weight is returned regardless of units.
|
||||
func (oi *OrderItem) BillableQuantityFor(units int) float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(units)
|
||||
}
|
||||
|
||||
// RoundMoney rounds to the 2 decimals that every monetary column stores, so the
|
||||
// value written back always matches what the database keeps.
|
||||
func RoundMoney(amount float64) float64 {
|
||||
return math.Round(amount*100) / 100
|
||||
}
|
||||
|
||||
// RoundWeight rounds to the 3 decimals of order_items.weight, so a validated value
|
||||
// is identical to the stored one.
|
||||
func RoundWeight(weight float64) float64 {
|
||||
return math.Round(weight*1000) / 1000
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalPrice() {
|
||||
oi.TotalPrice = float64(oi.Quantity) * oi.UnitPrice
|
||||
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalCost() {
|
||||
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CanBeModified() bool {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package entities
|
||||
|
||||
import "testing"
|
||||
|
||||
func weightPtr(v float64) *float64 { return &v }
|
||||
|
||||
func TestBillableQuantity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item OrderItem
|
||||
want float64
|
||||
}{
|
||||
{"unit line counts items", OrderItem{Quantity: 3}, 3},
|
||||
{"weighed line uses the weight", OrderItem{Quantity: 1, Weight: weightPtr(4.2)}, 4.2},
|
||||
{"weight of zero is still a weighed line", OrderItem{Quantity: 1, Weight: weightPtr(0)}, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.item.BillableQuantity(); got != tt.want {
|
||||
t.Errorf("BillableQuantity() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A weighed line is voided or refunded whole, so the units asked for must not shrink
|
||||
// the amount: refunding "1" of a 4.2 ons line refunds all 4.2 ons.
|
||||
func TestBillableQuantityForIgnoresUnitsOnWeighedLines(t *testing.T) {
|
||||
weighed := OrderItem{Quantity: 1, Weight: weightPtr(5.6)}
|
||||
if got := weighed.BillableQuantityFor(1); got != 5.6 {
|
||||
t.Errorf("weighed BillableQuantityFor(1) = %v, want 5.6", got)
|
||||
}
|
||||
|
||||
counted := OrderItem{Quantity: 3}
|
||||
if got := counted.BillableQuantityFor(2); got != 2 {
|
||||
t.Errorf("unit BillableQuantityFor(2) = %v, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The regression this whole feature exists to prevent: a 4.2 ons fish priced per ons
|
||||
// must not be billed as a single ons.
|
||||
func TestCalculateTotalPriceForWeighedLine(t *testing.T) {
|
||||
item := OrderItem{Quantity: 1, Weight: weightPtr(4.2), UnitPrice: 4500, UnitCost: 3000}
|
||||
item.CalculateTotalPrice()
|
||||
item.CalculateTotalCost()
|
||||
|
||||
if item.TotalPrice != 18900 {
|
||||
t.Errorf("TotalPrice = %v, want 18900", item.TotalPrice)
|
||||
}
|
||||
if item.TotalCost != 12600 {
|
||||
t.Errorf("TotalCost = %v, want 12600", item.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateTotalPriceForUnitLine(t *testing.T) {
|
||||
item := OrderItem{Quantity: 3, UnitPrice: 25000, UnitCost: 10000}
|
||||
item.CalculateTotalPrice()
|
||||
item.CalculateTotalCost()
|
||||
|
||||
if item.TotalPrice != 75000 {
|
||||
t.Errorf("TotalPrice = %v, want 75000", item.TotalPrice)
|
||||
}
|
||||
if item.TotalCost != 30000 {
|
||||
t.Errorf("TotalCost = %v, want 30000", item.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-rupiah residue must not survive into a decimal(10,2) column.
|
||||
func TestCalculateTotalPriceRoundsToStoredPrecision(t *testing.T) {
|
||||
item := OrderItem{Quantity: 1, Weight: weightPtr(4.237), UnitPrice: 4500}
|
||||
item.CalculateTotalPrice()
|
||||
|
||||
if item.TotalPrice != 19066.5 {
|
||||
t.Errorf("TotalPrice = %v, want 19066.5", item.TotalPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWeighed(t *testing.T) {
|
||||
if (&OrderItem{Quantity: 1}).IsWeighed() {
|
||||
t.Error("unit line reported as weighed")
|
||||
}
|
||||
if !(&OrderItem{Quantity: 1, Weight: weightPtr(1.5)}).IsWeighed() {
|
||||
t.Error("weighed line reported as unit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundWeight(t *testing.T) {
|
||||
tests := []struct {
|
||||
in float64
|
||||
want float64
|
||||
}{
|
||||
{4.2, 4.2},
|
||||
{4.2374, 4.237},
|
||||
{4.2375, 4.238},
|
||||
{0.0004, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := RoundWeight(tt.in); got != tt.want {
|
||||
t.Errorf("RoundWeight(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Product struct {
|
||||
ImageURL *string `gorm:"size:500" json:"image_url"`
|
||||
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
|
||||
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
|
||||
@@ -344,6 +344,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
|
||||
ProductID: item.ProductID,
|
||||
ProductVariantID: item.ProductVariantID,
|
||||
Quantity: item.Quantity,
|
||||
Weight: item.Weight,
|
||||
Notes: item.Notes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,12 +115,23 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
|
||||
}
|
||||
}
|
||||
|
||||
// Unit is only meaningful for weighed lines, but carry it whenever it was captured.
|
||||
var unitName, unitAbbreviation *string
|
||||
if item.Unit != nil {
|
||||
unitName = &item.Unit.Name
|
||||
unitAbbreviation = item.Unit.Abbreviation
|
||||
}
|
||||
|
||||
response := &models.OrderItemResponse{
|
||||
ID: item.ID,
|
||||
OrderID: item.OrderID,
|
||||
ProductID: item.ProductID,
|
||||
ProductVariantID: item.ProductVariantID,
|
||||
Quantity: item.Quantity,
|
||||
Weight: item.Weight,
|
||||
UnitID: item.UnitID,
|
||||
UnitName: unitName,
|
||||
UnitAbbreviation: unitAbbreviation,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: item.TotalPrice,
|
||||
UnitCost: item.UnitCost,
|
||||
|
||||
@@ -25,6 +25,7 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
SellBy: entity.SellBy,
|
||||
Metadata: map[string]interface{}(entity.Metadata),
|
||||
IsActive: entity.IsActive,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
@@ -76,6 +77,11 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
printerType = *req.PrinterType
|
||||
}
|
||||
|
||||
sellBy := constants.SellByUnit
|
||||
if constants.IsValidSellBy(req.SellBy) {
|
||||
sellBy = req.SellBy
|
||||
}
|
||||
|
||||
metadata := entities.Metadata{}
|
||||
if req.Metadata != nil {
|
||||
metadata = entities.Metadata(req.Metadata)
|
||||
@@ -92,6 +98,8 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
BusinessType: businessType,
|
||||
ImageURL: req.ImageURL,
|
||||
PrinterType: printerType,
|
||||
UnitID: req.UnitID,
|
||||
SellBy: sellBy,
|
||||
Metadata: metadata,
|
||||
IsActive: true, // Default to active
|
||||
}
|
||||
@@ -140,6 +148,8 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
UnitID: entity.UnitID,
|
||||
SellBy: entity.SellBy,
|
||||
Metadata: map[string]interface{}(entity.Metadata),
|
||||
IsActive: entity.IsActive,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
@@ -185,6 +195,14 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
|
||||
entity.PrinterType = *req.PrinterType
|
||||
}
|
||||
|
||||
if req.UnitID != nil {
|
||||
entity.UnitID = req.UnitID
|
||||
}
|
||||
|
||||
if req.SellBy != nil && constants.IsValidSellBy(*req.SellBy) {
|
||||
entity.SellBy = *req.SellBy
|
||||
}
|
||||
|
||||
if req.Metadata != nil {
|
||||
if entity.Metadata == nil {
|
||||
entity.Metadata = make(entities.Metadata)
|
||||
|
||||
@@ -233,6 +233,7 @@ type ProductAnalyticsData struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
@@ -359,6 +360,7 @@ type ParentCategoryAnalyticsProductData struct {
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
|
||||
@@ -44,6 +44,8 @@ type OrderItem struct {
|
||||
ProductID uuid.UUID
|
||||
ProductVariantID *uuid.UUID
|
||||
Quantity int
|
||||
Weight *float64
|
||||
UnitID *uuid.UUID
|
||||
UnitPrice float64
|
||||
TotalPrice float64
|
||||
UnitCost float64
|
||||
@@ -99,7 +101,8 @@ type CreateOrderItemRequest struct {
|
||||
ProductID uuid.UUID `validate:"required"`
|
||||
ProductVariantID *uuid.UUID `validate:"omitempty"`
|
||||
Quantity int `validate:"required,min=1"`
|
||||
UnitPrice *float64 `validate:"omitempty,min=0"` // Optional, will use database price if not provided
|
||||
Weight *float64 `validate:"omitempty,gt=0"`
|
||||
UnitPrice *float64 `validate:"omitempty,min=0"` // Ignored: price always comes from the database
|
||||
Modifiers []map[string]interface{} `validate:"omitempty"`
|
||||
Notes *string `validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `validate:"omitempty"`
|
||||
@@ -191,6 +194,10 @@ type OrderItemResponse struct {
|
||||
CategoryID *uuid.UUID
|
||||
CategoryName *string
|
||||
Quantity int
|
||||
Weight *float64
|
||||
UnitID *uuid.UUID
|
||||
UnitName *string
|
||||
UnitAbbreviation *string
|
||||
UnitPrice float64
|
||||
TotalPrice float64
|
||||
UnitCost float64
|
||||
|
||||
@@ -19,6 +19,7 @@ type Product struct {
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
SellBy string
|
||||
UnitID *uuid.UUID
|
||||
HasIngredients bool
|
||||
Metadata map[string]interface{}
|
||||
@@ -52,6 +53,7 @@ type CreateProductRequest struct {
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy string `validate:"omitempty,oneof=unit weight"`
|
||||
HasIngredients bool `validate:"omitempty"`
|
||||
Metadata map[string]interface{}
|
||||
Variants []CreateProductVariantRequest `validate:"omitempty,dive"`
|
||||
@@ -73,6 +75,7 @@ type UpdateProductRequest struct {
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy *string `validate:"omitempty,oneof=unit weight"`
|
||||
HasIngredients *bool `validate:"omitempty"`
|
||||
Metadata map[string]interface{}
|
||||
IsActive *bool
|
||||
@@ -110,6 +113,7 @@ type ProductResponse struct {
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
SellBy string
|
||||
PrintToChecker bool
|
||||
UnitID *uuid.UUID
|
||||
HasIngredients bool
|
||||
|
||||
@@ -322,6 +322,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
|
||||
CategoryName: data.CategoryName,
|
||||
CategoryOrder: data.CategoryOrder,
|
||||
QuantitySold: data.QuantitySold,
|
||||
WeightSold: data.WeightSold,
|
||||
Revenue: data.Revenue,
|
||||
AveragePrice: data.AveragePrice,
|
||||
OrderCount: data.OrderCount,
|
||||
@@ -447,6 +448,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
ProductSku: product.ProductSku,
|
||||
ProductPrice: product.ProductPrice,
|
||||
QuantitySold: product.QuantitySold,
|
||||
WeightSold: product.WeightSold,
|
||||
Revenue: product.Revenue,
|
||||
AveragePrice: product.AveragePrice,
|
||||
OrderCount: product.OrderCount,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
@@ -149,6 +150,33 @@ func NewOrderProcessorImpl(
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLineQuantity reconciles the requested quantity and weight against how the
|
||||
// product is sold, returning the values to store on the order line.
|
||||
//
|
||||
// For weight-based products a line is exactly one weighing, so quantity is pinned to 1
|
||||
// and the weighed amount is what gets priced. This mirrors
|
||||
// chk_order_items_weight_single_line in the database.
|
||||
func resolveLineQuantity(product *entities.Product, quantity int, weight *float64) (int, *float64, error) {
|
||||
if product.SellBy == constants.SellByWeight {
|
||||
if weight == nil {
|
||||
return 0, nil, fmt.Errorf("product %s is sold by weight and requires a weight", product.Name)
|
||||
}
|
||||
rounded := entities.RoundWeight(*weight)
|
||||
if rounded <= 0 {
|
||||
return 0, nil, fmt.Errorf("weight for product %s must be greater than 0", product.Name)
|
||||
}
|
||||
return 1, &rounded, nil
|
||||
}
|
||||
|
||||
if weight != nil {
|
||||
return 0, nil, fmt.Errorf("product %s is not sold by weight and must not carry a weight", product.Name)
|
||||
}
|
||||
if quantity < 1 {
|
||||
return 0, nil, fmt.Errorf("quantity for product %s must be at least 1", product.Name)
|
||||
}
|
||||
return quantity, nil, nil
|
||||
}
|
||||
|
||||
func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
|
||||
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
|
||||
if err != nil {
|
||||
@@ -194,25 +222,29 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
|
||||
}
|
||||
}
|
||||
|
||||
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
|
||||
itemTotalCost := float64(itemReq.Quantity) * unitCost
|
||||
|
||||
subtotal += itemTotalPrice
|
||||
totalCost += itemTotalCost
|
||||
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orderItem := &entities.OrderItem{
|
||||
ProductID: itemReq.ProductID,
|
||||
ProductVariantID: itemReq.ProductVariantID,
|
||||
Quantity: itemReq.Quantity,
|
||||
Quantity: quantity,
|
||||
Weight: weight,
|
||||
UnitID: product.UnitID,
|
||||
UnitPrice: unitPrice,
|
||||
TotalPrice: itemTotalPrice,
|
||||
UnitCost: unitCost,
|
||||
TotalCost: itemTotalCost,
|
||||
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
||||
Notes: itemReq.Notes,
|
||||
Metadata: entities.Metadata(itemReq.Metadata),
|
||||
Status: entities.OrderItemStatusPending,
|
||||
}
|
||||
orderItem.CalculateTotalPrice()
|
||||
orderItem.CalculateTotalCost()
|
||||
|
||||
subtotal += orderItem.TotalPrice
|
||||
totalCost += orderItem.TotalCost
|
||||
|
||||
orderItems = append(orderItems, orderItem)
|
||||
}
|
||||
@@ -327,26 +359,30 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
||||
}
|
||||
}
|
||||
|
||||
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
|
||||
itemTotalCost := float64(itemReq.Quantity) * unitCost
|
||||
|
||||
newSubtotal += itemTotalPrice
|
||||
newTotalCost += itemTotalCost
|
||||
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orderItem := &entities.OrderItem{
|
||||
OrderID: orderID,
|
||||
ProductID: itemReq.ProductID,
|
||||
ProductVariantID: itemReq.ProductVariantID,
|
||||
Quantity: itemReq.Quantity,
|
||||
Quantity: quantity,
|
||||
Weight: weight,
|
||||
UnitID: product.UnitID,
|
||||
UnitPrice: unitPrice,
|
||||
TotalPrice: itemTotalPrice,
|
||||
UnitCost: unitCost,
|
||||
TotalCost: itemTotalCost,
|
||||
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
||||
Notes: itemReq.Notes,
|
||||
Metadata: entities.Metadata(itemReq.Metadata),
|
||||
Status: entities.OrderItemStatusPending,
|
||||
}
|
||||
orderItem.CalculateTotalPrice()
|
||||
orderItem.CalculateTotalCost()
|
||||
|
||||
newSubtotal += orderItem.TotalPrice
|
||||
newTotalCost += orderItem.TotalCost
|
||||
|
||||
addedOrderItems = append(addedOrderItems, orderItem)
|
||||
}
|
||||
@@ -602,8 +638,9 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
|
||||
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
|
||||
}
|
||||
|
||||
voidedAmount := float64(itemVoid.Quantity) * orderItem.UnitPrice
|
||||
voidedCost := float64(itemVoid.Quantity) * orderItem.UnitCost
|
||||
billable := orderItem.BillableQuantityFor(itemVoid.Quantity)
|
||||
voidedAmount := entities.RoundMoney(billable * orderItem.UnitPrice)
|
||||
voidedCost := entities.RoundMoney(billable * orderItem.UnitCost)
|
||||
|
||||
totalVoidedAmount += voidedAmount
|
||||
totalVoidedCost += voidedCost
|
||||
@@ -720,7 +757,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
|
||||
return fmt.Errorf("refund quantity cannot exceed original quantity")
|
||||
}
|
||||
|
||||
refundAmount := float64(refundQuantity) * orderItem.UnitPrice
|
||||
refundAmount := entities.RoundMoney(orderItem.BillableQuantityFor(refundQuantity) * orderItem.UnitPrice)
|
||||
if itemRefund.RefundAmount != nil {
|
||||
refundAmount = *itemRefund.RefundAmount
|
||||
}
|
||||
@@ -1016,7 +1053,7 @@ func (p *OrderProcessorImpl) createInventoryMovement(ctx context.Context, item *
|
||||
PreviousQuantity: float64(updatedInventory.Quantity + item.Quantity), // Add back the quantity that was subtracted
|
||||
NewQuantity: float64(updatedInventory.Quantity),
|
||||
UnitCost: item.UnitCost,
|
||||
TotalCost: float64(item.Quantity) * item.UnitCost,
|
||||
TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
|
||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||
t := entities.InventoryMovementReferenceTypePayment
|
||||
return &t
|
||||
@@ -1314,7 +1351,7 @@ func (p *OrderProcessorImpl) prepareProductInventoryMovement(item *entities.Orde
|
||||
PreviousQuantity: float64(previousQuantity),
|
||||
NewQuantity: float64(updatedInventory.Quantity),
|
||||
UnitCost: item.UnitCost,
|
||||
TotalCost: float64(item.Quantity) * item.UnitCost,
|
||||
TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
|
||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||
t := entities.InventoryMovementReferenceTypePayment
|
||||
return &t
|
||||
@@ -1380,7 +1417,7 @@ type ingredientRecipeItem struct {
|
||||
|
||||
// prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls
|
||||
func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeItem, error) {
|
||||
totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
|
||||
totalIngredientQuantity := recipe.Quantity * item.BillableQuantity()
|
||||
|
||||
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
|
||||
if err != nil {
|
||||
@@ -1478,7 +1515,7 @@ func (p *OrderProcessorImpl) prepareRefundProductInventoryMovement(item *entitie
|
||||
PreviousQuantity: float64(previousQuantity),
|
||||
NewQuantity: float64(updatedInventory.Quantity),
|
||||
UnitCost: item.UnitCost,
|
||||
TotalCost: float64(refundedQuantity) * item.UnitCost,
|
||||
TotalCost: entities.RoundMoney(item.BillableQuantityFor(refundedQuantity) * item.UnitCost),
|
||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||
t := entities.InventoryMovementReferenceTypeRefund
|
||||
return &t
|
||||
@@ -1544,7 +1581,7 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeData(ctx context.Con
|
||||
// prepareRefundedIngredientRecipeItem prepares data for a single ingredient recipe restoration
|
||||
func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) (*ingredientRecipeItem, error) {
|
||||
// Calculate total ingredient quantity needed based on order item quantity
|
||||
totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
|
||||
totalIngredientQuantity := recipe.Quantity * item.BillableQuantity()
|
||||
|
||||
// Get current ingredient details
|
||||
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
func fPtr(v float64) *float64 { return &v }
|
||||
|
||||
func TestResolveLineQuantity(t *testing.T) {
|
||||
weightProduct := &entities.Product{Name: "Ikan Tude", SellBy: constants.SellByWeight}
|
||||
unitProduct := &entities.Product{Name: "Nasi Goreng", SellBy: constants.SellByUnit}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
product *entities.Product
|
||||
quantity int
|
||||
weight *float64
|
||||
wantQuantity int
|
||||
wantWeight *float64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "weighed line is pinned to quantity 1",
|
||||
// Mirrors chk_order_items_weight_single_line: even if a client sends 3,
|
||||
// one weighing is one line.
|
||||
product: weightProduct, quantity: 3, weight: fPtr(4.2),
|
||||
wantQuantity: 1, wantWeight: fPtr(4.2),
|
||||
},
|
||||
{
|
||||
name: "weight is rounded to stored precision",
|
||||
product: weightProduct, quantity: 1, weight: fPtr(5.6004),
|
||||
wantQuantity: 1, wantWeight: fPtr(5.6),
|
||||
},
|
||||
{
|
||||
name: "weight product without a weight is rejected",
|
||||
product: weightProduct, quantity: 1, weight: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "weight that rounds away to zero is rejected",
|
||||
product: weightProduct, quantity: 1, weight: fPtr(0.0004),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unit line keeps its quantity and carries no weight",
|
||||
product: unitProduct, quantity: 3, weight: nil,
|
||||
wantQuantity: 3, wantWeight: nil,
|
||||
},
|
||||
{
|
||||
name: "weight on a unit product is rejected",
|
||||
// Otherwise the line would silently be priced by weight.
|
||||
product: unitProduct, quantity: 1, weight: fPtr(4.2),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-positive quantity on a unit product is rejected",
|
||||
product: unitProduct, quantity: 0, weight: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotQty, gotWeight, err := resolveLineQuantity(tt.product, tt.quantity, tt.weight)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error, got quantity=%v weight=%v", gotQty, gotWeight)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if gotQty != tt.wantQuantity {
|
||||
t.Errorf("quantity = %v, want %v", gotQty, tt.wantQuantity)
|
||||
}
|
||||
switch {
|
||||
case tt.wantWeight == nil && gotWeight != nil:
|
||||
t.Errorf("weight = %v, want nil", *gotWeight)
|
||||
case tt.wantWeight != nil && gotWeight == nil:
|
||||
t.Errorf("weight = nil, want %v", *tt.wantWeight)
|
||||
case tt.wantWeight != nil && *gotWeight != *tt.wantWeight:
|
||||
t.Errorf("weight = %v, want %v", *gotWeight, *tt.wantWeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A product created before this feature has sell_by defaulted to 'unit' by the
|
||||
// migration, but a zero-valued struct must behave the same way.
|
||||
func TestResolveLineQuantityTreatsEmptySellByAsUnit(t *testing.T) {
|
||||
legacy := &entities.Product{Name: "Legacy"}
|
||||
|
||||
quantity, weight, err := resolveLineQuantity(legacy, 2, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if quantity != 2 || weight != nil {
|
||||
t.Errorf("got quantity=%v weight=%v, want quantity=2 weight=nil", quantity, weight)
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func calculateSplitAmounts(req *models.SplitBillRequest, validation *SplitBillVa
|
||||
|
||||
for _, item := range req.Items {
|
||||
orderItem := validation.OrderItems[item.OrderItemID]
|
||||
itemAmount := float64(item.Quantity) * orderItem.UnitPrice
|
||||
itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
|
||||
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
|
||||
totalItemAmount := itemAmount + itemTaxAmount
|
||||
|
||||
@@ -186,7 +186,7 @@ func (p *SplitBillProcessorImpl) createSplitPayment(ctx context.Context, req *mo
|
||||
func (p *SplitBillProcessorImpl) createPaymentOrderItems(ctx context.Context, splitPayment *entities.Payment, req *models.SplitBillRequest, validation *SplitBillValidation) error {
|
||||
for _, item := range req.Items {
|
||||
orderItem := validation.OrderItems[item.OrderItemID]
|
||||
itemAmount := float64(item.Quantity) * orderItem.UnitPrice
|
||||
itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
|
||||
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
|
||||
totalItemAmount := itemAmount + itemTaxAmount
|
||||
|
||||
@@ -228,7 +228,7 @@ func (p *SplitBillProcessorImpl) updateOrderAfterSplit(ctx context.Context, orde
|
||||
quantityInfo[item.OrderItemID.String()] = map[string]interface{}{
|
||||
"quantity": item.Quantity,
|
||||
"unit_price": orderItem.UnitPrice,
|
||||
"total_amount": float64(item.Quantity) * orderItem.UnitPrice,
|
||||
"total_amount": entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice),
|
||||
}
|
||||
}
|
||||
order.Metadata[MetadataKeyLastSplitQuantities] = quantityInfo
|
||||
|
||||
@@ -461,6 +461,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
|
||||
c.name as category_name,
|
||||
c.order as category_order,
|
||||
COALESCE(SUM(oi.quantity), 0) as quantity_sold,
|
||||
COALESCE(SUM(oi.weight), 0) as weight_sold,
|
||||
COALESCE(SUM(oi.total_price), 0) as revenue,
|
||||
CASE
|
||||
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity)
|
||||
|
||||
@@ -50,6 +50,7 @@ func (r *OrderItemRepositoryImpl) GetByOrderID(ctx context.Context, orderID uuid
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Product").
|
||||
Preload("ProductVariant").
|
||||
Preload("Unit").
|
||||
Where("order_id = ?", orderID).
|
||||
Find(&orderItems).Error
|
||||
return orderItems, err
|
||||
@@ -109,8 +110,12 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
|
||||
return err
|
||||
}
|
||||
|
||||
if voidQuantity >= orderItem.Quantity {
|
||||
voidedAmount := float64(voidQuantity) * orderItem.UnitPrice
|
||||
// A weighed line is one weighing, so it is voided whole — never split into a
|
||||
// remainder row. chk_order_items_weight_single_line pins its quantity to 1, which
|
||||
// already sends it down this branch; the explicit check keeps that guarantee
|
||||
// visible and independent of the comparison below.
|
||||
if orderItem.IsWeighed() || voidQuantity >= orderItem.Quantity {
|
||||
voidedAmount := entities.RoundMoney(orderItem.BillableQuantityFor(voidQuantity) * orderItem.UnitPrice)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"refund_quantity": voidQuantity,
|
||||
@@ -128,16 +133,18 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// Only unit-priced lines reach here: weighed lines were voided whole above.
|
||||
voidedOrderItem := entities.OrderItem{
|
||||
OrderID: orderItem.OrderID,
|
||||
ProductID: orderItem.ProductID,
|
||||
ProductVariantID: orderItem.ProductVariantID,
|
||||
Quantity: voidQuantity,
|
||||
UnitID: orderItem.UnitID,
|
||||
UnitPrice: orderItem.UnitPrice,
|
||||
TotalPrice: float64(voidQuantity) * orderItem.UnitPrice,
|
||||
TotalPrice: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
|
||||
UnitCost: orderItem.UnitCost,
|
||||
TotalCost: float64(voidQuantity) * orderItem.UnitCost,
|
||||
RefundAmount: float64(voidQuantity) * orderItem.UnitPrice,
|
||||
TotalCost: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitCost),
|
||||
RefundAmount: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
|
||||
RefundQuantity: voidQuantity,
|
||||
IsPartiallyRefunded: false,
|
||||
IsFullyRefunded: true,
|
||||
@@ -155,8 +162,8 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
|
||||
}
|
||||
|
||||
remainingQuantity := orderItem.Quantity - voidQuantity
|
||||
remainingTotalPrice := float64(remainingQuantity) * orderItem.UnitPrice
|
||||
remainingTotalCost := float64(remainingQuantity) * orderItem.UnitCost
|
||||
remainingTotalPrice := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitPrice)
|
||||
remainingTotalCost := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitCost)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"quantity": remainingQuantity,
|
||||
|
||||
@@ -63,6 +63,7 @@ func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID
|
||||
Preload("OrderItems.Product.Category").
|
||||
Preload("OrderItems.Product.ProductOutletPrices").
|
||||
Preload("OrderItems.ProductVariant").
|
||||
Preload("OrderItems.Unit").
|
||||
Preload("Payments").
|
||||
Preload("Payments.PaymentMethod").
|
||||
Preload("Payments.PaymentOrderItems").
|
||||
@@ -144,6 +145,7 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter
|
||||
Preload("OrderItems.Product.Category").
|
||||
Preload("OrderItems.Product.ProductOutletPrices").
|
||||
Preload("OrderItems.ProductVariant").
|
||||
Preload("OrderItems.Unit").
|
||||
Preload("Payments").
|
||||
Preload("Payments.PaymentMethod").
|
||||
Preload("Payments.PaymentOrderItems").
|
||||
@@ -162,6 +164,7 @@ func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID str
|
||||
Preload("OrderItems.Product.Category").
|
||||
Preload("OrderItems.Product.ProductOutletPrices").
|
||||
Preload("OrderItems.ProductVariant").
|
||||
Preload("OrderItems.Unit").
|
||||
Preload("Payments").
|
||||
Preload("Payments.PaymentMethod").
|
||||
Preload("Payments.PaymentOrderItems").
|
||||
|
||||
@@ -64,6 +64,7 @@ type reportSummary struct {
|
||||
type reportItem struct {
|
||||
Name string
|
||||
Quantity int64
|
||||
Weight string
|
||||
GrossSales string
|
||||
Discount string
|
||||
NetSales string
|
||||
@@ -161,6 +162,7 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org
|
||||
items = append(items, reportItem{
|
||||
Name: p.ProductName,
|
||||
Quantity: p.QuantitySold,
|
||||
Weight: formatWeight(p.WeightSold),
|
||||
GrossSales: formatCurrency(p.Revenue),
|
||||
Discount: formatCurrency(0),
|
||||
NetSales: formatCurrency(p.Revenue),
|
||||
@@ -489,6 +491,16 @@ func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLoss
|
||||
return result
|
||||
}
|
||||
|
||||
// formatWeight renders the weight sold for a product, or a dash when the product is
|
||||
// not sold by weight. Trailing zeros are trimmed so 4.200 prints as "4,2".
|
||||
func formatWeight(weight float64) string {
|
||||
if weight <= 0 {
|
||||
return "-"
|
||||
}
|
||||
s := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.3f", weight), "0"), ".")
|
||||
return strings.Replace(s, ".", ",", 1)
|
||||
}
|
||||
|
||||
func formatCurrencyOrDash(amount float64) string {
|
||||
if amount == 0 {
|
||||
return "-"
|
||||
|
||||
@@ -291,6 +291,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
|
||||
CategoryName: item.CategoryName,
|
||||
CategoryOrder: item.CategoryOrder,
|
||||
QuantitySold: item.QuantitySold,
|
||||
WeightSold: item.WeightSold,
|
||||
Revenue: item.Revenue,
|
||||
AveragePrice: item.AveragePrice,
|
||||
OrderCount: item.OrderCount,
|
||||
@@ -506,6 +507,7 @@ func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAna
|
||||
ProductSku: product.ProductSku,
|
||||
ProductPrice: product.ProductPrice,
|
||||
QuantitySold: product.QuantitySold,
|
||||
WeightSold: product.WeightSold,
|
||||
Revenue: product.Revenue,
|
||||
AveragePrice: product.AveragePrice,
|
||||
OrderCount: product.OrderCount,
|
||||
@@ -592,6 +594,7 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
|
||||
CategoryID: item.CategoryID,
|
||||
CategoryName: item.CategoryName,
|
||||
QuantitySold: item.QuantitySold,
|
||||
WeightSold: item.WeightSold,
|
||||
Revenue: item.Revenue,
|
||||
AveragePrice: item.AveragePrice,
|
||||
OrderCount: item.OrderCount,
|
||||
|
||||
@@ -103,6 +103,10 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse {
|
||||
CategoryID: item.CategoryID,
|
||||
CategoryName: item.CategoryName,
|
||||
Quantity: item.Quantity,
|
||||
Weight: item.Weight,
|
||||
UnitID: item.UnitID,
|
||||
UnitName: item.UnitName,
|
||||
UnitAbbreviation: item.UnitAbbreviation,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: item.TotalPrice,
|
||||
Modifiers: item.Modifiers,
|
||||
@@ -174,6 +178,10 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO
|
||||
CategoryID: item.CategoryID,
|
||||
CategoryName: item.CategoryName,
|
||||
Quantity: item.Quantity,
|
||||
Weight: item.Weight,
|
||||
UnitID: item.UnitID,
|
||||
UnitName: item.UnitName,
|
||||
UnitAbbreviation: item.UnitAbbreviation,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: item.TotalPrice,
|
||||
Modifiers: item.Modifiers,
|
||||
|
||||
@@ -20,6 +20,11 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
|
||||
businessType = constants.BusinessType(*req.BusinessType)
|
||||
}
|
||||
|
||||
sellBy := constants.SellByUnit
|
||||
if req.SellBy != nil {
|
||||
sellBy = *req.SellBy
|
||||
}
|
||||
|
||||
var variants []models.CreateProductVariantRequest
|
||||
if req.Variants != nil {
|
||||
variants = make([]models.CreateProductVariantRequest, len(req.Variants))
|
||||
@@ -58,6 +63,8 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
|
||||
ImageURL: req.ImageURL,
|
||||
PrinterType: req.PrinterType,
|
||||
PrintToChecker: req.PrintToChecker,
|
||||
UnitID: req.UnitID,
|
||||
SellBy: sellBy,
|
||||
Metadata: metadata,
|
||||
Variants: variants,
|
||||
}
|
||||
@@ -86,6 +93,8 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up
|
||||
ImageURL: req.ImageURL,
|
||||
PrinterType: req.PrinterType,
|
||||
PrintToChecker: req.PrintToChecker,
|
||||
UnitID: req.UnitID,
|
||||
SellBy: req.SellBy,
|
||||
Metadata: metadata,
|
||||
IsActive: req.IsActive,
|
||||
}
|
||||
@@ -145,6 +154,8 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
|
||||
ImageURL: prod.ImageURL,
|
||||
PrinterType: prod.PrinterType,
|
||||
PrintToChecker: prod.PrintToChecker,
|
||||
UnitID: prod.UnitID,
|
||||
SellBy: prod.SellBy,
|
||||
Metadata: prod.Metadata,
|
||||
IsActive: prod.IsActive,
|
||||
CreatedAt: prod.CreatedAt,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
DROP INDEX IF EXISTS idx_order_items_unit_id;
|
||||
|
||||
ALTER TABLE order_items
|
||||
DROP CONSTRAINT IF EXISTS chk_order_items_weight_single_line,
|
||||
DROP CONSTRAINT IF EXISTS chk_order_items_weight_positive;
|
||||
|
||||
ALTER TABLE order_items
|
||||
DROP COLUMN IF EXISTS unit_id,
|
||||
DROP COLUMN IF EXISTS weight;
|
||||
|
||||
ALTER TABLE products
|
||||
DROP CONSTRAINT IF EXISTS chk_products_sell_by;
|
||||
|
||||
ALTER TABLE products
|
||||
DROP COLUMN IF EXISTS sell_by;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Weight-based products (e.g. fish sold per ons/kg).
|
||||
-- One weighing = one order_items row: quantity stays 1, the weight goes in `weight`.
|
||||
|
||||
ALTER TABLE products
|
||||
ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit';
|
||||
|
||||
ALTER TABLE products
|
||||
ADD CONSTRAINT chk_products_sell_by
|
||||
CHECK (sell_by IN ('unit', 'weight'));
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN weight DECIMAL(12,3),
|
||||
ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT;
|
||||
|
||||
-- A weighed line always carries a positive weight...
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_weight_positive
|
||||
CHECK (weight IS NULL OR weight > 0);
|
||||
|
||||
-- ...and always represents exactly one weighing, so its quantity is pinned to 1.
|
||||
-- This is what makes billable quantity unambiguous and keeps void all-or-nothing.
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_weight_single_line
|
||||
CHECK (weight IS NULL OR quantity = 1);
|
||||
|
||||
CREATE INDEX idx_order_items_unit_id ON order_items(unit_id);
|
||||
|
||||
COMMENT ON COLUMN products.sell_by IS 'How the product is sold: unit (discrete count) or weight (weighed per transaction)';
|
||||
COMMENT ON COLUMN order_items.weight IS 'Weighed amount in unit_id units; NULL for unit-priced products. Price is weight * unit_price.';
|
||||
COMMENT ON COLUMN order_items.unit_id IS 'Snapshot of the product unit at sale time, so historical lines keep their meaning';
|
||||
@@ -524,6 +524,7 @@
|
||||
<th>No.</th>
|
||||
<th>Nama Item</th>
|
||||
<th class="align-center">Qty</th>
|
||||
<th class="align-center">Berat</th>
|
||||
<th class="align-right">Penjualan Kotor</th>
|
||||
<th class="align-right">Diskon</th>
|
||||
<th class="align-right">Penjualan Bersih</th>
|
||||
@@ -537,6 +538,7 @@
|
||||
<td class="align-center number-cell">{{add $i 1}}</td>
|
||||
<td><span class="item-name">{{$item.Name}}</span></td>
|
||||
<td class="align-center number-cell">{{$item.Quantity}}</td>
|
||||
<td class="align-center number-cell">{{$item.Weight}}</td>
|
||||
<td class="align-right number-cell">{{$item.GrossSales}}</td>
|
||||
<td class="align-right number-cell">{{$item.Discount}}</td>
|
||||
<td class="align-right number-cell">{{$item.NetSales}}</td>
|
||||
|
||||
Reference in New Issue
Block a user