diff --git a/.env.example b/.env.example index c8d263d..c3264b5 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,17 @@ APP_HOST=0.0.0.0 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_PORT=3306 -CPONE_DB_DATABASE=cpone_middleware -CPONE_DB_USERNAME=root +CPONE_DB_DATABASE=nama_database_his +CPONE_DB_USERNAME=cpone CPONE_DB_PASSWORD= DB_MAX_OPEN_CONNS=25 @@ -13,5 +19,3 @@ DB_MAX_IDLE_CONNS=25 DB_CONN_MAX_LIFETIME_MINUTES=5 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 diff --git a/README.md b/README.md index befb12f..71f6465 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,14 @@ GET /api/cpone/perusahaan/{id}/group-tarif?group_tarif_id= Authorization: Bearer X-RS-Code: +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 +X-RS-Code: + +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 Authorization: Bearer @@ -64,7 +72,6 @@ Contoh payload pembuatan pasien HIS: ```json { - "rs": "PRIMAYA", "nama": "BUDI SANTOS", "jenis_kelamin": "L", "tgl_lahir": "1990-01-15", @@ -91,6 +98,40 @@ Contoh payload pembuatan pasien HIS: Endpoint mengembalikan `created`, `already_exists`, atau `possible_duplicate` 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 +X-RS-Code: +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: ```json @@ -154,89 +195,45 @@ bersifat idempoten dan tidak membuat transaksi ganda. ## 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 APP_HOST=0.0.0.0 APP_PORT=8080 +CPONE_RS_CODE=AWALBROS_DEV +CPONE_DEPARTEMEN_ID=08_01 CPONE_DB_HOST=127.0.0.1 CPONE_DB_PORT=3306 -CPONE_DB_DATABASE=cpone_middleware -CPONE_DB_USERNAME=root +CPONE_DB_DATABASE=nama_database_his +CPONE_DB_USERNAME=cpone CPONE_DB_PASSWORD= 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 -sebelum disimpan; bila `Ping` gagal, setting tidak disimpan. +`CPONE_DEPARTEMEN_ID` adalah `DepartemenID` HIS yang digunakan untuk registrasi +laboratorium. Nilai ini diambil dari konfigurasi, sehingga tidak perlu dikirim +di payload endpoint registrasi. -```http -POST /api/cpone/database-settings -Authorization: Bearer -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 -``` - -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: +Semua endpoint bisnis tetap membutuhkan header: ```http X-RS-Code: PRIMAYA_BEKASI_BARAT ``` -Sebagai alternatif dapat memakai query `?kode_rs=PRIMAYA_BEKASI_BARAT`. -Header memiliki prioritas lebih tinggi daripada query. Response juga memuat -header `X-RS-Code` agar koneksi yang terpilih mudah diaudit. Salah satu dari -header atau query tersebut wajib dikirim; jika tidak ada, API mengembalikan -status `422` dan tidak mengakses database HIS. +Sebagai alternatif dapat memakai query `?kode_rs=AWALBROS_DEV`. Header memiliki +prioritas lebih tinggi daripada query. Kode tersebut hanya divalidasi terhadap +`CPONE_RS_CODE`; kode tidak digunakan untuk memilih database. + +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 diff --git a/cmd/server/main.go b/cmd/server/main.go index 6896a5d..fae40b7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -35,7 +35,7 @@ func main() { } defer resolver.Close() - handler := apphttp.NewDynamicHandler(cfg.RSCode) + handler := apphttp.NewDynamicHandler(cfg.RSCode, cfg.DepartmentID) protected := func(handler http.Handler) http.Handler { return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(resolver, handler)) } @@ -56,6 +56,7 @@ func main() { 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("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/{regId}/services", protected(http.HandlerFunc(handler.AddLabServices))) mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", protected(http.HandlerFunc(handler.AddLabPackage))) diff --git a/internal/config/config.go b/internal/config/config.go index 1c645e4..1430342 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ type Config struct { AppHost string AppPort string RSCode string + DepartmentID string BearerToken string DBMaxOpenConns int DBMaxIdleConns int @@ -33,6 +34,7 @@ func Load(path string) (Config, error) { AppHost: env("APP_HOST", "0.0.0.0"), AppPort: env("APP_PORT", "8080"), RSCode: env("CPONE_RS_CODE", ""), + DepartmentID: env("CPONE_DEPARTEMEN_ID", ""), BearerToken: env("CPONE_BEARER_TOKEN", ""), DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25), DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25), @@ -47,6 +49,9 @@ func Load(path string) (Config, error) { if cfg.RSCode == "" { return Config{}, fmt.Errorf("CPONE_RS_CODE wajib diisi") } + if cfg.DepartmentID == "" { + return Config{}, fmt.Errorf("CPONE_DEPARTEMEN_ID wajib diisi") + } if cfg.BearerToken == "" { return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi") } diff --git a/internal/http/handler.go b/internal/http/handler.go index c7c763e..9b43cbe 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -27,6 +27,10 @@ type PatientRepository interface { 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 { GetLabResult(ctx context.Context, labNumber string) (repository.LabResultOutcome, error) } @@ -63,6 +67,7 @@ type Handler struct { layananRepo LayananRepository labRepo LabRegistrationRepository patientRepo PatientRepository + registrationRepo RegistrationRepository labResultRepo LabResultRepository paketRepo PaketRepository tarifRepo TarifKerjasamaRepository @@ -71,12 +76,14 @@ type Handler struct { perusahaanRepo PerusahaanRepository dokterRepo DokterRepository hospitalCode string + departmentID string } type CPONERepository interface { LayananRepository LabRegistrationRepository PatientRepository + RegistrationRepository LabResultRepository PaketRepository TarifKerjasamaRepository @@ -88,15 +95,18 @@ type CPONERepository interface { func NewHandler(repo CPONERepository) Handler { return Handler{ - layananRepo: repo, labRepo: repo, patientRepo: repo, + layananRepo: repo, labRepo: repo, patientRepo: repo, registrationRepo: repo, labResultRepo: repo, paketRepo: repo, tarifRepo: repo, periodeTarifRepo: repo, periodeTarifDetailRepo: repo, perusahaanRepo: repo, dokterRepo: repo, } } -func NewDynamicHandler(hospitalCode string) Handler { - return Handler{hospitalCode: strings.ToUpper(strings.TrimSpace(hospitalCode))} +func NewDynamicHandler(hospitalCode string, departmentID string) Handler { + return Handler{ + hospitalCode: strings.ToUpper(strings.TrimSpace(hospitalCode)), + departmentID: strings.TrimSpace(departmentID), + } } func (h Handler) layananRepository(ctx context.Context) LayananRepository { @@ -120,6 +130,13 @@ func (h Handler) patientRepository(ctx context.Context) PatientRepository { 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 { if repo, ok := selectedRepository(ctx); ok { return repo diff --git a/internal/http/lab_registration.go b/internal/http/lab_registration.go index 220c47b..360710f 100644 --- a/internal/http/lab_registration.go +++ b/internal/http/lab_registration.go @@ -35,6 +35,11 @@ func (h Handler) RegisterLab(w http.ResponseWriter, r *http.Request) { writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", err.Error(), nil) 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) if validationMessage != "" { diff --git a/internal/http/lab_registration_test.go b/internal/http/lab_registration_test.go index 7ccfa32..b5cd55b 100644 --- a/internal/http/lab_registration_test.go +++ b/internal/http/lab_registration_test.go @@ -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) { repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{ HTTPStatus: http.StatusCreated, diff --git a/internal/http/registration.go b/internal/http/registration.go new file mode 100644 index 0000000..1da4bc6 --- /dev/null +++ b/internal/http/registration.go @@ -0,0 +1,178 @@ +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" + } + + 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), Channel: normalizeText(channel), + }, "" +} + +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}) +} diff --git a/internal/http/registration_test.go b/internal/http/registration_test.go new file mode 100644 index 0000000..e71b82a --- /dev/null +++ b/internal/http/registration_test.go @@ -0,0 +1,80 @@ +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, + "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) + } +} + +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()) + } +} diff --git a/internal/repository/medrec.go b/internal/repository/medrec.go index 8114101..681c1b1 100644 --- a/internal/repository/medrec.go +++ b/internal/repository/medrec.go @@ -52,6 +52,7 @@ type PossibleDuplicateData struct { type MedrecPatient struct { MEDRECID string `json:"MEDRECID"` Nama *string `json:"Nama"` + Kelamin *string `json:"-"` TanggalLahir *string `json:"TanggalLahir"` JenisNomerSosial *string `json:"JenisNomerSosial"` NomerSosial *string `json:"NomerSosial"` @@ -189,7 +190,7 @@ func findPossibleMedrecDuplicates(ctx context.Context, tx *sql.Tx, name string, } 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 FROM medrec` @@ -199,13 +200,14 @@ type rowScanner interface { func scanMedrecPatient(row rowScanner) (MedrecPatient, error) { var patient MedrecPatient - var name, birthDate, identityType, identityNumber, phone, mobile sql.NullString - err := row.Scan(&patient.MEDRECID, &name, &birthDate, &identityType, &identityNumber, &phone, &mobile) + var name, birthDate, gender, identityType, identityNumber, phone, mobile sql.NullString + err := row.Scan(&patient.MEDRECID, &name, &birthDate, &gender, &identityType, &identityNumber, &phone, &mobile) if err != nil { return MedrecPatient{}, err } patient.Nama = nullStringPtr(name) patient.TanggalLahir = nullStringPtr(birthDate) + patient.Kelamin = nullStringPtr(gender) patient.JenisNomerSosial = nullStringPtr(identityType) patient.NomerSosial = nullStringPtr(identityNumber) patient.Telepon = nullStringPtr(phone) diff --git a/internal/repository/registration.go b/internal/repository/registration.go new file mode 100644 index 0000000..a88d373 --- /dev/null +++ b/internal/repository/registration.go @@ -0,0 +1,267 @@ +package repository + +import ( + "context" + "database/sql" + "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 + Channel 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"` +} + +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} + 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 := "P" + params.RegistrationDate.Format("0601") + 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 ( + RegID, MEDRECID, Nama, ALAMATEXTPAS, TELPEXTPAS, TglLahir, JnsKelamin, + KunjunganKe, Tanggal, JenisPasienID, PeriodeTarifID, PerusahaanID, + GroupTarifID, AsuransiID, ProviderID, NomerPolis, NIK, NamaKaryawan, + NomerJaminan, KodeMasukID, DepartemenID, DepartemenID2, JenisRegID, + DokterID, DokterID2, KelasID, MataUang, Rate, Pulang, BolehPulang, Lunas, + BatalDaftar, Catatan, CatatanMasuk, ShiftHarianIDDaftar, LoginBuat, TanggalBuat, NA + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, '', ?, 'IDR', 1, 'N', 'N', 'N', 'N', ?, '', ?, ?, ?, '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(pointerValue(patient.NomerSosial), params.NIK), + params.GuaranteeNumber, + params.EntryCode, + params.DepartmentID, + params.DepartmentID, + params.RegistrationType, + 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", + } + return registrationOutcome(201, true, "registered", "Registrasi berhasil disimpan", RegistrationData{Patient: patient, Registration: registration}), nil +} + +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( + ®istration.RegID, ®istration.MEDRECID, ®istration.Tanggal, ®istration.DepartmentID, + ®istration.DoctorID, ®istration.PatientTypeID, ®istration.RegistrationID, + ®istration.TariffPeriodID, ®istration.CompanyID, ®istration.InsuranceID, + ®istration.ProviderID, ®istration.GroupTariffID, ®istration.PolicyNumber, + ®istration.Pulang, ®istration.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 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 "" +}