Files
satusehat-service/internal/worker/patient/migrator.go
T
2026-04-14 01:23:34 +00:00

359 lines
11 KiB
Go

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)
}
}