Membangun Sistem HL7 v2 & FHIR dengan Spring Boot dari Nol hingga SATUSEHAT

🏥 Hands-On Series — HealthTech untuk Java Developer

Panduan praktis end-to-end: setup project, parse HL7 v2 via MLLP, CRUD FHIR R4, konversi HL7→FHIR, integrasi SATUSEHAT sandbox, dan Docker Compose siap jalan.

✍️ Mohamad Yusuf Ibrahim · Senior Java Backend Engineer · 10+ tahun di Banking & Financial Services
Spring Boot 3.3 HAPI FHIR 7.4 Apache Camel 4.6 MLLP FHIR R4 SATUSEHAT Docker Compose HL7 v2

Di artikel sebelumnya kita sudah memahami apa itu HL7 v2 dan FHIR secara konseptual. Sekarang waktunya kita bangun sistemnya dari nol — bukan sekadar snippet, tapi project yang benar-benar bisa dijalankan dan menjadi fondasi proyek nyata Anda.

Kita akan membangun sebuah Healthcare Integration Service — layanan yang menerima pesan HL7 v2 dari SIMRS lama, mengkonversinya ke FHIR R4, menyimpannya ke FHIR server lokal, lalu melaporkannya ke SATUSEHAT.

Arsitektur Sistem yang Akan Kita Bangun

┌─────────────┐ HL7 v2/MLLP ┌──────────────────────────────────────────────┐
│ SIMRS Lama │ ───────────────▶ │ Healthcare Integration Service │
│ (Simulator) │ port 2575 │ │
└─────────────┘ │ ┌─────────────┐ ┌────────────────────┐ │
│ │ MLLP │────▶│ HL7v2→FHIR │ │
│ │ Listener │ │ Transformer │ │
│ │ (Camel) │ └─────────┬──────────┘ │
│ └─────────────┘ │ │
│ ┌─────────▼──────────┐ │
│ │ FHIR Service │ │
│ │ (HAPI FHIR) │ │
│ └──────┬──────┬──────┘ │
└──────────────────────────────┼──────┼─────────┘
│ │
┌───────────────────────┘ │
▼ ▼
┌────────────┐ ┌──────────────────┐
│ HAPI FHIR │ │ SATUSEHAT API │
│ JPA Server │ │ (Kemenkes) │
│ (lokal) │ └──────────────────┘
└────────────┘

Prerequisites

Artikel ini mengasumsikan Anda sudah familiar dengan Spring Boot dan Gradle. Yang perlu disiapkan:

ToolVersi MinimumKeterangan
JDK21 (LTS)Gunakan Amazon Corretto atau Eclipse Temurin
Gradle8.xAtau gunakan Gradle Wrapper (direkomendasikan)
Docker Desktop24.xUntuk menjalankan HAPI FHIR Server + PostgreSQL
IntelliJ IDEA2024.xAtau IDE pilihan Anda
Akun SATUSEHATDaftar di platform.satusehat.kemkes.go.id (gratis)

Bagian 1 — Setup Project Spring Boot

Struktur Project

Kita pakai single-module project dengan package yang jelas per domain:

healthcare-integration-service/
├── src/main/java/id/yusuf/healthcareis/
│ ├── HealthcareIntegrationServiceApplication.java
│ ├── config/
│ │ ├── FhirConfig.java ── HAPI FHIR beans
│ │ └── CamelConfig.java ── Apache Camel setup
│ ├── hl7/
│ │ ├── Hl7MllpRoute.java ── MLLP listener (Camel route)
│ │ ├── Hl7MessageProcessor.java ── dispatch per message type
│ │ └── Hl7AckBuilder.java ── build ACK response
│ ├── fhir/
│ │ ├── PatientService.java
│ │ ├── EncounterService.java
│ │ └── ObservationService.java
│ ├── transformer/
│ │ └── Hl7ToFhirTransformer.java ── core konversi HL7→FHIR
│ └── satusehat/
│ ├── SatuSehatAuthService.java ── OAuth2 token management
│ └── SatuSehatClient.java ── kirim resource ke SATUSEHAT
├── src/main/resources/
│ └── application.yml
├── src/test/
│ └── ...test files...
├── build.gradle
├── docker-compose.yml
└── settings.gradle

build.gradle

Ini adalah jantung proyek kita. Perhatikan bagaimana semua library dikelompokkan:

📄 build.gradleplugins {
    id 'org.springframework.boot' version '3.3.5'
    id 'io.spring.dependency-management' version '1.1.6'
    id 'java'
}

group   = 'id.yusuf'
version = '1.0.0-SNAPSHOT'

java {
    toolchain { languageVersion = JavaLanguageVersion.of(21) }
}

configurations {
    compileOnly { extendsFrom annotationProcessor }
}

repositories {
    mavenCentral()
}

ext {
    hapiFhirVersion    = '7.4.0'
    hapiHl7Version     = '2.5.1'
    camelVersion       = '4.6.0'
}

dependencies {

    // ── Spring Boot Core ──────────────────────────────────────────
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // ── Apache Camel (MLLP transport + HL7 parsing) ───────────────
    implementation "org.apache.camel.springboot:camel-spring-boot-starter:${camelVersion}"
    implementation "org.apache.camel.springboot:camel-hl7-starter:${camelVersion}"
    implementation "org.apache.camel.springboot:camel-mllp-starter:${camelVersion}"
    implementation "org.apache.camel.springboot:camel-jackson-starter:${camelVersion}"

    // ── HAPI HL7 v2 (model + parser) ─────────────────────────────
    implementation "ca.uhn.hapi:hapi-base:${hapiHl7Version}"
    implementation "ca.uhn.hapi:hapi-structures-v251:${hapiHl7Version}"

    // ── HAPI FHIR R4 (toolkit + REST client) ─────────────────────
    implementation "ca.uhn.hapi.fhir:hapi-fhir-base:${hapiFhirVersion}"
    implementation "ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${hapiFhirVersion}"
    implementation "ca.uhn.hapi.fhir:hapi-fhir-client:${hapiFhirVersion}"
    implementation "ca.uhn.hapi.fhir:hapi-fhir-validation:${hapiFhirVersion}"
    implementation "ca.uhn.hapi.fhir:hapi-fhir-validation-resources-r4:${hapiFhirVersion}"

    // ── Database ──────────────────────────────────────────────────
    runtimeOnly  'org.postgresql:postgresql'
    runtimeOnly  'com.h2database:h2'               // untuk dev/test

    // ── HTTP Client untuk SATUSEHAT API ───────────────────────────
    implementation 'org.springframework.boot:spring-boot-starter-webflux'  // WebClient

    // ── Utilities ─────────────────────────────────────────────────
    compileOnly    'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    implementation 'com.fasterxml.jackson.core:jackson-databind'

    // ── Test ──────────────────────────────────────────────────────
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation "org.apache.camel:camel-test-spring-junit5:${camelVersion}"
}
💡 Mengapa WebFlux hanya untuk WebClient? Kita tidak menggunakan reactive stack secara penuh. WebFlux di sini hanya sebagai penyedia WebClient — HTTP client non-blocking yang lebih modern dari RestTemplate dan cocok untuk memanggil SATUSEHAT API dengan retry logic.

