Add CPONE lab and radiology result forwarding
This commit is contained in:
@@ -19,3 +19,6 @@ DB_MAX_IDLE_CONNS=25
|
|||||||
DB_CONN_MAX_LIFETIME_MINUTES=5
|
DB_CONN_MAX_LIFETIME_MINUTES=5
|
||||||
|
|
||||||
CPONE_BEARER_TOKEN=replace-with-a-secure-token
|
CPONE_BEARER_TOKEN=replace-with-a-secure-token
|
||||||
|
|
||||||
|
# URL dasar penerusan hasil lab dan radiologi ke CPONE. Default di kode: devcpone.
|
||||||
|
CPONE_RESULT_BASE_URL=https://devcpone.aplikasi.web.id/one-api/api_his
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -368,3 +368,24 @@ go run ./cmd/server
|
|||||||
File `sqlc.yaml`, `sqlc/schema.sql`, dan `sqlc/query.sql` sudah disiapkan.
|
File `sqlc.yaml`, `sqlc/schema.sql`, dan `sqlc/query.sql` sudah disiapkan.
|
||||||
|
|
||||||
Catatan penting: sqlc tidak bisa menerima nama tabel dinamis seperti `harga_layanan_{periodeTarifID}`. Implementasi runtime saat ini tetap memakai `database/sql` untuk menyusun nama tabel setelah `periodeTarifID` divalidasi angka, sama seperti PHP memakai `HargaLayanan::makeTableName()`. Kalau semua periode ingin digenerate oleh sqlc, buat query konkret per tabel periode, misalnya `harga_layanan_10`, `harga_layanan_11`, dan seterusnya.
|
Catatan penting: sqlc tidak bisa menerima nama tabel dinamis seperti `harga_layanan_{periodeTarifID}`. Implementasi runtime saat ini tetap memakai `database/sql` untuk menyusun nama tabel setelah `periodeTarifID` divalidasi angka, sama seperti PHP memakai `HargaLayanan::makeTableName()`. Kalau semua periode ingin digenerate oleh sqlc, buat query konkret per tabel periode, misalnya `harga_layanan_10`, `harga_layanan_11`, dan seterusnya.
|
||||||
|
|
||||||
|
## Meneruskan hasil lab ke CPONE
|
||||||
|
|
||||||
|
`POST /api/cpone/his/update-result` menerima body JSON yang sama dengan
|
||||||
|
`/one-api/api_his/update_result` di CPONE dan meneruskannya tanpa mengubah isi.
|
||||||
|
Gunakan `Authorization: Bearer <CPONE_BEARER_TOKEN>` dan `Content-Type: application/json`.
|
||||||
|
Field `kode_rs` pada payload diteruskan apa adanya. Header Authorization dan
|
||||||
|
Cookie dari pemanggil tidak diteruskan ke CPONE.
|
||||||
|
|
||||||
|
URL dasar default adalah `https://devcpone.aplikasi.web.id/one-api/api_his`.
|
||||||
|
Ubah melalui `CPONE_RESULT_BASE_URL` bila diperlukan. Middleware menambahkan
|
||||||
|
`/update_result` untuk hasil lab. Status HTTP dan body
|
||||||
|
respons CPONE diteruskan ke pemanggil. Respons `422` dapat berarti hasil sudah
|
||||||
|
tersimpan di staging tetapi belum sepenuhnya cocok dengan order; periksa pesan
|
||||||
|
dan `hisOrderID` di respons untuk memastikan kondisinya.
|
||||||
|
|
||||||
|
`POST /api/cpone/his/update-result-radiologi` meneruskan payload radiologi ke
|
||||||
|
`/one-api/api_his/update_result_radiologi` dengan aturan autentikasi dan
|
||||||
|
respons yang sama. Payload dapat memuat `dokter_id` dan `results[].expertise`
|
||||||
|
seperti kontrak devcpone. Middleware menambahkan `/update_result_radiologi`
|
||||||
|
pada `CPONE_RESULT_BASE_URL` untuk hasil radiologi.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -62,6 +63,11 @@ func main() {
|
|||||||
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/services", protected(http.HandlerFunc(handler.AddLabServices)))
|
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/services", protected(http.HandlerFunc(handler.AddLabServices)))
|
||||||
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", protected(http.HandlerFunc(handler.AddLabPackage)))
|
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", protected(http.HandlerFunc(handler.AddLabPackage)))
|
||||||
mux.Handle("GET /api/cpone/patients/lab-results/{labNumber}", protected(http.HandlerFunc(handler.GetLabResult)))
|
mux.Handle("GET /api/cpone/patients/lab-results/{labNumber}", protected(http.HandlerFunc(handler.GetLabResult)))
|
||||||
|
resultBaseURL := strings.TrimRight(cfg.ResultBaseURL, "/")
|
||||||
|
mux.Handle("POST /api/cpone/his/update-result", apphttp.BearerAuth(cfg.BearerToken,
|
||||||
|
apphttp.NewUpdateResultForwarder(resultBaseURL+"/update_result", nil)))
|
||||||
|
mux.Handle("POST /api/cpone/his/update-result-radiologi", apphttp.BearerAuth(cfg.BearerToken,
|
||||||
|
apphttp.NewUpdateResultForwarder(resultBaseURL+"/update_result_radiologi", nil)))
|
||||||
// MCU report parity with docs/lap_mcu.php. The additional aliases keep the
|
// MCU report parity with docs/lap_mcu.php. The additional aliases keep the
|
||||||
// report reachable for clients that use the Indonesian legacy route name.
|
// report reachable for clients that use the Indonesian legacy route name.
|
||||||
mux.Handle("GET /api/cpone/reports/mcu", protected(http.HandlerFunc(handler.ListLaporanMCU)))
|
mux.Handle("GET /api/cpone/reports/mcu", protected(http.HandlerFunc(handler.ListLaporanMCU)))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Config struct {
|
|||||||
RSCode string
|
RSCode string
|
||||||
DepartmentID string
|
DepartmentID string
|
||||||
BearerToken string
|
BearerToken string
|
||||||
|
ResultBaseURL string
|
||||||
DBMaxOpenConns int
|
DBMaxOpenConns int
|
||||||
DBMaxIdleConns int
|
DBMaxIdleConns int
|
||||||
DBConnMaxLifetime time.Duration
|
DBConnMaxLifetime time.Duration
|
||||||
@@ -36,6 +37,7 @@ func Load(path string) (Config, error) {
|
|||||||
RSCode: env("CPONE_RS_CODE", ""),
|
RSCode: env("CPONE_RS_CODE", ""),
|
||||||
DepartmentID: env("CPONE_DEPARTEMEN_ID", ""),
|
DepartmentID: env("CPONE_DEPARTEMEN_ID", ""),
|
||||||
BearerToken: env("CPONE_BEARER_TOKEN", ""),
|
BearerToken: env("CPONE_BEARER_TOKEN", ""),
|
||||||
|
ResultBaseURL: env("CPONE_RESULT_BASE_URL", "https://devcpone.aplikasi.web.id/one-api/api_his"),
|
||||||
DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25),
|
DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25),
|
||||||
DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25),
|
DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25),
|
||||||
DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute,
|
DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute,
|
||||||
|
|||||||
73
internal/http/update_result_forward.go
Normal file
73
internal/http/update_result_forward.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxUpdateResultBody = 8 << 20
|
||||||
|
|
||||||
|
type resultHTTPClient interface {
|
||||||
|
Do(*http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateResultForwarder struct {
|
||||||
|
targetURL string
|
||||||
|
client resultHTTPClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUpdateResultForwarder relays the original JSON body to CPONE without
|
||||||
|
// forwarding the caller's Authorization header or cookies.
|
||||||
|
func NewUpdateResultForwarder(targetURL string, client resultHTTPClient) http.Handler {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
return updateResultForwarder{
|
||||||
|
targetURL: targetURL,
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h updateResultForwarder) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||||
|
if err != nil || mediaType != "application/json" {
|
||||||
|
WriteJSON(w, http.StatusUnsupportedMediaType, Response{Success: false, Message: "Content-Type harus application/json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxUpdateResultBody+1))
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Gagal membaca payload hasil pemeriksaan"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body) > maxUpdateResultBody {
|
||||||
|
WriteJSON(w, http.StatusRequestEntityTooLarge, Response{Success: false, Message: "Payload hasil pemeriksaan terlalu besar"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil || payload == nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Payload harus berupa object JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upstreamRequest, err := http.NewRequestWithContext(r.Context(), http.MethodPost, h.targetURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadGateway, Response{Success: false, Message: "URL tujuan CPONE tidak valid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upstreamRequest.Header.Set("Content-Type", "application/json")
|
||||||
|
upstreamRequest.Header.Set("Accept", "application/json")
|
||||||
|
upstreamResponse, err := h.client.Do(upstreamRequest)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadGateway, Response{Success: false, Message: "Gagal menghubungi API hasil pemeriksaan CPONE"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer upstreamResponse.Body.Close()
|
||||||
|
if contentType := upstreamResponse.Header.Get("Content-Type"); contentType != "" {
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
w.WriteHeader(upstreamResponse.StatusCode)
|
||||||
|
_, _ = io.Copy(w, upstreamResponse.Body)
|
||||||
|
}
|
||||||
96
internal/http/update_result_forward_test.go
Normal file
96
internal/http/update_result_forward_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeResultClient func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f fakeResultClient) Do(r *http.Request) (*http.Response, error) { return f(r) }
|
||||||
|
|
||||||
|
func TestUpdateResultForwarderPreservesPayloadAndResponse(t *testing.T) {
|
||||||
|
const body = `{"kode_rs":"GM","no_regpas":"MCU260900031","results":[{"layanan_id":"1102","result":"5","unit":"mm\/jam"}]}`
|
||||||
|
called := false
|
||||||
|
client := fakeResultClient(func(r *http.Request) (*http.Response, error) {
|
||||||
|
called = true
|
||||||
|
if r.Method != http.MethodPost || r.URL.Host != "devcpone.aplikasi.web.id" || r.URL.Path != "/one-api/api_his/update_result" {
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
gotBody, _ := io.ReadAll(r.Body)
|
||||||
|
if string(gotBody) != body {
|
||||||
|
t.Errorf("payload changed: %s", gotBody)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" {
|
||||||
|
t.Error("caller credentials were forwarded")
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusUnprocessableEntity,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"Status":{"OK":false,"Messages":"ORDER_STORED_PENDING_MATCH"}}`)),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := NewUpdateResultForwarder("https://devcpone.aplikasi.web.id/one-api/api_his/update_result", client)
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/api/cpone/his/update-result", strings.NewReader(body))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
r.Header.Set("Authorization", "Bearer middleware-secret")
|
||||||
|
r.Header.Set("Cookie", "session=secret")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
if !called || w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), "ORDER_STORED_PENDING_MATCH") {
|
||||||
|
t.Fatalf("called=%t status=%d body=%s", called, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateResultForwarderRejectsInvalidJSON(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
client := fakeResultClient(func(r *http.Request) (*http.Response, error) {
|
||||||
|
called = true
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||||
|
})
|
||||||
|
handler := NewUpdateResultForwarder("https://devcpone.aplikasi.web.id/one-api/api_his/update_result", client)
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/api/cpone/his/update-result", strings.NewReader(`{"kode_rs":`))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusBadRequest || called {
|
||||||
|
t.Fatalf("status=%d upstream_called=%t", w.Code, called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRadiologyResultForwarderPreservesPayloadAndResponse(t *testing.T) {
|
||||||
|
const body = `{"kode_rs":"GM","no_regpas":"MCU260900014","medrec":"00640052","dokter_id":"DR00069","results":[{"layanan_id":"RAD-01-01-001","testName":"A103 - Thorax PA","modality":"CR","expertise":"Hasil ekspertisi"}]}`
|
||||||
|
called := false
|
||||||
|
client := fakeResultClient(func(r *http.Request) (*http.Response, error) {
|
||||||
|
called = true
|
||||||
|
if r.Method != http.MethodPost || r.URL.Host != "devcpone.aplikasi.web.id" || r.URL.Path != "/one-api/api_his/update_result_radiologi" {
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||||
|
}
|
||||||
|
gotBody, _ := io.ReadAll(r.Body)
|
||||||
|
if string(gotBody) != body {
|
||||||
|
t.Errorf("radiology payload changed: %s", gotBody)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" {
|
||||||
|
t.Error("caller credentials were forwarded")
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusUnprocessableEntity,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"Status":{"OK":false,"Messages":"dokter_id HIS belum memiliki mapping dokter CPONE [Radiology004]"}}`)),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
handler := NewUpdateResultForwarder("https://devcpone.aplikasi.web.id/one-api/api_his/update_result_radiologi", client)
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/api/cpone/his/update-result-radiologi", strings.NewReader(body))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
r.Header.Set("Authorization", "Bearer middleware-secret")
|
||||||
|
r.Header.Set("Cookie", "session=secret")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
if !called || w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), "Radiology004") {
|
||||||
|
t.Fatalf("called=%t status=%d body=%s", called, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user