Compare commits

...
Author SHA1 Message Date
efrilmandClaude Opus 5 923c108690 fix(analytics): cost weighed lines by weight, not row count
A weight-based line is one weighing, so order_items.quantity is pinned to 1
while unit_price and unit_cost are per unit of weight. Analytics SQL was
multiplying and dividing per-unit rates by the raw quantity, costing a 4.2 ons
fish as a single ons: standard_hpp_total and moving_average_hpp_total came out
far too low across all four product reports, overstating gross profit, and
average_price and fifo_hpp_per_unit read per weighing while
standard_hpp_per_unit read per unit, so the three HPP figures in one row could
not be compared.

Adds billableQty and billableQtyNet as the single place that decides the
multiplier, mirroring entities.OrderItem.BillableQuantity.

quantity_sold and total_items stay as weighing counts; weight_sold already
carries the amount. revenue and fifo_hpp_total were already correct via
total_price/total_cost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 23:06:31 +07:00
efrilmandClaude Opus 5 f2701882dc fix(order): carry item weight through the contract-to-model transformer
CreateOrderContractToModel and AddToOrderContractToModel copied every order
item field except Weight, so a weight sent by the client never reached the
processor and every weight-based line failed with "product ... is sold by
weight and requires a weight".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 22:44:55 +07:00
efrilmandClaude Opus 5 ebf666c004 feat(product): require a unit for weight-based products
A product sold by weight with no unit produces order lines with nothing to
print: the receipt would read "4,2" with no idea of what. Until now nothing
stopped that — the mistake only surfaced at the cashier.

Enforce it in two places, because neither alone sees the whole picture. On
create, the validator has everything it needs. On update, the request may
omit unit_id for a product that already has one, so the check runs in the
processor against the merged product: what is rejected is the end state, a
product sold by weight with no unit.

Also fixes two things this uncovered:

The struct tags on the product contracts are decorative — this validator is
hand-written and never calls validator.Struct — so `oneof=unit weight` was
never enforced, and an unknown sell_by was silently rewritten to "unit" by
the mapper. It is now rejected with a message that names the valid values.

The update validator's "at least one field" guard did not list unit_id,
sell_by or print_to_checker, so an update carrying only one of those was
turned away as an empty request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:29:34 +07:00
efrilmandClaude Opus 5 d3987c7114 docs(order): add weight-based product integration guide
Client-facing companion to the RFC, aimed at the POS Mobile and Backoffice
teams: endpoints and payloads for setting up a weight product, placing an
order, rendering the line, and voiding, refunding or splitting it.

