Pengambilan Data KFA Pengiriman data medication dan service request

This commit is contained in:
meninjar
2026-05-04 03:24:01 +00:00
parent 24d25aa14a
commit e8a0642291
80 changed files with 1718354 additions and 263 deletions

No files matched your search

@@ -1,22 +1,27 @@
package medicationrequest
import (
"database/sql"
"time"
)
// MedicationRequestDB merepresentasikan tabel data resep obat
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
// MedicationRequestDB adalah data gabungan SIMRS + SATUDATA yang siap diproses worker
type MedicationRequestDB struct {
IdxResep int64 `db:"idxresep"`
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
TglResep sql.NullTime `db:"tgl_resep"`
KFA sql.NullString `db:"kfa_code"`
Instruksi sql.NullString `db:"instruksi"`
IdxPesanObat int64
No string
IdxDaftar int64
KdDokter string
PatientID string
EncounterID string
PractitionerID string
JmlhKeluar float64
Sediaan string
CreatedAt time.Time
MedicationID string
AturanPakai string
}
type MedicationRequestSyncLog struct {
IdxResep int64 `db:"idxresep"`
IdxPesanObat int64 `db:"idxpesanobat"`
MedicationRequestID string `db:"medicationrequest_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
@@ -0,0 +1 @@
3578721
@@ -2,30 +2,38 @@ package medicationrequest
import (
"encoding/json"
"fmt"
"time"
"service/pkg/logger"
)
// TODO: Sesuaikan dengan payload internal API MedicationRequest
func MapToInternalAPI(dbData *MedicationRequestDB, orgID string) map[string]interface{} {
waktu := time.Now()
if dbData.TglResep.Valid {
waktu = dbData.TglResep.Time
waktuStr := ""
if !dbData.CreatedAt.IsZero() {
waktuStr = dbData.CreatedAt.Format(time.RFC3339)
} else {
waktuStr = logger.LocalNow().Format(time.RFC3339)
}
return map[string]interface{}{
"medicationrequest_id": fmt.Sprintf("%d", dbData.IdxResep),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String,
"medication_code": dbData.KFA.String,
"authored_on": waktu.Format(time.RFC3339),
"dosage_text": dbData.Instruksi.String,
"medicationrequest_id": dbData.No,
"patient_id": dbData.PatientID,
"encounter_id": dbData.EncounterID,
"status": "active",
"intent": "order",
"medication_id": dbData.MedicationID,
"practitioner_id": dbData.PractitionerID,
"patient_instruction": dbData.AturanPakai,
"authored_on": waktuStr,
"dispense_value": dbData.JmlhKeluar,
"dispense_unit": dbData.Sediaan,
}
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) MedicationRequestSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return MedicationRequestSyncLog{
IdxResep: idx,
IdxPesanObat: idx,
MedicationRequestID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
File diff suppressed because it is too large Load diff
File diff suppressed because it is too large Load diff
File diff suppressed because it is too large Load diff
File diff suppressed because it is too large Load diff
File diff suppressed because it is too large Load diff
File diff suppressed because it is too large Load diff
File diff suppressed because it is too large Load diff
@@ -2,7 +2,11 @@ package medicationrequest
import (
"context"
"database/sql"
"fmt"
"service/internal/infrastructure/database"
"service/pkg/logger"
"service/pkg/utils/query"
)
@@ -27,28 +31,118 @@ func (r *repository) GetNextMedicationRequest(ctx context.Context, lastID int64)
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).
SetSecurityOptions(false, 0).
SetQueryLogging(false).
SetAllowedColumns([]string{"idxpesanobat", `"IDXDAFTAR"`, `"KDDOKTER"`})
type simrsRow struct {
IdxPesanObat int64 `db:"idxpesanobat"`
No sql.NullString `db:"no"`
IdxDaftar int64 `db:"idxdaftar"`
KdDokter sql.NullString `db:"kddokter"`
JmlhKeluar sql.NullFloat64 `db:"jmlh_keluar"`
Sediaan sql.NullString `db:"sediaan"`
CreatedAt sql.NullTime `db:"created_at"`
TglPesan sql.NullTime `db:"tgl_pesan"`
AturanPakai sql.NullString `db:"aturan_pakai"`
}
var row simrsRow
// TODO: Sesuaikan nama tabel resep
qSimrs := query.DynamicQuery{
From: "public.t_resep_obat",
From: "public.t_permintaan_apotek_rajal",
Fields: []query.SelectField{
{Expression: "idxpesanobat"},
{Expression: "no"},
{Expression: "idxdaftar"},
{Expression: "kddokter"},
{Expression: "jmlh_keluar"},
{Expression: "sediaan"},
{Expression: "created_at"},
{Expression: "tgl_pesan"},
{Expression: "aturan_pakai"},
},
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxresep", query.OpGreaterThan, lastID),
query.CreateFilter("idxpesanobat", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxresep"),
query.CreateAscSort("idxpesanobat"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &mr)
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &row)
if err != nil {
return nil, err
}
mr.IdxPesanObat = row.IdxPesanObat
mr.No = row.No.String
mr.IdxDaftar = row.IdxDaftar
mr.KdDokter = row.KdDokter.String
mr.JmlhKeluar = row.JmlhKeluar.Float64
mr.Sediaan = row.Sediaan.String
mr.AturanPakai = row.AturanPakai.String
if row.CreatedAt.Valid {
mr.CreatedAt = row.CreatedAt.Time
} else if row.TglPesan.Valid {
mr.CreatedAt = row.TglPesan.Time
} else {
mr.CreatedAt = logger.LocalNow()
}
satudataDB, err := r.dbManager.GetSQLXDB("satudata")
if err != nil {
return nil, fmt.Errorf("gagal koneksi ke database satudata: %w", err)
}
// Ambil PatientID dan EncounterID dari tabel public.data_kunjungan_pasien di database satudata
if mr.IdxDaftar > 0 {
type kunjunganRow struct {
PatientID sql.NullString `db:"Nomor_satusehat_pasien"`
EncounterID sql.NullString `db:"IDXDAFTAR_satusehat"`
}
var kRow kunjunganRow
qKunjungan := query.DynamicQuery{
From: "public.data_kunjungan_pasien",
Fields: []query.SelectField{
{Expression: `"Nomor_satusehat_pasien"`},
{Expression: `"IDXDAFTAR_satusehat"`},
},
Filters: []query.FilterGroup{
{Filters: []query.DynamicFilter{query.CreateEqualFilter(`"IDXDAFTAR"`, mr.IdxDaftar)}},
},
Limit: 1,
}
qb.ExecuteQueryRow(ctx, satudataDB, qKunjungan, &kRow)
mr.PatientID = kRow.PatientID.String
mr.EncounterID = kRow.EncounterID.String
}
// Ambil PractitionerID dari tabel public.data_pegawai di database satudata
if mr.KdDokter != "" {
type pegawaiRow struct {
PractitionerID sql.NullString `db:"Kode_satusehat"`
}
var pRow pegawaiRow
qPegawai := query.DynamicQuery{
From: "public.data_pegawai",
Fields: []query.SelectField{
{Expression: `"Kode_satusehat"`},
},
Filters: []query.FilterGroup{
{Filters: []query.DynamicFilter{query.CreateEqualFilter(`"KDDOKTER"`, mr.KdDokter)}},
},
Limit: 1,
}
qb.ExecuteQueryRow(ctx, satudataDB, qPegawai, &pRow)
mr.PractitionerID = pRow.PractitionerID.String
}
return &mr, nil
}
@@ -57,8 +151,11 @@ func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID stri
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_resep_obat", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxresep", idx)})})
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).
SetSecurityOptions(false, 0).
SetQueryLogging(false).
SetAllowedColumns([]string{"status_bridging", "idxpesanobat"})
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_permintaan_apotek_rajal", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxpesanobat", idx)})})
return err
}
@@ -67,11 +164,14 @@ func (r *repository) SaveSyncLog(ctx context.Context, logData MedicationRequestS
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).
SetSecurityOptions(false, 0).
SetQueryLogging(false).
SetAllowedColumns([]string{"idxpesanobat", "medicationrequest_id", "request_payload", "response_payload", "status", "error_message"})
insertData := query.InsertData{
Columns: []string{"idxresep", "medicationrequest_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxResep, logData.MedicationRequestID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
Columns: []string{"idxpesanobat", "medicationrequest_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxPesanObat, logData.MedicationRequestID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_medicationrequest", insertData, []string{"idxresep"}, []string{"medicationrequest_id", "request_payload", "response_payload", "status", "error_message"})
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_medicationrequest", insertData, []string{"idxpesanobat"}, []string{"medicationrequest_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,68 +1,311 @@
package medicationrequest
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log"
"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 = "last_migrated_medicationrequest_id.txt"
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
InternalToken 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
type mappingFileInfo struct {
Path string
Count int
MinID int64
MaxID int64
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
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/<YYYY-MM>/<YYYY-MM-DD>.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) {
log.Println("[MEDICATIONREQUEST WORKER] Memulai proses sinkronisasi...")
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():
log.Println("[MEDICATIONREQUEST WORKER] Proses dihentikan.")
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, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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{}
@@ -73,16 +316,25 @@ func (w *worker) Run(ctx context.Context) {
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.IdxResep, fhirID, internalPayload, respBody, status, errMsg))
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(mr.IdxPesanObat, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, mr.IdxResep, fhirID)
_ = w.repo.SaveSatuSehatID(ctx, mr.IdxPesanObat, fhirID)
w.appendMedicationRequestMapping(mr.IdxPesanObat, fhirID, mr.MedicationID, mr.EncounterID)
}
lastID = mr.IdxResep
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
// }
}
}
@@ -91,3 +343,24 @@ func (w *worker) readLastID() int64 {
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
}