Perbaikan Service Satu Sehat

This commit is contained in:
meninjar
2026-04-14 05:43:33 +00:00
parent 0adf9ef073
commit 135c631021
83 changed files with 95 additions and 5578 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ RUN go mod download
COPY . . COPY . .
# Expose port # Expose port
EXPOSE 8094 EXPOSE 8096
# Run air untuk hot reload # Run air untuk hot reload
CMD ["air", "-c", ".air.toml"] CMD ["air", "-c", ".air.toml"]
+14 -16
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -28,7 +27,6 @@ import (
satuSehatProcedure "service/internal/satusehat/usecase/procedure" satuSehatProcedure "service/internal/satusehat/usecase/procedure"
"service/pkg/logger" "service/pkg/logger"
imagingStudyWorker "service/internal/worker/satusehat/imagingstudy"
// roleComponent "service/internal/master/role/component" // roleComponent "service/internal/master/role/component"
roleMaster "service/internal/master/role/master" roleMaster "service/internal/master/role/master"
rolePages "service/internal/master/role/pages" rolePages "service/internal/master/role/pages"
@@ -197,21 +195,21 @@ func main() {
// Gunakan errgroup untuk mengelola lifecycle server // Gunakan errgroup untuk mengelola lifecycle server
g, ctx := errgroup.WithContext(ctx) g, ctx := errgroup.WithContext(ctx)
// --- [NEW] Start Background Workers --- // // --- [NEW] Start Background Workers ---
if cfg.SatuSehat.Enabled { // if cfg.SatuSehat.Enabled {
imagingStudyWorkerCfg := imagingStudyWorker.Config{ // imagingStudyWorkerCfg := imagingStudyWorker.Config{
DBManager: dbService, // DBManager: dbService,
InternalBaseURL: fmt.Sprintf("http://localhost:%d%s", cfg.Server.REST.Port, cfg.Swagger.BasePath), // otomatis: http://localhost:8080/api/v1 // InternalBaseURL: fmt.Sprintf("http://localhost:%d%s", cfg.Server.REST.Port, cfg.Swagger.BasePath), // otomatis: http://localhost:8080/api/v1
InternalToken: "", // Isi token di sini jika endpoint internal Usecase Anda membutuhkan Bearer Token // InternalToken: "", // Isi token di sini jika endpoint internal Usecase Anda membutuhkan Bearer Token
OrganizationID: cfg.SatuSehat.OrgID, // OrganizationID: cfg.SatuSehat.OrgID,
} // }
isWorker := imagingStudyWorker.NewWorker(imagingStudyWorkerCfg) // isWorker := imagingStudyWorker.NewWorker(imagingStudyWorkerCfg)
g.Go(func() error { // g.Go(func() error {
isWorker.Run(ctx) // isWorker.Run(ctx)
return nil // return nil
}) // })
} // }
serverCount := 0 serverCount := 0
-83
View File
@@ -1,83 +0,0 @@
package extapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client mendefinisikan kontrak fleksibel untuk mengirim HTTP request ke pihak ketiga/API internal.
type Client interface {
PostJSON(ctx context.Context, url string, token string, payload interface{}) ([]byte, int, error)
GetJSON(ctx context.Context, url string, token string) ([]byte, int, error)
}
type clientImpl struct {
httpClient *http.Client
}
// NewClient membuat instance baru dari Client dengan batas timeout tertentu.
func NewClient(timeout time.Duration) Client {
return &clientImpl{
httpClient: &http.Client{Timeout: timeout},
}
}
func (c *clientImpl) PostJSON(ctx context.Context, url string, token string, payload interface{}) ([]byte, int, error) {
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, 0, fmt.Errorf("gagal marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, 0, fmt.Errorf("gagal membuat request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("request gagal dieksekusi: %w", err)
}
defer res.Body.Close()
respBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, res.StatusCode, fmt.Errorf("gagal membaca body response: %w", err)
}
return respBody, res.StatusCode, nil
}
func (c *clientImpl) GetJSON(ctx context.Context, url string, token string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, 0, fmt.Errorf("gagal membuat request: %w", err)
}
req.Header.Set("Accept", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("request gagal dieksekusi: %w", err)
}
defer res.Body.Close()
respBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, res.StatusCode, fmt.Errorf("gagal membaca body response: %w", err)
}
return respBody, res.StatusCode, nil
}
-358
View File
@@ -1,358 +0,0 @@
package pasien
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const trackerFile = "last_migrated_patient_id.txt"
const (
defaultBaseURL = "https://main-api.demo-simrs-v4-rssa.multy.chat"
defaultUsername = "adm_pendaftaran"
defaultPassword = "1234"
)
// Config menyimpan konfigurasi untuk proses migrasi
type Config struct {
BaseURL string
Username string
Password string
DB *sql.DB // Menggunakan koneksi raw DB dari package database bawaan anda
}
// NewDefaultConfig membuat konfigurasi dengan kredensial default API
func NewDefaultConfig(db *sql.DB) Config {
return Config{
BaseURL: defaultBaseURL,
Username: defaultUsername,
Password: defaultPassword,
DB: db,
}
}
// PatientDB representasi struktur tabel public.m_pasien
type PatientDB struct {
ID int `db:"id"`
NoMR sql.NullString `db:"nomr"`
Title sql.NullString `db:"title"`
Nama sql.NullString `db:"nama"`
TempatLahir sql.NullString `db:"tempat"`
TglLahir sql.NullTime `db:"tgllahir"`
JenisKelamin sql.NullString `db:"jeniskelamin"`
NoKTP sql.NullString `db:"noktp"`
NoKTPBaru sql.NullString `db:"noktp_baru"`
NoKartu sql.NullString `db:"no_kartu"`
Pekerjaan sql.NullString `db:"pekerjaan"`
NoHP sql.NullString `db:"no_hp"`
Disabilitas sql.NullString `db:"disabilitas"`
HambatanKomunikasi sql.NullString `db:"hambatan_komunikasi"`
TxtAgama sql.NullString `db:"txt_agama"`
TxtPendidikan sql.NullString `db:"txt_pendidikan"`
TglDaftar sql.NullTime `db:"tgldaftar"`
}
// ApiPayload merepresentasikan struktur JSON untuk API pengiriman pasien
type ApiPayload struct {
Person Person `json:"person"`
PersonContacts []PersonContact `json:"personContacts"`
RegisteredAt string `json:"registeredAt"`
NewBornStatus bool `json:"newBornStatus"`
StatusCode string `json:"status_code"`
}
type Person struct {
Name string `json:"name"`
FrontTitle string `json:"frontTitle"`
EndTitle string `json:"endTitle"`
BirthDate string `json:"birthDate"`
BirthPlace string `json:"birthPlace"`
BirthRegencyCode string `json:"birthRegency_code,omitempty"`
GenderCode string `json:"gender_code"`
ResidentIdentityNumber string `json:"residentIdentityNumber"`
ReligionCode string `json:"religion_code"`
EducationCode string `json:"education_code"`
OccupationCode string `json:"occupation_code,omitempty"`
OccupationName string `json:"occupation_name"`
Nationality string `json:"nationality"`
CommunicationIssueStatus bool `json:"communicationIssueStatus"`
Disability string `json:"disability,omitempty"`
VclaimMemberCardNumber string `json:"vclaimMember_cardNumber,omitempty"`
}
type PersonContact struct {
TypeCode string `json:"type_code"`
Value string `json:"value"`
}
// AuthResponse menangkap kembalian token dari API Login
type AuthResponse struct {
Token string `json:"token"` // Sesuaikan field ini dengan struktur asli response API tujuan (misal: "access_token")
}
// RunMigration adalah fungsi utama yang dipanggil oleh cronjob / scheduler
func RunMigration(ctx context.Context, cfg Config) {
log.Println("[MIGRATOR] Memulai proses migrasi pasien...")
// 1. Dapatkan Token API
token, err := getAuthToken(cfg.BaseURL, cfg.Username, cfg.Password)
if err != nil {
log.Printf("[MIGRATOR] Gagal mendapatkan token: %v\n", err)
return
}
log.Println("[MIGRATOR] Berhasil mendapatkan Auth Token.")
// 2. Baca ID terakhir yang berhasil dikirim
lastID := readLastID()
log.Printf("[MIGRATOR] Melanjutkan migrasi dari ID pasien > %d\n", lastID)
// 3. Looping migrasi satu per satu
for {
// Cek apakah context dibatalkan (misal service dimatikan / graceful shutdown)
select {
case <-ctx.Done():
log.Println("[MIGRATOR] Proses dihentikan oleh sistem.")
return
default:
}
// Mengambil 1 data pasien berurutan berdasarkan ID
patient, err := getNextPatient(cfg.DB, lastID)
if err != nil {
if err == sql.ErrNoRows {
log.Println("[MIGRATOR] Selesai. Tidak ada lagi data pasien baru untuk dimigrasikan.")
return
}
log.Printf("[MIGRATOR] Gagal mengambil data database: %v\n", err)
return // Berhenti jika ada masalah database
}
log.Printf("[MIGRATOR] Memproses Pasien ID: %d, RM: %s, Nama: %s\n", patient.ID, patient.NoMR.String, patient.Nama.String)
// Mapping data DB ke Payload API
payload := buildPayload(patient)
// Kirim data ke API
err = sendPatientToAPI(cfg.BaseURL, token, payload)
if err != nil {
log.Printf("[MIGRATOR] Gagal mengirim Pasien ID %d ke API: %v\n", patient.ID, err)
log.Printf("[MIGRATOR] Migrasi terhenti. Akan diulang dari ID %d pada putaran berikutnya.\n", lastID)
return // Berhenti agar tidak ter-skip (Bisa diubah ke 'continue' jika ingin melewati data yg error)
}
// 4. Update file tracker dengan ID yang baru saja sukses
lastID = patient.ID
saveLastID(lastID)
log.Printf("[MIGRATOR] Sukses mengirim Pasien ID %d\n", lastID)
// Delay kecil agar tidak membebani server tujuan (Rate Limiting)
time.Sleep(500 * time.Millisecond)
}
}
// getNextPatient mengambil tepat 1 pasien setelah ID terakhir (ascending)
func getNextPatient(db *sql.DB, lastID int) (PatientDB, error) {
var p PatientDB
query := `
SELECT
id, nomr, title, nama, tempat, tgllahir, jeniskelamin,
noktp, noktp_baru, no_kartu, pekerjaan, no_hp, disabilitas,
hambatan_komunikasi, txt_agama, txt_pendidikan, tgldaftar
FROM public.m_pasien
WHERE id > $1
ORDER BY id ASC
LIMIT 1`
err := db.QueryRow(query, lastID).Scan(
&p.ID, &p.NoMR, &p.Title, &p.Nama, &p.TempatLahir, &p.TglLahir, &p.JenisKelamin,
&p.NoKTP, &p.NoKTPBaru, &p.NoKartu, &p.Pekerjaan, &p.NoHP, &p.Disabilitas, &p.HambatanKomunikasi,
&p.TxtAgama, &p.TxtPendidikan, &p.TglDaftar,
)
return p, err
}
// buildPayload memetakan struktur database menjadi struktur JSON API
func buildPayload(dbData PatientDB) ApiPayload {
// Format Gender (L/P ke male/female)
gender := "unknown"
if dbData.JenisKelamin.Valid {
if strings.ToUpper(dbData.JenisKelamin.String) == "L" {
gender = "male"
} else if strings.ToUpper(dbData.JenisKelamin.String) == "P" {
gender = "female"
}
}
// Format BirthDate ke ISO8601 (RFC3339)
birthDate := ""
if dbData.TglLahir.Valid {
birthDate = dbData.TglLahir.Time.Format(time.RFC3339)
}
// Format TglDaftar
registeredAt := time.Now().Format(time.RFC3339)
if dbData.TglDaftar.Valid {
registeredAt = dbData.TglDaftar.Time.Format(time.RFC3339)
}
// Issue komunikasi (boolean mapping dasar)
commIssue := false
if dbData.HambatanKomunikasi.Valid && dbData.HambatanKomunikasi.String != "" && strings.ToLower(dbData.HambatanKomunikasi.String) != "tidak ada" {
commIssue = true
}
// Susun Contacts array
contacts := []PersonContact{}
if dbData.NoHP.Valid && dbData.NoHP.String != "" {
contacts = append(contacts, PersonContact{
TypeCode: "phone",
Value: dbData.NoHP.String,
})
}
// Logika NoKTP Baru vs NoKTP Lama
ktp := ""
if dbData.NoKTPBaru.Valid && strings.TrimSpace(dbData.NoKTPBaru.String) != "" {
ktp = dbData.NoKTPBaru.String
} else if dbData.NoKTP.Valid {
ktp = dbData.NoKTP.String
}
return ApiPayload{
Person: Person{
Name: dbData.Nama.String,
FrontTitle: dbData.Title.String,
EndTitle: "", // Dikosongkan sesuai format JSON
BirthDate: birthDate,
BirthPlace: dbData.TempatLahir.String,
BirthRegencyCode: "", // Dikosongkan sesuai format JSON
GenderCode: gender,
ResidentIdentityNumber: ktp,
ReligionCode: strings.ToLower(dbData.TxtAgama.String), // Asumsi text cocok dengan code API
EducationCode: dbData.TxtPendidikan.String, // Asumsi text cocok dengan code API
OccupationCode: strings.ToLower(dbData.Pekerjaan.String),
OccupationName: dbData.Pekerjaan.String,
Nationality: "indonesia", // Default fallback
CommunicationIssueStatus: commIssue,
Disability: dbData.Disabilitas.String,
VclaimMemberCardNumber: dbData.NoKartu.String,
},
PersonContacts: contacts,
RegisteredAt: registeredAt,
NewBornStatus: false, // Hardcoded seperti pada contoh
StatusCode: "active",
}
}
// sendPatientToAPI melakukan HTTP POST request berisi data pasien
func sendPatientToAPI(baseURL, token string, payload ApiPayload) error {
url := fmt.Sprintf("%s/v1/patient", strings.TrimRight(baseURL, "/"))
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("gagal marshal payload: %v", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("X-UsedRole", "func|reg")
client := &http.Client{Timeout: 15 * time.Second}
res, err := client.Do(req)
if err != nil {
return fmt.Errorf("request gagal: %v", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
// Anggap HTTP 200 dan 201 adalah sukses
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated {
return fmt.Errorf("API merespon dengan status %d: %s", res.StatusCode, string(body))
}
return nil
}
// getAuthToken login dan mendapatkan token dari endpoint authentication
func getAuthToken(baseURL, username, password string) (string, error) {
url := fmt.Sprintf("%s/v1/authentication/login", strings.TrimRight(baseURL, "/"))
payload := map[string]string{
"name": username,
"password": password,
}
jsonData, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
res, err := client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
return "", fmt.Errorf("login failed dengan status %d: %s", res.StatusCode, string(body))
}
var authRes AuthResponse
if err := json.NewDecoder(res.Body).Decode(&authRes); err != nil {
// Fallback parse jika kembalian json menggunakan field lain misal "token" atau "access_token"
return "", fmt.Errorf("gagal decode response login: %v", err)
}
return authRes.Token, nil
}
// --- Tracker File Management ---
// readLastID membaca integer dari file tracker. Mengembalikan 0 jika file tidak ada/kosong.
func readLastID() int {
data, err := os.ReadFile(trackerFile)
if err != nil {
return 0 // Default mulai dari awal (ID > 0) jika file belum pernah dibuat
}
idStr := strings.TrimSpace(string(data))
if idStr == "" {
return 0
}
id, err := strconv.Atoi(idStr)
if err != nil {
log.Printf("[WARNING] Format isi tracker file salah ('%s'). Mulai dari 0.\n", idStr)
return 0
}
return id
}
// saveLastID menyimpan ID integer terakhir ke dalam file tracker
func saveLastID(id int) {
err := os.WriteFile(trackerFile, []byte(strconv.Itoa(id)), 0644)
if err != nil {
log.Printf("[ERROR] Gagal menyimpan file tracker: %v\n", err)
}
}
@@ -1,21 +0,0 @@
package allergyintolerance
import (
"database/sql"
)
// AllergyIntoleranceDB merepresentasikan tabel data alergi pasien
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
type AllergyIntoleranceDB struct {
IdxAlergi int64 `db:"idxalergi"`
NoMR sql.NullString `db:"nomr"`
}
type AllergyIntoleranceSyncLog struct {
IdxAlergi int64 `db:"idxalergi"`
AllergyIntoleranceID string `db:"allergyintolerance_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,27 +0,0 @@
package allergyintolerance
import (
"encoding/json"
"fmt"
)
// TODO: Sesuaikan dengan payload internal API AllergyIntolerance
func MapToInternalAPI(dbData *AllergyIntoleranceDB, orgID string) map[string]interface{} {
payload := map[string]interface{}{
"allergy_id": fmt.Sprintf("%d", dbData.IdxAlergi),
"patient_id": dbData.NoMR.String, // Sebaiknya mapping ke IHS Number di service
}
return payload
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) AllergyIntoleranceSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return AllergyIntoleranceSyncLog{
IdxAlergi: idx,
AllergyIntoleranceID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package allergyintolerance
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextAllergyIntolerance(ctx context.Context, lastID int64) (*AllergyIntoleranceDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData AllergyIntoleranceSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextAllergyIntolerance(ctx context.Context, lastID int64) (*AllergyIntoleranceDB, error) {
var al AllergyIntoleranceDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel alergi
qSimrs := query.DynamicQuery{
From: "public.t_alergi",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxalergi", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxalergi"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &al)
if err != nil {
return nil, err
}
return &al, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_alergi", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxalergi", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData AllergyIntoleranceSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxalergi", "allergyintolerance_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxAlergi, logData.AllergyIntoleranceID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_allergyintolerance", insertData, []string{"idxalergi"}, []string{"allergyintolerance_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package allergyintolerance
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_allergyintolerance_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[ALLERGYINTOLERANCE WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[ALLERGYINTOLERANCE WORKER] Proses dihentikan.")
return
default:
}
al, err := w.repo.GetNextAllergyIntolerance(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(al, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/allergyintolerance", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(al.IdxAlergi, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, al.IdxAlergi, fhirID)
}
lastID = al.IdxAlergi
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
-22
View File
@@ -1,22 +0,0 @@
package careplan
import (
"database/sql"
)
// CarePlanDB merepresentasikan struktur tabel rencana perawatan di database SIMRS Anda
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda yang sebenarnya
type CarePlanDB struct {
IdxRencana int64 `db:"idxrencana"` // Primary Key
IdxDaftar int64 `db:"idxdaftar"` // Relasi ke Encounter
NoMR sql.NullString `db:"nomr"`
}
type CarePlanSyncLog struct {
IdxRencana int64 `db:"idxrencana"`
CarePlanID string `db:"careplan_id"` // ID dari SatuSehat jika SUCCESS
RequestPayload string `db:"request_payload"` // Teks JSON raw request
ResponsePayload string `db:"response_payload"` // Teks JSON raw response
Status string `db:"status"` // Status: SUCCESS / FAILED
ErrorMessage string `db:"error_message"` // Pesan kegagalan
}
@@ -1,29 +0,0 @@
package careplan
import (
"encoding/json"
"fmt"
)
// MapToInternalAPI memetakan data database menjadi payload untuk Internal API
// TODO: Sesuaikan dengan JSON Payload endpoint CarePlan Anda
func MapToInternalAPI(dbData *CarePlanDB, orgID string) map[string]interface{} {
payload := map[string]interface{}{
"careplan_id": fmt.Sprintf("%d", dbData.IdxRencana),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String, // Perlu diganti IHS Number di service
}
return payload
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) CarePlanSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return CarePlanSyncLog{
IdxRencana: idx,
CarePlanID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package careplan
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextCarePlan(ctx context.Context, lastID int64) (*CarePlanDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData CarePlanSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextCarePlan(ctx context.Context, lastID int64) (*CarePlanDB, error) {
var cp CarePlanDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan dengan nama tabel CarePlan di SIMRS Anda
qSimrs := query.DynamicQuery{
From: "public.t_rencana_perawatan",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxrencana", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxrencana"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &cp)
if err != nil {
return nil, err
}
return &cp, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_rencana_perawatan", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxrencana", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData CarePlanSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxrencana", "careplan_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxRencana, logData.CarePlanID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_careplan", insertData, []string{"idxrencana"}, []string{"careplan_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package careplan
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_careplan_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[CAREPLAN WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[CAREPLAN WORKER] Proses dihentikan.")
return
default:
}
cp, err := w.repo.GetNextCarePlan(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(cp, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/careplan", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(cp.IdxRencana, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, cp.IdxRencana, fhirID)
}
lastID = cp.IdxRencana
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,23 +0,0 @@
package clinicalimpression
import (
"database/sql"
)
// ClinicalImpressionDB merepresentasikan tabel data kesan klinis / prognosis pasien
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
type ClinicalImpressionDB struct {
IdxKesan int64 `db:"idxkesan"`
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
TglKesan sql.NullTime `db:"tgl_kesan"`
}
type ClinicalImpressionSyncLog struct {
IdxKesan int64 `db:"idxkesan"`
ClinicalImpressionID string `db:"clinicalimpression_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,33 +0,0 @@
package clinicalimpression
import (
"encoding/json"
"fmt"
"time"
)
// TODO: Sesuaikan dengan payload internal API ClinicalImpression
func MapToInternalAPI(dbData *ClinicalImpressionDB, orgID string) map[string]interface{} {
waktu := time.Now()
if dbData.TglKesan.Valid {
waktu = dbData.TglKesan.Time
}
return map[string]interface{}{
"clinicalimpression_id": fmt.Sprintf("%d", dbData.IdxKesan),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String,
"date": waktu.Format(time.RFC3339),
}
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) ClinicalImpressionSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return ClinicalImpressionSyncLog{
IdxKesan: idx,
ClinicalImpressionID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package clinicalimpression
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextClinicalImpression(ctx context.Context, lastID int64) (*ClinicalImpressionDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData ClinicalImpressionSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextClinicalImpression(ctx context.Context, lastID int64) (*ClinicalImpressionDB, error) {
var ci ClinicalImpressionDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel kesan klinis/prognosis
qSimrs := query.DynamicQuery{
From: "public.t_kesan_klinis",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxkesan", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxkesan"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &ci)
if err != nil {
return nil, err
}
return &ci, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_kesan_klinis", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxkesan", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData ClinicalImpressionSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxkesan", "clinicalimpression_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxKesan, logData.ClinicalImpressionID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_clinicalimpression", insertData, []string{"idxkesan"}, []string{"clinicalimpression_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package clinicalimpression
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_clinicalimpression_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[CLINICALIMPRESSION WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[CLINICALIMPRESSION WORKER] Proses dihentikan.")
return
default:
}
ci, err := w.repo.GetNextClinicalImpression(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(ci, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/clinicalimpression", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(ci.IdxKesan, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, ci.IdxKesan, fhirID)
}
lastID = ci.IdxKesan
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,22 +0,0 @@
package composition
import (
"database/sql"
)
// CompositionDB merepresentasikan dokumen klinis
// TODO: Sesuaikan dengan tabel dokumen Anda
type CompositionDB struct {
IdxDokumen int64 `db:"idxdokumen"`
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
}
type CompositionSyncLog struct {
IdxDokumen int64 `db:"idxdokumen"`
CompositionID string `db:"composition_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,28 +0,0 @@
package composition
import (
"encoding/json"
"fmt"
)
// TODO: Sesuaikan dengan payload internal API
func MapToInternalAPI(dbData *CompositionDB, orgID string) map[string]interface{} {
payload := map[string]interface{}{
"composition_id": fmt.Sprintf("%d", dbData.IdxDokumen),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String,
}
return payload
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) CompositionSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return CompositionSyncLog{
IdxDokumen: idx,
CompositionID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package composition
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextComposition(ctx context.Context, lastID int64) (*CompositionDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData CompositionSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextComposition(ctx context.Context, lastID int64) (*CompositionDB, error) {
var comp CompositionDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel dokumen Anda
qSimrs := query.DynamicQuery{
From: "public.t_dokumen_klinis",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxdokumen", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxdokumen"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &comp)
if err != nil {
return nil, err
}
return &comp, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_dokumen_klinis", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxdokumen", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData CompositionSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxdokumen", "composition_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxDokumen, logData.CompositionID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_composition", insertData, []string{"idxdokumen"}, []string{"composition_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package composition
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_composition_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[COMPOSITION WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[COMPOSITION WORKER] Proses dihentikan.")
return
default:
}
comp, err := w.repo.GetNextComposition(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(comp, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/composition", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(comp.IdxDokumen, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, comp.IdxDokumen, fhirID)
}
lastID = comp.IdxDokumen
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,26 +0,0 @@
package condition
import (
"database/sql"
)
// ConditionDB merepresentasikan struktur tabel diagnosa medis
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda yang sebenarnya
type ConditionDB struct {
IdxDiagnosa int64 `db:"idxdiagnosa"` // Primary Key
IdxDaftar int64 `db:"idxdaftar"` // Relasi ke Encounter
NoMR sql.NullString `db:"nomr"`
TglDiagnosa sql.NullTime `db:"tgl_diagnosa"`
KdICD10 sql.NullString `db:"kd_icd10"` // Kode Penyakit ICD-10
NamaPenyakit sql.NullString `db:"nama_penyakit"`
}
// ConditionSyncLog merepresentasikan struktur log sinkronisasi API SatuSehat.
type ConditionSyncLog struct {
IdxDiagnosa int64 `db:"idxdiagnosa"`
ConditionID string `db:"condition_id"` // ID dari SatuSehat jika SUCCESS
RequestPayload string `db:"request_payload"` // Teks JSON raw request
ResponsePayload string `db:"response_payload"` // Teks JSON raw response
Status string `db:"status"` // Status: SUCCESS / FAILED
ErrorMessage string `db:"error_message"` // Pesan kegagalan
}
@@ -1,38 +0,0 @@
package condition
import (
"encoding/json"
"fmt"
"time"
)
// MapToInternalAPI memetakan data database menjadi payload untuk Internal API
// TODO: Sesuaikan dengan JSON Payload endpoint Condition Anda
func MapToInternalAPI(dbData *ConditionDB, orgID string) map[string]interface{} {
waktuDiagnosa := time.Now()
if dbData.TglDiagnosa.Valid {
waktuDiagnosa = dbData.TglDiagnosa.Time
}
payload := map[string]interface{}{
"condition_id": fmt.Sprintf("%d", dbData.IdxDiagnosa),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String, // Perlu diganti IHS Number di service
"code": dbData.KdICD10.String,
"display": dbData.NamaPenyakit.String,
"recorded_date": waktuDiagnosa.Format(time.RFC3339),
}
return payload
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) ConditionSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return ConditionSyncLog{
IdxDiagnosa: idx,
ConditionID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,92 +0,0 @@
package condition
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextCondition(ctx context.Context, lastID int64) (*ConditionDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData ConditionSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextCondition(ctx context.Context, lastID int64) (*ConditionDB, error) {
var cond ConditionDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan dengan nama tabel diagnosa di SIMRS Anda
qSimrs := query.DynamicQuery{
From: "public.t_diagnosa",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxdiagnosa", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxdiagnosa"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &cond)
if err != nil {
return nil, err
}
return &cond, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
updateData := query.UpdateData{
Columns: []string{"status_bridging"},
Values: []interface{}{fhirID},
}
filters := []query.FilterGroup{
query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxdiagnosa", idx)}),
}
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_diagnosa", updateData, filters)
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData ConditionSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxdiagnosa", "condition_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{
logData.IdxDiagnosa, logData.ConditionID, logData.RequestPayload,
logData.ResponsePayload, logData.Status, logData.ErrorMessage,
},
}
conflictCols := []string{"idxdiagnosa"}
updateCols := []string{"condition_id", "request_payload", "response_payload", "status", "error_message"}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_condition", insertData, conflictCols, updateCols)
return err
}
@@ -1,117 +0,0 @@
package condition
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_condition_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{
cfg: cfg,
repo: NewRepository(cfg.DBManager),
apiClient: extapi.NewClient(15 * time.Second),
}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[CONDITION WORKER] Memulai proses sinkronisasi ke API Satu Sehat...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[CONDITION WORKER] Proses dihentikan oleh sistem.")
return
default:
}
cond, err := w.repo.GetNextCondition(ctx, lastID)
if err != nil {
if err == sql.ErrNoRows {
time.Sleep(5 * time.Second)
continue
}
log.Printf("[CONDITION WORKER] Gagal mengambil data database: %v\n", err)
time.Sleep(5 * time.Second)
continue
}
log.Printf("[CONDITION WORKER] Memproses Diagnosa ID: %d\n", cond.IdxDiagnosa)
internalPayload := MapToInternalAPI(cond, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/condition", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
var status, errMsg, fhirID string
if err != nil {
status, errMsg = "FAILED", err.Error()
log.Printf("[CONDITION WORKER] Gagal HTTP Request (ID %d): %v\n", cond.IdxDiagnosa, err)
} else if statusCode != http.StatusOK && statusCode != http.StatusCreated {
status, errMsg = "FAILED", fmt.Sprintf("HTTP %d", statusCode)
log.Printf("[CONDITION WORKER] Gagal Response ID %d. HTTP: %d, Body: %s\n", cond.IdxDiagnosa, statusCode, string(respBody))
} else {
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
}
}
}
syncLog := MapToSyncLog(cond.IdxDiagnosa, fhirID, internalPayload, respBody, status, errMsg)
_ = w.repo.SaveSyncLog(ctx, syncLog)
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, cond.IdxDiagnosa, fhirID)
}
w.updateTracker(&lastID, cond.IdxDiagnosa)
time.Sleep(500 * time.Millisecond) // Rate limiting
}
}
func (w *worker) updateTracker(lastID *int64, currentID int64) {
*lastID = currentID
os.WriteFile(trackerFile, []byte(strconv.FormatInt(*lastID, 10)), 0644)
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,22 +0,0 @@
package diagnosticreport
import (
"database/sql"
)
// DiagnosticReportDB merepresentasikan tabel data hasil diagnostik (lab/radiologi)
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
type DiagnosticReportDB struct {
IdxHasil int64 `db:"idxhasil"`
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
}
type DiagnosticReportSyncLog struct {
IdxHasil int64 `db:"idxhasil"`
DiagnosticReportID string `db:"diagnosticreport_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,28 +0,0 @@
package diagnosticreport
import (
"encoding/json"
"fmt"
)
// TODO: Sesuaikan dengan payload internal API DiagnosticReport
func MapToInternalAPI(dbData *DiagnosticReportDB, orgID string) map[string]interface{} {
payload := map[string]interface{}{
"diagnosticreport_id": fmt.Sprintf("%d", dbData.IdxHasil),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String,
}
return payload
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) DiagnosticReportSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return DiagnosticReportSyncLog{
IdxHasil: idx,
DiagnosticReportID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package diagnosticreport
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextDiagnosticReport(ctx context.Context, lastID int64) (*DiagnosticReportDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData DiagnosticReportSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextDiagnosticReport(ctx context.Context, lastID int64) (*DiagnosticReportDB, error) {
var dr DiagnosticReportDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel hasil diagnostik
qSimrs := query.DynamicQuery{
From: "public.t_hasil_lab",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxhasil", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxhasil"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &dr)
if err != nil {
return nil, err
}
return &dr, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_hasil_lab", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxhasil", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData DiagnosticReportSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxhasil", "diagnosticreport_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxHasil, logData.DiagnosticReportID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_diagnosticreport", insertData, []string{"idxhasil"}, []string{"diagnosticreport_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package diagnosticreport
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_diagnosticreport_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[DIAGNOSTICREPORT WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[DIAGNOSTICREPORT WORKER] Proses dihentikan.")
return
default:
}
dr, err := w.repo.GetNextDiagnosticReport(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(dr, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/diagnosticreport", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(dr.IdxHasil, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, dr.IdxHasil, fhirID)
}
lastID = dr.IdxHasil
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,42 +0,0 @@
package encounter
import (
"database/sql"
)
// PendaftaranDB merepresentasikan struktur gabungan dari tabel transaksi SIMRS dan master SATUDATA
type PendaftaranDB struct {
// --- Sumber: Database SIMRS (PostgreSQL) ---
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
TglReg sql.NullTime `db:"tglreg"`
KdDokter sql.NullInt64 `db:"kddokter"`
KdPoly sql.NullInt64 `db:"kdpoly"`
KdCaraBayar sql.NullInt64 `db:"kdcarabayar"`
Status sql.NullInt64 `db:"status"`
Batal sql.NullString `db:"batal"`
JamReg sql.NullTime `db:"jamreg"`
MasukPoly sql.NullTime `db:"masukpoly"`
KeluarPoly sql.NullTime `db:"keluarpoly"`
// --- Sumber: Database SIMRS (public.m_pasien) ---
PatientName sql.NullString `db:"nama"`
NIK sql.NullString `db:"nik"`
// --- Sumber: Database SIMRS Master Data (PostgreSQL) ---
PatientIHS sql.NullString `db:"patient_ihs"`
PractitionerIHS sql.NullString `db:"practitioner_ihs"`
PractitionerNIK sql.NullString `db:"practitioner_nik"`
LocationIHS sql.NullString `db:"location_ihs"`
}
// EncounterSyncLog merepresentasikan struktur log sinkronisasi API SatuSehat.
// Struktur ini mempermudah pembacaan payload (Request/Response) serta mendukung proses pengiriman ulang (rollback/resend).
type EncounterSyncLog struct {
IdxDaftar int64 `db:"idxdaftar"`
EncounterID string `db:"encounter_id"` // ID dari SatuSehat jika status SUCCESS
RequestPayload string `db:"request_payload"` // Teks JSON raw request
ResponsePayload string `db:"response_payload"` // Teks JSON raw response
Status string `db:"status"` // Status: SUCCESS / FAILED
ErrorMessage string `db:"error_message"` // Pesan kegagalan (jika FAILED)
}
@@ -1,76 +0,0 @@
package encounter
import (
"encoding/json"
"fmt"
"time"
)
// MapToInternalAPI memetakan data database menjadi payload untuk Internal API GoPrint
func MapToInternalAPI(dbData *PendaftaranDB, orgID string) map[string]interface{} {
// 1. Penentuan status encounter
status := "arrived"
if dbData.Batal.Valid && dbData.Batal.String == "Y" {
status = "cancelled"
} else if dbData.KeluarPoly.Valid {
status = "finished"
} else if dbData.MasukPoly.Valid {
status = "in-progress"
}
// 2. Format Period Waktu
periodStart := time.Now()
if dbData.MasukPoly.Valid {
periodStart = dbData.MasukPoly.Time
} else if dbData.JamReg.Valid {
periodStart = dbData.JamReg.Time
}
// 3. Ekstrak Kelengkapan ID IHS
patientIHS := dbData.NoMR.String // Fallback ke NoMR jika IHS tidak ada
if dbData.PatientIHS.Valid && dbData.PatientIHS.String != "" {
patientIHS = dbData.PatientIHS.String
}
practitionerIHS := ""
if dbData.PractitionerIHS.Valid && dbData.PractitionerIHS.String != "" {
practitionerIHS = dbData.PractitionerIHS.String
}
locationIHS := ""
if dbData.LocationIHS.Valid && dbData.LocationIHS.String != "" {
locationIHS = dbData.LocationIHS.String
}
// 4. Bangun Payload Internal API
payload := map[string]interface{}{
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"organization_id": orgID,
"patient_id": patientIHS,
"patient_name": "Pasien " + patientIHS, // Nama fallback untuk lolos validasi usecase
"practitioner_id": practitionerIHS,
"practitioner_name": "Tenaga Kesehatan", // Nama fallback
"location_id": locationIHS,
"location_name": "Poli Faskes", // Nama fallback
"status": status,
"class": "AMB",
"period_start": periodStart.Format(time.RFC3339),
}
if dbData.KeluarPoly.Valid {
payload["period_end"] = dbData.KeluarPoly.Time.Format(time.RFC3339)
}
return payload
}
// MapToSyncLog membuat struktur data log histori untuk disimpan ke tabel database
func MapToSyncLog(idxdaftar int64, encounterID string, reqPayload interface{}, respBody []byte, status string, errMsg string) EncounterSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ") // Indent agar JSON mudah dibaca saat dibuka di DB/Adminer
return EncounterSyncLog{
IdxDaftar: idxdaftar,
EncounterID: encounterID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,187 +0,0 @@
package encounter
import (
"context"
"fmt"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
// Repository mendefinisikan kontrak akses database untuk worker encounter
type Repository interface {
GetNextPendaftaran(ctx context.Context, lastID int64) (*PendaftaranDB, error)
SaveSatuSehatID(ctx context.Context, idxdaftar int64, encounterID string) error
SaveSyncLog(ctx context.Context, logData EncounterSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextPendaftaran(ctx context.Context, lastID int64) (*PendaftaranDB, error) {
var p PendaftaranDB
// 1. Ambil koneksi database SQLX SIMRS (PostgreSQL)
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
// Inisiasi Query Builder (Disable security check untuk penggunaan internal & static)
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// 2. Ambil data Pendaftaran Utama dari SIMRS menggunakan Query Builder
qSimrs := query.DynamicQuery{
From: "public.t_pendaftaran",
Aliases: "p", // Alias untuk tabel pendaftaran
Fields: []query.SelectField{
{Expression: "p.idxdaftar"},
{Expression: "p.nomr"},
{Expression: "p.tglreg"},
{Expression: "p.kddokter"},
{Expression: "p.kdpoly"},
{Expression: "p.kdcarabayar"},
{Expression: "p.status"},
{Expression: "p.batal"},
{Expression: "p.jamreg"},
{Expression: "p.masukpoly"},
{Expression: "p.keluarpoly"},
{Expression: "m.nama"},
{Expression: "COALESCE(NULLIF(m.noktp_baru, ''), m.noktp) AS nik"},
},
Joins: []query.Join{
{
Type: "LEFT",
Table: "public.m_pasien",
Alias: "m", // Alias untuk tabel master pasien
OnConditions: query.FilterGroup{
Filters: []query.DynamicFilter{
// Relasi antar kolom: p.nomr = m.nomr
query.CreateEqualFilter("p.nomr", "m.nomr"),
},
},
},
},
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("p.idxdaftar", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("p.idxdaftar"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &p)
if err != nil {
return nil, err
}
// 3. Lengkapi referensi IHS Number dari tabel master SATUDATA menggunakan Query Builder
satuDataDB, err := r.dbManager.GetSQLXDB("satudata")
if err != nil {
return nil, fmt.Errorf("gagal koneksi ke database satudata: %w", err)
}
if p.NoMR.Valid && p.NoMR.String != "" {
qPasien := query.DynamicQuery{
From: "public.data_pasien",
Fields: []query.SelectField{{Expression: "\"Nomor_satusehat\""}},
Filters: []query.FilterGroup{{Filters: []query.DynamicFilter{query.CreateEqualFilter("\"Nomor_rekamedik\"", p.NoMR.String)}}},
Limit: 1,
}
_ = qb.ExecuteQueryRow(ctx, satuDataDB, qPasien, &p.PatientIHS)
}
if p.KdDokter.Valid {
qDokter := query.DynamicQuery{
From: "public.data_pegawai",
Fields: []query.SelectField{
{Expression: "\"Kode_satusehat\"", Alias: "practitioner_ihs"},
{Expression: "nik", Alias: "practitioner_nik"}, // Sesuaikan "nik" jika nama kolom di DB Anda menggunakan huruf besar "\"NIK\""
},
Filters: []query.FilterGroup{{Filters: []query.DynamicFilter{query.CreateEqualFilter("\"Kode_DPJP\"", fmt.Sprintf("%d", p.KdDokter.Int64))}}},
Limit: 1,
}
_ = qb.ExecuteQueryRow(ctx, satuDataDB, qDokter, &p)
}
if p.KdPoly.Valid {
qPoly := query.DynamicQuery{
From: "public.daftar_lokasi_ruang",
Fields: []query.SelectField{{Expression: "\"Kode_satusehat\""}},
Filters: []query.FilterGroup{{Filters: []query.DynamicFilter{query.CreateEqualFilter("\"ID_ruang_simgos\"", fmt.Sprintf("%d", p.KdPoly.Int64))}}},
Limit: 1,
}
_ = qb.ExecuteQueryRow(ctx, satuDataDB, qPoly, &p.LocationIHS)
}
return &p, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idxdaftar int64, encounterID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
// Menggunakan transaksi DB
tx, err := simrsDB.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // Otomatis rollback bila gagal (atau tidak di-commit)
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
updateData := query.UpdateData{
Columns: []string{"status_bridging"},
Values: []interface{}{encounterID},
}
filters := []query.FilterGroup{
query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxdaftar", idxdaftar)}),
}
// Eksekusi Update via Query Builder (melempar tx, bukan db, agar terbawa ke transaksi)
_, err = qb.ExecuteUpdate(ctx, tx, "public.t_pendaftaran", updateData, filters)
if err != nil {
return err
}
// Terapkan data
return tx.Commit()
}
// SaveSyncLog menyimpan histori pengiriman (Request & Response) ke tabel log
func (r *repository) SaveSyncLog(ctx context.Context, logData EncounterSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxdaftar", "encounter_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{
logData.IdxDaftar, logData.EncounterID, logData.RequestPayload,
logData.ResponsePayload, logData.Status, logData.ErrorMessage,
},
}
// Menggunakan UPSERT (ON CONFLICT) agar jika kita mengirim ulang (resend), log otomatis diperbarui
conflictCols := []string{"idxdaftar"}
updateCols := []string{"encounter_id", "request_payload", "response_payload", "status", "error_message"}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_encounter", insertData, conflictCols, updateCols)
return err
}
@@ -1,215 +0,0 @@
package encounter
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_encounter_id.txt"
// Config menyimpan konfigurasi untuk worker Encounter
type Config struct {
DBManager database.Service
InternalBaseURL string // URL Internal API (contoh: http://localhost:8080/api/v1)
InternalToken string // Token jika endpoint internal dilindungi Auth
OrganizationID string
}
// WorkerService adalah interface untuk trigger scheduler
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
// NewWorker inisiasi worker service baru
func NewWorker(cfg Config) WorkerService {
return &worker{
cfg: cfg,
repo: NewRepository(cfg.DBManager),
apiClient: extapi.NewClient(15 * time.Second),
}
}
// Run adalah fungsi utama worker/scheduler
func (w *worker) Run(ctx context.Context) {
log.Println("[ENCOUNTER WORKER] Memulai proses sinkronisasi ke API Satu Sehat...")
lastID := w.readLastID()
log.Printf("[ENCOUNTER WORKER] Melanjutkan migrasi dari idxdaftar > %d\n", lastID)
for {
select {
case <-ctx.Done():
log.Println("[ENCOUNTER WORKER] Proses dihentikan oleh sistem.")
return
default:
}
pendaftaran, err := w.repo.GetNextPendaftaran(ctx, lastID)
if err != nil {
if err == sql.ErrNoRows {
time.Sleep(5 * time.Second) // Tunggu data baru jika habis
continue
}
log.Printf("[ENCOUNTER WORKER] Gagal mengambil data database: %v\n", err)
time.Sleep(5 * time.Second)
continue
}
log.Printf("[ENCOUNTER WORKER] Memproses Pendaftaran ID: %d, RM: %s\n", pendaftaran.IdxDaftar, pendaftaran.NoMR.String)
// Fallback pencarian IHS Number lewat NIK jika data master kosong
if !pendaftaran.PatientIHS.Valid || pendaftaran.PatientIHS.String == "" {
if pendaftaran.NIK.Valid && pendaftaran.NIK.String != "" {
log.Printf("[ENCOUNTER WORKER] Patient IHS tidak ditemukan untuk ID %d. Mencoba mencari dari NIK: %s\n", pendaftaran.IdxDaftar, pendaftaran.NIK.String)
searchURL := fmt.Sprintf("%s/satusehat/reference/patient/nik/%s", strings.TrimRight(w.cfg.InternalBaseURL, "/"), pendaftaran.NIK.String)
searchResp, searchCode, searchErr := w.apiClient.GetJSON(ctx, searchURL, w.cfg.InternalToken)
if searchErr == nil && searchCode == http.StatusOK {
var searchResult map[string]interface{}
if err := json.Unmarshal(searchResp, &searchResult); err == nil {
// Ekstrak ID (IHS Number) dari atribut "data.id" pada format API standar Anda
if data, ok := searchResult["data"].(map[string]interface{}); ok {
if fhirID, ok := data["id"].(string); ok && fhirID != "" {
pendaftaran.PatientIHS.String = fhirID
pendaftaran.PatientIHS.Valid = true
log.Printf("[ENCOUNTER WORKER] Berhasil mendapatkan IHS %s dari pencarian NIK.\n", fhirID)
}
}
}
}
}
}
// Fallback pencarian Practitioner IHS Number lewat NIK jika data master kosong
if !pendaftaran.PractitionerIHS.Valid || pendaftaran.PractitionerIHS.String == "" {
if pendaftaran.PractitionerNIK.Valid && pendaftaran.PractitionerNIK.String != "" {
log.Printf("[ENCOUNTER WORKER] Practitioner IHS tidak ditemukan untuk ID %d. Mencoba mencari dari NIK: %s\n", pendaftaran.IdxDaftar, pendaftaran.PractitionerNIK.String)
searchURL := fmt.Sprintf("%s/satusehat/reference/practitioner/nik/%s", strings.TrimRight(w.cfg.InternalBaseURL, "/"), pendaftaran.PractitionerNIK.String)
searchResp, searchCode, searchErr := w.apiClient.GetJSON(ctx, searchURL, w.cfg.InternalToken)
if searchErr == nil && searchCode == http.StatusOK {
var searchResult map[string]interface{}
if err := json.Unmarshal(searchResp, &searchResult); err == nil {
if data, ok := searchResult["data"].(map[string]interface{}); ok {
if fhirID, ok := data["id"].(string); ok && fhirID != "" {
pendaftaran.PractitionerIHS.String = fhirID
pendaftaran.PractitionerIHS.Valid = true
log.Printf("[ENCOUNTER WORKER] Berhasil mendapatkan Practitioner IHS %s dari pencarian NIK.\n", fhirID)
}
}
}
}
}
}
// Skip record jika data IHS masih belum ada (gagal dari DB dan gagal dicari dari NIK)
if !pendaftaran.PatientIHS.Valid || pendaftaran.PatientIHS.String == "" {
log.Printf("[ENCOUNTER WORKER] Pendaftaran ID %d di-skip karena Patient IHS tidak ditemukan dan pencarian NIK gagal/kosong.\n", pendaftaran.IdxDaftar)
w.updateTracker(&lastID, pendaftaran.IdxDaftar)
continue
}
internalPayload := MapToInternalAPI(pendaftaran, w.cfg.OrganizationID)
// Mengirim data ke endpoint internal API Faskes (POST /satusehat/encounter)
reqURL := fmt.Sprintf("%s/satusehat/encounter", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
// Eksekusi HTTP request menggunakan Interface external client yang sudah dibuat
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
// Evaluasi Response dan Ekstrak ID (Jika Sukses)
var status, errMsg, fhirID string
if err != nil {
status = "FAILED"
errMsg = err.Error()
log.Printf("[ENCOUNTER WORKER] Gagal HTTP Request ke Internal API (ID %d): %v\n", pendaftaran.IdxDaftar, err)
} else if statusCode != http.StatusOK && statusCode != http.StatusCreated {
status = "FAILED"
errMsg = fmt.Sprintf("HTTP %d", statusCode)
log.Printf("[ENCOUNTER WORKER] Gagal Response Internal API ID %d. HTTP: %d, Response: %s\n", pendaftaran.IdxDaftar, statusCode, string(respBody))
} else {
status = "SUCCESS"
// Ambil ID FHIR Kemenkes dari response format Standar Golang Faskes (Result.Data)
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
}
}
log.Printf("[ENCOUNTER WORKER] Sukses mengirim ID %d. Response: %s\n", pendaftaran.IdxDaftar, truncate(string(respBody), 150))
}
// Simpan histori/log ke database untuk kemudahan audit dan resend/rollback
syncLog := MapToSyncLog(pendaftaran.IdxDaftar, fhirID, internalPayload, respBody, status, errMsg)
_ = w.repo.SaveSyncLog(ctx, syncLog)
// Jika sukses, simpan ID FHIR ke tabel transaksi utama
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, pendaftaran.IdxDaftar, fhirID)
}
// Update file tracker (PENTING: Lanjut ke ID berikutnya agar tidak stuck pada data yang selalu error)
w.updateTracker(&lastID, pendaftaran.IdxDaftar)
// Jika gagal, tahan sebentar sebelum lanjut (Rate limiting/jeda darurat)
if status == "FAILED" {
time.Sleep(5 * time.Second)
} else {
time.Sleep(500 * time.Millisecond)
}
}
}
func (w *worker) updateTracker(lastID *int64, currentID int64) {
*lastID = currentID
w.saveLastID(*lastID)
}
// --- Tracker File Management ---
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 {
return 0
}
return id
}
func (w *worker) saveLastID(id int64) {
os.WriteFile(trackerFile, []byte(strconv.FormatInt(id, 10)), 0644)
}
func truncate(s string, max int) string {
if len(s) > max {
return s[:max] + "..."
}
return s
}
@@ -1,21 +0,0 @@
package episodeofcare
import (
"database/sql"
)
// EpisodeOfCareDB merepresentasikan data episode perawatan
// TODO: Sesuaikan dengan tabel Episode Anda
type EpisodeOfCareDB struct {
IdxEpisode int64 `db:"idxepisode"`
NoMR sql.NullString `db:"nomr"`
}
type EpisodeOfCareSyncLog struct {
IdxEpisode int64 `db:"idxepisode"`
EpisodeOfCareID string `db:"episodeofcare_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,27 +0,0 @@
package episodeofcare
import (
"encoding/json"
"fmt"
)
// TODO: Sesuaikan dengan payload internal API EpisodeOfCare
func MapToInternalAPI(dbData *EpisodeOfCareDB, orgID string) map[string]interface{} {
payload := map[string]interface{}{
"episode_id": fmt.Sprintf("%d", dbData.IdxEpisode),
"patient_id": dbData.NoMR.String,
}
return payload
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) EpisodeOfCareSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return EpisodeOfCareSyncLog{
IdxEpisode: idx,
EpisodeOfCareID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package episodeofcare
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextEpisodeOfCare(ctx context.Context, lastID int64) (*EpisodeOfCareDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData EpisodeOfCareSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextEpisodeOfCare(ctx context.Context, lastID int64) (*EpisodeOfCareDB, error) {
var ep EpisodeOfCareDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel episode Anda
qSimrs := query.DynamicQuery{
From: "public.t_episode_perawatan",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxepisode", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxepisode"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &ep)
if err != nil {
return nil, err
}
return &ep, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_episode_perawatan", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxepisode", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData EpisodeOfCareSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxepisode", "episodeofcare_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxEpisode, logData.EpisodeOfCareID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_episodeofcare", insertData, []string{"idxepisode"}, []string{"episodeofcare_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package episodeofcare
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_episodeofcare_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[EPISODEOFCARE WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[EPISODEOFCARE WORKER] Proses dihentikan.")
return
default:
}
ep, err := w.repo.GetNextEpisodeOfCare(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(ep, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/episodeofcare", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(ep.IdxEpisode, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, ep.IdxEpisode, fhirID)
}
lastID = ep.IdxEpisode
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,28 +0,0 @@
package imagingstudy
import (
"database/sql"
)
// 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
}
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"`
}
@@ -1,36 +0,0 @@
package imagingstudy
import (
"encoding/json"
"time"
)
// MapToInternalAPI memetakan data gabungan ke payload API Internal SatuSehat
func MapToInternalAPI(dbData *ImagingStudyData, orgID string) map[string]interface{} {
waktu := time.Now()
if dbData.StartedDate.Valid {
waktu = dbData.StartedDate.Time
}
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),
}
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,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,87 +0,0 @@
package imagingstudy
import (
"context"
"database/sql"
"service/internal/infrastructure/database"
)
type Repository interface {
GetNextPendingRequest(ctx context.Context) (*ImagingStudyData, error)
UpdateSyncStatus(ctx context.Context, reqID string, status string, responsePayload string) error
}
type repository struct {
dbManager database.Service
}
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
// 1. Ambil koneksi Postgres (Satu Data) - Sesuaikan nama dengan konfigurasi Anda
pgDB, err := r.dbManager.GetDB("satudata")
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
}
// 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`
var modality sql.NullString
var foto sql.NullTime
// 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
}
}
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")
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)
return err
}
@@ -1,98 +0,0 @@
package imagingstudy
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[IMAGINGSTUDY WORKER] Memulai proses sinkronisasi...")
for {
select {
case <-ctx.Done():
log.Println("[IMAGINGSTUDY WORKER] Proses dihentikan.")
return
default:
}
// 1. Ambil data dengan status belum terkirim (IS NULL atau FAILED)
isData, err := w.repo.GetNextPendingRequest(ctx)
if err != nil {
if err == sql.ErrNoRows {
// Jika tidak ada data antrean, istirahat lebih lama
time.Sleep(10 * time.Second)
continue
}
log.Printf("[IMAGINGSTUDY WORKER] Error query db: %v", 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, "/"))
// 3. Eksekusi pengiriman API
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
// Buat response data berupa string JSON untuk log & penyimpanan error
logData := MapToSyncLog(isData.RequestID, fhirID, internalPayload, respBody, status, errMsg)
logBytes, _ := json.Marshal(logData)
// 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)
}
time.Sleep(500 * time.Millisecond)
}
}
@@ -1,24 +0,0 @@
package immunization
import (
"database/sql"
)
// ImmunizationDB merepresentasikan tabel data imunisasi / vaksinasi
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
type ImmunizationDB struct {
IdxImunisasi int64 `db:"idximunisasi"`
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
TglImunisasi sql.NullTime `db:"tgl_imunisasi"`
KodeVaksin sql.NullString `db:"kode_vaksin"` // Kode KFA Vaksin
}
type ImmunizationSyncLog struct {
IdxImunisasi int64 `db:"idximunisasi"`
ImmunizationID string `db:"immunization_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,35 +0,0 @@
package immunization
import (
"encoding/json"
"fmt"
"time"
)
// TODO: Sesuaikan dengan payload internal API Immunization
func MapToInternalAPI(dbData *ImmunizationDB, orgID string) map[string]interface{} {
waktu := time.Now()
if dbData.TglImunisasi.Valid {
waktu = dbData.TglImunisasi.Time
}
payload := map[string]interface{}{
"immunization_id": fmt.Sprintf("%d", dbData.IdxImunisasi),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String,
"vaccine_code": dbData.KodeVaksin.String,
"occurrence_date": waktu.Format(time.RFC3339),
}
return payload
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) ImmunizationSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return ImmunizationSyncLog{
IdxImunisasi: idx,
ImmunizationID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package immunization
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextImmunization(ctx context.Context, lastID int64) (*ImmunizationDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData ImmunizationSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextImmunization(ctx context.Context, lastID int64) (*ImmunizationDB, error) {
var im ImmunizationDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel imunisasi Anda
qSimrs := query.DynamicQuery{
From: "public.t_imunisasi",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idximunisasi", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idximunisasi"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &im)
if err != nil {
return nil, err
}
return &im, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_imunisasi", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idximunisasi", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData ImmunizationSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idximunisasi", "immunization_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxImunisasi, logData.ImmunizationID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_immunization", insertData, []string{"idximunisasi"}, []string{"immunization_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package immunization
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_immunization_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[IMMUNIZATION WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[IMMUNIZATION WORKER] Proses dihentikan.")
return
default:
}
im, err := w.repo.GetNextImmunization(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(im, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/immunization", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(im.IdxImunisasi, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, im.IdxImunisasi, fhirID)
}
lastID = im.IdxImunisasi
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
-134
View File
@@ -1,134 +0,0 @@
CREATE TABLE log_satusehat_encounter (
idxdaftar BIGINT PRIMARY KEY,
encounter_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_observation (
idxpemeriksaan BIGINT PRIMARY KEY,
observation_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_condition (
idxdiagnosa BIGINT PRIMARY KEY,
condition_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_allergyintolerance (
idxalergi BIGINT PRIMARY KEY,
allergyintolerance_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_clinicalimpression (
idxkesan BIGINT PRIMARY KEY,
clinicalimpression_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_diagnosticreport (
idxhasil BIGINT PRIMARY KEY,
diagnosticreport_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_immunization (
idximunisasi BIGINT PRIMARY KEY,
immunization_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_imagingstudy (
idxradiologi BIGINT PRIMARY KEY,
imagingstudy_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_servicerequest (
idxorder BIGINT PRIMARY KEY,
servicerequest_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_specimen (
idxspesimen BIGINT PRIMARY KEY,
specimen_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_questionnaireresponse (
idxkuesioner BIGINT PRIMARY KEY,
questionnaireresponse_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_medicationrequest (
idxresep BIGINT PRIMARY KEY,
medicationrequest_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_medicationdispense (
idxpemberian BIGINT PRIMARY KEY,
medicationdispense_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_medicationstatement (
idxriwayatobat BIGINT PRIMARY KEY,
medicationstatement_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
CREATE TABLE log_satusehat_medication (
idxobat BIGINT PRIMARY KEY,
medication_id VARCHAR(100),
request_payload TEXT,
response_payload TEXT,
status VARCHAR(20),
error_message TEXT
);
@@ -1,27 +0,0 @@
package medication
import (
"database/sql"
)
// MedicationDB merepresentasikan tabel master/transaksi obat (KFA)
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
type MedicationDB struct {
IdxObat int64 `db:"idxobat"`
KodeKFA sql.NullString `db:"kode_kfa"`
NamaKFA sql.NullString `db:"nama_kfa"`
KodeBentuk sql.NullString `db:"kode_bentuk"`
NamaBentuk sql.NullString `db:"nama_bentuk"`
ManufacturerID sql.NullString `db:"manufacturer_id"`
NoBatch sql.NullString `db:"no_batch"`
TglKadaluarsa sql.NullTime `db:"tgl_kadaluarsa"`
}
type MedicationSyncLog struct {
IdxObat int64 `db:"idxobat"`
MedicationID string `db:"medication_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,35 +0,0 @@
package medication
import (
"encoding/json"
"time"
)
// TODO: Sesuaikan dengan payload internal API Medication
func MapToInternalAPI(dbData *MedicationDB, orgID string) map[string]interface{} {
payload := map[string]interface{}{
"status_code": "active",
"kfa_code": dbData.KodeKFA.String,
"kfa_display": dbData.NamaKFA.String,
"form_code": dbData.KodeBentuk.String,
"form_display": dbData.NamaBentuk.String,
"manufacturer_id": orgID, // Bisa disesuaikan menjadi dbData.ManufacturerID.String jika beda pabrik
"batch_number": dbData.NoBatch.String,
}
if dbData.TglKadaluarsa.Valid {
payload["expiration_date"] = dbData.TglKadaluarsa.Time.Format(time.RFC3339)
}
return payload
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) MedicationSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return MedicationSyncLog{
IdxObat: idx,
MedicationID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package medication
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextMedication(ctx context.Context, lastID int64) (*MedicationDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData MedicationSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextMedication(ctx context.Context, lastID int64) (*MedicationDB, error) {
var med MedicationDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel referensi/master obat Anda
qSimrs := query.DynamicQuery{
From: "public.m_obat",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxobat", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxobat"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &med)
if err != nil {
return nil, err
}
return &med, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.m_obat", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxobat", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData MedicationSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxobat", "medication_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxObat, logData.MedicationID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_medication", insertData, []string{"idxobat"}, []string{"medication_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package medication
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_medication_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[MEDICATION WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[MEDICATION WORKER] Proses dihentikan.")
return
default:
}
med, err := w.repo.GetNextMedication(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(med, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/medication", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(med.IdxObat, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, med.IdxObat, fhirID)
}
lastID = med.IdxObat
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,25 +0,0 @@
package medicationdispense
import (
"database/sql"
)
// MedicationDispenseDB merepresentasikan tabel data pemberian obat
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
type MedicationDispenseDB struct {
IdxPemberian int64 `db:"idxpemberian"`
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
TglDiberikan sql.NullTime `db:"tgl_diberikan"`
KFA sql.NullString `db:"kfa_code"`
Qty sql.NullFloat64 `db:"qty"`
}
type MedicationDispenseSyncLog struct {
IdxPemberian int64 `db:"idxpemberian"`
MedicationDispenseID string `db:"medicationdispense_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,35 +0,0 @@
package medicationdispense
import (
"encoding/json"
"fmt"
"time"
)
// TODO: Sesuaikan dengan payload internal API MedicationDispense
func MapToInternalAPI(dbData *MedicationDispenseDB, orgID string) map[string]interface{} {
waktu := time.Now()
if dbData.TglDiberikan.Valid {
waktu = dbData.TglDiberikan.Time
}
return map[string]interface{}{
"medicationdispense_id": fmt.Sprintf("%d", dbData.IdxPemberian),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String,
"medication_code": dbData.KFA.String,
"when_handed_over": waktu.Format(time.RFC3339),
"quantity": dbData.Qty.Float64,
}
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) MedicationDispenseSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return MedicationDispenseSyncLog{
IdxPemberian: idx,
MedicationDispenseID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package medicationdispense
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextMedicationDispense(ctx context.Context, lastID int64) (*MedicationDispenseDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData MedicationDispenseSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextMedicationDispense(ctx context.Context, lastID int64) (*MedicationDispenseDB, error) {
var md MedicationDispenseDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel pemberian obat (Apotek)
qSimrs := query.DynamicQuery{
From: "public.t_pemberian_obat",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxpemberian", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxpemberian"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &md)
if err != nil {
return nil, err
}
return &md, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_pemberian_obat", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxpemberian", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData MedicationDispenseSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxpemberian", "medicationdispense_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxPemberian, logData.MedicationDispenseID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_medicationdispense", insertData, []string{"idxpemberian"}, []string{"medicationdispense_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package medicationdispense
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_medicationdispense_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[MEDICATIONDISPENSE WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[MEDICATIONDISPENSE WORKER] Proses dihentikan.")
return
default:
}
md, err := w.repo.GetNextMedicationDispense(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(md, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/medicationdispense", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(md.IdxPemberian, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, md.IdxPemberian, fhirID)
}
lastID = md.IdxPemberian
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,25 +0,0 @@
package medicationrequest
import (
"database/sql"
)
// MedicationRequestDB merepresentasikan tabel data resep obat
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
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"`
}
type MedicationRequestSyncLog struct {
IdxResep int64 `db:"idxresep"`
MedicationRequestID string `db:"medicationrequest_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,35 +0,0 @@
package medicationrequest
import (
"encoding/json"
"fmt"
"time"
)
// 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
}
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,
}
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) MedicationRequestSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return MedicationRequestSyncLog{
IdxResep: idx,
MedicationRequestID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package medicationrequest
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextMedicationRequest(ctx context.Context, lastID int64) (*MedicationRequestDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData MedicationRequestSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextMedicationRequest(ctx context.Context, lastID int64) (*MedicationRequestDB, error) {
var mr MedicationRequestDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel resep
qSimrs := query.DynamicQuery{
From: "public.t_resep_obat",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxresep", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxresep"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &mr)
if err != nil {
return nil, err
}
return &mr, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
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)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData MedicationRequestSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
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},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_medicationrequest", insertData, []string{"idxresep"}, []string{"medicationrequest_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package medicationrequest
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_medicationrequest_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[MEDICATIONREQUEST WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[MEDICATIONREQUEST WORKER] Proses dihentikan.")
return
default:
}
mr, err := w.repo.GetNextMedicationRequest(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
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)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(mr.IdxResep, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, mr.IdxResep, fhirID)
}
lastID = mr.IdxResep
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,25 +0,0 @@
package medicationstatement
import (
"database/sql"
)
// MedicationStatementDB merepresentasikan tabel data riwayat pengobatan / pengakuan pasien
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
type MedicationStatementDB struct {
IdxRiwayatObat int64 `db:"idxriwayatobat"`
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
TglDicatat sql.NullTime `db:"tgl_dicatat"`
KFA sql.NullString `db:"kfa_code"`
Instruksi sql.NullString `db:"instruksi"`
}
type MedicationStatementSyncLog struct {
IdxRiwayatObat int64 `db:"idxriwayatobat"`
MedicationStatementID string `db:"medicationstatement_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,35 +0,0 @@
package medicationstatement
import (
"encoding/json"
"fmt"
"time"
)
// TODO: Sesuaikan dengan payload internal API MedicationStatement
func MapToInternalAPI(dbData *MedicationStatementDB, orgID string) map[string]interface{} {
waktu := time.Now()
if dbData.TglDicatat.Valid {
waktu = dbData.TglDicatat.Time
}
return map[string]interface{}{
"medicationstatement_id": fmt.Sprintf("%d", dbData.IdxRiwayatObat),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String,
"medication_code": dbData.KFA.String,
"date_asserted": waktu.Format(time.RFC3339),
"dosage_text": dbData.Instruksi.String,
}
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) MedicationStatementSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return MedicationStatementSyncLog{
IdxRiwayatObat: idx,
MedicationStatementID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package medicationstatement
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextMedicationStatement(ctx context.Context, lastID int64) (*MedicationStatementDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData MedicationStatementSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextMedicationStatement(ctx context.Context, lastID int64) (*MedicationStatementDB, error) {
var ms MedicationStatementDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel riwayat obat
qSimrs := query.DynamicQuery{
From: "public.t_riwayat_obat",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxriwayatobat", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxriwayatobat"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &ms)
if err != nil {
return nil, err
}
return &ms, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_riwayat_obat", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxriwayatobat", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData MedicationStatementSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxriwayatobat", "medicationstatement_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxRiwayatObat, logData.MedicationStatementID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_medicationstatement", insertData, []string{"idxriwayatobat"}, []string{"medicationstatement_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package medicationstatement
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_medicationstatement_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[MEDICATIONSTATEMENT WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[MEDICATIONSTATEMENT WORKER] Proses dihentikan.")
return
default:
}
ms, err := w.repo.GetNextMedicationStatement(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(ms, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/medicationstatement", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(ms.IdxRiwayatObat, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, ms.IdxRiwayatObat, fhirID)
}
lastID = ms.IdxRiwayatObat
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,28 +0,0 @@
package observation
import (
"database/sql"
)
// ObservationDB merepresentasikan struktur tabel pemeriksaan (Contoh: Vital Sign/Lab)
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda yang sebenarnya
type ObservationDB struct {
IdxPemeriksaan int64 `db:"idxpemeriksaan"` // Primary Key
IdxDaftar int64 `db:"idxdaftar"` // Relasi ke Encounter
NoMR sql.NullString `db:"nomr"`
TglPeriksa sql.NullTime `db:"tgl_periksa"`
KodePemeriksaan sql.NullString `db:"kode_pemeriksaan"` // Contoh: LOINC Code
Nilai sql.NullString `db:"nilai"`
Satuan sql.NullString `db:"satuan"`
KdDokter sql.NullInt64 `db:"kddokter"`
}
// ObservationSyncLog merepresentasikan struktur log sinkronisasi API SatuSehat.
type ObservationSyncLog struct {
IdxPemeriksaan int64 `db:"idxpemeriksaan"`
ObservationID string `db:"observation_id"` // ID dari SatuSehat jika SUCCESS
RequestPayload string `db:"request_payload"` // Teks JSON raw request
ResponsePayload string `db:"response_payload"` // Teks JSON raw response
Status string `db:"status"` // Status: SUCCESS / FAILED
ErrorMessage string `db:"error_message"` // Pesan kegagalan
}
@@ -1,42 +0,0 @@
package observation
import (
"encoding/json"
"fmt"
"time"
)
// MapToInternalAPI memetakan data database menjadi payload untuk Internal API GoPrint
// TODO: Sesuaikan dengan JSON Payload yang dibutuhkan oleh internal endpoint Observation Anda
func MapToInternalAPI(dbData *ObservationDB, orgID string) map[string]interface{} {
// Format Waktu
waktuPeriksa := time.Now()
if dbData.TglPeriksa.Valid {
waktuPeriksa = dbData.TglPeriksa.Time
}
payload := map[string]interface{}{
"observation_id": fmt.Sprintf("%d", dbData.IdxPemeriksaan),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"organization_id": orgID,
"patient_id": dbData.NoMR.String, // Sebaiknya diganti dengan IHS Number di Service
"code": dbData.KodePemeriksaan.String,
"value": dbData.Nilai.String,
"unit": dbData.Satuan.String,
"effective_date": waktuPeriksa.Format(time.RFC3339),
}
return payload
}
// MapToSyncLog membuat struktur data log histori untuk disimpan ke tabel database
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) ObservationSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return ObservationSyncLog{
IdxPemeriksaan: idx,
ObservationID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,92 +0,0 @@
package observation
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextObservation(ctx context.Context, lastID int64) (*ObservationDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData ObservationSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextObservation(ctx context.Context, lastID int64) (*ObservationDB, error) {
var obs ObservationDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan dengan nama tabel pemeriksaan di SIMRS Anda
qSimrs := query.DynamicQuery{
From: "public.t_pemeriksaan",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxpemeriksaan", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxpemeriksaan"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &obs)
if err != nil {
return nil, err
}
return &obs, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
updateData := query.UpdateData{
Columns: []string{"status_bridging"},
Values: []interface{}{fhirID},
}
filters := []query.FilterGroup{
query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxpemeriksaan", idx)}),
}
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_pemeriksaan", updateData, filters)
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData ObservationSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxpemeriksaan", "observation_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{
logData.IdxPemeriksaan, logData.ObservationID, logData.RequestPayload,
logData.ResponsePayload, logData.Status, logData.ErrorMessage,
},
}
conflictCols := []string{"idxpemeriksaan"}
updateCols := []string{"observation_id", "request_payload", "response_payload", "status", "error_message"}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_observation", insertData, conflictCols, updateCols)
return err
}
@@ -1,117 +0,0 @@
package observation
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_observation_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{
cfg: cfg,
repo: NewRepository(cfg.DBManager),
apiClient: extapi.NewClient(15 * time.Second),
}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[OBSERVATION WORKER] Memulai proses sinkronisasi ke API Satu Sehat...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[OBSERVATION WORKER] Proses dihentikan oleh sistem.")
return
default:
}
obs, err := w.repo.GetNextObservation(ctx, lastID)
if err != nil {
if err == sql.ErrNoRows {
time.Sleep(5 * time.Second)
continue
}
log.Printf("[OBSERVATION WORKER] Gagal mengambil data database: %v\n", err)
time.Sleep(5 * time.Second)
continue
}
log.Printf("[OBSERVATION WORKER] Memproses Observasi ID: %d\n", obs.IdxPemeriksaan)
internalPayload := MapToInternalAPI(obs, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/observation", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
var status, errMsg, fhirID string
if err != nil {
status, errMsg = "FAILED", err.Error()
log.Printf("[OBSERVATION WORKER] Gagal HTTP Request (ID %d): %v\n", obs.IdxPemeriksaan, err)
} else if statusCode != http.StatusOK && statusCode != http.StatusCreated {
status, errMsg = "FAILED", fmt.Sprintf("HTTP %d", statusCode)
log.Printf("[OBSERVATION WORKER] Gagal Response ID %d. HTTP: %d, Body: %s\n", obs.IdxPemeriksaan, statusCode, string(respBody))
} else {
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
}
}
}
syncLog := MapToSyncLog(obs.IdxPemeriksaan, fhirID, internalPayload, respBody, status, errMsg)
_ = w.repo.SaveSyncLog(ctx, syncLog)
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, obs.IdxPemeriksaan, fhirID)
}
w.updateTracker(&lastID, obs.IdxPemeriksaan)
time.Sleep(500 * time.Millisecond) // Rate limiting
}
}
func (w *worker) updateTracker(lastID *int64, currentID int64) {
*lastID = currentID
os.WriteFile(trackerFile, []byte(strconv.FormatInt(*lastID, 10)), 0644)
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,24 +0,0 @@
package procedure
import (
"database/sql"
)
// ProcedureDB merepresentasikan tabel tindakan medis / prosedur
// TODO: Sesuaikan dengan tabel tindakan SIMRS Anda
type ProcedureDB struct {
IdxTindakan int64 `db:"idxtindakan"`
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
TglTindakan sql.NullTime `db:"tgl_tindakan"`
KdTindakan sql.NullString `db:"kd_tindakan"`
}
type ProcedureSyncLog struct {
IdxTindakan int64 `db:"idxtindakan"`
ProcedureID string `db:"procedure_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,35 +0,0 @@
package procedure
import (
"encoding/json"
"fmt"
"time"
)
// TODO: Sesuaikan dengan payload internal API Procedure
func MapToInternalAPI(dbData *ProcedureDB, orgID string) map[string]interface{} {
waktu := time.Now()
if dbData.TglTindakan.Valid {
waktu = dbData.TglTindakan.Time
}
payload := map[string]interface{}{
"procedure_id": fmt.Sprintf("%d", dbData.IdxTindakan),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String,
"code": dbData.KdTindakan.String,
"performed_date": waktu.Format(time.RFC3339),
}
return payload
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) ProcedureSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return ProcedureSyncLog{
IdxTindakan: idx,
ProcedureID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package procedure
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextProcedure(ctx context.Context, lastID int64) (*ProcedureDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData ProcedureSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextProcedure(ctx context.Context, lastID int64) (*ProcedureDB, error) {
var proc ProcedureDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel tindakan Anda
qSimrs := query.DynamicQuery{
From: "public.t_tindakan",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxtindakan", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxtindakan"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &proc)
if err != nil {
return nil, err
}
return &proc, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_tindakan", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxtindakan", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData ProcedureSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxtindakan", "procedure_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxTindakan, logData.ProcedureID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_procedure", insertData, []string{"idxtindakan"}, []string{"procedure_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package procedure
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_procedure_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[PROCEDURE WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[PROCEDURE WORKER] Proses dihentikan.")
return
default:
}
proc, err := w.repo.GetNextProcedure(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(proc, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/procedure", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(proc.IdxTindakan, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, proc.IdxTindakan, fhirID)
}
lastID = proc.IdxTindakan
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,23 +0,0 @@
package questionnaireresponse
import (
"database/sql"
)
// QuestionnaireResponseDB merepresentasikan tabel data pengkajian / kuesioner pasien
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
type QuestionnaireResponseDB struct {
IdxKuesioner int64 `db:"idxkuesioner"`
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
TglIsi sql.NullTime `db:"tgl_isi"`
}
type QuestionnaireResponseSyncLog struct {
IdxKuesioner int64 `db:"idxkuesioner"`
QuestionnaireResponseID string `db:"questionnaireresponse_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,33 +0,0 @@
package questionnaireresponse
import (
"encoding/json"
"fmt"
"time"
)
// TODO: Sesuaikan dengan payload internal API QuestionnaireResponse
func MapToInternalAPI(dbData *QuestionnaireResponseDB, orgID string) map[string]interface{} {
waktu := time.Now()
if dbData.TglIsi.Valid {
waktu = dbData.TglIsi.Time
}
return map[string]interface{}{
"questionnaireresponse_id": fmt.Sprintf("%d", dbData.IdxKuesioner),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String,
"authored_on": waktu.Format(time.RFC3339),
}
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) QuestionnaireResponseSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return QuestionnaireResponseSyncLog{
IdxKuesioner: idx,
QuestionnaireResponseID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package questionnaireresponse
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextQuestionnaireResponse(ctx context.Context, lastID int64) (*QuestionnaireResponseDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData QuestionnaireResponseSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextQuestionnaireResponse(ctx context.Context, lastID int64) (*QuestionnaireResponseDB, error) {
var qr QuestionnaireResponseDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel kuesioner Anda (contoh: skrining awal medis)
qSimrs := query.DynamicQuery{
From: "public.t_kuesioner",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxkuesioner", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxkuesioner"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &qr)
if err != nil {
return nil, err
}
return &qr, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_kuesioner", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxkuesioner", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData QuestionnaireResponseSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxkuesioner", "questionnaireresponse_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxKuesioner, logData.QuestionnaireResponseID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_questionnaireresponse", insertData, []string{"idxkuesioner"}, []string{"questionnaireresponse_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package questionnaireresponse
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_questionnaireresponse_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[QUESTIONNAIRERESPONSE WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[QUESTIONNAIRERESPONSE WORKER] Proses dihentikan.")
return
default:
}
qr, err := w.repo.GetNextQuestionnaireResponse(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(qr, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/questionnaireresponse", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(qr.IdxKuesioner, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, qr.IdxKuesioner, fhirID)
}
lastID = qr.IdxKuesioner
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
@@ -1,24 +0,0 @@
package servicerequest
import (
"database/sql"
)
// ServiceRequestDB merepresentasikan tabel order lab/radiologi
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
type ServiceRequestDB struct {
IdxOrder int64 `db:"idxorder"`
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
TglOrder sql.NullTime `db:"tgl_order"`
KdOrder sql.NullString `db:"kd_order"` // Kode pemeriksaan/tindakan yang diminta
}
type ServiceRequestSyncLog struct {
IdxOrder int64 `db:"idxorder"`
ServiceRequestID string `db:"servicerequest_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,35 +0,0 @@
package servicerequest
import (
"encoding/json"
"fmt"
"time"
)
// TODO: Sesuaikan dengan payload internal API ServiceRequest
func MapToInternalAPI(dbData *ServiceRequestDB, orgID string) map[string]interface{} {
waktu := time.Now()
if dbData.TglOrder.Valid {
waktu = dbData.TglOrder.Time
}
payload := map[string]interface{}{
"servicerequest_id": fmt.Sprintf("%d", dbData.IdxOrder),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String,
"code": dbData.KdOrder.String,
"authored_on": waktu.Format(time.RFC3339),
}
return payload
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) ServiceRequestSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return ServiceRequestSyncLog{
IdxOrder: idx,
ServiceRequestID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package servicerequest
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextServiceRequest(ctx context.Context, lastID int64) (*ServiceRequestDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData ServiceRequestSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextServiceRequest(ctx context.Context, lastID int64) (*ServiceRequestDB, error) {
var sr ServiceRequestDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel order lab/radiologi
qSimrs := query.DynamicQuery{
From: "public.t_order_lab",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxorder", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxorder"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &sr)
if err != nil {
return nil, err
}
return &sr, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_order_lab", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxorder", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData ServiceRequestSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxorder", "servicerequest_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxOrder, logData.ServiceRequestID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_servicerequest", insertData, []string{"idxorder"}, []string{"servicerequest_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package servicerequest
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_servicerequest_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[SERVICEREQUEST WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[SERVICEREQUEST WORKER] Proses dihentikan.")
return
default:
}
sr, err := w.repo.GetNextServiceRequest(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(sr, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/servicerequest", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(sr.IdxOrder, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, sr.IdxOrder, fhirID)
}
lastID = sr.IdxOrder
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
-24
View File
@@ -1,24 +0,0 @@
package specimen
import (
"database/sql"
)
// SpecimenDB merepresentasikan tabel data pengambilan spesimen (darah, urine, dll)
// TODO: Sesuaikan field dan tag `db` dengan tabel SIMRS Anda
type SpecimenDB struct {
IdxSpesimen int64 `db:"idxspesimen"`
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
TglAmbil sql.NullTime `db:"tgl_ambil"`
JenisSpesimen sql.NullString `db:"jenis_spesimen"` // misal: Darah, Urine
}
type SpecimenSyncLog struct {
IdxSpesimen int64 `db:"idxspesimen"`
SpecimenID string `db:"specimen_id"`
RequestPayload string `db:"request_payload"`
ResponsePayload string `db:"response_payload"`
Status string `db:"status"`
ErrorMessage string `db:"error_message"`
}
@@ -1,35 +0,0 @@
package specimen
import (
"encoding/json"
"fmt"
"time"
)
// TODO: Sesuaikan dengan payload internal API Specimen
func MapToInternalAPI(dbData *SpecimenDB, orgID string) map[string]interface{} {
waktu := time.Now()
if dbData.TglAmbil.Valid {
waktu = dbData.TglAmbil.Time
}
payload := map[string]interface{}{
"specimen_id": fmt.Sprintf("%d", dbData.IdxSpesimen),
"encounter_id": fmt.Sprintf("%d", dbData.IdxDaftar),
"patient_id": dbData.NoMR.String,
"specimen_type": dbData.JenisSpesimen.String,
"collected_date": waktu.Format(time.RFC3339),
}
return payload
}
func MapToSyncLog(idx int64, fhirID string, reqPayload interface{}, respBody []byte, status string, errMsg string) SpecimenSyncLog {
reqBytes, _ := json.MarshalIndent(reqPayload, "", " ")
return SpecimenSyncLog{
IdxSpesimen: idx,
SpecimenID: fhirID,
RequestPayload: string(reqBytes),
ResponsePayload: string(respBody),
Status: status,
ErrorMessage: errMsg,
}
}
@@ -1,77 +0,0 @@
package specimen
import (
"context"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
)
type Repository interface {
GetNextSpecimen(ctx context.Context, lastID int64) (*SpecimenDB, error)
SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error
SaveSyncLog(ctx context.Context, logData SpecimenSyncLog) error
}
type repository struct {
dbManager database.Service
}
func NewRepository(dbManager database.Service) Repository {
return &repository{dbManager: dbManager}
}
func (r *repository) GetNextSpecimen(ctx context.Context, lastID int64) (*SpecimenDB, error) {
var sp SpecimenDB
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return nil, err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
// TODO: Sesuaikan nama tabel spesimen
qSimrs := query.DynamicQuery{
From: "public.t_spesimen",
Filters: []query.FilterGroup{
{
Filters: []query.DynamicFilter{
query.CreateFilter("idxspesimen", query.OpGreaterThan, lastID),
},
},
},
Sort: []query.SortField{
query.CreateAscSort("idxspesimen"),
},
Limit: 1,
}
err = qb.ExecuteQueryRow(ctx, simrsDB, qSimrs, &sp)
if err != nil {
return nil, err
}
return &sp, nil
}
func (r *repository) SaveSatuSehatID(ctx context.Context, idx int64, fhirID string) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
_, err = qb.ExecuteUpdate(ctx, simrsDB, "public.t_spesimen", query.UpdateData{Columns: []string{"status_bridging"}, Values: []interface{}{fhirID}}, []query.FilterGroup{query.CreateAndFilterGroup([]query.DynamicFilter{query.CreateEqualFilter("idxspesimen", idx)})})
return err
}
func (r *repository) SaveSyncLog(ctx context.Context, logData SpecimenSyncLog) error {
simrsDB, err := r.dbManager.GetSQLXDB("simrs")
if err != nil {
return err
}
qb := query.NewSQLQueryBuilder(query.DBTypePostgreSQL).SetSecurityOptions(false, 0)
insertData := query.InsertData{
Columns: []string{"idxspesimen", "specimen_id", "request_payload", "response_payload", "status", "error_message"},
Values: []interface{}{logData.IdxSpesimen, logData.SpecimenID, logData.RequestPayload, logData.ResponsePayload, logData.Status, logData.ErrorMessage},
}
_, err = qb.ExecuteUpsert(ctx, simrsDB, "public.log_satusehat_specimen", insertData, []string{"idxspesimen"}, []string{"specimen_id", "request_payload", "response_payload", "status", "error_message"})
return err
}
@@ -1,93 +0,0 @@
package specimen
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/internal/infrastructure/database"
extapi "service/internal/worker/interface"
)
const trackerFile = "last_migrated_specimen_id.txt"
type Config struct {
DBManager database.Service
InternalBaseURL string
InternalToken string
OrganizationID string
}
type WorkerService interface {
Run(ctx context.Context)
}
type worker struct {
cfg Config
repo Repository
apiClient extapi.Client
}
func NewWorker(cfg Config) WorkerService {
return &worker{cfg: cfg, repo: NewRepository(cfg.DBManager), apiClient: extapi.NewClient(15 * time.Second)}
}
func (w *worker) Run(ctx context.Context) {
log.Println("[SPECIMEN WORKER] Memulai proses sinkronisasi...")
lastID := w.readLastID()
for {
select {
case <-ctx.Done():
log.Println("[SPECIMEN WORKER] Proses dihentikan.")
return
default:
}
sp, err := w.repo.GetNextSpecimen(ctx, lastID)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
internalPayload := MapToInternalAPI(sp, w.cfg.OrganizationID)
reqURL := fmt.Sprintf("%s/satusehat/specimen", strings.TrimRight(w.cfg.InternalBaseURL, "/"))
respBody, statusCode, err := w.apiClient.PostJSON(ctx, reqURL, w.cfg.InternalToken, internalPayload)
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)
}
}
}
_ = w.repo.SaveSyncLog(ctx, MapToSyncLog(sp.IdxSpesimen, fhirID, internalPayload, respBody, status, errMsg))
if status == "SUCCESS" && fhirID != "" {
_ = w.repo.SaveSatuSehatID(ctx, sp.IdxSpesimen, fhirID)
}
lastID = sp.IdxSpesimen
os.WriteFile(trackerFile, []byte(strconv.FormatInt(lastID, 10)), 0644)
time.Sleep(500 * time.Millisecond)
}
}
func (w *worker) readLastID() int64 {
data, _ := os.ReadFile(trackerFile)
id, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return id
}
-278
View File
@@ -1,278 +0,0 @@
package worker
import (
"context"
"fmt"
"os"
"sync"
"time"
"service/internal/infrastructure/config"
"service/internal/infrastructure/database"
"service/internal/interfaces/satusehat"
"service/internal/worker/satusehat/allergyintolerance"
"service/internal/worker/satusehat/careplan"
"service/internal/worker/satusehat/clinicalimpression"
"service/internal/worker/satusehat/composition"
"service/internal/worker/satusehat/condition"
"service/internal/worker/satusehat/diagnosticreport"
"service/internal/worker/satusehat/encounter"
"service/internal/worker/satusehat/episodeofcare"
"service/internal/worker/satusehat/imagingstudy"
"service/internal/worker/satusehat/immunization"
"service/internal/worker/satusehat/medication"
"service/internal/worker/satusehat/medicationdispense"
"service/internal/worker/satusehat/medicationrequest"
"service/internal/worker/satusehat/medicationstatement"
"service/internal/worker/satusehat/observation"
"service/internal/worker/satusehat/procedure"
"service/internal/worker/satusehat/questionnaireresponse"
"service/internal/worker/satusehat/servicerequest"
"service/internal/worker/satusehat/specimen"
"service/pkg/logger"
)
// Manager mengelola siklus hidup semua background workers
type Manager struct {
cfg *config.Config
db database.Service
satusehatClient satusehat.SatuSehatClient
}
// NewManager membuat instance Manager baru
func NewManager(cfg *config.Config, db database.Service, satusehatClient satusehat.SatuSehatClient) *Manager {
return &Manager{
cfg: cfg,
db: db,
satusehatClient: satusehatClient,
}
}
// Start menjalankan semua worker terdaftar dalam goroutine terpisah
func (m *Manager) Start(ctx context.Context) {
logger.Default().Info("Starting background workers...")
var wg sync.WaitGroup
// Tentukan Organization ID (Bisa dari Environment Variable atau Config Struct)
orgID := os.Getenv("SATUSEHAT_ORGANIZATION_ID")
if orgID == "" {
logger.Default().Warn("SATUSEHAT_ORGANIZATION_ID is not set in environment variables")
}
internalPort := 8080
if m.cfg != nil && m.cfg.Server.REST.Port != 0 {
internalPort = m.cfg.Server.REST.Port
}
internalBaseURL := fmt.Sprintf("http://localhost:%d/api/v1", internalPort)
internalToken := os.Getenv("INTERNAL_API_TOKEN")
// Jobs mendefinisikan urutan dan jeda waktu (delay) boot-up setiap worker.
// Konfigurasi waktu ini mencegah perebutan resource di awal (thundering herd)
// dan memastikan resource inti (seperti Encounter) mendapat "head start"
// sebelum worker anak mencoba merujuk ke data tersebut.
jobs := []struct {
name string
delay time.Duration
run func(context.Context)
}{
{
name: "Medication (Master)",
delay: 0, // Master data berjalan seketika
run: func(c context.Context) {
medication.NewWorker(medication.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "Encounter",
delay: 5 * time.Second, // Memberikan jeda 5 detik agar Encounter mendapat porsi jalan lebih dulu
run: func(c context.Context) {
encounter.NewWorker(encounter.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "Condition",
delay: 2 * time.Second,
run: func(c context.Context) {
condition.NewWorker(condition.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "Observation",
delay: 2 * time.Second,
run: func(c context.Context) {
observation.NewWorker(observation.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "Procedure",
delay: 2 * time.Second,
run: func(c context.Context) {
procedure.NewWorker(procedure.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "AllergyIntolerance",
delay: 2 * time.Second,
run: func(c context.Context) {
allergyintolerance.NewWorker(allergyintolerance.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "ClinicalImpression",
delay: 2 * time.Second,
run: func(c context.Context) {
clinicalimpression.NewWorker(clinicalimpression.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "Immunization",
delay: 2 * time.Second,
run: func(c context.Context) {
immunization.NewWorker(immunization.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "ServiceRequest",
delay: 2 * time.Second,
run: func(c context.Context) {
servicerequest.NewWorker(servicerequest.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "Specimen",
delay: 2 * time.Second,
run: func(c context.Context) {
specimen.NewWorker(specimen.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "DiagnosticReport",
delay: 2 * time.Second,
run: func(c context.Context) {
diagnosticreport.NewWorker(diagnosticreport.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "ImagingStudy",
delay: 2 * time.Second,
run: func(c context.Context) {
imagingstudy.NewWorker(imagingstudy.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "Composition",
delay: 2 * time.Second,
run: func(c context.Context) {
composition.NewWorker(composition.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "CarePlan",
delay: 2 * time.Second,
run: func(c context.Context) {
careplan.NewWorker(careplan.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "EpisodeOfCare",
delay: 2 * time.Second,
run: func(c context.Context) {
episodeofcare.NewWorker(episodeofcare.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "MedicationRequest",
delay: 2 * time.Second,
run: func(c context.Context) {
medicationrequest.NewWorker(medicationrequest.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "MedicationDispense",
delay: 2 * time.Second,
run: func(c context.Context) {
medicationdispense.NewWorker(medicationdispense.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "MedicationStatement",
delay: 2 * time.Second,
run: func(c context.Context) {
medicationstatement.NewWorker(medicationstatement.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
{
name: "QuestionnaireResponse",
delay: 2 * time.Second,
run: func(c context.Context) {
questionnaireresponse.NewWorker(questionnaireresponse.Config{
DBManager: m.db, InternalBaseURL: internalBaseURL, InternalToken: internalToken, OrganizationID: orgID,
}).Run(c)
},
},
}
// Eksekusi semua background worker secara tertib
go func() {
for _, job := range jobs {
// Jeda startup, dapat di-interrupt oleh Context Cancel (Shutdown System)
if job.delay > 0 {
select {
case <-ctx.Done():
return
case <-time.After(job.delay):
}
}
wg.Add(1)
j := job // copy pointer untuk goroutine loop
go func() {
defer wg.Done()
logger.Default().Info(fmt.Sprintf("[%s WORKER] Started", j.name))
j.run(ctx)
logger.Default().Info(fmt.Sprintf("[%s WORKER] Stopped", j.name))
}()
}
// Waiter untuk Graceful Shutdown keseluruhan
wg.Wait()
logger.Default().Info("All background workers stopped gracefully")
}()
}
+80
View File
@@ -0,0 +1,80 @@
[2026-04-14 05:41:59.766] [INFO ] Application starting | 📍 main.go:76 | environment: debug | ⚙️ main.main | log_level: debug | service: service-general
[2026-04-14 05:41:59.766] [DEBUG] Config loaded | cache_enabled: true | 📍 main.go:91 | database_count: 5 | environment: debug | ⚙️ main.main | server_mode: debug | service: service-general
[2026-04-14 05:41:59.767] [INFO ] Minio Object Storage client initialized | 📍 main.go:103 | environment: debug | ⚙️ main.main | service: service-general
[2026-04-14 05:41:59.767] [INFO ] Initializing database service with GORM as primary abstraction... | 📍 database.go:192 | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:41:59.769] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: ris | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:41:59.769] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: ris | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:41:59.789] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: farmasi | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:41:59.789] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: farmasi | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:41:59.800] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: simrs | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:41:59.800] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: simrs | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:41:59.807] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: default | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:41:59.808] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: default | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:41:59.814] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: satudata | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:41:59.814] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: satudata | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:41:59.817] [INFO ] Cache initialized | 📍 main.go:136 | environment: debug | ⚙️ main.main | host: redis | port: 6379 | service: service-general
[2026-04-14 05:41:59.817] [INFO ] Creating authentication providers | auth_type: hybrid | 📍 provider.go:69 | environment: debug | fallback_to: jwt | ⚙️ service/internal/infrastructure/transport/http/middleware.(*ProviderFactory).CreateProviders | keycloak_enabled: true | keycloak_issuer: https://auth.rssa.top/realms/Dev | service: service-general | static_tokens_len: 4
[2026-04-14 05:41:59.817] [INFO ] Keycloak provider added for hybrid | 📍 provider.go:97 | environment: debug | ⚙️ service/internal/infrastructure/transport/http/middleware.(*ProviderFactory).CreateProviders | service: service-general
[2026-04-14 05:41:59.818] [INFO ] REST API running | 📍 main.go:267 | environment: debug | ⚙️ main.main.func2 | port: 8096 | service: service-general
[2026-04-14 05:41:59.818] [INFO ] Application started successfully | 📍 main.go:328 | environment: debug | ⚙️ main.main | service: service-general
[2026-04-14 05:41:59.818] [INFO ] gRPC Server running | 📍 main.go:294 | environment: debug | ⚙️ main.main.func3 | port: 8097 | service: service-general
[2026-04-14 05:42:19.551] [INFO ] Application starting | 📍 main.go:76 | environment: debug | ⚙️ main.main | log_level: debug | service: service-general
[2026-04-14 05:42:19.551] [DEBUG] Config loaded | cache_enabled: true | 📍 main.go:91 | database_count: 5 | environment: debug | ⚙️ main.main | server_mode: debug | service: service-general
[2026-04-14 05:42:19.551] [INFO ] Minio Object Storage client initialized | 📍 main.go:103 | environment: debug | ⚙️ main.main | service: service-general
[2026-04-14 05:42:19.551] [INFO ] Initializing database service with GORM as primary abstraction... | 📍 database.go:192 | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:19.552] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: ris | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:19.552] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: ris | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:19.571] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: farmasi | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:19.571] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: farmasi | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:19.579] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: simrs | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:19.579] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: simrs | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:19.587] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: default | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:19.587] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: default | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:19.594] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: satudata | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:19.594] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: satudata | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:19.595] [INFO ] Cache initialized | 📍 main.go:136 | environment: debug | ⚙️ main.main | host: redis | port: 6379 | service: service-general
[2026-04-14 05:42:19.596] [INFO ] Creating authentication providers | auth_type: hybrid | 📍 provider.go:69 | environment: debug | fallback_to: jwt | ⚙️ service/internal/infrastructure/transport/http/middleware.(*ProviderFactory).CreateProviders | keycloak_enabled: true | keycloak_issuer: https://auth.rssa.top/realms/Dev | service: service-general | static_tokens_len: 4
[2026-04-14 05:42:19.596] [INFO ] Keycloak provider added for hybrid | 📍 provider.go:97 | environment: debug | ⚙️ service/internal/infrastructure/transport/http/middleware.(*ProviderFactory).CreateProviders | service: service-general
[2026-04-14 05:42:19.596] [INFO ] Application started successfully | 📍 main.go:328 | environment: debug | ⚙️ main.main | service: service-general
[2026-04-14 05:42:19.596] [INFO ] gRPC Server running | 📍 main.go:294 | environment: debug | ⚙️ main.main.func3 | port: 8097 | service: service-general
[2026-04-14 05:42:19.597] [INFO ] REST API running | 📍 main.go:267 | environment: debug | ⚙️ main.main.func2 | port: 8096 | service: service-general
[2026-04-14 05:42:39.938] [INFO ] Application starting | 📍 main.go:76 | environment: debug | ⚙️ main.main | log_level: debug | service: service-general
[2026-04-14 05:42:39.938] [DEBUG] Config loaded | cache_enabled: true | 📍 main.go:91 | database_count: 5 | environment: debug | ⚙️ main.main | server_mode: debug | service: service-general
[2026-04-14 05:42:39.939] [INFO ] Minio Object Storage client initialized | 📍 main.go:103 | environment: debug | ⚙️ main.main | service: service-general
[2026-04-14 05:42:39.939] [INFO ] Initializing database service with GORM as primary abstraction... | 📍 database.go:192 | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:39.953] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: farmasi | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:39.953] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: farmasi | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:39.961] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: simrs | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:39.962] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: simrs | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:39.969] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: default | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:39.969] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: default | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:39.976] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: satudata | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:39.976] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: satudata | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:39.977] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: ris | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:39.978] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: ris | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:39.979] [INFO ] Cache initialized | 📍 main.go:136 | environment: debug | ⚙️ main.main | host: redis | port: 6379 | service: service-general
[2026-04-14 05:42:39.979] [INFO ] Creating authentication providers | auth_type: hybrid | 📍 provider.go:69 | environment: debug | fallback_to: jwt | ⚙️ service/internal/infrastructure/transport/http/middleware.(*ProviderFactory).CreateProviders | keycloak_enabled: true | keycloak_issuer: https://auth.rssa.top/realms/Dev | service: service-general | static_tokens_len: 4
[2026-04-14 05:42:39.979] [INFO ] Keycloak provider added for hybrid | 📍 provider.go:97 | environment: debug | ⚙️ service/internal/infrastructure/transport/http/middleware.(*ProviderFactory).CreateProviders | service: service-general
[2026-04-14 05:42:39.979] [INFO ] Application started successfully | 📍 main.go:328 | environment: debug | ⚙️ main.main | service: service-general
[2026-04-14 05:42:39.980] [INFO ] REST API running | 📍 main.go:267 | environment: debug | ⚙️ main.main.func2 | port: 8096 | service: service-general
[2026-04-14 05:42:39.980] [INFO ] gRPC Server running | 📍 main.go:294 | environment: debug | ⚙️ main.main.func3 | port: 8097 | service: service-general
[2026-04-14 05:42:57.251] [INFO ] Application starting | 📍 main.go:74 | environment: debug | ⚙️ main.main | log_level: debug | service: service-general
[2026-04-14 05:42:57.251] [DEBUG] Config loaded | cache_enabled: true | 📍 main.go:89 | database_count: 5 | environment: debug | ⚙️ main.main | server_mode: debug | service: service-general
[2026-04-14 05:42:57.251] [INFO ] Minio Object Storage client initialized | 📍 main.go:101 | environment: debug | ⚙️ main.main | service: service-general
[2026-04-14 05:42:57.251] [INFO ] Initializing database service with GORM as primary abstraction... | 📍 database.go:192 | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:57.260] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: simrs | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:57.260] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: simrs | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:57.267] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: default | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:57.267] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: default | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:57.274] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: satudata | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:57.274] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: satudata | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:57.276] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: ris | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:57.276] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: ris | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:57.297] [INFO ] Successfully connected and configured GORM for database | 📍 database.go:384 | db_name: farmasi | environment: debug | ⚙️ service/internal/infrastructure/database.(*service).addDatabase | service: service-general
[2026-04-14 05:42:57.297] [INFO ] Successfully connected to database | 📍 database.go:203 | db_name: farmasi | environment: debug | ⚙️ service/internal/infrastructure/database.New.func1 | service: service-general
[2026-04-14 05:42:57.300] [INFO ] Cache initialized | 📍 main.go:134 | environment: debug | ⚙️ main.main | host: redis | port: 6379 | service: service-general
[2026-04-14 05:42:57.301] [INFO ] Creating authentication providers | auth_type: hybrid | 📍 provider.go:69 | environment: debug | fallback_to: jwt | ⚙️ service/internal/infrastructure/transport/http/middleware.(*ProviderFactory).CreateProviders | keycloak_enabled: true | keycloak_issuer: https://auth.rssa.top/realms/Dev | service: service-general | static_tokens_len: 4
[2026-04-14 05:42:57.301] [INFO ] Keycloak provider added for hybrid | 📍 provider.go:97 | environment: debug | ⚙️ service/internal/infrastructure/transport/http/middleware.(*ProviderFactory).CreateProviders | service: service-general
[2026-04-14 05:42:57.301] [INFO ] REST API running | 📍 main.go:265 | environment: debug | ⚙️ main.main.func1 | port: 8096 | service: service-general
[2026-04-14 05:42:57.301] [INFO ] Application started successfully | 📍 main.go:326 | environment: debug | ⚙️ main.main | service: service-general
[2026-04-14 05:42:57.302] [INFO ] gRPC Server running | 📍 main.go:292 | environment: debug | ⚙️ main.main.func2 | port: 8097 | service: service-general