application.yml

📄 src/main/resources/application.ymlspring:
  application:
    name: healthcare-integration-service
  datasource:
    url: jdbc:postgresql://localhost:5432/healthdb
    username: healthuser
    password: healthpass
    driver-class-name: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect

# ── MLLP Listener ────────────────────────────────────────────────
mllp:
  port: 2575
  bind-address: "0.0.0.0"

# ── HAPI FHIR — Local FHIR JPA Server ────────────────────────────
fhir:
  server:
    base-url: http://localhost:8090/fhir   # HAPI FHIR Docker container
  validation:
    enabled: true

# ── SATUSEHAT API Sandbox ─────────────────────────────────────────
satusehat:
  base-url: https://api-satusehat.kemkes.go.id/fhir-r4/v1
  auth-url: https://api-satusehat.kemkes.go.id/oauth2/v1/accesstoken
  client-id: "your-client-id-here"
  client-secret: "your-client-secret-here"
  organization-id: "your-org-id-here"       # ID faskes di SATUSEHAT
  enabled: true                                  # false = hanya kirim ke local

# ── Actuator ──────────────────────────────────────────────────────
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics
  endpoint:
    health:
      show-details: always

# ── Logging ───────────────────────────────────────────────────────
logging:
  level:
    id.yusuf: DEBUG
    ca.uhn.hl7v2: WARN
    ca.uhn.fhir: WARN
    org.apache.camel: INFO

# ── Profile: dev (H2 in-memory, SATUSEHAT disabled) ──────────────
---
spring:
  config:
    activate:
      on-profile: dev
  datasource:
    url: jdbc:h2:mem:healthdb;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
  jpa:
    properties:
      hibernate.dialect: org.hibernate.dialect.H2Dialect

satusehat:
  enabled: false

Bagian 2 — Menerima & Parsing Pesan HL7 v2 via MLLP

MLLP (Minimum Lower Layer Protocol) adalah transport layer di atas TCP yang digunakan HL7 v2. Setiap pesan dibungkus dengan karakter kontrol khusus: 0x0B (VT) sebagai pembuka dan 0x1C 0x0D sebagai penutup. Apache Camel menangani semua ini secara otomatis.

MLLP Listener — Camel Route

📄 hl7/Hl7MllpRoute.java@Component
@RequiredArgsConstructor
public class Hl7MllpRoute extends RouteBuilder {

    private final Hl7MessageProcessor messageProcessor;

    @Value("${mllp.port}")
    private int mllpPort;

    @Override
    public void configure() {

        // ── Error handler: log dan kirim NACK jika gagal ──────────
        onException(Exception.class)
            .handled(true)
            .log(LoggingLevel.ERROR, "HL7 processing error: ${exception.message}")
            .process(exchange -> {
                Message failed = exchange.getProperty(
                    "CamelHL7Message", Message.class);
                if (failed != null) {
                    exchange.getIn().setBody(
                        Hl7AckBuilder.buildNack(failed, exchange.getProperty(
                            Exchange.EXCEPTION_CAUGHT, Exception.class))
                    );
                }
            });

        // ── Main Route: MLLP Inbound ──────────────────────────────
        from("mllp://0.0.0.0:" + mllpPort + "?autoAck=false")
            .routeId("hl7-mllp-inbound")
            .log("[MLLP] Received message from ${header.CamelMllpRemoteAddress}")

            // Parse pipe-delimited text → HAPI Message object
            .unmarshal(HL7.hl7())

            // Simpan ke exchange property untuk error handler
            .process(e -> e.setProperty("CamelHL7Message", e.getIn().getBody(Message.class)))

            // Dispatch ke processor, dapatkan ACK kembali
            .bean(messageProcessor, "process")

            // Marshal ACK kembali ke string untuk dikirim via MLLP
            .marshal(HL7.hl7());
    }
}

Message Processor — Dispatch per Tipe Pesan

📄 hl7/Hl7MessageProcessor.java@Component
@RequiredArgsConstructor
@Slf4j
public class Hl7MessageProcessor {

    private final Hl7ToFhirTransformer transformer;
    private final PatientService        patientService;
    private final EncounterService      encounterService;
    private final ObservationService    observationService;
    private final SatuSehatClient       satuSehatClient;

    public Message process(Message hl7Msg) throws HL7Exception {
        String msgType = hl7Msg.getName(); // ex: "ADT_A04", "ORU_R01"
        log.info("[HL7] Processing: {}", msgType);

        try {
            switch (msgType) {
                case "ADT_A01": // Admit
                case "ADT_A04": // Register outpatient
                case "ADT_A08": // Update patient info
                    processAdtMessage((ADT_A01) hl7Msg);
                    break;
                case "ORU_R01": // Lab / observation results
                    processOruMessage((ORU_R01) hl7Msg);
                    break;
                default:
                    log.warn("[HL7] Unhandled message type: {}", msgType);
            }
        } catch (Exception ex) {
            log.error("[HL7] Failed to process {}: {}", msgType, ex.getMessage(), ex);
            return Hl7AckBuilder.buildNack(hl7Msg, ex);
        }

        return Hl7AckBuilder.buildAck(hl7Msg);
    }

    private void processAdtMessage(ADT_A01 adt) throws Exception {
        // 1. Konversi PID → FHIR Patient
        Patient fhirPatient = transformer.toPatient(adt.getPID());

        // 2. Upsert ke FHIR server lokal
        MethodOutcome outcome = patientService.upsert(fhirPatient);
        String patientId = outcome.getId().getIdPart();

        // 3. Konversi PV1 → FHIR Encounter
        Encounter encounter = transformer.toEncounter(adt.getPV1(), patientId);
        encounterService.create(encounter);

        // 4. Kirim ke SATUSEHAT (async, tidak block ACK)
        satuSehatClient.sendPatientAsync(fhirPatient);
    }

    private void processOruMessage(ORU_R01 oru) throws Exception {
        // ORU_R01 bisa punya multiple PATIENT_RESULT groups
        for (int i = 0; i < oru.getPATIENT_RESULTReps(); i++) {
            ORU_R01_PATIENT_RESULT pr = oru.getPATIENT_RESULT(i);
            String patientId = transformer.extractPatientId(pr.getPATIENT().getPID());

            // Setiap ORDER_OBSERVATION bisa punya banyak OBX
            for (int j = 0; j < pr.getORDER_OBSERVATIONReps(); j++) {
                ORU_R01_ORDER_OBSERVATION oo = pr.getORDER_OBSERVATION(j);

                for (int k = 0; k < oo.getOBSERVATIONReps(); k++) {
                    OBX obx = oo.getOBSERVATION(k).getOBX();
                    Observation obs = transformer.toObservation(obx, patientId);
                    observationService.create(obs);
                    satuSehatClient.sendObservationAsync(obs);
                }
            }
        }
    }
}

