Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
117e3529aa | ||
|
|
b59b7b4ddd | ||
|
|
fbe7e97dc7 | ||
|
|
b372821b23 | ||
|
|
d3e3ea6b08 | ||
|
|
b0ef226af1 | ||
|
|
79226bd891 | ||
|
|
306eabb1d9 | ||
|
|
892575202b | ||
|
|
c9654a387a | ||
|
|
e94eb2c26d | ||
|
|
e8b31035f7 | ||
|
|
100e006218 |
+1
-1
@@ -15,6 +15,6 @@ COPY --from=build /out/app /app
|
|||||||
ENV TZ=Asia/Jakarta
|
ENV TZ=Asia/Jakarta
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
CMD curl -fsS http://localhost:3300/health || exit 1
|
CMD curl -fsS http://localhost:4000/health || exit 1
|
||||||
USER appuser
|
USER appuser
|
||||||
ENTRYPOINT ["/app"]
|
ENTRYPOINT ["/app"]
|
||||||
|
|||||||
@@ -323,13 +323,13 @@ Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang;
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
"point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||||
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `max_payment_percent` 0–100.
|
Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. `earn_mode` adalah `PER_AMOUNT` (setiap `earn_per_amount` rupiah mendapat `earn_value`) atau `PERCENTAGE` (`earn_percent` persen dari basis). Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `earn_percent` 0–100 dengan maks. 2 angka desimal, `max_payment_percent` 0–100.
|
||||||
|
|
||||||
### /marketing/loyalty-settings
|
### /marketing/loyalty-settings
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari ord
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
"point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||||
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -41,15 +41,19 @@ Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari ord
|
|||||||
| Field | Label usulan | Tipe | Default | Validasi |
|
| Field | Label usulan | Tipe | Default | Validasi |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – |
|
| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – |
|
||||||
| `earn_per_amount` | Setiap belanja Rp … | Rp | 100 (point), 25.000 (coin) | > 0 |
|
| `earn_mode` | Cara hitung: per nominal / persentase | pilihan `PER_AMOUNT` / `PERCENTAGE` | `PER_AMOUNT` | salah satu dari keduanya |
|
||||||
| `earn_value` | … mendapat | angka | 1 | ≥ 0 |
|
| `earn_per_amount` | Setiap belanja Rp … (mode `PER_AMOUNT`) | Rp | 100 (point), 25.000 (coin) | > 0 |
|
||||||
|
| `earn_value` | … mendapat (mode `PER_AMOUNT`) | angka | 1 | ≥ 0 |
|
||||||
|
| `earn_percent` | … % dari belanja (mode `PERCENTAGE`) | %, boleh desimal | 1 | 0–100, maks. 2 angka desimal |
|
||||||
| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 |
|
| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 |
|
||||||
| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 |
|
| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 |
|
||||||
| `point_payment.accept_payment` | Terima pembayaran EnakPoint | toggle | mati | – |
|
| `point_payment.accept_payment` | Terima pembayaran EnakPoint | toggle | mati | – |
|
||||||
| `min_payment_points` | Minimal EnakPoint per pembayaran | angka | 1 | ≥ 1 |
|
| `min_payment_points` | Minimal EnakPoint per pembayaran | angka | 1 | ≥ 1 |
|
||||||
| `max_payment_percent` | Maksimal porsi order dibayar EnakPoint | % | 100 | 0–100 |
|
| `max_payment_percent` | Maksimal porsi order dibayar EnakPoint | % | 100 | 0–100 |
|
||||||
|
|
||||||
**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1).
|
**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`, atau pada mode `PERCENTAGE`: `earn_percent × point_value`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1).
|
||||||
|
|
||||||
|
**Mode earning.** Tampilkan hanya field mode yang dipilih (`earn_per_amount` + `earn_value`, atau `earn_percent`). Field mode lain tetap tersimpan di server, jadi tidak perlu dikosongkan saat owner berpindah mode. Pada mode `PERCENTAGE` jumlah yang didapat adalah `floor(basis × earn_percent ÷ 100)`, mis. 2,5% dari Rp 87.500 = 2.187 EnakPoint.
|
||||||
|
|
||||||
**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak, dan bagian yang dibayar EnakPoint tidak ikut dihitung.
|
**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak, dan bagian yang dibayar EnakPoint tidak ikut dihitung.
|
||||||
|
|
||||||
|
|||||||
@@ -516,15 +516,16 @@ Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
"point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||||
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan
|
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan
|
||||||
`point_value` organisasi dan `point_cashback_percent`
|
`point_value` organisasi dan `point_cashback_percent`
|
||||||
(`earn_value × point_value / earn_per_amount × 100`). **Tampilkan persentase ini di
|
(`earn_value × point_value / earn_per_amount × 100`, atau `earn_percent × point_value`
|
||||||
|
pada `earn_mode` `PERCENTAGE`). **Tampilkan persentase ini di
|
||||||
samping setting** supaya owner tidak salah membaca skala: default di atas setara
|
samping setting** supaya owner tidak salah membaca skala: default di atas setara
|
||||||
cashback 1%.
|
cashback 1%.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Migrasi printer_type ke printer_types
|
||||||
|
|
||||||
|
1 Oktober 2026
|
||||||
|
|
||||||
|
## Ringkasan
|
||||||
|
|
||||||
|
`printer_type` (string) dihapus dan diganti `printer_types` (array string), sehingga satu produk bisa dicetak ke lebih dari satu printer. Contohnya "Paket Makan Minum" dengan `["kitchen", "bar"]` tercetak di kitchen dan bar sekaligus.
|
||||||
|
|
||||||
|
Ini breaking change. Setelah backend baru dirilis, client yang masih membaca `printer_type` tidak menerima printer apa pun. Karena itu app POS dan dashboard harus diupdate lebih dulu, lihat [Urutan rilis](#database-dan-urutan-rilis).
|
||||||
|
|
||||||
|
Yang perlu bertindak:
|
||||||
|
|
||||||
|
- **App POS**: baca `printer_types` dan cetak tiap item ke semua printer di dalamnya.
|
||||||
|
- **Dashboard admin**: ganti pilihan printer di form produk jadi multi-select yang mengirim `printer_types`.
|
||||||
|
|
||||||
|
## Perubahan response
|
||||||
|
|
||||||
|
`printer_type` hilang dari semua response dan digantikan `printer_types`, yang nilainya selalu array dan tidak pernah `null`.
|
||||||
|
|
||||||
|
| Endpoint | Letak field |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/v1/products`, `PUT /api/v1/products/:id`, `GET /api/v1/products`, `GET /api/v1/products/all`, `GET /api/v1/products/:id` | objek produk |
|
||||||
|
| `POST /api/v1/orders`, `GET /api/v1/orders`, `GET /api/v1/orders/:id`, `PUT /api/v1/orders/:id` | `order_items[]` |
|
||||||
|
| `POST /api/v1/orders/:id/add-items` | `added_items[]` dan `updated_order.order_items[]` |
|
||||||
|
| `POST /api/v1/self-order/orders`, `GET /api/v1/self-order/orders/:session_id` | `order_items[]` |
|
||||||
|
| `/api/v1/product-recipes` (semua yang mengembalikan recipe) | `product` |
|
||||||
|
| `/api/v1/inventory` | `product`, selalu `[]` karena produk di sini hanya berisi id dan nama |
|
||||||
|
|
||||||
|
Contoh satu item di `order_items[]`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product_name": "Paket Makan Minum",
|
||||||
|
"printer_types": ["kitchen", "bar"],
|
||||||
|
"print_to_checker": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Aturan nilainya:
|
||||||
|
|
||||||
|
- `printer_types: []` artinya produk tidak dicetak ke printer mana pun.
|
||||||
|
- Urutan printer sesuai yang disimpan admin, tanpa duplikat.
|
||||||
|
- Void, refund, payment, split bill, dan set customer tidak membawa data printer, sama seperti sebelumnya.
|
||||||
|
|
||||||
|
## Perubahan request produk
|
||||||
|
|
||||||
|
`POST /api/v1/products` dan `PUT /api/v1/products/:id` menerima `printer_types` sebagai pengganti `printer_type`. Kalau `printer_type` masih dikirim, field itu diabaikan tanpa error.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Paket Makan Minum",
|
||||||
|
"category_id": "<uuid>",
|
||||||
|
"price": 25000,
|
||||||
|
"printer_types": ["kitchen", "bar"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Request | `printer_types` yang dikirim | Hasil |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Create | tidak dikirim | `["kitchen"]` |
|
||||||
|
| Create | `["kitchen", "bar"]` | `["kitchen", "bar"]` |
|
||||||
|
| Create | `[]` atau hanya string kosong | `["kitchen"]` |
|
||||||
|
| Update | tidak dikirim | tidak berubah |
|
||||||
|
| Update | `["bar"]` | `["bar"]`, seluruh daftar diganti |
|
||||||
|
| Update | `[]` | `[]`, produk tidak dicetak ke mana pun |
|
||||||
|
|
||||||
|
Sebelum disimpan, spasi di awal dan akhir tiap entri dibuang, lalu entri kosong dan duplikat dihapus. Urutan dipertahankan.
|
||||||
|
|
||||||
|
Tiap entri maksimal 50 karakter. Kalau lebih, request ditolak dengan error code `310` dan pesan `each printer_types entry cannot exceed 50 characters`.
|
||||||
|
|
||||||
|
## Migrasi app POS
|
||||||
|
|
||||||
|
App POS harus mengirim tiap item ke semua printer di `printer_types`, sehingga satu item bisa muncul di lebih dari satu tiket.
|
||||||
|
|
||||||
|
1. Ganti `printer_type` dengan `printer_types` (list string) di model order item dan produk.
|
||||||
|
2. Selama backend lama masih jalan, `printer_types` belum ada di response. Pakai `[printer_type]` kalau `printer_types` tidak ada, supaya app baru bisa dirilis sebelum backend.
|
||||||
|
3. Saat mencetak, kelompokkan item per printer dengan mengulang setiap entri `printer_types` milik item.
|
||||||
|
4. Item dengan `printer_types: []` tidak dicetak ke printer station mana pun.
|
||||||
|
5. `print_to_checker` tidak berubah dan tetap diperlakukan terpisah.
|
||||||
|
6. Untuk tambahan pesanan dari `POST /api/v1/orders/:id/add-items`, cetak dari `added_items[]`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
tiket = {}
|
||||||
|
untuk setiap item di order_items:
|
||||||
|
printers = item.printer_types ?? [item.printer_type] // fallback hanya untuk backend lama
|
||||||
|
untuk setiap printer di printers:
|
||||||
|
tiket[printer].tambah(item)
|
||||||
|
untuk setiap (printer, items) di tiket:
|
||||||
|
cetak items ke printer
|
||||||
|
```
|
||||||
|
|
||||||
|
Perbaikan di `added_items[]`: sebelumnya field ini selalu berisi `product_name` dan printer kosong, serta `print_to_checker: true`. Sekarang isinya lengkap seperti item di `updated_order.order_items[]`, dengan urutan sesuai request. Kalau app selama ini mengakali dengan mencari item baru di `updated_order`, cara itu bisa diganti dengan `added_items[]` langsung.
|
||||||
|
|
||||||
|
## Migrasi dashboard
|
||||||
|
|
||||||
|
Form produk di dashboard harus memakai multi-select printer dan selalu mengirim daftar lengkapnya lewat `printer_types`.
|
||||||
|
|
||||||
|
1. Ganti dropdown printer tunggal dengan multi-select, misalnya checkbox, berisi pilihan printer yang sama.
|
||||||
|
2. Saat membuka form edit, isi pilihan dari `printer_types`. Selama backend lama masih jalan, pakai `[printer_type]` kalau `printer_types` tidak ada.
|
||||||
|
3. Saat menyimpan, kirim `printer_types` berisi semua printer yang dipilih.
|
||||||
|
4. Selama backend lama masih jalan, kirim juga `printer_type` berisi printer pertama. Backend lama hanya membaca `printer_type`, dan backend baru mengabaikannya.
|
||||||
|
5. Di daftar produk, tampilkan semua printer dari `printer_types`.
|
||||||
|
|
||||||
|
Backend tidak membatasi nilai printer. Nilainya harus sama persis dengan nama printer yang dikenal app POS, termasuk huruf besar dan kecilnya.
|
||||||
|
|
||||||
|
Create dengan `printer_types: []` tetap menghasilkan `["kitchen"]`. Produk tanpa printer dibuat dulu, lalu di-update dengan `printer_types: []`.
|
||||||
|
|
||||||
|
## Database dan urutan rilis
|
||||||
|
|
||||||
|
Migration `000099_add_printer_types_to_products` menambah kolom `products.printer_types` (JSONB, `NOT NULL`, default `["kitchen"]`), mengisinya dari `printer_type`, lalu menghapus kolom `printer_type` beserta index-nya.
|
||||||
|
|
||||||
|
- Produk dengan `printer_type` berisi nilai menjadi `[printer_type]`.
|
||||||
|
- Produk dengan `printer_type` `NULL` atau kosong menjadi `[]`.
|
||||||
|
|
||||||
|
Urutan rilis:
|
||||||
|
|
||||||
|
1. Rilis app POS baru, yang membaca `printer_types` dengan fallback ke `printer_type`, ke semua outlet.
|
||||||
|
2. Rilis dashboard baru, yang mengirim `printer_types` dan `printer_type`.
|
||||||
|
3. Jalankan migration `000099` tepat sebelum deploy backend baru. Di antara keduanya, backend lama gagal menyimpan produk karena kolom `printer_type` sudah tidak ada.
|
||||||
|
4. Setelah backend baru jalan, dashboard boleh berhenti mengirim `printer_type`, dan fallback di app POS boleh dihapus.
|
||||||
|
5. Atur produk multi-printer, misalnya "Paket Makan Minum" ke `["kitchen", "bar"]`.
|
||||||
|
|
||||||
|
Outlet yang masih memakai app POS lama setelah langkah 3 tidak menerima printer untuk semua item. Pastikan langkah 1 sudah selesai di semua outlet.
|
||||||
|
|
||||||
|
Rollback: jalankan down migration dan deploy backend lama bersamaan. Down migration membuat ulang kolom `printer_type` berisi printer pertama, lalu menghapus `printer_types`, jadi yang hilang hanya printer tambahan.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] App POS baru (dengan fallback) terpasang di semua outlet
|
||||||
|
- [ ] Dashboard baru mengirim `printer_types` dan `printer_type`
|
||||||
|
- [ ] Migration `000099` dan backend baru dirilis di staging
|
||||||
|
- [ ] Uji "Paket Makan Minum" dengan `["kitchen", "bar"]` tercetak di dua printer, saat order baru dan saat tambah pesanan
|
||||||
|
- [ ] Migration `000099` dan backend baru dirilis di production
|
||||||
|
- [ ] Dashboard berhenti mengirim `printer_type`
|
||||||
|
- [ ] Fallback `printer_type` di app POS dihapus
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Kenapa `printer_type` dihapus, bukan dipertahankan?** Supaya hanya ada satu sumber data printer. Dua field yang menyimpan hal yang sama bisa saling berbeda.
|
||||||
|
|
||||||
|
**Apakah kitchen dan bar menerima tiket yang sama?** Ya. Keduanya mencetak "Paket Makan Minum" lengkap dengan varian dan modifier-nya. Memecah isi paket per station butuh fitur bundle, yang di luar rilis ini.
|
||||||
|
|
||||||
|
**Apakah nilai printer dibatasi?** Tidak. Nilainya string bebas sampai 50 karakter, dan harus sama dengan nama printer di app POS.
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# Migrasi profit sharing
|
||||||
|
|
||||||
|
4 Oktober 2026
|
||||||
|
|
||||||
|
## Ringkasan
|
||||||
|
|
||||||
|
Ada tiga perubahan di backend:
|
||||||
|
|
||||||
|
1. **Parent category bisa ditandai bukan team.** Kategori punya field baru `is_team`. Parent category dengan `is_team: false` tidak bisa dipilih sebagai team di purchase order dan cash advance, dan tidak ikut laporan profit sharing.
|
||||||
|
2. **Endpoint laporan pindah path.** `/api/v1/analytics/parent-categories` menjadi `/api/v1/analytics/profit-sharing`.
|
||||||
|
3. **Pembagian revenue tinggal dua porsi.** Porsi purchase (60%) dihapus. Revenue sekarang dibagi ke owner (SDL) dan team, dengan porsi team = 100% − fee owner.
|
||||||
|
|
||||||
|
Nomor 2 dan 3 adalah breaking change. Setelah backend baru dirilis, client yang masih memanggil path lama mendapat 404, dan `limit_purchase` serta `percentages.purchase` tidak ada lagi di response. Karena itu client harus diupdate lebih dulu, lihat [Urutan rilis](#database-dan-urutan-rilis).
|
||||||
|
|
||||||
|
Yang perlu bertindak, di dashboard maupun app mobile, mana pun yang punya layarnya:
|
||||||
|
|
||||||
|
- **Form kategori**: tambah toggle team untuk parent category.
|
||||||
|
- **Laporan profit sharing**: ganti path, hapus porsi purchase, tampilkan dua porsi.
|
||||||
|
- **Form purchase order dan cash advance**: picker team tidak perlu diubah, tapi form edit perlu menyesuaikan, lihat [Purchase order dan cash advance](#purchase-order-dan-cash-advance).
|
||||||
|
|
||||||
|
## Kategori: field `is_team`
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
Semua response kategori membawa `is_team` (boolean, tidak pernah `null`): `POST /api/v1/categories`, `PUT /api/v1/categories/:id`, `GET /api/v1/categories`, dan `GET /api/v1/categories/:id`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "<uuid>",
|
||||||
|
"name": "Merchandise",
|
||||||
|
"parent_id": null,
|
||||||
|
"owner_fee_percent": null,
|
||||||
|
"is_team": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
`POST /api/v1/categories` dan `PUT /api/v1/categories/:id` menerima `is_team`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Merchandise",
|
||||||
|
"is_team": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Request | `is_team` yang dikirim | Hasil |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Create | tidak dikirim | `true` |
|
||||||
|
| Create | `false` | `false` |
|
||||||
|
| Update | tidak dikirim atau `null` | tidak berubah |
|
||||||
|
| Update | `true` atau `false` | diganti |
|
||||||
|
|
||||||
|
Update yang hanya berisi `is_team` diterima.
|
||||||
|
|
||||||
|
Aturan nilainya:
|
||||||
|
|
||||||
|
- `is_team` hanya berpengaruh di parent category, yaitu kategori dengan `parent_id: null`. Di sub-category nilainya disimpan tapi tidak dipakai, jadi tampilkan toggle hanya untuk parent category.
|
||||||
|
- Semua kategori yang sudah ada sebelum rilis bernilai `true`. Tidak ada yang berubah sampai admin mematikannya.
|
||||||
|
- Flag dibaca saat request, bukan saat transaksi. Kalau parent category dimatikan, penjualannya di periode lampau juga hilang dari laporan profit sharing. Kalau dinyalakan lagi, semuanya muncul kembali.
|
||||||
|
|
||||||
|
### Efek `is_team: false`
|
||||||
|
|
||||||
|
| Endpoint | Efek |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/v1/purchase-orders/teams`, `GET /api/v1/cash-advances/teams` | Kategori tidak muncul. Pusat tetap ada. |
|
||||||
|
| Create dan update purchase order dan cash advance | `team_scope: "category"` dengan `team_category_id` kategori ini ditolak. |
|
||||||
|
| `GET /api/v1/analytics/profit-sharing` | Kategori tidak muncul di `data`, dan revenue-nya tidak dihitung di `budget`. |
|
||||||
|
| `GET /api/v1/analytics/profit-sharing/:parent_category_id` | Ditolak. |
|
||||||
|
|
||||||
|
Data yang sudah ada tidak diubah. Purchase order dan cash advance yang sudah tercatat ke kategori itu tetap menyimpan team-nya. Laporan purchasing (`GET /api/v1/analytics/purchasing`) masih menampilkannya di `team_data`, dan filter `team=<category_id>` tetap bisa dipakai.
|
||||||
|
|
||||||
|
Laporan lain yang tidak menyaring `is_team`, misalnya `GET /api/v1/analytics/categories`, tetap menampilkan penjualan kategori itu.
|
||||||
|
|
||||||
|
## Purchase order dan cash advance
|
||||||
|
|
||||||
|
Picker team sudah mengambil dari `GET /api/v1/purchase-orders/teams` dan `GET /api/v1/cash-advances/teams`, jadi kategori non-team otomatis tidak muncul tanpa perubahan di client.
|
||||||
|
|
||||||
|
Yang perlu diubah ada di form edit. Purchase order atau cash advance lama bisa tercatat ke kategori yang sekarang sudah non-team. Kalau form edit mengirim ulang `team_scope` dan `team_category_id` yang sama, request ditolak, walaupun user tidak mengubah team-nya.
|
||||||
|
|
||||||
|
1. Kirim `team_scope` dan `team_category_id` hanya kalau user mengganti team. Kalau `team_scope` tidak dikirim, team yang tersimpan tidak berubah.
|
||||||
|
2. Kalau team yang tersimpan tidak ada di daftar `/teams`, tampilkan namanya dari field `team` di response apa adanya, dan jangan memilihkan team lain secara otomatis.
|
||||||
|
|
||||||
|
Contoh error saat team ditolak, dengan HTTP status 500:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"data": null,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "900",
|
||||||
|
"entity": "purchase_order_service",
|
||||||
|
"cause": "category Merchandise is not a team"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Untuk cash advance, `entity` bernilai `cash_advance_service`. Cocokkan dengan teks `is not a team` di `cause` kalau perlu menampilkan pesan khusus.
|
||||||
|
|
||||||
|
## Laporan profit sharing
|
||||||
|
|
||||||
|
### Path baru
|
||||||
|
|
||||||
|
| Lama | Baru |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/v1/analytics/parent-categories` | `GET /api/v1/analytics/profit-sharing` |
|
||||||
|
| `GET /api/v1/analytics/parent-categories/:parent_category_id` | `GET /api/v1/analytics/profit-sharing/:parent_category_id` |
|
||||||
|
|
||||||
|
Query parameter dan role tidak berubah:
|
||||||
|
|
||||||
|
- `date_from` dan `date_to` wajib, dengan format `DD-MM-YYYY`, misalnya `28-09-2026`.
|
||||||
|
- `outlet_id` opsional.
|
||||||
|
- Hanya bisa diakses superadmin, admin, manager, owner, dan purchasing.
|
||||||
|
|
||||||
|
Path lama sudah tidak ada dan mengembalikan 404.
|
||||||
|
|
||||||
|
### Pembagian revenue
|
||||||
|
|
||||||
|
Revenue tiap parent category yang team dibagi dua:
|
||||||
|
|
||||||
|
- **SDL (fee owner)** = revenue × `owner_fee_percent` / 100. Default-nya 20%, dan bisa diganti per parent category lewat `owner_fee_percent` di kategori.
|
||||||
|
- **Team** = revenue − SDL.
|
||||||
|
|
||||||
|
Contoh dengan tiga parent category dalam satu minggu:
|
||||||
|
|
||||||
|
| Parent category | `is_team` | Fee owner | Revenue | SDL | Team |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Food | `true` | 20% (default) | 1.000.000 | 200.000 | 800.000 |
|
||||||
|
| Drink | `true` | 35% | 2.000.000 | 700.000 | 1.300.000 |
|
||||||
|
| Merchandise | `false` | - | 500.000 | tidak dihitung | tidak dihitung |
|
||||||
|
| **Budget minggu ini** | | | **3.000.000** | **900.000** | **2.100.000** |
|
||||||
|
|
||||||
|
### Perubahan field
|
||||||
|
|
||||||
|
| Field | Sebelum | Sesudah |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `data[]` | semua parent category | hanya parent category team |
|
||||||
|
| `budget.percentages.purchase` | `60` | dihapus |
|
||||||
|
| `budget.percentages.owner` | `20`, atau fee parent itu di endpoint detail | tidak berubah |
|
||||||
|
| `budget.percentages.team` | `20` | `100 − owner`: `80` di list, `100 − fee parent` di detail |
|
||||||
|
| `limit_purchase` di `budget.total`, `budget.weekly[]`, `budget.monthly[]` | 60% revenue | dihapus |
|
||||||
|
| `limit_team` di tempat yang sama | 20% revenue | `revenue − sdl` |
|
||||||
|
| `revenue` dan `sdl` di `budget` | semua parent category | hanya parent category team |
|
||||||
|
|
||||||
|
Di endpoint list, `budget.percentages` selalu berisi default `20` dan `80`, walaupun ada parent dengan fee berbeda. Angka `sdl` dan `limit_team` dihitung per parent dengan fee masing-masing, jadi `limit_team / revenue` bisa tidak persis 80%. Tampilkan angka rupiah dari response, jangan dihitung ulang dari persentase.
|
||||||
|
|
||||||
|
Baris di `data[]` membawa `sdl` tapi tidak membawa porsi team. Kalau porsi team per parent perlu ditampilkan, hitung dari `total_revenue − sdl`.
|
||||||
|
|
||||||
|
Contoh response list, dipotong:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"date_from": "2026-09-28T00:00:00+07:00",
|
||||||
|
"date_to": "2026-10-04T23:59:59.999999999+07:00",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"parent_category_id": "<uuid>",
|
||||||
|
"parent_category_name": "Drink",
|
||||||
|
"owner_fee_percent": 35,
|
||||||
|
"sdl": 700000,
|
||||||
|
"total_revenue": 2000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parent_category_id": "<uuid>",
|
||||||
|
"parent_category_name": "Food",
|
||||||
|
"owner_fee_percent": 20,
|
||||||
|
"sdl": 200000,
|
||||||
|
"total_revenue": 1000000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"budget": {
|
||||||
|
"percentages": { "owner": 20, "team": 80 },
|
||||||
|
"cut_off_from": "2026-09-28T00:00:00+07:00",
|
||||||
|
"cut_off_to": "2026-10-04T23:59:59.999999999+07:00",
|
||||||
|
"total": {
|
||||||
|
"period_start": "2026-09-28T00:00:00+07:00",
|
||||||
|
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||||
|
"revenue": 3000000,
|
||||||
|
"order_count": 4,
|
||||||
|
"sdl": 900000,
|
||||||
|
"limit_team": 2100000
|
||||||
|
},
|
||||||
|
"weekly": [
|
||||||
|
{
|
||||||
|
"period_start": "2026-09-28T00:00:00+07:00",
|
||||||
|
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||||
|
"revenue": 3000000,
|
||||||
|
"order_count": 4,
|
||||||
|
"sdl": 900000,
|
||||||
|
"limit_team": 2100000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"monthly": [
|
||||||
|
{
|
||||||
|
"month": "2026-09",
|
||||||
|
"week_count": 1,
|
||||||
|
"period_start": "2026-09-28T00:00:00+07:00",
|
||||||
|
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||||
|
"revenue": 3000000,
|
||||||
|
"order_count": 4,
|
||||||
|
"sdl": 900000,
|
||||||
|
"limit_team": 2100000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Di endpoint detail, `budget` bentuknya sama, tapi `percentages` memakai fee parent itu, misalnya `{ "owner": 35, "team": 65 }` untuk Drink.
|
||||||
|
|
||||||
|
### Detail kategori non-team
|
||||||
|
|
||||||
|
Detail untuk parent category non-team ditolak dengan HTTP status 500:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"data": null,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "internal_error",
|
||||||
|
"entity": "AnalyticsHandler::GetParentCategoryAnalyticsDetail",
|
||||||
|
"cause": "failed to get parent category analytics detail: failed to get parent category analytics detail: category Merchandise is not a team"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ini bisa terjadi kalau user membuka link lama atau bookmark ke parent yang baru dimatikan. Cocokkan dengan teks `is not a team` di `cause`, lalu arahkan user kembali ke list.
|
||||||
|
|
||||||
|
## Migrasi client
|
||||||
|
|
||||||
|
### Laporan profit sharing
|
||||||
|
|
||||||
|
1. Ganti path ke `/api/v1/analytics/profit-sharing`. Selama backend lama masih jalan, path baru mengembalikan 404. Kalau dapat 404, panggil path lama `/api/v1/analytics/parent-categories`, supaya client baru bisa dirilis sebelum backend.
|
||||||
|
2. Hapus tampilan limit purchase dan persentase purchase. Jangan menganggap `limit_purchase` atau `percentages.purchase` selalu ada.
|
||||||
|
3. Tampilkan dua porsi dengan label "SDL / Fee owner" dan "Team", ambil angkanya dari `sdl` dan `limit_team`.
|
||||||
|
4. Kalau detail ditolak dengan `is not a team`, arahkan kembali ke list.
|
||||||
|
|
||||||
|
Selama fallback ke backend lama, `limit_team` masih berisi 20% revenue. Angkanya baru jadi `revenue − sdl` setelah backend baru dirilis.
|
||||||
|
|
||||||
|
### Form kategori
|
||||||
|
|
||||||
|
1. Tambah toggle `is_team` untuk parent category, misalnya berlabel "Ikut profit sharing (team)". Untuk kategori baru, toggle menyala secara default.
|
||||||
|
2. Saat membuka form edit, isi toggle dari `is_team`. Selama backend lama masih jalan, field ini tidak ada di response, jadi anggap `true`.
|
||||||
|
3. Saat admin mematikan toggle, tampilkan konfirmasi bahwa kategori itu tidak akan muncul di pilihan team dan di laporan profit sharing, termasuk untuk periode lampau.
|
||||||
|
4. Di daftar kategori, beri penanda untuk parent category dengan `is_team: false`.
|
||||||
|
|
||||||
|
Backend lama mengabaikan `is_team` di request, jadi toggle bisa dirilis lebih dulu, tapi belum berpengaruh sampai backend baru jalan. Pengecualiannya update yang hanya berisi `is_team`: backend lama menolaknya dengan error code `303` dan pesan `at least one field must be provided for update`. Selama masa transisi, kirim `is_team` bersama field form lainnya.
|
||||||
|
|
||||||
|
## Database dan urutan rilis
|
||||||
|
|
||||||
|
Migration `000101_add_is_team_to_categories` menambah kolom `categories.is_team` (`BOOLEAN NOT NULL DEFAULT TRUE`). Semua kategori yang ada otomatis bernilai `true`. Migration ini hanya menambah kolom, jadi backend lama tetap jalan normal setelahnya.
|
||||||
|
|
||||||
|
Urutan rilis:
|
||||||
|
|
||||||
|
1. Rilis client baru: path profit sharing dengan fallback ke path lama, tanpa porsi purchase, dan dengan toggle `is_team`. Untuk app mobile, pastikan versi baru sudah dipakai sebagian besar user sebelum langkah 3, karena versi lama yang memanggil `/parent-categories` mendapat 404 setelah itu.
|
||||||
|
2. Jalankan migration `000101`.
|
||||||
|
3. Deploy backend baru.
|
||||||
|
4. Hapus fallback path lama di client.
|
||||||
|
5. Admin mematikan `is_team` di parent category yang bukan team.
|
||||||
|
|
||||||
|
Rollback: deploy backend lama, lalu jalankan down migration yang menghapus kolom `is_team`. Client dengan fallback tetap jalan di backend lama. Nilai `is_team` yang sudah diatur admin hilang saat kolom dihapus.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Client baru (fallback path, tanpa porsi purchase, toggle `is_team`) dirilis
|
||||||
|
- [ ] Migration `000101` dan backend baru dirilis di staging
|
||||||
|
- [ ] Uji: parent category yang dimatikan hilang dari `/purchase-orders/teams` dan `/cash-advances/teams`
|
||||||
|
- [ ] Uji: purchase order dan cash advance baru dengan kategori itu ditolak, dan edit purchase order lama tanpa mengganti team tetap berhasil
|
||||||
|
- [ ] Uji: kategori itu hilang dari `/analytics/profit-sharing`, dan detailnya ditolak
|
||||||
|
- [ ] Uji: `limit_team = revenue − sdl`, dan `sdl` mengikuti fee masing-masing parent
|
||||||
|
- [ ] Migration `000101` dan backend baru dirilis di production
|
||||||
|
- [ ] Fallback path lama di client dihapus
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Kenapa porsi team jadi 100% − fee owner, bukan tetap 20%?** Porsi purchase sudah tidak ada, jadi seluruh revenue dibagi dua. Owner mengambil fee-nya, dan sisanya untuk team. Kalau fee owner sebuah parent dinaikkan, porsi team parent itu turun sebesar yang sama.
|
||||||
|
|
||||||
|
**Apakah sub-category bisa dijadikan non-team sendiri?** Tidak. Team dan profit sharing dihitung per parent category, jadi semua sub-category ikut status parent-nya.
|
||||||
|
|
||||||
|
**Bagaimana dengan kategori top-level yang tidak punya sub-category?** Kategori itu tetap parent category, jadi `is_team` berlaku untuknya.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
|||||||
|
# Task Breakdown: EnakPoint & EnakCoin
|
||||||
|
|
||||||
|
**Sumber:** [PRD EnakPoint & EnakCoin](prd-point-coin.md)
|
||||||
|
**Tanggal:** 2026-09-29
|
||||||
|
|
||||||
|
Setiap task menyebut bagian PRD yang dikerjakan, lapisan kode yang disentuh, task yang
|
||||||
|
harus selesai lebih dulu, dan kriteria selesai. Ukuran: **S** ≤ 1 hari, **M** 2–3 hari,
|
||||||
|
**L** 4–5 hari.
|
||||||
|
|
||||||
|
Konvensi kode mengikuti yang sudah ada: `migrations/` (lanjut dari `000089`),
|
||||||
|
`entities` → `repository` → `processor` → `service` → `handler` / `validator` →
|
||||||
|
`router`, dan wiring di `internal/app/app.go`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ringkasan
|
||||||
|
|
||||||
|
| Fase | Task | Terblokir oleh catatan PRD |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. Fondasi | PC-101 – PC-109 | – |
|
||||||
|
| 2. Earning | PC-201 – PC-205 | – |
|
||||||
|
| 3. Pembayaran EnakPoint | PC-301 – PC-308 | N2 (keuangan), N3 (legal) sebelum **rilis** |
|
||||||
|
| 4. Pergerakan saldo | PC-401 – PC-404 | N3 (legal) sebelum **rilis** |
|
||||||
|
| 5. Kedaluwarsa | PC-501 – PC-504 | N4 (model kedaluwarsa) sebelum **dikerjakan** |
|
||||||
|
| 6. Bersih-bersih | PC-601 – PC-602 | – |
|
||||||
|
|
||||||
|
Catatan N2 dan N3 hanya memblokir **rilis** fase 3–4, bukan pengerjaannya. N4
|
||||||
|
memblokir pengerjaan fase 5, karena rumus kedaluwarsanya belum ditentukan.
|
||||||
|
|
||||||
|
```
|
||||||
|
PC-101 ─┬─ PC-103 ── PC-104 ─┬─ PC-105 ── PC-106
|
||||||
|
│ ├─ PC-107
|
||||||
|
│ ├─ PC-108
|
||||||
|
│ ├─ PC-203 ── PC-204 ── PC-205
|
||||||
|
│ ├─ PC-305 ── PC-306 / PC-307
|
||||||
|
│ ├─ PC-401 / PC-402 / PC-403 / PC-404
|
||||||
|
│ └─ PC-502 ── PC-503
|
||||||
|
PC-102 ── PC-109 ─┬─ PC-201 ── PC-202 ── PC-203
|
||||||
|
└─ PC-302
|
||||||
|
PC-301 ── PC-304 ── PC-305
|
||||||
|
PC-303 ── PC-305
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 1 — Fondasi
|
||||||
|
|
||||||
|
Semua fase lain bergantung pada fase ini. **PC-104 (wallet engine) adalah inti.** Tidak
|
||||||
|
ada kode lain yang boleh mengubah saldo tanpa lewat engine ini.
|
||||||
|
|
||||||
|
### PC-101 · Migrasi tabel wallet, ledger, dan lot · M
|
||||||
|
- **PRD:** §8 (`customer_wallets`, `wallet_transactions`, `wallet_lots`,
|
||||||
|
`wallet_lot_allocations`), K5, K9
|
||||||
|
- **Kerjakan:** migrasi `000090_create_wallet_tables` (up & down) dengan semua
|
||||||
|
`CHECK` constraint dan index persis seperti di §8.
|
||||||
|
- **Selesai jika:**
|
||||||
|
- Up dan down berjalan bersih di database kosong dan di salinan staging.
|
||||||
|
- Uji constraint langsung di SQL, masing-masing harus ditolak:
|
||||||
|
`PAYMENT` bercurrency `COIN`; `TRANSFER_IN` tanpa `counterparty_customer_id`;
|
||||||
|
`EARN_REVERSAL` tanpa `reverses_transaction_id`; `ADJUSTMENT` tanpa `reason`;
|
||||||
|
`EXPIRE` dengan `reference_type` selain `LOT`; saldo negatif; lot dengan
|
||||||
|
`remaining_amount > original_amount`.
|
||||||
|
- **Bergantung pada:** –
|
||||||
|
|
||||||
|
### PC-102 · Migrasi pengaturan organisasi dan riwayat perubahan setting · S
|
||||||
|
- **PRD:** F2, `loyalty_setting_changes` di §8
|
||||||
|
- **Kerjakan:** tabel `organization_settings` (key–value, pola sama dengan
|
||||||
|
`outlet_settings`, `UNIQUE(organization_id, key)`) dan `loyalty_setting_changes`.
|
||||||
|
Saat ini belum ada tempat menyimpan setting per organisasi.
|
||||||
|
- **Selesai jika:** up/down bersih.
|
||||||
|
- **Bergantung pada:** –
|
||||||
|
|
||||||
|
### PC-103 · Entities dan repository wallet · M
|
||||||
|
- **PRD:** §7.1–§7.4
|
||||||
|
- **Kerjakan:**
|
||||||
|
- Entities untuk empat tabel PC-101.
|
||||||
|
- Repository yang **selalu** memakai `DBFromContext` (berbeda dari repository
|
||||||
|
gamification yang sekarang memakai `r.db` langsung).
|
||||||
|
- `LockWallet(ctx, customerID)` dengan `SELECT … FOR UPDATE`. Membuat baris wallet
|
||||||
|
jika belum ada (`INSERT … ON CONFLICT DO NOTHING`, lalu lock).
|
||||||
|
- `LockWallets(ctx, a, b)` yang selalu mengunci berurutan berdasarkan `customer_id`.
|
||||||
|
- Update saldo bersyarat yang **mengembalikan error jika 0 baris ter-update**.
|
||||||
|
- Query lot aktif sesuai urutan K9: `expires_at NULLS LAST, created_at`.
|
||||||
|
- **Selesai jika:** test repository menunjukkan update bersyarat gagal saat saldo
|
||||||
|
kurang, dan dua goroutine yang mengunci wallet yang sama berjalan bergantian.
|
||||||
|
- **Bergantung pada:** PC-101
|
||||||
|
|
||||||
|
### PC-104 · Wallet engine (credit / debit / lot / idempotensi) · L
|
||||||
|
- **PRD:** K5, K6, K9, §7, §8.1
|
||||||
|
- **Kerjakan:** processor `WalletProcessor` sebagai **satu-satunya pintu** perubahan
|
||||||
|
saldo:
|
||||||
|
- `Credit(ctx, CreditInput)`: menulis ledger, membuat lot (dengan `expires_at` dan
|
||||||
|
`origin_lot_id` dari input), dan menambah saldo.
|
||||||
|
- `Debit(ctx, DebitInput)`: mengambil dari lot sesuai urutan K9 (atau dari lot
|
||||||
|
tertentu lebih dulu, untuk reversal di F10), menulis alokasi dan ledger, lalu
|
||||||
|
mengurangi saldo. Mengembalikan daftar alokasi, supaya transfer/exchange bisa
|
||||||
|
membuat lot penerima dengan `expires_at` yang sama.
|
||||||
|
- `DebitUpTo`: mengambil sebanyak yang tersedia, untuk reversal dengan shortfall
|
||||||
|
(F10, Q3).
|
||||||
|
- Idempotensi: jika `idempotency_key` sudah ada, kembalikan hasil pertama tanpa
|
||||||
|
mutasi baru.
|
||||||
|
- Validasi di level kode untuk aturan §8.1 (tipe ↔ currency ↔ referensi wajib),
|
||||||
|
sebagai lapisan kedua di atas constraint database.
|
||||||
|
- Semua method mewajibkan caller sudah berada di dalam transaksi dan wallet sudah
|
||||||
|
dikunci.
|
||||||
|
- **Selesai jika:** unit test mencakup debit yang melewati beberapa lot, urutan lot
|
||||||
|
dengan dan tanpa `expires_at`, debit melebihi saldo (ditolak), `DebitUpTo` dengan
|
||||||
|
shortfall, idempotency key ganda, dan saldo = `SUM(ledger)` = `SUM(lot.remaining)`
|
||||||
|
setelah setiap skenario.
|
||||||
|
- **Bergantung pada:** PC-103
|
||||||
|
|
||||||
|
### PC-105 · Migrasi data Point & Token lama · M
|
||||||
|
- **PRD:** §10, Q6
|
||||||
|
- **Kerjakan:** migrasi data (atau command satu kali) yang:
|
||||||
|
- Membuat wallet dari `customer_points` dan penjumlahan semua jenis
|
||||||
|
`customer_tokens`.
|
||||||
|
- Menulis ledger `MIGRATION` dan lot tanpa `expires_at` per customer per currency,
|
||||||
|
dengan rincian per jenis token di `metadata`.
|
||||||
|
- Mengganti `TOKENS` menjadi `COINS` di `campaigns.type` dan
|
||||||
|
`campaign_rules.reward_type`.
|
||||||
|
- **Selesai jika:** jumlah total Point dan Token sebelum = jumlah total saldo wallet
|
||||||
|
sesudah (diuji pada salinan data staging), dan migrasi aman dijalankan dua kali
|
||||||
|
(tidak menggandakan).
|
||||||
|
- **Bergantung pada:** PC-104
|
||||||
|
|
||||||
|
### PC-106 · Endpoint saldo & riwayat customer · M
|
||||||
|
- **PRD:** F6, §9 (customer app)
|
||||||
|
- **Kerjakan:** `GET /customer/wallet` dan `GET /customer/wallet/transactions`
|
||||||
|
(pagination, filter currency/tipe/tanggal). Ganti isi `/customer/points` dan
|
||||||
|
`/customer/tokens` menjadi alias yang membaca `customer_wallets`. Hapus pemakaian
|
||||||
|
`customer_points_repository` untuk saldo.
|
||||||
|
- **Selesai jika:** response menampilkan asal/tujuan setiap mutasi sesuai §8.1, dan
|
||||||
|
aplikasi lama yang memanggil `/points` / `/tokens` tetap mendapat angka yang benar.
|
||||||
|
- **Bergantung pada:** PC-105
|
||||||
|
|
||||||
|
### PC-107 · Wallet customer dan adjustment di dashboard · M
|
||||||
|
- **PRD:** F7
|
||||||
|
- **Kerjakan:** `GET /marketing/customers/:id/wallet` (saldo, lot aktif, mutasi) dan
|
||||||
|
`POST /marketing/customers/:id/wallet/adjust` (alasan wajib, tidak boleh membuat saldo
|
||||||
|
negatif, tercatat `created_by_user`).
|
||||||
|
- **Selesai jika:** adjustment muncul di riwayat customer dengan nama admin dan alasan.
|
||||||
|
Adjustment yang melebihi saldo ditolak.
|
||||||
|
- **Bergantung pada:** PC-104
|
||||||
|
|
||||||
|
### PC-108 · Job rekonsiliasi · S
|
||||||
|
- **PRD:** §7.5
|
||||||
|
- **Kerjakan:** query dan job terjadwal yang memeriksa semua invarian §7.5 dan
|
||||||
|
melaporkan selisih ke log dan notifikasi admin.
|
||||||
|
- **Selesai jika:** job menemukan selisih yang sengaja dibuat di data test, dan diam
|
||||||
|
saat data konsisten.
|
||||||
|
- **Bergantung pada:** PC-104
|
||||||
|
|
||||||
|
### PC-109 · Pembaca setting loyalitas dan riwayat perubahan · M
|
||||||
|
- **PRD:** F1, F2 (bagian mekanisme, bukan UI), §8 `loyalty_setting_changes`
|
||||||
|
- **Kerjakan:** service yang membaca setting outlet dan organisasi dengan nilai default
|
||||||
|
PRD bila key belum diisi, mengembalikan struct bertipe (bukan string mentah), dan
|
||||||
|
menulis `loyalty_setting_changes` setiap kali setting loyalitas diubah.
|
||||||
|
- **Selesai jika:** outlet tanpa setting mendapat semua default PRD, dan setiap
|
||||||
|
perubahan tercatat nilai lama, nilai baru, dan pelakunya.
|
||||||
|
- **Bergantung pada:** PC-102
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 2 — Earning
|
||||||
|
|
||||||
|
### PC-201 · API pengaturan loyalitas outlet · M
|
||||||
|
- **PRD:** F1
|
||||||
|
- **Kerjakan:** `GET/PUT /outlets/:id/loyalty-settings` dengan validasi F1. Response
|
||||||
|
menyertakan **persentase cashback efektif** (`earn_value × nilai EnakPoint /
|
||||||
|
earn_per_amount`).
|
||||||
|
- **Selesai jika:** validasi menolak nilai di luar batas, dan hanya Admin/Manager yang
|
||||||
|
bisa mengubah.
|
||||||
|
- **Bergantung pada:** PC-109
|
||||||
|
|
||||||
|
### PC-202 · Kalkulator earning · S
|
||||||
|
- **PRD:** F1 (rumus), Q1, Q10
|
||||||
|
- **Kerjakan:** fungsi murni `CalculateEarning(order, pointPaidAmount, settings)` yang
|
||||||
|
mengembalikan Point, Coin, basis, dan snapshot setting.
|
||||||
|
- **Selesai jika:** unit test mencakup contoh di PRD (Rp 87.500 → 875 Point, 3 Coin;
|
||||||
|
dengan Rp 20.000 dibayar EnakPoint → 675 Point, 2 Coin), basis di bawah minimum,
|
||||||
|
`max_per_order`, setting nonaktif, dan pajak tidak ikut dihitung.
|
||||||
|
- **Bergantung pada:** PC-201
|
||||||
|
|
||||||
|
### PC-203 · Earning saat order lunas · L
|
||||||
|
- **PRD:** F3
|
||||||
|
- **Kerjakan:**
|
||||||
|
- **Satukan titik "order menjadi lunas".** Saat ini `payment_status = completed`
|
||||||
|
di-set di empat tempat: `OrderProcessorImpl.UpdateOrder`,
|
||||||
|
`OrderProcessorImpl.updateOrderStatus`, dan dua tempat di `split_bill_processor.go`.
|
||||||
|
Buat satu hook `onOrderPaid(orderID)` yang dipanggil dari keempatnya.
|
||||||
|
- Hook menjalankan earning **setelah** transaksi pembayaran commit, sehingga
|
||||||
|
kegagalan earning tidak menggagalkan pembayaran.
|
||||||
|
- Idempotency key `earn:{order_id}:{currency}`.
|
||||||
|
- Lewati order tanpa customer, customer default, atau customer nonaktif.
|
||||||
|
- **Jaring pengaman:** job yang mencari order lunas beberapa hari terakhir yang belum
|
||||||
|
punya `EARN` (dan seharusnya punya), lalu menjalankan ulang earning.
|
||||||
|
- **Selesai jika:** test untuk pembayaran penuh, split bill (lunas di pembayaran
|
||||||
|
terakhir), self-order, dan pemanggilan ganda (hasilnya tetap satu earning). Earning
|
||||||
|
yang sengaja digagalkan tidak menggagalkan pembayaran dan terambil oleh job.
|
||||||
|
- **Bergantung pada:** PC-104, PC-202
|
||||||
|
|
||||||
|
### PC-204 · Reversal earning saat void / refund · M
|
||||||
|
- **PRD:** F10, Q3
|
||||||
|
- **Kerjakan:** panggil reversal dari `VoidOrder`, `RefundOrder`, dan `RefundPayment`.
|
||||||
|
Ambil pertama dari lot `EARN` order tersebut, lalu lot lain. Gunakan `DebitUpTo` dan
|
||||||
|
catat `shortfall`. Refund tidak pernah diblokir.
|
||||||
|
- **Selesai jika:** test untuk void penuh, refund sebagian (proporsional, akumulasi tidak
|
||||||
|
melebihi earning), dan saldo yang sudah terpakai (shortfall tercatat, saldo 0).
|
||||||
|
- **Bergantung pada:** PC-203
|
||||||
|
|
||||||
|
### PC-205 · Tampilkan earning di order dan struk · S
|
||||||
|
- **PRD:** F3
|
||||||
|
- **Kerjakan:** tambah `points_earned` dan `coins_earned` ke response order dan data
|
||||||
|
struk.
|
||||||
|
- **Selesai jika:** nilai sama dengan baris `EARN` di ledger, dan bernilai 0 untuk order
|
||||||
|
tanpa earning.
|
||||||
|
- **Bergantung pada:** PC-203
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 3 — Pembayaran EnakPoint
|
||||||
|
|
||||||
|
> Boleh dikerjakan sekarang. **Tidak boleh dirilis** sebelum catatan N2 (keuangan) dan
|
||||||
|
> N3 (legal) ditutup.
|
||||||
|
|
||||||
|
### PC-301 · PIN customer · L
|
||||||
|
- **PRD:** K8, F11, Q16, Q17
|
||||||
|
- **Kerjakan:**
|
||||||
|
- Migrasi kolom PIN di `customers` dan tabel `customer_security_events`.
|
||||||
|
- Purpose OTP baru `pin_setup` dan `pin_reset` di `OtpProcessor`.
|
||||||
|
- Endpoint `/customer/pin/*` (status, OTP, buat, ganti, reset).
|
||||||
|
- Hash bcrypt, tolak PIN lemah (digit sama, berurutan, tanggal lahir).
|
||||||
|
- Kunci 30 menit setelah 5 kali salah, dengan penghitung di database.
|
||||||
|
- Tahan transfer keluar 24 jam setelah reset.
|
||||||
|
- `VerifyPin(ctx, customerID, pin)` untuk dipakai task lain, dengan error
|
||||||
|
`PIN_NOT_SET`, `PIN_INVALID`, `PIN_LOCKED`, `TRANSFER_BLOCKED`.
|
||||||
|
- Hapus PIN oleh admin: `DELETE /marketing/customers/:id/pin`, dan
|
||||||
|
`GET /marketing/customers/:id/security-events`.
|
||||||
|
- PIN tidak pernah muncul di log (termasuk log request body).
|
||||||
|
- **Selesai jika:** test untuk semua aturan di atas, termasuk 5 kali salah lalu PIN
|
||||||
|
benar tetap ditolak selama terkunci, dan reset OTP membuka kunci.
|
||||||
|
- **Bergantung pada:** –
|
||||||
|
|
||||||
|
### PC-302 · API pengaturan loyalitas organisasi · S
|
||||||
|
- **PRD:** F2
|
||||||
|
- **Kerjakan:** `GET/PUT /marketing/loyalty-settings` untuk nilai EnakPoint, kurs
|
||||||
|
exchange, dan batas transfer, serta `GET /marketing/loyalty-settings/history`.
|
||||||
|
Sebelum menyimpan perubahan nilai EnakPoint atau kurs, response preview menampilkan
|
||||||
|
total saldo beredar dan nilai rupiahnya sebelum/sesudah.
|
||||||
|
- **Selesai jika:** perubahan tercatat di riwayat, dan transaksi lama tetap memakai nilai
|
||||||
|
yang dibekukan.
|
||||||
|
- **Bergantung pada:** PC-109
|
||||||
|
|
||||||
|
### PC-303 · Payment method EnakPoint · M
|
||||||
|
- **PRD:** F9 (payment method), §8 (perubahan `payments`), §10.5
|
||||||
|
- **Kerjakan:**
|
||||||
|
- Tambah tipe `point` ke `PaymentMethodType` dan validator (`oneof=cash card
|
||||||
|
digital_wallet point`).
|
||||||
|
- Migrasi kolom `points_used` dan `point_value` di `payments`.
|
||||||
|
- Buat payment method sistem "EnakPoint" untuk setiap organisasi yang sudah ada, dan
|
||||||
|
otomatis untuk organisasi baru.
|
||||||
|
- Tolak hapus / ubah tipe method sistem.
|
||||||
|
- Sembunyikan method ini di kasir jika outlet tidak mengaktifkan `accept_payment`.
|
||||||
|
- **Selesai jika:** setiap organisasi punya tepat satu method EnakPoint yang tidak bisa
|
||||||
|
dihapus.
|
||||||
|
- **Bergantung pada:** –
|
||||||
|
|
||||||
|
### PC-304 · Kode bayar sekali pakai · M
|
||||||
|
- **PRD:** F9 (persetujuan customer), K8
|
||||||
|
- **Kerjakan:** `POST /customer/wallet/payment-code` (butuh PIN) menghasilkan kode 6
|
||||||
|
digit dan QR yang berlaku 2 menit, disimpan di Redis dengan TTL dan diambil
|
||||||
|
sekali-pakai (`GETDEL`). Kode terikat ke `customer_id`.
|
||||||
|
- **Selesai jika:** kode kedaluwarsa, kode yang sudah dipakai, dan kode milik customer
|
||||||
|
lain semuanya ditolak.
|
||||||
|
- **Bergantung pada:** PC-301
|
||||||
|
|
||||||
|
### PC-305 · Bayar EnakPoint di kasir · L
|
||||||
|
- **PRD:** F9 (perhitungan, pencatatan), K7
|
||||||
|
- **Kerjakan:**
|
||||||
|
- `GET /orders/:id/point-payment/preview`.
|
||||||
|
- Cabang method `point` di `CreatePayment`: validasi kode bayar, cocokkan customer
|
||||||
|
order, hitung `maks_point`, lalu lakukan langkah 1–6 F9 dalam **satu transaksi**
|
||||||
|
bersama pembuatan baris `payments`.
|
||||||
|
- Idempotency `payment:{payment_id}`.
|
||||||
|
- Tolak jika `nominal_rupiah > remaining_amount` (tidak ada kembalian).
|
||||||
|
- **Selesai jika:** test untuk pembayaran penuh, sebagian + tunai (split), batas
|
||||||
|
`max_payment_percent`, order walk-in (ditolak), kode salah (ditolak), dan dua
|
||||||
|
pembayaran bersamaan untuk customer yang sama (saldo tidak terpakai dua kali).
|
||||||
|
Earning (PC-203) menghitung basis tanpa bagian EnakPoint.
|
||||||
|
- **Bergantung pada:** PC-104, PC-303, PC-304
|
||||||
|
|
||||||
|
### PC-306 · Bayar EnakPoint dari app / self-order · M
|
||||||
|
- **PRD:** F9
|
||||||
|
- **Kerjakan:** `POST /customer/orders/:id/pay-with-points` (butuh PIN, hanya untuk
|
||||||
|
order milik customer itu sendiri), memakai logika yang sama dengan PC-305.
|
||||||
|
- **Selesai jika:** customer tidak bisa membayar order milik customer lain.
|
||||||
|
- **Bergantung pada:** PC-305
|
||||||
|
|
||||||
|
### PC-307 · Refund pembayaran EnakPoint · M
|
||||||
|
- **PRD:** F9 (void/refund), K7, Q13
|
||||||
|
- **Kerjakan:** `PAYMENT_REFUND` di jalur void dan refund memakai `point_value` yang
|
||||||
|
dibekukan, dengan pembulatan ke bawah. Kembalikan ke lot dengan `expires_at` asal
|
||||||
|
(aturan perpanjangan 7 hari mengikuti N4, jadi untuk sekarang cukup pakai tanggal
|
||||||
|
asal). **Tolak** permintaan refund bagian EnakPoint lewat method lain.
|
||||||
|
- **Selesai jika:** test untuk void penuh, refund sebagian, perubahan nilai EnakPoint di
|
||||||
|
antara bayar dan refund (jumlah EnakPoint yang kembali tetap sama), dan percobaan
|
||||||
|
refund tunai atas pembayaran EnakPoint (ditolak).
|
||||||
|
- **Bergantung pada:** PC-305
|
||||||
|
|
||||||
|
### PC-308 · EnakPoint di laporan · S
|
||||||
|
- **PRD:** F9 (laporan)
|
||||||
|
- **Kerjakan:** laporan per payment method menampilkan EnakPoint terpisah dan tidak
|
||||||
|
menghitungnya sebagai kas masuk. Cek laporan analytics yang menjumlahkan pembayaran.
|
||||||
|
- **Selesai jika:** total kas masuk di laporan tidak berubah saat sebagian order dibayar
|
||||||
|
EnakPoint. Perlakuan akuntansi lanjutan menunggu N2.
|
||||||
|
- **Bergantung pada:** PC-305
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 4 — Pergerakan Saldo
|
||||||
|
|
||||||
|
> Boleh dikerjakan sekarang. Transfer (PC-402) **tidak boleh dirilis** sebelum N3
|
||||||
|
> (legal) ditutup.
|
||||||
|
|
||||||
|
### PC-401 · Exchange EnakCoin → EnakPoint · M
|
||||||
|
- **PRD:** F4, K3
|
||||||
|
- **Kerjakan:** `GET /customer/wallet/exchange/preview` dan
|
||||||
|
`POST /customer/wallet/exchange` (butuh PIN, `Idempotency-Key`). Jumlah harus
|
||||||
|
kelipatan `coin_amount`. Kurs dibekukan di metadata. Lot EnakPoint kedaluwarsa pada
|
||||||
|
`min(lot EnakCoin asal, sekarang + masa berlaku EnakPoint)`.
|
||||||
|
- **Selesai jika:** test untuk kurs default 1:1, kurs 10:3, jumlah bukan kelipatan
|
||||||
|
(ditolak), dan `expires_at` lot hasil exchange tidak pernah lebih lama dari lot asal.
|
||||||
|
- **Bergantung pada:** PC-104, PC-301, PC-302
|
||||||
|
|
||||||
|
### PC-402 · Transfer antar customer · L
|
||||||
|
- **PRD:** F5, Q4, Q16
|
||||||
|
- **Kerjakan:** `GET /customer/wallet/transfer/recipient` (nama disamarkan) dan
|
||||||
|
`POST /customer/wallet/transfer` (butuh PIN, `Idempotency-Key`). Kunci dua wallet
|
||||||
|
berurutan, cek batas organisasi (per transaksi dan harian), cek
|
||||||
|
`transfer_blocked_until`. Lot penerima mewarisi `expires_at` dan `origin_lot_id` dari
|
||||||
|
lot pengirim. Kirim notifikasi push ke penerima.
|
||||||
|
- **Selesai jika:** test untuk transfer ke diri sendiri / customer lain organisasi /
|
||||||
|
customer default (semua ditolak), batas harian, transfer dua arah bersamaan (tidak
|
||||||
|
deadlock), dan `expires_at` di penerima sama persis dengan pengirim.
|
||||||
|
- **Bergantung pada:** PC-104, PC-301, PC-302
|
||||||
|
|
||||||
|
### PC-403 · Semua game memakai EnakCoin · M
|
||||||
|
- **PRD:** F8, K1
|
||||||
|
- **Kerjakan:**
|
||||||
|
- `GamePlayProcessor.PlayGame` memotong EnakCoin lewat wallet engine sesuai
|
||||||
|
`games.metadata.coin_cost`.
|
||||||
|
- Seluruh permainan dalam satu transaksi: ubah repository game, game play, dan game
|
||||||
|
prize ke `DBFromContext`. Gagal mengurangi stok hadiah membatalkan permainan (saat
|
||||||
|
ini hanya di-`Printf`), dan rollback manual `AddTokens` dihapus.
|
||||||
|
- Rename `game_plays.token_used` menjadi `coins_used`.
|
||||||
|
- **Selesai jika:** test untuk saldo kurang (ditolak, tidak ada `game_plays` yang
|
||||||
|
tercatat), stok hadiah gagal (semua dibatalkan), dan `GAME_SPEND` menunjuk
|
||||||
|
`game_plays.id`.
|
||||||
|
- **Bergantung pada:** PC-104
|
||||||
|
|
||||||
|
### PC-404 · Telusuri per butir di dashboard · S
|
||||||
|
- **PRD:** F7, §8.1 (per butir)
|
||||||
|
- **Kerjakan:** `GET /marketing/wallet-transactions/:id/trace` yang mengembalikan
|
||||||
|
alokasi lot dan rantai `origin_lot_id` sampai ke lot pertama.
|
||||||
|
- **Selesai jika:** contoh di §8 (A transfer 120 ke B, B bayar 30) menelusuri ke order
|
||||||
|
#ORD-1 milik A.
|
||||||
|
- **Bergantung pada:** PC-402
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 5 — Kedaluwarsa
|
||||||
|
|
||||||
|
> **Jangan dikerjakan sebelum catatan N4 (model kedaluwarsa) diputuskan.** Struktur lot
|
||||||
|
> sudah ada sejak PC-101, jadi yang tersisa hanya rumus, job, dan tampilan.
|
||||||
|
|
||||||
|
### PC-501 · Setting kedaluwarsa · M
|
||||||
|
- **PRD:** F12 (pengaturan), N4
|
||||||
|
- **Kerjakan:** key setting sesuai model yang dipilih di N4, dengan validasi dan preview
|
||||||
|
"saldo yang didapat hari ini kedaluwarsa pada …".
|
||||||
|
- **Bergantung pada:** PC-302, **N4**
|
||||||
|
|
||||||
|
### PC-502 · Hitung `expires_at` saat lot dibuat · M
|
||||||
|
- **PRD:** F12 (tabel kedaluwarsa per lot), N4
|
||||||
|
- **Kerjakan:** satu fungsi `ComputeExpiry(currency, receivedAt, settings)` yang dipakai
|
||||||
|
oleh `EARN` dan `ADJUSTMENT`, serta aturan aktivasi pertama untuk lot lama (termasuk
|
||||||
|
lot `MIGRATION`).
|
||||||
|
- **Bergantung pada:** PC-104, PC-501
|
||||||
|
|
||||||
|
### PC-503 · Job kedaluwarsa · M
|
||||||
|
- **PRD:** F12 (proses kedaluwarsa)
|
||||||
|
- **Kerjakan:** job per jam yang memproses lot lewat tanggal dengan `EXPIRE`
|
||||||
|
(idempotency `expire:{lot_id}`) di bawah lock wallet.
|
||||||
|
- **Jangan menyalin pola `OmsetMilestoneScheduler`.** Scheduler itu menyimpan state di
|
||||||
|
memori, dan menurut komentarnya sendiri bisa mengirim ulang notifikasi setelah
|
||||||
|
restart. Job ini harus aman dijalankan di banyak instance sekaligus: pilih lot
|
||||||
|
dengan `FOR UPDATE SKIP LOCKED`, dan andalkan idempotency key.
|
||||||
|
- **Selesai jika:** dua instance yang berjalan bersamaan tidak menghanguskan lot yang
|
||||||
|
sama dua kali, dan tidak ada lot yang lewat tanggal lebih dari 1 jam tanpa diproses.
|
||||||
|
- **Bergantung pada:** PC-502
|
||||||
|
|
||||||
|
### PC-504 · Pengingat dan tampilan saldo yang akan kedaluwarsa · M
|
||||||
|
- **PRD:** F6 (`/wallet/expiring`), F12 (pengingat)
|
||||||
|
- **Kerjakan:** endpoint `GET /customer/wallet/expiring`, field saldo kedaluwarsa
|
||||||
|
terdekat di `/wallet`, dan notifikasi pengingat yang dikelompokkan per tanggal.
|
||||||
|
Jadwal pengingat mengikuti N4.
|
||||||
|
- **Bergantung pada:** PC-503
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 6 — Bersih-bersih
|
||||||
|
|
||||||
|
### PC-601 · Hapus tabel dan kode lama · S
|
||||||
|
- **PRD:** §10.7
|
||||||
|
- **Kerjakan:** satu rilis setelah PC-105 berjalan di production, drop
|
||||||
|
`customer_points` dan `customer_tokens`. Hapus `CustomerPointsProcessor` dan
|
||||||
|
`CustomerTokensProcessor` beserta stub `not implemented`, rute yang di-comment di
|
||||||
|
`router.go`, dan alias `/customer/points` / `/customer/tokens` setelah aplikasi
|
||||||
|
diperbarui.
|
||||||
|
- **Bergantung pada:** PC-106, PC-403, dan konfirmasi bahwa aplikasi sudah tidak
|
||||||
|
memanggil endpoint lama.
|
||||||
|
|
||||||
|
### PC-602 · Dokumentasi integrasi · S
|
||||||
|
- **Kerjakan:** panduan integrasi untuk tim aplikasi dan POS (seperti
|
||||||
|
`integration-weight-based-products.md`): endpoint, kode error PIN, alur kode bayar, dan
|
||||||
|
contoh request/response.
|
||||||
|
- **Bergantung pada:** PC-305, PC-402
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Yang Bisa Dimulai Sekarang
|
||||||
|
|
||||||
|
Bisa dikerjakan paralel tanpa menunggu apa pun:
|
||||||
|
- **PC-101** (migrasi wallet) → langsung lanjut **PC-103**, lalu **PC-104**
|
||||||
|
- **PC-102** (setting organisasi) → **PC-109**
|
||||||
|
- **PC-301** (PIN customer)
|
||||||
|
- **PC-303** (payment method EnakPoint)
|
||||||
|
|
||||||
|
PC-104 adalah jalur kritis: hampir semua task lain menunggunya.
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package constants
|
package constants
|
||||||
|
|
||||||
// Budget allocation of revenue used by the parent category cut-off report.
|
// Revenue split used by the profit sharing report: the owner takes its fee as SDL and
|
||||||
// The three shares are expected to add up to 100.
|
// the team takes the rest. BudgetLimitOwnerPercent is only the default: a parent
|
||||||
const (
|
// category can override it with categories.owner_fee_percent.
|
||||||
BudgetLimitPurchasePercent = 60.0
|
const BudgetLimitOwnerPercent = 20.0
|
||||||
BudgetLimitOwnerPercent = 20.0
|
|
||||||
BudgetLimitTeamPercent = 20.0
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ package constants
|
|||||||
// Per outlet (F1): what an order earns, and whether EnakPoint can pay.
|
// Per outlet (F1): what an order earns, and whether EnakPoint can pay.
|
||||||
const (
|
const (
|
||||||
LoyaltyPointEnabledKey = "loyalty.point.enabled"
|
LoyaltyPointEnabledKey = "loyalty.point.enabled"
|
||||||
|
LoyaltyPointEarnModeKey = "loyalty.point.earn_mode"
|
||||||
|
LoyaltyPointEarnPercentKey = "loyalty.point.earn_percent"
|
||||||
LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount"
|
LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount"
|
||||||
LoyaltyPointEarnValueKey = "loyalty.point.earn_value"
|
LoyaltyPointEarnValueKey = "loyalty.point.earn_value"
|
||||||
LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount"
|
LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount"
|
||||||
LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order"
|
LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order"
|
||||||
|
|
||||||
LoyaltyCoinEnabledKey = "loyalty.coin.enabled"
|
LoyaltyCoinEnabledKey = "loyalty.coin.enabled"
|
||||||
|
LoyaltyCoinEarnModeKey = "loyalty.coin.earn_mode"
|
||||||
|
LoyaltyCoinEarnPercentKey = "loyalty.coin.earn_percent"
|
||||||
LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount"
|
LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount"
|
||||||
LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value"
|
LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value"
|
||||||
LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount"
|
LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount"
|
||||||
@@ -47,6 +51,14 @@ const (
|
|||||||
LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months"
|
LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Modes of loyalty.{point,coin}.earn_mode.
|
||||||
|
const (
|
||||||
|
// earn_value for every earn_per_amount rupiah of the basis.
|
||||||
|
LoyaltyEarnModePerAmount = "PER_AMOUNT"
|
||||||
|
// earn_percent percent of the basis.
|
||||||
|
LoyaltyEarnModePercentage = "PERCENTAGE"
|
||||||
|
)
|
||||||
|
|
||||||
// Units of loyalty.{point,coin}.expiry_unit.
|
// Units of loyalty.{point,coin}.expiry_unit.
|
||||||
const (
|
const (
|
||||||
LoyaltyExpiryUnitDay = "DAY"
|
LoyaltyExpiryUnitDay = "DAY"
|
||||||
@@ -66,6 +78,8 @@ const (
|
|||||||
LoyaltyPointEarnPerAmountDefault = int64(100)
|
LoyaltyPointEarnPerAmountDefault = int64(100)
|
||||||
LoyaltyCoinEarnPerAmountDefault = int64(25000)
|
LoyaltyCoinEarnPerAmountDefault = int64(25000)
|
||||||
LoyaltyEarnValueDefault = int64(1)
|
LoyaltyEarnValueDefault = int64(1)
|
||||||
|
LoyaltyEarnModeDefault = LoyaltyEarnModePerAmount
|
||||||
|
LoyaltyEarnPercentDefault = float64(1)
|
||||||
|
|
||||||
LoyaltyMinPaymentPointsDefault = int64(1)
|
LoyaltyMinPaymentPointsDefault = int64(1)
|
||||||
LoyaltyMaxPaymentPercentDefault = int64(100)
|
LoyaltyMaxPaymentPercentDefault = int64(100)
|
||||||
|
|||||||
@@ -266,16 +266,27 @@ type ProductAnalyticsPerParentCategoryResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProductAnalyticsPerParentCategoryData struct {
|
type ProductAnalyticsPerParentCategoryData struct {
|
||||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||||
ParentCategoryName string `json:"parent_category_name"`
|
ParentCategoryName string `json:"parent_category_name"`
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||||
TotalQuantity int64 `json:"total_quantity"`
|
SDL float64 `json:"sdl"`
|
||||||
CategoryCount int64 `json:"category_count"`
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
ProductCount int64 `json:"product_count"`
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
OrderCount int64 `json:"order_count"`
|
CategoryCount int64 `json:"category_count"`
|
||||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
ProductCount int64 `json:"product_count"`
|
||||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
OrderCount int64 `json:"order_count"`
|
||||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
|
||||||
|
type ParentCategoryTopProduct struct {
|
||||||
|
ProductID uuid.UUID `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
QuantitySold int64 `json:"quantity_sold"`
|
||||||
|
Revenue float64 `json:"revenue"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
||||||
@@ -302,19 +313,25 @@ type ParentCategoryAnalyticsDetailResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ParentCategoryAnalyticsDetailSummary struct {
|
type ParentCategoryAnalyticsDetailSummary struct {
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||||
TotalQuantity int64 `json:"total_quantity"`
|
SDL float64 `json:"sdl"`
|
||||||
CategoryCount int64 `json:"category_count"`
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
ProductCount int64 `json:"product_count"`
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
OrderCount int64 `json:"order_count"`
|
CategoryCount int64 `json:"category_count"`
|
||||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
ProductCount int64 `json:"product_count"`
|
||||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
OrderCount int64 `json:"order_count"`
|
||||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParentCategoryAnalyticsDetailData struct {
|
type ParentCategoryAnalyticsDetailData struct {
|
||||||
CategoryID uuid.UUID `json:"category_id"`
|
CategoryID uuid.UUID `json:"category_id"`
|
||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
|
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||||
|
SDL float64 `json:"sdl"`
|
||||||
|
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
TotalQuantity int64 `json:"total_quantity"`
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
ProductCount int64 `json:"product_count"`
|
ProductCount int64 `json:"product_count"`
|
||||||
@@ -355,19 +372,17 @@ type BudgetCutOff struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BudgetPercentages struct {
|
type BudgetPercentages struct {
|
||||||
Purchase float64 `json:"purchase"`
|
Owner float64 `json:"owner"`
|
||||||
Owner float64 `json:"owner"`
|
Team float64 `json:"team"`
|
||||||
Team float64 `json:"team"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BudgetPeriod struct {
|
type BudgetPeriod struct {
|
||||||
PeriodStart time.Time `json:"period_start"`
|
PeriodStart time.Time `json:"period_start"`
|
||||||
PeriodEnd time.Time `json:"period_end"`
|
PeriodEnd time.Time `json:"period_end"`
|
||||||
Revenue float64 `json:"revenue"`
|
Revenue float64 `json:"revenue"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
LimitPurchase float64 `json:"limit_purchase"`
|
SDL float64 `json:"sdl"`
|
||||||
LimitOwner float64 `json:"limit_owner"`
|
LimitTeam float64 `json:"limit_team"`
|
||||||
LimitTeam float64 `json:"limit_team"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BudgetMonthPeriod struct {
|
type BudgetMonthPeriod struct {
|
||||||
|
|||||||
@@ -1,29 +1,81 @@
|
|||||||
package contract
|
package contract
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CreateCategoryRequest struct {
|
type CreateCategoryRequest struct {
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
|
||||||
|
IsTeam *bool `json:"is_team,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCategoryRequest struct {
|
type UpdateCategoryRequest struct {
|
||||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
|
||||||
|
IsTeam *bool `json:"is_team,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
|
||||||
|
// Set when the field is sent as null (or "" for parent_id), which asks for the
|
||||||
|
// value to be removed. A field that is left out stays unchanged.
|
||||||
|
ClearParentID bool `json:"-"`
|
||||||
|
ClearOwnerFeePercent bool `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON tells an explicit null apart from a field that was left out, so a
|
||||||
|
// category can be detached from its parent and an owner fee override can be removed.
|
||||||
|
func (r *UpdateCategoryRequest) UnmarshalJSON(data []byte) error {
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
isEmpty := func(key string, allowEmptyString bool) bool {
|
||||||
|
value, ok := raw[key]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
value = bytes.TrimSpace(value)
|
||||||
|
return bytes.Equal(value, []byte("null")) || (allowEmptyString && bytes.Equal(value, []byte(`""`)))
|
||||||
|
}
|
||||||
|
|
||||||
|
clearParentID := isEmpty("parent_id", true)
|
||||||
|
clearOwnerFeePercent := isEmpty("owner_fee_percent", false)
|
||||||
|
if clearParentID {
|
||||||
|
// An empty string is not a valid UUID, so keep it away from the decoder
|
||||||
|
delete(raw, "parent_id")
|
||||||
|
cleaned, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data = cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
type plain UpdateCategoryRequest
|
||||||
|
var decoded plain
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*r = UpdateCategoryRequest(decoded)
|
||||||
|
r.ClearParentID = clearParentID
|
||||||
|
r.ClearOwnerFeePercent = clearOwnerFeePercent
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListCategoriesRequest struct {
|
type ListCategoriesRequest struct {
|
||||||
@@ -39,18 +91,20 @@ type ListCategoriesRequest struct {
|
|||||||
|
|
||||||
// Category Response DTOs
|
// Category Response DTOs
|
||||||
type CategoryResponse struct {
|
type CategoryResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||||
ParentName *string `json:"parent_name,omitempty"`
|
ParentName *string `json:"parent_name,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
BusinessType string `json:"business_type"`
|
BusinessType string `json:"business_type"`
|
||||||
Order int `json:"order"`
|
Order int `json:"order"`
|
||||||
Metadata map[string]interface{} `json:"metadata"`
|
OwnerFeePercent *float64 `json:"owner_fee_percent"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
IsTeam bool `json:"is_team"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListCategoriesResponse struct {
|
type ListCategoriesResponse struct {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package contract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateCategoryRequestTellsNullFromOmitted(t *testing.T) {
|
||||||
|
parentID := uuid.New()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantParentID *uuid.UUID
|
||||||
|
wantClear bool
|
||||||
|
wantClearFee bool
|
||||||
|
wantFee *float64
|
||||||
|
wantNameIsSet bool
|
||||||
|
}{
|
||||||
|
{name: "omitted leaves parent unchanged", body: `{"name":"Food"}`, wantNameIsSet: true},
|
||||||
|
{name: "null detaches the parent", body: `{"parent_id":null}`, wantClear: true},
|
||||||
|
{name: "empty string detaches the parent", body: `{"name":"Food","parent_id":""}`, wantClear: true, wantNameIsSet: true},
|
||||||
|
{name: "uuid sets the parent", body: `{"parent_id":"` + parentID.String() + `"}`, wantParentID: &parentID},
|
||||||
|
{name: "null removes the owner fee override", body: `{"owner_fee_percent":null}`, wantClearFee: true},
|
||||||
|
{name: "number sets the owner fee", body: `{"owner_fee_percent":35}`, wantFee: func() *float64 { v := 35.0; return &v }()},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var req UpdateCategoryRequest
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(tt.body), &req))
|
||||||
|
|
||||||
|
require.Equal(t, tt.wantParentID, req.ParentID)
|
||||||
|
require.Equal(t, tt.wantClear, req.ClearParentID)
|
||||||
|
require.Equal(t, tt.wantClearFee, req.ClearOwnerFeePercent)
|
||||||
|
require.Equal(t, tt.wantFee, req.OwnerFeePercent)
|
||||||
|
require.Equal(t, tt.wantNameIsSet, req.Name != nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCategoryRequestRejectsInvalidParentID(t *testing.T) {
|
||||||
|
var req UpdateCategoryRequest
|
||||||
|
require.Error(t, json.Unmarshal([]byte(`{"parent_id":"not-a-uuid"}`), &req))
|
||||||
|
}
|
||||||
@@ -121,7 +121,7 @@ type OrderItemResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
PrinterType string `json:"printer_type"`
|
PrinterTypes []string `json:"printer_types"`
|
||||||
PrintToChecker bool `json:"print_to_checker"`
|
PrintToChecker bool `json:"print_to_checker"`
|
||||||
PaidQuantity int `json:"paid_quantity"`
|
PaidQuantity int `json:"paid_quantity"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type CreateProductRequest struct {
|
|||||||
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,max=50"`
|
||||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||||
@@ -38,7 +38,7 @@ type UpdateProductRequest struct {
|
|||||||
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,max=50"` // Replaces the whole list when sent
|
||||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||||
@@ -76,7 +76,7 @@ type ProductResponse struct {
|
|||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
BusinessType string `json:"business_type"`
|
BusinessType string `json:"business_type"`
|
||||||
ImageURL *string `json:"image_url"`
|
ImageURL *string `json:"image_url"`
|
||||||
PrinterType string `json:"printer_type"`
|
PrinterTypes []string `json:"printer_types"`
|
||||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||||
SellBy string `json:"sell_by"`
|
SellBy string `json:"sell_by"`
|
||||||
PrintToChecker bool `json:"print_to_checker"`
|
PrintToChecker bool `json:"print_to_checker"`
|
||||||
|
|||||||
@@ -143,16 +143,29 @@ type ProductAnalyticsPerCategory struct {
|
|||||||
// ProductAnalyticsPerParentCategory rolls the per-category figures up to the
|
// ProductAnalyticsPerParentCategory rolls the per-category figures up to the
|
||||||
// top-level category. A category without a parent is its own group.
|
// top-level category. A category without a parent is its own group.
|
||||||
type ProductAnalyticsPerParentCategory struct {
|
type ProductAnalyticsPerParentCategory struct {
|
||||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||||
ParentCategoryName string `json:"parent_category_name"`
|
ParentCategoryName string `json:"parent_category_name"`
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
// OwnerFeePercent is the owner share of this group's revenue, already resolved to the default
|
||||||
TotalQuantity int64 `json:"total_quantity"`
|
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||||
CategoryCount int64 `json:"category_count"`
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
ProductCount int64 `json:"product_count"`
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
OrderCount int64 `json:"order_count"`
|
CategoryCount int64 `json:"category_count"`
|
||||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
ProductCount int64 `json:"product_count"`
|
||||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
OrderCount int64 `json:"order_count"`
|
||||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
// TopProduct is filled by a separate query, so it is not a scanned column
|
||||||
|
TopProduct *ParentCategoryTopProduct `gorm:"-" json:"top_product"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
|
||||||
|
type ParentCategoryTopProduct struct {
|
||||||
|
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||||
|
ProductID uuid.UUID `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
QuantitySold int64 `json:"quantity_sold"`
|
||||||
|
Revenue float64 `json:"revenue"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParentCategoryAnalyticsDetail is the drill-down for a single parent category:
|
// ParentCategoryAnalyticsDetail is the drill-down for a single parent category:
|
||||||
@@ -171,6 +184,8 @@ type BudgetCutOffWeek struct {
|
|||||||
WeekStart time.Time `json:"week_start"`
|
WeekStart time.Time `json:"week_start"`
|
||||||
Revenue float64 `json:"revenue"`
|
Revenue float64 `json:"revenue"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
|
// SDL is summed per parent category, each at its own owner fee percent
|
||||||
|
SDL float64 `json:"sdl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DashboardOverview represents dashboard overview data
|
// DashboardOverview represents dashboard overview data
|
||||||
|
|||||||
@@ -41,8 +41,14 @@ type Category struct {
|
|||||||
Order int `gorm:"default:0" json:"order"`
|
Order int `gorm:"default:0" json:"order"`
|
||||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
// OwnerFeePercent overrides the owner's share in the parent category budget report; nil uses the default
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
OwnerFeePercent *float64 `gorm:"type:numeric(5,2)" json:"owner_fee_percent"`
|
||||||
|
// IsTeam marks a parent category as a team in the revenue split. It has no gorm
|
||||||
|
// default on purpose: GORM would turn an explicit false into the default on insert,
|
||||||
|
// so callers creating a category set it themselves.
|
||||||
|
IsTeam bool `gorm:"not null" json:"is_team"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
|
||||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||||
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
|
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package entities
|
package entities
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -8,24 +9,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Product struct {
|
type Product struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||||
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
|
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
|
||||||
SKU *string `gorm:"size:100;index" json:"sku"`
|
SKU *string `gorm:"size:100;index" json:"sku"`
|
||||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||||
Description *string `gorm:"type:text" json:"description"`
|
Description *string `gorm:"type:text" json:"description"`
|
||||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"`
|
Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"`
|
||||||
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"`
|
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"`
|
||||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||||
ImageURL *string `gorm:"size:500" json:"image_url"`
|
ImageURL *string `gorm:"size:500" json:"image_url"`
|
||||||
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
|
// PrinterTypes are the stations the product is printed at. Set them through
|
||||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
// SetPrinterTypes.
|
||||||
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
|
PrinterTypes StringSlice `gorm:"type:jsonb;not null;default:'[\"kitchen\"]'" json:"printer_types"`
|
||||||
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
|
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
|
||||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||||
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||||
@@ -48,6 +51,32 @@ func (Product) TableName() string {
|
|||||||
return "products"
|
return "products"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPrinterTypes records the stations the product is printed at, in order, dropping
|
||||||
|
// blanks and repeats.
|
||||||
|
func (p *Product) SetPrinterTypes(printerTypes []string) {
|
||||||
|
cleaned := StringSlice{}
|
||||||
|
seen := make(map[string]bool, len(printerTypes))
|
||||||
|
for _, printerType := range printerTypes {
|
||||||
|
printerType = strings.TrimSpace(printerType)
|
||||||
|
if printerType == "" || seen[printerType] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[printerType] = true
|
||||||
|
cleaned = append(cleaned, printerType)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.PrinterTypes = cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrinterTypes returns the stations the product is printed at, never nil, so a
|
||||||
|
// response always carries a list. A product that was not loaded has none.
|
||||||
|
func (p *Product) GetPrinterTypes() []string {
|
||||||
|
if p.PrinterTypes == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return []string(p.PrinterTypes)
|
||||||
|
}
|
||||||
|
|
||||||
type ProductVariant struct {
|
type ProductVariant struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
|
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
|||||||
status, body := call(http.MethodGet, "/manager"+path, "")
|
status, body := call(http.MethodGet, "/manager"+path, "")
|
||||||
require.Equal(t, http.StatusOK, status, body)
|
require.Equal(t, http.StatusOK, status, body)
|
||||||
got := data(body)
|
got := data(body)
|
||||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(100), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
||||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
|
assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(25000), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
|
||||||
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
|
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
|
||||||
assert.EqualValues(t, 1, got["point_value"])
|
assert.EqualValues(t, 1, got["point_value"])
|
||||||
assert.EqualValues(t, 1, got["point_cashback_percent"])
|
assert.EqualValues(t, 1, got["point_cashback_percent"])
|
||||||
@@ -135,6 +135,8 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
|||||||
for name, bad := range map[string]string{
|
for name, bad := range map[string]string{
|
||||||
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
|
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
|
||||||
"negative earn_value": `{"coin": {"earn_value": -1}}`,
|
"negative earn_value": `{"coin": {"earn_value": -1}}`,
|
||||||
|
"earn mode unknown": `{"point": {"earn_mode": "PERCENT"}}`,
|
||||||
|
"earn_percent over 100": `{"coin": {"earn_percent": 101}}`,
|
||||||
"negative min_order": `{"point": {"min_order_amount": -5}}`,
|
"negative min_order": `{"point": {"min_order_amount": -5}}`,
|
||||||
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
|
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
|
||||||
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
|
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
|
||||||
|
|||||||
@@ -58,15 +58,23 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
|
|||||||
metadata["image_url"] = *req.ImageURL
|
metadata["image_url"] = *req.ImageURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A category is a team unless the request says otherwise
|
||||||
|
isTeam := true
|
||||||
|
if req.IsTeam != nil {
|
||||||
|
isTeam = *req.IsTeam
|
||||||
|
}
|
||||||
|
|
||||||
return &entities.Category{
|
return &entities.Category{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
ParentID: req.ParentID,
|
ParentID: req.ParentID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Order: req.Order,
|
Order: req.Order,
|
||||||
BusinessType: "restaurant",
|
OwnerFeePercent: req.OwnerFeePercent,
|
||||||
Metadata: metadata,
|
IsTeam: isTeam,
|
||||||
|
BusinessType: "restaurant",
|
||||||
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,18 +102,20 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &models.CategoryResponse{
|
return &models.CategoryResponse{
|
||||||
ID: entity.ID,
|
ID: entity.ID,
|
||||||
OrganizationID: entity.OrganizationID,
|
OrganizationID: entity.OrganizationID,
|
||||||
OutletID: entity.OutletID,
|
OutletID: entity.OutletID,
|
||||||
ParentID: entity.ParentID,
|
ParentID: entity.ParentID,
|
||||||
ParentName: parentName,
|
ParentName: parentName,
|
||||||
Name: entity.Name,
|
Name: entity.Name,
|
||||||
Description: entity.Description,
|
Description: entity.Description,
|
||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
Order: entity.Order,
|
Order: entity.Order,
|
||||||
IsActive: true,
|
OwnerFeePercent: entity.OwnerFeePercent,
|
||||||
CreatedAt: entity.CreatedAt,
|
IsTeam: entity.IsTeam,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
IsActive: true,
|
||||||
|
CreatedAt: entity.CreatedAt,
|
||||||
|
UpdatedAt: entity.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,11 +144,25 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
|
|||||||
entity.Order = *req.Order
|
entity.Order = *req.Order
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.ClearOwnerFeePercent {
|
||||||
|
entity.OwnerFeePercent = nil
|
||||||
|
} else if req.OwnerFeePercent != nil {
|
||||||
|
entity.OwnerFeePercent = req.OwnerFeePercent
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.IsTeam != nil {
|
||||||
|
entity.IsTeam = *req.IsTeam
|
||||||
|
}
|
||||||
|
|
||||||
if req.OutletID != nil {
|
if req.OutletID != nil {
|
||||||
entity.OutletID = req.OutletID
|
entity.OutletID = req.OutletID
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ParentID != nil {
|
if req.ClearParentID {
|
||||||
|
// Drop the preloaded parent too, so the response stops reporting it
|
||||||
|
entity.ParentID = nil
|
||||||
|
entity.Parent = nil
|
||||||
|
} else if req.ParentID != nil {
|
||||||
entity.ParentID = req.ParentID
|
entity.ParentID = req.ParentID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package mappers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateCategoryRequestToEntityDefaultsToTeam(t *testing.T) {
|
||||||
|
notTeam := false
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
isTeam *bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "omitted is a team", isTeam: nil, want: true},
|
||||||
|
{name: "false is not a team", isTeam: ¬Team, want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
entity := CreateCategoryRequestToEntity(&models.CreateCategoryRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
Name: "Merchandise",
|
||||||
|
IsTeam: tt.isTeam,
|
||||||
|
})
|
||||||
|
require.Equal(t, tt.want, entity.IsTeam)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,7 +149,7 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
|
|||||||
Status: constants.OrderItemStatus(item.Status),
|
Status: constants.OrderItemStatus(item.Status),
|
||||||
CreatedAt: item.CreatedAt,
|
CreatedAt: item.CreatedAt,
|
||||||
UpdatedAt: item.UpdatedAt,
|
UpdatedAt: item.UpdatedAt,
|
||||||
PrinterType: item.Product.PrinterType,
|
PrinterTypes: item.Product.GetPrinterTypes(),
|
||||||
PrintToChecker: printToChecker,
|
PrintToChecker: printToChecker,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
|
|||||||
Cost: entity.Cost,
|
Cost: entity.Cost,
|
||||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||||
ImageURL: entity.ImageURL,
|
ImageURL: entity.ImageURL,
|
||||||
PrinterType: entity.PrinterType,
|
PrinterTypes: entity.GetPrinterTypes(),
|
||||||
UnitID: entity.UnitID,
|
UnitID: entity.UnitID,
|
||||||
SellBy: entity.SellBy,
|
SellBy: entity.SellBy,
|
||||||
HasIngredients: entity.HasIngredients,
|
HasIngredients: entity.HasIngredients,
|
||||||
@@ -51,7 +51,7 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
|
|||||||
Cost: model.Cost,
|
Cost: model.Cost,
|
||||||
BusinessType: string(model.BusinessType),
|
BusinessType: string(model.BusinessType),
|
||||||
ImageURL: model.ImageURL,
|
ImageURL: model.ImageURL,
|
||||||
PrinterType: model.PrinterType,
|
PrinterTypes: entities.StringSlice(model.PrinterTypes),
|
||||||
UnitID: model.UnitID,
|
UnitID: model.UnitID,
|
||||||
SellBy: model.SellBy,
|
SellBy: model.SellBy,
|
||||||
HasIngredients: model.HasIngredients,
|
HasIngredients: model.HasIngredients,
|
||||||
@@ -77,11 +77,6 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
|||||||
businessType = string(req.BusinessType)
|
businessType = string(req.BusinessType)
|
||||||
}
|
}
|
||||||
|
|
||||||
printerType := "kitchen"
|
|
||||||
if req.PrinterType != nil && *req.PrinterType != "" {
|
|
||||||
printerType = *req.PrinterType
|
|
||||||
}
|
|
||||||
|
|
||||||
sellBy := constants.SellByUnit
|
sellBy := constants.SellByUnit
|
||||||
if constants.IsValidSellBy(req.SellBy) {
|
if constants.IsValidSellBy(req.SellBy) {
|
||||||
sellBy = req.SellBy
|
sellBy = req.SellBy
|
||||||
@@ -92,7 +87,7 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
|||||||
metadata = entities.Metadata(req.Metadata)
|
metadata = entities.Metadata(req.Metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &entities.Product{
|
product := &entities.Product{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
CategoryID: req.CategoryID,
|
CategoryID: req.CategoryID,
|
||||||
SKU: req.SKU,
|
SKU: req.SKU,
|
||||||
@@ -102,12 +97,18 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
|||||||
Cost: cost,
|
Cost: cost,
|
||||||
BusinessType: businessType,
|
BusinessType: businessType,
|
||||||
ImageURL: req.ImageURL,
|
ImageURL: req.ImageURL,
|
||||||
PrinterType: printerType,
|
|
||||||
UnitID: req.UnitID,
|
UnitID: req.UnitID,
|
||||||
SellBy: sellBy,
|
SellBy: sellBy,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
IsActive: true, // Default to active
|
IsActive: true, // Default to active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
product.SetPrinterTypes(req.PrinterTypes)
|
||||||
|
if len(product.PrinterTypes) == 0 {
|
||||||
|
product.SetPrinterTypes([]string{"kitchen"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return product
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
||||||
@@ -152,7 +153,7 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
|||||||
Cost: entity.Cost,
|
Cost: entity.Cost,
|
||||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||||
ImageURL: entity.ImageURL,
|
ImageURL: entity.ImageURL,
|
||||||
PrinterType: entity.PrinterType,
|
PrinterTypes: entity.GetPrinterTypes(),
|
||||||
UnitID: entity.UnitID,
|
UnitID: entity.UnitID,
|
||||||
SellBy: entity.SellBy,
|
SellBy: entity.SellBy,
|
||||||
Metadata: map[string]interface{}(entity.Metadata),
|
Metadata: map[string]interface{}(entity.Metadata),
|
||||||
@@ -196,8 +197,8 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
|
|||||||
entity.ImageURL = req.ImageURL
|
entity.ImageURL = req.ImageURL
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.PrinterType != nil {
|
if req.PrinterTypes != nil {
|
||||||
entity.PrinterType = *req.PrinterType
|
entity.SetPrinterTypes(req.PrinterTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.UnitID != nil {
|
if req.UnitID != nil {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package mappers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateProductRequestToEntityPrinterTypes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
printerTypes []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nothing sent prints in the kitchen",
|
||||||
|
want: []string{"kitchen"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a meal-and-drink package prints in the kitchen and the bar",
|
||||||
|
printerTypes: []string{"kitchen", "bar"},
|
||||||
|
want: []string{"kitchen", "bar"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blanks and repeats are dropped",
|
||||||
|
printerTypes: []string{" kitchen ", "", "kitchen", "bar"},
|
||||||
|
want: []string{"kitchen", "bar"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a list of only blanks falls back to the kitchen",
|
||||||
|
printerTypes: []string{" ", ""},
|
||||||
|
want: []string{"kitchen"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
product := CreateProductRequestToEntity(&models.CreateProductRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
CategoryID: uuid.New(),
|
||||||
|
Name: "Paket Makan Minum",
|
||||||
|
PrinterTypes: tt.printerTypes,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateProductEntityFromRequestPrinterTypes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
printerTypes []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "a save that does not send printers keeps them",
|
||||||
|
want: []string{"kitchen", "bar"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "printer_types replaces the whole list",
|
||||||
|
printerTypes: []string{"bar"},
|
||||||
|
want: []string{"bar"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an empty printer_types prints nowhere",
|
||||||
|
printerTypes: []string{},
|
||||||
|
want: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
product := &entities.Product{}
|
||||||
|
product.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||||
|
|
||||||
|
UpdateProductEntityFromRequest(product, &models.UpdateProductRequest{PrinterTypes: tt.printerTypes})
|
||||||
|
|
||||||
|
assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderItemEntityToResponsePrinterTypes(t *testing.T) {
|
||||||
|
product := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"}
|
||||||
|
product.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||||
|
|
||||||
|
response := OrderItemEntityToResponse(&entities.OrderItem{ProductID: product.ID, Product: product}, uuid.New())
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"kitchen", "bar"}, response.PrinterTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProductEntityToResponseAlwaysHasAList(t *testing.T) {
|
||||||
|
// The POS reads printer_types as a list, so a product without one sends [] not null.
|
||||||
|
response := ProductEntityToResponse(&entities.Product{})
|
||||||
|
|
||||||
|
assert.Equal(t, []string{}, response.PrinterTypes)
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
||||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty
|
// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty
|
||||||
|
|||||||
@@ -306,16 +306,27 @@ type ProductAnalyticsPerParentCategoryResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProductAnalyticsPerParentCategoryData struct {
|
type ProductAnalyticsPerParentCategoryData struct {
|
||||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||||
ParentCategoryName string `json:"parent_category_name"`
|
ParentCategoryName string `json:"parent_category_name"`
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||||
TotalQuantity int64 `json:"total_quantity"`
|
SDL float64 `json:"sdl"`
|
||||||
CategoryCount int64 `json:"category_count"`
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
ProductCount int64 `json:"product_count"`
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
OrderCount int64 `json:"order_count"`
|
CategoryCount int64 `json:"category_count"`
|
||||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
ProductCount int64 `json:"product_count"`
|
||||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
OrderCount int64 `json:"order_count"`
|
||||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
|
||||||
|
type ParentCategoryTopProduct struct {
|
||||||
|
ProductID uuid.UUID `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
QuantitySold int64 `json:"quantity_sold"`
|
||||||
|
Revenue float64 `json:"revenue"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
||||||
@@ -342,19 +353,25 @@ type ParentCategoryAnalyticsDetailResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ParentCategoryAnalyticsDetailSummary struct {
|
type ParentCategoryAnalyticsDetailSummary struct {
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||||
TotalQuantity int64 `json:"total_quantity"`
|
SDL float64 `json:"sdl"`
|
||||||
CategoryCount int64 `json:"category_count"`
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
ProductCount int64 `json:"product_count"`
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
OrderCount int64 `json:"order_count"`
|
CategoryCount int64 `json:"category_count"`
|
||||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
ProductCount int64 `json:"product_count"`
|
||||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
OrderCount int64 `json:"order_count"`
|
||||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParentCategoryAnalyticsDetailData struct {
|
type ParentCategoryAnalyticsDetailData struct {
|
||||||
CategoryID uuid.UUID `json:"category_id"`
|
CategoryID uuid.UUID `json:"category_id"`
|
||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
|
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||||
|
SDL float64 `json:"sdl"`
|
||||||
|
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
TotalQuantity int64 `json:"total_quantity"`
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
ProductCount int64 `json:"product_count"`
|
ProductCount int64 `json:"product_count"`
|
||||||
@@ -395,19 +412,17 @@ type BudgetCutOff struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BudgetPercentages struct {
|
type BudgetPercentages struct {
|
||||||
Purchase float64 `json:"purchase"`
|
Owner float64 `json:"owner"`
|
||||||
Owner float64 `json:"owner"`
|
Team float64 `json:"team"`
|
||||||
Team float64 `json:"team"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BudgetPeriod struct {
|
type BudgetPeriod struct {
|
||||||
PeriodStart time.Time `json:"period_start"`
|
PeriodStart time.Time `json:"period_start"`
|
||||||
PeriodEnd time.Time `json:"period_end"`
|
PeriodEnd time.Time `json:"period_end"`
|
||||||
Revenue float64 `json:"revenue"`
|
Revenue float64 `json:"revenue"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
LimitPurchase float64 `json:"limit_purchase"`
|
SDL float64 `json:"sdl"`
|
||||||
LimitOwner float64 `json:"limit_owner"`
|
LimitTeam float64 `json:"limit_team"`
|
||||||
LimitTeam float64 `json:"limit_team"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BudgetMonthPeriod struct {
|
type BudgetMonthPeriod struct {
|
||||||
|
|||||||
+37
-26
@@ -20,36 +20,47 @@ type Category struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateCategoryRequest struct {
|
type CreateCategoryRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
OutletID *uuid.UUID
|
OutletID *uuid.UUID
|
||||||
ParentID *uuid.UUID
|
ParentID *uuid.UUID
|
||||||
Name string `validate:"required,min=1,max=255"`
|
Name string `validate:"required,min=1,max=255"`
|
||||||
Description *string `validate:"omitempty,max=1000"`
|
Description *string `validate:"omitempty,max=1000"`
|
||||||
ImageURL *string `validate:"omitempty,url"`
|
ImageURL *string `validate:"omitempty,url"`
|
||||||
Order int `validate:"min=0"`
|
Order int `validate:"min=0"`
|
||||||
|
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
|
||||||
|
// IsTeam defaults to true when left out
|
||||||
|
IsTeam *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCategoryRequest struct {
|
type UpdateCategoryRequest struct {
|
||||||
Name *string `validate:"omitempty,min=1,max=255"`
|
Name *string `validate:"omitempty,min=1,max=255"`
|
||||||
Description *string `validate:"omitempty,max=1000"`
|
Description *string `validate:"omitempty,max=1000"`
|
||||||
ImageURL *string `validate:"omitempty,url"`
|
ImageURL *string `validate:"omitempty,url"`
|
||||||
OutletID *uuid.UUID
|
OutletID *uuid.UUID
|
||||||
ParentID *uuid.UUID
|
ParentID *uuid.UUID
|
||||||
Order *int `validate:"omitempty,min=0"`
|
Order *int `validate:"omitempty,min=0"`
|
||||||
IsActive *bool
|
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
|
||||||
|
IsTeam *bool
|
||||||
|
IsActive *bool
|
||||||
|
|
||||||
|
// Clear flags remove the value; a nil pointer above only means "leave unchanged"
|
||||||
|
ClearParentID bool
|
||||||
|
ClearOwnerFeePercent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryResponse struct {
|
type CategoryResponse struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
OutletID *uuid.UUID
|
OutletID *uuid.UUID
|
||||||
ParentID *uuid.UUID
|
ParentID *uuid.UUID
|
||||||
ParentName *string
|
ParentName *string
|
||||||
Name string
|
Name string
|
||||||
Description *string
|
Description *string
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
Order int
|
Order int
|
||||||
IsActive bool
|
OwnerFeePercent *float64
|
||||||
CreatedAt time.Time
|
IsTeam bool
|
||||||
UpdatedAt time.Time
|
IsActive bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-10
@@ -5,6 +5,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1).
|
// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1).
|
||||||
@@ -15,15 +17,23 @@ type OutletLoyaltySettings struct {
|
|||||||
PointPayment LoyaltyPointPaymentSettings `json:"point_payment"`
|
PointPayment LoyaltyPointPaymentSettings `json:"point_payment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoyaltyEarnSettings is how much of one currency an order earns:
|
// LoyaltyEarnSettings is how much of one currency an order earns, nothing below
|
||||||
// floor(basis / EarnPerAmount) × EarnValue, nothing below MinOrderAmount, and at most
|
// MinOrderAmount, and at most MaxPerOrder when set:
|
||||||
// MaxPerOrder when set.
|
//
|
||||||
|
// - PER_AMOUNT: floor(basis / EarnPerAmount) × EarnValue;
|
||||||
|
// - PERCENTAGE: floor(basis × EarnPercent / 100).
|
||||||
|
//
|
||||||
|
// The settings of the mode not in use are kept, so switching back restores them.
|
||||||
type LoyaltyEarnSettings struct {
|
type LoyaltyEarnSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
EarnPerAmount int64 `json:"earn_per_amount"`
|
// PER_AMOUNT or PERCENTAGE.
|
||||||
EarnValue int64 `json:"earn_value"`
|
EarnMode string `json:"earn_mode"`
|
||||||
MinOrderAmount int64 `json:"min_order_amount"`
|
EarnPerAmount int64 `json:"earn_per_amount"`
|
||||||
MaxPerOrder *int64 `json:"max_per_order"`
|
EarnValue int64 `json:"earn_value"`
|
||||||
|
// PERCENTAGE: percent of the basis earned, 0–100 with at most two decimals.
|
||||||
|
EarnPercent float64 `json:"earn_percent"`
|
||||||
|
MinOrderAmount int64 `json:"min_order_amount"`
|
||||||
|
MaxPerOrder *int64 `json:"max_per_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoyaltyPointPaymentSettings struct {
|
type LoyaltyPointPaymentSettings struct {
|
||||||
@@ -102,8 +112,8 @@ type OutletLoyaltySettingsView struct {
|
|||||||
// The organization's rupiah value of one EnakPoint, which the cashback depends on.
|
// The organization's rupiah value of one EnakPoint, which the cashback depends on.
|
||||||
PointValue int64 `json:"point_value"`
|
PointValue int64 `json:"point_value"`
|
||||||
// Effective EnakPoint cashback in percent: earn_value × point_value /
|
// Effective EnakPoint cashback in percent: earn_value × point_value /
|
||||||
// earn_per_amount × 100. Shown next to the setting so an owner cannot misread the
|
// earn_per_amount × 100, or earn_percent × point_value in PERCENTAGE mode. Shown
|
||||||
// scale (F1).
|
// next to the setting so an owner cannot misread the scale (F1).
|
||||||
PointCashbackPercent float64 `json:"point_cashback_percent"`
|
PointCashbackPercent float64 `json:"point_cashback_percent"`
|
||||||
// Set on PUT: the keys that changed.
|
// Set on PUT: the keys that changed.
|
||||||
Changes []LoyaltySettingChange `json:"changes,omitempty"`
|
Changes []LoyaltySettingChange `json:"changes,omitempty"`
|
||||||
@@ -168,6 +178,15 @@ type LoyaltySettingsImpact struct {
|
|||||||
CoinRupiahAfter int64 `json:"coin_rupiah_after"`
|
CoinRupiahAfter int64 `json:"coin_rupiah_after"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CashbackPercent is the rupiah value of what an order earns as a percentage of its
|
||||||
|
// basis, in the mode the settings are in, rounded to two decimals.
|
||||||
|
func (s LoyaltyEarnSettings) CashbackPercent(pointValue int64) float64 {
|
||||||
|
if s.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||||
|
return math.Round(s.EarnPercent*float64(pointValue)*100) / 100
|
||||||
|
}
|
||||||
|
return LoyaltyCashbackPercent(s.EarnValue, pointValue, s.EarnPerAmount)
|
||||||
|
}
|
||||||
|
|
||||||
// NewLoyaltySettingsImpact computes the impact of moving from one organization setting
|
// NewLoyaltySettingsImpact computes the impact of moving from one organization setting
|
||||||
// to another on the balances in circulation.
|
// to another on the balances in circulation.
|
||||||
func NewLoyaltySettingsImpact(points, coins int64, before, after OrganizationLoyaltySettings) LoyaltySettingsImpact {
|
func NewLoyaltySettingsImpact(points, coins int64, before, after OrganizationLoyaltySettings) LoyaltySettingsImpact {
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ func TestLoyaltyCashbackPercent(t *testing.T) {
|
|||||||
assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0))
|
assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoyaltyEarnSettings_CashbackPercent(t *testing.T) {
|
||||||
|
s := LoyaltyEarnSettings{EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 2.5}
|
||||||
|
assert.Equal(t, 1.0, s.CashbackPercent(1))
|
||||||
|
s.EarnMode = "PERCENTAGE"
|
||||||
|
assert.Equal(t, 2.5, s.CashbackPercent(1))
|
||||||
|
assert.Equal(t, 250.0, s.CashbackPercent(100), "each point earned is worth point_value rupiah")
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewLoyaltySettingsImpact(t *testing.T) {
|
func TestNewLoyaltySettingsImpact(t *testing.T) {
|
||||||
before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}}
|
before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}}
|
||||||
after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}}
|
after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}}
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ type OrderItemResponse struct {
|
|||||||
Status constants.OrderItemStatus
|
Status constants.OrderItemStatus
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
PrinterType string
|
PrinterTypes []string
|
||||||
PrintToChecker bool
|
PrintToChecker bool
|
||||||
PaidQuantity int
|
PaidQuantity int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type Product struct {
|
|||||||
Cost float64
|
Cost float64
|
||||||
BusinessType constants.BusinessType
|
BusinessType constants.BusinessType
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
PrinterType string
|
PrinterTypes []string
|
||||||
SellBy string
|
SellBy string
|
||||||
UnitID *uuid.UUID
|
UnitID *uuid.UUID
|
||||||
HasIngredients bool
|
HasIngredients bool
|
||||||
@@ -50,7 +50,7 @@ type CreateProductRequest struct {
|
|||||||
Cost float64 `validate:"min=0"`
|
Cost float64 `validate:"min=0"`
|
||||||
BusinessType constants.BusinessType `validate:"required"`
|
BusinessType constants.BusinessType `validate:"required"`
|
||||||
ImageURL *string `validate:"omitempty,max=500"`
|
ImageURL *string `validate:"omitempty,max=500"`
|
||||||
PrinterType *string `validate:"omitempty,max=50"`
|
PrinterTypes []string `validate:"omitempty,dive,max=50"`
|
||||||
PrintToChecker *bool `validate:"omitempty"`
|
PrintToChecker *bool `validate:"omitempty"`
|
||||||
UnitID *uuid.UUID `validate:"omitempty"`
|
UnitID *uuid.UUID `validate:"omitempty"`
|
||||||
SellBy string `validate:"omitempty,oneof=unit weight"`
|
SellBy string `validate:"omitempty,oneof=unit weight"`
|
||||||
@@ -72,7 +72,7 @@ type UpdateProductRequest struct {
|
|||||||
Price *float64 `validate:"omitempty,min=0"`
|
Price *float64 `validate:"omitempty,min=0"`
|
||||||
Cost *float64 `validate:"omitempty,min=0"`
|
Cost *float64 `validate:"omitempty,min=0"`
|
||||||
ImageURL *string `validate:"omitempty,max=500"`
|
ImageURL *string `validate:"omitempty,max=500"`
|
||||||
PrinterType *string `validate:"omitempty,max=50"`
|
PrinterTypes []string `validate:"omitempty,dive,max=50"` // Replaces the whole list when not nil
|
||||||
PrintToChecker *bool `validate:"omitempty"`
|
PrintToChecker *bool `validate:"omitempty"`
|
||||||
UnitID *uuid.UUID `validate:"omitempty"`
|
UnitID *uuid.UUID `validate:"omitempty"`
|
||||||
SellBy *string `validate:"omitempty,oneof=unit weight"`
|
SellBy *string `validate:"omitempty,oneof=unit weight"`
|
||||||
@@ -112,7 +112,7 @@ type ProductResponse struct {
|
|||||||
Cost float64
|
Cost float64
|
||||||
BusinessType constants.BusinessType
|
BusinessType constants.BusinessType
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
PrinterType string
|
PrinterTypes []string
|
||||||
SellBy string
|
SellBy string
|
||||||
PrintToChecker bool
|
PrintToChecker bool
|
||||||
UnitID *uuid.UUID
|
UnitID *uuid.UUID
|
||||||
|
|||||||
@@ -413,9 +413,22 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerParentCategory(ctx contex
|
|||||||
// Transform data
|
// Transform data
|
||||||
var resultData []models.ProductAnalyticsPerParentCategoryData
|
var resultData []models.ProductAnalyticsPerParentCategoryData
|
||||||
for _, data := range analyticsData {
|
for _, data := range analyticsData {
|
||||||
|
var topProduct *models.ParentCategoryTopProduct
|
||||||
|
if data.TopProduct != nil {
|
||||||
|
topProduct = &models.ParentCategoryTopProduct{
|
||||||
|
ProductID: data.TopProduct.ProductID,
|
||||||
|
ProductName: data.TopProduct.ProductName,
|
||||||
|
QuantitySold: data.TopProduct.QuantitySold,
|
||||||
|
Revenue: data.TopProduct.Revenue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resultData = append(resultData, models.ProductAnalyticsPerParentCategoryData{
|
resultData = append(resultData, models.ProductAnalyticsPerParentCategoryData{
|
||||||
|
TopProduct: topProduct,
|
||||||
ParentCategoryID: data.ParentCategoryID,
|
ParentCategoryID: data.ParentCategoryID,
|
||||||
ParentCategoryName: data.ParentCategoryName,
|
ParentCategoryName: data.ParentCategoryName,
|
||||||
|
OwnerFeePercent: data.OwnerFeePercent,
|
||||||
|
SDL: data.TotalRevenue * data.OwnerFeePercent / 100,
|
||||||
TotalRevenue: data.TotalRevenue,
|
TotalRevenue: data.TotalRevenue,
|
||||||
TotalQuantity: data.TotalQuantity,
|
TotalQuantity: data.TotalQuantity,
|
||||||
CategoryCount: data.CategoryCount,
|
CategoryCount: data.CategoryCount,
|
||||||
@@ -456,7 +469,9 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
|||||||
|
|
||||||
// Bucket the product rows by the category they belong to
|
// Bucket the product rows by the category they belong to
|
||||||
productsByCategory := make(map[uuid.UUID][]models.ParentCategoryAnalyticsProductData)
|
productsByCategory := make(map[uuid.UUID][]models.ParentCategoryAnalyticsProductData)
|
||||||
|
productRowsByCategory := make(map[uuid.UUID][]*entities.ProductAnalytics)
|
||||||
for _, product := range detail.Products {
|
for _, product := range detail.Products {
|
||||||
|
productRowsByCategory[product.CategoryID] = append(productRowsByCategory[product.CategoryID], product)
|
||||||
productsByCategory[product.CategoryID] = append(productsByCategory[product.CategoryID], models.ParentCategoryAnalyticsProductData{
|
productsByCategory[product.CategoryID] = append(productsByCategory[product.CategoryID], models.ParentCategoryAnalyticsProductData{
|
||||||
ProductID: product.ProductID,
|
ProductID: product.ProductID,
|
||||||
ProductName: product.ProductName,
|
ProductName: product.ProductName,
|
||||||
@@ -476,6 +491,12 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sub-categories take the owner fee of the parent category they sit under
|
||||||
|
var ownerFeePercent float64
|
||||||
|
if detail.Summary != nil {
|
||||||
|
ownerFeePercent = detail.Summary.OwnerFeePercent
|
||||||
|
}
|
||||||
|
|
||||||
categories := make([]models.ParentCategoryAnalyticsDetailData, 0, len(detail.Categories))
|
categories := make([]models.ParentCategoryAnalyticsDetailData, 0, len(detail.Categories))
|
||||||
for _, category := range detail.Categories {
|
for _, category := range detail.Categories {
|
||||||
products := productsByCategory[category.CategoryID]
|
products := productsByCategory[category.CategoryID]
|
||||||
@@ -486,6 +507,9 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
|||||||
categories = append(categories, models.ParentCategoryAnalyticsDetailData{
|
categories = append(categories, models.ParentCategoryAnalyticsDetailData{
|
||||||
CategoryID: category.CategoryID,
|
CategoryID: category.CategoryID,
|
||||||
CategoryName: category.CategoryName,
|
CategoryName: category.CategoryName,
|
||||||
|
OwnerFeePercent: ownerFeePercent,
|
||||||
|
SDL: category.TotalRevenue * ownerFeePercent / 100,
|
||||||
|
TopProduct: topProductByRevenue(productRowsByCategory[category.CategoryID]),
|
||||||
TotalRevenue: category.TotalRevenue,
|
TotalRevenue: category.TotalRevenue,
|
||||||
TotalQuantity: category.TotalQuantity,
|
TotalQuantity: category.TotalQuantity,
|
||||||
ProductCount: category.ProductCount,
|
ProductCount: category.ProductCount,
|
||||||
@@ -500,6 +524,9 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
|||||||
summary := models.ParentCategoryAnalyticsDetailSummary{}
|
summary := models.ParentCategoryAnalyticsDetailSummary{}
|
||||||
if detail.Summary != nil {
|
if detail.Summary != nil {
|
||||||
summary = models.ParentCategoryAnalyticsDetailSummary{
|
summary = models.ParentCategoryAnalyticsDetailSummary{
|
||||||
|
TopProduct: topProductByRevenue(detail.Products),
|
||||||
|
OwnerFeePercent: detail.Summary.OwnerFeePercent,
|
||||||
|
SDL: detail.Summary.TotalRevenue * detail.Summary.OwnerFeePercent / 100,
|
||||||
TotalRevenue: detail.Summary.TotalRevenue,
|
TotalRevenue: detail.Summary.TotalRevenue,
|
||||||
TotalQuantity: detail.Summary.TotalQuantity,
|
TotalQuantity: detail.Summary.TotalQuantity,
|
||||||
CategoryCount: detail.Summary.CategoryCount,
|
CategoryCount: detail.Summary.CategoryCount,
|
||||||
@@ -515,6 +542,11 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// The block covers a single parent category, so it reports that category's own fee
|
||||||
|
if detail.Summary != nil {
|
||||||
|
budget.Percentages.Owner = detail.Summary.OwnerFeePercent
|
||||||
|
budget.Percentages.Team = 100 - detail.Summary.OwnerFeePercent
|
||||||
|
}
|
||||||
|
|
||||||
return &models.ParentCategoryAnalyticsDetailResponse{
|
return &models.ParentCategoryAnalyticsDetailResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
@@ -530,6 +562,30 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// topProductByRevenue picks the best-selling product of a parent category. A product
|
||||||
|
// can span several rows (one per outlet price), so the rows are added up per product
|
||||||
|
// first. Ties keep the product that was seen first.
|
||||||
|
func topProductByRevenue(products []*entities.ProductAnalytics) *models.ParentCategoryTopProduct {
|
||||||
|
totals := make(map[uuid.UUID]*models.ParentCategoryTopProduct)
|
||||||
|
for _, product := range products {
|
||||||
|
total, ok := totals[product.ProductID]
|
||||||
|
if !ok {
|
||||||
|
total = &models.ParentCategoryTopProduct{ProductID: product.ProductID, ProductName: product.ProductName}
|
||||||
|
totals[product.ProductID] = total
|
||||||
|
}
|
||||||
|
total.QuantitySold += product.QuantitySold
|
||||||
|
total.Revenue += product.Revenue
|
||||||
|
}
|
||||||
|
|
||||||
|
var top *models.ParentCategoryTopProduct
|
||||||
|
for _, product := range products {
|
||||||
|
if total := totals[product.ProductID]; top == nil || total.Revenue > top.Revenue {
|
||||||
|
top = total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return top
|
||||||
|
}
|
||||||
|
|
||||||
// startOfWeek returns the Monday 00:00 of the week containing t, in t's own location.
|
// startOfWeek returns the Monday 00:00 of the week containing t, in t's own location.
|
||||||
func startOfWeek(t time.Time) time.Time {
|
func startOfWeek(t time.Time) time.Time {
|
||||||
daysSinceMonday := (int(t.Weekday()) + 6) % 7
|
daysSinceMonday := (int(t.Weekday()) + 6) % 7
|
||||||
@@ -542,16 +598,17 @@ func endOfWeek(t time.Time) time.Time {
|
|||||||
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
|
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newBudgetPeriod splits a period's revenue into the spending limits.
|
// newBudgetPeriod splits a period's revenue between the owner and the team. The owner
|
||||||
func newBudgetPeriod(start, end time.Time, revenue float64, orderCount int64) models.BudgetPeriod {
|
// share is passed in because a parent category can carry its own owner fee percent,
|
||||||
|
// and the team takes whatever the owner does not.
|
||||||
|
func newBudgetPeriod(start, end time.Time, revenue, sdl float64, orderCount int64) models.BudgetPeriod {
|
||||||
return models.BudgetPeriod{
|
return models.BudgetPeriod{
|
||||||
PeriodStart: start,
|
PeriodStart: start,
|
||||||
PeriodEnd: end,
|
PeriodEnd: end,
|
||||||
Revenue: revenue,
|
Revenue: revenue,
|
||||||
OrderCount: orderCount,
|
OrderCount: orderCount,
|
||||||
LimitPurchase: revenue * constants.BudgetLimitPurchasePercent / 100,
|
SDL: sdl,
|
||||||
LimitOwner: revenue * constants.BudgetLimitOwnerPercent / 100,
|
LimitTeam: revenue - sdl,
|
||||||
LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,9 +622,8 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
|||||||
|
|
||||||
budget := models.BudgetCutOff{
|
budget := models.BudgetCutOff{
|
||||||
Percentages: models.BudgetPercentages{
|
Percentages: models.BudgetPercentages{
|
||||||
Purchase: constants.BudgetLimitPurchasePercent,
|
Owner: constants.BudgetLimitOwnerPercent,
|
||||||
Owner: constants.BudgetLimitOwnerPercent,
|
Team: 100 - constants.BudgetLimitOwnerPercent,
|
||||||
Team: constants.BudgetLimitTeamPercent,
|
|
||||||
},
|
},
|
||||||
CutOffFrom: cutOffFrom,
|
CutOffFrom: cutOffFrom,
|
||||||
CutOffTo: cutOffTo,
|
CutOffTo: cutOffTo,
|
||||||
@@ -588,22 +644,24 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
totalRevenue float64
|
totalRevenue float64
|
||||||
|
totalSDL float64
|
||||||
totalOrders int64
|
totalOrders int64
|
||||||
monthOrder []string
|
monthOrder []string
|
||||||
monthAccumulator = map[string]*models.BudgetMonthPeriod{}
|
monthAccumulator = map[string]*models.BudgetMonthPeriod{}
|
||||||
)
|
)
|
||||||
|
|
||||||
for week := cutOffFrom; !week.After(cutOffTo); week = week.AddDate(0, 0, 7) {
|
for week := cutOffFrom; !week.After(cutOffTo); week = week.AddDate(0, 0, 7) {
|
||||||
var revenue float64
|
var revenue, sdl float64
|
||||||
var orderCount int64
|
var orderCount int64
|
||||||
if row, ok := rowsByWeek[week.Format("2006-01-02")]; ok {
|
if row, ok := rowsByWeek[week.Format("2006-01-02")]; ok {
|
||||||
revenue, orderCount = row.Revenue, row.OrderCount
|
revenue, sdl, orderCount = row.Revenue, row.SDL, row.OrderCount
|
||||||
}
|
}
|
||||||
|
|
||||||
period := newBudgetPeriod(week, endOfWeek(week), revenue, orderCount)
|
period := newBudgetPeriod(week, endOfWeek(week), revenue, sdl, orderCount)
|
||||||
budget.Weekly = append(budget.Weekly, period)
|
budget.Weekly = append(budget.Weekly, period)
|
||||||
|
|
||||||
totalRevenue += revenue
|
totalRevenue += revenue
|
||||||
|
totalSDL += sdl
|
||||||
totalOrders += orderCount
|
totalOrders += orderCount
|
||||||
|
|
||||||
// A week belongs to the month of its Monday, so every week is counted once
|
// A week belongs to the month of its Monday, so every week is counted once
|
||||||
@@ -618,6 +676,7 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
|||||||
month.WeekCount++
|
month.WeekCount++
|
||||||
month.PeriodEnd = period.PeriodEnd
|
month.PeriodEnd = period.PeriodEnd
|
||||||
month.Revenue += revenue
|
month.Revenue += revenue
|
||||||
|
month.SDL += sdl
|
||||||
month.OrderCount += orderCount
|
month.OrderCount += orderCount
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,11 +685,11 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
|||||||
budget.Monthly = append(budget.Monthly, models.BudgetMonthPeriod{
|
budget.Monthly = append(budget.Monthly, models.BudgetMonthPeriod{
|
||||||
Month: month.Month,
|
Month: month.Month,
|
||||||
WeekCount: month.WeekCount,
|
WeekCount: month.WeekCount,
|
||||||
BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.OrderCount),
|
BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.SDL, month.OrderCount),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalOrders)
|
budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalSDL, totalOrders)
|
||||||
|
|
||||||
return budget, nil
|
return budget, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type analyticsRepositoryStub struct {
|
|||||||
purchasingResult *entities.PurchasingAnalytics
|
purchasingResult *entities.PurchasingAnalytics
|
||||||
purchasingTeam *entities.PurchaseTeamFilter
|
purchasingTeam *entities.PurchaseTeamFilter
|
||||||
budgetCutOffWeeks []*entities.BudgetCutOffWeek
|
budgetCutOffWeeks []*entities.BudgetCutOffWeek
|
||||||
|
parentCategories []*entities.ProductAnalyticsPerParentCategory
|
||||||
profitLossResult *entities.ProfitLossAnalytics
|
profitLossResult *entities.ProfitLossAnalytics
|
||||||
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
||||||
bankBalances []entities.ExclusiveSummaryBankBalance
|
bankBalances []entities.ExclusiveSummaryBankBalance
|
||||||
@@ -49,8 +50,8 @@ func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, u
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
|
func (s analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
|
||||||
return nil, nil
|
return s.parentCategories, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (analyticsRepositoryStub) GetParentCategoryAnalyticsDetail(context.Context, uuid.UUID, *uuid.UUID, uuid.UUID, time.Time, time.Time) (*entities.ParentCategoryAnalyticsDetail, error) {
|
func (analyticsRepositoryStub) GetParentCategoryAnalyticsDetail(context.Context, uuid.UUID, *uuid.UUID, uuid.UUID, time.Time, time.Time) (*entities.ParentCategoryAnalyticsDetail, error) {
|
||||||
@@ -604,3 +605,61 @@ func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) {
|
|||||||
assert.Zero(t, byType["point"].Percentage)
|
assert.Zero(t, byType["point"].Percentage)
|
||||||
assert.Equal(t, int64(30000), byType["point"].PointsUsed)
|
assert.Equal(t, int64(30000), byType["point"].PointsUsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A parent category with its own owner fee percent moves the owner limit away from the
|
||||||
|
// default share, and the team limit shrinks by the same amount.
|
||||||
|
func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
|
||||||
|
monday := time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC)
|
||||||
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
|
parentCategories: []*entities.ProductAnalyticsPerParentCategory{
|
||||||
|
{ParentCategoryName: "Food", OwnerFeePercent: constants.BudgetLimitOwnerPercent, TotalRevenue: 1000},
|
||||||
|
{ParentCategoryName: "Drink", OwnerFeePercent: 35, TotalRevenue: 2000},
|
||||||
|
},
|
||||||
|
budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||||
|
// 1000 at the default 20% plus 2000 at 35%
|
||||||
|
{WeekStart: monday, Revenue: 3000, OrderCount: 4, SDL: 900},
|
||||||
|
},
|
||||||
|
}, expenseRepositoryStub{})
|
||||||
|
|
||||||
|
result, err := processor.GetProductAnalyticsPerParentCategory(context.Background(), &models.ProductAnalyticsPerParentCategoryRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: monday,
|
||||||
|
DateTo: monday.AddDate(0, 0, 6),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result.Data, 2)
|
||||||
|
require.Equal(t, float64(20), result.Data[0].OwnerFeePercent)
|
||||||
|
require.Equal(t, float64(200), result.Data[0].SDL)
|
||||||
|
require.Equal(t, float64(35), result.Data[1].OwnerFeePercent)
|
||||||
|
require.Equal(t, float64(700), result.Data[1].SDL)
|
||||||
|
|
||||||
|
require.Len(t, result.Budget.Weekly, 1)
|
||||||
|
require.Equal(t, float64(900), result.Budget.Weekly[0].SDL)
|
||||||
|
require.Equal(t, float64(2100), result.Budget.Weekly[0].LimitTeam)
|
||||||
|
require.Equal(t, float64(20), result.Budget.Percentages.Owner)
|
||||||
|
require.Equal(t, float64(80), result.Budget.Percentages.Team)
|
||||||
|
require.Len(t, result.Budget.Monthly, 1)
|
||||||
|
require.Equal(t, float64(900), result.Budget.Monthly[0].SDL)
|
||||||
|
require.Equal(t, float64(900), result.Budget.Total.SDL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A product sold at several outlet prices comes back as several rows, which must be
|
||||||
|
// added up before the best seller is picked.
|
||||||
|
func TestTopProductByRevenueAddsUpRowsOfTheSameProduct(t *testing.T) {
|
||||||
|
split, single := uuid.New(), uuid.New()
|
||||||
|
|
||||||
|
top := topProductByRevenue([]*entities.ProductAnalytics{
|
||||||
|
{ProductID: single, ProductName: "Es Teh", QuantitySold: 5, Revenue: 500},
|
||||||
|
{ProductID: split, ProductName: "Nasi Goreng", QuantitySold: 2, Revenue: 300},
|
||||||
|
{ProductID: split, ProductName: "Nasi Goreng", QuantitySold: 2, Revenue: 300},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, top)
|
||||||
|
require.Equal(t, split, top.ProductID)
|
||||||
|
require.Equal(t, "Nasi Goreng", top.ProductName)
|
||||||
|
require.Equal(t, int64(4), top.QuantitySold)
|
||||||
|
require.Equal(t, float64(600), top.Revenue)
|
||||||
|
|
||||||
|
require.Nil(t, topProductByRevenue(nil))
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
|
|||||||
weekStart := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
weekStart := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||||
|
|
||||||
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||||
{WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120},
|
{WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120, SDL: 2_500_000},
|
||||||
}}
|
}}
|
||||||
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||||
|
|
||||||
@@ -93,16 +93,17 @@ func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, budget.Weekly, 1)
|
require.Len(t, budget.Weekly, 1)
|
||||||
|
|
||||||
// 60 / 20 / 20 of the week's revenue
|
// The owner limit is whatever the repository summed from each parent category's
|
||||||
|
// own fee percent, and the team takes the rest of the week's revenue
|
||||||
week := budget.Weekly[0]
|
week := budget.Weekly[0]
|
||||||
require.Equal(t, float64(6_000_000), week.LimitPurchase)
|
require.Equal(t, float64(2_500_000), week.SDL)
|
||||||
require.Equal(t, float64(2_000_000), week.LimitOwner)
|
require.Equal(t, float64(7_500_000), week.LimitTeam)
|
||||||
require.Equal(t, float64(2_000_000), week.LimitTeam)
|
|
||||||
require.Equal(t, int64(120), week.OrderCount)
|
require.Equal(t, int64(120), week.OrderCount)
|
||||||
|
|
||||||
// Totals mirror the single week
|
// Totals mirror the single week
|
||||||
require.Equal(t, week.Revenue, budget.Total.Revenue)
|
require.Equal(t, week.Revenue, budget.Total.Revenue)
|
||||||
require.Equal(t, week.LimitPurchase, budget.Total.LimitPurchase)
|
require.Equal(t, week.SDL, budget.Total.SDL)
|
||||||
|
require.Equal(t, week.LimitTeam, budget.Total.LimitTeam)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
||||||
@@ -110,8 +111,8 @@ func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
|||||||
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||||
|
|
||||||
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||||
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100},
|
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100, SDL: 2_000_000},
|
||||||
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60},
|
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60, SDL: 1_000_000},
|
||||||
}}
|
}}
|
||||||
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||||
|
|
||||||
@@ -125,9 +126,10 @@ func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
|||||||
require.Equal(t, float64(15_000_000), month.Revenue)
|
require.Equal(t, float64(15_000_000), month.Revenue)
|
||||||
require.Equal(t, int64(160), month.OrderCount)
|
require.Equal(t, int64(160), month.OrderCount)
|
||||||
|
|
||||||
// The month limit is the accumulation of its weeks
|
// The month limits are the accumulation of its weeks
|
||||||
require.Equal(t, float64(9_000_000), month.LimitPurchase)
|
require.Equal(t, float64(3_000_000), month.SDL)
|
||||||
require.Equal(t, budget.Weekly[0].LimitPurchase+budget.Weekly[1].LimitPurchase, month.LimitPurchase)
|
require.Equal(t, float64(12_000_000), month.LimitTeam)
|
||||||
|
require.Equal(t, budget.Weekly[0].LimitTeam+budget.Weekly[1].LimitTeam, month.LimitTeam)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
||||||
@@ -145,7 +147,7 @@ func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
|||||||
|
|
||||||
require.Len(t, budget.Weekly, 3)
|
require.Len(t, budget.Weekly, 3)
|
||||||
require.Zero(t, budget.Weekly[0].Revenue)
|
require.Zero(t, budget.Weekly[0].Revenue)
|
||||||
require.Zero(t, budget.Weekly[0].LimitPurchase)
|
require.Zero(t, budget.Weekly[0].LimitTeam)
|
||||||
require.Zero(t, budget.Weekly[1].Revenue)
|
require.Zero(t, budget.Weekly[1].Revenue)
|
||||||
require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue)
|
require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue)
|
||||||
require.Equal(t, float64(4_000_000), budget.Total.Revenue)
|
require.Equal(t, float64(4_000_000), budget.Total.Revenue)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package processor
|
|||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
)
|
)
|
||||||
@@ -36,6 +37,10 @@ func (r EarningResult) Metadata(line EarningLine) entities.Metadata {
|
|||||||
"min_order_amount": line.Settings.MinOrderAmount,
|
"min_order_amount": line.Settings.MinOrderAmount,
|
||||||
"capped": line.Capped,
|
"capped": line.Capped,
|
||||||
}
|
}
|
||||||
|
if line.Settings.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||||
|
m["earn_mode"] = line.Settings.EarnMode
|
||||||
|
m["earn_percent"] = line.Settings.EarnPercent
|
||||||
|
}
|
||||||
if line.Settings.MaxPerOrder != nil {
|
if line.Settings.MaxPerOrder != nil {
|
||||||
m["max_per_order"] = *line.Settings.MaxPerOrder
|
m["max_per_order"] = *line.Settings.MaxPerOrder
|
||||||
}
|
}
|
||||||
@@ -46,7 +51,8 @@ func (r EarningResult) Metadata(line EarningLine) entities.Metadata {
|
|||||||
//
|
//
|
||||||
// basis = subtotal − discount_amount − paid with EnakPoint
|
// basis = subtotal − discount_amount − paid with EnakPoint
|
||||||
// amount = 0 if basis < min_order_amount
|
// amount = 0 if basis < min_order_amount
|
||||||
// amount = floor(basis / earn_per_amount) × earn_value
|
// amount = floor(basis / earn_per_amount) × earn_value in PER_AMOUNT mode
|
||||||
|
// amount = floor(basis × earn_percent / 100) in PERCENTAGE mode
|
||||||
// amount = min(amount, max_per_order) if max_per_order is set
|
// amount = min(amount, max_per_order) if max_per_order is set
|
||||||
//
|
//
|
||||||
// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so
|
// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so
|
||||||
@@ -66,13 +72,23 @@ func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings m
|
|||||||
|
|
||||||
func earnLine(basisCents int64, s models.LoyaltyEarnSettings) EarningLine {
|
func earnLine(basisCents int64, s models.LoyaltyEarnSettings) EarningLine {
|
||||||
line := EarningLine{Settings: s}
|
line := EarningLine{Settings: s}
|
||||||
if !s.Enabled || s.EarnPerAmount <= 0 || s.EarnValue <= 0 {
|
if !s.Enabled || basisCents < s.MinOrderAmount*100 {
|
||||||
return line
|
return line
|
||||||
}
|
}
|
||||||
if basisCents < s.MinOrderAmount*100 {
|
if s.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||||
return line
|
// The percent has at most two decimals, so in hundredths it is whole and the
|
||||||
|
// product stays in integers: cents × hundredths / (100 × 100 × 100).
|
||||||
|
hundredths := int64(math.Round(s.EarnPercent * 100))
|
||||||
|
if hundredths <= 0 {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
line.Amount = basisCents * hundredths / 1_000_000
|
||||||
|
} else {
|
||||||
|
if s.EarnPerAmount <= 0 || s.EarnValue <= 0 {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue
|
||||||
}
|
}
|
||||||
line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue
|
|
||||||
if s.MaxPerOrder != nil && line.Amount > *s.MaxPerOrder {
|
if s.MaxPerOrder != nil && line.Amount > *s.MaxPerOrder {
|
||||||
line.Amount = *s.MaxPerOrder
|
line.Amount = *s.MaxPerOrder
|
||||||
line.Capped = true
|
line.Capped = true
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
)
|
)
|
||||||
@@ -113,6 +114,44 @@ func TestCalculateEarning_EdgeCases(t *testing.T) {
|
|||||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCalculateEarning_Percentage(t *testing.T) {
|
||||||
|
s := prdEarningSettings()
|
||||||
|
s.Point.EarnMode = constants.LoyaltyEarnModePercentage
|
||||||
|
s.Point.EarnPercent = 1
|
||||||
|
|
||||||
|
// 1% of the basis; the per-amount settings are ignored, and coin keeps its own mode.
|
||||||
|
got := CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 0, s)
|
||||||
|
assert.Equal(t, int64(875), got.Point.Amount)
|
||||||
|
assert.Equal(t, int64(3), got.Coin.Amount)
|
||||||
|
// The part paid with EnakPoint earns nothing in this mode either.
|
||||||
|
assert.Equal(t, int64(675), CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 20000, s).Point.Amount)
|
||||||
|
|
||||||
|
// Decimals, and floor, not round: 2.5% of 87.500 is 2187.5.
|
||||||
|
s.Point.EarnPercent = 2.5
|
||||||
|
assert.Equal(t, int64(2187), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||||
|
s.Point.EarnPercent = 0.01
|
||||||
|
assert.Equal(t, int64(8), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount)
|
||||||
|
|
||||||
|
// The minimum and the cap apply as in the other mode.
|
||||||
|
s.Point.EarnPercent = 10
|
||||||
|
s.Point.MinOrderAmount = 50000
|
||||||
|
max := int64(6000)
|
||||||
|
s.Point.MaxPerOrder = &max
|
||||||
|
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount)
|
||||||
|
assert.Equal(t, int64(5000), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount)
|
||||||
|
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||||
|
assert.Equal(t, int64(6000), got.Point.Amount)
|
||||||
|
assert.True(t, got.Point.Capped)
|
||||||
|
|
||||||
|
// The mode and percent are frozen on the ledger row.
|
||||||
|
assert.Equal(t, constants.LoyaltyEarnModePercentage, got.Metadata(got.Point)["earn_mode"])
|
||||||
|
assert.Equal(t, 10.0, got.Metadata(got.Point)["earn_percent"])
|
||||||
|
|
||||||
|
// A zero percent earns nothing even when enabled.
|
||||||
|
s.Point.EarnPercent = 0
|
||||||
|
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCalculateEarning_MetadataSnapshot(t *testing.T) {
|
func TestCalculateEarning_MetadataSnapshot(t *testing.T) {
|
||||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings())
|
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings())
|
||||||
assert.Equal(t, entities.Metadata{
|
assert.Equal(t, entities.Metadata{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -240,14 +241,20 @@ func loyaltySettingChangeModel(row entities.LoyaltySettingChange) models.Loyalty
|
|||||||
func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField {
|
func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField {
|
||||||
return []loyaltyField{
|
return []loyaltyField{
|
||||||
boolLoyaltyField(constants.LoyaltyPointEnabledKey, &s.Point.Enabled, false),
|
boolLoyaltyField(constants.LoyaltyPointEnabledKey, &s.Point.Enabled, false),
|
||||||
|
enumLoyaltyField(constants.LoyaltyPointEarnModeKey, &s.Point.EarnMode, constants.LoyaltyEarnModeDefault,
|
||||||
|
constants.LoyaltyEarnModePerAmount, constants.LoyaltyEarnModePercentage),
|
||||||
intLoyaltyField(constants.LoyaltyPointEarnPerAmountKey, &s.Point.EarnPerAmount, constants.LoyaltyPointEarnPerAmountDefault, 1, noLoyaltyMax),
|
intLoyaltyField(constants.LoyaltyPointEarnPerAmountKey, &s.Point.EarnPerAmount, constants.LoyaltyPointEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||||
intLoyaltyField(constants.LoyaltyPointEarnValueKey, &s.Point.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
intLoyaltyField(constants.LoyaltyPointEarnValueKey, &s.Point.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||||
|
percentLoyaltyField(constants.LoyaltyPointEarnPercentKey, &s.Point.EarnPercent, constants.LoyaltyEarnPercentDefault),
|
||||||
intLoyaltyField(constants.LoyaltyPointMinOrderAmountKey, &s.Point.MinOrderAmount, 0, 0, noLoyaltyMax),
|
intLoyaltyField(constants.LoyaltyPointMinOrderAmountKey, &s.Point.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||||
optionalIntLoyaltyField(constants.LoyaltyPointMaxPerOrderKey, &s.Point.MaxPerOrder, 0),
|
optionalIntLoyaltyField(constants.LoyaltyPointMaxPerOrderKey, &s.Point.MaxPerOrder, 0),
|
||||||
|
|
||||||
boolLoyaltyField(constants.LoyaltyCoinEnabledKey, &s.Coin.Enabled, false),
|
boolLoyaltyField(constants.LoyaltyCoinEnabledKey, &s.Coin.Enabled, false),
|
||||||
|
enumLoyaltyField(constants.LoyaltyCoinEarnModeKey, &s.Coin.EarnMode, constants.LoyaltyEarnModeDefault,
|
||||||
|
constants.LoyaltyEarnModePerAmount, constants.LoyaltyEarnModePercentage),
|
||||||
intLoyaltyField(constants.LoyaltyCoinEarnPerAmountKey, &s.Coin.EarnPerAmount, constants.LoyaltyCoinEarnPerAmountDefault, 1, noLoyaltyMax),
|
intLoyaltyField(constants.LoyaltyCoinEarnPerAmountKey, &s.Coin.EarnPerAmount, constants.LoyaltyCoinEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||||
intLoyaltyField(constants.LoyaltyCoinEarnValueKey, &s.Coin.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
intLoyaltyField(constants.LoyaltyCoinEarnValueKey, &s.Coin.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||||
|
percentLoyaltyField(constants.LoyaltyCoinEarnPercentKey, &s.Coin.EarnPercent, constants.LoyaltyEarnPercentDefault),
|
||||||
intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax),
|
intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||||
optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0),
|
optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0),
|
||||||
|
|
||||||
@@ -329,6 +336,35 @@ func intLoyaltyField(key string, v *int64, def, min, max int64) loyaltyField {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// percentLoyaltyField is a percentage from 0 to 100 with at most two decimals, so it
|
||||||
|
// is whole in hundredths and calculations with it stay in integers.
|
||||||
|
func percentLoyaltyField(key string, v *float64, def float64) loyaltyField {
|
||||||
|
check := func(n float64) error {
|
||||||
|
// The negated form also rejects NaN.
|
||||||
|
if !(n >= 0 && n <= 100) {
|
||||||
|
return fmt.Errorf("%w: %s must be between 0 and 100", ErrInvalidLoyaltySettings, key)
|
||||||
|
}
|
||||||
|
if math.Abs(n*100-math.Round(n*100)) > 1e-6 {
|
||||||
|
return fmt.Errorf("%w: %s must have at most two decimals", ErrInvalidLoyaltySettings, key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return loyaltyField{
|
||||||
|
key: key,
|
||||||
|
parse: func(raw string) bool {
|
||||||
|
n, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||||
|
if err != nil || check(n) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*v = n
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
reset: func() { *v = def },
|
||||||
|
validate: func() error { return check(*v) },
|
||||||
|
format: func() *string { s := strconv.FormatFloat(*v, 'f', -1, 64); return &s },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// optionalIntLoyaltyField is a limit that may be unset, meaning no limit.
|
// optionalIntLoyaltyField is a limit that may be unset, meaning no limit.
|
||||||
func optionalIntLoyaltyField(key string, v **int64, min int64) loyaltyField {
|
func optionalIntLoyaltyField(key string, v **int64, min int64) loyaltyField {
|
||||||
return loyaltyField{
|
return loyaltyField{
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) {
|
|||||||
s, err := p.Outlet(context.Background(), uuid.New())
|
s, err := p.Outlet(context.Background(), uuid.New())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, models.OutletLoyaltySettings{
|
assert.Equal(t, models.OutletLoyaltySettings{
|
||||||
Point: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 100, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
Point: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||||
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 25000, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 25000, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||||
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
|
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
|
||||||
}, *s)
|
}, *s)
|
||||||
}
|
}
|
||||||
@@ -290,6 +290,10 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
|
|||||||
for name, mutate := range map[string]func(*models.OutletLoyaltySettings){
|
for name, mutate := range map[string]func(*models.OutletLoyaltySettings){
|
||||||
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
|
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
|
||||||
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
|
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
|
||||||
|
"earn mode unknown": func(s *models.OutletLoyaltySettings) { s.Point.EarnMode = "PERCENT" },
|
||||||
|
"negative earn_percent": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = -1 },
|
||||||
|
"earn_percent over 100": func(s *models.OutletLoyaltySettings) { s.Coin.EarnPercent = 100.5 },
|
||||||
|
"earn_percent 3 decimals": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = 1.125 },
|
||||||
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
|
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
|
||||||
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
|
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
|
||||||
"payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 },
|
"payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 },
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The items AddToOrder creates have no product loaded, so their responses used to go out
|
||||||
|
// without a name or a printer, and the POS could not print them.
|
||||||
|
func TestAddedItemsFromOrderUsesTheReloadedProduct(t *testing.T) {
|
||||||
|
outletID := uuid.New()
|
||||||
|
|
||||||
|
paket := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"}
|
||||||
|
paket.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||||
|
paket.ProductOutletPrices = []entities.ProductOutletPrice{{OutletID: outletID, PrintToChecker: false}}
|
||||||
|
esTeh := entities.Product{ID: uuid.New(), Name: "Es Teh"}
|
||||||
|
esTeh.SetPrinterTypes([]string{"bar"})
|
||||||
|
|
||||||
|
existing := entities.OrderItem{ID: uuid.New(), ProductID: esTeh.ID, Product: esTeh}
|
||||||
|
addedPaket := &entities.OrderItem{ID: uuid.New(), ProductID: paket.ID}
|
||||||
|
addedEsTeh := &entities.OrderItem{ID: uuid.New(), ProductID: esTeh.ID}
|
||||||
|
|
||||||
|
order := &entities.Order{
|
||||||
|
OutletID: outletID,
|
||||||
|
OrderItems: []entities.OrderItem{
|
||||||
|
existing,
|
||||||
|
{ID: addedEsTeh.ID, ProductID: esTeh.ID, Product: esTeh},
|
||||||
|
{ID: addedPaket.ID, ProductID: paket.ID, Product: paket},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := addedItemsFromOrder([]*entities.OrderItem{addedPaket, addedEsTeh}, order)
|
||||||
|
|
||||||
|
require.Len(t, responses, 2, "only the added items, not the ones already on the order")
|
||||||
|
assert.Equal(t, addedPaket.ID, responses[0].ID, "in the order they were requested")
|
||||||
|
assert.Equal(t, "Paket Makan Minum", responses[0].ProductName)
|
||||||
|
assert.Equal(t, []string{"kitchen", "bar"}, responses[0].PrinterTypes)
|
||||||
|
assert.False(t, responses[0].PrintToChecker, "the outlet's print_to_checker is loaded too")
|
||||||
|
assert.Equal(t, addedEsTeh.ID, responses[1].ID)
|
||||||
|
assert.Equal(t, []string{"bar"}, responses[1].PrinterTypes)
|
||||||
|
}
|
||||||
@@ -316,6 +316,18 @@ func resolveLineQuantity(product *entities.Product, quantity int, weight *float6
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
|
func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
|
||||||
|
// The order's customer earns EnakPoint and EnakCoin once it is paid, so it must be
|
||||||
|
// one of the organization's own; orders.customer_id has no foreign key to catch it.
|
||||||
|
customerID := req.CustomerID
|
||||||
|
if customerID != nil && *customerID == uuid.Nil {
|
||||||
|
customerID = nil
|
||||||
|
}
|
||||||
|
if customerID != nil {
|
||||||
|
if _, err := p.customerRepo.GetByIDAndOrganization(ctx, *customerID, organizationID); err != nil {
|
||||||
|
return nil, fmt.Errorf("customer not found or does not belong to the organization: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
|
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate order number: %w", err)
|
return nil, fmt.Errorf("failed to generate order number: %w", err)
|
||||||
@@ -401,7 +413,7 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
|
|||||||
OrganizationID: organizationID,
|
OrganizationID: organizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
CustomerID: req.CustomerID,
|
CustomerID: customerID,
|
||||||
OrderNumber: orderNumber,
|
OrderNumber: orderNumber,
|
||||||
TableNumber: req.TableNumber,
|
TableNumber: req.TableNumber,
|
||||||
OrderType: entities.OrderType(req.OrderType),
|
OrderType: entities.OrderType(req.OrderType),
|
||||||
@@ -556,16 +568,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
|||||||
return nil, fmt.Errorf("failed to update order: %w", err)
|
return nil, fmt.Errorf("failed to update order: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var addedItemResponses []models.OrderItemResponse
|
|
||||||
for _, orderItem := range addedOrderItems {
|
for _, orderItem := range addedOrderItems {
|
||||||
if err := p.orderItemRepo.Create(ctx, orderItem); err != nil {
|
if err := p.orderItemRepo.Create(ctx, orderItem); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create order item: %w", err)
|
return nil, fmt.Errorf("failed to create order item: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
itemResponse := mappers.OrderItemEntityToResponse(orderItem, order.OutletID)
|
|
||||||
if itemResponse != nil {
|
|
||||||
addedItemResponses = append(addedItemResponses, *itemResponse)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID)
|
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID)
|
||||||
@@ -573,6 +579,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
|||||||
return nil, fmt.Errorf("failed to retrieve updated order: %w", err)
|
return nil, fmt.Errorf("failed to retrieve updated order: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The items just created carry no product, so take them from the reloaded order: the
|
||||||
|
// POS prints added items from this response and needs their name and printers.
|
||||||
|
addedItemResponses := addedItemsFromOrder(addedOrderItems, orderWithRelations)
|
||||||
|
|
||||||
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
|
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
|
||||||
p.attachEarnings(ctx, updatedOrderResponse)
|
p.attachEarnings(ctx, updatedOrderResponse)
|
||||||
|
|
||||||
@@ -584,6 +594,26 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addedItemsFromOrder maps the added items in the order they were requested, using their
|
||||||
|
// copies in the reloaded order, which have the product and its outlet prices loaded.
|
||||||
|
func addedItemsFromOrder(added []*entities.OrderItem, order *entities.Order) []models.OrderItemResponse {
|
||||||
|
reloaded := make(map[uuid.UUID]*entities.OrderItem, len(order.OrderItems))
|
||||||
|
for i := range order.OrderItems {
|
||||||
|
reloaded[order.OrderItems[i].ID] = &order.OrderItems[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.OrderItemResponse
|
||||||
|
for _, item := range added {
|
||||||
|
if loaded, ok := reloaded[item.ID]; ok {
|
||||||
|
item = loaded
|
||||||
|
}
|
||||||
|
if response := mappers.OrderItemEntityToResponse(item, order.OutletID); response != nil {
|
||||||
|
responses = append(responses, *response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return responses
|
||||||
|
}
|
||||||
|
|
||||||
func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) {
|
func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) {
|
||||||
// Get existing order
|
// Get existing order
|
||||||
order, err := p.orderRepo.GetByID(ctx, id)
|
order, err := p.orderRepo.GetByID(ctx, id)
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe
|
|||||||
Cost: entity.Product.Cost,
|
Cost: entity.Product.Cost,
|
||||||
BusinessType: string(entity.Product.BusinessType),
|
BusinessType: string(entity.Product.BusinessType),
|
||||||
ImageURL: entity.Product.ImageURL,
|
ImageURL: entity.Product.ImageURL,
|
||||||
PrinterType: entity.Product.PrinterType,
|
PrinterTypes: entity.Product.GetPrinterTypes(),
|
||||||
Metadata: entity.Product.Metadata,
|
Metadata: entity.Product.Metadata,
|
||||||
IsActive: entity.Product.IsActive,
|
IsActive: entity.Product.IsActive,
|
||||||
CreatedAt: entity.Product.CreatedAt,
|
CreatedAt: entity.Product.CreatedAt,
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Teams are the parent product categories, plus Pusat for spending that belongs to
|
// Teams are the parent product categories flagged is_team, plus Pusat for spending
|
||||||
// no single team. Both purchase orders and cash advances are charged to one, so the rules
|
// that belongs to no single team. Both purchase orders and cash advances are charged to
|
||||||
// for picking and storing a team live here rather than in either processor.
|
// one, so the rules for picking and storing a team live here rather than in either
|
||||||
|
// processor.
|
||||||
|
|
||||||
// listTeams returns the teams money can be charged to: the parent categories of the
|
// listTeams returns the teams money can be charged to: the team parent categories of
|
||||||
// outlet in scope, followed by Pusat. Pusat has no category row, so it is appended
|
// the outlet in scope, followed by Pusat. Pusat has no category row, so it is appended
|
||||||
// here rather than read from the database.
|
// here rather than read from the database.
|
||||||
func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
|
func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
|
||||||
categories, err := categoryRepo.ListParentCategories(ctx, organizationID, outletID)
|
categories, err := categoryRepo.ListParentCategories(ctx, organizationID, outletID)
|
||||||
@@ -26,6 +27,9 @@ func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizatio
|
|||||||
|
|
||||||
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
|
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
|
||||||
for _, category := range categories {
|
for _, category := range categories {
|
||||||
|
if !category.IsTeam {
|
||||||
|
continue
|
||||||
|
}
|
||||||
categoryID := category.ID
|
categoryID := category.ID
|
||||||
teams = append(teams, models.PurchaseTeam{
|
teams = append(teams, models.PurchaseTeam{
|
||||||
Scope: constants.PurchaseTeamScopeCategory,
|
Scope: constants.PurchaseTeamScopeCategory,
|
||||||
@@ -75,6 +79,9 @@ func resolveTeamSelection(ctx context.Context, categoryRepo CategoryRepository,
|
|||||||
if category.ParentID != nil {
|
if category.ParentID != nil {
|
||||||
return nil, nil, fmt.Errorf("team must be a parent category")
|
return nil, nil, fmt.Errorf("team must be a parent category")
|
||||||
}
|
}
|
||||||
|
if !category.IsTeam {
|
||||||
|
return nil, nil, fmt.Errorf("category %s is not a team", category.Name)
|
||||||
|
}
|
||||||
// Categories without an outlet are shared, so only an outlet-specific
|
// Categories without an outlet are shared, so only an outlet-specific
|
||||||
// category has to match the outlet the record is booked against.
|
// category has to match the outlet the record is booked against.
|
||||||
if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID {
|
if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// teamCategoryRepositoryStub serves the two lookups the team rules make. The embedded
|
||||||
|
// interface is left nil, so any other call panics and shows up in the test.
|
||||||
|
type teamCategoryRepositoryStub struct {
|
||||||
|
CategoryRepository
|
||||||
|
categories []*entities.Category
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *teamCategoryRepositoryStub) ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) {
|
||||||
|
return s.categories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *teamCategoryRepositoryStub) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
|
||||||
|
for _, category := range s.categories {
|
||||||
|
if category.ID == id {
|
||||||
|
return category, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("record not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTeamsSkipsParentCategoriesThatAreNotTeams(t *testing.T) {
|
||||||
|
organizationID := uuid.New()
|
||||||
|
food := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Food", IsTeam: true}
|
||||||
|
merch := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Merchandise", IsTeam: false}
|
||||||
|
repo := &teamCategoryRepositoryStub{categories: []*entities.Category{food, merch}}
|
||||||
|
|
||||||
|
result, err := listTeams(context.Background(), repo, organizationID, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, result.Teams, 2)
|
||||||
|
require.Equal(t, "Food", result.Teams[0].Name)
|
||||||
|
require.Equal(t, &food.ID, result.Teams[0].CategoryID)
|
||||||
|
require.Equal(t, constants.PurchaseTeamCentralName, result.Teams[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTeamSelectionRejectsParentCategoryThatIsNotATeam(t *testing.T) {
|
||||||
|
organizationID := uuid.New()
|
||||||
|
food := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Food", IsTeam: true}
|
||||||
|
merch := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Merchandise", IsTeam: false}
|
||||||
|
repo := &teamCategoryRepositoryStub{categories: []*entities.Category{food, merch}}
|
||||||
|
scope := constants.PurchaseTeamScopeCategory
|
||||||
|
|
||||||
|
resolvedScope, resolvedID, err := resolveTeamSelection(context.Background(), repo, organizationID, nil, &scope, &food.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, constants.PurchaseTeamScopeCategory, *resolvedScope)
|
||||||
|
require.Equal(t, food.ID, *resolvedID)
|
||||||
|
|
||||||
|
_, _, err = resolveTeamSelection(context.Background(), repo, organizationID, nil, &scope, &merch.ID)
|
||||||
|
require.EqualError(t, err, "category Merchandise is not a team")
|
||||||
|
}
|
||||||
@@ -600,6 +600,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
|
|||||||
Select(`
|
Select(`
|
||||||
pc.id as parent_category_id,
|
pc.id as parent_category_id,
|
||||||
pc.name as parent_category_name,
|
pc.name as parent_category_name,
|
||||||
|
COALESCE(pc.owner_fee_percent, ?) as owner_fee_percent,
|
||||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue,
|
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue,
|
||||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
|
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 c.id) as category_count,
|
COUNT(DISTINCT c.id) as category_count,
|
||||||
@@ -608,11 +609,13 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
|
|||||||
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 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 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) * `+billableQtyNet+` 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
|
||||||
`).
|
`, constants.BudgetLimitOwnerPercent).
|
||||||
Joins("JOIN products p ON oi.product_id = p.id").
|
Joins("JOIN products p ON oi.product_id = p.id").
|
||||||
Joins("JOIN categories c ON p.category_id = c.id").
|
Joins("JOIN categories c ON p.category_id = c.id").
|
||||||
// Categories without a parent roll up to themselves, so top-level categories still appear
|
// Categories without a parent roll up to themselves, so top-level categories still appear
|
||||||
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
|
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
|
||||||
|
// Profit sharing only covers the parent categories that are teams
|
||||||
|
Where("pc.is_team = ?", true).
|
||||||
Joins("JOIN orders o ON oi.order_id = o.id").
|
Joins("JOIN orders o ON oi.order_id = o.id").
|
||||||
Joins("LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id").
|
Joins("LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id").
|
||||||
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
|
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
|
||||||
@@ -640,11 +643,57 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
|
|||||||
query = r.resolveOutletID(query, outletID, "o.outlet_id")
|
query = r.resolveOutletID(query, outletID, "o.outlet_id")
|
||||||
|
|
||||||
err := query.
|
err := query.
|
||||||
Group("pc.id, pc.name").
|
Group("pc.id, pc.name, pc.owner_fee_percent").
|
||||||
Order("pc.name ASC").
|
Order("pc.name ASC").
|
||||||
Scan(&results).Error
|
Scan(&results).Error
|
||||||
|
if err != nil || len(results) == 0 {
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
return results, err
|
// Best-selling product of each parent category. DISTINCT ON keeps the first row per
|
||||||
|
// parent category, which the ordering makes the one with the highest revenue.
|
||||||
|
var topProducts []*entities.ParentCategoryTopProduct
|
||||||
|
topQuery := r.db.WithContext(ctx).
|
||||||
|
Table("order_items oi").
|
||||||
|
Select(`
|
||||||
|
DISTINCT ON (pc.id)
|
||||||
|
pc.id as parent_category_id,
|
||||||
|
p.id as product_id,
|
||||||
|
p.name as product_name,
|
||||||
|
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as quantity_sold,
|
||||||
|
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue
|
||||||
|
`).
|
||||||
|
Joins("JOIN products p ON oi.product_id = p.id").
|
||||||
|
Joins("JOIN categories c ON p.category_id = c.id").
|
||||||
|
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
|
||||||
|
Joins("JOIN orders o ON oi.order_id = o.id").
|
||||||
|
Where("pc.is_team = ?", true).
|
||||||
|
Where("o.organization_id = ?", organizationID).
|
||||||
|
Where("o.is_void = ?", false).
|
||||||
|
Where("o.is_refund = ?", false).
|
||||||
|
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
||||||
|
Where("oi.status != ?", entities.OrderItemStatusCancelled).
|
||||||
|
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
|
||||||
|
|
||||||
|
topQuery = r.resolveOutletID(topQuery, outletID, "o.outlet_id")
|
||||||
|
|
||||||
|
err = topQuery.
|
||||||
|
Group("pc.id, p.id, p.name").
|
||||||
|
Order("pc.id, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) DESC, p.name ASC").
|
||||||
|
Scan(&topProducts).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
topByParent := make(map[uuid.UUID]*entities.ParentCategoryTopProduct, len(topProducts))
|
||||||
|
for _, product := range topProducts {
|
||||||
|
topByParent[product.ParentCategoryID] = product
|
||||||
|
}
|
||||||
|
for _, result := range results {
|
||||||
|
result.TopProduct = topByParent[result.ParentCategoryID]
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// movingAverageHppSubquery builds the per-product moving-average HPP lookup shared by
|
// movingAverageHppSubquery builds the per-product moving-average HPP lookup shared by
|
||||||
@@ -691,12 +740,14 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
|
|||||||
// Resolve the category first so the endpoint still identifies the category when it
|
// Resolve the category first so the endpoint still identifies the category when it
|
||||||
// has no sales in the requested range, and rejects ids from another organization.
|
// has no sales in the requested range, and rejects ids from another organization.
|
||||||
var parent struct {
|
var parent struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
|
IsTeam bool
|
||||||
|
OwnerFeePercent float64
|
||||||
}
|
}
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Table("categories").
|
Table("categories").
|
||||||
Select("id, name").
|
Select("id, name, is_team, COALESCE(owner_fee_percent, ?) as owner_fee_percent", constants.BudgetLimitOwnerPercent).
|
||||||
Where("id = ? AND organization_id = ?", parentCategoryID, organizationID).
|
Where("id = ? AND organization_id = ?", parentCategoryID, organizationID).
|
||||||
Scan(&parent).Error; err != nil {
|
Scan(&parent).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -704,6 +755,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
|
|||||||
if parent.ID == uuid.Nil {
|
if parent.ID == uuid.Nil {
|
||||||
return nil, fmt.Errorf("category not found")
|
return nil, fmt.Errorf("category not found")
|
||||||
}
|
}
|
||||||
|
if !parent.IsTeam {
|
||||||
|
return nil, fmt.Errorf("category %s is not a team", parent.Name)
|
||||||
|
}
|
||||||
|
|
||||||
detail := &entities.ParentCategoryAnalyticsDetail{
|
detail := &entities.ParentCategoryAnalyticsDetail{
|
||||||
ParentCategoryID: parent.ID,
|
ParentCategoryID: parent.ID,
|
||||||
@@ -732,6 +786,7 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
|
|||||||
}
|
}
|
||||||
summary.ParentCategoryID = parent.ID
|
summary.ParentCategoryID = parent.ID
|
||||||
summary.ParentCategoryName = parent.Name
|
summary.ParentCategoryName = parent.Name
|
||||||
|
summary.OwnerFeePercent = parent.OwnerFeePercent
|
||||||
detail.Summary = summary
|
detail.Summary = summary
|
||||||
|
|
||||||
// Sub-category rows.
|
// Sub-category rows.
|
||||||
@@ -801,7 +856,8 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
|
|||||||
// GetBudgetCutOffWeekly buckets revenue and cost of goods sold into Monday-to-Sunday
|
// GetBudgetCutOffWeekly buckets revenue and cost of goods sold into Monday-to-Sunday
|
||||||
// weeks. DATE_TRUNC('week') is ISO, so the buckets start on Monday, and the connection
|
// weeks. DATE_TRUNC('week') is ISO, so the buckets start on Monday, and the connection
|
||||||
// runs with TimeZone=Asia/Jakarta so the boundaries land on local midnight.
|
// runs with TimeZone=Asia/Jakarta so the boundaries land on local midnight.
|
||||||
// A nil parentCategoryID covers every category in scope.
|
// A nil parentCategoryID covers every team in scope; parent categories that are not a
|
||||||
|
// team stay out of the split.
|
||||||
func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, cutOffFrom, cutOffTo time.Time) ([]*entities.BudgetCutOffWeek, error) {
|
func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, cutOffFrom, cutOffTo time.Time) ([]*entities.BudgetCutOffWeek, error) {
|
||||||
var results []*entities.BudgetCutOffWeek
|
var results []*entities.BudgetCutOffWeek
|
||||||
|
|
||||||
@@ -810,13 +866,18 @@ func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, org
|
|||||||
Select(`
|
Select(`
|
||||||
DATE_TRUNC('week', o.created_at) as week_start,
|
DATE_TRUNC('week', o.created_at) as week_start,
|
||||||
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), 0) as revenue,
|
||||||
|
COALESCE(SUM((CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) * COALESCE(pc.owner_fee_percent, ?) / 100.0), 0) as sdl,
|
||||||
COUNT(DISTINCT oi.order_id) as order_count
|
COUNT(DISTINCT oi.order_id) as order_count
|
||||||
`).
|
`, constants.BudgetLimitOwnerPercent).
|
||||||
// products and categories are joined to keep the scope identical to the report
|
// products and categories are joined to keep the scope identical to the report
|
||||||
// the block is attached to, even when no parent category filter is applied
|
// the block is attached to, even when no parent category filter is applied
|
||||||
Joins("JOIN products p ON oi.product_id = p.id").
|
Joins("JOIN products p ON oi.product_id = p.id").
|
||||||
Joins("JOIN categories c ON p.category_id = c.id").
|
Joins("JOIN categories c ON p.category_id = c.id").
|
||||||
|
// The owner fee lives on the parent category; a category without a parent is its own group
|
||||||
|
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
|
||||||
Joins("JOIN orders o ON oi.order_id = o.id").
|
Joins("JOIN orders o ON oi.order_id = o.id").
|
||||||
|
// Only teams take part in the revenue split
|
||||||
|
Where("pc.is_team = ?", true).
|
||||||
Where("o.organization_id = ?", organizationID).
|
Where("o.organization_id = ?", organizationID).
|
||||||
Where("o.is_void = ?", false).
|
Where("o.is_void = ?", false).
|
||||||
Where("o.is_refund = ?", false).
|
Where("o.is_refund = ?", false).
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func (r *ProductIngredientRepository) Create(ctx context.Context, productIngredi
|
|||||||
func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.ProductIngredient, error) {
|
func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.ProductIngredient, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
|
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
|
||||||
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
||||||
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
|
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
|
||||||
FROM product_ingredients pi
|
FROM product_ingredients pi
|
||||||
LEFT JOIN products p ON pi.product_id = p.id
|
LEFT JOIN products p ON pi.product_id = p.id
|
||||||
@@ -70,7 +70,7 @@ func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizat
|
|||||||
&product.Cost,
|
&product.Cost,
|
||||||
&product.BusinessType,
|
&product.BusinessType,
|
||||||
&product.ImageURL,
|
&product.ImageURL,
|
||||||
&product.PrinterType,
|
&product.PrinterTypes,
|
||||||
&product.UnitID,
|
&product.UnitID,
|
||||||
&product.HasIngredients,
|
&product.HasIngredients,
|
||||||
&product.Metadata,
|
&product.Metadata,
|
||||||
@@ -103,7 +103,7 @@ func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizat
|
|||||||
func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, productID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) {
|
func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, productID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
|
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
|
||||||
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
||||||
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
|
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
|
||||||
FROM product_ingredients pi
|
FROM product_ingredients pi
|
||||||
LEFT JOIN products p ON pi.product_id = p.id
|
LEFT JOIN products p ON pi.product_id = p.id
|
||||||
@@ -143,7 +143,7 @@ func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, produc
|
|||||||
&product.Cost,
|
&product.Cost,
|
||||||
&product.BusinessType,
|
&product.BusinessType,
|
||||||
&product.ImageURL,
|
&product.ImageURL,
|
||||||
&product.PrinterType,
|
&product.PrinterTypes,
|
||||||
&product.UnitID,
|
&product.UnitID,
|
||||||
&product.HasIngredients,
|
&product.HasIngredients,
|
||||||
&product.Metadata,
|
&product.Metadata,
|
||||||
@@ -178,7 +178,7 @@ func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, produc
|
|||||||
func (r *ProductIngredientRepository) GetByIngredientID(ctx context.Context, ingredientID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) {
|
func (r *ProductIngredientRepository) GetByIngredientID(ctx context.Context, ingredientID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
|
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
|
||||||
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
||||||
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
|
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
|
||||||
FROM product_ingredients pi
|
FROM product_ingredients pi
|
||||||
LEFT JOIN products p ON pi.product_id = p.id
|
LEFT JOIN products p ON pi.product_id = p.id
|
||||||
@@ -218,7 +218,7 @@ func (r *ProductIngredientRepository) GetByIngredientID(ctx context.Context, ing
|
|||||||
&product.Cost,
|
&product.Cost,
|
||||||
&product.BusinessType,
|
&product.BusinessType,
|
||||||
&product.ImageURL,
|
&product.ImageURL,
|
||||||
&product.PrinterType,
|
&product.PrinterTypes,
|
||||||
&product.UnitID,
|
&product.UnitID,
|
||||||
&product.HasIngredients,
|
&product.HasIngredients,
|
||||||
&product.Metadata,
|
&product.Metadata,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Needs TEST_DATABASE_URL pointing at a migrated database; see walletTestDB.
|
||||||
|
func TestProductPrinterTypes_AgainstPostgres(t *testing.T) {
|
||||||
|
db := walletTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
org, category := uuid.New(), uuid.New()
|
||||||
|
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'printer test', 'basic')`, org).Error)
|
||||||
|
require.NoError(t, db.Exec(`INSERT INTO categories (id, organization_id, name) VALUES (?, ?, 'Paket')`, category, org).Error)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec(`DELETE FROM products WHERE organization_id = ?`, org)
|
||||||
|
db.Exec(`DELETE FROM categories WHERE id = ?`, category)
|
||||||
|
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||||
|
})
|
||||||
|
|
||||||
|
repo := NewProductRepositoryImpl(db)
|
||||||
|
product := &entities.Product{OrganizationID: org, CategoryID: category, Name: "Paket Makan Minum", Price: 25000}
|
||||||
|
product.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||||
|
require.NoError(t, repo.Create(ctx, product))
|
||||||
|
|
||||||
|
stored, err := repo.GetByID(ctx, product.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, entities.StringSlice{"kitchen", "bar"}, stored.PrinterTypes)
|
||||||
|
|
||||||
|
stored.SetPrinterTypes([]string{"bar"})
|
||||||
|
require.NoError(t, repo.Update(ctx, stored))
|
||||||
|
|
||||||
|
var printerTypes string
|
||||||
|
require.NoError(t, db.Raw(`SELECT printer_types::text FROM products WHERE id = ?`, product.ID).Scan(&printerTypes).Error)
|
||||||
|
assert.Equal(t, `["bar"]`, printerTypes)
|
||||||
|
|
||||||
|
// The old single column is gone.
|
||||||
|
var columns int64
|
||||||
|
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'printer_type'`).Scan(&columns).Error)
|
||||||
|
assert.Zero(t, columns)
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ type Router struct {
|
|||||||
customerDeviceHandler *handler.CustomerDeviceHandler
|
customerDeviceHandler *handler.CustomerDeviceHandler
|
||||||
customerOutletHandler *handler.CustomerOutletHandler
|
customerOutletHandler *handler.CustomerOutletHandler
|
||||||
customerOrderHandler *handler.CustomerOrderHandler
|
customerOrderHandler *handler.CustomerOrderHandler
|
||||||
authMiddleware *middleware.AuthMiddleware
|
authMiddleware *middleware.AuthMiddleware
|
||||||
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
||||||
redisClient *redis.Client
|
redisClient *redis.Client
|
||||||
}
|
}
|
||||||
@@ -374,8 +374,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
|
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
|
||||||
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
|
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
|
||||||
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
||||||
analytics.GET("/parent-categories", r.analyticsHandler.GetProductAnalyticsPerParentCategory)
|
analytics.GET("/profit-sharing", r.analyticsHandler.GetProductAnalyticsPerParentCategory)
|
||||||
analytics.GET("/parent-categories/:parent_category_id", r.analyticsHandler.GetParentCategoryAnalyticsDetail)
|
analytics.GET("/profit-sharing/:parent_category_id", r.analyticsHandler.GetParentCategoryAnalyticsDetail)
|
||||||
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
||||||
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
|
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
|
||||||
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
|
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ func (s *LoyaltySettingsServiceImpl) outletView(ctx context.Context, organizatio
|
|||||||
OutletID: outletID,
|
OutletID: outletID,
|
||||||
OutletLoyaltySettings: settings,
|
OutletLoyaltySettings: settings,
|
||||||
PointValue: pointValue,
|
PointValue: pointValue,
|
||||||
PointCashbackPercent: models.LoyaltyCashbackPercent(settings.Point.EarnValue, pointValue, settings.Point.EarnPerAmount),
|
PointCashbackPercent: settings.Point.CashbackPercent(pointValue),
|
||||||
Changes: changes,
|
Changes: changes,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,23 +224,23 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
|||||||
|
|
||||||
// profitLossReportData holds data for the profit/loss PDF template
|
// profitLossReportData holds data for the profit/loss PDF template
|
||||||
type profitLossReportData struct {
|
type profitLossReportData struct {
|
||||||
OrganizationName string
|
OrganizationName string
|
||||||
MonthName string
|
MonthName string
|
||||||
ReportDate string
|
ReportDate string
|
||||||
ReportDateUpper string
|
ReportDateUpper string
|
||||||
TotalPenjualan string
|
TotalPenjualan string
|
||||||
TotalBiaya string
|
TotalBiaya string
|
||||||
LabaRugi string
|
LabaRugi string
|
||||||
LabaRugiClass string
|
LabaRugiClass string
|
||||||
LabaRugiValueClass string
|
LabaRugiValueClass string
|
||||||
LabaRugiMtd string
|
LabaRugiMtd string
|
||||||
LabaRugiMtdClass string
|
LabaRugiMtdClass string
|
||||||
LabaRugiMtdValueClass string
|
LabaRugiMtdValueClass string
|
||||||
MainSummary []profitLossSummaryRowView
|
MainSummary []profitLossSummaryRowView
|
||||||
PurchasingItems []profitLossPurchasingItem
|
PurchasingItems []profitLossPurchasingItem
|
||||||
PurchasingTotal string
|
PurchasingTotal string
|
||||||
GeneratedBy string
|
GeneratedBy string
|
||||||
PrintTime string
|
PrintTime string
|
||||||
}
|
}
|
||||||
|
|
||||||
type profitLossSummaryRowView struct {
|
type profitLossSummaryRowView struct {
|
||||||
|
|||||||
@@ -405,6 +405,9 @@ func ProductAnalyticsPerParentCategoryModelToContract(resp *models.ProductAnalyt
|
|||||||
data = append(data, contract.ProductAnalyticsPerParentCategoryData{
|
data = append(data, contract.ProductAnalyticsPerParentCategoryData{
|
||||||
ParentCategoryID: item.ParentCategoryID,
|
ParentCategoryID: item.ParentCategoryID,
|
||||||
ParentCategoryName: item.ParentCategoryName,
|
ParentCategoryName: item.ParentCategoryName,
|
||||||
|
OwnerFeePercent: item.OwnerFeePercent,
|
||||||
|
TopProduct: parentCategoryTopProductModelToContract(item.TopProduct),
|
||||||
|
SDL: item.SDL,
|
||||||
TotalRevenue: item.TotalRevenue,
|
TotalRevenue: item.TotalRevenue,
|
||||||
TotalQuantity: item.TotalQuantity,
|
TotalQuantity: item.TotalQuantity,
|
||||||
CategoryCount: item.CategoryCount,
|
CategoryCount: item.CategoryCount,
|
||||||
@@ -427,16 +430,28 @@ func ProductAnalyticsPerParentCategoryModelToContract(resp *models.ProductAnalyt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parentCategoryTopProductModelToContract converts the top product of a parent category to contract
|
||||||
|
func parentCategoryTopProductModelToContract(product *models.ParentCategoryTopProduct) *contract.ParentCategoryTopProduct {
|
||||||
|
if product == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &contract.ParentCategoryTopProduct{
|
||||||
|
ProductID: product.ProductID,
|
||||||
|
ProductName: product.ProductName,
|
||||||
|
QuantitySold: product.QuantitySold,
|
||||||
|
Revenue: product.Revenue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// budgetPeriodModelToContract converts one budget period to contract
|
// budgetPeriodModelToContract converts one budget period to contract
|
||||||
func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod {
|
func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod {
|
||||||
return contract.BudgetPeriod{
|
return contract.BudgetPeriod{
|
||||||
PeriodStart: period.PeriodStart,
|
PeriodStart: period.PeriodStart,
|
||||||
PeriodEnd: period.PeriodEnd,
|
PeriodEnd: period.PeriodEnd,
|
||||||
Revenue: period.Revenue,
|
Revenue: period.Revenue,
|
||||||
OrderCount: period.OrderCount,
|
OrderCount: period.OrderCount,
|
||||||
LimitPurchase: period.LimitPurchase,
|
SDL: period.SDL,
|
||||||
LimitOwner: period.LimitOwner,
|
LimitTeam: period.LimitTeam,
|
||||||
LimitTeam: period.LimitTeam,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,9 +473,8 @@ func BudgetCutOffModelToContract(budget models.BudgetCutOff) contract.BudgetCutO
|
|||||||
|
|
||||||
return contract.BudgetCutOff{
|
return contract.BudgetCutOff{
|
||||||
Percentages: contract.BudgetPercentages{
|
Percentages: contract.BudgetPercentages{
|
||||||
Purchase: budget.Percentages.Purchase,
|
Owner: budget.Percentages.Owner,
|
||||||
Owner: budget.Percentages.Owner,
|
Team: budget.Percentages.Team,
|
||||||
Team: budget.Percentages.Team,
|
|
||||||
},
|
},
|
||||||
CutOffFrom: budget.CutOffFrom,
|
CutOffFrom: budget.CutOffFrom,
|
||||||
CutOffTo: budget.CutOffTo,
|
CutOffTo: budget.CutOffTo,
|
||||||
@@ -528,6 +542,9 @@ func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAna
|
|||||||
categories = append(categories, contract.ParentCategoryAnalyticsDetailData{
|
categories = append(categories, contract.ParentCategoryAnalyticsDetailData{
|
||||||
CategoryID: category.CategoryID,
|
CategoryID: category.CategoryID,
|
||||||
CategoryName: category.CategoryName,
|
CategoryName: category.CategoryName,
|
||||||
|
OwnerFeePercent: category.OwnerFeePercent,
|
||||||
|
SDL: category.SDL,
|
||||||
|
TopProduct: parentCategoryTopProductModelToContract(category.TopProduct),
|
||||||
TotalRevenue: category.TotalRevenue,
|
TotalRevenue: category.TotalRevenue,
|
||||||
TotalQuantity: category.TotalQuantity,
|
TotalQuantity: category.TotalQuantity,
|
||||||
ProductCount: category.ProductCount,
|
ProductCount: category.ProductCount,
|
||||||
@@ -548,6 +565,9 @@ func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAna
|
|||||||
ParentCategoryID: resp.ParentCategoryID,
|
ParentCategoryID: resp.ParentCategoryID,
|
||||||
ParentCategoryName: resp.ParentCategoryName,
|
ParentCategoryName: resp.ParentCategoryName,
|
||||||
Summary: contract.ParentCategoryAnalyticsDetailSummary{
|
Summary: contract.ParentCategoryAnalyticsDetailSummary{
|
||||||
|
OwnerFeePercent: resp.Summary.OwnerFeePercent,
|
||||||
|
TopProduct: parentCategoryTopProductModelToContract(resp.Summary.TopProduct),
|
||||||
|
SDL: resp.Summary.SDL,
|
||||||
TotalRevenue: resp.Summary.TotalRevenue,
|
TotalRevenue: resp.Summary.TotalRevenue,
|
||||||
TotalQuantity: resp.Summary.TotalQuantity,
|
TotalQuantity: resp.Summary.TotalQuantity,
|
||||||
CategoryCount: resp.Summary.CategoryCount,
|
CategoryCount: resp.Summary.CategoryCount,
|
||||||
|
|||||||
@@ -12,25 +12,32 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C
|
|||||||
order = *req.Order
|
order = *req.Order
|
||||||
}
|
}
|
||||||
return &models.CreateCategoryRequest{
|
return &models.CreateCategoryRequest{
|
||||||
OrganizationID: apctx.OrganizationID,
|
OrganizationID: apctx.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
ParentID: req.ParentID,
|
ParentID: req.ParentID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
ImageURL: nil,
|
ImageURL: nil,
|
||||||
Order: order,
|
Order: order,
|
||||||
|
OwnerFeePercent: req.OwnerFeePercent,
|
||||||
|
IsTeam: req.IsTeam,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.UpdateCategoryRequest {
|
func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.UpdateCategoryRequest {
|
||||||
return &models.UpdateCategoryRequest{
|
return &models.UpdateCategoryRequest{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
ImageURL: nil,
|
ImageURL: nil,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
ParentID: req.ParentID,
|
ParentID: req.ParentID,
|
||||||
Order: req.Order,
|
Order: req.Order,
|
||||||
IsActive: nil,
|
OwnerFeePercent: req.OwnerFeePercent,
|
||||||
|
IsTeam: req.IsTeam,
|
||||||
|
IsActive: nil,
|
||||||
|
|
||||||
|
ClearParentID: req.ClearParentID,
|
||||||
|
ClearOwnerFeePercent: req.ClearOwnerFeePercent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,18 +47,20 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &contract.CategoryResponse{
|
return &contract.CategoryResponse{
|
||||||
ID: cat.ID,
|
ID: cat.ID,
|
||||||
OrganizationID: cat.OrganizationID,
|
OrganizationID: cat.OrganizationID,
|
||||||
OutletID: cat.OutletID,
|
OutletID: cat.OutletID,
|
||||||
ParentID: cat.ParentID,
|
ParentID: cat.ParentID,
|
||||||
ParentName: cat.ParentName,
|
ParentName: cat.ParentName,
|
||||||
Name: cat.Name,
|
Name: cat.Name,
|
||||||
Description: cat.Description,
|
Description: cat.Description,
|
||||||
BusinessType: "restaurant",
|
BusinessType: "restaurant",
|
||||||
Order: cat.Order,
|
Order: cat.Order,
|
||||||
Metadata: map[string]interface{}{},
|
OwnerFeePercent: cat.OwnerFeePercent,
|
||||||
CreatedAt: cat.CreatedAt,
|
IsTeam: cat.IsTeam,
|
||||||
UpdatedAt: cat.UpdatedAt,
|
Metadata: map[string]interface{}{},
|
||||||
|
CreatedAt: cat.CreatedAt,
|
||||||
|
UpdatedAt: cat.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ func InventoryModelResponseToResponse(inv *models.InventoryResponse) *contract.I
|
|||||||
IsLowStock: inv.IsLowStock,
|
IsLowStock: inv.IsLowStock,
|
||||||
UpdatedAt: inv.UpdatedAt,
|
UpdatedAt: inv.UpdatedAt,
|
||||||
Product: &contract.ProductResponse{
|
Product: &contract.ProductResponse{
|
||||||
ID: inv.ProductID,
|
ID: inv.ProductID,
|
||||||
Name: inv.ProductName,
|
Name: inv.ProductName,
|
||||||
|
PrinterTypes: []string{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func CreateOrderContractToModel(req *contract.CreateOrderRequest) *models.Create
|
|||||||
return &models.CreateOrderRequest{
|
return &models.CreateOrderRequest{
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
|
CustomerID: req.CustomerID,
|
||||||
TableID: req.TableID,
|
TableID: req.TableID,
|
||||||
TableNumber: req.TableNumber,
|
TableNumber: req.TableNumber,
|
||||||
OrderType: constants.OrderType(req.OrderType),
|
OrderType: constants.OrderType(req.OrderType),
|
||||||
@@ -117,7 +118,7 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse {
|
|||||||
Status: string(item.Status),
|
Status: string(item.Status),
|
||||||
CreatedAt: item.CreatedAt,
|
CreatedAt: item.CreatedAt,
|
||||||
UpdatedAt: item.UpdatedAt,
|
UpdatedAt: item.UpdatedAt,
|
||||||
PrinterType: item.PrinterType,
|
PrinterTypes: item.PrinterTypes,
|
||||||
PrintToChecker: item.PrintToChecker,
|
PrintToChecker: item.PrintToChecker,
|
||||||
PaidQuantity: item.PaidQuantity,
|
PaidQuantity: item.PaidQuantity,
|
||||||
}
|
}
|
||||||
@@ -194,6 +195,7 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO
|
|||||||
Status: string(item.Status),
|
Status: string(item.Status),
|
||||||
CreatedAt: item.CreatedAt,
|
CreatedAt: item.CreatedAt,
|
||||||
UpdatedAt: item.UpdatedAt,
|
UpdatedAt: item.UpdatedAt,
|
||||||
|
PrinterTypes: item.PrinterTypes,
|
||||||
PrintToChecker: item.PrintToChecker,
|
PrintToChecker: item.PrintToChecker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package transformer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
"apskel-pos-be/internal/mappers"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The POS prints items added to an open order from this response, so each one has to
|
||||||
|
// say where it is printed.
|
||||||
|
func TestAddToOrderModelToContractCarriesPrinters(t *testing.T) {
|
||||||
|
result := AddToOrderModelToContract(&models.AddToOrderResponse{
|
||||||
|
AddedItems: []models.OrderItemResponse{{PrinterTypes: []string{"kitchen", "bar"}}},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Len(t, result.AddedItems, 1)
|
||||||
|
assert.Equal(t, []string{"kitchen", "bar"}, result.AddedItems[0].PrinterTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follows an order from the database row to the JSON the POS reads.
|
||||||
|
func TestOrderJSONCarriesPrinters(t *testing.T) {
|
||||||
|
paket := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"}
|
||||||
|
paket.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||||
|
order := &entities.Order{
|
||||||
|
ID: uuid.New(),
|
||||||
|
OrderItems: []entities.OrderItem{{ID: uuid.New(), ProductID: paket.ID, Product: paket, Quantity: 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(OrderModelToContract(mappers.OrderEntityToResponse(order)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, string(body), `"printer_types":["kitchen","bar"]`)
|
||||||
|
assert.NotContains(t, string(body), `"printer_type":`, "printer_types replaced it")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderModelToContractCarriesPrinters(t *testing.T) {
|
||||||
|
result := OrderModelToContract(&models.OrderResponse{
|
||||||
|
OrderItems: []models.OrderItemResponse{{PrinterTypes: []string{"kitchen", "bar"}}},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Len(t, result.OrderItems, 1)
|
||||||
|
assert.Equal(t, []string{"kitchen", "bar"}, result.OrderItems[0].PrinterTypes)
|
||||||
|
}
|
||||||
@@ -32,6 +32,20 @@ func TestCreateOrderContractToModelCarriesWeight(t *testing.T) {
|
|||||||
require.Equal(t, weight, *result.OrderItems[0].Weight)
|
require.Equal(t, weight, *result.OrderItems[0].Weight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateOrderContractToModelCarriesCustomer(t *testing.T) {
|
||||||
|
customerID := uuid.New()
|
||||||
|
|
||||||
|
result := CreateOrderContractToModel(&contract.CreateOrderRequest{
|
||||||
|
OutletID: uuid.New(),
|
||||||
|
UserID: uuid.New(),
|
||||||
|
CustomerID: &customerID,
|
||||||
|
OrderItems: []contract.CreateOrderItemRequest{{ProductID: uuid.New(), Quantity: 1}},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, result.CustomerID, "the order's customer is who earns EnakPoint and EnakCoin")
|
||||||
|
require.Equal(t, customerID, *result.CustomerID)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddToOrderContractToModelCarriesWeight(t *testing.T) {
|
func TestAddToOrderContractToModelCarriesWeight(t *testing.T) {
|
||||||
weight := 0.8
|
weight := 0.8
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
|
|||||||
Cost: cost,
|
Cost: cost,
|
||||||
BusinessType: businessType,
|
BusinessType: businessType,
|
||||||
ImageURL: req.ImageURL,
|
ImageURL: req.ImageURL,
|
||||||
PrinterType: req.PrinterType,
|
PrinterTypes: req.PrinterTypes,
|
||||||
PrintToChecker: req.PrintToChecker,
|
PrintToChecker: req.PrintToChecker,
|
||||||
UnitID: req.UnitID,
|
UnitID: req.UnitID,
|
||||||
SellBy: sellBy,
|
SellBy: sellBy,
|
||||||
@@ -91,7 +91,7 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up
|
|||||||
Price: req.Price,
|
Price: req.Price,
|
||||||
Cost: req.Cost,
|
Cost: req.Cost,
|
||||||
ImageURL: req.ImageURL,
|
ImageURL: req.ImageURL,
|
||||||
PrinterType: req.PrinterType,
|
PrinterTypes: req.PrinterTypes,
|
||||||
PrintToChecker: req.PrintToChecker,
|
PrintToChecker: req.PrintToChecker,
|
||||||
UnitID: req.UnitID,
|
UnitID: req.UnitID,
|
||||||
SellBy: req.SellBy,
|
SellBy: req.SellBy,
|
||||||
@@ -152,7 +152,7 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
|
|||||||
Cost: prod.Cost,
|
Cost: prod.Cost,
|
||||||
BusinessType: string(prod.BusinessType),
|
BusinessType: string(prod.BusinessType),
|
||||||
ImageURL: prod.ImageURL,
|
ImageURL: prod.ImageURL,
|
||||||
PrinterType: prod.PrinterType,
|
PrinterTypes: prod.PrinterTypes,
|
||||||
PrintToChecker: prod.PrintToChecker,
|
PrintToChecker: prod.PrintToChecker,
|
||||||
UnitID: prod.UnitID,
|
UnitID: prod.UnitID,
|
||||||
SellBy: prod.SellBy,
|
SellBy: prod.SellBy,
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ func (v *CategoryValidatorImpl) ValidateCreateCategoryRequest(req *contract.Crea
|
|||||||
return errors.New("description cannot exceed 1000 characters"), constants.MalformedFieldErrorCode
|
return errors.New("description cannot exceed 1000 characters"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.OwnerFeePercent != nil && (*req.OwnerFeePercent < 0 || *req.OwnerFeePercent > 100) {
|
||||||
|
return errors.New("owner_fee_percent must be between 0 and 100"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
if req.BusinessType != nil && strings.TrimSpace(*req.BusinessType) != "" {
|
if req.BusinessType != nil && strings.TrimSpace(*req.BusinessType) != "" {
|
||||||
validBusinessTypes := map[string]bool{
|
validBusinessTypes := map[string]bool{
|
||||||
"restaurant": true,
|
"restaurant": true,
|
||||||
@@ -59,7 +63,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
|
|||||||
}
|
}
|
||||||
|
|
||||||
// At least one field should be provided for update
|
// At least one field should be provided for update
|
||||||
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil {
|
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil && req.OwnerFeePercent == nil && req.IsTeam == nil && req.Order == nil && req.OutletID == nil && !req.ClearParentID && !req.ClearOwnerFeePercent {
|
||||||
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +80,10 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
|
|||||||
return errors.New("description cannot exceed 1000 characters"), constants.MalformedFieldErrorCode
|
return errors.New("description cannot exceed 1000 characters"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.OwnerFeePercent != nil && (*req.OwnerFeePercent < 0 || *req.OwnerFeePercent > 100) {
|
||||||
|
return errors.New("owner_fee_percent must be between 0 and 100"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
if req.BusinessType != nil && strings.TrimSpace(*req.BusinessType) != "" {
|
if req.BusinessType != nil && strings.TrimSpace(*req.BusinessType) != "" {
|
||||||
validBusinessTypes := map[string]bool{
|
validBusinessTypes := map[string]bool{
|
||||||
"restaurant": true,
|
"restaurant": true,
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create
|
|||||||
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
|
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.PrinterType != nil && len(*req.PrinterType) > 50 {
|
if err, code := validatePrinterTypes(req.PrinterTypes); err != nil {
|
||||||
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
|
return err, code
|
||||||
}
|
}
|
||||||
|
|
||||||
if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil {
|
if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil {
|
||||||
@@ -70,6 +70,18 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create
|
|||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validatePrinterTypes holds each printer to the 50 characters the single printer_type
|
||||||
|
// column allowed.
|
||||||
|
func validatePrinterTypes(printerTypes []string) (error, string) {
|
||||||
|
for _, printerType := range printerTypes {
|
||||||
|
if len(strings.TrimSpace(printerType)) > 50 {
|
||||||
|
return errors.New("each printer_types entry cannot exceed 50 characters"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
// validateSellBy checks how a product is sold and that it carries what that choice
|
// 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
|
// 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.
|
// to print, so the receipt could show "4,2" with no idea of what.
|
||||||
@@ -100,7 +112,7 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
|
|||||||
// At least one field should be provided for update
|
// At least one field should be provided for update
|
||||||
if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil &&
|
if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil &&
|
||||||
req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil &&
|
req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil &&
|
||||||
req.PrinterType == nil && req.PrintToChecker == nil && req.UnitID == nil &&
|
req.PrinterTypes == nil && req.PrintToChecker == nil && req.UnitID == nil &&
|
||||||
req.SellBy == nil && req.Metadata == nil && req.IsActive == nil {
|
req.SellBy == nil && req.Metadata == nil && req.IsActive == nil {
|
||||||
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
@@ -134,8 +146,8 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
|
|||||||
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
|
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.PrinterType != nil && len(*req.PrinterType) > 50 {
|
if err, code := validatePrinterTypes(req.PrinterTypes); err != nil {
|
||||||
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
|
return err, code
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the value is checked here. Whether the product ends up with a unit depends on
|
// Only the value is checked here. Whether the product ends up with a unit depends on
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/contract"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateProductRequestPrinterTypes(t *testing.T) {
|
||||||
|
v := NewProductValidator()
|
||||||
|
tooLong := strings.Repeat("a", 51)
|
||||||
|
|
||||||
|
create := baseCreateRequest()
|
||||||
|
create.PrinterTypes = []string{"kitchen", "bar"}
|
||||||
|
if err, _ := v.ValidateCreateProductRequest(create); err != nil {
|
||||||
|
t.Fatalf("kitchen and bar should be accepted, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
create.PrinterTypes = []string{"kitchen", tooLong}
|
||||||
|
if err, _ := v.ValidateCreateProductRequest(create); err == nil {
|
||||||
|
t.Fatal("expected an error for a printer longer than 50 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{PrinterTypes: []string{tooLong}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for a printer longer than 50 characters on update")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An update that only changes the printers is a real update.
|
||||||
|
err, _ = v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{PrinterTypes: []string{"kitchen", "bar"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("an update of only printer_types should be accepted, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,4 +13,4 @@ DROP INDEX IF EXISTS uq_payment_methods_point_per_organization;
|
|||||||
|
|
||||||
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
|
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
|
||||||
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
|
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
|
||||||
CHECK (type IN ('cash', 'card', 'digital_wallet'));
|
CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery'));
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
-- Paying with EnakPoint (docs/prd-point-coin.md F9, §8, §10.5).
|
-- Paying with EnakPoint (docs/prd-point-coin.md F9, §8, §10.5).
|
||||||
|
|
||||||
-- A new payment method type. Every organization has exactly one method of it, made by
|
-- A new payment method type, next to the existing ones (edc and delivery were added to
|
||||||
|
-- the constraint outside the migrations). Every organization has exactly one method of it, made by
|
||||||
-- the system, which cannot be deleted or change type.
|
-- the system, which cannot be deleted or change type.
|
||||||
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
|
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
|
||||||
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
|
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
|
||||||
CHECK (type IN ('cash', 'card', 'digital_wallet', 'point'));
|
CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery', 'point'));
|
||||||
|
|
||||||
CREATE UNIQUE INDEX uq_payment_methods_point_per_organization ON payment_methods(organization_id)
|
CREATE UNIQUE INDEX uq_payment_methods_point_per_organization ON payment_methods(organization_id)
|
||||||
WHERE type = 'point';
|
WHERE type = 'point';
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Nothing to undo: 000094 now allows the same types.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Production also has edc and delivery payment methods, allowed by a constraint changed
|
||||||
|
-- outside the migrations, and the code also accepts qr, which no constraint allowed.
|
||||||
|
-- 000094 first rewrote the constraint without them, which
|
||||||
|
-- failed on production and, where it ran, dropped them. Keep every type in use.
|
||||||
|
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
|
||||||
|
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
|
||||||
|
CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery', 'point'));
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Only the first printer fits back into printer_type; the extra stations are lost.
|
||||||
|
ALTER TABLE products ADD COLUMN printer_type VARCHAR(50) DEFAULT 'kitchen';
|
||||||
|
|
||||||
|
UPDATE products SET printer_type = COALESCE(printer_types->>0, '');
|
||||||
|
|
||||||
|
CREATE INDEX idx_products_printer_type ON products(printer_type);
|
||||||
|
|
||||||
|
ALTER TABLE products DROP COLUMN printer_types;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- A product can be prepared at more than one station: a meal-and-drink package goes to
|
||||||
|
-- the kitchen and to the bar. printer_types replaces the single printer_type.
|
||||||
|
ALTER TABLE products ADD COLUMN printer_types JSONB NOT NULL DEFAULT '["kitchen"]';
|
||||||
|
|
||||||
|
UPDATE products
|
||||||
|
SET printer_types = CASE
|
||||||
|
WHEN COALESCE(printer_type, '') = '' THEN '[]'::jsonb
|
||||||
|
ELSE jsonb_build_array(printer_type)
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Dropping the column drops idx_products_printer_type with it.
|
||||||
|
ALTER TABLE products DROP COLUMN printer_type;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE categories DROP COLUMN owner_fee_percent;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- The owner's share of revenue in the parent category budget report. NULL keeps the
|
||||||
|
-- default share, so only the parent categories with a different fee carry a value.
|
||||||
|
ALTER TABLE categories ADD COLUMN owner_fee_percent NUMERIC(5,2)
|
||||||
|
CONSTRAINT chk_categories_owner_fee_percent CHECK (owner_fee_percent >= 0 AND owner_fee_percent <= 100);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE categories DROP COLUMN is_team;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Whether a parent category is a team: it can be charged with purchases and cash
|
||||||
|
-- advances, and its revenue takes part in the parent category budget split. Every
|
||||||
|
-- existing category stays a team, since that is how parent categories were treated.
|
||||||
|
ALTER TABLE categories ADD COLUMN is_team BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
+2
-2
@@ -500,7 +500,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"name\": \"Cappuccino\",\n \"description\": \"Classic Italian coffee drink\",\n \"category_id\": \"{{category_id}}\",\n \"sku\": \"CAP001\",\n \"barcode\": \"1234567890123\",\n \"price\": 4.50,\n \"cost\": 1.20,\n \"is_active\": true,\n \"has_variants\": false,\n \"image_url\": \"https://example.com/cappuccino.jpg\",\n \"printer_type\": \"kitchen\"\n}"
|
"raw": "{\n \"name\": \"Cappuccino\",\n \"description\": \"Classic Italian coffee drink\",\n \"category_id\": \"{{category_id}}\",\n \"sku\": \"CAP001\",\n \"barcode\": \"1234567890123\",\n \"price\": 4.50,\n \"cost\": 1.20,\n \"is_active\": true,\n \"has_variants\": false,\n \"image_url\": \"https://example.com/cappuccino.jpg\",\n \"printer_types\": [\"kitchen\"]\n}"
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{base_url}}/api/v1/products",
|
"raw": "{{base_url}}/api/v1/products",
|
||||||
@@ -563,7 +563,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"name\": \"Premium Cappuccino\",\n \"description\": \"Premium Italian coffee drink with extra foam\",\n \"price\": 5.50,\n \"cost\": 1.50,\n \"is_active\": true,\n \"image_url\": \"https://example.com/premium-cappuccino.jpg\",\n \"printer_type\": \"kitchen\"\n}"
|
"raw": "{\n \"name\": \"Premium Cappuccino\",\n \"description\": \"Premium Italian coffee drink with extra foam\",\n \"price\": 5.50,\n \"cost\": 1.50,\n \"is_active\": true,\n \"image_url\": \"https://example.com/premium-cappuccino.jpg\",\n \"printer_types\": [\"kitchen\"]\n}"
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{base_url}}/api/v1/products/{{product_id}}",
|
"raw": "{{base_url}}/api/v1/products/{{product_id}}",
|
||||||
|
|||||||
Reference in New Issue
Block a user