first commit

This commit is contained in:
meninjar
2026-04-14 01:23:34 +00:00
commit edfaa886ff
443 changed files with 1245931 additions and 0 deletions
@@ -0,0 +1,42 @@
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)
}
@@ -0,0 +1,76 @@
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,
}
}
@@ -0,0 +1,187 @@
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
}
@@ -0,0 +1,215 @@
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
}