ACK Builder — Respons Wajib ke Pengirim

HL7 v2 menggunakan mekanisme ACK/NACK — setiap pesan yang diterima wajib dibalas. Jika tidak dibalas, pengirim akan menganggap terjadi kegagalan dan mencoba kirim ulang.

📄 hl7/Hl7AckBuilder.java@UtilityClass
public class Hl7AckBuilder {

    public static Message buildAck(Message original) throws HL7Exception, IOException {
        return DefaultApplication.makeACK((Segment) original.get("MSH"));
    }

    public static Message buildNack(Message original, Exception cause) {
        try {
            Message ack = DefaultApplication.makeACK((Segment) original.get("MSH"));
            // Set MSA-1 ke "AE" (Application Error) dan isi error text
            Segment msa = (Segment) ack.get("MSA");
            Terser terser = new Terser(ack);
            terser.set("MSA-1", "AE");
            terser.set("MSA-3", cause.getMessage() != null
                ? cause.getMessage().substring(0, Math.min(80, cause.getMessage().length()))
                : "Unknown error");
            return ack;
        } catch (Exception ex) {
            throw new RuntimeException("Cannot build NACK", ex);
        }
    }
}

Bagian 3 — FHIR R4: Konfigurasi & Services

FhirConfig — Bean Konfigurasi

FhirContext adalah objek besar yang mahal untuk dibuat — inisialisasi membutuhkan ~2-3 detik dan ~50MB RAM. Selalu jadikan sebagai singleton Spring bean.

📄 config/FhirConfig.java@Configuration
public class FhirConfig {

    @Value("${fhir.server.base-url}")
    private String fhirBaseUrl;

    @Value("${fhir.validation.enabled:true}")
    private boolean validationEnabled;

    /**
     * FhirContext: thread-safe, buat sekali, pakai selamanya.
     * R4 = FHIR Release 4 — versi yang digunakan SATUSEHAT.
     */
    @Bean
    @Singleton
    public FhirContext fhirContext() {
        FhirContext ctx = FhirContext.forR4();
        // Timeout untuk koneksi ke FHIR server
        ctx.getRestfulClientFactory().setSocketTimeout(30_000);
        ctx.getRestfulClientFactory().setConnectTimeout(10_000);
        return ctx;
    }

    /**
     * IGenericClient: REST client ke HAPI FHIR server lokal.
     * Thread-safe — bisa di-share antar bean.
     */
    @Bean
    public IGenericClient localFhirClient(FhirContext ctx) {
        return ctx.newRestfulGenericClient(fhirBaseUrl);
    }

    /**
     * FhirValidator: validasi resource sebelum dikirim.
     * Aktifkan hanya di development — agak lambat.
     */
    @Bean
    @ConditionalOnProperty(name = "fhir.validation.enabled", havingValue = "true")
    public FhirValidator fhirValidator(FhirContext ctx) {
        FhirValidator validator = ctx.newValidator();
        validator.registerValidatorModule(new FhirInstanceValidator(ctx));
        return validator;
    }
}

PatientService — Operasi FHIR Patient

📄 fhir/PatientService.java@Service
@RequiredArgsConstructor
@Slf4j
public class PatientService {

    private final IGenericClient fhirClient;
    private final FhirContext    fhirContext;

    /**
     * Upsert: jika NIK sudah ada → update, jika belum → create.
     * Ini pola umum di integrasi SIMRS karena ADT^A08 (update)
     * kadang datang sebelum ADT^A01 (create).
     */
    public MethodOutcome upsert(Patient patient) {
        // Cari berdasarkan NIK
        String nik = extractNik(patient);

        if (nik != null) {
            Bundle existing = fhirClient.search()
                .forResource(Patient.class)
                .where(Patient.IDENTIFIER.exactly()
                    .systemAndCode("https://fhir.kemkes.go.id/id/nik", nik))
                .returnBundle(Bundle.class)
                .execute();

            if (!existing.getEntry().isEmpty()) {
                String existingId = existing.getEntryFirstRep()
                    .getResource().getIdElement().getIdPart();
                patient.setId(existingId);
                log.debug("[FHIR] Updating existing Patient id={}", existingId);
                return fhirClient.update()
                    .resource(patient).execute();
            }
        }

        log.debug("[FHIR] Creating new Patient NIK={}", nik);
        return fhirClient.create()
            .resource(patient).encodedJson().execute();
    }

    /**
     * Read by FHIR ID
     */
    public Patient findById(String id) {
        return fhirClient.read()
            .resource(Patient.class)
            .withId(id)
            .execute();
    }

    /**
     * Search by NIK
     */
    public Optional<Patient> findByNik(String nik) {
        Bundle bundle = fhirClient.search()
            .forResource(Patient.class)
            .where(Patient.IDENTIFIER.exactly()
                .systemAndCode("https://fhir.kemkes.go.id/id/nik", nik))
            .returnBundle(Bundle.class)
            .execute();

        return bundle.getEntry().stream()
            .map(e -> (Patient) e.getResource())
            .findFirst();
    }

    /**
     * Search by nama + tanggal lahir (jika NIK tidak tersedia)
     */
    public Bundle searchByNameAndBirthdate(String familyName, LocalDate birthdate) {
        return fhirClient.search()
            .forResource(Patient.class)
            .where(Patient.FAMILY.matches().value(familyName))
            .and(Patient.BIRTHDATE.exactly().day(birthdate.toString()))
            .returnBundle(Bundle.class)
            .execute();
    }

    private String extractNik(Patient patient) {
        return patient.getIdentifier().stream()
            .filter(id -> "https://fhir.kemkes.go.id/id/nik".equals(id.getSystem()))
            .map(Identifier::getValue)
            .findFirst().orElse(null);
    }
}

EncounterService — Kunjungan Pasien

📄 fhir/EncounterService.java@Service
@RequiredArgsConstructor
@Slf4j
public class EncounterService {

    private final IGenericClient fhirClient;

    public MethodOutcome create(Encounter encounter) {
        log.debug("[FHIR] Creating Encounter for Patient={}",
            encounter.getSubject().getReference());
        return fhirClient.create()
            .resource(encounter)
            .encodedJson()
            .execute();
    }

