first commit
This commit is contained in:
+1223
File diff suppressed because it is too large
Load Diff
+33
-112
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+11
-16
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
package antreanbpjs
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+157
@@ -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}
|
||||
Reference in New Issue
Block a user