package medication import ( "context" "database/sql" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "time" "service/internal/infrastructure/database" extapi "service/internal/worker/interface" "service/pkg/logger" ) const trackerFile = "internal/worker/satusehat/medication/last_medication_id.txt" const mappingFile = "internal/worker/satusehat/medication/medication_mapping.txt" type Config struct { DBManager database.Service InternalBaseURL string OrganizationID string } type TokenManager interface { GetAccessToken() string ForceRefreshAndGetToken(ctx context.Context) (string, error) } type WorkerService interface { Run(ctx context.Context) } type worker struct { cfg Config repo Repository apiClient extapi.Client tokenManager TokenManager } func NewWorker(cfg Config, tokenManager TokenManager) WorkerService { return &worker{ cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(60 * time.Second), tokenManager: tokenManager, } } func (w *worker) Run(ctx context.Context) { logger.Default().Info("[MEDICATION WORKER] Memulai proses sinkronisasi API...") w.ensureTrackerFileExists() lastID := w.readLastID() // if lastID == 0 { // lastID = 3384064 // Dikurangi 1 agar ID 3384065 ikut terambil // w.updateTracker(&lastID, lastID) // } logger.Default().Info("[MEDICATION WORKER] Melanjutkan proses dari", logger.Int64("last_id", lastID)) for { select { case <-ctx.Done(): logger.Default().Warn("[MEDICATION WORKER] Proses dihentikan oleh sistem (Graceful Shutdown)") return default: } med, err := w.repo.GetNextMedication(ctx, lastID) if err != nil { if err == sql.ErrNoRows { time.Sleep(5 * time.Second) // Tunggu data baru continue } logger.Default().Error("[MEDICATION WORKER] Gagal query ke database", logger.ErrorField(err)) time.Sleep(5 * time.Second) continue } logger.Default().Info("[MEDICATION WORKER] 🔄 Memproses Dokumen", logger.Int64("idxpesanobat", med.IdxPesanObat), logger.String("kode_kfa", med.KfaCode.String), ) if !med.KfaCode.Valid || med.KfaCode.String == "" { logger.Default().Warn("[MEDICATION WORKER] ⏭️ Skip Dokumen: KFA Code kosong", logger.Int64("idxpesanobat", med.IdxPesanObat), ) w.updateTracker(&lastID, med.IdxPesanObat) continue } kfaDisplay, formCode, formDisplay := w.fetchKFADetails(ctx, med.KfaCode.String) internalPayload := MapToInternalAPI(med, w.cfg.OrganizationID, kfaDisplay, formCode, formDisplay) fhirID, respBody, status, errMsg, shouldRetry := w.sendToInternalAPI(ctx, internalPayload, med.IdxPesanObat) // Menyimpan payload request dan response ke dalam log logger.Default().Info("[MEDICATION WORKER] Detail Transaksi", logger.Int64("idxpesanobat", med.IdxPesanObat), logger.Any("request_payload", internalPayload), logger.String("response_payload", string(respBody)), logger.String("status", status), ) syncLog := MapToSyncLog(med.IdxPesanObat, fhirID, internalPayload, respBody, status, errMsg) if errLog := w.repo.SaveSyncLog(ctx, syncLog); errLog != nil { logger.Default().Error("[MEDICATION WORKER] Gagal menyimpan histori log ke DB", logger.ErrorField(errLog)) } if status == "SUCCESS" && fhirID != "" { // if errUpdate := w.repo.SaveSatuSehatID(ctx, med.IdxPesanObat, fhirID); errUpdate != nil { // logger.Default().Error("[MEDICATION WORKER] Gagal update FHIR ID ke transaksi utama", logger.ErrorField(errUpdate)) // } w.saveMapping(med.IdxPesanObat, fhirID) } // SELALU update tracker agar lastID berjalan maju terus dan tidak mengulang data yang sama w.updateTracker(&lastID, med.IdxPesanObat) if shouldRetry { logger.Default().Warn("[MEDICATION WORKER] ⚠️ Gagal memproses data (melewati dokumen)", logger.Int64("idxpesanobat", med.IdxPesanObat), logger.String("reason", errMsg)) time.Sleep(5 * time.Second) } else { time.Sleep(2 * time.Second) } } } func (w *worker) sendToInternalAPI(ctx context.Context, payload map[string]interface{}, idxPesanObat int64) (fhirID string, respBody []byte, status string, errMsg string, shouldRetry bool) { reqURL := fmt.Sprintf("%s/satusehat/medication", strings.TrimRight(w.cfg.InternalBaseURL, "/")) respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.tokenManager.GetAccessToken(), payload) if err != nil { logger.Default().Error("[MEDICATION WORKER] ❌ Gagal Koneksi ke API Part 3", logger.Int64("idxpesanobat", idxPesanObat), logger.Any("payload_sent", payload), logger.ErrorField(err), ) return "", nil, "FAILED", err.Error(), true } if statusCode == http.StatusUnauthorized { logger.Default().Warn("[MEDICATION WORKER] Menerima status 401 Unauthorized, mencoba refresh token...", logger.Int64("idxpesanobat", idxPesanObat)) newToken, refreshErr := w.tokenManager.ForceRefreshAndGetToken(ctx) if refreshErr != nil { logger.Default().Error("[MEDICATION WORKER] Gagal refresh token, akan mencoba lagi pada iterasi berikutnya.", logger.Int64("idxpesanobat", idxPesanObat), logger.ErrorField(refreshErr)) return "", respBody, "FAILED", refreshErr.Error(), true } logger.Default().Info("[MEDICATION WORKER] Token berhasil di-refresh, mencoba ulang request...", logger.Int64("idxpesanobat", idxPesanObat)) respBody, statusCode, err = w.apiClient.PostJSON(ctx, reqURL, newToken, payload) if err != nil { logger.Default().Error("[MEDICATION WORKER] ❌ Gagal Koneksi ke API Part 3 (setelah retry)", logger.Int64("idxpesanobat", idxPesanObat), logger.ErrorField(err), ) return "", nil, "FAILED", err.Error(), true } } if statusCode != http.StatusOK && statusCode != http.StatusCreated { errMsg = fmt.Sprintf("HTTP %d", statusCode) logger.Default().Error("[MEDICATION WORKER] ❌ Respons Error dari API Part 3", logger.Int64("idxpesanobat", idxPesanObat), logger.Int("status_code", statusCode), logger.String("response", truncate(string(respBody), 200)), ) if statusCode >= 500 || statusCode == http.StatusTooManyRequests { return "", respBody, "FAILED", errMsg, true } return "", respBody, "FAILED", errMsg, false } var result map[string]interface{} if errUnmarshal := json.Unmarshal(respBody, &result); errUnmarshal == nil { if data, ok := result["data"].(map[string]interface{}); ok { if id, ok := data["id"].(string); ok { fhirID = id } } else if id, ok := result["id"].(string); ok { fhirID = id } } logger.Default().Info("[MEDICATION WORKER] ✅ Sukses mengirim data Medication", logger.Int64("idxpesanobat", idxPesanObat), logger.String("fhir_id", fhirID), ) return fhirID, respBody, "SUCCESS", "", false } func (w *worker) fetchKFADetails(ctx context.Context, kfaCode string) (kfaDisplay, formCode, formDisplay string) { reqURL := fmt.Sprintf("%s/satusehat/reference/kfa/products/%s", strings.TrimRight(w.cfg.InternalBaseURL, "/"), kfaCode) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { logger.Default().Error("[MEDICATION WORKER] Gagal membuat request KFA", logger.ErrorField(err)) return "", "", "" } req.Header.Set("Authorization", "Bearer "+w.tokenManager.GetAccessToken()) req.Header.Set("Accept", "application/json") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { logger.Default().Error("[MEDICATION WORKER] Gagal request data ke Part 3 KFA", logger.ErrorField(err)) return "", "", "" } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { logger.Default().Warn("[MEDICATION WORKER] KFA tidak ditemukan / Error", logger.Int("status", resp.StatusCode), logger.String("kfa_code", kfaCode)) return "", "", "" } var result map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { logger.Default().Error("[MEDICATION WORKER] Gagal decode response KFA", logger.ErrorField(err)) return "", "", "" } // Parsing respons bersarang dari KFA API (data -> result -> dosage_form) if dataMap, ok := result["data"].(map[string]interface{}); ok { if resultMap, ok := dataMap["result"].(map[string]interface{}); ok { // Ekstrak nama obat if val, ok := resultMap["name"].(string); ok { kfaDisplay = val } // Ekstrak bentuk obat (dosage_form) if dosageForm, ok := resultMap["dosage_form"].(map[string]interface{}); ok { if val, ok := dosageForm["code"].(string); ok { formCode = val } if val, ok := dosageForm["name"].(string); ok { formDisplay = val } } } } // Fallback jika format respons langsung flat if kfaDisplay == "" { if val, ok := result["name"].(string); ok { kfaDisplay = val } } if formCode == "" { if val, ok := result["form_code"].(string); ok { formCode = val } } if formDisplay == "" { if val, ok := result["form_display"].(string); ok { formDisplay = val } } return kfaDisplay, formCode, formDisplay } func (w *worker) updateTracker(lastID *int64, currentID int64) { *lastID = currentID os.MkdirAll("internal/worker/satusehat/medication", 0755) os.WriteFile(trackerFile, []byte(strconv.FormatInt(currentID, 10)), 0644) } func (w *worker) readLastID() int64 { data, err := os.ReadFile(trackerFile) if err != nil { return 0 } idStr := strings.TrimSpace(string(data)) id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { logger.Default().Warn("[MEDICATION WORKER] Gagal parsing ID dari tracker file, memulai dari awal.", logger.String("content", idStr), logger.ErrorField(err)) return 0 } return id } func (w *worker) ensureTrackerFileExists() { if _, err := os.Stat(trackerFile); os.IsNotExist(err) { dir := filepath.Dir(trackerFile) if err := os.MkdirAll(dir, 0755); err != nil { logger.Default().Error("[MEDICATION WORKER] Gagal membuat direktori tracker", logger.ErrorField(err)) } if err := os.WriteFile(trackerFile, []byte("0"), 0644); err != nil { logger.Default().Error("[MEDICATION WORKER] Gagal membuat file tracker default", logger.ErrorField(err)) } } } func (w *worker) saveMapping(idxPesanObat int64, fhirID string) { os.MkdirAll("internal/worker/satusehat/medication", 0755) f, err := os.OpenFile(mappingFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { logger.Default().Error("[MEDICATION WORKER] Gagal membuka file mapping", logger.ErrorField(err)) return } defer f.Close() line := fmt.Sprintf("%d|%s\n", idxPesanObat, fhirID) if _, err := f.WriteString(line); err != nil { logger.Default().Error("[MEDICATION WORKER] Gagal menulis ke file mapping", logger.ErrorField(err)) } } func truncate(s string, max int) string { if len(s) > max { return s[:max] + "..." } return s }