Add optimized doctor search endpoint

This commit is contained in:
2026-09-07 10:50:19 +07:00
parent 9afd5b792c
commit aae3c733ae
5 changed files with 329 additions and 0 deletions

View File

@@ -47,6 +47,9 @@ func main() {
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}/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/{id}", protected(http.HandlerFunc(handler.GetTarifKerjasama)))
mux.Handle("GET /api/cpone/periode-tarif", protected(http.HandlerFunc(handler.ListPeriodeTarif)))

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

@@ -55,6 +55,10 @@ type PerusahaanRepository interface {
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 {
layananRepo LayananRepository
labRepo LabRegistrationRepository
@@ -65,6 +69,7 @@ type Handler struct {
periodeTarifRepo PeriodeTarifRepository
periodeTarifDetailRepo PeriodeTarifDetailRepository
perusahaanRepo PerusahaanRepository
dokterRepo DokterRepository
hospitalCode string
}
@@ -78,6 +83,7 @@ type CPONERepository interface {
PeriodeTarifRepository
PeriodeTarifDetailRepository
PerusahaanRepository
DokterRepository
}
func NewHandler(repo CPONERepository) Handler {
@@ -85,6 +91,7 @@ func NewHandler(repo CPONERepository) Handler {
layananRepo: repo, labRepo: repo, patientRepo: repo,
labResultRepo: repo, paketRepo: repo, tarifRepo: repo,
periodeTarifRepo: repo, periodeTarifDetailRepo: repo, perusahaanRepo: repo,
dokterRepo: repo,
}
}
@@ -155,6 +162,13 @@ func (h Handler) perusahaanRepository(ctx context.Context) PerusahaanRepository
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) {
layananIDs := parseLayananIDs(r)
if len(layananIDs) == 0 {

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
}