Medication Update

This commit is contained in:
meninjar
2026-04-26 08:06:57 +00:00
parent 9a15d74283
commit 24d25aa14a
37 changed files with 82527 additions and 2172 deletions
+22 -19
View File
@@ -2,27 +2,30 @@ package imagingstudy
import (
"database/sql"
"time"
)
// ImagingStudyData merepresentasikan gabungan data dari Postgres (Satu Data) dan MySQL (RIS)
type ImagingStudyData struct {
RequestID string `db:"id"`
ServiceRequestID sql.NullString `db:"Entity_Id"`
EncounterID sql.NullString `db:"Encounter_Id"`
NoRegister sql.NullString `db:"No_register"`
PatientID sql.NullString `db:"Nomor_satusehat_pasien"`
PatientName sql.NullString `db:"Nama_lengkap"`
// Data dari MySQL (RIS)
Modality sql.NullString
StartedDate sql.NullTime
// ImagingStudyDB merepresentasikan struktur tabel dari SIMRS
type ImagingStudyDB struct {
ID int64 `db:"id"`
SourceID string // Holds the UUID from the source table (data_service_requests)
CreatedAt time.Time // Holds the Created_at from the source table for tracking
NoMR sql.NullString `db:"nomr"`
PatientIHS sql.NullString `db:"patient_ihs"`
PractitionerIHS sql.NullString `db:"practitioner_ihs"`
EncounterIHS sql.NullString `db:"encounter_ihs"`
AccessionNumber sql.NullString `db:"accession_number"`
ServiceRequestID sql.NullString `db:"service_request_id"`
Modality sql.NullString `db:"modality"`
StartedDate sql.NullTime `db:"started_date"`
}
type ImagingStudySyncLog struct {
RequestID string `db:"id"`
ImagingStudyID string `db:"imagingstudy_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
// SyncLog menyimpan log sinkronisasi untuk diaudit di database
type SyncLog struct {
ImagingStudyID string
SatuSehatID string
RequestPayload string
ResponsePayload string
Status string
ErrorMessage string
}
@@ -0,0 +1 @@
2026-04-24T12:46:09Z
@@ -6,28 +6,35 @@ import (
)
// MapToInternalAPI memetakan data gabungan ke payload API Internal SatuSehat
func MapToInternalAPI(dbData *ImagingStudyData, orgID string) map[string]interface{} {
func MapToInternalAPI(dbData *ImagingStudyDB, orgID string) map[string]interface{} {
waktu := time.Now()
if dbData.StartedDate.Valid {
waktu = dbData.StartedDate.Time
}
procedureCode := dbData.Modality.String
if procedureCode == "" {
procedureCode = "DX" // Default ke "DX" jika modality kosong
}
payload := map[string]interface{}{
"request_id": dbData.RequestID,
"servicerequest_id": dbData.ServiceRequestID.String,
"encounter_id": dbData.EncounterID.String,
"patient_id": dbData.PatientID.String,
"accession_number": dbData.NoRegister.String, // Digunakan sebagai Identifier SATUSEHAT
"modality": dbData.Modality.String,
"started_date": waktu.Format(time.RFC3339),
"organization_id": orgID,
"accession_number": dbData.AccessionNumber.String,
"service_request_id": dbData.ServiceRequestID.String,
"patient_id": dbData.PatientIHS.String,
"encounter_id": dbData.EncounterIHS.String,
"status": "available",
"started": waktu.Format(time.RFC3339),
"procedure_code": procedureCode,
}
return payload
}
func MapToSyncLog(reqID string, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) ImagingStudySyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return ImagingStudySyncLog{
RequestID: reqID,
ImagingStudyID: fhirID,
func MapToSyncLog(id string, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) SyncLog {
reqBytes, _ := json.Marshal(reqPayload)
return SyncLog{
ImagingStudyID: id,
SatuSehatID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
@@ -3,13 +3,16 @@ package imagingstudy
import (
"context"
"database/sql"
"service/internal/infrastructure/database"
"service/pkg/logger"
"service/pkg/utils/query"
"time"
)
type Repository interface {
GetNextPendingRequest(ctx context.Context) (*ImagingStudyData, error)
UpdateSyncStatus(ctx context.Context, reqID string, status string, responsePayload string) error
GetNextImagingStudy(ctx context.Context, lastCreatedAt time.Time) (*ImagingStudyDB, error)
SaveSatuSehatID(ctx context.Context, sourceID string, fhirID string) error
SaveSyncLog(ctx context.Context, logData SyncLog) error
}
type repository struct {
@@ -20,68 +23,173 @@ func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
// GetNextPendingRequest mengambil 1 data service request radiologi yang belum terkirim
func (r *repository) GetNextPendingRequest(ctx context.Context) (*ImagingStudyData, error) {
var data ImagingStudyData
// GetNextImagingStudy mengambil data imaging study berikutnya untuk diproses.
// Tracker `lastID` adalah primary key integer (`id`) dari tabel `data_service_requests`.
// Data akan diambil mulai dari tanggal 2026-04-15 dan melompati `lastCreatedAt` yang sudah diproses.
func (r *repository) GetNextImagingStudy(ctx context.Context, lastCreatedAt time.Time) (*ImagingStudyDB, error) {
var data ImagingStudyDB
// 1. Ambil koneksi Postgres (Satu Data) - Sesuaikan nama dengan konfigurasi Anda
pgDB, err := r.dbManager.GetDB("satudata")
// 1. Inisialisasi Koneksi ke Database yang diperlukan
satudataDB, err := r.dbManager.GetSQLXDB("satudata")
if err != nil {
return nil, err
}
risDB, err := r.dbManager.GetSQLXDB("ris")
if err != nil {
return nil, err
}
// Ambil dari Postgres
queryPG := `
SELECT
sr.id, sr."Entity_Id", sr."Encounter_Id", sr."No_register",
kp."Nomor_satusehat_pasien", kp."Nama_lengkap"
FROM public.data_service_requests sr
JOIN public.data_kunjungan_pasien kp ON sr."Encounter_Id" = kp."IDXDAFTAR_satusehat"
WHERE LOWER(sr."Order_from") = 'radiologi'
AND (sr."Send_status" IS NULL OR sr."Send_status" = 'FAILED')
ORDER BY sr."Created_at" ASC
LIMIT 1
`
err = pgDB.QueryRowContext(ctx, queryPG).Scan(
&data.RequestID, &data.ServiceRequestID, &data.EncounterID, &data.NoRegister,
&data.PatientID, &data.PatientName,
)
if err != nil { // Bisa berupa sql.ErrNoRows jika data habis
return nil, err
// --- Langkah 1: Ambil data pemicu dari `satudata` ---
qbSatuData := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// Struct penampung sementara untuk mendapat foreign key ke db lain
type BaseSatuData struct {
ID string `db:"id"` // Perbaikan: Ubah ke string untuk menampung UUID
ServiceRequestID sql.NullString `db:"service_request_id"`
EncounterIHS sql.NullString `db:"encounter_ihs"`
PatientIHS sql.NullString `db:"patient_ihs"`
NoRegister sql.NullString `db:"No_register"`
TanggalOrder sql.NullTime `db:"Tanggal_order"`
NoMR sql.NullString `db:"nomr"`
CreatedAt time.Time `db:"Created_at"`
PractitionerIHS sql.NullString `db:"practitioner_ihs"`
StartedDate sql.NullTime `db:"started_date"`
}
var baseData BaseSatuData
// Hardcode tanggal mulai
startDate, _ := time.Parse("2006-01-02", "2026-01-01")
qSatuData := query.DynamicQuery{
From: "public.data_service_requests",
Aliases: "dsr",
Fields: []query.SelectField{
{Expression: `dsr.id`},
{Expression: `dsr."Entity_Id"`, Alias: "service_request_id"},
{Expression: `dsr."Encounter_Id"`, Alias: "encounter_ihs"},
{Expression: `dsr."No_register"`},
{Expression: `dsr."Tanggal_order"`},
{Expression: `dsr."Occurrence_date_time"`, Alias: "started_date"},
{Expression: `dsr."Requester_reference"`, Alias: "practitioner_ihs"},
{Expression: `dsr."Created_at"`},
{Expression: `dkp."Nomor_satusehat_pasien"`, Alias: "patient_ihs"},
{Expression: `dkp."NOMR"`, Alias: "nomr"},
},
Joins: []query.Join{
{
Type: "LEFT",
Table: "public.data_kunjungan_pasien",
Alias: "dkp",
OnConditions: query.CreateAndFilterGroup([]query.DynamicFilter{
query.CreateFilter(`dkp."IDXDAFTAR_satusehat"`, query.OpEqual, `dsr."Encounter_Id"`),
}),
},
},
Filters: []query.FilterGroup{
{Filters: []query.DynamicFilter{
query.CreateFilter(`dsr."Created_at"`, query.OpGreaterThan, lastCreatedAt),
query.CreateFilter(`dsr."Tanggal_order"`, query.OpGreaterThanEqual, startDate),
query.CreateFilter(`dsr."Entity_Id"`, query.OpNotNull, nil),
}},
},
Sort: []query.SortField{query.CreateAscSort(`dsr."Created_at"`)},
Limit: 1,
}
// 2. Ambil koneksi MySQL (RIS) - Sesuaikan nama dengan konfigurasi Anda
mySQLDB, err := r.dbManager.GetDB("simrs")
if err == nil && data.NoRegister.Valid {
queryMySQL := `SELECT modality, foto FROM periksa WHERE noregister = ? LIMIT 1`
err = qbSatuData.ExecuteQueryRow(ctx, satudataDB, qSatuData, &baseData)
if err != nil {
return nil, err // Akan melempar sql.ErrNoRows jika tidak ada data baru
}
var modality sql.NullString
var foto sql.NullTime
// Map data dari `satudata` ke struct utama
// SourceID (UUID) digunakan untuk tracking, sedangkan ID (int64) adalah dari tabel log,
// yang akan diisi saat menyimpan log.
data.SourceID = baseData.ID
data.CreatedAt = baseData.CreatedAt
data.ID = 0 // ID dari tabel log akan diisi nanti, bukan dari query ini.
data.ServiceRequestID = baseData.ServiceRequestID
data.EncounterIHS = baseData.EncounterIHS
data.PatientIHS = baseData.PatientIHS
data.NoMR = baseData.NoMR
data.PractitionerIHS = baseData.PractitionerIHS
data.StartedDate = baseData.StartedDate
// Gunakan placeholder '?' jika MySQL, atau '$1' jika PostgreSQL
err = mySQLDB.QueryRowContext(ctx, queryMySQL, data.NoRegister.String).Scan(&modality, &foto)
if err == nil {
data.Modality = modality
data.StartedDate = foto
// --- Langkah 2: Ambil Accession Number dan Modality dari `ris` (MySQL) ---
if baseData.NoRegister.Valid {
qbRis := query.NewSQLQueryBuilder(query.DBTypeMySQL).SetSecurityOptions(false, 0)
// PERBAIKAN: Secara eksplisit mengizinkan kolom yang akan digunakan dalam filter query RIS.
// Ini untuk mengatasi masalah validasi pada query builder yang menganggap kolom 'noregister' tidak valid.
qbRis.SetAllowedColumns([]string{
"noregister", "DATE(daftar)",
})
risFilters := []query.DynamicFilter{
query.CreateFilter("noregister", query.OpEqual, baseData.NoRegister.String),
}
// Filter berdasarkan tanggal order jika ada, untuk mempersempit pencarian
if baseData.TanggalOrder.Valid {
risFilters = append(risFilters, query.CreateFilter("DATE(daftar)", query.OpEqual, baseData.TanggalOrder.Time.Format("2006-01-02")))
}
qRis := query.DynamicQuery{
From: "periksa",
Fields: []query.SelectField{
{Expression: "nofoto", Alias: "accession_number"},
{Expression: "modality", Alias: "modality"},
},
Filters: []query.FilterGroup{
{Filters: risFilters},
},
Limit: 1,
}
// Mengeluarkan Log hasil SQL query yang di-generate
sqlStr, args, errBuild := qbRis.BuildQuery(qRis)
if errBuild == nil {
logger.Default().Debug("[IMAGING STUDY REPO] RIS Query Generated", logger.String("query", sqlStr), logger.Any("args", args))
} else {
logger.Default().Error("[IMAGING STUDY REPO] Failed to build RIS Query", logger.ErrorField(errBuild))
}
// PERBAIKAN: Jangan abaikan semua error. Log error jika bukan 'sql.ErrNoRows'.
// Ini penting untuk mendeteksi masalah koneksi atau query ke database RIS.
if errRis := qbRis.ExecuteQueryRow(ctx, risDB, qRis, &data); errRis != nil && errRis != sql.ErrNoRows {
logger.Default().Warn("[IMAGING STUDY REPO] Gagal mengambil data dari RIS, melanjutkan tanpa data RIS.",
logger.String("query", sqlStr), logger.Any("args", args), logger.ErrorField(errRis))
}
}
return &data, nil
}
// UpdateSyncStatus memperbarui status pengiriman di PostgreSQL
func (r *repository) UpdateSyncStatus(ctx context.Context, reqID string, status string, responsePayload string) error {
pgDB, err := r.dbManager.GetDB("satudata")
func (r *repository) SaveSatuSehatID(ctx context.Context, sourceID string, fhirID string) error {
// Perhatian: Karena ID dari data_service_requests adalah UUID,
// kita tidak bisa lagi menggunakan `id` (int64) dari tabel log untuk melakukan UPDATE.
// Fungsi ini perlu penyesuaian lebih lanjut jika Anda perlu menyimpan fhir_id kembali ke tabel `data_service_requests`.
// Untuk sementara, fungsi ini tidak akan melakukan apa-apa untuk menghindari error.
// return nil
// satudataDB, err := r.dbManager.GetSQLXDB("satudata")
// if err != nil {
// return err
// }
// qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// _, err = qb.ExecuteUpdate(ctx, satudataDB, "public.data_service_requests", query.UpdateData{Columns: []string{"fhir_id"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("id", sourceID)})})
// return err
return nil
}
func (r *repository) SaveSyncLog(ctx context.Context, logData SyncLog) error {
satudataDB, err := r.dbManager.GetSQLXDB("satudata")
if err != nil {
return err
}
query := `
UPDATE public.data_service_requests
SET "Send_status" = $1, "Send_response" = $2, "Updated_at" = NOW()
WHERE id = $3
`
_, err = pgDB.ExecContext(ctx, query, status, responsePayload, reqID)
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"imaging_id", "fhir_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.ImagingStudyID, logData.SatuSehatID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, satudataDB, "public.log_satusehat_imagingstudy", insertData, []string{"imaging_id"}, []string{"fhir_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
+260 -40
View File
@@ -5,94 +5,314 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
"service/pkg/logger"
)
const trackerFile = "internal/worker/satusehat/imagingstudy/last_imagingstudy_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
// TokenManager defines the interface for a centralized token provider.
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
cfg Config
repo Repository
apiClient extapi.Client
tokenManager TokenManager
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
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) {
log.Println("[IMAGINGSTUDY WORKER] Memulai proses sinkronisasi...")
logger.Default().Info("[IMAGING STUDY WORKER] Memulai proses sinkronisasi API...")
lastCreatedAt := w.readLastCreatedAt()
logger.Default().Info("[IMAGING STUDY WORKER] Melanjutkan proses dari", logger.Time("last_created_at", lastCreatedAt))
for {
select {
case <-ctx.Done():
log.Println("[IMAGINGSTUDY WORKER] Proses dihentikan.")
logger.Default().Warn("[IMAGING STUDY WORKER] Proses dihentikan oleh sistem (Graceful Shutdown)")
return
default:
}
// 1. Ambil data dengan status belum terkirim (IS NULL atau FAILED)
isData, err := w.repo.GetNextPendingRequest(ctx)
// 1. Fetch Data
imagingData, err := w.repo.GetNextImagingStudy(ctx, lastCreatedAt)
if err != nil {
if err == sql.ErrNoRows {
// Jika tidak ada data antrean, istirahat lebih lama
time.Sleep(10 * time.Second)
time.Sleep(5 * time.Second) // Tunggu data baru
continue
}
log.Printf("[IMAGINGSTUDY WORKER] Error query db: %v", err)
logger.Default().Error("[IMAGING STUDY WORKER] Gagal query ke database", logger.ErrorField(err))
time.Sleep(5 * time.Second)
continue
}
// 2. Petakan ke payload SATUSEHAT via Internal API
internalPayload := MapToInternalAPI(isData, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/imagingstudy", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
// Fallback pencarian Encounter ID lewat API jika data di tabel kosong
if (!imagingData.EncounterIHS.Valid || imagingData.EncounterIHS.String == "") && imagingData.ServiceRequestID.Valid && imagingData.ServiceRequestID.String != "" {
logger.Default().Info("[IMAGING STUDY WORKER] Encounter ID kosong, mencoba fetch dari API ServiceRequest", logger.String("servicerequest_id", imagingData.ServiceRequestID.String))
// 3. Eksekusi pengiriman API
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
reqURL := fmt.Sprintf("%s/satusehat/servicerequest/%s", strings.TrimRight(w.cfg.InternalBaseURL, "/"), imagingData.ServiceRequestID.String)
srRespBody, srStatusCode, srErr := w.apiClient.GetJSON(ctx, reqURL, w.tokenManager.GetAccessToken())
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)
} 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)
if srStatusCode == http.StatusUnauthorized {
logger.Default().Warn("[IMAGING STUDY WORKER] 401 Unauthorized saat GET ServiceRequest, refresh token...")
if newToken, errRef := w.tokenManager.ForceRefreshAndGetToken(ctx); errRef == nil {
srRespBody, srStatusCode, srErr = w.apiClient.GetJSON(ctx, reqURL, newToken)
}
}
if srErr == nil && (srStatusCode == http.StatusOK || srStatusCode == http.StatusCreated) {
var parsedResp map[string]interface{}
if errParse := json.Unmarshal(srRespBody, &parsedResp); errParse == nil {
var encounterRef string
if data, ok := parsedResp["data"].(map[string]interface{}); ok {
if enc, ok := data["encounter"].(map[string]interface{}); ok {
encounterRef, _ = enc["reference"].(string)
}
} else if enc, ok := parsedResp["encounter"].(map[string]interface{}); ok {
encounterRef, _ = enc["reference"].(string)
}
if encounterRef != "" {
if parts := strings.Split(encounterRef, "/"); len(parts) > 1 {
imagingData.EncounterIHS.String = parts[len(parts)-1]
imagingData.EncounterIHS.Valid = true
logger.Default().Info("[IMAGING STUDY WORKER] Berhasil mendapatkan Encounter ID dari API", logger.String("encounter_id", imagingData.EncounterIHS.String))
}
}
}
}
}
// Buat response data berupa string JSON untuk log & penyimpanan error
logData := MapToSyncLog(isData.RequestID, fhirID, internalPayload, respBody, status, errMsg)
logBytes, _ := json.Marshal(logData)
// Gunakan SourceID (UUID) untuk logging dan tracking
logger.Default().Info("[IMAGING STUDY WORKER] 🔄 Memproses Dokumen",
logger.String("source_id", imagingData.SourceID),
logger.String("no_mr", imagingData.NoMR.String),
)
// 4. Update status ke tabel data_service_requests
errUpdate := w.repo.UpdateSyncStatus(ctx, isData.RequestID, status, string(logBytes))
if errUpdate != nil {
log.Printf("[IMAGINGSTUDY WORKER] Gagal update status di database untuk ID %s: %v", isData.RequestID, errUpdate)
} else {
log.Printf("[IMAGINGSTUDY WORKER] Sukses proses ID %s dengan status %s", isData.RequestID, status)
// Skip jika accession_number kosong / null
if !imagingData.AccessionNumber.Valid || imagingData.AccessionNumber.String == "" {
logger.Default().Warn("[IMAGING STUDY WORKER] ⏭️ Skip Dokumen: Accession Number kosong (Data RIS tidak ditemukan)",
logger.String("source_id", imagingData.SourceID),
logger.String("no_mr", imagingData.NoMR.String),
)
w.updateTracker(&lastCreatedAt, imagingData.CreatedAt)
continue
}
time.Sleep(500 * time.Millisecond)
// 2. Mapping Payload
internalPayload := MapToInternalAPI(imagingData, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/imagingstudy", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
logger.Default().Debug("[IMAGING STUDY WORKER] 📡 Mengirim Request ke API Part 3",
logger.String("url", reqURL),
logger.Any("payload", internalPayload),
)
// 3. Eksekusi Kirim HTTP via Client Helper Anda
fhirID, respBody, status, errMsg, shouldRetry := w.sendToInternalAPI(ctx, internalPayload, imagingData.SourceID)
/*
if err != nil {
// Kegagalan Jaringan/Timeout
status = "FAILED"
errMsg = err.Error()
logger.Default().Error("[IMAGING STUDY WORKER] ❌ Gagal Koneksi ke API Part 3",
logger.String("source_id", imagingData.SourceID),
logger.String("no_mr", imagingData.NoMR.String),
logger.Any("payload_sent", internalPayload),
logger.ErrorField(err),
)
shouldRetry = true // Wajib retry jika jaringan terputus/timeout
} else if statusCode != http.StatusOK && statusCode != http.StatusCreated {
// Kegagalan Response API
status = "FAILED"
errMsg = fmt.Sprintf("HTTP %d", statusCode)
logger.Default().Error("[IMAGING STUDY WORKER] ❌ Respons Error dari API Part 3",
logger.String("source_id", imagingData.SourceID),
logger.Int("status_code", statusCode),
logger.String("response", truncate(string(respBody), 200)),
)
// Retry jika Internal Server Error (5xx) atau Rate Limit (429)
if statusCode >= 500 || statusCode == http.StatusTooManyRequests {
shouldRetry = true
}
} else {
// Sukses
status = "SUCCESS"
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("[IMAGING STUDY WORKER] ✅ Sukses mengirim data Imaging Study",
logger.String("source_id", imagingData.SourceID),
logger.String("fhir_id", fhirID),
)
}*/
// 4. Catat Log ke Database
syncLog := MapToSyncLog(imagingData.SourceID, fhirID, internalPayload, respBody, status, errMsg)
if errLog := w.repo.SaveSyncLog(ctx, syncLog); errLog != nil {
logger.Default().Error("[IMAGING STUDY WORKER] Gagal menyimpan histori log ke DB", logger.ErrorField(errLog))
}
// 5. Update FHIR ID di tabel utama bila sukses
if status == "SUCCESS" && fhirID != "" {
if errUpdate := w.repo.SaveSatuSehatID(ctx, imagingData.SourceID, fhirID); errUpdate != nil {
logger.Default().Error("[IMAGING STUDY WORKER] Gagal update FHIR ID ke transaksi utama", logger.ErrorField(errUpdate))
}
}
// Update Tracker TXT agar tidak dikirim ulang terus-menerus KECUALI jika butuh retry
if !shouldRetry {
w.updateTracker(&lastCreatedAt, imagingData.CreatedAt)
} else {
// Memberitahu log bahwa data yang sama akan dicoba lagi pada loop berikutnya
logger.Default().Warn("[IMAGING STUDY WORKER] ⚠️ Retrying data", logger.String("source_id", imagingData.SourceID), logger.Time("created_at", imagingData.CreatedAt), logger.String("reason", errMsg))
}
// Jeda
if shouldRetry {
time.Sleep(5 * time.Second) // Tunggu sebentar jika server tujuan sedang sibuk
} else {
time.Sleep(2 * time.Second) // Jeda antar pengiriman diperpanjang untuk mencegah Rate Limit
}
}
}
// sendToInternalAPI sends the payload to the internal FHIR server, handles authentication and retries.
func (w *worker) sendToInternalAPI(ctx context.Context, payload map[string]interface{}, sourceID string) (fhirID string, respBody []byte, status string, errMsg string, shouldRetry bool) {
reqURL := fmt.Sprintf("%s/satusehat/imagingstudy", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
// --- First Attempt ---
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.tokenManager.GetAccessToken(), payload)
if err != nil {
// Network errors are always retried
logger.Default().Error("[IMAGING STUDY WORKER] ❌ Gagal Koneksi ke API Part 3",
logger.String("source_id", sourceID),
logger.Any("payload_sent", payload),
logger.ErrorField(err),
)
return "", nil, "FAILED", err.Error(), true
}
// --- Handle Unauthorized (401) ---
if statusCode == http.StatusUnauthorized {
logger.Default().Warn("[IMAGING STUDY WORKER] Menerima status 401 Unauthorized, mencoba refresh token...", logger.String("source_id", sourceID))
newToken, refreshErr := w.tokenManager.ForceRefreshAndGetToken(ctx)
if refreshErr != nil {
logger.Default().Error("[IMAGING STUDY WORKER] Gagal refresh token, akan mencoba lagi pada iterasi berikutnya.", logger.String("source_id", sourceID), logger.ErrorField(refreshErr))
return "", respBody, "FAILED", refreshErr.Error(), true
}
logger.Default().Info("[IMAGING STUDY WORKER] Token berhasil di-refresh, mencoba ulang request...", logger.String("source_id", sourceID))
// --- Second Attempt ---
respBody, statusCode, err = w.apiClient.PostJSON(ctx, reqURL, newToken, payload)
if err != nil {
logger.Default().Error("[IMAGING STUDY WORKER] ❌ Gagal Koneksi ke API Part 3 (setelah retry)",
logger.String("source_id", sourceID),
logger.ErrorField(err),
)
return "", nil, "FAILED", err.Error(), true
}
}
// --- Process Final Response ---
if statusCode != http.StatusOK && statusCode != http.StatusCreated {
errMsg = fmt.Sprintf("HTTP %d", statusCode)
logger.Default().Error("[IMAGING STUDY WORKER] ❌ Respons Error dari API Part 3",
logger.String("source_id", sourceID),
logger.Int("status_code", statusCode),
logger.String("response", truncate(string(respBody), 200)),
)
// Retry on server-side errors
if statusCode >= 500 || statusCode == http.StatusTooManyRequests {
return "", respBody, "FAILED", errMsg, true
}
// Do not retry on client-side errors (4xx)
return "", respBody, "FAILED", errMsg, false
}
// --- Success ---
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("[IMAGING STUDY WORKER] ✅ Sukses mengirim data Imaging Study",
logger.String("source_id", sourceID),
logger.String("fhir_id", fhirID),
)
return fhirID, respBody, "SUCCESS", "", false
}
func (w *worker) updateTracker(lastCreatedAt *time.Time, currentCreatedAt time.Time) {
*lastCreatedAt = currentCreatedAt
os.WriteFile(trackerFile, []byte(currentCreatedAt.Format(time.RFC3339Nano)), 0644)
}
func (w *worker) readLastCreatedAt() time.Time {
data, err := os.ReadFile(trackerFile)
if err != nil {
// Jika file tidak ada, mulai dari awal (zero time)
return time.Time{}
}
timestampStr := strings.TrimSpace(string(data))
t, err := time.Parse(time.RFC3339Nano, timestampStr)
if err != nil {
logger.Default().Warn("[IMAGING STUDY WORKER] Gagal parsing timestamp dari tracker file, memulai dari awal.", logger.String("content", timestampStr), logger.ErrorField(err))
return time.Time{}
}
return t
}
func truncate(s string, max int) string {
if len(s) > max {
return s[:max] + "..."
}
return s
}