Compare commits

...

4 Commits

15 changed files with 1285 additions and 79 deletions

View File

@@ -1,11 +1,17 @@
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
# Database khusus master CPONE middleware. # Kode instance rumah sakit. Dipakai hanya untuk validasi X-RS-Code.
CPONE_RS_CODE=AWALBROS_DEV
# Departemen HIS yang dipakai endpoint registrasi laboratorium.
CPONE_DEPARTEMEN_ID=08_01
# Database HIS rumah sakit untuk instance ini.
CPONE_DB_HOST=127.0.0.1 CPONE_DB_HOST=127.0.0.1
CPONE_DB_PORT=3306 CPONE_DB_PORT=3306
CPONE_DB_DATABASE=cpone_middleware CPONE_DB_DATABASE=nama_database_his
CPONE_DB_USERNAME=root CPONE_DB_USERNAME=cpone
CPONE_DB_PASSWORD= CPONE_DB_PASSWORD=
DB_MAX_OPEN_CONNS=25 DB_MAX_OPEN_CONNS=25
@@ -13,5 +19,3 @@ 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
CPONE_DATABASE_SETTINGS_TOKEN=replace-with-a-different-admin-token
CPONE_DATABASE_CREDENTIAL_KEY=replace-with-a-long-random-encryption-key

131
README.md
View File

@@ -17,6 +17,14 @@ GET /api/cpone/perusahaan/{id}/group-tarif?group_tarif_id=
Authorization: Bearer <your-secure-token> Authorization: Bearer <your-secure-token>
X-RS-Code: <kode-rs> X-RS-Code: <kode-rs>
GET /api/cpone/doctors?search=&DokterID_in=&page=1&per_page=15
GET /api/doctors?search=&DokterID_in=&page=1&per_page=15
Authorization: Bearer <your-secure-token>
X-RS-Code: <kode-rs>
Endpoint ini menampilkan daftar dokter aktif dari HIS. Parameter `search` hanya
mencari berdasarkan nama dokter atau kode dokter (`DokterID`).
GET /api/cpone/tarif-kerjasama?search=&perusahaan_id=&tarif_id=&page=1&per_page=15 GET /api/cpone/tarif-kerjasama?search=&perusahaan_id=&tarif_id=&page=1&per_page=15
Authorization: Bearer <your-secure-token> Authorization: Bearer <your-secure-token>
@@ -64,7 +72,6 @@ Contoh payload pembuatan pasien HIS:
```json ```json
{ {
"rs": "PRIMAYA",
"nama": "BUDI SANTOS", "nama": "BUDI SANTOS",
"jenis_kelamin": "L", "jenis_kelamin": "L",
"tgl_lahir": "1990-01-15", "tgl_lahir": "1990-01-15",
@@ -91,6 +98,40 @@ Contoh payload pembuatan pasien HIS:
Endpoint mengembalikan `created`, `already_exists`, atau `possible_duplicate` Endpoint mengembalikan `created`, `already_exists`, atau `possible_duplicate`
sesuai hasil pemeriksaan identitas dan kemiripan data pasien di HIS. sesuai hasil pemeriksaan identitas dan kemiripan data pasien di HIS.
Registrasi pasien umum/MCU menggunakan endpoint berikut. Jika `medrec_id` belum
diisi dan pasien tidak ditemukan, caller dapat membuat pasien melalui endpoint
medrec lalu mengulangi request registrasi. `departemen_id` dapat dikosongkan;
middleware akan memakai `CPONE_DEPARTEMEN_ID` dari `.env`.
```http
POST /api/cpone/patients/registration
Authorization: Bearer <your-secure-token>
X-RS-Code: <kode-rs>
Content-Type: application/json
```
```json
{
"medrec_id": "00001234",
"nik": "317xxxxxxxxxxxx",
"tgl_lahir": "1988-03-03",
"tanggal": "2026-09-07",
"dokter_id": "D260900003",
"jenis_pasien_id": "1",
"jenis_reg_id": 2,
"periode_tarif_id": "10",
"perusahaan_id": "",
"asuransi_id": "",
"group_tarif_id": "0",
"nomer_polis": "",
"kelas_id": "Default",
"channel": "CPONE"
}
```
Endpoint ini idempoten untuk pasien, tanggal, departemen, dan dokter yang sama;
registrasi aktif yang sudah ada dikembalikan dengan status `already_registered_visit`.
Contoh payload registrasi laboratorium: Contoh payload registrasi laboratorium:
```json ```json
@@ -154,89 +195,45 @@ bersifat idempoten dan tidak membuat transaksi ganda.
## Config ## Config
Copy `.env.example` menjadi `.env`, lalu isi koneksi MySQL: Setiap instalasi CPONE hanya terhubung ke satu database HIS. Copy `.env.example`
menjadi `.env`, lalu isi kode instance dan koneksi MySQL HIS:
```text ```text
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
CPONE_RS_CODE=AWALBROS_DEV
CPONE_DEPARTEMEN_ID=08_01
CPONE_DB_HOST=127.0.0.1 CPONE_DB_HOST=127.0.0.1
CPONE_DB_PORT=3306 CPONE_DB_PORT=3306
CPONE_DB_DATABASE=cpone_middleware CPONE_DB_DATABASE=nama_database_his
CPONE_DB_USERNAME=root CPONE_DB_USERNAME=cpone
CPONE_DB_PASSWORD= CPONE_DB_PASSWORD=
CPONE_BEARER_TOKEN=replace-with-a-secure-token CPONE_BEARER_TOKEN=replace-with-a-secure-token
CPONE_DATABASE_SETTINGS_TOKEN=replace-with-a-different-admin-token
CPONE_DATABASE_CREDENTIAL_KEY=replace-with-a-long-random-encryption-key
``` ```
## Multi-database per kode RS `CPONE_DB_*` adalah koneksi database HIS, bukan database management CPONE.
Password hanya dibaca dari `.env`; aplikasi tidak menyimpan kredensial database
ke database lain.
Tambahkan atau perbarui koneksi RS melalui API berikut. Koneksi akan dites `CPONE_DEPARTEMEN_ID` adalah `DepartemenID` HIS yang digunakan untuk registrasi
sebelum disimpan; bila `Ping` gagal, setting tidak disimpan. laboratorium. Nilai ini diambil dari konfigurasi, sehingga tidak perlu dikirim
di payload endpoint registrasi.
```http Semua endpoint bisnis tetap membutuhkan header:
POST /api/cpone/database-settings
Authorization: Bearer <your-database-settings-token>
Content-Type: application/json
{
"kode_rs": "PRIMAYA_BEKASI_BARAT",
"nama": "Primaya Hospital Bekasi Barat",
"host": "10.10.10.20",
"port": "3306",
"database": "his_bekasi_barat",
"username": "cpone",
"password": "secret"
}
```
Endpoint pengelolaan yang tersedia:
```text
POST /api/cpone/database-settings
GET /api/cpone/database-settings
GET /api/cpone/database-settings/{kodeRs}
```
Daftar ringkas rumah sakit aktif dapat diambil tanpa memilih database HIS:
```http
GET /api/cpone/hospitals
Authorization: Bearer <your-secure-token>
```
Response hanya memuat `kode_rs`, `nama`, dan `status`; rumah sakit nonaktif tidak
ditampilkan.
Saat startup, service otomatis membuat database `CPONE_DB_DATABASE` beserta
tabel `hospitals` dan `hospital_databases`. Tidak ada database RS default; semua
koneksi HIS harus ditambahkan lewat API. Akun `CPONE_DB_USERNAME` harus memiliki
izin `CREATE DATABASE` pada bootstrap pertama; alternatifnya jalankan migration
di direktori `database/migrations` secara manual.
Response list/detail tidak pernah menampilkan password, hanya field
`has_password`. Password koneksi RS disimpan terenkripsi AES-GCM menggunakan
`CPONE_DATABASE_CREDENTIAL_KEY`. Jangan mengubah key tersebut setelah data mulai
disimpan karena password lama tidak akan bisa didekripsi. Jika key belum diisi,
service memakai `CPONE_DATABASE_SETTINGS_TOKEN`, lalu `CPONE_BEARER_TOKEN`
sebagai fallback; token fallback itu juga tidak boleh diubah tanpa rotasi
credential.
Endpoint pengelolaan memakai `CPONE_DATABASE_SETTINGS_TOKEN`. Jika variabel ini
belum diisi, service memakai `CPONE_BEARER_TOKEN` untuk kompatibilitas; gunakan
token admin yang berbeda di production.
Pilih database pada semua endpoint bisnis dengan header:
```http ```http
X-RS-Code: PRIMAYA_BEKASI_BARAT X-RS-Code: PRIMAYA_BEKASI_BARAT
``` ```
Sebagai alternatif dapat memakai query `?kode_rs=PRIMAYA_BEKASI_BARAT`. Sebagai alternatif dapat memakai query `?kode_rs=AWALBROS_DEV`. Header memiliki
Header memiliki prioritas lebih tinggi daripada query. Response juga memuat prioritas lebih tinggi daripada query. Kode tersebut hanya divalidasi terhadap
header `X-RS-Code` agar koneksi yang terpilih mudah diaudit. Salah satu dari `CPONE_RS_CODE`; kode tidak digunakan untuk memilih database.
header atau query tersebut wajib dikirim; jika tidak ada, API mengembalikan
status `422` dan tidak mengakses database HIS. Request tanpa kode mengembalikan `422`, sedangkan kode untuk instance rumah sakit
lain mengembalikan `403`. Response sukses tetap mengembalikan header `X-RS-Code`.
Payload `POST /api/cpone/patients/medrec` tidak lagi membutuhkan field `rs`.
Nilai `KodeRS` untuk kebutuhan internal HIS diisi dari `CPONE_RS_CODE`.
## Run ## Run

View File

@@ -35,7 +35,7 @@ func main() {
} }
defer resolver.Close() defer resolver.Close()
handler := apphttp.NewDynamicHandler(cfg.RSCode) handler := apphttp.NewDynamicHandler(cfg.RSCode, cfg.DepartmentID)
protected := func(handler http.Handler) http.Handler { protected := func(handler http.Handler) http.Handler {
return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(resolver, handler)) return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(resolver, handler))
} }
@@ -47,12 +47,16 @@ func main() {
mux.Handle("GET /api/cpone/perusahaan", protected(http.HandlerFunc(handler.ListPerusahaan))) mux.Handle("GET /api/cpone/perusahaan", protected(http.HandlerFunc(handler.ListPerusahaan)))
mux.Handle("GET /api/cpone/perusahaan/{id}", protected(http.HandlerFunc(handler.GetPerusahaan))) mux.Handle("GET /api/cpone/perusahaan/{id}", protected(http.HandlerFunc(handler.GetPerusahaan)))
mux.Handle("GET /api/cpone/perusahaan/{id}/group-tarif", protected(http.HandlerFunc(handler.GetPerusahaanGroupTarif))) mux.Handle("GET /api/cpone/perusahaan/{id}/group-tarif", protected(http.HandlerFunc(handler.GetPerusahaanGroupTarif)))
mux.Handle("GET /api/cpone/doctors", protected(http.HandlerFunc(handler.ListDokter)))
// Backward-compatible route matching the source Primaya API.
mux.Handle("GET /api/doctors", protected(http.HandlerFunc(handler.ListDokter)))
mux.Handle("GET /api/cpone/tarif-kerjasama", protected(http.HandlerFunc(handler.ListTarifKerjasama))) mux.Handle("GET /api/cpone/tarif-kerjasama", protected(http.HandlerFunc(handler.ListTarifKerjasama)))
mux.Handle("GET /api/cpone/tarif-kerjasama/{id}", protected(http.HandlerFunc(handler.GetTarifKerjasama))) mux.Handle("GET /api/cpone/tarif-kerjasama/{id}", protected(http.HandlerFunc(handler.GetTarifKerjasama)))
mux.Handle("GET /api/cpone/periode-tarif", protected(http.HandlerFunc(handler.ListPeriodeTarif))) mux.Handle("GET /api/cpone/periode-tarif", protected(http.HandlerFunc(handler.ListPeriodeTarif)))
mux.Handle("GET /api/cpone/periode-tarif/{periodeTarifID}", protected(http.HandlerFunc(handler.ListPeriodeTarifLayanan))) mux.Handle("GET /api/cpone/periode-tarif/{periodeTarifID}", protected(http.HandlerFunc(handler.ListPeriodeTarifLayanan)))
mux.Handle("GET /api/cpone/layanan-mapping-harga", protected(http.HandlerFunc(handler.MappingHarga))) mux.Handle("GET /api/cpone/layanan-mapping-harga", protected(http.HandlerFunc(handler.MappingHarga)))
mux.Handle("POST /api/cpone/patients/medrec", protected(http.HandlerFunc(handler.StoreMedrec))) mux.Handle("POST /api/cpone/patients/medrec", protected(http.HandlerFunc(handler.StoreMedrec)))
mux.Handle("POST /api/cpone/patients/registration", protected(http.HandlerFunc(handler.RegisterPatient)))
mux.Handle("POST /api/cpone/patients/lab-registration", protected(http.HandlerFunc(handler.RegisterLab))) mux.Handle("POST /api/cpone/patients/lab-registration", protected(http.HandlerFunc(handler.RegisterLab)))
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)))