    /**
     * Cari semua kunjungan pasien, diurutkan dari terbaru.
     */
    public Bundle findByPatientId(String patientId) {
        return fhirClient.search()
            .forResource(Encounter.class)
            .where(Encounter.PATIENT.hasId(patientId))
            .sort().descending(Encounter.DATE)
            .returnBundle(Bundle.class)
            .execute();
    }
}

ObservationService — Hasil Lab & Vital Signs

📄 fhir/ObservationService.java@Service
@RequiredArgsConstructor
public class ObservationService {

    private final IGenericClient fhirClient;

    public MethodOutcome create(Observation observation) {
        return fhirClient.create()
            .resource(observation).encodedJson().execute();
    }

    /**
     * Cari hasil lab pasien.
     * LOINC 15074-8 = Glucose [Moles/volume] in Blood
     * Gunakan kode LOINC yang sesuai untuk jenis lab yang dicari.
     */
    public Bundle findLabResults(String patientId, String loincCode) {
        ICriterion<?> patientCriteria = Observation.PATIENT.hasId(patientId);
        ICriterion<?> codeCriteria   = Observation.CODE.exactly()
            .systemAndCode("http://loinc.org", loincCode);

        return fhirClient.search()
            .forResource(Observation.class)
            .where(patientCriteria)
            .and(codeCriteria)
            .and(Observation.CATEGORY.exactly().code("laboratory"))
            .sort().descending(Observation.DATE)
            .returnBundle(Bundle.class)
            .execute();
    }
}

Bagian 4 — Transformer: HL7 v2 → FHIR R4

Ini adalah komponen paling kritis dalam keseluruhan sistem. Konversi harus presisi karena berkaitan dengan data klinis pasien. Setiap field HL7 yang hilang atau salah mapping bisa berdampak pada keselamatan pasien.

⚠️ Field Mapping adalah Tanggung Jawab Klinis Selalu libatkan tenaga kesehatan (dokter, apoteker, atau informaticist medis) untuk memverifikasi mapping antara field HL7 v2 dan FHIR resource. Kode di bawah adalah contoh teknis — validasi klinis tetap diperlukan sebelum produksi.
📄 transformer/Hl7ToFhirTransformer.java@Component
@Slf4j
public class Hl7ToFhirTransformer {

    private static final String NIK_SYSTEM = "https://fhir.kemkes.go.id/id/nik";
    private static final String MRN_SYSTEM = "http://rs.example.id/mrn";
    private static final String LOINC      = "http://loinc.org";
    private static final String ICD10      = "http://hl7.org/fhir/sid/icd-10";

    // ══════════════════════════════════════════════════════════
    // PID Segment → FHIR Patient
    // ══════════════════════════════════════════════════════════
    public Patient toPatient(PID pid) throws HL7Exception {
        Patient patient = new Patient();

        // PID-3: Patient Identifier List (bisa multi-value)
        for (int i = 0; i < pid.getPatientIdentifierListReps(); i++) {
            CX cx = pid.getPatientIdentifierList(i);
            String idType = cx.getIdentifierTypeCode().getValue();
            String idValue = cx.getIDNumber().getValue();

            if ("NIK".equalsIgnoreCase(idType)) {
                patient.addIdentifier()
                    .setUse(Identifier.IdentifierUse.OFFICIAL)
                    .setSystem(NIK_SYSTEM)
                    .setValue(idValue);
            } else if ("MR".equalsIgnoreCase(idType)) {
                patient.addIdentifier()
                    .setUse(Identifier.IdentifierUse.USUAL)
                    .setSystem(MRN_SYSTEM)
                    .setValue(idValue);
            }
        }

        // PID-5: Patient Name — bisa multi (official, alias, dll)
        for (int i = 0; i < pid.getPatientNameReps(); i++) {
            XPN xpn = pid.getPatientName(i);
            HumanName name = patient.addName();

            String family = getValueSafe(xpn.getFamilyName().getSurname());
            String given  = getValueSafe(xpn.getGivenName());
            String prefix = getValueSafe(xpn.getPrefixEgDR());

            if (family != null) name.setFamily(family);
            if (given  != null) name.addGiven(given);
            if (prefix != null) name.addPrefix(prefix);

            String nameTypeCode = getValueSafe(xpn.getNameTypeCode());
            if ("L".equals(nameTypeCode) || nameTypeCode == null) {
                name.setUse(HumanName.NameUse.OFFICIAL);
            }
        }

        // PID-7: Date/Time of Birth
        TS dob = pid.getDateTimeOfBirth();
        if (dob != null && dob.getTime() != null) {
            Date birthDate = dob.getTime().getValueAsDate();
            if (birthDate != null) patient.setBirthDate(birthDate);
        }

        // PID-8: Administrative Sex — "M", "F", "O", "U"
        String sex = getValueSafe(pid.getAdministrativeSex());
        if (sex != null) {
            patient.setGender(mapGender(sex));
        }

        // PID-11: Patient Address
        for (int i = 0; i < pid.getPatientAddressReps(); i++) {
            XAD xad = pid.getPatientAddress(i);
            Address addr = patient.addAddress();
            addr.setUse(Address.AddressUse.HOME);

            String street = getValueSafe(xad.getStreetAddress().getStreetOrMailingAddress());
            String city   = getValueSafe(xad.getCity());
            String zip    = getValueSafe(xad.getZipOrPostalCode());
            String country = getValueSafe(xad.getCountry());

            if (street  != null) addr.addLine(street);
            if (city    != null) addr.setCity(city);
            if (zip     != null) addr.setPostalCode(zip);
            if (country != null) addr.setCountry(country);
        }

        // PID-13/14: Phone Numbers
        for (int i = 0; i < pid.getPhoneNumberHomeReps(); i++) {
            XTN phone = pid.getPhoneNumberHome(i);
            String number = getValueSafe(phone.getTelephoneNumber());
            if (number != null) {
                patient.addTelecom()
                    .setSystem(ContactPoint.ContactPointSystem.PHONE)
                    .setUse(ContactPoint.ContactPointUse.HOME)
                    .setValue(number);
            }
        }

        return patient;
    }

