feat: require hospital code and add company APIs

This commit is contained in:
2026-08-28 10:30:08 +07:00
parent a59b2c6443
commit 8dd880003d
17 changed files with 867 additions and 142 deletions

View File

@@ -13,6 +13,7 @@ import (
var digitsOnly = regexp.MustCompile(`^\d+$`)
type LayananRepository interface {
ListLayanan(ctx context.Context, params repository.LayananListParams) (repository.LayananPage, error)
MappingHarga(ctx context.Context, params repository.MappingHargaParams) ([]repository.TarifLayanan, error)
}
@@ -40,13 +41,20 @@ type TarifKerjasamaRepository interface {
GetTarifKerjasama(ctx context.Context, id int64, params repository.TarifKerjasamaDetailParams) (*repository.TarifKerjasamaDetail, error)
}
type PerusahaanRepository interface {
ListPerusahaan(ctx context.Context, params repository.PerusahaanListParams) (repository.PerusahaanPage, error)
GetPerusahaan(ctx context.Context, id string) (*repository.PerusahaanDetail, error)
GetPerusahaanGroupTarif(ctx context.Context, id, groupTarifID string) (*repository.PerusahaanGroupTarifDetail, error)
}
type Handler struct {
layananRepo LayananRepository
labRepo LabRegistrationRepository
patientRepo PatientRepository
labResultRepo LabResultRepository
paketRepo PaketRepository
tarifRepo TarifKerjasamaRepository
layananRepo LayananRepository
labRepo LabRegistrationRepository
patientRepo PatientRepository
labResultRepo LabResultRepository
paketRepo PaketRepository
tarifRepo TarifKerjasamaRepository
perusahaanRepo PerusahaanRepository
}
type CPONERepository interface {
@@ -56,15 +64,18 @@ type CPONERepository interface {
LabResultRepository
PaketRepository
TarifKerjasamaRepository
PerusahaanRepository
}
func NewHandler(repo CPONERepository) Handler {
return Handler{
layananRepo: repo, labRepo: repo, patientRepo: repo,
labResultRepo: repo, paketRepo: repo, tarifRepo: repo,
labResultRepo: repo, paketRepo: repo, tarifRepo: repo, perusahaanRepo: repo,
}
}
func NewDynamicHandler() Handler { return Handler{} }
func (h Handler) layananRepository(ctx context.Context) LayananRepository {
if repo, ok := selectedRepository(ctx); ok {
return repo
@@ -107,6 +118,13 @@ func (h Handler) tarifRepository(ctx context.Context) TarifKerjasamaRepository {
return h.tarifRepo
}
func (h Handler) perusahaanRepository(ctx context.Context) PerusahaanRepository {
if repo, ok := selectedRepository(ctx); ok {
return repo
}
return h.perusahaanRepo
}
func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {
layananIDs := parseLayananIDs(r)
if len(layananIDs) == 0 {

65
internal/http/layanan.go Normal file
View File

@@ -0,0 +1,65 @@
package http
import (
"net/http"
"strconv"
"primaya-api/cpone-middleware/internal/repository"
)
const layananPerPage = 15
type LayananPaginationResponse struct {
CurrentPage int `json:"current_page"`
Data []repository.LayananDepartemen `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) ListLayanan(w http.ResponseWriter, r *http.Request) {
pageNumber := layananPageNumber(r)
page, err := h.layananRepository(r.Context()).ListLayanan(r.Context(), repository.LayananListParams{
Page: pageNumber, PerPage: layananPerPage,
})
if err != nil {
WriteJSON(w, http.StatusInternalServerError, Response{
Success: false, Message: "internal server error", Data: nil,
})
return
}
lastPage := (page.Total + layananPerPage - 1) / layananPerPage
if lastPage < 1 {
lastPage = 1
}
var from *int
if len(page.Items) > 0 {
value := (pageNumber-1)*layananPerPage + 1
from = &value
}
var nextPageURL *string
if pageNumber < lastPage {
value := currentRequestURL(r, pageNumber+1)
nextPageURL = &value
}
WriteJSON(w, http.StatusOK, LayananPaginationResponse{
CurrentPage: pageNumber, Data: page.Items, FirstPageURL: "", From: from,
LastPage: lastPage, LastPageURL: "", Links: map[string]any{},
NextPageURL: nextPageURL, Total: page.Total, PerPage: layananPerPage,
})
}
func layananPageNumber(r *http.Request) int {
page, err := strconv.Atoi(r.URL.Query().Get("page"))
if err != nil || page < 1 {
return 1
}
return page
}

View File

@@ -0,0 +1,82 @@
package http
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"primaya-api/cpone-middleware/internal/repository"
)
type fakeLayananRepository struct {
page repository.LayananPage
err error
params repository.LayananListParams
}
func (f *fakeLayananRepository) ListLayanan(_ context.Context, params repository.LayananListParams) (repository.LayananPage, error) {
f.params = params
return f.page, f.err
}
func (f *fakeLayananRepository) MappingHarga(context.Context, repository.MappingHargaParams) ([]repository.TarifLayanan, error) {
return nil, nil
}
func TestListLayananResponseParity(t *testing.T) {
group, department := "MCU", "MEDICAL CHECK UP"
layananID, name, price := "6413", "DARAH LENGKAP", "125000.00"
repo := &fakeLayananRepository{page: repository.LayananPage{
Total: 16,
Items: []repository.LayananDepartemen{{
GroupLayananID: &group, Departemen: &department, LayananDepartemenID: 987,
LayananID: &layananID, NamaLayanan: &name, Harga: &price,
}},
}}
handler := Handler{layananRepo: repo}
request := httptest.NewRequest(http.MethodGet, "http://localhost/api/cpone/layanan?page=1&kode_rs=AWALBROS_DEV", nil)
recorder := httptest.NewRecorder()
handler.ListLayanan(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if repo.params.Page != 1 || repo.params.PerPage != 15 {
t.Fatalf("params = %+v", repo.params)
}
for _, expected := range []string{
`"GroupLayananID":"MCU"`, `"Departemen":"MEDICAL CHECK UP"`,
`"LayananDepartemenID":987`, `"LayananID":"6413"`,
`"NamaLayanan":"DARAH LENGKAP"`, `"Harga":"125000.00"`,
`"last_page":2`, `"per_page":15`,
`"next_page_url":"http://localhost/api/cpone/layanan?kode_rs=AWALBROS_DEV\u0026page=2"`,
} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
}
}
}
func TestListLayananUsesLaravelPageFallback(t *testing.T) {
repo := &fakeLayananRepository{page: repository.LayananPage{Items: []repository.LayananDepartemen{}}}
handler := Handler{layananRepo: repo}
for _, target := range []string{"/api/cpone/layanan", "/api/cpone/layanan?page=0", "/api/cpone/layanan?page=invalid"} {
recorder := httptest.NewRecorder()
handler.ListLayanan(recorder, httptest.NewRequest(http.MethodGet, target, nil))
if recorder.Code != http.StatusOK || repo.params.Page != 1 || repo.params.PerPage != layananPerPage {
t.Fatalf("target=%s status=%d params=%+v", target, recorder.Code, repo.params)
}
}
}
func TestListLayananInternalError(t *testing.T) {
handler := Handler{layananRepo: &fakeLayananRepository{err: errors.New("query failed")}}
recorder := httptest.NewRecorder()
handler.ListLayanan(recorder, httptest.NewRequest(http.MethodGet, "/api/cpone/layanan", nil))
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}

View File

@@ -13,7 +13,6 @@ import (
type selectedRepositoryKey struct{}
type DatabaseResolver interface {
DefaultCode() string
Resolve(ctx context.Context, code string) (repository.MySQLLayananRepository, error)
}
@@ -39,7 +38,9 @@ func SelectDatabase(resolver DatabaseResolver, next http.Handler) http.Handler {
code = strings.TrimSpace(r.URL.Query().Get("kode_rs"))
}
if code == "" {
code = resolver.DefaultCode()
WriteJSON(w, http.StatusUnprocessableEntity, Response{Success: false,
Message: "Kode RS wajib diisi melalui header X-RS-Code atau parameter kode_rs", Data: nil})
return
}
code = databaseconfig.NormalizeRSCode(code)

View File

@@ -12,28 +12,25 @@ import (
)
type fakeDatabaseResolver struct {
defaultCode string
resolvedCode string
err error
}
func (f *fakeDatabaseResolver) DefaultCode() string { return f.defaultCode }
func (f *fakeDatabaseResolver) Resolve(_ context.Context, code string) (repository.MySQLLayananRepository, error) {
f.resolvedCode = code
return repository.MySQLLayananRepository{}, f.err
}
func TestSelectDatabasePriorityAndDefault(t *testing.T) {
func TestSelectDatabasePriority(t *testing.T) {
tests := []struct {
name, target, header, want string
}{
{name: "header", target: "/resource?kode_rs=QUERY", header: " rs_header ", want: "RS_HEADER"},
{name: "query", target: "/resource?kode_rs=rs_query", want: "RS_QUERY"},
{name: "default", target: "/resource", want: "AWALBROS_DEV"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
resolver := &fakeDatabaseResolver{defaultCode: "AWALBROS_DEV"}
resolver := &fakeDatabaseResolver{}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := selectedRepository(r.Context()); !ok {
t.Fatal("selected repository missing from context")
@@ -54,8 +51,23 @@ func TestSelectDatabasePriorityAndDefault(t *testing.T) {
}
}
func TestSelectDatabaseRequiresRSCode(t *testing.T) {
resolver := &fakeDatabaseResolver{}
request := httptest.NewRequest(http.MethodGet, "/resource", nil)
recorder := httptest.NewRecorder()
SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("next handler should not be called")
})).ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
}
if resolver.resolvedCode != "" {
t.Fatalf("resolver called with %q", resolver.resolvedCode)
}
}
func TestSelectDatabaseUnknownCode(t *testing.T) {
resolver := &fakeDatabaseResolver{defaultCode: "DEFAULT", err: databaseconfig.ErrSettingNotFound}
resolver := &fakeDatabaseResolver{err: databaseconfig.ErrSettingNotFound}
request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", nil)
recorder := httptest.NewRecorder()
SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {

View File

@@ -171,7 +171,12 @@ func currentRequestURL(r *http.Request, page int) string {
if host == "" {
host = "localhost"
}
return (&url.URL{Scheme: scheme, Host: host, Path: r.URL.Path, RawQuery: "page=" + strconv.Itoa(page)}).String()
query := url.Values{}
if code := r.URL.Query().Get("kode_rs"); code != "" {
query.Set("kode_rs", code)
}
query.Set("page", strconv.Itoa(page))
return (&url.URL{Scheme: scheme, Host: host, Path: r.URL.Path, RawQuery: query.Encode()}).String()
}
func writePaketValidationError(w http.ResponseWriter, message string) {

View File

@@ -0,0 +1,98 @@
package http
import (
"net/http"
"strconv"
"strings"
"primaya-api/cpone-middleware/internal/repository"
)
const perusahaanPerPage = 15
type PerusahaanPaginationResponse struct {
CurrentPage int `json:"current_page"`
Data []repository.PerusahaanSummary `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) ListPerusahaan(w http.ResponseWriter, r *http.Request) {
pageNumber := perusahaanPageNumber(r)
page, err := h.perusahaanRepository(r.Context()).ListPerusahaan(r.Context(), repository.PerusahaanListParams{
Search: strings.TrimSpace(r.URL.Query().Get("search")), Page: pageNumber, PerPage: perusahaanPerPage,
})
if err != nil {
writePerusahaanInternalError(w)
return
}
lastPage := (page.Total + perusahaanPerPage - 1) / perusahaanPerPage
if lastPage < 1 {
lastPage = 1
}
var from *int
if len(page.Items) > 0 {
value := (pageNumber-1)*perusahaanPerPage + 1
from = &value
}
var nextPageURL *string
if pageNumber < lastPage {
value := currentRequestURL(r, pageNumber+1)
nextPageURL = &value
}
WriteJSON(w, http.StatusOK, PerusahaanPaginationResponse{
CurrentPage: pageNumber, Data: page.Items, FirstPageURL: "", From: from,
LastPage: lastPage, LastPageURL: "", Links: map[string]any{}, NextPageURL: nextPageURL,
Total: page.Total, PerPage: perusahaanPerPage,
})
}
func (h Handler) GetPerusahaan(w http.ResponseWriter, r *http.Request) {
detail, err := h.perusahaanRepository(r.Context()).GetPerusahaan(r.Context(), strings.TrimSpace(r.PathValue("id")))
if err != nil {
writePerusahaanInternalError(w)
return
}
if detail == nil {
writePerusahaanNotFound(w)
return
}
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: detail})
}
func (h Handler) GetPerusahaanGroupTarif(w http.ResponseWriter, r *http.Request) {
detail, err := h.perusahaanRepository(r.Context()).GetPerusahaanGroupTarif(
r.Context(), strings.TrimSpace(r.PathValue("id")), strings.TrimSpace(r.URL.Query().Get("group_tarif_id")),
)
if err != nil {
writePerusahaanInternalError(w)
return
}
if detail == nil {
writePerusahaanNotFound(w)
return
}
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: detail})
}
func perusahaanPageNumber(r *http.Request) int {
page, err := strconv.Atoi(r.URL.Query().Get("page"))
if err != nil || page < 1 {
return 1
}
return page
}
func writePerusahaanNotFound(w http.ResponseWriter) {
WriteJSON(w, http.StatusNotFound, Response{Success: false, Message: "Data tidak ditemukan", Data: nil})
}
func writePerusahaanInternalError(w http.ResponseWriter) {
WriteJSON(w, http.StatusInternalServerError, Response{Success: false, Message: "internal server error", Data: nil})
}

View File

@@ -0,0 +1,142 @@
package http
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"primaya-api/cpone-middleware/internal/repository"
)
type fakePerusahaanRepository struct {
page repository.PerusahaanPage
detail *repository.PerusahaanDetail
groupDetail *repository.PerusahaanGroupTarifDetail
err error
listParams repository.PerusahaanListParams
detailID string
groupID string
groupTarifID string
}
func (f *fakePerusahaanRepository) ListPerusahaan(_ context.Context, params repository.PerusahaanListParams) (repository.PerusahaanPage, error) {
f.listParams = params
return f.page, f.err
}
func (f *fakePerusahaanRepository) GetPerusahaan(_ context.Context, id string) (*repository.PerusahaanDetail, error) {
f.detailID = id
return f.detail, f.err
}
func (f *fakePerusahaanRepository) GetPerusahaanGroupTarif(_ context.Context, id, groupTarifID string) (*repository.PerusahaanGroupTarifDetail, error) {
f.groupID = id
f.groupTarifID = groupTarifID
return f.groupDetail, f.err
}
func TestListPerusahaanResponseParity(t *testing.T) {
repo := &fakePerusahaanRepository{page: repository.PerusahaanPage{
Total: 16,
Items: []repository.PerusahaanSummary{{
PerusahaanID: "AAL", KodePerusahaan: "INS-114", Nama: "ASURANSI ALLIANZ LIFE INDONESIA, PT",
}},
}}
handler := Handler{perusahaanRepo: repo}
request := httptest.NewRequest(http.MethodGet, "http://localhost/api/cpone/perusahaan?search=allianz&page=1&kode_rs=AWALBROS_DEV", nil)
recorder := httptest.NewRecorder()
handler.ListPerusahaan(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if repo.listParams.Search != "allianz" || repo.listParams.Page != 1 || repo.listParams.PerPage != 15 {
t.Fatalf("params = %+v", repo.listParams)
}
for _, expected := range []string{
`"PerusahaanID":"AAL"`, `"KodePerusahaan":"INS-114"`,
`"Nama":"ASURANSI ALLIANZ LIFE INDONESIA, PT"`, `"last_page":2`,
`"next_page_url":"http://localhost/api/cpone/perusahaan?kode_rs=AWALBROS_DEV\u0026page=2"`,
} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
}
}
}
func TestGetPerusahaanByCode(t *testing.T) {
detail := &repository.PerusahaanDetail{
PerusahaanID: "AAL", KodePerusahaan: "INS-114", Nama: "ALLIANZ",
JenisPerusahaanID: 1, Penjamin: "Y", Asuransi: "Y", Provider: "N",
COB: "N", MataUangID: "IDR", DefaultDiskon: "0.00", PPN: "0.00",
FlagDiskon: "N", VerifikasiBilling: "N", NA: "N",
}
repo := &fakePerusahaanRepository{detail: detail}
handler := Handler{perusahaanRepo: repo}
request := httptest.NewRequest(http.MethodGet, "/api/cpone/perusahaan/INS-114", nil)
request.SetPathValue("id", "INS-114")
recorder := httptest.NewRecorder()
handler.GetPerusahaan(recorder, request)
if recorder.Code != http.StatusOK || repo.detailID != "INS-114" {
t.Fatalf("status=%d id=%q body=%s", recorder.Code, repo.detailID, recorder.Body.String())
}
for _, expected := range []string{`"success":true`, `"PerusahaanID":"AAL"`, `"ppn":"0.00"`, `"nomerRekening":null`} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
}
}
}
func TestGetPerusahaanGroupTarif(t *testing.T) {
name := "DARAH LENGKAP"
detail := &repository.PerusahaanGroupTarifDetail{
PerusahaanID: "AAL", KodePerusahaan: "INS-114", NamaPerusahaan: "ALLIANZ",
GroupTarif: []repository.PerusahaanGroupTarif{{
GroupTarifID: 27, NamaGroupTarif: "Tarif 2026", TanggalBerlaku: "2026-01-01",
TanggalBerakhir: "2026-12-31", PeriodeTarifID: 10, Keterangan: "Tarif 2026",
DetailLayanan: []repository.PerusahaanGroupTarifLayanan{{
TarifLayananID: 1, LayananID: "6413", NamaLayanan: &name,
JenisLayananID: 1, Kelas: "Default", NamaKelas: "Default", Harga: "100000.00",
}},
}},
}
repo := &fakePerusahaanRepository{groupDetail: detail}
handler := Handler{perusahaanRepo: repo}
request := httptest.NewRequest(http.MethodGet, "/api/cpone/perusahaan/AAL/group-tarif?group_tarif_id=27", nil)
request.SetPathValue("id", "AAL")
recorder := httptest.NewRecorder()
handler.GetPerusahaanGroupTarif(recorder, request)
if recorder.Code != http.StatusOK || repo.groupID != "AAL" || repo.groupTarifID != "27" {
t.Fatalf("status=%d company=%q group=%q body=%s", recorder.Code, repo.groupID, repo.groupTarifID, recorder.Body.String())
}
for _, expected := range []string{`"GroupTarifID":27`, `"NamaGroupTarif":"Tarif 2026"`, `"DetailLayanan":[`, `"NamaKelas":"Default"`} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
}
}
}
func TestPerusahaanNotFoundAndInternalError(t *testing.T) {
tests := []struct {
name string
repo *fakePerusahaanRepository
want int
}{
{name: "not found", repo: &fakePerusahaanRepository{}, want: http.StatusNotFound},
{name: "error", repo: &fakePerusahaanRepository{err: errors.New("query failed")}, want: http.StatusInternalServerError},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := Handler{perusahaanRepo: test.repo}
request := httptest.NewRequest(http.MethodGet, "/api/cpone/perusahaan/UNKNOWN", nil)
request.SetPathValue("id", "UNKNOWN")
recorder := httptest.NewRecorder()
handler.GetPerusahaan(recorder, request)
if recorder.Code != test.want {
t.Fatalf("status=%d want=%d body=%s", recorder.Code, test.want, recorder.Body.String())
}
})
}
}