View File

@@ -13,6 +13,7 @@ type Config struct {
AppHost string AppHost string
AppPort string AppPort string
RSCode string RSCode string
DepartmentID string
BearerToken string BearerToken string
DBMaxOpenConns int DBMaxOpenConns int
DBMaxIdleConns int DBMaxIdleConns int
@@ -33,6 +34,7 @@ func Load(path string) (Config, error) {
AppHost: env("APP_HOST", "0.0.0.0"), AppHost: env("APP_HOST", "0.0.0.0"),
AppPort: env("APP_PORT", "8080"), AppPort: env("APP_PORT", "8080"),
RSCode: env("CPONE_RS_CODE", ""), RSCode: env("CPONE_RS_CODE", ""),
DepartmentID: env("CPONE_DEPARTEMEN_ID", ""),
BearerToken: env("CPONE_BEARER_TOKEN", ""), BearerToken: env("CPONE_BEARER_TOKEN", ""),
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),
@@ -47,6 +49,9 @@ func Load(path string) (Config, error) {
if cfg.RSCode == "" { if cfg.RSCode == "" {
return Config{}, fmt.Errorf("CPONE_RS_CODE wajib diisi") return Config{}, fmt.Errorf("CPONE_RS_CODE wajib diisi")
} }
if cfg.DepartmentID == "" {
return Config{}, fmt.Errorf("CPONE_DEPARTEMEN_ID wajib diisi")
}
if cfg.BearerToken == "" { if cfg.BearerToken == "" {
return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi") return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi")
} }

103
internal/http/dokter.go Normal file
View File

@@ -0,0 +1,103 @@
package http
import (
"net/http"
"strconv"
"strings"
"primaya-api/cpone-middleware/internal/repository"
)
const (
defaultDokterPerPage = 15
maxDokterPerPage = 100
)
type DokterPaginationResponse struct {
CurrentPage int `json:"current_page"`
Data []repository.Dokter `json:"data"`
FirstPageURL string `json:"first_page_url"`
From *int `json:"from"`
LastPage int `json:"last_page"`
LastPageURL string `json:"last_page_url"`
Links map[string]any `json:"links"`
NextPageURL *string `json:"next_page_url"`
Total int `json:"total"`
PerPage int `json:"per_page"`
}
func (h Handler) ListDokter(w http.ResponseWriter, r *http.Request) {
page := parseDokterPage(r.URL.Query().Get("page"))
perPage := parseDokterPerPage(r.URL.Query().Get("per_page"))
doctorIDs := queryValues(r, "DokterID_in", "DokterID_in[]")
doctorIDs = splitDoctorIDs(doctorIDs)
result, err := h.dokterRepository(r.Context()).ListDokter(r.Context(), repository.DokterListParams{
Search: strings.TrimSpace(r.URL.Query().Get("search")), DoctorIDs: doctorIDs,
Page: page, PerPage: perPage,
})
if err != nil {
WriteJSON(w, http.StatusInternalServerError, Response{Success: false, Message: "internal server error", Data: nil})
return
}
lastPage := (result.Total + perPage - 1) / perPage
if lastPage < 1 {
lastPage = 1
}
var from *int
if len(result.Items) > 0 {
value := (page-1)*perPage + 1
from = &value
}
var nextPageURL *string
if page < lastPage {
value := currentRequestURL(r, page+1)
nextPageURL = &value
}
WriteJSON(w, http.StatusOK, DokterPaginationResponse{
CurrentPage: page, Data: result.Items, FirstPageURL: "", From: from,
LastPage: lastPage, LastPageURL: "", Links: map[string]any{},
NextPageURL: nextPageURL, Total: result.Total, PerPage: perPage,
})
}
func parseDokterPage(raw string) int {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 {
return 1
}
return value
}
func parseDokterPerPage(raw string) int {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 {
return defaultDokterPerPage
}
if value > maxDokterPerPage {
return maxDokterPerPage
}
return value
}
func splitDoctorIDs(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if _, exists := seen[part]; exists {
continue
}
seen[part] = struct{}{}
result = append(result, part)
}
}
return result
}