    // ══════════════════════════════════════════════════════════
    // PV1 Segment → FHIR Encounter
    // ══════════════════════════════════════════════════════════
    public Encounter toEncounter(PV1 pv1, String patientId) throws HL7Exception {
        Encounter encounter = new Encounter();

        // Reference ke Patient
        encounter.setSubject(new Reference("Patient/" + patientId));

        // PV1-2: Patient Class — "I" inpatient, "O" outpatient, "E" emergency
        String patClass = getValueSafe(pv1.getPatientClass());
        if (patClass != null) {
            encounter.setClass_(new Coding()
                .setSystem("http://terminology.hl7.org/CodeSystem/v3-ActCode")
                .setCode(mapEncounterClass(patClass))
                .setDisplay(mapEncounterClassDisplay(patClass)));
        }

        // Status: aktif (sedang berlangsung)
        encounter.setStatus(Encounter.EncounterStatus.INPROGRESS);

        // PV1-3: Assigned Patient Location (ruang/poli)
        if (pv1.getAssignedPatientLocationReps() > 0) {
            PL location = pv1.getAssignedPatientLocation(0);
            String ward = getValueSafe(location.getPointOfCare());
            String desc = getValueSafe(location.getLocationDescription());
            if (ward != null) {
                encounter.addLocation()
                    .setLocation(new Reference().setDisplay(
                        desc != null ? desc : ward));
            }
        }

        // PV1-7: Attending Doctor
        if (pv1.getAttendingDoctorReps() > 0) {
            XCN doctor = pv1.getAttendingDoctor(0);
            String doctorId = getValueSafe(doctor.getIDNumber());
            String lastName  = getValueSafe(doctor.getFamilyName().getSurname());
            if (doctorId != null) {
                encounter.addParticipant()
                    .addType(codeableConcept(
                        "http://terminology.hl7.org/CodeSystem/v3-ParticipationType",
                        "ATND", "attender"))
                    .setIndividual(new Reference()
                        .setDisplay(lastName != null ? "dr. " + lastName : doctorId));
            }
        }

        return encounter;
    }

    // ══════════════════════════════════════════════════════════
    // OBX Segment → FHIR Observation
    // ══════════════════════════════════════════════════════════
    public Observation toObservation(OBX obx, String patientId) throws HL7Exception {
        Observation obs = new Observation();
        obs.setStatus(Observation.ObservationStatus.FINAL);
        obs.setSubject(new Reference("Patient/" + patientId));

        // OBX-3: Observation Identifier — LOINC code
        CE obsId = obx.getObservationIdentifier();
        String loincCode = getValueSafe(obsId.getIdentifier());
        String loincDesc = getValueSafe(obsId.getText());
        if (loincCode != null) {
            obs.setCode(codeableConcept(LOINC, loincCode, loincDesc));
        }

        // OBX-5: Observation Value (bisa NM, ST, CWE, dll)
        String valueType = getValueSafe(obx.getValueType());
        Varies[] values  = obx.getObservationValue();

        if (values.length > 0) {
            Type value = values[0].getData();
            if ("NM".equals(valueType) && value instanceof NM) {
                // Numeric value — mis: glukosa = 95.0 mg/dL
                String numStr = ((NM) value).getValue();
                if (numStr != null) {
                    Quantity q = new Quantity();
                    q.setValue(new BigDecimal(numStr));

                    // OBX-6: Unit (UCUM unit, mis: "mg/dL")
                    CE unit = obx.getUnits();
                    String unitCode = getValueSafe(unit.getIdentifier());
                    if (unitCode != null) {
                        q.setUnit(unitCode)
                         .setSystem("http://unitsofmeasure.org")
                         .setCode(unitCode);
                    }
                    obs.setValue(q);
                }
            } else if ("ST".equals(valueType) && value instanceof ST) {
                // String value
                obs.setValue(new StringType(((ST) value).getValue()));
            } else if ("CWE".equals(valueType) && value instanceof CWE) {
                // Coded value — mis: hasil kultur, golongan darah
                CWE coded = (CWE) value;
                obs.setValue(codeableConcept(
                    getValueSafe(coded.getNameOfCodingSystem()),
                    getValueSafe(coded.getIdentifier()),
                    getValueSafe(coded.getText())));
            }
        }

        // OBX-14: Date/Time of Observation
        TS observationTime = obx.getDateTimeOfTheObservation();
        if (observationTime != null && observationTime.getTime() != null) {
            Date obsDate = observationTime.getTime().getValueAsDate();
            if (obsDate != null) obs.setEffective(new DateTimeType(obsDate));
        }

        // Kategori: laboratory (untuk OBX dari ORU^R01)
        obs.addCategory(codeableConcept(
            "http://terminology.hl7.org/CodeSystem/observation-category",
            "laboratory", "Laboratory"));

        return obs;
    }

    // ── Helper Methods ────────────────────────────────────────────

    public String extractPatientId(PID pid) throws HL7Exception {
        // Coba ambil FHIR Patient ID dari PID-3 extension, atau gunakan MRN
        for (int i = 0; i < pid.getPatientIdentifierListReps(); i++) {
            CX cx = pid.getPatientIdentifierList(i);
            if ("FHIR".equalsIgnoreCase(cx.getIdentifierTypeCode().getValue())) {
                return cx.getIDNumber().getValue();
            }
        }
        return pid.getPatientIdentifierList(0).getIDNumber().getValue();
    }

    private Enumerations.AdministrativeGender mapGender(String hl7Sex) {
        return switch (hl7Sex.toUpperCase()) {
            case "M" -> Enumerations.AdministrativeGender.MALE;
            case "F" -> Enumerations.AdministrativeGender.FEMALE;
            case "O" -> Enumerations.AdministrativeGender.OTHER;
            default -> Enumerations.AdministrativeGender.UNKNOWN;
        };
    }

    private String mapEncounterClass(String hl7Class) {
        return switch (hl7Class.toUpperCase()) {
            case "I" -> "IMP";   // inpatient
            case "O" -> "AMB";   // ambulatory / outpatient
            case "E" -> "EMER";  // emergency
            default -> "AMB";
        };
    }

    private String mapEncounterClassDisplay(String hl7Class) {
        return switch (hl7Class.toUpperCase()) {
            case "I" -> "inpatient encounter";
            case "O" -> "ambulatory";
            case "E" -> "emergency";
            default -> "ambulatory";
        };
    }

    private CodeableConcept codeableConcept(String system, String code, String display) {
        CodeableConcept cc = new CodeableConcept();
        Coding coding = cc.addCoding().setSystem(system).setCode(code);
        if (display != null) coding.setDisplay(display);
        return cc;
    }

    private String getValueSafe(Primitive p) {
        if (p == null) return null;
        String val = p.getValue();
        return (val == null || val.isBlank()) ? null : val.trim();
    }

    private String getValueSafe(String val) {
        return (val == null || val.isBlank()) ? null : val.trim();
    }
}

Bagian 5 — Integrasi SATUSEHAT Sandbox

SATUSEHAT menggunakan OAuth 2.0 Client Credentials flow. Token berlaku 3600 detik (1 jam). Kita perlu token manager yang menyimpan token dan auto-refresh sebelum expired.

SatuSehatAuthService — Token Manager

📄 satusehat/SatuSehatAuthService.java@Service
@Slf4j
public class SatuSehatAuthService {

    @Value("${satusehat.auth-url}")
    private String authUrl;

    @Value("${satusehat.client-id}")
    private String clientId;

    @Value("${satusehat.client-secret}")
    private String clientSecret;

