package medicationrequest import ( "bufio" "context" "encoding/json" "fmt" "io/fs" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "time" "service/internal/infrastructure/database" extapi "service/internal/worker/interface" "service/pkg/logger" ) const ( trackerFile = "internal/worker/satusehat/medicationrequest/last_medicationrequest_id.txt" medMappingFile = "internal/worker/satusehat/medication/medication_mapping.txt" medMappingBaseDir = "internal/worker/satusehat/medication/mapperdata" medReqMappingBaseDir = "internal/worker/satusehat/medicationrequest/mapperdata" rateLimitSleep = 60 * time.Second ) 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 mappingFileInfo struct { Path string Count int MinID int64 MaxID int64 } type worker struct { cfg Config repo Repository apiClient extapi.Client tokenManager TokenManager medicationMap map[int64]string minMedicationID int64 maxMedicationID int64 fileStats []mappingFileInfo // statistik per-file mapping (urut kronologis) } func NewWorker(cfg Config, tokenManager TokenManager) WorkerService { w := &worker{ cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second), tokenManager: tokenManager, medicationMap: make(map[int64]string), } w.loadMedicationMapping() return w } func (w *worker) loadMedicationMapping() { var minID, maxID int64 minSet := false var stats []mappingFileInfo loadFromFile := func(path string) { file, err := os.Open(path) if err != nil { return } defer file.Close() info := mappingFileInfo{Path: path} fileMinSet := false scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } parts := strings.Split(line, "|") if len(parts) >= 2 { id, err := strconv.ParseInt(parts[0], 10, 64) if err == nil { w.medicationMap[id] = parts[1] info.Count++ if !fileMinSet || id < info.MinID { info.MinID = id fileMinSet = true } if id > info.MaxID { info.MaxID = id } if !minSet || id < minID { minID = id minSet = true } if id > maxID { maxID = id } } } } if info.Count > 0 { stats = append(stats, info) } } // Kumpulkan path mapping dalam urutan kronologis: legacy (paling tua) β†’ file harian // (mapperdata//.txt) di-sort ascending sehingga data tanggal // terkecil dimuat lebih dulu dan tidak ada hari yang dilewati. var paths []string if _, err := os.Stat(medMappingFile); err == nil { paths = append(paths, medMappingFile) } if _, err := os.Stat(medMappingBaseDir); err == nil { var dailyPaths []string errWalk := filepath.WalkDir(medMappingBaseDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() || !strings.HasSuffix(d.Name(), ".txt") { return nil } dailyPaths = append(dailyPaths, path) return nil }) if errWalk != nil { logger.Default().Warn("[MEDICATIONREQUEST WORKER] Gagal walk direktori mapping medication", logger.ErrorField(errWalk)) } sort.Strings(dailyPaths) paths = append(paths, dailyPaths...) } for _, p := range paths { loadFromFile(p) } w.minMedicationID = minID w.maxMedicationID = maxID w.fileStats = stats } // logMappingProgress menampilkan ringkasan per-file mapping pada saat startup, // menunjukkan apakah data tanggal-tanggal sebelumnya sudah DONE, IN_PROGRESS, // atau masih PENDING relatif terhadap lastID. Tujuannya: memastikan tidak ada // hari yang terlewat sebelum lanjut ke hari berikutnya. func (w *worker) logMappingProgress(lastID int64) { if len(w.fileStats) == 0 { logger.Default().Warn("[MEDICATIONREQUEST WORKER] ⚠️ Tidak ada file mapping medication yang dimuat") return } logger.Default().Info("[MEDICATIONREQUEST WORKER] πŸ“‹ Status file mapping medication (urut kronologis)", logger.Int("file_count", len(w.fileStats)), logger.Int64("last_id", lastID), ) for _, fs := range w.fileStats { var status string switch { case fs.MaxID <= lastID: status = "DONE" case fs.MinID > lastID: status = "PENDING" default: status = "IN_PROGRESS" } logger.Default().Info("[MEDICATIONREQUEST WORKER] πŸ“ "+filepath.Base(fs.Path), logger.String("status", status), logger.Int("count", fs.Count), logger.Int64("min_id", fs.MinID), logger.Int64("max_id", fs.MaxID), ) } } func (w *worker) appendMedicationRequestMapping(idx int64, fhirID, medicationID, encounterID string) { now := logger.LocalNow() monthDir := filepath.Join(medReqMappingBaseDir, now.Format("2006-01")) if err := os.MkdirAll(monthDir, 0755); err != nil { logger.Default().Error("[MEDICATIONREQUEST WORKER] Gagal membuat direktori mapping bulanan", logger.ErrorField(err)) return } mappingPath := filepath.Join(monthDir, now.Format("2006-01-02")+".txt") f, err := os.OpenFile(mappingPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { logger.Default().Error("[MEDICATIONREQUEST WORKER] Gagal membuka/membuat file mapping medicationrequest", logger.ErrorField(err)) return } defer f.Close() _, _ = f.WriteString(fmt.Sprintf("%d|%s|%s|%s\n", idx, fhirID, medicationID, encounterID)) } func (w *worker) Run(ctx context.Context) { lastID := w.readLastID() // Jika lastID 0, mulai dari ID terkecil pada tanggal terkecil pada mapping medication. // minMedicationID dihitung saat load (file dimuat kronologis: legacy β†’ harian asc), // sehingga semua data dari tanggal terkecil akan dihabiskan dulu sebelum berlanjut // ke tanggal berikutnya secara berurutan. if lastID == 0 && w.minMedicationID > 0 { lastID = w.minMedicationID - 1 } logger.Default().Info("[MEDICATIONREQUEST WORKER] ⏳ Memulai proses sinkronisasi...", logger.Int64("lastID", lastID)) w.logMappingProgress(lastID) // testCount := 0 for { select { case <-ctx.Done(): logger.Default().Info("[MEDICATIONREQUEST WORKER] πŸ›‘ Proses dihentikan.") return default: } mr, err := w.repo.GetNextMedicationRequest(ctx, lastID) if err != nil { if !strings.Contains(err.Error(), "no rows") { logger.Default().Error("[MEDICATIONREQUEST WORKER] ❌ Gagal query data dari DB", logger.ErrorField(err)) } time.Sleep(5 * time.Second) continue } // Cari Medication ID dari mapping internal memory medID, ok := w.medicationMap[mr.IdxPesanObat] if !ok { // Coba reload mapping file barangkali ada data baru w.loadMedicationMapping() medID, ok = w.medicationMap[mr.IdxPesanObat] } if ok { mr.MedicationID = medID } else { // Jika masih tidak ditemukan, cek apakah Medication Worker sudah melewati ID ini if mr.IdxPesanObat <= w.maxMedicationID { logger.Default().Warn(fmt.Sprintf("[MEDICATIONREQUEST WORKER] ⏭️ Skip Dokumen: Medication ID tidak ditemukan di mapping untuk IdxPesanObat: %d (Di-skip/Error di Medication Worker)", mr.IdxPesanObat)) lastID = mr.IdxPesanObat os.MkdirAll("internal/worker/satusehat/medicationrequest", 0755) os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644) } else { logger.Default().Info(fmt.Sprintf("[MEDICATIONREQUEST WORKER] ⏳ Menunggu data mapping baru dari Medication Worker untuk IdxPesanObat: %d...", mr.IdxPesanObat)) time.Sleep(5 * time.Second) } continue } // Validasi data mandatory (Patient, Encounter, Practitioner) dari tabel satudata if mr.PatientID == "" || mr.EncounterID == "" || mr.PractitionerID == "" { logger.Default().Warn("[MEDICATIONREQUEST WORKER] ⏭️ Skip Dokumen: Data mandatory (PatientID/EncounterID/PractitionerID) kosong", logger.Int64("idxpesanobat", mr.IdxPesanObat), logger.String("patient_id", mr.PatientID), logger.String("encounter_id", mr.EncounterID), logger.String("practitioner_id", mr.PractitionerID), ) lastID = mr.IdxPesanObat os.MkdirAll("internal/worker/satusehat/medicationrequest", 0755) os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644) continue } internalPayload := MapToInternalAPI(mr, w.cfg.OrganizationID) reqURL := fmt.Sprintf("%s/satusehat/medicationrequest", strings.TrimRight(w.cfg.InternalBaseURL, "/")) logger.Default().Info("[MEDICATIONREQUEST WORKER] πŸš€ Mengirim request ke API", logger.Int64("idxpesanobat", mr.IdxPesanObat), logger.Any("payload", internalPayload)) respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.tokenManager.GetAccessToken(), internalPayload) if statusCode == http.StatusUnauthorized { logger.Default().Warn("[MEDICATIONREQUEST WORKER] ⚠️ Token expired, refreshing...") if newToken, errRef := w.tokenManager.ForceRefreshAndGetToken(ctx); errRef == nil { respBody, statusCode, err = w.apiClient.PostJSON(ctx, reqURL, newToken, internalPayload) } } if isRateLimitResponse(statusCode, respBody) { logger.Default().Warn("[MEDICATIONREQUEST WORKER] ⏸️ Rate limit dari Satu Sehat - jeda dan akan retry dokumen yang sama", logger.Int64("idxpesanobat", mr.IdxPesanObat), logger.Int("status_code", statusCode), logger.String("response", truncateMR(string(respBody), 300)), logger.String("sleep", rateLimitSleep.String()), ) time.Sleep(rateLimitSleep) continue } var status, errMsg, fhirID string if err != nil || (statusCode != http.StatusOK && statusCode != http.StatusCreated) { status, errMsg = "FAILED", fmt.Sprintf("Err: %v, HTTP: %d", err, statusCode) logger.Default().Error("[MEDICATIONREQUEST WORKER] ❌ Gagal mengirim data", logger.String("error", errMsg), logger.String("response", string(respBody))) } else { status = "SUCCESS" var res map[string]interface{} if json.Unmarshal(respBody, &res) == nil { if data, ok := res["data"].(map[string]interface{}); ok { fhirID, _ = data["id"].(string) } else { fhirID, _ = res["id"].(string) } } logger.Default().Info("[MEDICATIONREQUEST WORKER] βœ… Berhasil mengirim data", logger.String("fhir_id", fhirID), logger.String("response", string(respBody))) } _ = w.repo.SaveSyncLog(ctx, MapToSyncLog(mr.IdxPesanObat, fhirID, internalPayload, respBody, status, errMsg)) if status == "SUCCESS" && fhirID != "" { _ = w.repo.SaveSatuSehatID(ctx, mr.IdxPesanObat, fhirID) w.appendMedicationRequestMapping(mr.IdxPesanObat, fhirID, mr.MedicationID, mr.EncounterID) } lastID = mr.IdxPesanObat os.MkdirAll("internal/worker/satusehat/medicationrequest", 0755) os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644) time.Sleep(500 * time.Millisecond) // testCount++ // if testCount >= 3 { // logger.Default().Info("[MEDICATIONREQUEST WORKER] πŸ›‘ Uji coba 3 data selesai. Menghentikan worker.") // return // } } } func (w *worker) readLastID() int64 { data, _ := os.ReadFile(trackerFile) id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) return id } // isRateLimitResponse mendeteksi respons rate-limit dari Satu Sehat // (HTTP 429 atau pesan body yang mengindikasikan limit pengiriman terlampaui). func isRateLimitResponse(statusCode int, respBody []byte) bool { if statusCode == http.StatusTooManyRequests { return true } body := strings.ToLower(string(respBody)) return strings.Contains(body, "too many requests") || strings.Contains(body, "rate limit") || strings.Contains(body, "limit exceeded") || strings.Contains(body, "limit pengiriman") || strings.Contains(body, "quota exceeded") } func truncateMR(s string, max int) string { if len(s) > max { return s[:max] + "..." } return s }