feat: add printer types #40
@@ -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
@@ -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.
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -568,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)
|
||||||
@@ -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)
|
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)
|
||||||
|
|
||||||
@@ -596,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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,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,
|
||||||
}
|
}
|
||||||
@@ -195,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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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