    private final WebClient webClient;

    // In-memory token cache
    private volatile String  cachedToken;
    private volatile Instant tokenExpiry = Instant.MIN;

    public SatuSehatAuthService(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder.build();
    }

    /**
     * Ambil token yang valid. Otomatis refresh jika sudah/hampir expired.
     * Thread-safe dengan double-checked locking.
     */
    public synchronized String getValidToken() {
        // Refresh jika sisa waktu < 5 menit
        if (cachedToken == null || Instant.now().isAfter(tokenExpiry.minusSeconds(300))) {
            refreshToken();
        }
        return cachedToken;
    }

    private void refreshToken() {
        log.info("[SATUSEHAT] Refreshing OAuth2 token...");

        try {
            // SATUSEHAT menggunakan form-encoded body, bukan Authorization header
            Map<String, Object> response = webClient.post()
                .uri(authUrl + "?grant_type=client_credentials")
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .bodyValue("client_id=" + clientId + "&client_secret=" + clientSecret)
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
                .timeout(Duration.ofSeconds(15))
                .block();

            if (response == null || !response.containsKey("access_token")) {
                throw new IllegalStateException("Empty token response from SATUSEHAT");
            }

            cachedToken  = (String) response.get("access_token");
            int expiresIn = (Integer) response.getOrDefault("expires_in", 3600);
            tokenExpiry  = Instant.now().plusSeconds(expiresIn);

            log.info("[SATUSEHAT] Token refreshed, expires in {} seconds", expiresIn);

        } catch (Exception e) {
            log.error("[SATUSEHAT] Token refresh failed: {}", e.getMessage());
            throw new RuntimeException("Cannot get SATUSEHAT access token", e);
        }
    }
}

SatuSehatClient — Kirim Resource ke SATUSEHAT

📄 satusehat/SatuSehatClient.java@Service
@RequiredArgsConstructor
@Slf4j
public class SatuSehatClient {

    private final SatuSehatAuthService authService;
    private final FhirContext          fhirContext;
    private final WebClient.Builder    webClientBuilder;

    @Value("${satusehat.base-url}")
    private String baseUrl;

    @Value("${satusehat.enabled:true}")
    private boolean enabled;

    /**
     * Kirim Patient ke SATUSEHAT.
     * Async — tidak memblokir proses ACK HL7.
     */
    @Async
    public CompletableFuture<String> sendPatientAsync(Patient patient) {
        if (!enabled) return CompletableFuture.completedFuture("disabled");
        return CompletableFuture.supplyAsync(() -> sendResource("Patient", patient));
    }

    @Async
    public CompletableFuture<String> sendObservationAsync(Observation obs) {
        if (!enabled) return CompletableFuture.completedFuture("disabled");
        return CompletableFuture.supplyAsync(() -> sendResource("Observation", obs));
    }

    @Async
    public CompletableFuture<String> sendEncounterAsync(Encounter encounter) {
        if (!enabled) return CompletableFuture.completedFuture("disabled");
        return CompletableFuture.supplyAsync(() -> sendResource("Encounter", encounter));
    }

    /**
     * Serialize FHIR resource ke JSON → POST ke SATUSEHAT dengan retry.
     */
    private String sendResource(String resourceType, IBaseResource resource) {
        int maxRetries = 3;
        int attempt    = 0;

        while (attempt < maxRetries) {
            attempt++;
            try {
                String token  = authService.getValidToken();
                String json   = serializeToJson(resource);

                String result = webClientBuilder.build()
                    .post()
                    .uri(baseUrl + "/" + resourceType)
                    .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
                    .header(HttpHeaders.CONTENT_TYPE, "application/fhir+json")
                    .bodyValue(json)
                    .retrieve()
                    .onStatus(status -> status.is4xxClientError(), response ->
                        response.bodyToMono(String.class)
                            .flatMap(body -> Mono.error(
                                new SatuSehatClientException("4xx error: " + body))))
                    .onStatus(status -> status.is5xxServerError(), response ->
                        response.bodyToMono(String.class)
                            .flatMap(body -> Mono.error(
                                new SatuSehatServerException("5xx error: " + body))))
                    .bodyToMono(String.class)
                    .timeout(Duration.ofSeconds(30))
                    .block();

                log.info("[SATUSEHAT] ✓ {} sent successfully", resourceType);
                return result;

            } catch (SatuSehatClientException e) {
                // 4xx: jangan retry, ini kesalahan data kita
                log.error("[SATUSEHAT] Client error sending {}: {}", resourceType, e.getMessage());
                throw e;

            } catch (Exception e) {
                log.warn("[SATUSEHAT] Attempt {}/{} failed for {}: {}",
                    attempt, maxRetries, resourceType, e.getMessage());
                if (attempt == maxRetries) {
                    log.error("[SATUSEHAT] All retries exhausted for {}", resourceType);
                    throw new RuntimeException("SATUSEHAT send failed after retries", e);
                }
                // Exponential backoff: 1s, 2s, 4s
                try { Thread.sleep(1000L * (long) Math.pow(2, attempt - 1)); }
                catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
            }
        }
        throw new IllegalStateException("Unreachable");
    }

    private String serializeToJson(IBaseResource resource) {
        IParser parser = fhirContext.newJsonParser();
        parser.setPrettyPrint(false);
        return parser.encodeResourceToString(resource);
    }
}
⚠️ Urutan Pengiriman ke SATUSEHAT Wajib Diperhatikan SATUSEHAT melakukan referential integrity check. Urutan yang benar: Patient → Organization/Practitioner → Encounter → Condition/Observation/Procedure. Jika Encounter dikirim sebelum Patient-nya ada, akan mendapat error 422 Unprocessable Entity.

Bagian 6 — Unit Test untuk Transformer

Test transformer adalah yang paling krusial karena kesalahan mapping di sini langsung berdampak ke data klinis.

📄 src/test/java/.../transformer/Hl7ToFhirTransformerTest.java@SpringBootTest(classes = {Hl7ToFhirTransformer.class})
class Hl7ToFhirTransformerTest {

    @Autowired
    private Hl7ToFhirTransformer transformer;

    private Parser parser;

    @BeforeEach
    void setUp() {
        this.parser = new PipeParser();
    }

