From 27694798c2d9cb9b9efb43354dc7815c0a6650c0 Mon Sep 17 00:00:00 2001 From: ahdan15 Date: Wed, 22 Apr 2026 09:11:46 +0700 Subject: [PATCH] first commit --- data/state.json | 1223 +++++++++++++++ docker-compose.yml | 145 +- docker-push.sh | 27 + go.mod | 4 - go.sum | 8 - internal/aplicare/aplicare.go | 278 ++++ internal/aplicare/bpjs.go | 270 ++++ internal/aplicare/database.go | 155 ++ internal/aplicare/state.go | 141 ++ internal/aplicare/syncer.go | 158 ++ internal/aplicare/synclog.go | 96 ++ internal/config/config.go | 27 +- internal/handlers/antreanbpjs/antreanbpjs.go | 68 + internal/handlers/peserta/peserta.go | 604 -------- internal/handlers/retribusi/retribusi.go | 1401 ------------------ internal/models/antreanbpjs/antreanbpjs.go | 1 + internal/routes/v1/routes.go | 76 +- internal/services/bpjs/vclaimBridge.go | 183 +-- logs/sync.log | 157 ++ 19 files changed, 2748 insertions(+), 2274 deletions(-) create mode 100644 data/state.json create mode 100644 docker-push.sh create mode 100644 internal/aplicare/aplicare.go create mode 100644 internal/aplicare/bpjs.go create mode 100644 internal/aplicare/database.go create mode 100644 internal/aplicare/state.go create mode 100644 internal/aplicare/syncer.go create mode 100644 internal/aplicare/synclog.go create mode 100644 internal/handlers/antreanbpjs/antreanbpjs.go delete mode 100644 internal/handlers/peserta/peserta.go delete mode 100644 internal/handlers/retribusi/retribusi.go create mode 100644 internal/models/antreanbpjs/antreanbpjs.go create mode 100644 logs/sync.log diff --git a/data/state.json b/data/state.json new file mode 100644 index 0000000..7a9d29a --- /dev/null +++ b/data/state.json @@ -0,0 +1,1223 @@ +{ + "last_updated": "2026-04-22T02:01:08Z", + "rooms": { + "BARI1": { + "kode_ruang": "BARI1", + "kodekelas": "KL1", + "nama_ruang": "RUANG BARITO KELAS 1", + "old_value": { + "kapasitas": 6, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "new_value": { + "kapasitas": 6, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:37Z" + }, + "BARI2": { + "kode_ruang": "BARI2", + "kodekelas": "KL2", + "nama_ruang": "RUANG BARITO KELAS 2", + "old_value": { + "kapasitas": 4, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "new_value": { + "kapasitas": 4, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:39Z" + }, + "BARI3": { + "kode_ruang": "BARI3", + "kodekelas": "KL3", + "nama_ruang": "RUANG BARITO KELAS 3", + "old_value": { + "kapasitas": 11, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "new_value": { + "kapasitas": 11, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:40Z" + }, + "BARIV": { + "kode_ruang": "BARIV", + "kodekelas": "VIP", + "nama_ruang": "RUANG BARITO VIP", + "old_value": { + "kapasitas": 2, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "new_value": { + "kapasitas": 2, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:35Z" + }, + "BENG2": { + "kode_ruang": "BENG2", + "kodekelas": "KL2", + "nama_ruang": "RUANG BENGAWAN SOLO KELAS 2", + "old_value": { + "kapasitas": 4, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "new_value": { + "kapasitas": 4, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:38Z" + }, + "BRAN2": { + "kode_ruang": "BRAN2", + "kodekelas": "HCU", + "nama_ruang": "RUANG HCU BRANTAS", + "old_value": { + "kapasitas": 9, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "new_value": { + "kapasitas": 9, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "changed": false, + "last_synced": "2026-04-22T01:51:10Z" + }, + "BROM3": { + "kode_ruang": "BROM3", + "kodekelas": "KL3", + "nama_ruang": "RUANG BROMO KELAS 3", + "old_value": { + "kapasitas": 42, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "new_value": { + "kapasitas": 42, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": true, + "last_synced": "2026-04-22T02:01:03Z" + }, + "BUGV3": { + "kode_ruang": "BUGV3", + "kodekelas": "ISO", + "nama_ruang": "RUANG BUGENVILE KELAS 3", + "old_value": { + "kapasitas": 20, + "tersedia": 10, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 10 + }, + "new_value": { + "kapasitas": 20, + "tersedia": 10, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 10 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:47Z" + }, + "BUNA2": { + "kode_ruang": "BUNA2", + "kodekelas": "KL2", + "nama_ruang": "RUANG BUNAKEN KELAS 2", + "old_value": { + "kapasitas": 6, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "new_value": { + "kapasitas": 6, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:18Z" + }, + "BUNA3": { + "kode_ruang": "BUNA3", + "kodekelas": "KL3", + "nama_ruang": "RUANG BUNAKEN KELAS 3", + "old_value": { + "kapasitas": 16, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "new_value": { + "kapasitas": 16, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:19Z" + }, + "CILI2": { + "kode_ruang": "CILI2", + "kodekelas": "HCU", + "nama_ruang": "RUANG HCU CILIWUNG", + "old_value": { + "kapasitas": 28, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "new_value": { + "kapasitas": 28, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:22Z" + }, + "CISA2": { + "kode_ruang": "CISA2", + "kodekelas": "HCU", + "nama_ruang": "RUANG HCU CISADANE", + "old_value": { + "kapasitas": 40, + "tersedia": 8, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 8 + }, + "new_value": { + "kapasitas": 40, + "tersedia": 8, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 8 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:41Z" + }, + "DAHL1": { + "kode_ruang": "DAHL1", + "kodekelas": "KL1", + "nama_ruang": "RUANG DAHLIA KELAS 1", + "old_value": { + "kapasitas": 38, + "tersedia": 13, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 13 + }, + "new_value": { + "kapasitas": 38, + "tersedia": 13, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 13 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:48Z" + }, + "GALG3": { + "kode_ruang": "GALG3", + "kodekelas": "KL3", + "nama_ruang": "RUANG GALUNGGUNG KELAS 3", + "old_value": { + "kapasitas": 16, + "tersedia": 4, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 4 + }, + "new_value": { + "kapasitas": 16, + "tersedia": 4, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 4 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:31Z" + }, + "GILI1": { + "kode_ruang": "GILI1", + "kodekelas": "KL1", + "nama_ruang": "RUANG GILI TRAWANGAN KELAS 1", + "old_value": { + "kapasitas": 2, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "new_value": { + "kapasitas": 2, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:20Z" + }, + "GILI2": { + "kode_ruang": "GILI2", + "kodekelas": "KL2", + "nama_ruang": "RUANG GILI TRAWANGAN KELAS 2", + "old_value": { + "kapasitas": 4, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "new_value": { + "kapasitas": 4, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:36Z" + }, + "GILI3": { + "kode_ruang": "GILI3", + "kodekelas": "KL3", + "nama_ruang": "RUANG GILI TRAWANGAN KELAS 3", + "old_value": { + "kapasitas": 9, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "new_value": { + "kapasitas": 9, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:21Z" + }, + "HMEL1": { + "kode_ruang": "HMEL1", + "kodekelas": "ISO", + "nama_ruang": "RUANG HCU INFEKSI MELATI", + "old_value": { + "kapasitas": 8, + "tersedia": 7, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 7 + }, + "new_value": { + "kapasitas": 8, + "tersedia": 7, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 7 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:46Z" + }, + "IMEL1": { + "kode_ruang": "IMEL1", + "kodekelas": "ISO", + "nama_ruang": "RUANG ICU INFEKSI MELATI", + "old_value": { + "kapasitas": 6, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "new_value": { + "kapasitas": 6, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:16Z" + }, + "JIMB2": { + "kode_ruang": "JIMB2", + "kodekelas": "KL2", + "nama_ruang": "RUANG JIMBARAN KELAS 2", + "old_value": { + "kapasitas": 28, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "new_value": { + "kapasitas": 28, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:50Z" + }, + "KAPA1": { + "kode_ruang": "KAPA1", + "kodekelas": "ICU", + "nama_ruang": "RUANG ICU KAPUAS A", + "old_value": { + "kapasitas": 16, + "tersedia": 6, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 6 + }, + "new_value": { + "kapasitas": 16, + "tersedia": 5, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 5 + }, + "changed": true, + "last_synced": "2026-04-22T02:01:03Z" + }, + "KAPB1": { + "kode_ruang": "KAPB1", + "kodekelas": "ICU", + "nama_ruang": "RUANG ICU KAPUAS B", + "old_value": { + "kapasitas": 9, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "new_value": { + "kapasitas": 9, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:46Z" + }, + "KAPC1": { + "kode_ruang": "KAPC1", + "kodekelas": "ICU", + "nama_ruang": "RUANG ICU KAPUAS C KELAS 1", + "old_value": { + "kapasitas": 14, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "new_value": { + "kapasitas": 14, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:48Z" + }, + "KELI1": { + "kode_ruang": "KELI1", + "kodekelas": "KL1", + "nama_ruang": "RUANG KELIMUTU KELAS 1", + "old_value": { + "kapasitas": 16, + "tersedia": 5, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 5 + }, + "new_value": { + "kapasitas": 16, + "tersedia": 5, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 5 + }, + "changed": false, + "last_synced": "2026-04-22T01:51:16Z" + }, + "KELI2": { + "kode_ruang": "KELI2", + "kodekelas": "KL2", + "nama_ruang": "RUANG KELIMUTU KELAS 2", + "old_value": { + "kapasitas": 8, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "new_value": { + "kapasitas": 8, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:33Z" + }, + "KERC2": { + "kode_ruang": "KERC2", + "kodekelas": "KL2", + "nama_ruang": "RUANG KERINCI KELAS 2", + "old_value": { + "kapasitas": 8, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "new_value": { + "kapasitas": 8, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": false, + "last_synced": "2026-04-22T01:56:12Z" + }, + "KERC3": { + "kode_ruang": "KERC3", + "kodekelas": "KL3", + "nama_ruang": "RUANG KERINCI KELAS 3", + "old_value": { + "kapasitas": 18, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "new_value": { + "kapasitas": 18, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:52Z" + }, + "KRAK1": { + "kode_ruang": "KRAK1", + "kodekelas": "PIC", + "nama_ruang": "RUANG PICU KRAKATAU", + "old_value": { + "kapasitas": 17, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "new_value": { + "kapasitas": 17, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:34Z" + }, + "LOSA3": { + "kode_ruang": "LOSA3", + "kodekelas": "KL3", + "nama_ruang": "RUANG LOSARI KELAS 3", + "old_value": { + "kapasitas": 14, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "new_value": { + "kapasitas": 14, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "changed": false, + "last_synced": "2026-04-22T01:56:06Z" + }, + "MAHA2": { + "kode_ruang": "MAHA2", + "kodekelas": "HCU", + "nama_ruang": "RUANG HCU MAHAKAM", + "old_value": { + "kapasitas": 20, + "tersedia": 10, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 10 + }, + "new_value": { + "kapasitas": 20, + "tersedia": 10, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 10 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:23Z" + }, + "MUSI1": { + "kode_ruang": "MUSI1", + "kodekelas": "ICC", + "nama_ruang": "RUANG CVCU MUSI", + "old_value": { + "kapasitas": 13, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "new_value": { + "kapasitas": 13, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:36Z" + }, + "MWAR1": { + "kode_ruang": "MWAR1", + "kodekelas": "KL1", + "nama_ruang": "RUANG MAWAR KELAS 1", + "old_value": { + "kapasitas": 30, + "tersedia": 10, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 10 + }, + "new_value": { + "kapasitas": 30, + "tersedia": 10, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 10 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:49Z" + }, + "PANG3": { + "kode_ruang": "PANG3", + "kodekelas": "KL3", + "nama_ruang": "RUANG PANGANDARAN", + "old_value": { + "kapasitas": 35, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "new_value": { + "kapasitas": 35, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:25Z" + }, + "PARA3": { + "kode_ruang": "PARA3", + "kodekelas": "KL3", + "nama_ruang": "RUANG PARANGTRITIS", + "old_value": { + "kapasitas": 30, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "new_value": { + "kapasitas": 30, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:26Z" + }, + "RANKB3": { + "kode_ruang": "RANKB3", + "kodekelas": "KL3", + "nama_ruang": "RUANG RANU KUMBOLO (BAYI) KELAS 3", + "old_value": { + "kapasitas": 1, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "new_value": { + "kapasitas": 1, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:32Z" + }, + "RANU2": { + "kode_ruang": "RANU2", + "kodekelas": "HCU", + "nama_ruang": "RUANG HCU RANU GRATI", + "old_value": { + "kapasitas": 8, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "new_value": { + "kapasitas": 8, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:43Z" + }, + "RANU3": { + "kode_ruang": "RANU3", + "kodekelas": "KL3", + "nama_ruang": "RUANG RANU KUMBOLO KELAS 3", + "old_value": { + "kapasitas": 18, + "tersedia": 6, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 6 + }, + "new_value": { + "kapasitas": 18, + "tersedia": 6, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 6 + }, + "changed": false, + "last_synced": "2026-04-22T01:31:19Z" + }, + "RGPLT21": { + "kode_ruang": "RGPLT21", + "kodekelas": "KL1", + "nama_ruang": "RUANG GRAND PAV LANTAI 2 KELAS 1", + "old_value": { + "kapasitas": 12, + "tersedia": 11, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 11 + }, + "new_value": { + "kapasitas": 12, + "tersedia": 11, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 11 + }, + "changed": false, + "last_synced": "2026-04-22T01:06:02Z" + }, + "RGPLT2VIP": { + "kode_ruang": "RGPLT2VIP", + "kodekelas": "VIP", + "nama_ruang": "RUANG GRAND PAV LANTAI 2 KELAS VIP", + "old_value": { + "kapasitas": 5, + "tersedia": 4, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 4 + }, + "new_value": { + "kapasitas": 5, + "tersedia": 4, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 4 + }, + "changed": false, + "last_synced": "2026-04-22T01:41:07Z" + }, + "RGPLT3": { + "kode_ruang": "RGPLT3", + "kodekelas": "VIP", + "nama_ruang": "RUANG GRAND PAV LANTAI 3 KELAS VIP A", + "old_value": { + "kapasitas": 24, + "tersedia": 14, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 14 + }, + "new_value": { + "kapasitas": 24, + "tersedia": 14, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 14 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:55Z" + }, + "RGPLT4": { + "kode_ruang": "RGPLT4", + "kodekelas": "VIP", + "nama_ruang": "RUANG GRAND PAV LANTAI 4 KELAS VIP A", + "old_value": { + "kapasitas": 24, + "tersedia": 24, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 24 + }, + "new_value": { + "kapasitas": 24, + "tersedia": 24, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 24 + }, + "changed": false, + "last_synced": "2026-04-22T01:06:01Z" + }, + "RGPLT7VIP": { + "kode_ruang": "RGPLT7VIP", + "kodekelas": "VIP", + "nama_ruang": "RUANG GRAND PAV LANTAI 7 KELAS VIP", + "old_value": { + "kapasitas": 3, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "new_value": { + "kapasitas": 3, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "changed": false, + "last_synced": "2026-04-22T01:06:03Z" + }, + "RHCKW2": { + "kode_ruang": "RHCKW2", + "kodekelas": "HCU", + "nama_ruang": "RUANG HCU KAWI KELAS 2", + "old_value": { + "kapasitas": 9, + "tersedia": 7, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 7 + }, + "new_value": { + "kapasitas": 9, + "tersedia": 7, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 7 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:56Z" + }, + "RHRP3": { + "kode_ruang": "RHRP3", + "kodekelas": "HCU", + "nama_ruang": "RUANG HCU RANU PANE KELAS 2", + "old_value": { + "kapasitas": 38, + "tersedia": 27, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 27 + }, + "new_value": { + "kapasitas": 38, + "tersedia": 27, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 27 + }, + "changed": false, + "last_synced": "2026-04-22T01:41:06Z" + }, + "RINJ1": { + "kode_ruang": "RINJ1", + "kodekelas": "KL1", + "nama_ruang": "RUANG RINJANI KELAS 1", + "old_value": { + "kapasitas": 2, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "new_value": { + "kapasitas": 2, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "changed": true, + "last_synced": "2026-04-22T02:01:03Z" + }, + "RINJ2": { + "kode_ruang": "RINJ2", + "kodekelas": "KL2", + "nama_ruang": "RUANG RINJANI KELAS 2", + "old_value": { + "kapasitas": 2, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "new_value": { + "kapasitas": 2, + "tersedia": 0, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 0 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:29Z" + }, + "RINJ3": { + "kode_ruang": "RINJ3", + "kodekelas": "KL3", + "nama_ruang": "RUANG RINJANI KELAS 3", + "old_value": { + "kapasitas": 18, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "new_value": { + "kapasitas": 18, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "changed": false, + "last_synced": "2026-04-22T01:31:03Z" + }, + "RNICU1": { + "kode_ruang": "RNICU1", + "kodekelas": "NIC", + "nama_ruang": "RUANG NICU MANINJAU KELAS 1", + "old_value": { + "kapasitas": 12, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "new_value": { + "kapasitas": 12, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "changed": false, + "last_synced": "2026-04-22T01:06:00Z" + }, + "RNS1": { + "kode_ruang": "RNS1", + "kodekelas": "KL1", + "nama_ruang": "RUANG NUSA DUA KELAS 1", + "old_value": { + "kapasitas": 20, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "new_value": { + "kapasitas": 20, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:56Z" + }, + "ROE2": { + "kode_ruang": "ROE2", + "kodekelas": "KL2", + "nama_ruang": "RUANG ROE KELAS 2", + "old_value": { + "kapasitas": 10, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "new_value": { + "kapasitas": 10, + "tersedia": 3, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 3 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:53Z" + }, + "RSING3": { + "kode_ruang": "RSING3", + "kodekelas": "KL3", + "nama_ruang": "RUANG SINGKARAK KELAS 3", + "old_value": { + "kapasitas": 30, + "tersedia": 15, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 15 + }, + "new_value": { + "kapasitas": 30, + "tersedia": 15, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 15 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:58Z" + }, + "RTOBY2": { + "kode_ruang": "RTOBY2", + "kodekelas": "KL2", + "nama_ruang": "RUANG TOBA (BAYI) KELAS 2", + "old_value": { + "kapasitas": 1, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "new_value": { + "kapasitas": 1, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:57Z" + }, + "SARA2": { + "kode_ruang": "SARA2", + "kodekelas": "HCU", + "nama_ruang": "RUANG HCU SARANGAN", + "old_value": { + "kapasitas": 11, + "tersedia": 4, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 4 + }, + "new_value": { + "kapasitas": 11, + "tersedia": 4, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 4 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:34Z" + }, + "SEME3": { + "kode_ruang": "SEME3", + "kodekelas": "KL3", + "nama_ruang": "RUANG SEMERU", + "old_value": { + "kapasitas": 43, + "tersedia": 19, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 19 + }, + "new_value": { + "kapasitas": 43, + "tersedia": 18, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 18 + }, + "changed": true, + "last_synced": "2026-04-22T02:01:03Z" + }, + "TOBA1": { + "kode_ruang": "TOBA1", + "kodekelas": "KL1", + "nama_ruang": "RUANG TOBA (BAYI) KELAS 1", + "old_value": { + "kapasitas": 1, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "new_value": { + "kapasitas": 1, + "tersedia": 1, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 1 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:40Z" + }, + "TOIB1": { + "kode_ruang": "TOIB1", + "kodekelas": "KL1", + "nama_ruang": "RUANG TOBA (IBU) KELAS 1", + "old_value": { + "kapasitas": 10, + "tersedia": 6, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 6 + }, + "new_value": { + "kapasitas": 10, + "tersedia": 6, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 6 + }, + "changed": false, + "last_synced": "2026-04-22T01:56:09Z" + }, + "TOIB2": { + "kode_ruang": "TOIB2", + "kodekelas": "KL2", + "nama_ruang": "RUANG TOBA (IBU) KELAS 2", + "old_value": { + "kapasitas": 8, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "new_value": { + "kapasitas": 8, + "tersedia": 2, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 2 + }, + "changed": false, + "last_synced": "2026-04-22T01:05:43Z" + }, + "TOND3": { + "kode_ruang": "TOND3", + "kodekelas": "KL3", + "nama_ruang": "RUANG TONDANO", + "old_value": { + "kapasitas": 50, + "tersedia": 20, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 20 + }, + "new_value": { + "kapasitas": 50, + "tersedia": 20, + "tersedia_pria": 0, + "tersedia_wanita": 0, + "tersedia_pria_wanita": 20 + }, + "changed": false, + "last_synced": "2026-04-22T01:51:18Z" + } + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 17b58e9..70b6abf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,81 +1,4 @@ services: - # # PostgreSQL Database - # psql_bp: - # image: postgres:15-alpine - # restart: unless-stopped - # environment: - # POSTGRES_USER: stim - # POSTGRES_PASSWORD: stim*RS54 - # POSTGRES_DB: satu_db - # ports: - # - "5432:5432" - # volumes: - # - postgres_data:/var/lib/postgresql/data - # healthcheck: - # test: ["CMD-SHELL", "pg_isready -U stim -d satu_db"] - # interval: 10s - # timeout: 5s - # retries: 5 - # networks: - # - blueprint - - # # MongoDB Database - # mongodb: - # image: mongo:7-jammy - # restart: unless-stopped - # environment: - # MONGO_INITDB_ROOT_USERNAME: admin - # MONGO_INITDB_ROOT_PASSWORD: stim*rs54 - # ports: - # - "27017:27017" - # volumes: - # - mongodb_data:/data/db - # networks: - # - blueprint - - # # MySQL Antrian Database - # mysql_antrian: - # image: mysql:8.0 - # restart: unless-stopped - # environment: - # MYSQL_ROOT_PASSWORD: www-data - # MYSQL_USER: www-data - # MYSQL_PASSWORD: www-data - # MYSQL_DATABASE: antrian_rssa - # ports: - # - "3306:3306" - # volumes: - # - mysql_antrian_data:/var/lib/mysql - # healthcheck: - # test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - # interval: 10s - # timeout: 5s - # retries: 5 - # networks: - # - blueprint - - # # MySQL Medical Database - # mysql_medical: - # image: mysql:8.0 - # restart: unless-stopped - # environment: - # MYSQL_ROOT_PASSWORD: meninjar*RS54 - # MYSQL_USER: meninjardev - # MYSQL_PASSWORD: meninjar*RS54 - # MYSQL_DATABASE: healtcare_database - # ports: - # - "3307:3306" - # volumes: - # - mysql_medical_data:/var/lib/mysql - # healthcheck: - # test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - # interval: 10s - # timeout: 5s - # retries: 5 - # networks: - # - blueprint - - # Main Application app: build: context: . @@ -84,13 +7,17 @@ services: restart: unless-stopped ports: - "8080:8080" + volumes: + # Persist state.json dan logs supaya tidak hilang saat container restart + - ./data:/app/data + - ./logs:/app/logs environment: - # Server Configuration + # Server APP_ENV: production PORT: 8080 GIN_MODE: release - # Default Database Configuration (PostgreSQL) + # Default Database DB_CONNECTION: postgres DB_USERNAME: stim DB_PASSWORD: stim*RS54 @@ -99,7 +26,7 @@ services: DB_PORT: 5432 DB_SSLMODE: disable - # satudata Database Configuration (PostgreSQL) + # Satudata Database POSTGRES_SATUDATA_CONNECTION: postgres POSTGRES_SATUDATA_USERNAME: stim POSTGRES_SATUDATA_PASSWORD: stim*RS54 @@ -108,7 +35,16 @@ services: POSTGRES_SATUDATA_PORT: 5432 POSTGRES_SATUDATA_SSLMODE: disable - # Mongo Database + # SIMRS Database + POSTGRES_SIMRS_CONNECTION: postgres + POSTGRES_SIMRS_HOST: 10.10.123.163 + POSTGRES_SIMRS_PORT: 5432 + POSTGRES_SIMRS_USERNAME: simrs + POSTGRES_SIMRS_PASSWORD: simrs.rssa + POSTGRES_SIMRS_DATABASE: simrs + POSTGRES_SIMRS_SSLMODE: disable + + # MongoDB MONGODB_MONGOHL7_CONNECTION: mongodb MONGODB_MONGOHL7_HOST: 10.10.123.206 MONGODB_MONGOHL7_PORT: 27017 @@ -118,16 +54,7 @@ services: MONGODB_MONGOHL7_LOCAL: local MONGODB_MONGOHL7_SSLMODE: disable - # MYSQL Antrian Database - # MYSQL_ANTRIAN_CONNECTION: mysql - # MYSQL_ANTRIAN_HOST: mysql_antrian - # MYSQL_ANTRIAN_USERNAME: www-data - # MYSQL_ANTRIAN_PASSWORD: www-data - # MYSQL_ANTRIAN_DATABASE: antrian_rssa - # MYSQL_ANTRIAN_PORT: 3306 - # MYSQL_ANTRIAN_SSLMODE: disable - - # MYSQL Medical Database + # MySQL Medical MYSQL_MEDICAL_CONNECTION: mysql MYSQL_MEDICAL_HOST: 10.10.123.163 MYSQL_MEDICAL_USERNAME: meninjardev @@ -136,19 +63,28 @@ services: MYSQL_MEDICAL_PORT: 3306 MYSQL_MEDICAL_SSLMODE: disable - # Keycloak Configuration + # Keycloak KEYCLOAK_ISSUER: https://auth.rssa.top/realms/sandbox KEYCLOAK_AUDIENCE: nuxtsim-pendaftaran KEYCLOAK_JWKS_URL: https://auth.rssa.top/realms/sandbox/protocol/openid-connect/certs KEYCLOAK_ENABLED: true - # BPJS Configuration + # BPJS VClaim — untuk antrian/vclaim (tidak diubah) BPJS_BASEURL: https://apijkn.bpjs-kesehatan.go.id/vclaim-rest BPJS_CONSID: 5257 BPJS_USERKEY: 4cf1cbef8c008440bbe9ef9ba789e482 BPJS_SECRETKEY: 1bV363512D - # SatuSehat Configuration + # BPJS Aplicares — khusus sync tempat tidur + APLICARES_BPJS_BASEURL: https://apijkn.bpjs-kesehatan.go.id/ + APLICARES_BPJS_CONSID: 5257 + APLICARES_BPJS_SECRETKEY: 1bV363512D + APLICARES_KODE_PPK: 1323R001 + APLICARES_SYNC_INTERVAL: 5m + APLICARES_STATE_PATH: /app/data/state.json + APLICARES_DRY_RUN: "false" + + # SatuSehat BRIDGING_SATUSEHAT_ORG_ID: 100026555 BRIDGING_SATUSEHAT_FASYAKES_ID: 3573011 BRIDGING_SATUSEHAT_CLIENT_ID: l1ZgJGW6K5pnrqGUikWM7fgIoquA2AQ5UUG0U8WqHaq2VEyZ @@ -158,7 +94,7 @@ services: BRIDGING_SATUSEHAT_CONSENT_URL: https://api-satusehat.dto.kemkes.go.id/consent/v1 BRIDGING_SATUSEHAT_KFA_URL: https://api-satusehat.kemkes.go.id/kfa-v2 - # Swagger Configuration + # Swagger SWAGGER_TITLE: My Custom API Service SWAGGER_DESCRIPTION: This is a custom API service for managing various resources SWAGGER_VERSION: 2.0.0 @@ -167,28 +103,13 @@ services: SWAGGER_BASE_PATH: /api/v2 SWAGGER_SCHEMES: https - # API Configuration + # API API_TITLE: API Service UJICOBA API_DESCRIPTION: Dokumentation SWAGGER API_VERSION: 3.0.0 - # depends_on: - # psql_bp: - # condition: service_healthy - # mongodb: - # condition: service_started - # mysql_antrian: - # condition: service_healthy - # mysql_medical: - # condition: service_healthy networks: - goservice -# volumes: -# postgres_data: -# mongodb_data: -# mysql_antrian_data: -# mysql_medical_data: - networks: - goservice: + goservice: \ No newline at end of file diff --git a/docker-push.sh b/docker-push.sh new file mode 100644 index 0000000..c94d3eb --- /dev/null +++ b/docker-push.sh @@ -0,0 +1,27 @@ +!/bin/bash + +#get image name +remote_url=$(git remote get-url origin) +image=$(echo $remote_url | sed 's|https://||g; s|.git||g') + +#get branch name +branch_name=$(git rev-parse --abbrev-ref HEAD) +clean_branch_name=${branch_name##*/} + +#get timestamp for the tag +timestamp=$(date +%Y%m%d%H%M%S) + +tag=$image:$timestamp-$clean_branch_name +latest=$image:latest-$clean_branch_name + +#build image +docker build -t $tag . +docker tag $tag $latest + +#push to dockerhub +docker login git.rssa.top -u stim -p 4fde63b07906e7bfa6b3493d76d153a3981039b9 +docker push $tag +docker push $latest + +#remove dangling images +docker system prune -f \ No newline at end of file diff --git a/go.mod b/go.mod index 5dd4cb6..46b6619 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/gin-gonic/gin v1.10.1 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 - github.com/gorilla/websocket v1.5.1 github.com/jackc/pgx/v5 v5.7.2 // Ensure pgx is a direct dependency go.mongodb.org/mongo-driver v1.17.3 golang.org/x/crypto v0.41.0 @@ -27,7 +26,6 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.6 - github.com/tidwall/gjson v1.18.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -70,8 +68,6 @@ require ( github.com/montanaflynn/stats v0.7.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/go.sum b/go.sum index 7ecaabb..beb9ef6 100644 --- a/go.sum +++ b/go.sum @@ -90,8 +90,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -197,12 +195,6 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= diff --git a/internal/aplicare/aplicare.go b/internal/aplicare/aplicare.go new file mode 100644 index 0000000..06a0b63 --- /dev/null +++ b/internal/aplicare/aplicare.go @@ -0,0 +1,278 @@ +package aplicare + +import ( + "api-service/internal/config" + "api-service/internal/database" + "api-service/pkg/logger" + "context" + "encoding/json" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "net/http" + "os" + "sync" + "time" +) + +type AplicaresHandler struct { + syncer *Syncer + simrs *SimrsDB + validator *validator.Validate + logger logger.Logger + cfg *config.Config + once sync.Once + interval time.Duration +} + +type AplicaresHandlerConfig struct { + Config *config.Config + Logger logger.Logger + Validator *validator.Validate +} + +func NewAplicaresHandler(cfg AplicaresHandlerConfig) *AplicaresHandler { + statePath := os.Getenv("APLICARES_STATE_PATH") + if statePath == "" { + statePath = "./data/state.json" + } + + interval, err := time.ParseDuration(os.Getenv("APLICARES_SYNC_INTERVAL")) + if err != nil || interval <= 0 { + interval = 5 * time.Minute + } + + dryRun := os.Getenv("APLICARES_DRY_RUN") == "true" + _ = os.MkdirAll("./data", 0755) + + db := database.New(cfg.Config) + simrs := NewSimrsDB(db) + syncer := NewSyncer(simrs, cfg.Config, statePath, dryRun) + + h := &AplicaresHandler{ + syncer: syncer, + simrs: simrs, + validator: cfg.Validator, + logger: cfg.Logger, + cfg: cfg.Config, + interval: interval, + } + + if dryRun { + h.logger.Info("=== APLICARES DRY RUN — tidak kirim ke BPJS ===", nil) + } else { + h.logger.Info("=== APLICARES LIVE MODE ===", nil) + } + + return h +} + +// ============================================= +// SCHEDULER +// ============================================= + +func (h *AplicaresHandler) StartScheduler(ctx context.Context) { + h.once.Do(func() { + go h.runScheduler(ctx) + }) +} + +func (h *AplicaresHandler) runScheduler(ctx context.Context) { + h.logger.Info("Scheduler started", map[string]interface{}{ + "interval": h.interval.String(), + }) + + // Langsung sync sekali saat startup + h.runOnce(ctx) + + ticker := time.NewTicker(h.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + h.runOnce(ctx) + case <-ctx.Done(): + h.logger.Info("Scheduler stopped", nil) + return + } + } +} + +func (h *AplicaresHandler) runOnce(ctx context.Context) { + result, err := h.syncer.Sync(ctx) + if err != nil { + h.logger.Errorf("Sync error: %v", err) + return + } + h.logger.Info("Sync selesai", map[string]interface{}{ + "total": result.TotalRooms, + "changed": result.Changed, + "posted": result.Posted, + "dry_run": result.DryRun, + "errors": len(result.Errors), + }) +} + +// ============================================= +// HTTP HANDLERS +// ============================================= + +// GetBeds — GET /api/v1/aplicares/beds +func (h *AplicaresHandler) GetBeds(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + ruangans, err := h.simrs.GetRuangan(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + detailMap, err := h.simrs.GetBedDetails(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + beds := buildBedData(ruangans, detailMap) + c.JSON(http.StatusOK, gin.H{ + "total": len(beds), + "timestamp": time.Now().Format(time.RFC3339), + "data": beds, + }) +} + +func (h *AplicaresHandler) GetState(c *gin.Context) { + statePath := os.Getenv("APLICARES_STATE_PATH") + if statePath == "" { + statePath = "./data/state.json" + } + + state, err := LoadState(statePath) + if err != nil || state == nil { + c.JSON(http.StatusOK, gin.H{"message": "belum ada state", "data": nil}) + return + } + c.JSON(http.StatusOK, state) +} + +func (h *AplicaresHandler) TriggerSync(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second) + defer cancel() + + result, err := h.syncer.Sync(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} + +// CheckBPJS — GET /api/v1/aplicares/check-bpjs +func (h *AplicaresHandler) CheckBPJS(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + bpjs := NewBpjsClient(h.cfg.Bpjs) + start := time.Now() + kamars, err := bpjs.BacaKamar(ctx, 1, 60) + elapsed := time.Since(start).Milliseconds() + + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "status": "gagal", + "keterangan": "tidak bisa konek ke BPJS", + "error": err.Error(), + "response_ms": elapsed, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "keterangan": "BPJS bisa diakses", + "total_kamar": len(kamars), + "sample": kamars, + "response_ms": elapsed, + }) +} + +func (h *AplicaresHandler) GetRefKelas(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + bpjs := NewBpjsClient(h.cfg.Bpjs) + start := time.Now() + kelas, err := bpjs.GetRefKelas(ctx) + elapsed := time.Since(start).Milliseconds() + + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "status": "gagal", + "error": err.Error(), + "response_ms": elapsed, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "total": len(kelas), + "data": kelas, + "response_ms": elapsed, + }) +} + +func (h *AplicaresHandler) GetSyncLogs(c *gin.Context) { + content, err := os.ReadFile("./logs/sync.log") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": "belum ada log", + "data": []interface{}{}, + }) + return + } + + // Parse JSON lines + lines := splitLines(string(content)) + var logs []interface{} + for _, line := range lines { + if line == "" { + continue + } + var entry interface{} + if err := json.Unmarshal([]byte(line), &entry); err == nil { + logs = append(logs, entry) + } + } + + // Ambil 100 terakhir + if len(logs) > 100 { + logs = logs[len(logs)-100:] + } + + // Balik urutan — terbaru di atas + for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 { + logs[i], logs[j] = logs[j], logs[i] + } + + c.JSON(http.StatusOK, gin.H{ + "total": len(logs), + "data": logs, + }) +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} diff --git a/internal/aplicare/bpjs.go b/internal/aplicare/bpjs.go new file mode 100644 index 0000000..59d1dcc --- /dev/null +++ b/internal/aplicare/bpjs.go @@ -0,0 +1,270 @@ +package aplicare + +import ( + "api-service/internal/config" + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// ============================================= +// BPJS CLIENT +// Semua komunikasi ke BPJS Aplicares API ada di sini +// Header yang dikirim: X-cons-id, X-timestamp, X-signature SAJA +// Tidak pakai user_key — itu khusus VClaim, bukan Aplicares +// ============================================= + +type BpjsClient struct { + baseURL string + consID string + consSecret string + kodePPK string + httpClient *http.Client +} + +func NewBpjsClient(cfg config.BpjsConfig) *BpjsClient { + kodePPK := os.Getenv("APLICARES_KODE_PPK") + if kodePPK == "" { + kodePPK = "1323R001" + } + + // Pakai APLICARES_BPJS_* kalau ada, fallback ke BPJS_* dari config + baseURL := os.Getenv("APLICARES_BPJS_BASEURL") + if baseURL == "" { + baseURL = cfg.BaseURL + } + + consID := os.Getenv("APLICARES_BPJS_CONSID") + if consID == "" { + consID = cfg.ConsID + } + + secretKey := os.Getenv("APLICARES_BPJS_SECRETKEY") + if secretKey == "" { + secretKey = cfg.SecretKey + } + + timeout := cfg.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + + return &BpjsClient{ + baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/", + consID: strings.TrimSpace(consID), + consSecret: strings.TrimSpace(secretKey), + kodePPK: kodePPK, + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +// createHeaders — HANYA 3 header untuk Aplicares +func (c *BpjsClient) createHeaders() map[string]string { + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + message := c.consID + "&" + timestamp + + mac := hmac.New(sha256.New, []byte(c.consSecret)) + mac.Write([]byte(message)) + signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + return map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + "X-cons-id": c.consID, + "X-timestamp": timestamp, + "X-signature": signature, + } +} + +// ============================================= +// HTTP HELPERS +// ============================================= + +type bpjsResponse struct { + Metadata struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"metadata"` + Response struct { + List json.RawMessage `json:"list"` + } `json:"response"` +} + +func (c *BpjsClient) get(ctx context.Context, endpoint string) (json.RawMessage, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+endpoint, nil) + if err != nil { + return nil, err + } + for k, v := range c.createHeaders() { + req.Header.Set(k, v) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s gagal: %w", endpoint, err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP error: %d - %s", resp.StatusCode, string(body)) + } + + var r bpjsResponse + if err := json.Unmarshal(body, &r); err != nil { + return nil, fmt.Errorf("parse response gagal: %w", err) + } + if r.Metadata.Code != 1 { + return nil, fmt.Errorf("BPJS error code %d: %s", r.Metadata.Code, r.Metadata.Message) + } + return r.Response.List, nil +} + +func (c *BpjsClient) post(ctx context.Context, endpoint string, payload interface{}) (int, string, error) { + body, err := json.Marshal(payload) + if err != nil { + return 0, "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+endpoint, bytes.NewReader(body)) + if err != nil { + return 0, "", err + } + for k, v := range c.createHeaders() { + req.Header.Set(k, v) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, "", fmt.Errorf("POST %s gagal: %w", endpoint, err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return 0, "", fmt.Errorf("HTTP error: %d - %s", resp.StatusCode, string(respBody)) + } + + var r bpjsResponse + if err := json.Unmarshal(respBody, &r); err != nil { + return 0, "", fmt.Errorf("parse response gagal: %w", err) + } + return r.Metadata.Code, r.Metadata.Message, nil +} + +// ============================================= +// BPJS OPERATIONS +// ============================================= + +// BacaKamar membaca daftar kamar yang ada di BPJS +func (c *BpjsClient) BacaKamar(ctx context.Context, start, limit int) ([]map[string]interface{}, error) { + endpoint := fmt.Sprintf("aplicaresws/rest/bed/read/%s/%d/%d", c.kodePPK, start, limit) + raw, err := c.get(ctx, endpoint) + if err != nil { + return nil, err + } + var list []map[string]interface{} + if err := json.Unmarshal(raw, &list); err != nil { + return nil, err + } + return list, nil +} + +// PostKamar upsert kamar ke BPJS — coba update dulu, kalau gagal baru create +func (c *BpjsClient) PostKamar(ctx context.Context, bed BedData) error { + payload := map[string]interface{}{ + "kodekelas": bed.KodeKelas, + "koderuang": bed.KodeRuang, + "namaruang": bed.NamaRuang, + "kapasitas": bed.Kapasitas, + "tersedia": bed.Tersedia, + "tersediapria": bed.TersediaPria, + "tersediawanita": bed.TersediaWanita, + "tersediapriawanita": bed.TersediaPriaWanita, + } + + // Coba update dulu + code, _, err := c.post(ctx, fmt.Sprintf("aplicaresws/rest/bed/update/%s", c.kodePPK), payload) + if err == nil && code == 1 { + return nil + } + + // Fallback ke create + code, msg, err := c.post(ctx, fmt.Sprintf("aplicaresws/rest/bed/create/%s", c.kodePPK), payload) + if err != nil { + return fmt.Errorf("create kamar %s gagal: %w", bed.KodeRuang, err) + } + if code != 1 { + return fmt.Errorf("create kamar %s: %s", bed.KodeRuang, msg) + } + return nil +} + +// HapusKamar hapus kamar dari BPJS +func (c *BpjsClient) HapusKamar(ctx context.Context, kodekelas, koderuang string) error { + payload := map[string]string{ + "kodekelas": kodekelas, + "koderuang": koderuang, + } + code, msg, err := c.post(ctx, fmt.Sprintf("aplicaresws/rest/bed/delete/%s", c.kodePPK), payload) + if err != nil { + return fmt.Errorf("hapus kamar %s gagal: %w", koderuang, err) + } + if code != 1 { + return fmt.Errorf("hapus kamar %s: %s", koderuang, msg) + } + return nil +} + +// Flush hapus kamar di BPJS yang tidak ada lagi di SIMRS +func (c *BpjsClient) Flush(ctx context.Context, currentBeds []BedData) (int, []string) { + activeRooms := make(map[string]bool, len(currentBeds)) + for _, b := range currentBeds { + activeRooms[b.KodeRuang] = true + } + + bpjsKamars, err := c.BacaKamar(ctx, 1, 200) + if err != nil { + return 0, []string{fmt.Sprintf("BacaKamar flush gagal (tidak fatal): %v", err)} + } + + flushed := 0 + var errs []string + for _, kamar := range bpjsKamars { + kodeRuang, _ := kamar["koderuang"].(string) + kodeKelas, _ := kamar["kodekelas"].(string) + if !activeRooms[kodeRuang] { + if err := c.HapusKamar(ctx, kodeKelas, kodeRuang); err != nil { + errs = append(errs, fmt.Sprintf("hapus %s gagal: %v", kodeRuang, err)) + continue + } + flushed++ + } + } + return flushed, errs +} + +// GetRefKelas membaca referensi kelas dari BPJS +func (c *BpjsClient) GetRefKelas(ctx context.Context) ([]map[string]interface{}, error) { + raw, err := c.get(ctx, "aplicaresws/rest/ref/kelas") + if err != nil { + return nil, err + } + + var list []map[string]interface{} + if err := json.Unmarshal(raw, &list); err != nil { + return nil, err + } + return list, nil +} diff --git a/internal/aplicare/database.go b/internal/aplicare/database.go new file mode 100644 index 0000000..1e34c77 --- /dev/null +++ b/internal/aplicare/database.go @@ -0,0 +1,155 @@ +package aplicare + +import ( + "api-service/internal/database" + "context" + "database/sql" + "fmt" +) + +type Ruangan struct { + No int `db:"no"` + Nama string `db:"nama"` + JumlahTT int `db:"jumlah_tt"` + KodeRuang sql.NullString `db:"kode_aplicare"` // diisi manual, dikirim ke BPJS + NamaRuang sql.NullString `db:"nama_ruang"` // diisi manual, dikirim ke BPJS + KelasRuang sql.NullString `db:"kode_kelas"` // diisi manual, dikirim ke BPJS +} + +// BedDetail adalah data dari tabel m_detail_tempat_tidur +// Setiap row = 1 bed yang sedang terisi +// idxruang bertipe varchar di DB, relasi ke m_ruang.no (integer) +type BedDetail struct { + IdxRuang string `db:"idxruang"` +} + +// ============================================= +// SIMRS READER +// ============================================= + +type SimrsDB struct { + db database.Service +} + +func NewSimrsDB(db database.Service) *SimrsDB { + return &SimrsDB{db: db} +} + +// GetRuangan membaca semua ruangan aktif dari m_ruang +// Hanya ruangan yang sudah di-mapping manual (kode_ruang + kode_kelas tidak kosong) +func (s *SimrsDB) GetRuangan(ctx context.Context) ([]Ruangan, error) { + db, err := s.db.GetDB("simrs") + if err != nil { + return nil, fmt.Errorf("koneksi simrs gagal: %w", err) + } + + query := ` + SELECT no, nama, jumlah_tt,kode_aplicare, nama_ruang, kode_kelas + FROM m_ruang + where st_aktif = 1 AND kode_aplicare IS NOT NULL + ORDER BY no + ` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("query m_ruang gagal: %w", err) + } + defer rows.Close() + + var result []Ruangan + for rows.Next() { + var r Ruangan + if err := rows.Scan( + &r.No, &r.Nama, &r.JumlahTT, + &r.KodeRuang, &r.NamaRuang, &r.KelasRuang, + ); err != nil { + return nil, fmt.Errorf("scan m_ruang gagal: %w", err) + } + result = append(result, r) + } + return result, rows.Err() +} + +// GetBedDetails membaca semua bed yang terisi dari m_detail_tempat_tidur +// Return map[idxruang][]BedDetail — dikelompokkan per ruangan +// Setiap row = 1 bed terisi, COUNT per idxruang = total terisi +func (s *SimrsDB) GetBedDetails(ctx context.Context) (map[string][]BedDetail, error) { + db, err := s.db.GetDB("simrs") + if err != nil { + return nil, fmt.Errorf("koneksi simrs gagal: %w", err) + } + + query := ` + SELECT idxruang + FROM m_detail_tempat_tidur + WHERE status IN (1, 5) + ORDER BY idxruang + ` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("query m_detail_tempat_tidur gagal: %w", err) + } + defer rows.Close() + + result := make(map[string][]BedDetail) + for rows.Next() { + var d BedDetail + if err := rows.Scan(&d.IdxRuang); err != nil { + return nil, fmt.Errorf("scan m_detail_tempat_tidur gagal: %w", err) + } + result[d.IdxRuang] = append(result[d.IdxRuang], d) + } + return result, rows.Err() +} + +// ============================================= +// TRANSFORM +// ============================================= + +// BedData adalah hasil agregasi per ruangan — siap kirim ke BPJS +type BedData struct { + No int `json:"no"` + KodeKelas string `json:"kodekelas"` + KodeRuang string `json:"koderuang"` + NamaRuang string `json:"namaruang"` + Kapasitas int `json:"kapasitas"` + Tersedia int `json:"tersedia"` + TersediaPria int `json:"tersediapria"` + TersediaWanita int `json:"tersediawanita"` + TersediaPriaWanita int `json:"tersediapriawanita"` +} + +// buildBedData mengubah data SIMRS menjadi BedData siap kirim ke BPJS +// tersedia = jumlah_tt - COUNT(row di m_detail per ruangan) +// karena setiap row di m_detail = 1 bed yang terisi +func buildBedData(ruangans []Ruangan, detailMap map[string][]BedDetail) []BedData { + var result []BedData + for _, r := range ruangans { + // detailMap berisi semua bed yang tidak tersedia (status != 0) + // tersedia = jumlah_tt - jumlah yang tidak tersedia + terisi := len(detailMap[fmt.Sprintf("%d", r.No)]) + tersedia := r.JumlahTT - terisi + if tersedia < 0 { + tersedia = 0 + } + + // Skip ruangan dengan kapasitas 0 — tidak valid untuk dikirim ke BPJS + if r.JumlahTT == 0 { + continue + } + + result = append(result, BedData{ + No: r.No, + KodeKelas: r.KelasRuang.String, + KodeRuang: r.KodeRuang.String, + NamaRuang: r.NamaRuang.String, + Kapasitas: r.JumlahTT, + Tersedia: tersedia, + TersediaPria: 0, + TersediaWanita: 0, + TersediaPriaWanita: tersedia, + }) + } + return result +} diff --git a/internal/aplicare/state.go b/internal/aplicare/state.go new file mode 100644 index 0000000..3807a4a --- /dev/null +++ b/internal/aplicare/state.go @@ -0,0 +1,141 @@ +package aplicare + +import ( + "encoding/json" + "os" + "time" +) + +// RoomSnapshot adalah snapshot nilai per ruangan +type RoomSnapshot struct { + Kapasitas int `json:"kapasitas"` + Tersedia int `json:"tersedia"` + TersediaPria int `json:"tersedia_pria"` + TersediaWanita int `json:"tersedia_wanita"` + TersediaPriaWanita int `json:"tersedia_pria_wanita"` +} + +// RoomState adalah state per ruangan dengan perbandingan old vs new +type RoomState struct { + KodeRuang string `json:"kode_ruang"` + KodeKelas string `json:"kodekelas"` + NamaRuang string `json:"nama_ruang"` + OldValue RoomSnapshot `json:"old_value"` + NewValue RoomSnapshot `json:"new_value"` + Changed bool `json:"changed"` + LastSynced string `json:"last_synced"` +} + +// State adalah struktur utama state.json +type State struct { + LastUpdated string `json:"last_updated"` + Rooms map[string]RoomState `json:"rooms"` // key: kode_ruang +} + +// LoadState membaca state.json dari disk +// Mengembalikan empty State (bukan error) jika file belum ada +func LoadState(path string) (*State, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return &State{Rooms: make(map[string]RoomState)}, nil + } + if err != nil { + return nil, err + } + + var state State + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + + if state.Rooms == nil { + state.Rooms = make(map[string]RoomState) + } + + return &state, nil +} + +// SaveState menulis state ke disk +func SaveState(path string, state *State) error { + state.LastUpdated = time.Now().Format(time.RFC3339) + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +// ComputeDiff membandingkan data SIMRS terbaru dengan state lama. +// Mengembalikan state baru dengan field Changed=true hanya untuk yang berubah. +func ComputeDiff(old *State, current []BedData) *State { + newState := &State{ + Rooms: make(map[string]RoomState), + } + + now := time.Now().Format(time.RFC3339) + + for _, bed := range current { + newSnap := RoomSnapshot{ + Kapasitas: bed.Kapasitas, + Tersedia: bed.Tersedia, + TersediaPria: bed.TersediaPria, + TersediaWanita: bed.TersediaWanita, + TersediaPriaWanita: bed.TersediaPriaWanita, + } + + oldRoom, exists := old.Rooms[bed.KodeRuang] + oldSnap := oldRoom.NewValue + if !exists { + + oldSnap = RoomSnapshot{} + } + + changed := !snapshotEqual(oldSnap, newSnap) + + // Kalau sebelumnya sudah ada lastSynced, pertahankan + lastSynced := oldRoom.LastSynced + if changed { + lastSynced = now + } + + newState.Rooms[bed.KodeRuang] = RoomState{ + KodeRuang: bed.KodeRuang, + KodeKelas: bed.KodeKelas, + NamaRuang: bed.NamaRuang, + OldValue: oldSnap, + NewValue: newSnap, + Changed: changed, + LastSynced: lastSynced, + } + } + + return newState +} + +// GetChangedBeds mengembalikan hanya BedData yang berubah dari state +func GetChangedBeds(state *State, allBeds []BedData) []BedData { + changedMap := make(map[string]bool) + for kode, room := range state.Rooms { + if room.Changed { + changedMap[kode] = true + } + } + + var changed []BedData + for _, bed := range allBeds { + if changedMap[bed.KodeRuang] { + changed = append(changed, bed) + } + } + return changed +} + +func snapshotEqual(a, b RoomSnapshot) bool { + return a.Kapasitas == b.Kapasitas && + a.Tersedia == b.Tersedia && + a.TersediaPria == b.TersediaPria && + a.TersediaWanita == b.TersediaWanita && + a.TersediaPriaWanita == b.TersediaPriaWanita +} diff --git a/internal/aplicare/syncer.go b/internal/aplicare/syncer.go new file mode 100644 index 0000000..13b9459 --- /dev/null +++ b/internal/aplicare/syncer.go @@ -0,0 +1,158 @@ +package aplicare + +import ( + "api-service/internal/config" + "context" + "fmt" + "time" +) + +type SyncResult struct { + RunAt string `json:"run_at"` + TotalRooms int `json:"total_rooms"` + Changed int `json:"changed"` + Posted int `json:"posted"` + Errors []string `json:"errors,omitempty"` + DryRun bool `json:"dry_run"` +} + +type Syncer struct { + simrs *SimrsDB + bpjs *BpjsClient + statePath string + dryRun bool +} + +func NewSyncer(simrs *SimrsDB, cfg *config.Config, statePath string, dryRun bool) *Syncer { + return &Syncer{ + simrs: simrs, + bpjs: NewBpjsClient(cfg.Bpjs), + statePath: statePath, + dryRun: dryRun, + } +} + +func (s *Syncer) Sync(ctx context.Context) (*SyncResult, error) { + result := &SyncResult{ + RunAt: time.Now().Format(time.RFC3339), + DryRun: s.dryRun, + } + + // 1. Baca dari SIMRS + ruangans, err := s.simrs.GetRuangan(ctx) + if err != nil { + return nil, fmt.Errorf("baca m_ruang gagal: %w", err) + } + + detailMap, err := s.simrs.GetBedDetails(ctx) + if err != nil { + return nil, fmt.Errorf("baca m_detail gagal: %w", err) + } + + // 2. Transform + beds := buildBedData(ruangans, detailMap) + result.TotalRooms = len(beds) + + // 3. Diff vs state lama + oldState, err := LoadState(s.statePath) + if err != nil || oldState == nil { + oldState = &State{Rooms: make(map[string]RoomState)} + } + + newState := ComputeDiff(oldState, beds) + changedBeds := GetChangedBeds(newState, beds) + result.Changed = len(changedBeds) + + // 4. Dry run — tampilkan perubahan di terminal + if s.dryRun { + if len(changedBeds) == 0 { + fmt.Println("[DRY RUN] Tidak ada perubahan") + } else { + fmt.Printf("[DRY RUN] %d ruangan berubah:\n", len(changedBeds)) + for _, bed := range changedBeds { + old := newState.Rooms[bed.KodeRuang].OldValue + fmt.Printf(" → %-30s | kelas: %-6s | kapasitas: %d | tersedia: %d → %d\n", + bed.NamaRuang, + bed.KodeKelas, + bed.Kapasitas, + old.Tersedia, + bed.Tersedia, + ) + result.Posted++ + } + } + if err := SaveState(s.statePath, newState); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("simpan state gagal: %v", err)) + } + WriteBatchLog(result) + return result, nil + } + + // 5. POST ke BPJS hanya yang berubah + if len(changedBeds) > 0 { + fmt.Printf("[SYNC] %d ruangan akan dikirim ke BPJS:\n", len(changedBeds)) + } + + for _, bed := range changedBeds { + if bed.Kapasitas == 0 { + continue + } + + oldTersedia := newState.Rooms[bed.KodeRuang].OldValue.Tersedia + + start := time.Now() + err := s.bpjs.PostKamar(ctx, bed) + elapsed := time.Since(start).Milliseconds() + + if err != nil { + msg := fmt.Sprintf("POST %s gagal: %v", bed.KodeRuang, err) + result.Errors = append(result.Errors, msg) + fmt.Printf(" → %-30s | kelas: %-6s | tersedia: %d → %d | [GAGAL] %v\n", + bed.NamaRuang, bed.KodeKelas, oldTersedia, bed.Tersedia, err) + WriteLog(SyncLog{ + KodeRuang: bed.KodeRuang, + NamaRuang: bed.NamaRuang, + KodeKelas: bed.KodeKelas, + Kapasitas: bed.Kapasitas, + Tersedia: bed.Tersedia, + Action: "post", + Status: "gagal", + Error: err.Error(), + ResponseMs: elapsed, + }) + continue + } + + fmt.Printf(" → %-30s | kelas: %-6s | tersedia: %d → %d | [SUKSES] %dms\n", + bed.NamaRuang, bed.KodeKelas, oldTersedia, bed.Tersedia, elapsed) + WriteLog(SyncLog{ + KodeRuang: bed.KodeRuang, + NamaRuang: bed.NamaRuang, + KodeKelas: bed.KodeKelas, + Kapasitas: bed.Kapasitas, + Tersedia: bed.Tersedia, + Action: "post", + Status: "sukses", + ResponseMs: elapsed, + }) + + result.Posted++ + + if room, ok := newState.Rooms[bed.KodeRuang]; ok { + room.OldValue = room.NewValue + room.Changed = false + room.LastSynced = time.Now().Format(time.RFC3339) + newState.Rooms[bed.KodeRuang] = room + } + } + + // 6. Simpan state + if err := SaveState(s.statePath, newState); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("simpan state gagal: %v", err)) + } + + // 7. Tulis ringkasan ke log + WriteBatchLog(result) + + return result, nil +} diff --git a/internal/aplicare/synclog.go b/internal/aplicare/synclog.go new file mode 100644 index 0000000..5d5bdc2 --- /dev/null +++ b/internal/aplicare/synclog.go @@ -0,0 +1,96 @@ +package aplicare + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +type SyncLog struct { + Timestamp string `json:"timestamp"` + KodeRuang string `json:"kode_ruang,omitempty"` + NamaRuang string `json:"nama_ruang,omitempty"` + KodeKelas string `json:"kode_kelas,omitempty"` + Kapasitas int `json:"kapasitas,omitempty"` + Tersedia int `json:"tersedia,omitempty"` + Action string `json:"action"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + ResponseMs int64 `json:"response_ms,omitempty"` +} + +var logPath = "./logs/sync.log" + +func init() { + _ = os.MkdirAll("./logs", 0755) +} + +// WriteLog menulis 1 entry log ke sync.log +func WriteLog(entry SyncLog) { + entry.Timestamp = time.Now().Format(time.RFC3339) + writeToFile(entry) +} + +// WriteBatchLog menulis ringkasan 1 run sync +func WriteBatchLog(result *SyncResult) { + if result == nil { + return + } + + status := "sukses" + if len(result.Errors) > 0 { + status = "partial" + } + if result.Posted == 0 && result.Changed > 0 { + status = "gagal" + } + + summary := map[string]interface{}{ + "timestamp": time.Now().Format(time.RFC3339), + "action": "batch_sync", + "total_rooms": result.TotalRooms, + "changed": result.Changed, + "posted": result.Posted, + "dry_run": result.DryRun, + "status": status, + "errors": result.Errors, + } + + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + + line, _ := json.Marshal(summary) + _, _ = f.Write(append(line, '\n')) + + // Rotasi log — jaga ukuran file max 5MB + rotateLogs() +} + +func writeToFile(entry SyncLog) { + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + fmt.Printf("gagal buka log file: %v\n", err) + return + } + defer f.Close() + + line, _ := json.Marshal(entry) + _, _ = f.Write(append(line, '\n')) +} + +// rotateLogs — kalau file > 5MB, rename jadi sync.log.old +func rotateLogs() { + info, err := os.Stat(logPath) + if err != nil { + return + } + + // 5MB + if info.Size() > 5*1024*1024 { + _ = os.Rename(logPath, logPath+".old") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index f34deb4..a5f6c49 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,7 +4,6 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/base64" - "encoding/hex" "fmt" "log" "os" @@ -112,24 +111,20 @@ type SatuSehatConfig struct { // return cfg.ConsID, cfg.SecretKey, cfg.UserKey, fmt.Sprint(tstamp), xSignature // } func (cfg BpjsConfig) SetHeader() (string, string, string, string, string) { - timenow := time.Now().UTC() - t, err := time.Parse(time.RFC3339, "1970-01-01T00:00:00Z") - if err != nil { - log.Fatal(err) - } + tstamp := time.Now().Unix() + message := strings.TrimSpace(cfg.ConsID) + "&" + fmt.Sprint(tstamp) - tstamp := timenow.Unix() - t.Unix() - secret := []byte(cfg.SecretKey) - message := []byte(cfg.ConsID + "&" + fmt.Sprint(tstamp)) - hash := hmac.New(sha256.New, secret) - hash.Write(message) + mac := hmac.New(sha256.New, []byte(strings.TrimSpace(cfg.SecretKey))) + mac.Write([]byte(message)) - // to lowercase hexits - hex.EncodeToString(hash.Sum(nil)) - // to base64 - xSignature := base64.StdEncoding.EncodeToString(hash.Sum(nil)) + // BENAR: langsung base64 dari raw bytes (bukan hex dulu) + xSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) - return cfg.ConsID, cfg.SecretKey, cfg.UserKey, fmt.Sprint(tstamp), xSignature + return strings.TrimSpace(cfg.ConsID), + strings.TrimSpace(cfg.SecretKey), + strings.TrimSpace(cfg.UserKey), + fmt.Sprint(tstamp), + xSignature } type ConfigBpjs struct { diff --git a/internal/handlers/antreanbpjs/antreanbpjs.go b/internal/handlers/antreanbpjs/antreanbpjs.go new file mode 100644 index 0000000..9962bcd --- /dev/null +++ b/internal/handlers/antreanbpjs/antreanbpjs.go @@ -0,0 +1,68 @@ +package antreanbpjs + +import ( + "api-service/internal/config" + "api-service/internal/database" + services "api-service/internal/services/bpjs" + "api-service/pkg/logger" + "context" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "log" + "net/http" + "sync" + "time" +) + +type VClaimHandler struct { + service services.VClaimService + validator *validator.Validate + logger logger.Logger + config config.BpjsConfig + db database.Service + once sync.Once +} + +type VClaimHandlerConfig struct { + Config *config.Config + Logger logger.Logger + Validator *validator.Validate + db database.Service +} + +// NewVClaimHandler creates a new VClaimHandler +func NewVClaimHandler(cfg VClaimHandlerConfig) *VClaimHandler { + return &VClaimHandler{ + service: services.NewService(cfg.Config.Bpjs), + db: database.New(cfg.Config), + validator: cfg.Validator, + logger: cfg.Logger, + config: cfg.Config.Bpjs, + } +} + +func (h *VClaimHandler) GetPoli(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) + defer cancel() + + h.logger.Info("Processing Get Applicare request", map[string]interface{}{ + "endpoint": "/aplicaresws/rest/ref/kelas", + }) + endpoint := "antrean/aplicaresws/rest/bed/read/1323R001/1/1" + + resp, err := h.service.GetRawResponse(ctx, endpoint) + if err != nil { + h.logger.Errorf("Error in Get Applicare endpoint %s: %s", endpoint, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to connect to BPJS API", + "details": err.Error(), + }) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *VClaimHandler) Gethealth(c *gin.Context) { + log.Println("halo") + +} diff --git a/internal/handlers/peserta/peserta.go b/internal/handlers/peserta/peserta.go deleted file mode 100644 index 585acf5..0000000 --- a/internal/handlers/peserta/peserta.go +++ /dev/null @@ -1,604 +0,0 @@ -// Package peserta handles Peserta BPJS services -// Generated on: 2025-09-07 11:01:18 -package handlers - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - "sync" - "time" - - "api-service/internal/config" - "api-service/internal/database" - "api-service/internal/models" - "api-service/internal/models/vclaim/peserta" - services "api-service/internal/services/bpjs" - "api-service/pkg/logger" - - "github.com/gin-gonic/gin" - "github.com/go-playground/validator/v10" - "github.com/google/uuid" -) - -// PesertaHandler handles Peserta BPJS services -type PesertaHandler struct { - service services.VClaimService - db database.Service - validator *validator.Validate - logger logger.Logger - config config.BpjsConfig -} - -// PesertaHandlerConfig contains configuration for PesertaHandler -type PesertaHandlerConfig struct { - Config *config.Config - Logger logger.Logger - Validator *validator.Validate -} - -// NewPesertaHandler creates a new PesertaHandler -func NewPesertaHandler(cfg PesertaHandlerConfig) *PesertaHandler { - return &PesertaHandler{ - db: database.New(cfg.Config), - service: services.NewService(cfg.Config.Bpjs), - validator: cfg.Validator, - logger: cfg.Logger, - config: cfg.Config.Bpjs, - } -} - -// min returns the minimum of two integers -func min(a, b int) int { - if a < b { - return a - } - return b -} - -// cleanResponse removes invalid characters and BOM from the response string -func cleanResponse(resp string) string { - // Remove UTF-8 BOM - // Konversi string ke byte slice untuk pengecekan BOM - data := []byte(resp) - // Cek dan hapus semua jenis representasi UTF-8 BOM - // 1. Byte sequence: EF BB BF - if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { - data = data[3:] - } - // 2. Unicode character: U+FEFF - if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { - data = data[3:] - } - // 3. Zero Width No-Break Space (Unicode) - if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { - data = data[3:] - } - // 4. Representasi heksadesimal lainnya - if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { - data = data[3:] - } - // Konversi kembali ke string - resp = string(data) - - // Hapus karakter null - // Hapus semua karakter kontrol ASCII (0-31) kecuali whitespace yang valid - controlChars := []string{ - "\x00", // Null character - "\x01", // Start of Heading - "\x02", // Start of Text - "\x03", // End of Text - "\x04", // End of Transmission (EOT) - "\x05", // Enquiry - "\x06", // Acknowledge - "\x07", // Bell - "\x08", // Backspace - "\x0B", // Vertical Tab - "\x0C", // Form Feed - "\x0E", // Shift Out - "\x0F", // Shift In - "\x10", // Data Link Escape - "\x11", // Device Control 1 - "\x12", // Device Control 2 - "\x13", // Device Control 3 - "\x14", // Device Control 4 - "\x15", // Negative Acknowledge - "\x16", // Synchronous Idle - "\x17", // End of Transmission Block - "\x18", // Cancel - "\x19", // End of Medium - "\x1A", // Substitute - "\x1B", // Escape - "\x1C", // File Separator - "\x1D", // Group Separator - "\x1E", // Record Separator - "\x1F", // Unit Separator - } - - for _, char := range controlChars { - resp = strings.ReplaceAll(resp, char, "") - } - - // Hapus karakter invalid termasuk backtick - invalidChars := []string{ - "¢", // Cent sign - "\u00a2", // Cent sign Unicode - "\u0080", // Control character - "`", // Backtick - "´", // Acute accent - "‘", // Left single quote - "’", // Right single quote - "“", // Left double quote - "”", // Right double quote - } - - for _, char := range invalidChars { - resp = strings.ReplaceAll(resp, char, "") - } - // Gunakan buffer pool untuk efisiensi memori - var bufPool = sync.Pool{ - New: func() interface{} { - return &strings.Builder{} - }, - } - buf := bufPool.Get().(*strings.Builder) - defer func() { - buf.Reset() - bufPool.Put(buf) - }() - - // Definisikan karakter yang diperbolehkan - allowedChars := map[rune]bool{ - '\n': true, '\r': true, '\t': true, - // Tambahkan karakter non-ASCII yang diperbolehkan jika adafalse - // Contoh: - // Latin-1 Supplement - // ASCII printable (32-126) kecuali backtick (96) - '!': true, '"': true, '#': true, '$': true, '%': true, '&': true, - '\'': true, '(': true, ')': true, '*': true, '+': true, ',': true, - '-': true, '.': true, '/': true, '0': true, '1': true, '2': true, - '3': true, '4': true, '5': true, '6': true, '7': true, '8': true, - '9': true, ':': true, ';': true, '<': true, '=': true, '>': true, - '?': true, '@': true, 'A': true, 'B': true, 'C': true, 'D': true, - 'E': true, 'F': true, 'G': true, 'H': true, 'I': true, 'J': true, - 'K': true, 'L': true, 'M': true, 'N': true, 'O': true, 'P': true, - 'Q': true, 'R': true, 'S': true, 'T': true, 'U': true, 'V': true, - 'W': true, 'X': true, 'Y': true, 'Z': true, '[': true, '\\': true, - ']': true, '^': true, '_': true, 'a': true, 'b': true, 'c': true, - 'd': true, 'e': true, 'f': true, 'g': true, 'h': true, 'i': true, - 'j': true, 'k': true, 'l': true, 'm': true, 'n': true, 'o': true, - 'p': true, 'q': true, 'r': true, 's': true, 't': true, 'u': true, - 'v': true, 'w': true, 'x': true, 'y': true, 'z': true, '{': true, - '|': true, '}': true, '~': true, - - // Latin-1 Supplement - '¡': true, '¢': true, '£': true, '¤': true, '¥': true, '¦': true, - '§': true, '¨': true, '©': true, 'ª': true, '«': true, '¬': true, - '®': true, '¯': true, '°': true, '±': true, '²': true, '³': true, - '´': true, 'µ': true, '¶': true, '·': true, '¸': true, '¹': true, - 'º': true, '»': true, '¼': true, '½': true, '¾': true, '¿': true, - - // Huruf Latin dengan diakritik (Lowercase) - 'á': true, 'é': true, 'í': true, 'ó': true, 'ú': true, 'ý': true, 'þ': true, - 'à': true, 'è': true, 'ì': true, 'ò': true, 'ù': true, - 'â': true, 'ê': true, 'î': true, 'ô': true, 'û': true, - 'ä': true, 'ë': true, 'ï': true, 'ö': true, 'ü': true, 'ÿ': true, - 'ã': true, 'õ': true, 'ñ': true, 'ç': true, - 'ā': true, 'ē': true, 'ī': true, 'ō': true, 'ū': true, - 'ă': true, 'đ': true, 'ħ': true, 'ij': true, 'ĸ': true, 'ł': true, - 'ŋ': true, 'œ': true, 'ŧ': true, 'ß': true, - - // Huruf Latin dengan diakritik (Uppercase) - 'Á': true, 'É': true, 'Í': true, 'Ó': true, 'Ú': true, 'Ý': true, 'Þ': true, - 'À': true, 'È': true, 'Ì': true, 'Ò': true, 'Ù': true, - 'Â': true, 'Ê': true, 'Î': true, 'Ô': true, 'Û': true, - 'Ä': true, 'Ë': true, 'Ï': true, 'Ö': true, 'Ü': true, - 'Ã': true, 'Õ': true, 'Ñ': true, 'Ç': true, - 'Ā': true, 'Ē': true, 'Ī': true, 'Ō': true, 'Ū': true, - 'Ă': true, 'Đ': true, 'Ħ': true, 'IJ': true, 'Ł': true, - 'Ŋ': true, 'Œ': true, 'Ŧ': true, 'ẞ': true, - - // Karakter Nordik dan lainnya - 'Å': true, 'å': true, 'Æ': true, 'æ': true, 'Ø': true, 'ø': true, - 'ſ': true, 'ʼn': true, 'ŀ': true, - - // Tanda baca dan simbol matematika - '‐': true, '–': true, '—': true, '―': true, '‖': true, '‗': true, - '†': true, '‡': true, '•': true, '‣': true, '․': true, '‥': true, - '…': true, '‧': true, '‰': true, '′': true, '″': true, '‴': true, - '‵': true, '‶': true, '‷': true, '‸': true, '‹': true, '›': true, - '※': true, - - // Simbol mata uang (hanya yang umum) - '€': true, '₹': true, - - // Karakter lain yang mungkin diperlukan - } - - // Filter karakter menggunakan buffer pool - for _, r := range resp { - if r < 128 || allowedChars[r] { - buf.WriteRune(r) - } - } - // Trim whitespace - result := strings.TrimSpace(buf.String()) - return result -} - -// extractCode extracts the code field from metaData using reflection -// func extractCode(metaData interface{}) interface{} { -// v := reflect.ValueOf(metaData) -// switch v.Kind() { -// case reflect.Struct: -// codeField := v.FieldByName("Code") -// if codeField.IsValid() { -// return codeField.Interface() -// } -// case reflect.Map: -// if m, ok := metaData.(map[string]interface{}); ok { -// return m["code"] -// } -// case reflect.String: -// var metaMap map[string]interface{} -// if err := json.Unmarshal([]byte(metaData.(string)), &metaMap); err == nil { -// return metaMap["code"] -// } -// } -// return nil -// } - -// parseHTTPStatusCode extracts HTTP status code from error message -func parseHTTPStatusCode(errMsg string) int { - if strings.Contains(errMsg, "HTTP error:") { - parts := strings.Split(errMsg, "HTTP error:") - if len(parts) > 1 { - statusPart := strings.TrimSpace(parts[1]) - if statusCode, err := strconv.Atoi(strings.Fields(statusPart)[0]); err == nil { - return statusCode - } - } - } - return 500 // Default to internal server error -} -func (h *PesertaHandler) isValidJSON(str string) bool { - var js interface{} - return json.Unmarshal([]byte(str), &js) == nil -} - -// GetBynik godoc -// @Summary Get Bynik data -// @Description Get participant eligibility information by NIK -// @Tags Peserta -// @Accept json -// @Produce json -// @Security ApiKeyAuth -// @Param X-Request-ID header string false "Request ID for tracking" -// @Param nik path string true "nik" example("example_value") -// @Success 200 {object} peserta.PesertaResponse "Successfully retrieved Bynik data" -// @Failure 400 {object} models.ErrorResponseBpjs "Bad request - invalid parameters" -// @Failure 401 {object} models.ErrorResponseBpjs "Unauthorized - invalid API credentials" -// @Failure 404 {object} models.ErrorResponseBpjs "Not found - Bynik not found" -// @Failure 500 {object} models.ErrorResponseBpjs "Internal server error" -// @Router /Peserta/nik/:nik [get] -func (h *PesertaHandler) GetBynik(c *gin.Context) { - ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) - defer cancel() - - // Generate request ID if not present - requestID := c.GetHeader("X-Request-ID") - if requestID == "" { - requestID = uuid.New().String() - c.Header("X-Request-ID", requestID) - } - - // Get database connection - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logger.Error("Database connection failed", map[string]interface{}{ - "error": err.Error(), - "request_id": requestID, - }) - c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{ - Status: "error", - Message: "Database connection failed", - RequestID: requestID, - }) - return - } - // Note: dbConn is available for future database operations (e.g., caching, logging) - _ = dbConn // Prevent unused variable warning - - // Context Paramaeter - now := time.Now() - dateStr := now.Format("2006-01-02") - fmt.Println("Date (YYYY-MM-DD):", dateStr) - h.logger.Info("Processing GetBynik request", map[string]interface{}{ - "request_id": requestID, - "endpoint": "/Peserta/nik/:nik/tglSEP/" + dateStr, - "nik": c.Param("nik"), - }) - - // Extract path parameters - - nik := c.Param("nik") - if nik == "" || nik == ":nik" { - - h.logger.Error("Missing required parameter nik", map[string]interface{}{ - "request_id": requestID, - }) - - c.JSON(http.StatusBadRequest, models.ErrorResponseBpjs{ - Status: "error", - Message: "Parameter NIK Masih Kosong / Isi Dahulu NIK!", - RequestID: requestID, - }) - return - } - var response peserta.PesertaResponse - - endpoint := "/Peserta/nik/:nik/tglSEP/" + dateStr - - endpoint = strings.Replace(endpoint, ":nik", nik, 1) - - resp, err := h.service.GetRawResponse(ctx, endpoint) - - if err != nil { - // Check if error message contains 404 status code - if strings.Contains(err.Error(), "HTTP error: 404") { - h.logger.Error("Bynik not found", map[string]interface{}{ - "error": err.Error(), - "request_id": requestID, - }) - - c.JSON(http.StatusNotFound, models.ErrorResponseBpjs{ - Status: "error", - Message: "Bynik not found", - RequestID: requestID, - }) - return - } - - h.logger.Error("Failed to get Bynik", map[string]interface{}{ - "error": err.Error(), - "request_id": requestID, - }) - - c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{ - Status: "error", - Message: "Internal server error", - RequestID: requestID, - }) - return - } - - // Map the raw response - response.MetaData = resp.MetaData - if resp.Response != nil { - response.Data = &peserta.PesertaData{} - if respStr, ok := resp.Response.(string); ok { - // Decrypt the response string - consID, secretKey, _, tstamp, _ := h.config.SetHeader() - decryptedResp, err := services.ResponseVclaim(respStr, consID+secretKey+tstamp) - if err != nil { - - h.logger.Error("Failed to decrypt response", map[string]interface{}{ - "error": err.Error(), - "request_id": requestID, - }) - - } else { - // Clean the decrypted response - cleanedResp := cleanResponse(decryptedResp) - if h.isValidJSON(cleanedResp) { - // Unmarshal kembali setelah dibersihkan - err = json.Unmarshal([]byte(cleanedResp), response.Data) - if err != nil { - h.logger.Warn("Failed to unmarshal decrypted response", map[string]interface{}{ - "error": err.Error(), - "request_id": requestID, - "response_preview": cleanedResp[:min(100, len(cleanedResp))], // Log first 100 chars for debugging - }) - // Set Data to nil if unmarshal fails to avoid sending empty struct - response.Data = nil - } - } else { - h.logger.Warn("Invalid JSON in data, storing as string", map[string]interface{}{ - "request_id": requestID, - "response": cleanedResp, - }) - response.Data.RawResponse = cleanedResp - } - - } - } else if respMap, ok := resp.Response.(map[string]interface{}); ok { - // Response is already unmarshaled JSON - if dataMap, exists := respMap["peserta"]; exists { - dataBytes, _ := json.Marshal(dataMap) - json.Unmarshal(dataBytes, response.Data) - } else { - // Try to unmarshal the whole response - respBytes, _ := json.Marshal(resp.Response) - json.Unmarshal(respBytes, response.Data) - } - } - } - - // Ensure response has proper fields - response.Status = "success" - response.RequestID = requestID - // Ambil status code dari metaData.code - var statusCode int - code := models.ExtractCode(response.MetaData) - if code != nil { - statusCode = models.GetStatusCodeFromMeta(code) - } else { - statusCode = 200 - } - c.JSON(statusCode, response) -} - -// GetBynokartu godoc -// @Summary Get Bynokartu data -// @Description Get participant eligibility information by card number -// @Tags Peserta -// @Accept json -// @Produce json -// @Security ApiKeyAuth -// @Param X-Request-ID header string false "Request ID for tracking" -// @Param nokartu path string true "nokartu" example("example_value") -// @Success 200 {object} peserta.PesertaResponse "Successfully retrieved Bynokartu data" -// @Failure 400 {object} models.ErrorResponseBpjs "Bad request - invalid parameters" -// @Failure 401 {object} models.ErrorResponseBpjs "Unauthorized - invalid API credentials" -// @Failure 404 {object} models.ErrorResponseBpjs "Not found - Bynokartu not found" -// @Failure 500 {object} models.ErrorResponseBpjs "Internal server error" -// @Router /Peserta/nokartu/:nokartu [get] -func (h *PesertaHandler) GetBynokartu(c *gin.Context) { - ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) - defer cancel() - - // Generate request ID if not present - requestID := c.GetHeader("X-Request-ID") - if requestID == "" { - requestID = uuid.New().String() - c.Header("X-Request-ID", requestID) - } - - // Get database connection - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logger.Error("Database connection failed", map[string]interface{}{ - "error": err.Error(), - "request_id": requestID, - }) - c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{ - Status: "error", - Message: "Database connection failed", - RequestID: requestID, - }) - return - } - // Note: dbConn is available for future database operations (e.g., caching, logging) - _ = dbConn // Prevent unused variable warning - - // Context Paramaeter - now := time.Now() - dateStr := now.Format("2006-01-02") - fmt.Println("Date (YYYY-MM-DD):", dateStr) - h.logger.Info("Processing GetBynokartu request", map[string]interface{}{ - "request_id": requestID, - "endpoint": "/Peserta/nokartu/:nokartu/tglSEP/" + dateStr, - "nik": c.Param("nokartu"), - }) - - // Extract path parameters - - nokartu := c.Param("nokartu") - if nokartu == "" || nokartu == ":nokartu" { - - h.logger.Error("Missing required parameter nokartu", map[string]interface{}{ - "request_id": requestID, - }) - - c.JSON(http.StatusBadRequest, models.ErrorResponseBpjs{ - Status: "error", - Message: "Parameter Nomor Kartu Bpjs Masih Kosong / Isi Dahulu Nomor Kartu!", - RequestID: requestID, - }) - return - } - var response peserta.PesertaResponse - - endpoint := "/Peserta/nokartu/:nokartu/tglSEP/" + dateStr - - endpoint = strings.Replace(endpoint, ":nokartu", nokartu, 1) - - resp, err := h.service.GetRawResponse(ctx, endpoint) - - if err != nil { - // Check if error message contains 404 status code - if strings.Contains(err.Error(), "HTTP error: 404") { - h.logger.Error("ByNoKartu not found", map[string]interface{}{ - "error": err.Error(), - "request_id": requestID, - }) - - c.JSON(http.StatusNotFound, models.ErrorResponseBpjs{ - Status: "error", - Message: "ByNoKartu not found", - RequestID: requestID, - }) - return - } - - h.logger.Error("Failed to get ByNoKartu", map[string]interface{}{ - "error": err.Error(), - "request_id": requestID, - }) - - c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{ - Status: "error", - Message: "Internal server error", - RequestID: requestID, - }) - return - } - - // Map the raw response - response.MetaData = resp.MetaData - if resp.Response != nil { - response.Data = &peserta.PesertaData{} - if respStr, ok := resp.Response.(string); ok { - // Decrypt the response string - consID, secretKey, _, tstamp, _ := h.config.SetHeader() - decryptedResp, err := services.ResponseVclaim(respStr, consID+secretKey+tstamp) - if err != nil { - - h.logger.Error("Failed to decrypt response", map[string]interface{}{ - "error": err.Error(), - "request_id": requestID, - }) - - } else { - // Clean the decrypted response - cleanedResp := cleanResponse(decryptedResp) - err = json.Unmarshal([]byte(cleanedResp), response.Data) - if err != nil { - h.logger.Warn("Failed to unmarshal decrypted response", map[string]interface{}{ - "error": err.Error(), - "request_id": requestID, - "response_preview": cleanedResp[:min(100, len(cleanedResp))], // Log first 100 chars for debugging - }) - // Set Data to nil if unmarshal fails to avoid sending empty struct - response.Data = nil - } - } - } else if respMap, ok := resp.Response.(map[string]interface{}); ok { - // Response is already unmarshaled JSON - if dataMap, exists := respMap["peserta"]; exists { - dataBytes, _ := json.Marshal(dataMap) - json.Unmarshal(dataBytes, response.Data) - } else { - // Try to unmarshal the whole response - respBytes, _ := json.Marshal(resp.Response) - json.Unmarshal(respBytes, response.Data) - } - } - } - - // Ensure response has proper fields - response.Status = "success" - response.RequestID = requestID - // Ambil status code dari metaData.code - var statusCode int - code := models.ExtractCode(response.MetaData) - if code != nil { - statusCode = models.GetStatusCodeFromMeta(code) - } else { - statusCode = 200 - } - c.JSON(statusCode, response) -} diff --git a/internal/handlers/retribusi/retribusi.go b/internal/handlers/retribusi/retribusi.go deleted file mode 100644 index b5e9a94..0000000 --- a/internal/handlers/retribusi/retribusi.go +++ /dev/null @@ -1,1401 +0,0 @@ -package handlers - -import ( - "api-service/internal/config" - "api-service/internal/database" - models "api-service/internal/models" - "api-service/internal/models/retribusi" - utils "api-service/internal/utils/filters" - "api-service/internal/utils/validation" - "api-service/pkg/logger" - "context" - "database/sql" - "fmt" - "net/http" - "strconv" - "strings" - "sync" - "time" - - "github.com/gin-gonic/gin" - "github.com/go-playground/validator/v10" - "github.com/google/uuid" -) - -var ( - db database.Service - once sync.Once - validate *validator.Validate -) - -// Initialize the database connection and validator -func init() { - once.Do(func() { - db = database.New(config.LoadConfig()) - validate = validator.New() - - // Register custom validations if needed - validate.RegisterValidation("retribusi_status", validateRetribusiStatus) - - if db == nil { - logger.Fatal("Failed to initialize database connection") - } - }) -} - -// Custom validation for retribusi status -func validateRetribusiStatus(fl validator.FieldLevel) bool { - return models.IsValidStatus(fl.Field().String()) -} - -// RetribusiHandler handles retribusi services -type RetribusiHandler struct { - db database.Service -} - -// NewRetribusiHandler creates a new RetribusiHandler -func NewRetribusiHandler() *RetribusiHandler { - return &RetribusiHandler{ - db: db, - } -} - -// GetRetribusi godoc -// @Summary Get retribusi with pagination and optional aggregation -// @Description Returns a paginated list of retribusis with optional summary statistics -// @Tags Retribusi -// @Accept json -// @Produce json -// @Param limit query int false "Limit (max 100)" default(10) -// @Param offset query int false "Offset" default(0) -// @Param include_summary query bool false "Include aggregation summary" default(false) -// @Param status query string false "Filter by status" -// @Param jenis query string false "Filter by jenis" -// @Param dinas query string false "Filter by dinas" -// @Param search query string false "Search in multiple fields" -// @Success 200 {object} retribusi.RetribusiGetResponse "Success response" -// @Failure 400 {object} models.ErrorResponse "Bad request" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/retribusis [get] -func (h *RetribusiHandler) GetRetribusi(c *gin.Context) { - // Parse pagination parameters - limit, offset, err := h.parsePaginationParams(c) - if err != nil { - h.respondError(c, "Invalid pagination parameters", err, http.StatusBadRequest) - return - } - - // Parse filter parameters - filter := h.parseFilterParams(c) - includeAggregation := c.Query("include_summary") == "true" - - // Get database connection - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } - - // Create context with timeout - ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) - defer cancel() - - // Execute concurrent operations - var ( - retribusis []retribusi.Retribusi - total int - aggregateData *models.AggregateData - wg sync.WaitGroup - errChan = make(chan error, 3) - mu sync.Mutex - ) - - // Fetch total count - wg.Add(1) - go func() { - defer wg.Done() - if err := h.getTotalCount(ctx, dbConn, filter, &total); err != nil { - mu.Lock() - errChan <- fmt.Errorf("failed to get total count: %w", err) - mu.Unlock() - } - }() - - // Fetch main data - wg.Add(1) - go func() { - defer wg.Done() - result, err := h.fetchRetribusis(ctx, dbConn, filter, limit, offset) - mu.Lock() - if err != nil { - errChan <- fmt.Errorf("failed to fetch data: %w", err) - } else { - retribusis = result - } - mu.Unlock() - }() - - // Fetch aggregation data if requested - if includeAggregation { - wg.Add(1) - go func() { - defer wg.Done() - result, err := h.getAggregateData(ctx, dbConn, filter) - mu.Lock() - if err != nil { - errChan <- fmt.Errorf("failed to get aggregate data: %w", err) - } else { - aggregateData = result - } - mu.Unlock() - }() - } - - // Wait for all goroutines - wg.Wait() - close(errChan) - - // Check for errors - for err := range errChan { - if err != nil { - h.logAndRespondError(c, "Data processing failed", err, http.StatusInternalServerError) - return - } - } - - // Build response - meta := h.calculateMeta(limit, offset, total) - response := retribusi.RetribusiGetResponse{ - Message: "Data retribusi berhasil diambil", - Data: retribusis, - Meta: meta, - } - - if includeAggregation && aggregateData != nil { - response.Summary = aggregateData - } - - c.JSON(http.StatusOK, response) -} - -// GetRetribusiByID godoc -// @Summary Get Retribusi by ID -// @Description Returns a single retribusi by ID -// @Tags Retribusi -// @Accept json -// @Produce json -// @Param id path string true "Retribusi ID (UUID)" -// @Success 200 {object} retribusi.RetribusiGetByIDResponse "Success response" -// @Failure 400 {object} models.ErrorResponse "Invalid ID format" -// @Failure 404 {object} models.ErrorResponse "Retribusi not found" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/retribusi/{id} [get] -func (h *RetribusiHandler) GetRetribusiByID(c *gin.Context) { - id := c.Param("id") - - // Validate UUID format - if _, err := uuid.Parse(id); err != nil { - h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) - return - } - - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } - - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) - defer cancel() - - dataretribusi, err := h.getRetribusiByID(ctx, dbConn, id) - if err != nil { - if err == sql.ErrNoRows { - h.respondError(c, "Retribusi not found", err, http.StatusNotFound) - } else { - h.logAndRespondError(c, "Failed to get retribusi", err, http.StatusInternalServerError) - } - return - } - - response := retribusi.RetribusiGetByIDResponse{ - Message: "Retribusi details retrieved successfully", - Data: dataretribusi, - } - - c.JSON(http.StatusOK, response) -} - -// GetRetribusiDynamic godoc -// @Summary Get retribusi with dynamic filtering -// @Description Returns retribusis with advanced dynamic filtering like Directus -// @Tags Retribusi -// @Accept json -// @Produce json -// @Param fields query string false "Fields to select (e.g., fields=*.*)" -// @Param filter[column][operator] query string false "Dynamic filters (e.g., filter[Jenis][_eq]=value)" -// @Param sort query string false "Sort fields (e.g., sort=date_created,-Jenis)" -// @Param limit query int false "Limit" default(10) -// @Param offset query int false "Offset" default(0) -// @Success 200 {object} retribusi.RetribusiGetResponse "Success response" -// @Failure 400 {object} models.ErrorResponse "Bad request" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/retribusis/dynamic [get] -func (h *RetribusiHandler) GetRetribusiDynamic(c *gin.Context) { - // Parse query parameters - parser := utils.NewQueryParser().SetLimits(10, 100) - dynamicQuery, err := parser.ParseQuery(c.Request.URL.Query()) - if err != nil { - h.respondError(c, "Invalid query parameters", err, http.StatusBadRequest) - return - } - - // Get database connection - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } - - // Create context with timeout - ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) - defer cancel() - - // Execute query with dynamic filtering - retribusis, total, err := h.fetchRetribusisDynamic(ctx, dbConn, dynamicQuery) - if err != nil { - h.logAndRespondError(c, "Failed to fetch data", err, http.StatusInternalServerError) - return - } - - // Build response - meta := h.calculateMeta(dynamicQuery.Limit, dynamicQuery.Offset, total) - response := retribusi.RetribusiGetResponse{ - Message: "Data retribusi berhasil diambil", - Data: retribusis, - Meta: meta, - } - - c.JSON(http.StatusOK, response) -} - -// fetchRetribusisDynamic executes dynamic query -func (h *RetribusiHandler) fetchRetribusisDynamic(ctx context.Context, dbConn *sql.DB, query utils.DynamicQuery) ([]retribusi.Retribusi, int, error) { - // Setup query builder - countBuilder := utils.NewQueryBuilder("data_retribusi"). - SetColumnMapping(map[string]string{ - "jenis": "Jenis", - "pelayanan": "Pelayanan", - "dinas": "Dinas", - "kelompok_obyek": "Kelompok_obyek", - "Kode_tarif": "Kode_tarif", - "kode_tarif": "Kode_tarif", - "tarif": "Tarif", - "satuan": "Satuan", - "tarif_overtime": "Tarif_overtime", - "satuan_overtime": "Satuan_overtime", - "rekening_pokok": "Rekening_pokok", - "rekening_denda": "Rekening_denda", - "uraian_1": "Uraian_1", - "uraian_2": "Uraian_2", - "uraian_3": "Uraian_3", - }). - SetAllowedColumns([]string{ - "id", "status", "sort", "user_created", "date_created", - "user_updated", "date_updated", "Jenis", "Pelayanan", - "Dinas", "Kelompok_obyek", "Kode_tarif", "Tarif", "Satuan", - "Tarif_overtime", "Satuan_overtime", "Rekening_pokok", - "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3", - }) - - mainBuilder := utils.NewQueryBuilder("data_retribusi"). - SetColumnMapping(map[string]string{ - "jenis": "Jenis", - "pelayanan": "Pelayanan", - "dinas": "Dinas", - "kelompok_obyek": "Kelompok_obyek", - "Kode_tarif": "Kode_tarif", - "kode_tarif": "Kode_tarif", - "tarif": "Tarif", - "satuan": "Satuan", - "tarif_overtime": "Tarif_overtime", - "satuan_overtime": "Satuan_overtime", - "rekening_pokok": "Rekening_pokok", - "rekening_denda": "Rekening_denda", - "uraian_1": "Uraian_1", - "uraian_2": "Uraian_2", - "uraian_3": "Uraian_3", - }). - SetAllowedColumns([]string{ - "id", "status", "sort", "user_created", "date_created", - "user_updated", "date_updated", "Jenis", "Pelayanan", - "Dinas", "Kelompok_obyek", "Kode_tarif", "Tarif", "Satuan", - "Tarif_overtime", "Satuan_overtime", "Rekening_pokok", - "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3", - }) - - // Add default filter to exclude deleted records - if len(query.Filters) > 0 { - query.Filters = append([]utils.FilterGroup{{ - Filters: []utils.DynamicFilter{{ - Column: "status", - Operator: utils.OpNotEqual, - Value: "deleted", - }}, - LogicOp: "AND", - }}, query.Filters...) - } else { - query.Filters = []utils.FilterGroup{{ - Filters: []utils.DynamicFilter{{ - Column: "status", - Operator: utils.OpNotEqual, - Value: "deleted", - }}, - LogicOp: "AND", - }} - } - - // Execute queries sequentially to avoid race conditions - var total int - var retribusis []retribusi.Retribusi - - // 1. Get total count first - countQuery := query - countQuery.Limit = 0 - countQuery.Offset = 0 - - countSQL, countArgs, err := countBuilder.BuildCountQuery(countQuery) - if err != nil { - return nil, 0, fmt.Errorf("failed to build count query: %w", err) - } - - if err := dbConn.QueryRowContext(ctx, countSQL, countArgs...).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("failed to get total count: %w", err) - } - - // 2. Get main data - mainSQL, mainArgs, err := mainBuilder.BuildQuery(query) - if err != nil { - return nil, 0, fmt.Errorf("failed to build main query: %w", err) - } - - rows, err := dbConn.QueryContext(ctx, mainSQL, mainArgs...) - if err != nil { - return nil, 0, fmt.Errorf("failed to execute main query: %w", err) - } - defer rows.Close() - - for rows.Next() { - retribusi, err := h.scanRetribusi(rows) - if err != nil { - return nil, 0, fmt.Errorf("failed to scan retribusi: %w", err) - } - retribusis = append(retribusis, retribusi) - } - - if err := rows.Err(); err != nil { - return nil, 0, fmt.Errorf("rows iteration error: %w", err) - } - - return retribusis, total, nil -} - -// SearchRetribusiAdvanced provides advanced search capabilities -func (h *RetribusiHandler) SearchRetribusiAdvanced(c *gin.Context) { - // Parse complex search parameters - searchQuery := c.Query("q") - if searchQuery == "" { - // If no search query provided, return all records with default sorting - query := utils.DynamicQuery{ - Fields: []string{"*"}, - Filters: []utils.FilterGroup{}, // Empty filters - fetchRetribusisDynamic will add default deleted filter - Sort: []utils.SortField{{ - Column: "date_created", - Order: "DESC", - }}, - Limit: 20, - Offset: 0, - } - - // Parse pagination if provided - if limit := c.Query("limit"); limit != "" { - if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 100 { - query.Limit = l - } - } - - if offset := c.Query("offset"); offset != "" { - if o, err := strconv.Atoi(offset); err == nil && o >= 0 { - query.Offset = o - } - } - - // Get database connection - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } - - ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) - defer cancel() - - // Execute query to get all records - retribusis, total, err := h.fetchRetribusisDynamic(ctx, dbConn, query) - if err != nil { - h.logAndRespondError(c, "Failed to fetch data", err, http.StatusInternalServerError) - return - } - - // Build response - meta := h.calculateMeta(query.Limit, query.Offset, total) - response := retribusi.RetribusiGetResponse{ - Message: "All records retrieved (no search query provided)", - Data: retribusis, - Meta: meta, - } - - c.JSON(http.StatusOK, response) - return - } - - // Build dynamic query for search - query := utils.DynamicQuery{ - Fields: []string{"*"}, - Filters: []utils.FilterGroup{{ - Filters: []utils.DynamicFilter{ - { - Column: "Jenis", - Operator: utils.OpContains, - Value: searchQuery, - LogicOp: "OR", - }, - { - Column: "Pelayanan", - Operator: utils.OpContains, - Value: searchQuery, - LogicOp: "OR", - }, - { - Column: "Dinas", - Operator: utils.OpContains, - Value: searchQuery, - LogicOp: "OR", - }, - { - Column: "Uraian_1", - Operator: utils.OpContains, - Value: searchQuery, - LogicOp: "OR", - }, - }, - LogicOp: "AND", - }}, - Sort: []utils.SortField{{ - Column: "date_created", - Order: "DESC", - }}, - Limit: 20, - Offset: 0, - } - - // Parse pagination if provided - if limit := c.Query("limit"); limit != "" { - if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 100 { - query.Limit = l - } - } - - if offset := c.Query("offset"); offset != "" { - if o, err := strconv.Atoi(offset); err == nil && o >= 0 { - query.Offset = o - } - } - - // Get database connection - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } - - ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) - defer cancel() - - // Execute search - retribusis, total, err := h.fetchRetribusisDynamic(ctx, dbConn, query) - if err != nil { - h.logAndRespondError(c, "Search failed", err, http.StatusInternalServerError) - return - } - - // Build response - meta := h.calculateMeta(query.Limit, query.Offset, total) - response := retribusi.RetribusiGetResponse{ - Message: fmt.Sprintf("Search results for '%s'", searchQuery), - Data: retribusis, - Meta: meta, - } - - c.JSON(http.StatusOK, response) -} - -// CreateRetribusi godoc -// @Summary Create retribusi -// @Description Creates a new retribusi record -// @Tags Retribusi -// @Accept json -// @Produce json -// @Param request body retribusi.RetribusiCreateRequest true "Retribusi creation request" -// @Success 201 {object} retribusi.RetribusiCreateResponse "Retribusi created successfully" -// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/retribusis [post] -func (h *RetribusiHandler) CreateRetribusi(c *gin.Context) { - var req retribusi.RetribusiCreateRequest - - if err := c.ShouldBindJSON(&req); err != nil { - h.respondError(c, "Invalid request body", err, http.StatusBadRequest) - return - } - - // Validate request - if err := validate.Struct(&req); err != nil { - h.respondError(c, "Validation failed", err, http.StatusBadRequest) - return - } - - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } - - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) - defer cancel() - - // Validate duplicate and daily submission - if err := h.validateRetribusiSubmission(ctx, dbConn, &req); err != nil { - h.respondError(c, "Validation failed", err, http.StatusBadRequest) - return - } - - dataretribusi, err := h.createRetribusi(ctx, dbConn, &req) - if err != nil { - h.logAndRespondError(c, "Failed to create retribusi", err, http.StatusInternalServerError) - return - } - - response := retribusi.RetribusiCreateResponse{ - Message: "Retribusi berhasil dibuat", - Data: dataretribusi, - } - - c.JSON(http.StatusCreated, response) -} - -// UpdateRetribusi godoc -// @Summary Update retribusi -// @Description Updates an existing retribusi record -// @Tags Retribusi -// @Accept json -// @Produce json -// @Param id path string true "Retribusi ID (UUID)" -// @Param request body retribusi.RetribusiUpdateRequest true "Retribusi update request" -// @Success 200 {object} retribusi.RetribusiUpdateResponse "Retribusi updated successfully" -// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" -// @Failure 404 {object} models.ErrorResponse "Retribusi not found" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/retribusi/{id} [put] -func (h *RetribusiHandler) UpdateRetribusi(c *gin.Context) { - id := c.Param("id") - - // Validate UUID format - if _, err := uuid.Parse(id); err != nil { - h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) - return - } - - var req retribusi.RetribusiUpdateRequest - if err := c.ShouldBindJSON(&req); err != nil { - h.respondError(c, "Invalid request body", err, http.StatusBadRequest) - return - } - - // Set ID from path parameter - req.ID = id - - // Validate request - if err := validate.Struct(&req); err != nil { - h.respondError(c, "Validation failed", err, http.StatusBadRequest) - return - } - - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } - - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) - defer cancel() - - dataretribusi, err := h.updateRetribusi(ctx, dbConn, &req) - if err != nil { - if err == sql.ErrNoRows { - h.respondError(c, "Retribusi not found", err, http.StatusNotFound) - } else { - h.logAndRespondError(c, "Failed to update retribusi", err, http.StatusInternalServerError) - } - return - } - - response := retribusi.RetribusiUpdateResponse{ - Message: "Retribusi berhasil diperbarui", - Data: dataretribusi, - } - - c.JSON(http.StatusOK, response) -} - -// DeleteRetribusi godoc -// @Summary Delete retribusi -// @Description Soft deletes a retribusi by setting status to 'deleted' -// @Tags Retribusi -// @Accept json -// @Produce json -// @Param id path string true "Retribusi ID (UUID)" -// @Success 200 {object} retribusi.RetribusiDeleteResponse "Retribusi deleted successfully" -// @Failure 400 {object} models.ErrorResponse "Invalid ID format" -// @Failure 404 {object} models.ErrorResponse "Retribusi not found" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/retribusi/{id} [delete] -func (h *RetribusiHandler) DeleteRetribusi(c *gin.Context) { - id := c.Param("id") - - // Validate UUID format - if _, err := uuid.Parse(id); err != nil { - h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) - return - } - - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } - - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) - defer cancel() - - err = h.deleteRetribusi(ctx, dbConn, id) - if err != nil { - if err == sql.ErrNoRows { - h.respondError(c, "Retribusi not found", err, http.StatusNotFound) - } else { - h.logAndRespondError(c, "Failed to delete retribusi", err, http.StatusInternalServerError) - } - return - } - - response := retribusi.RetribusiDeleteResponse{ - Message: "Retribusi berhasil dihapus", - ID: id, - } - - c.JSON(http.StatusOK, response) -} - -// GetRetribusiStats godoc -// @Summary Get retribusi statistics -// @Description Returns comprehensive statistics about retribusi data -// @Tags Retribusi -// @Accept json -// @Produce json -// @Param status query string false "Filter statistics by status" -// @Success 200 {object} models.AggregateData "Statistics data" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/retribusis/stats [get] -func (h *RetribusiHandler) GetRetribusiStats(c *gin.Context) { - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } - - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) - defer cancel() - - filter := h.parseFilterParams(c) - aggregateData, err := h.getAggregateData(ctx, dbConn, filter) - if err != nil { - h.logAndRespondError(c, "Failed to get statistics", err, http.StatusInternalServerError) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message": "Statistik retribusi berhasil diambil", - "data": aggregateData, - }) -} - -// Get retribusi by ID -func (h *RetribusiHandler) getRetribusiByID(ctx context.Context, dbConn *sql.DB, id string) (*retribusi.Retribusi, error) { - query := ` - SELECT - id, status, sort, user_created, date_created, user_updated, date_updated, - "Jenis", "Pelayanan", "Dinas", "Kelompok_obyek", "Kode_tarif", - "Tarif", "Satuan", "Tarif_overtime", "Satuan_overtime", - "Rekening_pokok", "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3" - FROM data_retribusi - WHERE id = $1 AND status != 'deleted'` - - row := dbConn.QueryRowContext(ctx, query, id) - - var retribusi retribusi.Retribusi - err := row.Scan( - &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, - &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, - &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, - &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, - &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, - &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, - ) - - if err != nil { - return nil, err - } - - return &retribusi, nil -} - -// Create retribusi -func (h *RetribusiHandler) createRetribusi(ctx context.Context, dbConn *sql.DB, req *retribusi.RetribusiCreateRequest) (*retribusi.Retribusi, error) { - id := uuid.New().String() - now := time.Now() - - query := ` - INSERT INTO data_retribusi ( - id, status, date_created, date_updated, - "Jenis", "Pelayanan", "Dinas", "Kelompok_obyek", "Kode_tarif", - "Tarif", "Satuan", "Tarif_overtime", "Satuan_overtime", - "Rekening_pokok", "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3" - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) - RETURNING - id, status, sort, user_created, date_created, user_updated, date_updated, - "Jenis", "Pelayanan", "Dinas", "Kelompok_obyek", "Kode_tarif", - "Tarif", "Satuan", "Tarif_overtime", "Satuan_overtime", - "Rekening_pokok", "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3"` - - row := dbConn.QueryRowContext(ctx, query, - id, req.Status, now, now, - req.Jenis, req.Pelayanan, req.Dinas, req.KelompokObyek, req.KodeTarif, - req.Tarif, req.Satuan, req.TarifOvertime, req.SatuanOvertime, - req.RekeningPokok, req.RekeningDenda, req.Uraian1, req.Uraian2, req.Uraian3, - ) - - var retribusi retribusi.Retribusi - err := row.Scan( - &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, - &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, - &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, - &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, - &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, - &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, - ) - - if err != nil { - return nil, fmt.Errorf("failed to create retribusi: %w", err) - } - - return &retribusi, nil -} - -// Update retribusi -func (h *RetribusiHandler) updateRetribusi(ctx context.Context, dbConn *sql.DB, req *retribusi.RetribusiUpdateRequest) (*retribusi.Retribusi, error) { - now := time.Now() - - query := ` - UPDATE data_retribusi SET - status = $2, date_updated = $3, - "Jenis" = $4, "Pelayanan" = $5, "Dinas" = $6, "Kelompok_obyek" = $7, "Kode_tarif" = $8, - "Tarif" = $9, "Satuan" = $10, "Tarif_overtime" = $11, "Satuan_overtime" = $12, - "Rekening_pokok" = $13, "Rekening_denda" = $14, "Uraian_1" = $15, "Uraian_2" = $16, "Uraian_3" = $17 - WHERE id = $1 AND status != 'deleted' - RETURNING - id, status, sort, user_created, date_created, user_updated, date_updated, - "Jenis", "Pelayanan", "Dinas", "Kelompok_obyek", "Kode_tarif", - "Tarif", "Satuan", "Tarif_overtime", "Satuan_overtime", - "Rekening_pokok", "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3"` - - row := dbConn.QueryRowContext(ctx, query, - req.ID, req.Status, now, - req.Jenis, req.Pelayanan, req.Dinas, req.KelompokObyek, req.KodeTarif, - req.Tarif, req.Satuan, req.TarifOvertime, req.SatuanOvertime, - req.RekeningPokok, req.RekeningDenda, req.Uraian1, req.Uraian2, req.Uraian3, - ) - - var retribusi retribusi.Retribusi - err := row.Scan( - &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, - &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, - &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, - &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, - &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, - &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, - ) - - if err != nil { - return nil, fmt.Errorf("failed to update retribusi: %w", err) - } - - return &retribusi, nil -} - -// Soft delete retribusi -func (h *RetribusiHandler) deleteRetribusi(ctx context.Context, dbConn *sql.DB, id string) error { - now := time.Now() - - query := `UPDATE data_retribusi SET status = 'deleted', date_updated = $2 WHERE id = $1 AND status != 'deleted'` - - result, err := dbConn.ExecContext(ctx, query, id, now) - if err != nil { - return fmt.Errorf("failed to delete retribusi: %w", err) - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to get affected rows: %w", err) - } - - if rowsAffected == 0 { - return sql.ErrNoRows - } - - return nil -} - -// Enhanced error handling -func (h *RetribusiHandler) logAndRespondError(c *gin.Context, message string, err error, statusCode int) { - logger.Error(message, map[string]interface{}{ - "error": err.Error(), - "status_code": statusCode, - }) - h.respondError(c, message, err, statusCode) -} - -func (h *RetribusiHandler) respondError(c *gin.Context, message string, err error, statusCode int) { - errorMessage := message - if gin.Mode() == gin.ReleaseMode { - errorMessage = "Internal server error" - } - - c.JSON(statusCode, models.ErrorResponse{ - Error: errorMessage, - Code: statusCode, - Message: err.Error(), - Timestamp: time.Now(), - }) -} - -// Parse pagination parameters dengan validation yang lebih ketat -func (h *RetribusiHandler) parsePaginationParams(c *gin.Context) (int, int, error) { - limit := 10 // Default limit - offset := 0 // Default offset - - if limitStr := c.Query("limit"); limitStr != "" { - parsedLimit, err := strconv.Atoi(limitStr) - if err != nil { - return 0, 0, fmt.Errorf("invalid limit parameter: %s", limitStr) - } - if parsedLimit <= 0 { - return 0, 0, fmt.Errorf("limit must be greater than 0") - } - if parsedLimit > 100 { - return 0, 0, fmt.Errorf("limit cannot exceed 100") - } - limit = parsedLimit - } - - if offsetStr := c.Query("offset"); offsetStr != "" { - parsedOffset, err := strconv.Atoi(offsetStr) - if err != nil { - return 0, 0, fmt.Errorf("invalid offset parameter: %s", offsetStr) - } - if parsedOffset < 0 { - return 0, 0, fmt.Errorf("offset cannot be negative") - } - offset = parsedOffset - } - - logger.Debug("Pagination parameters", map[string]interface{}{ - "limit": limit, - "offset": offset, - }) - return limit, offset, nil -} - -// Build WHERE clause dengan filter parameters -func (h *RetribusiHandler) buildWhereClause(filter retribusi.RetribusiFilter) (string, []interface{}) { - conditions := []string{"status != 'deleted'"} - args := []interface{}{} - paramCount := 1 - - if filter.Status != nil { - conditions = append(conditions, fmt.Sprintf("status = $%d", paramCount)) - args = append(args, *filter.Status) - paramCount++ - } - - if filter.Jenis != nil { - conditions = append(conditions, fmt.Sprintf(`"Jenis" ILIKE $%d`, paramCount)) - args = append(args, "%"+*filter.Jenis+"%") - paramCount++ - } - - if filter.Dinas != nil { - conditions = append(conditions, fmt.Sprintf(`"Dinas" ILIKE $%d`, paramCount)) - args = append(args, "%"+*filter.Dinas+"%") - paramCount++ - } - - if filter.KelompokObyek != nil { - conditions = append(conditions, fmt.Sprintf(`"Kelompok_obyek" ILIKE $%d`, paramCount)) - args = append(args, "%"+*filter.KelompokObyek+"%") - paramCount++ - } - - if filter.Search != nil { - searchCondition := fmt.Sprintf(`( - "Jenis" ILIKE $%d OR - "Pelayanan" ILIKE $%d OR - "Dinas" ILIKE $%d OR - "Kode_tarif" ILIKE $%d OR - "Uraian_1" ILIKE $%d OR - "Uraian_2" ILIKE $%d OR - "Uraian_3" ILIKE $%d - )`, paramCount, paramCount, paramCount, paramCount, paramCount, paramCount, paramCount) - conditions = append(conditions, searchCondition) - searchTerm := "%" + *filter.Search + "%" - args = append(args, searchTerm) - paramCount++ - } - - if filter.DateFrom != nil { - conditions = append(conditions, fmt.Sprintf("date_created >= $%d", paramCount)) - args = append(args, *filter.DateFrom) - paramCount++ - } - - if filter.DateTo != nil { - conditions = append(conditions, fmt.Sprintf("date_created <= $%d", paramCount)) - args = append(args, filter.DateTo.Add(24*time.Hour-time.Nanosecond)) // End of day - paramCount++ - } - - return strings.Join(conditions, " AND "), args -} - -// Optimized scanning function yang menggunakan sql.Null* types langsung -func (h *RetribusiHandler) scanRetribusi(rows *sql.Rows) (retribusi.Retribusi, error) { - var retribusi retribusi.Retribusi - - return retribusi, rows.Scan( - &retribusi.ID, - &retribusi.Status, - &retribusi.Sort, - &retribusi.UserCreated, - &retribusi.DateCreated, - &retribusi.UserUpdated, - &retribusi.DateUpdated, - &retribusi.Jenis, - &retribusi.Pelayanan, - &retribusi.Dinas, - &retribusi.KelompokObyek, - &retribusi.KodeTarif, - &retribusi.Tarif, - &retribusi.Satuan, - &retribusi.TarifOvertime, - &retribusi.SatuanOvertime, - &retribusi.RekeningPokok, - &retribusi.RekeningDenda, - &retribusi.Uraian1, - &retribusi.Uraian2, - &retribusi.Uraian3, - ) -} - -// Parse filter parameters dari query string -func (h *RetribusiHandler) parseFilterParams(c *gin.Context) retribusi.RetribusiFilter { - filter := retribusi.RetribusiFilter{} - - if status := c.Query("status"); status != "" { - if models.IsValidStatus(status) { - filter.Status = &status - } - } - - if jenis := c.Query("jenis"); jenis != "" { - filter.Jenis = &jenis - } - - if dinas := c.Query("dinas"); dinas != "" { - filter.Dinas = &dinas - } - - if kelompokObyek := c.Query("kelompok_obyek"); kelompokObyek != "" { - filter.KelompokObyek = &kelompokObyek - } - - if search := c.Query("search"); search != "" { - filter.Search = &search - } - - // Parse date filters - if dateFromStr := c.Query("date_from"); dateFromStr != "" { - if dateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil { - filter.DateFrom = &dateFrom - } - } - - if dateToStr := c.Query("date_to"); dateToStr != "" { - if dateTo, err := time.Parse("2006-01-02", dateToStr); err == nil { - filter.DateTo = &dateTo - } - } - - return filter -} - -// Get comprehensive aggregate data dengan filter support -func (h *RetribusiHandler) getAggregateData(ctx context.Context, dbConn *sql.DB, filter retribusi.RetribusiFilter) (*models.AggregateData, error) { - aggregate := &models.AggregateData{ - ByStatus: make(map[string]int), - ByDinas: make(map[string]int), - ByJenis: make(map[string]int), - } - - // Build where clause untuk filter - whereClause, args := h.buildWhereClause(filter) - - // Use concurrent execution untuk performance - var wg sync.WaitGroup - var mu sync.Mutex - errChan := make(chan error, 4) - - // 1. Count by status - wg.Add(1) - go func() { - defer wg.Done() - statusQuery := fmt.Sprintf(` - SELECT status, COUNT(*) - FROM data_retribusi - WHERE %s - GROUP BY status - ORDER BY status`, whereClause) - - rows, err := dbConn.QueryContext(ctx, statusQuery, args...) - if err != nil { - errChan <- fmt.Errorf("status query failed: %w", err) - return - } - defer rows.Close() - - mu.Lock() - for rows.Next() { - var status string - var count int - if err := rows.Scan(&status, &count); err != nil { - mu.Unlock() - errChan <- fmt.Errorf("status scan failed: %w", err) - return - } - aggregate.ByStatus[status] = count - switch status { - case "active": - aggregate.TotalActive = count - case "draft": - aggregate.TotalDraft = count - case "inactive": - aggregate.TotalInactive = count - } - } - mu.Unlock() - - if err := rows.Err(); err != nil { - errChan <- fmt.Errorf("status iteration error: %w", err) - } - }() - - // 2. Count by Dinas - wg.Add(1) - go func() { - defer wg.Done() - dinasQuery := fmt.Sprintf(` - SELECT COALESCE("Dinas", 'Unknown') as dinas, COUNT(*) - FROM data_retribusi - WHERE %s AND "Dinas" IS NOT NULL AND TRIM("Dinas") != '' - GROUP BY "Dinas" - ORDER BY COUNT(*) DESC - LIMIT 10`, whereClause) - - rows, err := dbConn.QueryContext(ctx, dinasQuery, args...) - if err != nil { - errChan <- fmt.Errorf("dinas query failed: %w", err) - return - } - defer rows.Close() - - mu.Lock() - for rows.Next() { - var dinas string - var count int - if err := rows.Scan(&dinas, &count); err != nil { - mu.Unlock() - errChan <- fmt.Errorf("dinas scan failed: %w", err) - return - } - aggregate.ByDinas[dinas] = count - } - mu.Unlock() - - if err := rows.Err(); err != nil { - errChan <- fmt.Errorf("dinas iteration error: %w", err) - } - }() - - // 3. Count by Jenis - wg.Add(1) - go func() { - defer wg.Done() - jenisQuery := fmt.Sprintf(` - SELECT COALESCE("Jenis", 'Unknown') as jenis, COUNT(*) - FROM data_retribusi - WHERE %s AND "Jenis" IS NOT NULL AND TRIM("Jenis") != '' - GROUP BY "Jenis" - ORDER BY COUNT(*) DESC - LIMIT 10`, whereClause) - - rows, err := dbConn.QueryContext(ctx, jenisQuery, args...) - if err != nil { - errChan <- fmt.Errorf("jenis query failed: %w", err) - return - } - defer rows.Close() - - mu.Lock() - for rows.Next() { - var jenis string - var count int - if err := rows.Scan(&jenis, &count); err != nil { - mu.Unlock() - errChan <- fmt.Errorf("jenis scan failed: %w", err) - return - } - aggregate.ByJenis[jenis] = count - } - mu.Unlock() - - if err := rows.Err(); err != nil { - errChan <- fmt.Errorf("jenis iteration error: %w", err) - } - }() - - // 4. Get last updated time dan today statistics - wg.Add(1) - go func() { - defer wg.Done() - - // Last updated - lastUpdatedQuery := fmt.Sprintf(` - SELECT MAX(date_updated) - FROM data_retribusi - WHERE %s AND date_updated IS NOT NULL`, whereClause) - - var lastUpdated sql.NullTime - if err := dbConn.QueryRowContext(ctx, lastUpdatedQuery, args...).Scan(&lastUpdated); err != nil { - errChan <- fmt.Errorf("last updated query failed: %w", err) - return - } - - // Today statistics - today := time.Now().Format("2006-01-02") - todayStatsQuery := fmt.Sprintf(` - SELECT - SUM(CASE WHEN DATE(date_created) = $%d THEN 1 ELSE 0 END) as created_today, - SUM(CASE WHEN DATE(date_updated) = $%d AND DATE(date_created) != $%d THEN 1 ELSE 0 END) as updated_today - FROM data_retribusi - WHERE %s`, len(args)+1, len(args)+1, len(args)+1, whereClause) - - todayArgs := append(args, today) - var createdToday, updatedToday int - if err := dbConn.QueryRowContext(ctx, todayStatsQuery, todayArgs...).Scan(&createdToday, &updatedToday); err != nil { - errChan <- fmt.Errorf("today stats query failed: %w", err) - return - } - - mu.Lock() - if lastUpdated.Valid { - aggregate.LastUpdated = &lastUpdated.Time - } - aggregate.CreatedToday = createdToday - aggregate.UpdatedToday = updatedToday - mu.Unlock() - }() - - // Wait for all goroutines - wg.Wait() - close(errChan) - - // Check for errors - for err := range errChan { - if err != nil { - return nil, err - } - } - - return aggregate, nil -} - -// Get total count dengan filter support -func (h *RetribusiHandler) getTotalCount(ctx context.Context, dbConn *sql.DB, filter retribusi.RetribusiFilter, total *int) error { - whereClause, args := h.buildWhereClause(filter) - countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM data_retribusi WHERE %s`, whereClause) - - if err := dbConn.QueryRowContext(ctx, countQuery, args...).Scan(total); err != nil { - return fmt.Errorf("total count query failed: %w", err) - } - - return nil -} - -// Enhanced fetchRetribusis dengan filter support -func (h *RetribusiHandler) fetchRetribusis(ctx context.Context, dbConn *sql.DB, filter retribusi.RetribusiFilter, limit, offset int) ([]retribusi.Retribusi, error) { - whereClause, args := h.buildWhereClause(filter) - - // Build the main query with pagination - query := fmt.Sprintf(` - SELECT - id, status, sort, user_created, date_created, user_updated, date_updated, - "Jenis", "Pelayanan", "Dinas", "Kelompok_obyek", "Kode_tarif", - "Tarif", "Satuan", "Tarif_overtime", "Satuan_overtime", - "Rekening_pokok", "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3" - FROM data_retribusi - WHERE %s - ORDER BY date_created DESC NULLS LAST - LIMIT $%d OFFSET $%d`, - whereClause, len(args)+1, len(args)+2) - - // Add pagination parameters - args = append(args, limit, offset) - - rows, err := dbConn.QueryContext(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("fetch retribusis query failed: %w", err) - } - defer rows.Close() - - // Pre-allocate slice dengan kapasitas yang tepat - retribusis := make([]retribusi.Retribusi, 0, limit) - - for rows.Next() { - retribusi, err := h.scanRetribusi(rows) - if err != nil { - return nil, fmt.Errorf("scan retribusi failed: %w", err) - } - retribusis = append(retribusis, retribusi) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("rows iteration error: %w", err) - } - - logger.Info("Successfully fetched retribusis", map[string]interface{}{ - "count": len(retribusis), - "limit": limit, - "offset": offset, - }) - return retribusis, nil -} - -// Calculate pagination metadata -func (h *RetribusiHandler) calculateMeta(limit, offset, total int) models.MetaResponse { - totalPages := 0 - currentPage := 1 - - if limit > 0 { - totalPages = (total + limit - 1) / limit // Ceiling division - currentPage = (offset / limit) + 1 - } - - return models.MetaResponse{ - Limit: limit, - Offset: offset, - Total: total, - TotalPages: totalPages, - CurrentPage: currentPage, - HasNext: offset+limit < total, - HasPrev: offset > 0, - } -} - -// validateRetribusiSubmission performs validation for duplicate entries and daily submission limits -func (h *RetribusiHandler) validateRetribusiSubmission(ctx context.Context, dbConn *sql.DB, req *retribusi.RetribusiCreateRequest) error { - // Import the validation utility - validator := validation.NewDuplicateValidator(dbConn) - - // Use default retribusi configuration - config := validation.DefaultRetribusiConfig() - - // Validate duplicate entries with active status for today - err := validator.ValidateDuplicate(ctx, config, "dummy_id") - if err != nil { - return fmt.Errorf("validation failed: %w", err) - } - - // Validate once per day submission - err = validator.ValidateOncePerDay(ctx, "data_retribusi", "id", "date_created", "daily_limit") - if err != nil { - return fmt.Errorf("daily submission limit exceeded: %w", err) - } - - return nil -} - -// Example usage of the validation utility with custom configuration -func (h *RetribusiHandler) validateWithCustomConfig(ctx context.Context, dbConn *sql.DB, req *retribusi.RetribusiCreateRequest) error { - // Create validator instance - validator := validation.NewDuplicateValidator(dbConn) - - // Use custom configuration - config := validation.ValidationConfig{ - TableName: "data_retribusi", - IDColumn: "id", - StatusColumn: "status", - DateColumn: "date_created", - ActiveStatuses: []string{"active", "draft"}, - AdditionalFields: map[string]interface{}{ - "jenis": req.Jenis, - "dinas": req.Dinas, - }, - } - - // Validate with custom fields - fields := map[string]interface{}{ - "jenis": *req.Jenis, - "dinas": *req.Dinas, - } - - err := validator.ValidateDuplicateWithCustomFields(ctx, config, fields) - if err != nil { - return fmt.Errorf("custom validation failed: %w", err) - } - - return nil -} - -// GetLastSubmissionTime example -func (h *RetribusiHandler) getLastSubmissionTimeExample(ctx context.Context, dbConn *sql.DB, identifier string) (*time.Time, error) { - validator := validation.NewDuplicateValidator(dbConn) - return validator.GetLastSubmissionTime(ctx, "data_retribusi", "id", "date_created", identifier) -} diff --git a/internal/models/antreanbpjs/antreanbpjs.go b/internal/models/antreanbpjs/antreanbpjs.go new file mode 100644 index 0000000..96d1efc --- /dev/null +++ b/internal/models/antreanbpjs/antreanbpjs.go @@ -0,0 +1 @@ +package antreanbpjs diff --git a/internal/routes/v1/routes.go b/internal/routes/v1/routes.go index 37dd3a3..00ad537 100644 --- a/internal/routes/v1/routes.go +++ b/internal/routes/v1/routes.go @@ -1,19 +1,22 @@ package v1 import ( + AplicareHandler "api-service/internal/aplicare" "api-service/internal/config" "api-service/internal/database" + "api-service/internal/handlers/antreanbpjs" authHandlers "api-service/internal/handlers/auth" healthcheckHandlers "api-service/internal/handlers/healthcheck" - pesertaHandlers "api-service/internal/handlers/peserta" - retribusiHandlers "api-service/internal/handlers/retribusi" "api-service/internal/middleware" services "api-service/internal/services/auth" "api-service/pkg/logger" + "context" + "os" + "os/signal" + "syscall" "time" "github.com/gin-gonic/gin" - "github.com/go-playground/validator/v10" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) @@ -111,37 +114,35 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { // PUBLISHED ROUTES // ============================================================================= - // Participant eligibility information (peserta) routes - pesertaHandler := pesertaHandlers.NewPesertaHandler(pesertaHandlers.PesertaHandlerConfig{ + // Retribusi endpoints with WebSocket notifications + + antreanbpjsHandler := antreanbpjs.NewVClaimHandler(antreanbpjs.VClaimHandlerConfig{ Config: cfg, Logger: *logger.Default(), - Validator: validator.New(), + Validator: nil, }) - pesertaGroup := v1.Group("/peserta") - pesertaGroup.GET("/nokartu/:nokartu", pesertaHandler.GetBynokartu) - pesertaGroup.GET("/nik/:nik", pesertaHandler.GetBynik) + antreanbpjsGroup := v1.Group("/poli") + antreanbpjsGroup.GET("/ketersediaan", antreanbpjsHandler.GetPoli) + antreanbpjsGroup.GET("/apapun", antreanbpjsHandler.Gethealth) - // Retribusi endpoints with WebSocket notifications - retribusiHandler := retribusiHandlers.NewRetribusiHandler() - retribusiGroup := v1.Group("/retribusi") + aplicaresHandler := AplicareHandler.NewAplicaresHandler(AplicareHandler.AplicaresHandlerConfig{ + Config: cfg, + Logger: *logger.Default(), + Validator: nil, + }) + + // Start background scheduler — berhenti otomatis saat app shutdown + ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + aplicaresHandler.StartScheduler(ctx) + + ag := v1.Group("/aplicares") { - retribusiGroup.GET("", retribusiHandler.GetRetribusi) - retribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic) - retribusiGroup.GET("/search", retribusiHandler.SearchRetribusiAdvanced) - retribusiGroup.GET("/id/:id", retribusiHandler.GetRetribusiByID) - - // POST/PUT/DELETE with automatic WebSocket notifications - retribusiGroup.POST("", func(c *gin.Context) { - retribusiHandler.CreateRetribusi(c) - }) - - retribusiGroup.PUT("/id/:id", func(c *gin.Context) { - retribusiHandler.UpdateRetribusi(c) - }) - - retribusiGroup.DELETE("/id/:id", func(c *gin.Context) { - retribusiHandler.DeleteRetribusi(c) - }) + ag.GET("/beds", aplicaresHandler.GetBeds) + ag.GET("/state", aplicaresHandler.GetState) + ag.POST("/sync", aplicaresHandler.TriggerSync) + ag.GET("/check-bpjs", aplicaresHandler.CheckBPJS) + ag.GET("/ref/kelas", aplicaresHandler.GetRefKelas) + ag.GET("/logs", aplicaresHandler.GetSyncLogs) } // ============================================================================= @@ -151,23 +152,6 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { protected := v1.Group("/") protected.Use(middleware.ConfigurableAuthMiddleware(cfg)) // Protected retribusi endpoints (Authentication Required) - protectedRetribusiGroup := protected.Group("/retribusi") - { - protectedRetribusiGroup.GET("", retribusiHandler.GetRetribusi) - protectedRetribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic) - protectedRetribusiGroup.GET("/search", retribusiHandler.SearchRetribusiAdvanced) - protectedRetribusiGroup.GET("/id/:id", retribusiHandler.GetRetribusiByID) - protectedRetribusiGroup.POST("", func(c *gin.Context) { - retribusiHandler.CreateRetribusi(c) - }) - protectedRetribusiGroup.PUT("/id/:id", func(c *gin.Context) { - retribusiHandler.UpdateRetribusi(c) - }) - - protectedRetribusiGroup.DELETE("/id/:id", func(c *gin.Context) { - retribusiHandler.DeleteRetribusi(c) - }) - } return router } diff --git a/internal/services/bpjs/vclaimBridge.go b/internal/services/bpjs/vclaimBridge.go index ba48472..aaf2a5f 100644 --- a/internal/services/bpjs/vclaimBridge.go +++ b/internal/services/bpjs/vclaimBridge.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "time" "unicode" @@ -39,20 +40,20 @@ type Service struct { } // Response structures +// Gunakan di struct +type MetadataStruct struct { + Code json.Number `json:"code"` + Message string `json:"message"` +} + type ResponMentahDTOVclaim struct { - MetaData struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"metaData"` - Response string `json:"response"` + Metadata MetadataStruct `json:"metadata"` + Response interface{} `json:"response"` } type ResponDTOVclaim struct { - MetaData struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"metaData"` - Response interface{} `json:"response"` + Metadata MetadataStruct `json:"metadata"` + Response interface{} `json:"response"` } // NewService creates a new VClaim service instance @@ -62,21 +63,33 @@ func NewService(cfg config.BpjsConfig) VClaimService { Dur("timeout", cfg.Timeout). Msg("Creating new VClaim service instance") + // Custom transport dengan konfigurasi lebih agresif + transport := &http.Transport{ + TLSHandshakeTimeout: 15 * time.Second, // Timeout untuk SSL handshake + ResponseHeaderTimeout: 30 * time.Second, // Timeout menunggu response header + ExpectContinueTimeout: 2 * time.Second, + IdleConnTimeout: 90 * time.Second, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + DisableKeepAlives: false, // Aktifkan keep-alive + ForceAttemptHTTP2: true, // Coba gunakan HTTP/2 + } + service := &Service{ config: cfg, httpClient: &http.Client{ - Timeout: cfg.Timeout, + Timeout: cfg.Timeout, // Total timeout (default 30s) + Transport: transport, }, } + return service } -// NewServiceFromConfig creates service from main config func NewServiceFromConfig(cfg *config.Config) VClaimService { return NewService(cfg.Bpjs) } -// NewServiceFromInterface creates service from interface (for backward compatibility) func NewServiceFromInterface(cfg interface{}) (VClaimService, error) { var bpjsConfig config.BpjsConfig @@ -93,12 +106,10 @@ func NewServiceFromInterface(cfg interface{}) (VClaimService, error) { return NewService(bpjsConfig), nil } -// SetHTTPClient allows custom http client configuration func (s *Service) SetHTTPClient(client *http.Client) { s.httpClient = client } -// prepareRequest prepares HTTP request with required headers func (s *Service) prepareRequest(ctx context.Context, method, endpoint string, body io.Reader) (*http.Request, string, string, string, string, error) { fullURL := s.config.BaseURL + endpoint @@ -141,7 +152,6 @@ func (s *Service) prepareRequest(ctx context.Context, method, endpoint string, b // processResponse processes response from VClaim API func (s *Service) processResponse(res *http.Response, consID, secretKey, tstamp string) (*ResponDTOVclaim, error) { defer res.Body.Close() - body, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) @@ -151,78 +161,46 @@ func (s *Service) processResponse(res *http.Response, consID, secretKey, tstamp return nil, fmt.Errorf("HTTP error: %d - %s", res.StatusCode, string(body)) } - // Parse raw response var respMentah ResponMentahDTOVclaim if err := json.Unmarshal(body, &respMentah); err != nil { return nil, fmt.Errorf("failed to unmarshal raw response: %w", err) } - // Create final response finalResp := &ResponDTOVclaim{ - MetaData: respMentah.MetaData, + Metadata: respMentah.Metadata, } - // Check if response needs decryption - if respMentah.Response == "" { - return finalResp, nil - } - - // Try to parse as JSON first (unencrypted response) - var tempResp interface{} - if json.Unmarshal([]byte(respMentah.Response), &tempResp) == nil { - finalResp.Response = tempResp - return finalResp, nil - } - - // Check if response looks like HTML or error message (don't try to decrypt) - if strings.HasPrefix(respMentah.Response, "<") || strings.Contains(respMentah.Response, "error") { - finalResp.Response = respMentah.Response - return finalResp, nil - } - - // Decrypt response using the same timestamp from the request - decryptionKey := consID + secretKey + tstamp - - log.Debug(). - Str("consID", consID). - Str("tstamp", tstamp). - Int("key_length", len(decryptionKey)). - Msg("Decryption key components") - - respDecrypt, err := ResponseVclaim(respMentah.Response, decryptionKey) - if err != nil { - log.Error().Err(err).Msg("Failed to decrypt response") - return nil, fmt.Errorf("failed to decrypt response: %w", err) - } - - // Try to unmarshal decrypted response as JSON - if respDecrypt != "" { - // Clean the decrypted response - respDecrypt = cleanResponse(respDecrypt) - - // Try multiple cleaning strategies - cleaningStrategies := []string{ - respDecrypt, - strings.TrimLeft(respDecrypt, "\ufeff\xfe\xef\xbb\xbf"), - strings.TrimLeftFunc(respDecrypt, func(r rune) bool { return r < 32 && r != '\n' && r != '\r' && r != '\t' }), + // Check tipe response + switch v := respMentah.Response.(type) { + case string: + // Response berupa string (mungkin encrypted) + if v == "" { + return finalResp, nil } - var jsonParseSuccess bool - for i, cleaned := range cleaningStrategies { - if err := json.Unmarshal([]byte(cleaned), &finalResp.Response); err == nil { - log.Info(). - Int("strategy", i+1). - Msg("Successfully parsed JSON with cleaning strategy") - jsonParseSuccess = true - break - } + // Coba decrypt jika terenkripsi + decryptionKey := consID + secretKey + tstamp + respDecrypt, err := ResponseVclaim(v, decryptionKey) + if err != nil { + log.Error().Err(err).Msg("Failed to decrypt response") + finalResp.Response = v // Simpan string asli jika gagal decrypt + return finalResp, nil } - if !jsonParseSuccess { - // If all JSON parsing fails, store as string - log.Warn().Msg("All JSON parsing strategies failed, storing as string") + // Parse hasil decrypt + var tempResp interface{} + if json.Unmarshal([]byte(respDecrypt), &tempResp) == nil { + finalResp.Response = tempResp + } else { finalResp.Response = respDecrypt } + + case map[string]interface{}, []interface{}: + // Response sudah berupa object/array (tidak terenkripsi) + finalResp.Response = v + + default: + finalResp.Response = v } return finalResp, nil @@ -324,17 +302,36 @@ func (s *Service) Patch(ctx context.Context, endpoint string, payload interface{ // GetRawResponse returns raw response without mapping func (s *Service) GetRawResponse(ctx context.Context, endpoint string) (*ResponDTOVclaim, error) { - req, consID, secretKey, tstamp, _, err := s.prepareRequest(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, err + maxRetries := 3 + var lastErr error + + for attempt := 1; attempt <= maxRetries; attempt++ { + req, consID, secretKey, tstamp, _, err := s.prepareRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + res, err := s.httpClient.Do(req) + if err != nil { + lastErr = err + log.Warn(). + Int("attempt", attempt). + Int("max_retries", maxRetries). + Err(err). + Msg("Request failed, retrying...") + + // Tunggu sebelum retry (exponential backoff) + if attempt < maxRetries { + time.Sleep(time.Duration(attempt) * time.Second) + continue + } + return nil, fmt.Errorf("failed to execute GET request after %d attempts: %w", maxRetries, lastErr) + } + + return s.processResponse(res, consID, secretKey, tstamp) } - res, err := s.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute GET request: %w", err) - } - - return s.processResponse(res, consID, secretKey, tstamp) + return nil, lastErr } // PostRawResponse returns raw response without mapping @@ -562,3 +559,23 @@ func findMatchingBrace(s string) int { return -1 } + +type FlexibleCode string + +func (fc *FlexibleCode) UnmarshalJSON(data []byte) error { + // Coba unmarshal sebagai string + var s string + if err := json.Unmarshal(data, &s); err == nil { + *fc = FlexibleCode(s) + return nil + } + + // Coba unmarshal sebagai number + var n int + if err := json.Unmarshal(data, &n); err == nil { + *fc = FlexibleCode(strconv.Itoa(n)) + return nil + } + + return fmt.Errorf("code must be string or number") +} diff --git a/logs/sync.log b/logs/sync.log new file mode 100644 index 0000000..25cd933 --- /dev/null +++ b/logs/sync.log @@ -0,0 +1,157 @@ +{"action":"batch_sync","changed":56,"dry_run":true,"errors":null,"flushed":0,"posted":56,"status":"sukses","timestamp":"2026-04-21T03:40:45Z","total_rooms":56} +{"action":"batch_sync","changed":2,"dry_run":true,"errors":null,"flushed":0,"posted":2,"status":"sukses","timestamp":"2026-04-21T03:45:45Z","total_rooms":56} +{"action":"batch_sync","changed":2,"dry_run":true,"errors":null,"flushed":0,"posted":2,"status":"sukses","timestamp":"2026-04-21T03:50:45Z","total_rooms":56} +{"action":"batch_sync","changed":1,"dry_run":true,"errors":null,"flushed":0,"posted":1,"status":"sukses","timestamp":"2026-04-21T03:52:49Z","total_rooms":56} +{"action":"batch_sync","changed":0,"dry_run":false,"errors":null,"flushed":9,"posted":0,"status":"sukses","timestamp":"2026-04-21T03:55:45Z","total_rooms":56} +{"action":"batch_sync","changed":56,"dry_run":true,"errors":null,"flushed":0,"posted":56,"status":"sukses","timestamp":"2026-04-21T03:58:55Z","total_rooms":56} +{"action":"batch_sync","changed":0,"dry_run":true,"errors":null,"flushed":0,"posted":0,"status":"sukses","timestamp":"2026-04-21T04:02:16Z","total_rooms":56} +{"action":"batch_sync","changed":0,"dry_run":true,"errors":null,"flushed":0,"posted":0,"status":"sukses","timestamp":"2026-04-21T04:07:16Z","total_rooms":56} +{"timestamp":"2026-04-21T04:09:09Z","kode_ruang":"IMEL1","nama_ruang":"RUANG ICU INFEKSI MELATI","kode_kelas":"ISO","kapasitas":6,"tersedia":3,"action":"post","status":"sukses","response_ms":1592} +{"timestamp":"2026-04-21T04:09:10Z","kode_ruang":"BUNA2","nama_ruang":"RUANG BUNAKEN KELAS 2","kode_kelas":"KL2","kapasitas":6,"tersedia":1,"action":"post","status":"sukses","response_ms":1388} +{"timestamp":"2026-04-21T04:09:12Z","kode_ruang":"BUNA3","nama_ruang":"RUANG BUNAKEN KELAS 3","kode_kelas":"KL3","kapasitas":16,"tersedia":1,"action":"post","status":"sukses","response_ms":1990} +{"timestamp":"2026-04-21T04:09:14Z","kode_ruang":"GILI1","nama_ruang":"RUANG GILI TRAWANGAN KELAS 1","kode_kelas":"KL1","kapasitas":2,"tersedia":2,"action":"post","status":"sukses","response_ms":1871} +{"timestamp":"2026-04-21T04:09:16Z","kode_ruang":"GILI3","nama_ruang":"RUANG GILI TRAWANGAN KELAS 3","kode_kelas":"KL3","kapasitas":9,"tersedia":2,"action":"post","status":"sukses","response_ms":2122} +{"timestamp":"2026-04-21T04:09:19Z","kode_ruang":"CILI2","nama_ruang":"RUANG HCU CILIWUNG","kode_kelas":"HCU","kapasitas":28,"tersedia":1,"action":"post","status":"sukses","response_ms":2532} +{"timestamp":"2026-04-21T04:09:20Z","kode_ruang":"MAHA2","nama_ruang":"RUANG HCU MAHAKAM","kode_kelas":"HCU","kapasitas":20,"tersedia":10,"action":"post","status":"sukses","response_ms":1468} +{"timestamp":"2026-04-21T04:09:22Z","kode_ruang":"BRAN2","nama_ruang":"RUANG HCU BRANTAS","kode_kelas":"HCU","kapasitas":9,"tersedia":4,"action":"post","status":"sukses","response_ms":1479} +{"timestamp":"2026-04-21T04:09:23Z","kode_ruang":"PANG3","nama_ruang":"RUANG PANGANDARAN","kode_kelas":"KL3","kapasitas":35,"tersedia":1,"action":"post","status":"sukses","response_ms":1475} +{"timestamp":"2026-04-21T04:09:25Z","kode_ruang":"PARA3","nama_ruang":"RUANG PARANGTRITIS","kode_kelas":"KL3","kapasitas":30,"tersedia":2,"action":"post","status":"sukses","response_ms":1487} +{"timestamp":"2026-04-21T04:09:26Z","kode_ruang":"BROM3","nama_ruang":"RUANG BROMO KELAS 3","kode_kelas":"KL3","kapasitas":42,"tersedia":8,"action":"post","status":"sukses","response_ms":1325} +{"timestamp":"2026-04-21T04:09:28Z","kode_ruang":"RINJ1","nama_ruang":"RUANG RINJANI KELAS 1","kode_kelas":"KL1","kapasitas":2,"tersedia":0,"action":"post","status":"sukses","response_ms":2026} +{"timestamp":"2026-04-21T04:09:30Z","kode_ruang":"RINJ2","nama_ruang":"RUANG RINJANI KELAS 2","kode_kelas":"KL2","kapasitas":2,"tersedia":0,"action":"post","status":"sukses","response_ms":1766} +{"timestamp":"2026-04-21T04:09:32Z","kode_ruang":"RINJ3","nama_ruang":"RUANG RINJANI KELAS 3","kode_kelas":"KL3","kapasitas":18,"tersedia":4,"action":"post","status":"sukses","response_ms":1818} +{"timestamp":"2026-04-21T04:09:33Z","kode_ruang":"GALG3","nama_ruang":"RUANG GALUNGGUNG KELAS 3","kode_kelas":"KL3","kapasitas":16,"tersedia":0,"action":"post","status":"sukses","response_ms":1448} +{"timestamp":"2026-04-21T04:09:35Z","kode_ruang":"RANKB3","nama_ruang":"RUANG RANU KUMBOLO (BAYI) KELAS 3","kode_kelas":"KL3","kapasitas":1,"tersedia":1,"action":"post","status":"sukses","response_ms":1932} +{"timestamp":"2026-04-21T04:09:36Z","kode_ruang":"KELI1","nama_ruang":"RUANG KELIMUTU KELAS 1","kode_kelas":"KL1","kapasitas":16,"tersedia":3,"action":"post","status":"sukses","response_ms":1031} +{"timestamp":"2026-04-21T04:09:38Z","kode_ruang":"KELI2","nama_ruang":"RUANG KELIMUTU KELAS 2","kode_kelas":"KL2","kapasitas":8,"tersedia":0,"action":"post","status":"sukses","response_ms":1463} +{"timestamp":"2026-04-21T04:09:39Z","kode_ruang":"SARA2","nama_ruang":"RUANG HCU SARANGAN","kode_kelas":"HCU","kapasitas":11,"tersedia":3,"action":"post","status":"sukses","response_ms":1702} +{"timestamp":"2026-04-21T04:09:41Z","kode_ruang":"TOND3","nama_ruang":"RUANG TONDANO","kode_kelas":"KL3","kapasitas":50,"tersedia":19,"action":"post","status":"sukses","response_ms":1545} +{"timestamp":"2026-04-21T04:09:42Z","kode_ruang":"KRAK1","nama_ruang":"RUANG PICU KRAKATAU","kode_kelas":"PIC","kapasitas":17,"tersedia":1,"action":"post","status":"sukses","response_ms":1005} +{"timestamp":"2026-04-21T04:09:44Z","kode_ruang":"BARIV","nama_ruang":"RUANG BARITO VIP","kode_kelas":"VIP","kapasitas":2,"tersedia":1,"action":"post","status":"sukses","response_ms":1799} +{"timestamp":"2026-04-21T04:09:46Z","kode_ruang":"MUSI1","nama_ruang":"RUANG CVCU MUSI","kode_kelas":"ICC","kapasitas":13,"tersedia":3,"action":"post","status":"sukses","response_ms":2369} +{"timestamp":"2026-04-21T04:09:48Z","kode_ruang":"GILI2","nama_ruang":"RUANG GILI TRAWANGAN KELAS 2","kode_kelas":"KL2","kapasitas":4,"tersedia":2,"action":"post","status":"sukses","response_ms":1342} +{"timestamp":"2026-04-21T04:09:49Z","kode_ruang":"BARI1","nama_ruang":"RUANG BARITO KELAS 1","kode_kelas":"KL1","kapasitas":6,"tersedia":0,"action":"post","status":"sukses","response_ms":1541} +{"timestamp":"2026-04-21T04:09:50Z","kode_ruang":"BENG2","nama_ruang":"RUANG BENGAWAN SOLO KELAS 2","kode_kelas":"KL2","kapasitas":4,"tersedia":0,"action":"post","status":"sukses","response_ms":1161} +{"timestamp":"2026-04-21T04:09:51Z","kode_ruang":"TOIB1","nama_ruang":"RUANG TOBA (IBU) KELAS 1","kode_kelas":"KL1","kapasitas":10,"tersedia":6,"action":"post","status":"sukses","response_ms":1126} +{"timestamp":"2026-04-21T04:09:53Z","kode_ruang":"BARI2","nama_ruang":"RUANG BARITO KELAS 2","kode_kelas":"KL2","kapasitas":4,"tersedia":0,"action":"post","status":"sukses","response_ms":1455} +{"timestamp":"2026-04-21T04:09:54Z","kode_ruang":"BARI3","nama_ruang":"RUANG BARITO KELAS 3","kode_kelas":"KL3","kapasitas":11,"tersedia":1,"action":"post","status":"sukses","response_ms":1349} +{"timestamp":"2026-04-21T04:09:56Z","kode_ruang":"TOBA1","nama_ruang":"RUANG TOBA (BAYI) KELAS 1","kode_kelas":"KL1","kapasitas":1,"tersedia":1,"action":"post","status":"sukses","response_ms":1832} +{"timestamp":"2026-04-21T04:09:57Z","kode_ruang":"CISA2","nama_ruang":"RUANG HCU CISADANE","kode_kelas":"HCU","kapasitas":40,"tersedia":3,"action":"post","status":"sukses","response_ms":1430} +{"timestamp":"2026-04-21T04:09:58Z","kode_ruang":"SEME3","nama_ruang":"RUANG SEMERU","kode_kelas":"KL3","kapasitas":43,"tersedia":22,"action":"post","status":"sukses","response_ms":930} +{"timestamp":"2026-04-21T04:10:00Z","kode_ruang":"RANU2","nama_ruang":"RUANG HCU RANU GRATI","kode_kelas":"HCU","kapasitas":8,"tersedia":0,"action":"post","status":"sukses","response_ms":1530} +{"timestamp":"2026-04-21T04:10:03Z","kode_ruang":"TOIB2","nama_ruang":"RUANG TOBA (IBU) KELAS 2","kode_kelas":"KL2","kapasitas":8,"tersedia":4,"action":"post","status":"sukses","response_ms":3495} +{"timestamp":"2026-04-21T04:10:06Z","kode_ruang":"KAPA1","nama_ruang":"RUANG ICU KAPUAS A","kode_kelas":"ICU","kapasitas":16,"tersedia":2,"action":"post","status":"sukses","response_ms":2876} +{"timestamp":"2026-04-21T04:10:09Z","kode_ruang":"KAPB1","nama_ruang":"RUANG ICU KAPUAS B","kode_kelas":"ICU","kapasitas":9,"tersedia":0,"action":"post","status":"sukses","response_ms":3216} +{"timestamp":"2026-04-21T04:10:12Z","kode_ruang":"HMEL1","nama_ruang":"RUANG HCU INFEKSI MELATI","kode_kelas":"ISO","kapasitas":8,"tersedia":7,"action":"post","status":"sukses","response_ms":2287} +{"timestamp":"2026-04-21T04:10:15Z","kode_ruang":"BUGV3","nama_ruang":"RUANG BUGENVILE KELAS 3","kode_kelas":"ISO","kapasitas":20,"tersedia":8,"action":"post","status":"sukses","response_ms":2768} +{"timestamp":"2026-04-21T04:10:16Z","kode_ruang":"KAPC1","nama_ruang":"RUANG ICU KAPUAS C KELAS 1","kode_kelas":"ICU","kapasitas":14,"tersedia":6,"action":"post","status":"sukses","response_ms":1876} +{"timestamp":"2026-04-21T04:10:20Z","kode_ruang":"DAHL1","nama_ruang":"RUANG DAHLIA KELAS 1","kode_kelas":"KL1","kapasitas":38,"tersedia":11,"action":"post","status":"sukses","response_ms":3359} +{"timestamp":"2026-04-21T04:10:23Z","kode_ruang":"MWAR1","nama_ruang":"RUANG MAWAR KELAS 1","kode_kelas":"KL1","kapasitas":30,"tersedia":10,"action":"post","status":"sukses","response_ms":3093} +{"timestamp":"2026-04-21T04:10:25Z","kode_ruang":"JIMB2","nama_ruang":"RUANG JIMBARAN KELAS 2","kode_kelas":"KL2","kapasitas":28,"tersedia":5,"action":"post","status":"sukses","response_ms":2189} +{"timestamp":"2026-04-21T04:10:28Z","kode_ruang":"KERC2","nama_ruang":"RUANG KERINCI KELAS 2","kode_kelas":"KL2","kapasitas":8,"tersedia":1,"action":"post","status":"sukses","response_ms":2597} +{"timestamp":"2026-04-21T04:10:30Z","kode_ruang":"RANU3","nama_ruang":"RUANG RANU KUMBOLO KELAS 3","kode_kelas":"KL3","kapasitas":18,"tersedia":6,"action":"post","status":"sukses","response_ms":2015} +{"timestamp":"2026-04-21T04:10:32Z","kode_ruang":"ROE2","nama_ruang":"RUANG ROE KELAS 2","kode_kelas":"KL2","kapasitas":10,"tersedia":5,"action":"post","status":"sukses","response_ms":2772} +{"timestamp":"2026-04-21T04:10:35Z","kode_ruang":"RGPLT3","nama_ruang":"RUANG GRAND PAV LANTAI 3 KELAS VIP A","kode_kelas":"VIP","kapasitas":24,"tersedia":17,"action":"post","status":"sukses","response_ms":2991} +{"timestamp":"2026-04-21T04:10:38Z","kode_ruang":"RNS1","nama_ruang":"RUANG NUSA DUA KELAS 1","kode_kelas":"KL1","kapasitas":20,"tersedia":2,"action":"post","status":"sukses","response_ms":2976} +{"timestamp":"2026-04-21T04:10:42Z","kode_ruang":"RHCKW2","nama_ruang":"RUANG HCU KAWI KELAS 2","kode_kelas":"HCU","kapasitas":9,"tersedia":7,"action":"post","status":"sukses","response_ms":3383} +{"timestamp":"2026-04-21T04:10:44Z","kode_ruang":"RTOBY2","nama_ruang":"RUANG TOBA (BAYI) KELAS 2","kode_kelas":"KL2","kapasitas":1,"tersedia":1,"action":"post","status":"sukses","response_ms":2196} +{"timestamp":"2026-04-21T04:10:46Z","kode_ruang":"RSING3","nama_ruang":"RUANG SINGKARAK KELAS 3","kode_kelas":"KL3","kapasitas":30,"tersedia":16,"action":"post","status":"sukses","response_ms":2394} +{"timestamp":"2026-04-21T04:10:50Z","kode_ruang":"RHRP3","nama_ruang":"RUANG HCU RANU PANE KELAS 2","kode_kelas":"HCU","kapasitas":38,"tersedia":27,"action":"post","status":"sukses","response_ms":3811} +{"timestamp":"2026-04-21T04:10:53Z","kode_ruang":"RNICU1","nama_ruang":"RUANG NICU MANINJAU KELAS 1","kode_kelas":"NIC","kapasitas":12,"tersedia":1,"action":"post","status":"sukses","response_ms":2454} +{"timestamp":"2026-04-21T04:10:56Z","kode_ruang":"RGPLT4","nama_ruang":"RUANG GRAND PAV LANTAI 4 KELAS VIP A","kode_kelas":"VIP","kapasitas":24,"tersedia":24,"action":"post","status":"sukses","response_ms":3365} +{"timestamp":"2026-04-21T04:10:57Z","kode_ruang":"RGPLT21","nama_ruang":"RUANG GRAND PAV LANTAI 2 KELAS 1","kode_kelas":"KL1","kapasitas":12,"tersedia":11,"action":"post","status":"sukses","response_ms":1020} +{"timestamp":"2026-04-21T04:10:58Z","kode_ruang":"RGPLT7VIP","nama_ruang":"RUANG GRAND PAV LANTAI 7 KELAS VIP","kode_kelas":"VIP","kapasitas":3,"tersedia":3,"action":"post","status":"sukses","response_ms":1302} +{"timestamp":"2026-04-21T04:11:02Z","kode_ruang":"RGPLT2VIP","nama_ruang":"RUANG GRAND PAV LANTAI 2 KELAS VIP","kode_kelas":"VIP","kapasitas":5,"tersedia":4,"action":"post","status":"sukses","response_ms":3382} +{"action":"batch_sync","changed":56,"dry_run":false,"errors":null,"flushed":0,"posted":56,"status":"sukses","timestamp":"2026-04-21T04:11:02Z","total_rooms":56} +{"timestamp":"2026-04-21T04:15:20Z","kode_ruang":"JIMB2","nama_ruang":"RUANG JIMBARAN KELAS 2","kode_kelas":"KL2","kapasitas":28,"tersedia":4,"action":"post","status":"sukses","response_ms":1092} +{"action":"batch_sync","changed":1,"dry_run":false,"errors":null,"flushed":0,"posted":1,"status":"sukses","timestamp":"2026-04-21T04:15:20Z","total_rooms":56} +{"timestamp":"2026-04-22T01:05:16Z","kode_ruang":"IMEL1","nama_ruang":"RUANG ICU INFEKSI MELATI","kode_kelas":"ISO","kapasitas":6,"tersedia":2,"action":"post","status":"sukses","response_ms":1715} +{"timestamp":"2026-04-22T01:05:18Z","kode_ruang":"BUNA2","nama_ruang":"RUANG BUNAKEN KELAS 2","kode_kelas":"KL2","kapasitas":6,"tersedia":1,"action":"post","status":"sukses","response_ms":1875} +{"timestamp":"2026-04-22T01:05:19Z","kode_ruang":"BUNA3","nama_ruang":"RUANG BUNAKEN KELAS 3","kode_kelas":"KL3","kapasitas":16,"action":"post","status":"sukses","response_ms":1021} +{"timestamp":"2026-04-22T01:05:20Z","kode_ruang":"GILI1","nama_ruang":"RUANG GILI TRAWANGAN KELAS 1","kode_kelas":"KL1","kapasitas":2,"tersedia":2,"action":"post","status":"sukses","response_ms":530} +{"timestamp":"2026-04-22T01:05:21Z","kode_ruang":"GILI3","nama_ruang":"RUANG GILI TRAWANGAN KELAS 3","kode_kelas":"KL3","kapasitas":9,"tersedia":2,"action":"post","status":"sukses","response_ms":1133} +{"timestamp":"2026-04-22T01:05:22Z","kode_ruang":"CILI2","nama_ruang":"RUANG HCU CILIWUNG","kode_kelas":"HCU","kapasitas":28,"tersedia":2,"action":"post","status":"sukses","response_ms":1412} +{"timestamp":"2026-04-22T01:05:23Z","kode_ruang":"MAHA2","nama_ruang":"RUANG HCU MAHAKAM","kode_kelas":"HCU","kapasitas":20,"tersedia":10,"action":"post","status":"sukses","response_ms":888} +{"timestamp":"2026-04-22T01:05:24Z","kode_ruang":"BRAN2","nama_ruang":"RUANG HCU BRANTAS","kode_kelas":"HCU","kapasitas":9,"tersedia":4,"action":"post","status":"sukses","response_ms":854} +{"timestamp":"2026-04-22T01:05:25Z","kode_ruang":"PANG3","nama_ruang":"RUANG PANGANDARAN","kode_kelas":"KL3","kapasitas":35,"tersedia":2,"action":"post","status":"sukses","response_ms":925} +{"timestamp":"2026-04-22T01:05:26Z","kode_ruang":"PARA3","nama_ruang":"RUANG PARANGTRITIS","kode_kelas":"KL3","kapasitas":30,"action":"post","status":"sukses","response_ms":910} +{"timestamp":"2026-04-22T01:05:27Z","kode_ruang":"LOSA3","nama_ruang":"RUANG LOSARI KELAS 3","kode_kelas":"KL3","kapasitas":14,"tersedia":1,"action":"post","status":"sukses","response_ms":927} +{"timestamp":"2026-04-22T01:05:27Z","kode_ruang":"BROM3","nama_ruang":"RUANG BROMO KELAS 3","kode_kelas":"KL3","kapasitas":42,"tersedia":3,"action":"post","status":"sukses","response_ms":575} +{"timestamp":"2026-04-22T01:05:28Z","kode_ruang":"RINJ1","nama_ruang":"RUANG RINJANI KELAS 1","kode_kelas":"KL1","kapasitas":2,"tersedia":1,"action":"post","status":"sukses","response_ms":884} +{"timestamp":"2026-04-22T01:05:29Z","kode_ruang":"RINJ2","nama_ruang":"RUANG RINJANI KELAS 2","kode_kelas":"KL2","kapasitas":2,"action":"post","status":"sukses","response_ms":1380} +{"timestamp":"2026-04-22T01:05:31Z","kode_ruang":"RINJ3","nama_ruang":"RUANG RINJANI KELAS 3","kode_kelas":"KL3","kapasitas":18,"tersedia":4,"action":"post","status":"sukses","response_ms":1046} +{"timestamp":"2026-04-22T01:05:31Z","kode_ruang":"GALG3","nama_ruang":"RUANG GALUNGGUNG KELAS 3","kode_kelas":"KL3","kapasitas":16,"tersedia":4,"action":"post","status":"sukses","response_ms":530} +{"timestamp":"2026-04-22T01:05:32Z","kode_ruang":"RANKB3","nama_ruang":"RUANG RANU KUMBOLO (BAYI) KELAS 3","kode_kelas":"KL3","kapasitas":1,"tersedia":1,"action":"post","status":"sukses","response_ms":476} +{"timestamp":"2026-04-22T01:05:32Z","kode_ruang":"KELI1","nama_ruang":"RUANG KELIMUTU KELAS 1","kode_kelas":"KL1","kapasitas":16,"tersedia":6,"action":"post","status":"sukses","response_ms":969} +{"timestamp":"2026-04-22T01:05:33Z","kode_ruang":"KELI2","nama_ruang":"RUANG KELIMUTU KELAS 2","kode_kelas":"KL2","kapasitas":8,"tersedia":1,"action":"post","status":"sukses","response_ms":546} +{"timestamp":"2026-04-22T01:05:34Z","kode_ruang":"SARA2","nama_ruang":"RUANG HCU SARANGAN","kode_kelas":"HCU","kapasitas":11,"tersedia":4,"action":"post","status":"sukses","response_ms":475} +{"timestamp":"2026-04-22T01:05:34Z","kode_ruang":"TOND3","nama_ruang":"RUANG TONDANO","kode_kelas":"KL3","kapasitas":50,"tersedia":20,"action":"post","status":"sukses","response_ms":466} +{"timestamp":"2026-04-22T01:05:34Z","kode_ruang":"KRAK1","nama_ruang":"RUANG PICU KRAKATAU","kode_kelas":"PIC","kapasitas":17,"tersedia":2,"action":"post","status":"sukses","response_ms":474} +{"timestamp":"2026-04-22T01:05:35Z","kode_ruang":"BARIV","nama_ruang":"RUANG BARITO VIP","kode_kelas":"VIP","kapasitas":2,"tersedia":1,"action":"post","status":"sukses","response_ms":990} +{"timestamp":"2026-04-22T01:05:36Z","kode_ruang":"MUSI1","nama_ruang":"RUANG CVCU MUSI","kode_kelas":"ICC","kapasitas":13,"tersedia":1,"action":"post","status":"sukses","response_ms":529} +{"timestamp":"2026-04-22T01:05:36Z","kode_ruang":"GILI2","nama_ruang":"RUANG GILI TRAWANGAN KELAS 2","kode_kelas":"KL2","kapasitas":4,"tersedia":2,"action":"post","status":"sukses","response_ms":502} +{"timestamp":"2026-04-22T01:05:37Z","kode_ruang":"BARI1","nama_ruang":"RUANG BARITO KELAS 1","kode_kelas":"KL1","kapasitas":6,"tersedia":2,"action":"post","status":"sukses","response_ms":496} +{"timestamp":"2026-04-22T01:05:38Z","kode_ruang":"BENG2","nama_ruang":"RUANG BENGAWAN SOLO KELAS 2","kode_kelas":"KL2","kapasitas":4,"action":"post","status":"sukses","response_ms":509} +{"timestamp":"2026-04-22T01:05:38Z","kode_ruang":"TOIB1","nama_ruang":"RUANG TOBA (IBU) KELAS 1","kode_kelas":"KL1","kapasitas":10,"tersedia":7,"action":"post","status":"sukses","response_ms":810} +{"timestamp":"2026-04-22T01:05:39Z","kode_ruang":"BARI2","nama_ruang":"RUANG BARITO KELAS 2","kode_kelas":"KL2","kapasitas":4,"action":"post","status":"sukses","response_ms":671} +{"timestamp":"2026-04-22T01:05:40Z","kode_ruang":"BARI3","nama_ruang":"RUANG BARITO KELAS 3","kode_kelas":"KL3","kapasitas":11,"action":"post","status":"sukses","response_ms":670} +{"timestamp":"2026-04-22T01:05:40Z","kode_ruang":"TOBA1","nama_ruang":"RUANG TOBA (BAYI) KELAS 1","kode_kelas":"KL1","kapasitas":1,"tersedia":1,"action":"post","status":"sukses","response_ms":501} +{"timestamp":"2026-04-22T01:05:41Z","kode_ruang":"CISA2","nama_ruang":"RUANG HCU CISADANE","kode_kelas":"HCU","kapasitas":40,"tersedia":8,"action":"post","status":"sukses","response_ms":506} +{"timestamp":"2026-04-22T01:05:42Z","kode_ruang":"SEME3","nama_ruang":"RUANG SEMERU","kode_kelas":"KL3","kapasitas":43,"tersedia":20,"action":"post","status":"sukses","response_ms":931} +{"timestamp":"2026-04-22T01:05:43Z","kode_ruang":"RANU2","nama_ruang":"RUANG HCU RANU GRATI","kode_kelas":"HCU","kapasitas":8,"tersedia":1,"action":"post","status":"sukses","response_ms":1347} +{"timestamp":"2026-04-22T01:05:43Z","kode_ruang":"TOIB2","nama_ruang":"RUANG TOBA (IBU) KELAS 2","kode_kelas":"KL2","kapasitas":8,"tersedia":2,"action":"post","status":"sukses","response_ms":508} +{"timestamp":"2026-04-22T01:05:44Z","kode_ruang":"KAPA1","nama_ruang":"RUANG ICU KAPUAS A","kode_kelas":"ICU","kapasitas":16,"tersedia":5,"action":"post","status":"sukses","response_ms":864} +{"timestamp":"2026-04-22T01:05:46Z","kode_ruang":"KAPB1","nama_ruang":"RUANG ICU KAPUAS B","kode_kelas":"ICU","kapasitas":9,"action":"post","status":"sukses","response_ms":1521} +{"timestamp":"2026-04-22T01:05:46Z","kode_ruang":"HMEL1","nama_ruang":"RUANG HCU INFEKSI MELATI","kode_kelas":"ISO","kapasitas":8,"tersedia":7,"action":"post","status":"sukses","response_ms":511} +{"timestamp":"2026-04-22T01:05:47Z","kode_ruang":"BUGV3","nama_ruang":"RUANG BUGENVILE KELAS 3","kode_kelas":"ISO","kapasitas":20,"tersedia":10,"action":"post","status":"sukses","response_ms":561} +{"timestamp":"2026-04-22T01:05:48Z","kode_ruang":"KAPC1","nama_ruang":"RUANG ICU KAPUAS C KELAS 1","kode_kelas":"ICU","kapasitas":14,"action":"post","status":"sukses","response_ms":926} +{"timestamp":"2026-04-22T01:05:48Z","kode_ruang":"DAHL1","nama_ruang":"RUANG DAHLIA KELAS 1","kode_kelas":"KL1","kapasitas":38,"tersedia":13,"action":"post","status":"sukses","response_ms":476} +{"timestamp":"2026-04-22T01:05:49Z","kode_ruang":"MWAR1","nama_ruang":"RUANG MAWAR KELAS 1","kode_kelas":"KL1","kapasitas":30,"tersedia":10,"action":"post","status":"sukses","response_ms":988} +{"timestamp":"2026-04-22T01:05:50Z","kode_ruang":"JIMB2","nama_ruang":"RUANG JIMBARAN KELAS 2","kode_kelas":"KL2","kapasitas":28,"tersedia":2,"action":"post","status":"sukses","response_ms":889} +{"timestamp":"2026-04-22T01:05:51Z","kode_ruang":"KERC2","nama_ruang":"RUANG KERINCI KELAS 2","kode_kelas":"KL2","kapasitas":8,"tersedia":3,"action":"post","status":"sukses","response_ms":860} +{"timestamp":"2026-04-22T01:05:52Z","kode_ruang":"KERC3","nama_ruang":"RUANG KERINCI KELAS 3","kode_kelas":"KL3","kapasitas":18,"tersedia":3,"action":"post","status":"sukses","response_ms":1094} +{"timestamp":"2026-04-22T01:05:53Z","kode_ruang":"RANU3","nama_ruang":"RUANG RANU KUMBOLO KELAS 3","kode_kelas":"KL3","kapasitas":18,"tersedia":7,"action":"post","status":"sukses","response_ms":532} +{"timestamp":"2026-04-22T01:05:53Z","kode_ruang":"ROE2","nama_ruang":"RUANG ROE KELAS 2","kode_kelas":"KL2","kapasitas":10,"tersedia":3,"action":"post","status":"sukses","response_ms":606} +{"timestamp":"2026-04-22T01:05:55Z","kode_ruang":"RGPLT3","nama_ruang":"RUANG GRAND PAV LANTAI 3 KELAS VIP A","kode_kelas":"VIP","kapasitas":24,"tersedia":14,"action":"post","status":"sukses","response_ms":1169} +{"timestamp":"2026-04-22T01:05:56Z","kode_ruang":"RNS1","nama_ruang":"RUANG NUSA DUA KELAS 1","kode_kelas":"KL1","kapasitas":20,"tersedia":3,"action":"post","status":"sukses","response_ms":1133} +{"timestamp":"2026-04-22T01:05:56Z","kode_ruang":"RHCKW2","nama_ruang":"RUANG HCU KAWI KELAS 2","kode_kelas":"HCU","kapasitas":9,"tersedia":7,"action":"post","status":"sukses","response_ms":676} +{"timestamp":"2026-04-22T01:05:57Z","kode_ruang":"RTOBY2","nama_ruang":"RUANG TOBA (BAYI) KELAS 2","kode_kelas":"KL2","kapasitas":1,"tersedia":1,"action":"post","status":"sukses","response_ms":503} +{"timestamp":"2026-04-22T01:05:58Z","kode_ruang":"RSING3","nama_ruang":"RUANG SINGKARAK KELAS 3","kode_kelas":"KL3","kapasitas":30,"tersedia":15,"action":"post","status":"sukses","response_ms":886} +{"timestamp":"2026-04-22T01:05:59Z","kode_ruang":"RHRP3","nama_ruang":"RUANG HCU RANU PANE KELAS 2","kode_kelas":"HCU","kapasitas":38,"tersedia":28,"action":"post","status":"sukses","response_ms":1069} +{"timestamp":"2026-04-22T01:06:00Z","kode_ruang":"RNICU1","nama_ruang":"RUANG NICU MANINJAU KELAS 1","kode_kelas":"NIC","kapasitas":12,"tersedia":3,"action":"post","status":"sukses","response_ms":948} +{"timestamp":"2026-04-22T01:06:01Z","kode_ruang":"RGPLT4","nama_ruang":"RUANG GRAND PAV LANTAI 4 KELAS VIP A","kode_kelas":"VIP","kapasitas":24,"tersedia":24,"action":"post","status":"sukses","response_ms":984} +{"timestamp":"2026-04-22T01:06:02Z","kode_ruang":"RGPLT21","nama_ruang":"RUANG GRAND PAV LANTAI 2 KELAS 1","kode_kelas":"KL1","kapasitas":12,"tersedia":11,"action":"post","status":"sukses","response_ms":946} +{"timestamp":"2026-04-22T01:06:03Z","kode_ruang":"RGPLT7VIP","nama_ruang":"RUANG GRAND PAV LANTAI 7 KELAS VIP","kode_kelas":"VIP","kapasitas":3,"tersedia":3,"action":"post","status":"sukses","response_ms":1243} +{"timestamp":"2026-04-22T01:06:03Z","kode_ruang":"RGPLT2VIP","nama_ruang":"RUANG GRAND PAV LANTAI 2 KELAS VIP","kode_kelas":"VIP","kapasitas":5,"tersedia":5,"action":"post","status":"sukses","response_ms":464} +{"action":"batch_sync","changed":58,"dry_run":false,"errors":null,"posted":58,"status":"sukses","timestamp":"2026-04-22T01:06:03Z","total_rooms":58} +{"timestamp":"2026-04-22T01:11:09Z","kode_ruang":"BROM3","nama_ruang":"RUANG BROMO KELAS 3","kode_kelas":"KL3","kapasitas":42,"tersedia":2,"action":"post","status":"gagal","error":"create kamar BROM3: Data tersebut sudah ada.","response_ms":5657} +{"action":"batch_sync","changed":1,"dry_run":false,"errors":["POST BROM3 gagal: create kamar BROM3: Data tersebut sudah ada."],"posted":0,"status":"gagal","timestamp":"2026-04-22T01:11:09Z","total_rooms":58} +{"timestamp":"2026-04-22T01:16:09Z","kode_ruang":"LOSA3","nama_ruang":"RUANG LOSARI KELAS 3","kode_kelas":"KL3","kapasitas":14,"action":"post","status":"gagal","error":"create kamar LOSA3: Data tersebut sudah ada.","response_ms":5187} +{"action":"batch_sync","changed":1,"dry_run":false,"errors":["POST LOSA3 gagal: create kamar LOSA3: Data tersebut sudah ada."],"posted":0,"status":"gagal","timestamp":"2026-04-22T01:16:09Z","total_rooms":58} +{"action":"batch_sync","changed":0,"dry_run":false,"errors":null,"posted":0,"status":"sukses","timestamp":"2026-04-22T01:21:03Z","total_rooms":58} +{"action":"batch_sync","changed":0,"dry_run":false,"errors":null,"posted":0,"status":"sukses","timestamp":"2026-04-22T01:26:03Z","total_rooms":58} +{"timestamp":"2026-04-22T01:31:12Z","kode_ruang":"RINJ3","nama_ruang":"RUANG RINJANI KELAS 3","kode_kelas":"KL3","kapasitas":18,"tersedia":3,"action":"post","status":"gagal","error":"create kamar RINJ3 gagal: POST aplicaresws/rest/bed/create/1323R001 gagal: Post \"https://apijkn.bpjs-kesehatan.go.id/aplicaresws/rest/bed/create/1323R001\": EOF","response_ms":8276} +{"timestamp":"2026-04-22T01:31:16Z","kode_ruang":"TOND3","nama_ruang":"RUANG TONDANO","kode_kelas":"KL3","kapasitas":50,"tersedia":21,"action":"post","status":"sukses","response_ms":3919} +{"timestamp":"2026-04-22T01:31:19Z","kode_ruang":"RANU3","nama_ruang":"RUANG RANU KUMBOLO KELAS 3","kode_kelas":"KL3","kapasitas":18,"tersedia":6,"action":"post","status":"sukses","response_ms":3562} +{"action":"batch_sync","changed":3,"dry_run":false,"errors":["POST RINJ3 gagal: create kamar RINJ3 gagal: POST aplicaresws/rest/bed/create/1323R001 gagal: Post \"https://apijkn.bpjs-kesehatan.go.id/aplicaresws/rest/bed/create/1323R001\": EOF"],"posted":2,"status":"partial","timestamp":"2026-04-22T01:31:19Z","total_rooms":58} +{"action":"batch_sync","changed":0,"dry_run":false,"errors":null,"posted":0,"status":"sukses","timestamp":"2026-04-22T01:36:03Z","total_rooms":58} +{"timestamp":"2026-04-22T01:41:06Z","kode_ruang":"RHRP3","nama_ruang":"RUANG HCU RANU PANE KELAS 2","kode_kelas":"HCU","kapasitas":38,"tersedia":27,"action":"post","status":"sukses","response_ms":2587} +{"timestamp":"2026-04-22T01:41:07Z","kode_ruang":"RGPLT2VIP","nama_ruang":"RUANG GRAND PAV LANTAI 2 KELAS VIP","kode_kelas":"VIP","kapasitas":5,"tersedia":4,"action":"post","status":"sukses","response_ms":1496} +{"action":"batch_sync","changed":2,"dry_run":false,"errors":null,"posted":2,"status":"sukses","timestamp":"2026-04-22T01:41:07Z","total_rooms":58} +{"timestamp":"2026-04-22T01:46:04Z","kode_ruang":"SEME3","nama_ruang":"RUANG SEMERU","kode_kelas":"KL3","kapasitas":43,"tersedia":19,"action":"post","status":"sukses","response_ms":923} +{"action":"batch_sync","changed":1,"dry_run":false,"errors":null,"posted":1,"status":"sukses","timestamp":"2026-04-22T01:46:04Z","total_rooms":58} +{"timestamp":"2026-04-22T01:51:10Z","kode_ruang":"BRAN2","nama_ruang":"RUANG HCU BRANTAS","kode_kelas":"HCU","kapasitas":9,"tersedia":3,"action":"post","status":"sukses","response_ms":7041} +{"timestamp":"2026-04-22T01:51:14Z","kode_ruang":"BROM3","nama_ruang":"RUANG BROMO KELAS 3","kode_kelas":"KL3","kapasitas":42,"tersedia":1,"action":"post","status":"sukses","response_ms":3553} +{"timestamp":"2026-04-22T01:51:16Z","kode_ruang":"KELI1","nama_ruang":"RUANG KELIMUTU KELAS 1","kode_kelas":"KL1","kapasitas":16,"tersedia":5,"action":"post","status":"sukses","response_ms":2357} +{"timestamp":"2026-04-22T01:51:18Z","kode_ruang":"TOND3","nama_ruang":"RUANG TONDANO","kode_kelas":"KL3","kapasitas":50,"tersedia":20,"action":"post","status":"sukses","response_ms":1995} +{"action":"batch_sync","changed":4,"dry_run":false,"errors":null,"posted":4,"status":"sukses","timestamp":"2026-04-22T01:51:18Z","total_rooms":58} +{"timestamp":"2026-04-22T01:56:06Z","kode_ruang":"LOSA3","nama_ruang":"RUANG LOSARI KELAS 3","kode_kelas":"KL3","kapasitas":14,"tersedia":1,"action":"post","status":"sukses","response_ms":2905} +{"timestamp":"2026-04-22T01:56:09Z","kode_ruang":"TOIB1","nama_ruang":"RUANG TOBA (IBU) KELAS 1","kode_kelas":"KL1","kapasitas":10,"tersedia":6,"action":"post","status":"sukses","response_ms":2794} +{"timestamp":"2026-04-22T01:56:11Z","kode_ruang":"KAPA1","nama_ruang":"RUANG ICU KAPUAS A","kode_kelas":"ICU","kapasitas":16,"tersedia":6,"action":"post","status":"sukses","response_ms":1646} +{"timestamp":"2026-04-22T01:56:12Z","kode_ruang":"KERC2","nama_ruang":"RUANG KERINCI KELAS 2","kode_kelas":"KL2","kapasitas":8,"tersedia":2,"action":"post","status":"sukses","response_ms":1093} +{"action":"batch_sync","changed":4,"dry_run":false,"errors":null,"posted":4,"status":"sukses","timestamp":"2026-04-22T01:56:12Z","total_rooms":58} +{"timestamp":"2026-04-22T02:01:08Z","kode_ruang":"BROM3","nama_ruang":"RUANG BROMO KELAS 3","kode_kelas":"KL3","kapasitas":42,"tersedia":2,"action":"post","status":"gagal","error":"create kamar BROM3 gagal: HTTP error: 503 - no healthy upstream","response_ms":4308} +{"timestamp":"2026-04-22T02:01:08Z","kode_ruang":"RINJ1","nama_ruang":"RUANG RINJANI KELAS 1","kode_kelas":"KL1","kapasitas":2,"action":"post","status":"gagal","error":"create kamar RINJ1 gagal: HTTP error: 503 - no healthy upstream","response_ms":152} +{"timestamp":"2026-04-22T02:01:08Z","kode_ruang":"SEME3","nama_ruang":"RUANG SEMERU","kode_kelas":"KL3","kapasitas":43,"tersedia":18,"action":"post","status":"gagal","error":"create kamar SEME3 gagal: HTTP error: 503 - no healthy upstream","response_ms":121} +{"timestamp":"2026-04-22T02:01:08Z","kode_ruang":"KAPA1","nama_ruang":"RUANG ICU KAPUAS A","kode_kelas":"ICU","kapasitas":16,"tersedia":5,"action":"post","status":"gagal","error":"create kamar KAPA1 gagal: HTTP error: 503 - no healthy upstream","response_ms":163} +{"action":"batch_sync","changed":4,"dry_run":false,"errors":["POST BROM3 gagal: create kamar BROM3 gagal: HTTP error: 503 - no healthy upstream","POST RINJ1 gagal: create kamar RINJ1 gagal: HTTP error: 503 - no healthy upstream","POST SEME3 gagal: create kamar SEME3 gagal: HTTP error: 503 - no healthy upstream","POST KAPA1 gagal: create kamar KAPA1 gagal: HTTP error: 503 - no healthy upstream"],"posted":0,"status":"gagal","timestamp":"2026-04-22T02:01:08Z","total_rooms":58}