feat: add printer types

This commit is contained in:
efrilm
2026-10-01 22:26:44 +07:00
parent 892575202b
commit b0ef226af1
25 changed files with 2048 additions and 66 deletions
+143
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+438
View File
@@ -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 -1
View File
@@ -121,7 +121,7 @@ type OrderItemResponse struct {
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PrinterType string `json:"printer_type"`
PrinterTypes []string `json:"printer_types"`
PrintToChecker bool `json:"print_to_checker"`
PaidQuantity int `json:"paid_quantity"`
}
+3 -3
View File
@@ -16,7 +16,7 @@ type CreateProductRequest struct {
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
BusinessType *string `json:"business_type,omitempty"`
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"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
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"`
BusinessType *string `json:"business_type,omitempty"`
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"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
@@ -76,7 +76,7 @@ type ProductResponse struct {
Cost float64 `json:"cost"`
BusinessType string `json:"business_type"`
ImageURL *string `json:"image_url"`
PrinterType string `json:"printer_type"`
PrinterTypes []string `json:"printer_types"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
SellBy string `json:"sell_by"`
PrintToChecker bool `json:"print_to_checker"`
+47 -18
View File
@@ -1,6 +1,7 @@
package entities
import (
"strings"
"time"
"github.com/google/uuid"
@@ -8,24 +9,26 @@ import (
)
type Product struct {
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"`
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
SKU *string `gorm:"size:100;index" json:"sku"`
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
Description *string `gorm:"type:text" json:"description"`
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"`
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
ImageURL *string `gorm:"size:500" json:"image_url"`
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
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"`
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
SKU *string `gorm:"size:100;index" json:"sku"`
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
Description *string `gorm:"type:text" json:"description"`
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"`
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
ImageURL *string `gorm:"size:500" json:"image_url"`
// PrinterTypes are the stations the product is printed at. Set them through
// SetPrinterTypes.
PrinterTypes StringSlice `gorm:"type:jsonb;not null;default:'[\"kitchen\"]'" json:"printer_types"`
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
@@ -48,6 +51,32 @@ func (Product) TableName() string {
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 {
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"`
+1 -1
View File
@@ -149,7 +149,7 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
Status: constants.OrderItemStatus(item.Status),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PrinterType: item.Product.PrinterType,
PrinterTypes: item.Product.GetPrinterTypes(),
PrintToChecker: printToChecker,
}
+13 -12
View File
@@ -24,7 +24,7 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
Cost: entity.Cost,
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
PrinterTypes: entity.GetPrinterTypes(),
UnitID: entity.UnitID,
SellBy: entity.SellBy,
HasIngredients: entity.HasIngredients,
@@ -51,7 +51,7 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
Cost: model.Cost,
BusinessType: string(model.BusinessType),
ImageURL: model.ImageURL,
PrinterType: model.PrinterType,
PrinterTypes: entities.StringSlice(model.PrinterTypes),
UnitID: model.UnitID,
SellBy: model.SellBy,
HasIngredients: model.HasIngredients,
@@ -77,11 +77,6 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
businessType = string(req.BusinessType)
}
printerType := "kitchen"
if req.PrinterType != nil && *req.PrinterType != "" {
printerType = *req.PrinterType
}
sellBy := constants.SellByUnit
if constants.IsValidSellBy(req.SellBy) {
sellBy = req.SellBy
@@ -92,7 +87,7 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
metadata = entities.Metadata(req.Metadata)
}
return &entities.Product{
product := &entities.Product{
OrganizationID: req.OrganizationID,
CategoryID: req.CategoryID,
SKU: req.SKU,
@@ -102,12 +97,18 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
Cost: cost,
BusinessType: businessType,
ImageURL: req.ImageURL,
PrinterType: printerType,
UnitID: req.UnitID,
SellBy: sellBy,
Metadata: metadata,
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 {
@@ -152,7 +153,7 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
Cost: entity.Cost,
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
PrinterTypes: entity.GetPrinterTypes(),
UnitID: entity.UnitID,
SellBy: entity.SellBy,
Metadata: map[string]interface{}(entity.Metadata),
@@ -196,8 +197,8 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
entity.ImageURL = req.ImageURL
}
if req.PrinterType != nil {
entity.PrinterType = *req.PrinterType
if req.PrinterTypes != nil {
entity.SetPrinterTypes(req.PrinterTypes)
}
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)
}
+1 -1
View File
@@ -218,7 +218,7 @@ type OrderItemResponse struct {
Status constants.OrderItemStatus
CreatedAt time.Time
UpdatedAt time.Time
PrinterType string
PrinterTypes []string
PrintToChecker bool
PaidQuantity int
}
+4 -4
View File
@@ -18,7 +18,7 @@ type Product struct {
Cost float64
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
PrinterTypes []string
SellBy string
UnitID *uuid.UUID
HasIngredients bool
@@ -50,7 +50,7 @@ type CreateProductRequest struct {
Cost float64 `validate:"min=0"`
BusinessType constants.BusinessType `validate:"required"`
ImageURL *string `validate:"omitempty,max=500"`
PrinterType *string `validate:"omitempty,max=50"`
PrinterTypes []string `validate:"omitempty,dive,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"`
SellBy string `validate:"omitempty,oneof=unit weight"`
@@ -72,7 +72,7 @@ type UpdateProductRequest struct {
Price *float64 `validate:"omitempty,min=0"`
Cost *float64 `validate:"omitempty,min=0"`
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"`
UnitID *uuid.UUID `validate:"omitempty"`
SellBy *string `validate:"omitempty,oneof=unit weight"`
@@ -112,7 +112,7 @@ type ProductResponse struct {
Cost float64
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
PrinterTypes []string
SellBy string
PrintToChecker bool
UnitID *uuid.UUID
@@ -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)
}
+24 -6
View File
@@ -568,16 +568,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
return nil, fmt.Errorf("failed to update order: %w", err)
}
var addedItemResponses []models.OrderItemResponse
for _, orderItem := range addedOrderItems {
if err := p.orderItemRepo.Create(ctx, orderItem); err != nil {
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)
@@ -585,6 +579,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
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)
p.attachEarnings(ctx, updatedOrderResponse)
@@ -596,6 +594,26 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
}, 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) {
// Get existing order
order, err := p.orderRepo.GetByID(ctx, id)
@@ -199,7 +199,7 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe
Cost: entity.Product.Cost,
BusinessType: string(entity.Product.BusinessType),
ImageURL: entity.Product.ImageURL,
PrinterType: entity.Product.PrinterType,
PrinterTypes: entity.Product.GetPrinterTypes(),
Metadata: entity.Product.Metadata,
IsActive: entity.Product.IsActive,
CreatedAt: entity.Product.CreatedAt,
@@ -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) {
query := `
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
FROM product_ingredients pi
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.BusinessType,
&product.ImageURL,
&product.PrinterType,
&product.PrinterTypes,
&product.UnitID,
&product.HasIngredients,
&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) {
query := `
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
FROM product_ingredients pi
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.BusinessType,
&product.ImageURL,
&product.PrinterType,
&product.PrinterTypes,
&product.UnitID,
&product.HasIngredients,
&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) {
query := `
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
FROM product_ingredients pi
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.BusinessType,
&product.ImageURL,
&product.PrinterType,
&product.PrinterTypes,
&product.UnitID,
&product.HasIngredients,
&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)
}
@@ -44,8 +44,9 @@ func InventoryModelResponseToResponse(inv *models.InventoryResponse) *contract.I
IsLowStock: inv.IsLowStock,
UpdatedAt: inv.UpdatedAt,
Product: &contract.ProductResponse{
ID: inv.ProductID,
Name: inv.ProductName,
ID: inv.ProductID,
Name: inv.ProductName,
PrinterTypes: []string{},
},
}
}
+2 -1
View File
@@ -118,7 +118,7 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse {
Status: string(item.Status),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PrinterType: item.PrinterType,
PrinterTypes: item.PrinterTypes,
PrintToChecker: item.PrintToChecker,
PaidQuantity: item.PaidQuantity,
}
@@ -195,6 +195,7 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO
Status: string(item.Status),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PrinterTypes: item.PrinterTypes,
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)
}
+3 -3
View File
@@ -61,7 +61,7 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
Cost: cost,
BusinessType: businessType,
ImageURL: req.ImageURL,
PrinterType: req.PrinterType,
PrinterTypes: req.PrinterTypes,
PrintToChecker: req.PrintToChecker,
UnitID: req.UnitID,
SellBy: sellBy,
@@ -91,7 +91,7 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up
Price: req.Price,
Cost: req.Cost,
ImageURL: req.ImageURL,
PrinterType: req.PrinterType,
PrinterTypes: req.PrinterTypes,
PrintToChecker: req.PrintToChecker,
UnitID: req.UnitID,
SellBy: req.SellBy,
@@ -152,7 +152,7 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
Cost: prod.Cost,
BusinessType: string(prod.BusinessType),
ImageURL: prod.ImageURL,
PrinterType: prod.PrinterType,
PrinterTypes: prod.PrinterTypes,
PrintToChecker: prod.PrintToChecker,
UnitID: prod.UnitID,
SellBy: prod.SellBy,
+17 -5
View File
@@ -59,8 +59,8 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
}
if req.PrinterType != nil && len(*req.PrinterType) > 50 {
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
if err, code := validatePrinterTypes(req.PrinterTypes); err != nil {
return err, code
}
if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil {
@@ -70,6 +70,18 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create
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
// 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.
@@ -100,7 +112,7 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
// At least one field should be provided for update
if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil &&
req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil &&
req.PrinterType == nil && req.PrintToChecker == nil && req.UnitID == nil &&
req.PrinterTypes == nil && req.PrintToChecker == nil && req.UnitID == nil &&
req.SellBy == nil && req.Metadata == nil && req.IsActive == nil {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
}
@@ -134,8 +146,8 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
}
if req.PrinterType != nil && len(*req.PrinterType) > 50 {
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
if err, code := validatePrinterTypes(req.PrinterTypes); err != nil {
return err, code
}
// 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)
}
}
@@ -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;
+2 -2
View File
@@ -500,7 +500,7 @@
],
"body": {
"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": {
"raw": "{{base_url}}/api/v1/products",
@@ -563,7 +563,7 @@
],
"body": {
"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": {
"raw": "{{base_url}}/api/v1/products/{{product_id}}",