View File

@@ -0,0 +1,72 @@
package http
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"primaya-api/cpone-middleware/internal/repository"
)
type fakeDokterRepository struct {
page repository.DokterPage
params repository.DokterListParams
err error
}
func (f *fakeDokterRepository) ListDokter(_ context.Context, params repository.DokterListParams) (repository.DokterPage, error) {
f.params = params
return f.page, f.err
}
func TestListDokterResponseParity(t *testing.T) {
name := "DR. ANDI"
repo := &fakeDokterRepository{page: repository.DokterPage{
Total: 16,
Items: []repository.Dokter{{DokterID: "D001", Nama: &name}},
}}
handler := Handler{dokterRepo: repo}
request := httptest.NewRequest(http.MethodGet, "http://localhost/api/cpone/doctors?search=andi&DokterID_in=D001,D002&page=1&per_page=15&kode_rs=AWALBROS_DEV", nil)
recorder := httptest.NewRecorder()
handler.ListDokter(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if repo.params.Search != "andi" || repo.params.Page != 1 || repo.params.PerPage != 15 || strings.Join(repo.params.DoctorIDs, ",") != "D001,D002" {
t.Fatalf("params=%+v", repo.params)
}
for _, expected := range []string{
`"DokterID":"D001"`, `"Nama":"DR. ANDI"`,
`"last_page":2`, `"per_page":15`,
`"next_page_url":"http://localhost/api/cpone/doctors?kode_rs=AWALBROS_DEV\u0026page=2"`,
} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
}
}
for _, excluded := range []string{`"spesialisasi"`, `"subspesialisasi"`, `"departementIDs"`, `"profileDokter"`} {
if strings.Contains(recorder.Body.String(), excluded) {
t.Fatalf("response should not contain %s: %s", excluded, recorder.Body.String())
}
}
}
func TestListDokterDefaultsAndPerPageLimit(t *testing.T) {
repo := &fakeDokterRepository{page: repository.DokterPage{Items: []repository.Dokter{}}}
handler := Handler{dokterRepo: repo}
for target, expected := range map[string]int{
"/api/cpone/doctors": defaultDokterPerPage,
"/api/cpone/doctors?page=0": defaultDokterPerPage,
"/api/cpone/doctors?per_page=250": maxDokterPerPage,
} {
recorder := httptest.NewRecorder()
handler.ListDokter(recorder, httptest.NewRequest(http.MethodGet, target, nil))
if recorder.Code != http.StatusOK || repo.params.PerPage != expected || repo.params.Page != 1 {
t.Fatalf("target=%s status=%d params=%+v", target, recorder.Code, repo.params)
}
}
}

View File

