diff --git a/cmd/server/main.go b/cmd/server/main.go index cfa1d95..6896a5d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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))) diff --git a/internal/http/dokter.go b/internal/http/dokter.go new file mode 100644 index 0000000..edcb8ad --- /dev/null +++ b/internal/http/dokter.go @@ -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 +} diff --git a/internal/http/dokter_test.go b/internal/http/dokter_test.go new file mode 100644 index 0000000..6b059e6 --- /dev/null +++ b/internal/http/dokter_test.go @@ -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) + } + } +} diff --git a/internal/http/handler.go b/internal/http/handler.go index 792f067..c7c763e 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -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 { diff --git a/internal/repository/dokter.go b/internal/repository/dokter.go new file mode 100644 index 0000000..17d1da8 --- /dev/null +++ b/internal/repository/dokter.go @@ -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 +}