    @Test
    @DisplayName("PID segment dengan NIK dan MRN → FHIR Patient dengan 2 identifier")
    void testPidToPatient_withNikAndMrn() throws Exception {
        // GIVEN: pesan ADT^A04 dengan PID lengkap
        String hl7msg = "MSH|^~\\&|SIMRS|RS_TEST|GATEWAY|PUSAT|20250622||ADT^A04^ADT_A01|MSG001|P|2.5.1\r"
            + "PID|1||12345678^^^RS_TEST^MR~3273011501850001^^^Dukcapil^NIK||Ibrahim^Mohamad Yusuf||19850101|M|||Jl. Merdeka^^Bandung^JB^40111^ID||(022)1234567";

        ADT_A01 adt = (ADT_A01) parser.parse(hl7msg);

        // WHEN
        Patient patient = transformer.toPatient(adt.getPID());

        // THEN
        assertThat(patient.getIdentifier()).hasSize(2);

        // NIK identifier
        assertThat(patient.getIdentifier())
            .anyMatch(id -> "https://fhir.kemkes.go.id/id/nik".equals(id.getSystem())
                && "3273011501850001".equals(id.getValue()));

        // MRN identifier
        assertThat(patient.getIdentifier())
            .anyMatch(id -> id.getSystem().contains("mrn")
                && "12345678".equals(id.getValue()));

        // Nama
        assertThat(patient.getNameFirstRep().getFamily()).isEqualTo("Ibrahim");
        assertThat(patient.getNameFirstRep().getGiven()).hasSize(1);
        assertThat(patient.getNameFirstRep().getGivenAsSingleString()).isEqualTo("Mohamad Yusuf");

        // Gender
        assertThat(patient.getGender()).isEqualTo(Enumerations.AdministrativeGender.MALE);

        // Tanggal lahir
        assertThat(patient.getBirthDateElement().getValueAsString()).isEqualTo("1985-01-01");
    }

    @Test
    @DisplayName("OBX numeric → FHIR Observation dengan nilai dan satuan")
    void testObxToObservation_numericGlucose() throws Exception {
        // GIVEN: OBX untuk hasil glukosa darah
        String hl7msg = "MSH|^~\\&|LIS|LAB|SIMRS|RS|20250622||ORU^R01|LAB001|P|2.5.1\r"
            + "PID|1||12345678\r"
            + "OBR|1|LAB-001|LAB-001|15074-8^Glucose^LN\r"
            + "OBX|1|NM|15074-8^Glucose [Moles/vol] in Blood^LN||6.3|mmol/L|3.9-6.1||||F|||20250622150000";

        ORU_R01 oru = (ORU_R01) parser.parse(hl7msg);
        OBX obx = oru.getPATIENT_RESULT().getORDER_OBSERVATION().getOBSERVATION().getOBX();

        // WHEN
        Observation obs = transformer.toObservation(obx, "patient-123");

        // THEN
        assertThat(obs.getStatus()).isEqualTo(Observation.ObservationStatus.FINAL);
        assertThat(obs.getCode().getCodingFirstRep().getSystem()).isEqualTo("http://loinc.org");
        assertThat(obs.getCode().getCodingFirstRep().getCode()).isEqualTo("15074-8");

        // Value harus Quantity
        assertThat(obs.getValue()).isInstanceOf(Quantity.class);
        Quantity qty = (Quantity) obs.getValue();
        assertThat(qty.getValue()).isEqualByComparingTo(new BigDecimal("6.3"));
        assertThat(qty.getUnit()).isEqualTo("mmol/L");
        assertThat(qty.getSystem()).isEqualTo("http://unitsofmeasure.org");
    }
}

Bagian 7 — Docker Compose: Full Stack

Satu perintah docker compose up -d untuk menyalakan seluruh infrastruktur:

📄 docker-compose.ymlversion: '3.8'

services:

  # ── PostgreSQL — database untuk HAPI FHIR Server ──────────────
  postgres:
    image: postgres:16-alpine
    container_name: hc-postgres
    environment:
      POSTGRES_DB:       fhirdb
      POSTGRES_USER:     fhiruser
      POSTGRES_PASSWORD: fhirpass
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U fhiruser -d fhirdb"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ── HAPI FHIR JPA Server — FHIR server lokal ─────────────────
  hapi-fhir:
    image: hapiproject/hapi:latest
    container_name: hc-fhir-server
    ports:
      - "8090:8080"
    environment:
      hapi.fhir.fhir_version:       R4
      hapi.fhir.allow_multiple_delete: "true"
      spring.datasource.url:        jdbc:postgresql://postgres:5432/fhirdb
      spring.datasource.username:   fhiruser
      spring.datasource.password:   fhirpass
      spring.datasource.driverClassName: org.postgresql.Driver
      spring.jpa.properties.hibernate.dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgres94Dialect
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/fhir/metadata"]
      interval: 30s
      timeout: 10s
      retries: 10
      start_period: 60s

  # ── PostgreSQL untuk aplikasi kita (terpisah dari FHIR) ────────
  app-postgres:
    image: postgres:16-alpine
    container_name: hc-app-postgres
    environment:
      POSTGRES_DB:       healthdb
      POSTGRES_USER:     healthuser
      POSTGRES_PASSWORD: healthpass
    ports:
      - "5433:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U healthuser -d healthdb"]
      interval: 10s
      retries: 5

  # ── HL7 Sender Simulator — untuk test MLLP ───────────────────
  # Mengirim pesan ADT^A04 sampel setiap 30 detik
  hl7-simulator:
    image: alpine:3.19
    container_name: hc-hl7-simulator
    depends_on:
      - hapi-fhir                   # pastikan app sudah ready dulu
    volumes:
      - ./scripts/hl7-simulator.sh:/simulator.sh
    command: sh /simulator.sh
    restart: unless-stopped

  # ── Aplikasi kita ─────────────────────────────────────────────
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: hc-integration-service
    ports:
      - "8080:8080"   # Spring Boot HTTP
      - "2575:2575"   # MLLP listener
    environment:
      SPRING_PROFILES_ACTIVE:                prod
      SPRING_DATASOURCE_URL:                 jdbc:postgresql://app-postgres:5432/healthdb
      SPRING_DATASOURCE_USERNAME:            healthuser
      SPRING_DATASOURCE_PASSWORD:            healthpass
      FHIR_SERVER_BASE_URL:                  http://hapi-fhir:8080/fhir
      SATUSEHAT_CLIENT_ID:                   ${SATUSEHAT_CLIENT_ID}
      SATUSEHAT_CLIENT_SECRET:               ${SATUSEHAT_CLIENT_SECRET}
      SATUSEHAT_ORGANIZATION_ID:             ${SATUSEHAT_ORGANIZATION_ID}
    depends_on:
      hapi-fhir:
        condition: service_healthy
      app-postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 30s
      retries: 5

volumes:
  postgres_data:

Dockerfile untuk Aplikasi Kita

📄 Dockerfile# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY gradlew settings.gradle build.gradle ./
COPY gradle/ gradle/
RUN ./gradlew dependencies --no-daemon # cache layer
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test

# Stage 2: Runtime (image lebih kecil)
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar

# Port HTTP + MLLP
EXPOSE 8080 2575

ENTRYPOINT ["java", "-jar", "-Djava.security.egd=file:/dev/./urandom", "app.jar"]