@@ -27,6 +27,10 @@ type PatientRepository interface {
StoreMedrec(ctx context.Context, params repository.MedrecParams) (repository.MedrecOutcome, error) StoreMedrec(ctx context.Context, params repository.MedrecParams) (repository.MedrecOutcome, error)
} }
type RegistrationRepository interface {
RegisterPatient(ctx context.Context, params repository.RegistrationParams) (repository.RegistrationOutcome, error)
}
type LabResultRepository interface { type LabResultRepository interface {
GetLabResult(ctx context.Context, labNumber string) (repository.LabResultOutcome, error) GetLabResult(ctx context.Context, labNumber string) (repository.LabResultOutcome, error)
} }
@@ -55,41 +59,54 @@ type PerusahaanRepository interface {
GetPerusahaanGroupTarif(ctx context.Context, id, groupTarifID string) (*repository.PerusahaanGroupTarifDetail, error) GetPerusahaanGroupTarif(ctx context.Context, id, groupTarifID string) (*repository.PerusahaanGroupTarifDetail, error)
} }
type DokterRepository interface {
ListDokter(ctx context.Context, params repository.DokterListParams) (repository.DokterPage, error)
}
type Handler struct { type Handler struct {
layananRepo LayananRepository layananRepo LayananRepository
labRepo LabRegistrationRepository labRepo LabRegistrationRepository
patientRepo PatientRepository patientRepo PatientRepository
registrationRepo RegistrationRepository
labResultRepo LabResultRepository labResultRepo LabResultRepository
paketRepo PaketRepository paketRepo PaketRepository
tarifRepo TarifKerjasamaRepository tarifRepo TarifKerjasamaRepository
periodeTarifRepo PeriodeTarifRepository periodeTarifRepo PeriodeTarifRepository
periodeTarifDetailRepo PeriodeTarifDetailRepository periodeTarifDetailRepo PeriodeTarifDetailRepository
perusahaanRepo PerusahaanRepository perusahaanRepo PerusahaanRepository
dokterRepo DokterRepository
hospitalCode string hospitalCode string
departmentID string
} }
type CPONERepository interface { type CPONERepository interface {
LayananRepository LayananRepository
LabRegistrationRepository LabRegistrationRepository
PatientRepository PatientRepository
RegistrationRepository
LabResultRepository LabResultRepository
PaketRepository PaketRepository
TarifKerjasamaRepository TarifKerjasamaRepository
PeriodeTarifRepository PeriodeTarifRepository
PeriodeTarifDetailRepository PeriodeTarifDetailRepository
PerusahaanRepository PerusahaanRepository
DokterRepository
} }
func NewHandler(repo CPONERepository) Handler { func NewHandler(repo CPONERepository) Handler {
return Handler{ return Handler{
layananRepo: repo, labRepo: repo, patientRepo: repo, layananRepo: repo, labRepo: repo, patientRepo: repo, registrationRepo: repo,
labResultRepo: repo, paketRepo: repo, tarifRepo: repo, labResultRepo: repo, paketRepo: repo, tarifRepo: repo,
periodeTarifRepo: repo, periodeTarifDetailRepo: repo, perusahaanRepo: repo, periodeTarifRepo: repo, periodeTarifDetailRepo: repo, perusahaanRepo: repo,
dokterRepo: repo,
} }
} }
func NewDynamicHandler(hospitalCode string) Handler { func NewDynamicHandler(hospitalCode string, departmentID string) Handler {
return Handler{hospitalCode: strings.ToUpper(strings.TrimSpace(hospitalCode))} return Handler{
hospitalCode: strings.ToUpper(strings.TrimSpace(hospitalCode)),
departmentID: strings.TrimSpace(departmentID),
}
} }
func (h Handler) layananRepository(ctx context.Context) LayananRepository { func (h Handler) layananRepository(ctx context.Context) LayananRepository {
@@ -113,6 +130,13 @@ func (h Handler) patientRepository(ctx context.Context) PatientRepository {
return h.patientRepo return h.patientRepo
} }
func (h Handler) registrationRepository(ctx context.Context) RegistrationRepository {
if repo, ok := selectedRepository(ctx); ok {
return repo
}
return h.registrationRepo
}
func (h Handler) labResultRepository(ctx context.Context) LabResultRepository { func (h Handler) labResultRepository(ctx context.Context) LabResultRepository {
if repo, ok := selectedRepository(ctx); ok { if repo, ok := selectedRepository(ctx); ok {
return repo return repo
@@ -155,6 +179,13 @@ func (h Handler) perusahaanRepository(ctx context.Context) PerusahaanRepository
return h.perusahaanRepo return h.perusahaanRepo
} }
func (h Handler) dokterRepository(ctx context.Context) DokterRepository {
if repo, ok := selectedRepository(ctx); ok {
return repo
}
return h.dokterRepo
}
func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) { func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {
layananIDs := parseLayananIDs(r) layananIDs := parseLayananIDs(r)
if len(layananIDs) == 0 { if len(layananIDs) == 0 {

View File

@@ -35,6 +35,11 @@ func (h Handler) RegisterLab(w http.ResponseWriter, r *http.Request) {
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", err.Error(), nil) writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", err.Error(), nil)
return return
} }
// Departemen registrasi ditentukan per instance CPONE melalui .env.
// Payload tetap didukung untuk handler/test lama yang tidak memakai config.
if h.departmentID != "" {
payload["departemen_id"] = h.departmentID
}
validated, validationMessage := validateLabRegistration(payload) validated, validationMessage := validateLabRegistration(payload)
if validationMessage != "" { if validationMessage != "" {

View File

@@ -103,6 +103,36 @@ func TestRegisterLabValidPayloadAndResponseParity(t *testing.T) {
} }
} }
func TestRegisterLabUsesConfiguredDepartmentID(t *testing.T) {
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
HTTPStatus: http.StatusCreated,
Success: true,
Status: "registered",
Message: "Registrasi laboratorium berhasil disimpan",
}}
handler := Handler{labRepo: repo, departmentID: "MED-PNJ-LAB"}
body := `{
"medrec_id":"00001234",
"departemen_id":"DIABAIKAN",
"dokter_id":"DR01",
"dokter_pengirim_id":"DR02",
"jenis_pasien_id":"0",
"periode_tarif_id":10,
"diagnosa_kerja":"Pemeriksaan laboratorium"
}`
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration", strings.NewReader(body))
recorder := httptest.NewRecorder()
handler.RegisterLab(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
}
if repo.params.DepartmentID != "MED-PNJ-LAB" {
t.Fatalf("department ID = %q, want %q", repo.params.DepartmentID, "MED-PNJ-LAB")
}
}
func TestRegisterLabCompanyPayload(t *testing.T) { func TestRegisterLabCompanyPayload(t *testing.T) {
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{ repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
HTTPStatus: http.StatusCreated, HTTPStatus: http.StatusCreated,

View File

@@ -0,0 +1,204 @@
package http
import (
"errors"
"net/http"
"strings"
"time"
"primaya-api/cpone-middleware/internal/repository"
)
func (h Handler) RegisterPatient(w http.ResponseWriter, r *http.Request) {
payload, err := decodeJSONObject(r)
if err != nil {
writeRegistrationResponse(w, http.StatusUnprocessableEntity, false, "validation_error", "Payload harus berupa object JSON", nil)
return
}
params, message := validateRegistration(payload, h.departmentID)
if message != "" {
writeRegistrationResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil)
return
}
outcome, err := h.registrationRepository(r.Context()).RegisterPatient(r.Context(), params)
if err != nil {
var validationErr repository.ValidationError
if errors.As(err, &validationErr) {
writeRegistrationResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationErr.Message, nil)
return
}
writeRegistrationResponse(w, http.StatusInternalServerError, false, "error", err.Error(), nil)
return
}
writeRegistrationResponse(w, outcome.HTTPStatus, outcome.Success, outcome.Status, outcome.Message, outcome.Data)
}
func validateRegistration(payload map[string]any, configuredDepartment string) (repository.RegistrationParams, string) {
medrecID, _, message := optionalString(payload, "medrec_id", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
nik, _, message := optionalString(payload, "nik", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
birthDateText, birthPresent, message := optionalString(payload, "tgl_lahir", 10)
if message != "" {
return repository.RegistrationParams{}, message
}
if strings.TrimSpace(medrecID) == "" && (!birthPresent || strings.TrimSpace(nik) == "") {
return repository.RegistrationParams{}, "nik dan tgl_lahir wajib diisi jika medrec_id belum tersedia"
}
jakarta, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return repository.RegistrationParams{}, err.Error()
}
var birthDate time.Time
if birthPresent && strings.TrimSpace(birthDateText) != "" {
birthDate, err = time.ParseInLocation("2006-01-02", birthDateText, jakarta)
if err != nil {
return repository.RegistrationParams{}, "tgl_lahir harus format Y-m-d"
}
}
registrationDateText, present, message := optionalString(payload, "tanggal", 10)
if message != "" {
return repository.RegistrationParams{}, message
}
registrationDate := time.Now().In(jakarta)
if present && strings.TrimSpace(registrationDateText) != "" {
registrationDate, err = time.ParseInLocation("2006-01-02", registrationDateText, jakarta)
if err != nil {
return repository.RegistrationParams{}, "tanggal harus format Y-m-d"
}
}
departmentID, present, message := optionalString(payload, "departemen_id", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
if !present || strings.TrimSpace(departmentID) == "" {
departmentID = configuredDepartment
}
if strings.TrimSpace(departmentID) == "" {
return repository.RegistrationParams{}, "departemen_id wajib diisi atau dikonfigurasi di middleware"
}
doctorID, _, message := optionalString(payload, "dokter_id", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
patientType, message := optionalInteger(payload, "jenis_pasien_id", 0)
if message != "" {
return repository.RegistrationParams{}, message
}
registrationType, message := optionalInteger(payload, "jenis_reg_id", 2)
if message != "" {
return repository.RegistrationParams{}, message
}
tariffPeriod, message := optionalInteger(payload, "periode_tarif_id", 0)
if message != "" {
return repository.RegistrationParams{}, message
}
groupTariff, message := optionalInteger(payload, "group_tarif_id", 0)
if message != "" {
return repository.RegistrationParams{}, message
}
companyID, _, message := optionalString(payload, "perusahaan_id", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
insuranceID, _, message := optionalString(payload, "asuransi_id", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
providerID, _, message := optionalString(payload, "provider_id", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
policyNumber, _, message := optionalString(payload, "nomer_polis", 100)
if message != "" {
return repository.RegistrationParams{}, message
}
guaranteeNumber, _, message := optionalString(payload, "nomer_jaminan", 100)
if message != "" {
return repository.RegistrationParams{}, message
}
entryCode, _, message := optionalString(payload, "kode_masuk_id", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
classID, present, message := optionalString(payload, "kelas_id", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
if !present || strings.TrimSpace(classID) == "" {
classID = "Default"
}
notes, _, message := optionalString(payload, "catatan", 500)
if message != "" {
return repository.RegistrationParams{}, message
}
channel, present, message := optionalString(payload, "channel", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
if !present || strings.TrimSpace(channel) == "" {
channel = "CPONE"
}
referrer, _, message := optionalString(payload, "perujuk", 150)
if message != "" {
return repository.RegistrationParams{}, message
}
employeeID, _, message := optionalString(payload, "nik_karyawan", 100)
if message != "" {
return repository.RegistrationParams{}, message
}
employeeName, _, message := optionalString(payload, "nama_karyawan", 150)
if message != "" {
return repository.RegistrationParams{}, message
}
packageID, packagePresent, message := optionalString(payload, "kode_paket", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
if !packagePresent || strings.TrimSpace(packageID) == "" {
packageID, _, message = optionalString(payload, "paket_id", 50)
if message != "" {
return repository.RegistrationParams{}, message
}
}
packageID = strings.ToUpper(strings.TrimSpace(packageID))
if packageID != "" && !labIdentifier.MatchString(packageID) {
return repository.RegistrationParams{}, "kode_paket hanya boleh berisi huruf, angka, dan tanda hubung"
}
return repository.RegistrationParams{
MedrecID: strings.TrimSpace(medrecID), NIK: normalizeIdentity(nik), BirthDate: birthDate,
RegistrationDate: registrationDate, DepartmentID: strings.TrimSpace(departmentID),
DoctorID: strings.TrimSpace(doctorID), PatientTypeID: patientType, RegistrationType: registrationType,
TariffPeriodID: tariffPeriod, CompanyID: strings.TrimSpace(companyID), InsuranceID: strings.TrimSpace(insuranceID),
GroupTariffID: groupTariff, ProviderID: strings.TrimSpace(providerID), PolicyNumber: strings.TrimSpace(policyNumber),
GuaranteeNumber: strings.TrimSpace(guaranteeNumber), EntryCode: strings.TrimSpace(entryCode), ClassID: strings.TrimSpace(classID),
Notes: strings.TrimSpace(notes), Referrer: strings.TrimSpace(referrer), EmployeeID: strings.TrimSpace(employeeID), EmployeeName: strings.TrimSpace(employeeName), Channel: normalizeText(channel), PackageID: packageID,
}, ""
}
func optionalInteger(payload map[string]any, key string, fallback int64) (int64, string) {
value, present := payload[key]
if !present || isLaravelEmpty(value) {
return fallback, ""
}
number, ok := parseInteger(value)
if !ok || number < 0 {
return 0, key + " harus berupa angka"
}
return number, ""
}
func writeRegistrationResponse(w http.ResponseWriter, statusCode int, success bool, status, message string, data any) {
WriteJSON(w, statusCode, MedrecResponse{Success: success, Status: status, Message: message, Data: data})
}

View File

@@ -0,0 +1,84 @@
package http
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"primaya-api/cpone-middleware/internal/repository"
)
type fakeRegistrationRepository struct {
outcome repository.RegistrationOutcome
err error
params repository.RegistrationParams
}
func (f *fakeRegistrationRepository) RegisterPatient(_ context.Context, params repository.RegistrationParams) (repository.RegistrationOutcome, error) {
f.params = params
return f.outcome, f.err
}
func TestRegisterPatientUsesConfiguredDepartmentAndNormalizesPayload(t *testing.T) {
repo := &fakeRegistrationRepository{outcome: repository.RegistrationOutcome{
HTTPStatus: http.StatusCreated,
Success: true,
Status: "registered",
Message: "Registrasi berhasil disimpan",
}}
handler := Handler{registrationRepo: repo, departmentID: "08_01"}
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/registration", strings.NewReader(`{
"nik":"3174-0115-0190-0001",
"tgl_lahir":"1990-01-15",
"tanggal":"2026-09-07",
"dokter_id":"driqbal",
"jenis_pasien_id":"1",
"jenis_reg_id":2,
"periode_tarif_id":"10",
"perusahaan_id":"ADMEDIKAAA",
"group_tarif_id":18,
"kode_paket":"PN2600036",
"kelas_id":"Default",
"channel":"cpone"
}`))
recorder := httptest.NewRecorder()
handler.RegisterPatient(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
}
if repo.params.DepartmentID != "08_01" || repo.params.NIK != "3174011501900001" || repo.params.RegistrationType != 2 {
t.Fatalf("unexpected normalized params: %+v", repo.params)
}
if repo.params.PatientTypeID != 1 || repo.params.TariffPeriodID != 10 || repo.params.GroupTariffID != 18 || repo.params.Channel != "CPONE" {
t.Fatalf("unexpected numeric/text params: %+v", repo.params)
}
if repo.params.PackageID != "PN2600036" {
t.Fatalf("unexpected package: %+v", repo.params)
}
}
func TestRegisterPatientRequiresIdentityWhenMedrecIsMissing(t *testing.T) {
handler := Handler{registrationRepo: &fakeRegistrationRepository{}, departmentID: "08_01"}
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/registration", strings.NewReader(`{"tanggal":"2026-09-07"}`))
recorder := httptest.NewRecorder()
handler.RegisterPatient(recorder, request)
if recorder.Code != http.StatusUnprocessableEntity || !strings.Contains(recorder.Body.String(), "nik dan tgl_lahir wajib") {
t.Fatalf("unexpected validation response: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestRegisterPatientForwardsRepositoryError(t *testing.T) {
handler := Handler{registrationRepo: &fakeRegistrationRepository{err: errors.New("database unavailable")}, departmentID: "08_01"}
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/registration", strings.NewReader(`{"medrec_id":"00001234"}`))
recorder := httptest.NewRecorder()
handler.RegisterPatient(recorder, request)
if recorder.Code != http.StatusInternalServerError || !strings.Contains(recorder.Body.String(), "database unavailable") {
t.Fatalf("unexpected error response: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}

View File

@@ -0,0 +1,137 @@
package repository
import (
"context"
"database/sql"
"fmt"
"strings"
)
type DokterListParams struct {
Search string
DoctorIDs []string
Page int
PerPage int
}
type DokterPage struct {
Items []Dokter
Total int
}
type Dokter struct {
KodeRS *string `json:"KodeRs"`
NamaRS *string `json:"NamaRs"`
DokterID string `json:"DokterID"`
Nama *string `json:"Nama"`
Sapaan *string `json:"Sapaan"`
Inisial *string `json:"Inisial"`
Gelar *string `json:"Gelar"`
GelarBelakang *string `json:"Gelar_belakang"`
JenisKaryawanID *string `json:"JenisKaryawanID"`
Kelamin *string `json:"Kelamin"`
GolonganDarah *string `json:"GolonganDarah"`
TempatLahir *string `json:"TempatLahir"`
TanggalLahir *string `json:"TanggalLahir"`
NIK *string `json:"NIK"`
Telepon *string `json:"Telepon"`
Ponsel *string `json:"Ponsel"`
Email *string `json:"Email"`
Alamat *string `json:"Alamat"`
Kota *string `json:"Kota"`
KodePos *string `json:"KodePos"`
Propinsi *string `json:"Propinsi"`
Negara *string `json:"Negara"`
TanggalMulai *string `json:"TanggalMulai"`
Keilmuan *string `json:"Keilmuan"`
SpesialisasiID *string `json:"SpesialisasiID"`
KodeSpesialisasiID *string `json:"KodeSpesialisasiID"`
KodeSubSpesialisasiID *string `json:"KodeSubSpesialisasiID"`
Tipe *string `json:"Tipe"`
NA *string `json:"NA"`
}
func (r MySQLLayananRepository) ListDokter(ctx context.Context, params DokterListParams) (DokterPage, error) {
where, args := dokterListWhere(params)
var total int
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dokter d "+where, args...).Scan(&total); err != nil {
return DokterPage{}, err
}
queryArgs := append(append([]any{}, args...), params.PerPage, (params.Page-1)*params.PerPage)
rows, err := r.db.QueryContext(ctx, `
SELECT
d.DokterID, d.Nama, d.Sapaan, d.Inisial, d.Gelar, d.Gelar_belakang,
d.JenisKaryawanID, d.Kelamin, d.GolonganDarah, d.TempatLahir,
d.TanggalLahir, d.NIK, d.Telepon, d.Ponsel, d.Email, d.Alamat,
d.Kota, d.KodePos, d.Propinsi, d.Negara, d.TanggalMulai, d.Keilmuan,
d.SpesialisasiID, d.KodeSpesialisasiID, d.KodeSubSpesialisasiID,
d.Tipe, d.NA,
(SELECT rs.KodeRS FROM rs ORDER BY rs.RSID LIMIT 1),
(SELECT rs.Nama FROM rs ORDER BY rs.RSID LIMIT 1)
FROM dokter d
`+where+`
ORDER BY d.Nama, d.DokterID
LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return DokterPage{}, err
}
defer rows.Close()
items := make([]Dokter, 0)
for rows.Next() {
item, err := scanDokter(rows)
if err != nil {
return DokterPage{}, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return DokterPage{}, err
}
return DokterPage{Items: items, Total: total}, nil
}
func dokterListWhere(params DokterListParams) (string, []any) {
conditions := []string{"d.NA = 'N'"}
args := make([]any, 0, 2+len(params.DoctorIDs))
if params.Search != "" {
conditions = append(conditions, "(d.DokterID LIKE ? OR d.Nama LIKE ?)")
like := "%" + params.Search + "%"
args = append(args, like, like)
}
if len(params.DoctorIDs) > 0 {
placeholders := make([]string, len(params.DoctorIDs))
for i, id := range params.DoctorIDs {
placeholders[i] = "?"
args = append(args, id)
}
conditions = append(conditions, "d.DokterID IN ("+strings.Join(placeholders, ",")+")")
}
return "WHERE " + strings.Join(conditions, " AND "), args
}
func scanDokter(rows *sql.Rows) (Dokter, error) {
var item Dokter
var kodeRS, namaRS sql.NullString
err := rows.Scan(
&item.DokterID, &item.Nama, &item.Sapaan, &item.Inisial, &item.Gelar, &item.GelarBelakang,
&item.JenisKaryawanID, &item.Kelamin, &item.GolonganDarah, &item.TempatLahir,
&item.TanggalLahir, &item.NIK, &item.Telepon, &item.Ponsel, &item.Email, &item.Alamat,
&item.Kota, &item.KodePos, &item.Propinsi, &item.Negara, &item.TanggalMulai, &item.Keilmuan,
&item.SpesialisasiID, &item.KodeSpesialisasiID, &item.KodeSubSpesialisasiID,
&item.Tipe, &item.NA, &kodeRS, &namaRS,
)
if err != nil {
return Dokter{}, fmt.Errorf("scan dokter: %w", err)
}
item.KodeRS = nullStringPtr(kodeRS)
item.NamaRS = nullStringPtr(namaRS)
return item, nil
}

View File

@@ -52,6 +52,7 @@ type PossibleDuplicateData struct {
type MedrecPatient struct { type MedrecPatient struct {
MEDRECID string `json:"MEDRECID"` MEDRECID string `json:"MEDRECID"`
Nama *string `json:"Nama"` Nama *string `json:"Nama"`
Kelamin *string `json:"-"`
TanggalLahir *string `json:"TanggalLahir"` TanggalLahir *string `json:"TanggalLahir"`
JenisNomerSosial *string `json:"JenisNomerSosial"` JenisNomerSosial *string `json:"JenisNomerSosial"`
NomerSosial *string `json:"NomerSosial"` NomerSosial *string `json:"NomerSosial"`
@@ -189,7 +190,7 @@ func findPossibleMedrecDuplicates(ctx context.Context, tx *sql.Tx, name string,
} }
const medrecPatientSelect = ` const medrecPatientSelect = `
SELECT MEDRECID, Nama, DATE_FORMAT(TanggalLahir, '%Y-%m-%d'), SELECT MEDRECID, Nama, DATE_FORMAT(TanggalLahir, '%Y-%m-%d'), Kelamin,
JenisNomerSosial, NomerSosial, Telepon, Ponsel JenisNomerSosial, NomerSosial, Telepon, Ponsel
FROM medrec` FROM medrec`
@@ -199,13 +200,14 @@ type rowScanner interface {
func scanMedrecPatient(row rowScanner) (MedrecPatient, error) { func scanMedrecPatient(row rowScanner) (MedrecPatient, error) {
var patient MedrecPatient var patient MedrecPatient
var name, birthDate, identityType, identityNumber, phone, mobile sql.NullString var name, birthDate, gender, identityType, identityNumber, phone, mobile sql.NullString
err := row.Scan(&patient.MEDRECID, &name, &birthDate, &identityType, &identityNumber, &phone, &mobile) err := row.Scan(&patient.MEDRECID, &name, &birthDate, &gender, &identityType, &identityNumber, &phone, &mobile)
if err != nil { if err != nil {
return MedrecPatient{}, err return MedrecPatient{}, err
} }
patient.Nama = nullStringPtr(name) patient.Nama = nullStringPtr(name)
patient.TanggalLahir = nullStringPtr(birthDate) patient.TanggalLahir = nullStringPtr(birthDate)
patient.Kelamin = nullStringPtr(gender)
patient.JenisNomerSosial = nullStringPtr(identityType) patient.JenisNomerSosial = nullStringPtr(identityType)
patient.NomerSosial = nullStringPtr(identityNumber) patient.NomerSosial = nullStringPtr(identityNumber)
patient.Telepon = nullStringPtr(phone) patient.Telepon = nullStringPtr(phone)

View File

@@ -0,0 +1,511 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
type RegistrationParams struct {
MedrecID string
NIK string
BirthDate time.Time
RegistrationDate time.Time
DepartmentID string
DoctorID string
PatientTypeID int64
RegistrationType int64
TariffPeriodID int64
CompanyID string
InsuranceID string
GroupTariffID int64
ProviderID string
PolicyNumber string
GuaranteeNumber string
EntryCode string
ClassID string
Notes string
Referrer string
EmployeeID string
EmployeeName string
Channel string
PackageID string
}
type RegistrationOutcome struct {
HTTPStatus int
Success bool
Status string
Message string
Data any
}
type RegistrationData struct {
Patient MedrecPatient `json:"patient"`
Registration RegistrationRecord `json:"registration"`
Package *RegistrationPackage `json:"package,omitempty"`
}
type RegistrationPackage struct {
PaketDispenserID int64 `json:"PaketDispenserID"`
PaketID string `json:"PaketID"`
Nama string `json:"Nama"`
KunjunganKe int64 `json:"KunjunganKe"`
}
type RegistrationRecord struct {
RegID string `json:"RegID"`
MEDRECID string `json:"MEDRECID"`
Tanggal string `json:"Tanggal"`
DepartmentID string `json:"DepartemenID"`
DoctorID string `json:"DokterID"`
PatientTypeID int64 `json:"JenisPasienID"`
RegistrationID int64 `json:"JenisRegID"`
TariffPeriodID int64 `json:"PeriodeTarifID"`
CompanyID string `json:"PerusahaanID"`
InsuranceID string `json:"AsuransiID"`
ProviderID string `json:"ProviderID"`
GroupTariffID int64 `json:"GroupTarifID"`
PolicyNumber string `json:"NomerPolis"`
Pulang string `json:"Pulang"`
BatalDaftar string `json:"BatalDaftar"`
}
func (r MySQLLayananRepository) RegisterPatient(ctx context.Context, params RegistrationParams) (RegistrationOutcome, error) {
for attempt := 0; attempt < 3; attempt++ {
outcome, err := r.registerPatientAttempt(ctx, params)
if err == nil {
return outcome, nil
}
if !isConcurrencyError(err) || ctx.Err() != nil {
return RegistrationOutcome{}, err
}
}
return RegistrationOutcome{}, fmt.Errorf("registrasi pasien gagal karena konflik transaksi")
}
func (r MySQLLayananRepository) registerPatientAttempt(ctx context.Context, params RegistrationParams) (outcome RegistrationOutcome, err error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return RegistrationOutcome{}, err
}
defer func() {
if recovered := recover(); recovered != nil {
_ = tx.Rollback()
panic(recovered)
}
if err != nil {
_ = tx.Rollback()
}
}()
outcome, err = registerPatientInTransaction(ctx, tx, params)
if err != nil {
return RegistrationOutcome{}, err
}
if err = tx.Commit(); err != nil {
return RegistrationOutcome{}, err
}
return outcome, nil
}
func registerPatientInTransaction(ctx context.Context, tx *sql.Tx, params RegistrationParams) (RegistrationOutcome, error) {
patient, found, err := findRegistrationPatient(ctx, tx, params)
if err != nil {
return RegistrationOutcome{}, err
}
if !found {
return registrationOutcome(404, false, "patient_not_found", "Pasien tidak ditemukan", nil), nil
}
existing, found, err := findExistingRegistration(ctx, tx, patient.MEDRECID, params)
if err != nil {
return RegistrationOutcome{}, err
}
if found {
data := RegistrationData{Patient: patient, Registration: existing}
if strings.TrimSpace(params.PackageID) != "" {
data.Package, err = addRegistrationPackage(ctx, tx, existing, patient.MEDRECID, params.PackageID, params.Channel, params.RegistrationDate)
if err != nil {
return RegistrationOutcome{}, err
}
}
return registrationOutcome(200, true, "already_registered_visit", "Pasien sudah memiliki registrasi aktif pada tujuan yang sama", data), nil
}
jakarta, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return RegistrationOutcome{}, err
}
now := time.Now().In(jakarta)
nowText := now.Format("2006-01-02 15:04:05")
regDate := params.RegistrationDate.Format("2006-01-02")
prefix := registrationIDPrefix(params.RegistrationType, params.RegistrationDate)
regID, err := nextRegistrationID(ctx, tx, prefix)
if err != nil {
return RegistrationOutcome{}, err
}
shiftID, err := resolveDailyShiftID(ctx, tx, nil, params.RegistrationDate)
if err != nil {
return RegistrationOutcome{}, err
}
var previousVisits int64
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM regpas WHERE MEDRECID = ? AND NA = 'N'`, patient.MEDRECID).Scan(&previousVisits); err != nil {
return RegistrationOutcome{}, err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO regpas SET
RegID = ?, MEDRECID = ?, Nama = ?, ALAMATEXTPAS = ?, TELPEXTPAS = ?, TglLahir = ?, JnsKelamin = ?,
KunjunganKe = ?, Tanggal = ?, JenisPasienID = ?, PeriodeTarifID = ?, PerusahaanID = ?,
GroupTarifID = ?, AsuransiID = ?, ProviderID = ?, NomerPolis = ?, NIK = ?, NamaKaryawan = ?,
NomerJaminan = ?, KodeMasukID = ?, DepartemenID = ?, DepartemenID2 = ?, JenisRegID = ?,
Perujuk = ?, DokterID = ?, DokterID2 = '', KelasID = ?, MataUang = 'IDR', Rate = 1,
Pulang = 'N', BolehPulang = 'N', Lunas = 'N', BatalDaftar = 'N', Catatan = '',
CatatanMasuk = ?, ShiftHarianIDDaftar = ?, LoginBuat = ?, TanggalBuat = ?, NA = 'N'`,
regID,
patient.MEDRECID,
patient.Nama,
"",
firstNonEmpty(pointerValue(patient.Ponsel), pointerValue(patient.Telepon)),
pointerValue(patient.TanggalLahir),
firstNonEmpty(pointerValue(patient.Kelamin), "L"),
previousVisits+1,
regDate,
params.PatientTypeID,
params.TariffPeriodID,
params.CompanyID,
params.GroupTariffID,
params.InsuranceID,
params.ProviderID,
params.PolicyNumber,
firstNonEmpty(params.EmployeeID, params.NIK, pointerValue(patient.NomerSosial)),
params.EmployeeName,
params.GuaranteeNumber,
params.EntryCode,
params.DepartmentID,
params.DepartmentID,
params.RegistrationType,
params.Referrer,
params.DoctorID,
params.ClassID,
params.Notes,
shiftID,
params.Channel,
nowText,
)
if err != nil {
return RegistrationOutcome{}, err
}
registration := RegistrationRecord{
RegID: regID, MEDRECID: patient.MEDRECID, Tanggal: regDate,
DepartmentID: params.DepartmentID, DoctorID: params.DoctorID,
PatientTypeID: params.PatientTypeID, RegistrationID: params.RegistrationType,
TariffPeriodID: params.TariffPeriodID, CompanyID: params.CompanyID,
InsuranceID: params.InsuranceID, ProviderID: params.ProviderID,
GroupTariffID: params.GroupTariffID, PolicyNumber: params.PolicyNumber,
Pulang: "N", BatalDaftar: "N",
}
data := RegistrationData{Patient: patient, Registration: registration}
if strings.TrimSpace(params.PackageID) != "" {
data.Package, err = addRegistrationPackage(ctx, tx, registration, patient.MEDRECID, params.PackageID, params.Channel, params.RegistrationDate)
if err != nil {
return RegistrationOutcome{}, err
}
}
return registrationOutcome(201, true, "registered", "Registrasi berhasil disimpan", data), nil
}
func addRegistrationPackage(ctx context.Context, tx *sql.Tx, registration RegistrationRecord, medrecID, packageID, channel string, registrationDate time.Time) (*RegistrationPackage, error) {
packageID = strings.ToUpper(strings.TrimSpace(packageID))
header, found, err := findActiveOutpatientPackage(ctx, tx, packageID, registrationDate)
if err != nil {
return nil, err
}
if !found {
return nil, ValidationError{Message: "Paket dispenser MCU tidak ditemukan, belum disetujui, atau sudah tidak berlaku"}
}
jakarta, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, err
}
now := time.Now().In(jakarta).Format("2006-01-02 15:04:05")
if registration.RegistrationID == 2 {
if _, err := ensureMCUTransaction(ctx, tx, registration, channel, now); err != nil {
return nil, err
}
}
dbRegistration := dbLabRegistration{RegID: registration.RegID, MEDRECID: medrecID}
dispenserID, visitNumber, err := ensurePackageDispenser(ctx, tx, dbRegistration, header, channel, now)
if err != nil {
return nil, err
}
if registration.RegistrationID == 2 {
if err := ensureMCUPackageDetails(ctx, tx, dispenserID, registration.RegID, medrecID, header.ID, channel, now); err != nil {
return nil, err
}
}
return &RegistrationPackage{
PaketDispenserID: dispenserID,
PaketID: header.ID,
Nama: header.Name,
KunjunganKe: visitNumber,
}, nil
}
func ensureMCUTransaction(ctx context.Context, tx *sql.Tx, registration RegistrationRecord, channel, now string) (int64, error) {
var transactionID int64
err := tx.QueryRowContext(ctx, `
SELECT TrxDepartemenID
FROM trxdepartemen
WHERE RegID = ? AND DepartemenID = ? AND DokterID = ? AND NA = 'N'
ORDER BY TrxDepartemenID DESC LIMIT 1 FOR UPDATE`, registration.RegID, registration.DepartmentID, registration.DoctorID).Scan(&transactionID)
if err == nil {
return transactionID, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, err
}
result, err := tx.ExecContext(ctx, `
INSERT INTO trxdepartemen (
RegID, TrxID, DepartemenID, DokterID, Dokter2, AntrianID, Tanggal,
KelasID, MataUang, StatusID, LogID, LoginBuat, TanggalBuat, NA
) VALUES (?, ?, ?, ?, '', 0, ?, ?, 'IDR', 0, 0, ?, ?, 'N')`,
registration.RegID, registration.RegID, registration.DepartmentID, registration.DoctorID,
now, "Default", channel, now,
)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
type registrationPackageItem struct {
PackageDepartmentID sql.NullInt64
ItemDepartmentID sql.NullInt64
ItemWarehouseID sql.NullInt64
ItemID sql.NullString
HeaderID sql.NullInt64
DepartmentID sql.NullString
UnitID sql.NullString
BaseUnitID sql.NullString
Quantity sql.NullString
Price sql.NullString
DiscountType sql.NullString
Discount sql.NullString
}
func ensureMCUPackageDetails(ctx context.Context, tx *sql.Tx, dispenserID int64, regID, medrecID, packageID, channel, now string) error {
services, err := findActivePackageServices(ctx, tx, packageID)
if err != nil {
return err
}
for _, service := range services {
var present int
err := tx.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM paket_dispenser_layanan
WHERE PaketDispenserID = ? AND LayananID = ? AND NA = 'N'
)`, dispenserID, service.ServiceID).Scan(&present)
if err != nil {
return err
}
if present != 0 {
continue
}
operationID, err := resolvePackageOperationIDForMCU(ctx, tx, service.HeaderService)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO paket_dispenser_layanan (
PaketDispenserID, PaketDispenserOperasiID, RegID, MEDRECID, PaketID,
DepartemenID, LayananID, HeaderLayanan, JumlahAwal, Jumlah, JumlahHari,
Harga, TipeDiskon, Diskon, ShareTipe, ShareRS, ShareDokter, ShareExternal,
ShareLain, LoginBuat, TanggalBuat, NA
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N')`,
dispenserID, operationID, regID, medrecID, packageID, service.DepartmentID, service.ServiceID,
nullStringValue(service.HeaderService), nullStringValue(service.Quantity), nullStringValue(service.Quantity),
nullInt64Value(service.Days), nullStringValue(service.Price), nullStringValue(service.DiscountType),
nullStringValue(service.Discount), nullStringValue(service.ShareType), nullStringValue(service.ShareHospital),
nullStringValue(service.ShareDoctor), nullStringValue(service.ShareExternal), nullStringValue(service.ShareOther),
channel, now,
)
if err != nil {
return err
}
}
rows, err := tx.QueryContext(ctx, `
SELECT PaketDepartemenID, ItemDepartemenID, ItemGudangID, ItemID, HeaderID,
DepartemenID, SatuanID, SatuanDasarID, Jumlah, Harga, TipeDiskon, Diskon
FROM paket_item WHERE PaketID = ? AND NA = 'N' ORDER BY PaketItemID`, packageID)
if err != nil {
return err
}
items := make([]registrationPackageItem, 0)
for rows.Next() {
var item registrationPackageItem
if err := rows.Scan(&item.PackageDepartmentID, &item.ItemDepartmentID, &item.ItemWarehouseID, &item.ItemID, &item.HeaderID, &item.DepartmentID, &item.UnitID, &item.BaseUnitID, &item.Quantity, &item.Price, &item.DiscountType, &item.Discount); err != nil {
rows.Close()
return err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
for _, item := range items {
var present int
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM paket_dispenser_item WHERE PaketDispenserID = ? AND ItemID = ? AND HeaderID = ? AND NA = 'N')`, dispenserID, nullStringValue(item.ItemID), nullInt64Value(item.HeaderID)).Scan(&present); err != nil {
return err
}
if present != 0 {
continue
}
operationID, err := resolvePackageOperationIDForMCU(ctx, tx, nullStringFromInt(item.HeaderID))
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO paket_dispenser_item (
PaketDispenserID, PaketDispenserOperasiID, RegID, MEDRECID, PaketID,
PaketDepartemenID, ItemDepartemenID, ItemGudangID, ItemID, DepartemenID,
HeaderID, SatuanID, SatuanDasarID, JumlahAwal, Jumlah, Harga, TipeDiskon,
Diskon, LoginBuat, TanggalBuat, NA
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N')`,
dispenserID, operationID, regID, medrecID, packageID, nullInt64Value(item.PackageDepartmentID),
nullInt64Value(item.ItemDepartmentID), nullInt64Value(item.ItemWarehouseID), nullStringValue(item.ItemID),
nullStringValue(item.DepartmentID), nullInt64Value(item.HeaderID), nullStringValue(item.UnitID),
nullStringValue(item.BaseUnitID), nullStringValue(item.Quantity), nullStringValue(item.Quantity),
nullStringValue(item.Price), nullStringValue(item.DiscountType), nullStringValue(item.Discount), channel, now,
); err != nil {
return err
}
}
_, err = tx.ExecContext(ctx, `
INSERT INTO paket_dispenser_adm (
PaketAdmID, PaketDispenserID, RegID, PaketID, TipeRI, Hitung, AdmRI,
AdmRIBaru, TipeDiskonRI, DiskonRI, LoginBuat, TanggalBuat, NA
)
SELECT pa.PaketAdmID, ?, ?, pa.PaketID, pa.TipeRI, 'Y', pa.AdmRI,
pa.AdmRIBaru, pa.TipeDiskonRI, pa.DiskonRI, ?, ?, 'N'
FROM paket_adm pa
WHERE pa.PaketID = ? AND pa.NA = 'N'
AND NOT EXISTS (
SELECT 1 FROM paket_dispenser_adm da
WHERE da.PaketDispenserID = ? AND da.PaketAdmID = pa.PaketAdmID AND da.NA = 'N'
)
LIMIT 1`, dispenserID, regID, channel, now, packageID, dispenserID)
return err
}
func resolvePackageOperationIDForMCU(ctx context.Context, tx *sql.Tx, header sql.NullString) (int64, error) {
if !header.Valid || strings.TrimSpace(header.String) == "" {
return 0, nil
}
var operationID sql.NullInt64
err := tx.QueryRowContext(ctx, `SELECT PaketDispenserOperasiID FROM paket_dispenser_operasi_header WHERE PaketOperasiHeaderID = ? LIMIT 1`, header.String).Scan(&operationID)
if errors.Is(err, sql.ErrNoRows) || !operationID.Valid {
return 0, nil
}
return operationID.Int64, err
}
func nullStringFromInt(value sql.NullInt64) sql.NullString {
if !value.Valid {
return sql.NullString{}
}
return sql.NullString{String: strconv.FormatInt(value.Int64, 10), Valid: true}
}
func findRegistrationPatient(ctx context.Context, tx *sql.Tx, params RegistrationParams) (MedrecPatient, bool, error) {
query := medrecPatientSelect + ` WHERE NA = 'N' AND MEDRECID = ? LIMIT 1 FOR UPDATE`
args := []any{params.MedrecID}
if strings.TrimSpace(params.MedrecID) == "" {
query = medrecPatientSelect + ` WHERE NA = 'N' AND JenisNomerSosial = 'KTP' AND NomerSosial = ? AND DATE(TanggalLahir) = ? LIMIT 1 FOR UPDATE`
args = []any{params.NIK, params.BirthDate.Format("2006-01-02")}
}
patient, err := scanMedrecPatient(tx.QueryRowContext(ctx, query, args...))
if err == sql.ErrNoRows {
return MedrecPatient{}, false, nil
}
return patient, err == nil, err
}
func findExistingRegistration(ctx context.Context, tx *sql.Tx, medrecID string, params RegistrationParams) (RegistrationRecord, bool, error) {
query := `
SELECT RegID, MEDRECID, DATE_FORMAT(Tanggal, '%Y-%m-%d'), DepartemenID, DokterID,
JenisPasienID, JenisRegID, PeriodeTarifID, PerusahaanID, AsuransiID,
ProviderID, GroupTarifID, NomerPolis, Pulang, BatalDaftar
FROM regpas
WHERE MEDRECID = ? AND DATE(Tanggal) = ? AND DepartemenID = ?
AND Pulang = 'N' AND (BatalDaftar IS NULL OR BatalDaftar IN ('N', '')) AND NA = 'N'`
args := []any{medrecID, params.RegistrationDate.Format("2006-01-02"), params.DepartmentID}
if params.DoctorID != "" {
query += ` AND DokterID = ?`
args = append(args, params.DoctorID)
}
query += ` ORDER BY RegID DESC LIMIT 1 FOR UPDATE`
var registration RegistrationRecord
err := tx.QueryRowContext(ctx, query, args...).Scan(
&registration.RegID, &registration.MEDRECID, &registration.Tanggal, &registration.DepartmentID,
&registration.DoctorID, &registration.PatientTypeID, &registration.RegistrationID,
&registration.TariffPeriodID, &registration.CompanyID, &registration.InsuranceID,
&registration.ProviderID, &registration.GroupTariffID, &registration.PolicyNumber,
&registration.Pulang, &registration.BatalDaftar,
)
if err == sql.ErrNoRows {
return RegistrationRecord{}, false, nil
}
return registration, err == nil, err
}
func nextRegistrationID(ctx context.Context, tx *sql.Tx, prefix string) (string, error) {
var lastID string
err := tx.QueryRowContext(ctx, `SELECT RegID FROM regpas WHERE RegID LIKE ? ORDER BY RegID DESC LIMIT 1 FOR UPDATE`, prefix+"%").Scan(&lastID)
if err == sql.ErrNoRows {
return prefix + "00001", nil
}
if err != nil {
return "", err
}
number, err := strconv.Atoi(strings.TrimPrefix(lastID, prefix))
if err != nil {
return "", err
}
return fmt.Sprintf("%s%05d", prefix, number+1), nil
}
func registrationIDPrefix(registrationType int64, registrationDate time.Time) string {
prefix := "P"
if registrationType == 2 {
prefix = "MCU"
}
return prefix + registrationDate.Format("0601")
}
func registrationOutcome(statusCode int, success bool, status, message string, data any) RegistrationOutcome {
return RegistrationOutcome{HTTPStatus: statusCode, Success: success, Status: status, Message: message, Data: data}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,17 @@
package repository
import (
"testing"
"time"
)
func TestRegistrationIDPrefix(t *testing.T) {
registrationDate := time.Date(2026, time.September, 7, 0, 0, 0, 0, time.UTC)
if got, want := registrationIDPrefix(2, registrationDate), "MCU2609"; got != want {
t.Fatalf("MCU prefix = %q; want %q", got, want)
}
if got, want := registrationIDPrefix(1, registrationDate), "P2609"; got != want {
t.Fatalf("regular prefix = %q; want %q", got, want)
}
}