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