Add optimized doctor search endpoint
This commit is contained in:
@@ -47,6 +47,9 @@ 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)))
|
||||||
|
|||||||
103
internal/http/dokter.go
Normal file
103
internal/http/dokter.go
Normal 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
|
||||||
|
}
|
||||||
72
internal/http/dokter_test.go
Normal file
72
internal/http/dokter_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ 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
|
||||||
@@ -65,6 +69,7 @@ type Handler struct {
|
|||||||
periodeTarifRepo PeriodeTarifRepository
|
periodeTarifRepo PeriodeTarifRepository
|
||||||
periodeTarifDetailRepo PeriodeTarifDetailRepository
|
periodeTarifDetailRepo PeriodeTarifDetailRepository
|
||||||
perusahaanRepo PerusahaanRepository
|
perusahaanRepo PerusahaanRepository
|
||||||
|
dokterRepo DokterRepository
|
||||||
hospitalCode string
|
hospitalCode string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +83,7 @@ type CPONERepository interface {
|
|||||||
PeriodeTarifRepository
|
PeriodeTarifRepository
|
||||||
PeriodeTarifDetailRepository
|
PeriodeTarifDetailRepository
|
||||||
PerusahaanRepository
|
PerusahaanRepository
|
||||||
|
DokterRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(repo CPONERepository) Handler {
|
func NewHandler(repo CPONERepository) Handler {
|
||||||
@@ -85,6 +91,7 @@ func NewHandler(repo CPONERepository) Handler {
|
|||||||
layananRepo: repo, labRepo: repo, patientRepo: repo,
|
layananRepo: repo, labRepo: repo, patientRepo: 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +162,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 {
|
||||||
|
|||||||
137
internal/repository/dokter.go
Normal file
137
internal/repository/dokter.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user