Documents two gaps the teams have to work around rather than discover:
unit_id is not yet enforced when sell_by is "weight", so Backoffice must
require it in the form; and money rounds to 2 decimals rather than whole
rupiah, which is still an open decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:20:22 +07:00
efrilmandClaude Opus 5 992bb04816 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>
2026-09-06 17:14:42 +07:00
aefril 1e5573af75 Merge pull request 'feat: cash advance' (#28) from dev into main
Reviewed-on: #28
2026-08-13 09:39:53 +02:00
aefril b42d141927 Merge pull request 'Dev' (#27) from dev into main
Reviewed-on: #27
2026-08-11 17:46:43 +02:00
35 changed files with 1438 additions and 83 deletions
+279
View File
@@ -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
+362
View File
@@ -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.
+17
View File
@@ -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
}
+2
View File
@@ -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"`
+35 -27
View File
@@ -40,13 +40,16 @@ type UpdateOrderRequest struct {
}
type CreateOrderItemRequest struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
Quantity int `json:"quantity" validate:"required,min=1"`
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Optional, will use database price if not provided
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
ProductID uuid.UUID `json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
Quantity int `json:"quantity" validate:"required,min=1"`
// Weight is required for products with sell_by = "weight" and must be absent otherwise.
// One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price.
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Ignored: price always comes from the database
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type UpdateOrderItemRequest struct {
@@ -92,26 +95,31 @@ type OrderResponse struct {
}
type OrderItemResponse struct {
ID uuid.UUID `json:"id"`
OrderID uuid.UUID `json:"order_id"`
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductVariantID *uuid.UUID `json:"product_variant_id"`
ProductVariantName *string `json:"product_variant_name,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
CategoryName *string `json:"category_name,omitempty"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Modifiers []map[string]interface{} `json:"modifiers"`
Notes *string `json:"notes,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PrinterType string `json:"printer_type"`
PrintToChecker bool `json:"print_to_checker"`
PaidQuantity int `json:"paid_quantity"`
ID uuid.UUID `json:"id"`
OrderID uuid.UUID `json:"order_id"`
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductVariantID *uuid.UUID `json:"product_variant_id"`
ProductVariantName *string `json:"product_variant_name,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
CategoryName *string `json:"category_name,omitempty"`
Quantity int `json:"quantity"`
// Weight is set only for products sold by weight; the line is priced weight * unit_price.
Weight *float64 `json:"weight,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
UnitName *string `json:"unit_name,omitempty"`
UnitAbbreviation *string `json:"unit_abbreviation,omitempty"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Modifiers []map[string]interface{} `json:"modifiers"`
Notes *string `json:"notes,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PrinterType string `json:"printer_type"`
PrintToChecker bool `json:"print_to_checker"`
PaidQuantity int `json:"paid_quantity"`
}
type ListOrdersQuery struct {
+6
View File
@@ -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"`
+1
View File
@@ -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"`
}
+1
View File
@@ -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"`
+54 -1
View File
@@ -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 {
+104
View File
@@ -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)
}
}
}
+1
View File
@@ -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"`
+1
View File
@@ -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,
})
}
+11
View File
@@ -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,
+23
View File
@@ -25,6 +25,9 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
UnitID: entity.UnitID,
SellBy: entity.SellBy,
HasIngredients: entity.HasIngredients,
Metadata: map[string]interface{}(entity.Metadata),
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
@@ -49,6 +52,9 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
BusinessType: string(model.BusinessType),
ImageURL: model.ImageURL,
PrinterType: model.PrinterType,
UnitID: model.UnitID,
SellBy: model.SellBy,
HasIngredients: model.HasIngredients,
Metadata: entities.Metadata(model.Metadata),
IsActive: model.IsActive,
CreatedAt: model.CreatedAt,
@@ -76,6 +82,11 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
printerType = *req.PrinterType
}
sellBy := constants.SellByUnit
if constants.IsValidSellBy(req.SellBy) {
sellBy = req.SellBy
}
metadata := entities.Metadata{}
if req.Metadata != nil {
metadata = entities.Metadata(req.Metadata)
@@ -92,6 +103,8 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
BusinessType: businessType,
ImageURL: req.ImageURL,
PrinterType: printerType,
UnitID: req.UnitID,
SellBy: sellBy,
Metadata: metadata,
IsActive: true, // Default to active
}
@@ -140,6 +153,8 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
UnitID: entity.UnitID,
SellBy: entity.SellBy,
Metadata: map[string]interface{}(entity.Metadata),
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
@@ -185,6 +200,14 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
entity.PrinterType = *req.PrinterType
}
if req.UnitID != nil {
entity.UnitID = req.UnitID
}
if req.SellBy != nil && constants.IsValidSellBy(*req.SellBy) {
entity.SellBy = *req.SellBy
}
if req.Metadata != nil {
if entity.Metadata == nil {
entity.Metadata = make(entities.Metadata)
+2
View File
@@ -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"`
+8 -1
View File
@@ -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
+4
View File
@@ -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,
+61 -24
View File
@@ -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)
+106
View File
@@ -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)
}
}
+8
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers"
@@ -191,6 +192,13 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
mappers.UpdateProductEntityFromRequest(existingProduct, req)
// Checked after the merge, not on the request: switching a product to sell_by
// "weight" is valid when it already carries a unit, and clearing the unit is
// invalid when it is already sold by weight. Only the merged product shows either.
if existingProduct.SellBy == constants.SellByWeight && existingProduct.UnitID == nil {
return nil, fmt.Errorf("product '%s' is sold by weight and requires a unit_id", existingProduct.Name)
}
if err := p.productRepo.Update(ctx, existingProduct); err != nil {
return nil, fmt.Errorf("failed to update product: %w", err)
}
+3 -3
View File
@@ -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
+34 -19
View File
@@ -29,6 +29,20 @@ type AnalyticsRepository interface {
GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error)
}
// A weight-based line is a single weighing: order_items.quantity is pinned to 1 and
// the weighed amount lives in order_items.weight, while unit_price and unit_cost are
// per unit of weight. Anything that multiplies or divides by a per-unit rate must use
// the weighed amount, not the row count, or a 4.2 ons fish is costed as a single ons.
// These mirror entities.OrderItem.BillableQuantity for aggregate SQL.
const (
// billableQty is the multiplier for a per-unit price or cost, before refunds.
billableQty = `COALESCE(oi.weight, oi.quantity)`
// billableQtyNet is billableQty after refunds. A weighed line is always refunded
// whole, so with quantity pinned to 1 the factor is the full weight or zero.
billableQtyNet = `COALESCE(oi.weight, 1) * (oi.quantity - COALESCE(oi.refund_quantity, 0))`
)
type AnalyticsRepositoryImpl struct {
db *gorm.DB
}
@@ -461,9 +475,10 @@ 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)
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(` + billableQty + `)
ELSE 0
END as average_price,
COUNT(DISTINCT oi.order_id) as order_count,
@@ -478,14 +493,14 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
FROM product_recipes pr
JOIN ingredients i ON pr.ingredient_id = i.id
WHERE pr.product_id = p.id
), p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as standard_hpp_total,
), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total,
CASE
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(oi.quantity)
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(` + billableQty + `)
ELSE 0
END as fifo_hpp_per_unit,
COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total,
COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit,
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as moving_average_hpp_total
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as moving_average_hpp_total
`).
Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id").
@@ -536,9 +551,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_count,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
`).
Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id").
@@ -589,9 +604,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
COUNT(DISTINCT c.id) as category_count,
COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_count,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
`).
Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id").
@@ -706,9 +721,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
COUNT(DISTINCT c.id) as category_count,
COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_count,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
`).
Scan(summary).Error
if err != nil {
@@ -727,9 +742,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_count,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
`).
Group("c.id, c.name, c.order").
Order("c.order ASC, c.name ASC").
@@ -759,18 +774,18 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue,
COALESCE(
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END)
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0),
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0),
0) as average_price,
COUNT(DISTINCT oi.order_id) as order_count,
COALESCE(shpp.hpp_per_unit, p.cost, 0) as standard_hpp_per_unit,
COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as standard_hpp_total,
COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as standard_hpp_total,
COALESCE(
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END)
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0),
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0),
0) as fifo_hpp_per_unit,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as fifo_hpp_total,
COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit,
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as moving_average_hpp_total
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as moving_average_hpp_total
`).
Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, shpp.hpp_per_unit, mahpp.hpp_per_unit").
Order("revenue DESC").
@@ -1167,9 +1182,9 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C
}
type purchasingTotals struct {
Total float64
RawMaterial float64
Expense float64
Total float64
RawMaterial float64
Expense float64
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
+14 -7
View File
@@ -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,
+3
View File
@@ -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").
+12
View File
@@ -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,
+10
View File
@@ -17,6 +17,7 @@ func CreateOrderContractToModel(req *contract.CreateOrderRequest) *models.Create
ProductID: item.ProductID,
ProductVariantID: item.ProductVariantID,
Quantity: item.Quantity,
Weight: item.Weight,
UnitPrice: item.UnitPrice, // Now optional
Modifiers: item.Modifiers,
Notes: item.Notes,
@@ -58,6 +59,7 @@ func AddToOrderContractToModel(req *contract.AddToOrderRequest) *models.AddToOrd
ProductID: item.ProductID,
ProductVariantID: item.ProductVariantID,
Quantity: item.Quantity,
Weight: item.Weight,
UnitPrice: item.UnitPrice, // Now optional
Modifiers: item.Modifiers,
Notes: item.Notes,
@@ -103,6 +105,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 +180,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,
@@ -0,0 +1,48 @@
package transformer
import (
"testing"
"apskel-pos-be/internal/contract"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestCreateOrderContractToModelCarriesWeight(t *testing.T) {
weight := 1.25
result := CreateOrderContractToModel(&contract.CreateOrderRequest{
OutletID: uuid.New(),
UserID: uuid.New(),
OrderItems: []contract.CreateOrderItemRequest{
{
ProductID: uuid.New(),
Quantity: 1,
Weight: &weight,
},
},
})
require.Len(t, result.OrderItems, 1)
require.NotNil(t, result.OrderItems[0].Weight)
require.Equal(t, weight, *result.OrderItems[0].Weight)
}
func TestAddToOrderContractToModelCarriesWeight(t *testing.T) {
weight := 0.8
result := AddToOrderContractToModel(&contract.AddToOrderRequest{
OrderItems: []contract.CreateOrderItemRequest{
{
ProductID: uuid.New(),
Quantity: 1,
Weight: &weight,
},
},
})
require.Len(t, result.OrderItems, 1)
require.NotNil(t, result.OrderItems[0].Weight)
require.Equal(t, weight, *result.OrderItems[0].Weight)
}
@@ -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,
+35 -1
View File
@@ -63,6 +63,32 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
}
if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil {
return err, code
}
return nil, ""
}
// validateSellBy checks how a product is sold and that it carries what that choice
// needs. A weight-based product without a unit would produce order lines with no unit
// to print, so the receipt could show "4,2" with no idea of what.
//
// unitID is the unit the request would leave on the product: for an update that does
// not touch unit_id, pass the product's current one.
func validateSellBy(sellBy *string, unitID *uuid.UUID) (error, string) {
if sellBy == nil {
return nil, ""
}
if !constants.IsValidSellBy(*sellBy) {
return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode
}
if *sellBy == constants.SellByWeight && unitID == nil {
return errors.New("unit_id is required when sell_by is 'weight'"), constants.MissingFieldErrorCode
}
return nil, ""
}
@@ -74,7 +100,8 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
// At least one field should be provided for update
if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil &&
req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil &&
req.PrinterType == nil && req.Metadata == nil && req.IsActive == nil {
req.PrinterType == nil && req.PrintToChecker == nil && req.UnitID == nil &&
req.SellBy == nil && req.Metadata == nil && req.IsActive == nil {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
}
@@ -111,6 +138,13 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
}
// Only the value is checked here. Whether the product ends up with a unit depends on
// what it already has, which this request cannot see — the processor checks that
// against the stored product.
if req.SellBy != nil && !constants.IsValidSellBy(*req.SellBy) {
return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode
}
return nil, ""
}
@@ -0,0 +1,130 @@
package validator
import (
"testing"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"github.com/google/uuid"
)
func strPtr(s string) *string { return &s }
func baseCreateRequest() *contract.CreateProductRequest {
return &contract.CreateProductRequest{
CategoryID: uuid.New(),
Name: "Ikan Tude",
Price: 4500,
}
}
func TestValidateCreateProductRequestSellBy(t *testing.T) {
unitID := uuid.New()
tests := []struct {
name string
sellBy *string
unitID *uuid.UUID
wantErr bool
wantMsg string
}{
{
name: "omitted sell_by is allowed and defaults to unit",
sellBy: nil, unitID: nil,
},
{
name: "unit product needs no unit_id",
sellBy: strPtr(constants.SellByUnit), unitID: nil,
},
{
name: "weight product with a unit is accepted",
sellBy: strPtr(constants.SellByWeight), unitID: &unitID,
},
{
name: "weight product without a unit is rejected",
// Otherwise its order lines would have no unit to print on the receipt.
sellBy: strPtr(constants.SellByWeight), unitID: nil,
wantErr: true,
wantMsg: "unit_id is required when sell_by is 'weight'",
},
{
name: "unknown sell_by is rejected rather than silently corrected",
// The struct tags on this contract are not enforced — this validator is
// hand-written — so the check has to be explicit.
sellBy: strPtr("pisang"), unitID: &unitID,
wantErr: true,
wantMsg: "sell_by must be either 'unit' or 'weight'",
},
}
v := NewProductValidator()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := baseCreateRequest()
req.SellBy = tt.sellBy
req.UnitID = tt.unitID
err, code := v.ValidateCreateProductRequest(req)
if !tt.wantErr {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatal("expected an error, got none")
}
if err.Error() != tt.wantMsg {
t.Errorf("message = %q, want %q", err.Error(), tt.wantMsg)
}
if code == "" {
t.Error("expected an error code")
}
})
}
}
// An update carrying only sell_by must not be turned away as an empty update.
func TestValidateUpdateProductRequestAcceptsSellByAlone(t *testing.T) {
v := NewProductValidator()
err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{
SellBy: strPtr(constants.SellByWeight),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
err, _ = v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{})
if err == nil {
t.Error("an update with no fields at all should be rejected")
}
}
func TestValidateUpdateProductRequestRejectsUnknownSellBy(t *testing.T) {
v := NewProductValidator()
err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{
SellBy: strPtr("timbangan"),
})
if err == nil {
t.Fatal("expected an error for an unknown sell_by")
}
}
// The update path deliberately does NOT require unit_id on the request: a product that
// already has a unit can be switched to sell_by "weight" without resending it. That
// pairing is checked by the processor against the stored product.
func TestValidateUpdateProductRequestDefersUnitCheck(t *testing.T) {
v := NewProductValidator()
err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{
SellBy: strPtr(constants.SellByWeight),
})
if err != nil {
t.Fatalf("update should not require unit_id on the request, got: %v", err)
}
}
@@ -0,0 +1,15 @@
DROP INDEX IF EXISTS idx_order_items_unit_id;
ALTER TABLE order_items
DROP CONSTRAINT IF EXISTS chk_order_items_weight_single_line,
DROP CONSTRAINT IF EXISTS chk_order_items_weight_positive;
ALTER TABLE order_items
DROP COLUMN IF EXISTS unit_id,
DROP COLUMN IF EXISTS weight;
ALTER TABLE products
DROP CONSTRAINT IF EXISTS chk_products_sell_by;
ALTER TABLE products
DROP COLUMN IF EXISTS sell_by;
@@ -0,0 +1,30 @@
-- Weight-based products (e.g. fish sold per ons/kg).
-- One weighing = one order_items row: quantity stays 1, the weight goes in `weight`.
ALTER TABLE products
ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit';
ALTER TABLE products
ADD CONSTRAINT chk_products_sell_by
CHECK (sell_by IN ('unit', 'weight'));
ALTER TABLE order_items
ADD COLUMN weight DECIMAL(12,3),
ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT;
-- A weighed line always carries a positive weight...
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_weight_positive
CHECK (weight IS NULL OR weight > 0);
-- ...and always represents exactly one weighing, so its quantity is pinned to 1.
-- This is what makes billable quantity unambiguous and keeps void all-or-nothing.
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_weight_single_line
CHECK (weight IS NULL OR quantity = 1);
CREATE INDEX idx_order_items_unit_id ON order_items(unit_id);
COMMENT ON COLUMN products.sell_by IS 'How the product is sold: unit (discrete count) or weight (weighed per transaction)';
COMMENT ON COLUMN order_items.weight IS 'Weighed amount in unit_id units; NULL for unit-priced products. Price is weight * unit_price.';
COMMENT ON COLUMN order_items.unit_id IS 'Snapshot of the product unit at sale time, so historical lines keep their meaning';
+2
View File
@@ -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>