HL7 Simulator Script

Script sederhana untuk mengirim pesan ADT^A04 via MLLP ke aplikasi kita, menggunakan netcat:

📄 scripts/hl7-simulator.sh#!/bin/sh
# Simulator: kirim ADT^A04 ke MLLP listener setiap 30 detik
# Gunakan di Docker — tidak butuh tool khusus

APP_HOST="app"
APP_PORT="2575"
MLLP_START="$(printf '\x0b')"   # 0x0B VT
MLLP_END="$(printf '\x1c\x0d')" # 0x1C FS + 0x0D CR

while true; do
    TIMESTAMP=$(date +%Y%m%d%H%M%S)
    MSG_ID="MSG-${TIMESTAMP}"

    # Bangun pesan HL7 v2 ADT^A04
    HL7_MSG="MSH|^~\&|SIMRS_TEST|RS_HARAPAN|HIS_GATEWAY|PUSAT|${TIMESTAMP}||ADT^A04^ADT_A01|${MSG_ID}|P|2.5.1
PID|1||98765432^^^RS_HARAPAN^MR~3273019901010001^^^Dukcapil^NIK||Santoso^Budi||19901231|M|||Jl. Sudirman No. 99^^Jakarta^JK^10220^ID||(021)87654321
PV1|1|O|POLI-UMUM^Poliklinik Umum^RS_HARAPAN||||dr.ANDIKA^Andika^Pratama^^^dr.|||MED
IN1|1|BPJS|BPJS|BPJS Kesehatan||||0001998765432"

    # Kirim via netcat (tersedia di Alpine)
    printf "%s%s%s" "$MLLP_START" "$HL7_MSG" "$MLLP_END" | nc -w 5 $APP_HOST $APP_PORT

    echo "[SIM] Sent ADT^A04 ${MSG_ID} at ${TIMESTAMP}"
    sleep 30
done

Bagian 8 — Menjalankan & Verifikasi End-to-End

Langkah Menjalankan

  • 1
    Clone dan konfigurasi credential Isi SATUSEHAT_CLIENT_ID, SATUSEHAT_CLIENT_SECRET, dan SATUSEHAT_ORGANIZATION_ID di file .env atau environment variable. Credential didapat dari portal developer SATUSEHAT.
  • 2
    Jalankan infrastruktur docker compose up -d postgres app-postgres hapi-fhir — tunggu hingga HAPI FHIR server healthy (~60 detik pertama kali). Verifikasi: akses http://localhost:8090/fhir/metadata.
  • 3
    Jalankan aplikasi Spring Boot ./gradlew bootRun --args='--spring.profiles.active=dev' untuk mode development dengan H2. Atau docker compose up -d app untuk full Docker.
  • 4
    Kirim test HL7 message Jalankan docker compose up hl7-simulator atau gunakan tool seperti HAPI TestPanel untuk mengirim pesan ADT^A04 ke port 2575.
  • 5
    Verifikasi di FHIR server lokal Buka http://localhost:8090/fhir/Patient?_format=json — Anda harus melihat Patient resource yang baru dibuat dari konversi HL7 v2.
  • 6
    Verifikasi di SATUSEHAT Login ke portal SATUSEHAT sandbox dan cek apakah data masuk. Atau cek log aplikasi untuk konfirmasi response HTTP 200/201 dari SATUSEHAT.

Query untuk Verifikasi Data

🔍 Verifikasi FHIR resources di server lokal-- Lihat semua Patient yang baru masuk
GET http://localhost:8090/fhir/Patient?_count=10&_sort=-_lastUpdated

-- Cari patient berdasarkan NIK
GET http://localhost:8090/fhir/Patient?identifier=https://fhir.kemkes.go.id/id/nik|3273019901010001

-- Lihat Encounter yang terkait patient
GET http://localhost:8090/fhir/Encounter?patient=[PATIENT_ID]

-- Lihat semua Observation lab pasien
GET http://localhost:8090/fhir/Observation?patient=[PATIENT_ID]&category=laboratory

-- Health check aplikasi kita
GET http://localhost:8080/actuator/health
💡 Gunakan HAPI FHIR TestPanel untuk Debugging Download HAPI TestPanel — tool GUI gratis yang bisa mengirim pesan HL7 v2 via MLLP, menampilkan struktur segment, dan memvalidasi ACK response. Jauh lebih nyaman daripada netcat manual.

Kesimpulan & Langkah Selanjutnya

Kita sudah membangun sistem integrasi kesehatan yang lengkap: dari MLLP listener yang menerima HL7 v2, transformer yang mengkonversinya ke FHIR R4, service layer untuk CRUD ke FHIR server lokal, sampai pengiriman async ke SATUSEHAT dengan OAuth2 dan retry logic.

Ini adalah fondasi yang cukup solid untuk project integrasi SIMRS nyata. Beberapa hal yang perlu diperhatikan sebelum masuk produksi:

AspekCatatan untuk Produksi
Secret ManagementJangan simpan client_secret di application.yml. Gunakan HashiCorp Vault, AWS Secrets Manager, atau Spring Cloud Config dengan enkripsi.
Dead Letter QueuePesan HL7 yang gagal diproses harus masuk DLQ (Kafka / RabbitMQ) untuk reprocessing manual, bukan sekadar dilog.
IdempotencyMLLP bisa mengirim ulang pesan yang sama (retransmit). Implementasikan idempotency check berdasarkan MSH-10 (Message Control ID).
FHIR ValidationAktifkan validator SATUSEHAT profile sebelum kirim ke produksi. Profil FHIR Kemenkes lebih ketat dari spesifikasi R4 standar.
Audit TrailData klinis wajib punya audit log lengkap (siapa mengubah apa, kapan). Pertimbangkan FHIR AuditEvent resource.
Enkripsi DataData pasien (PII + PHI) harus dienkripsi at-rest (kolom DB) dan in-transit (TLS 1.2+). Ini bukan opsional di healthcare.

Di artikel berikutnya saya akan membahas lebih dalam tentang FHIR Subscription (notifikasi real-time ketika resource berubah) dan implementasi SMART on FHIR untuk autentikasi pasien mengakses data rekam medis mereka sendiri.

💬 Feedback & Diskusi

Apakah ada bagian yang perlu saya jelaskan lebih detail? Atau ada bagian yang error saat Anda coba implementasi? Tinggalkan komentar di bawah — saya aktif membalas.

GitHub repository dengan kode lengkap (termasuk unit test dan integration test) akan saya share di artikel selanjutnya. Subscribe agar tidak ketinggalan.

Comments

Popular posts from this blog

Numpang Kerja Remote dari Bandung Creative Hub

Membangun AI Development Assistant Lokal

Debugging PHP Web dengan XDebug di Intellij IDEA (PHP STORM)