first commit
This commit is contained in:
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user